diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java index a3577b6d8..c25e99477 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandFind.java @@ -28,6 +28,9 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.StructureReachability; +import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisRegion; @@ -37,7 +40,9 @@ import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.iris.util.common.director.specialhandlers.HydrologyTypeHandler; import art.arcane.iris.util.common.director.specialhandlers.ObjectHandler; +import art.arcane.iris.util.common.director.specialhandlers.ReachableBiomeHandler; import art.arcane.iris.util.common.director.specialhandlers.StructureHandler; import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.plugin.VolmitSender; @@ -68,7 +73,7 @@ import java.util.Set; public class CommandFind implements DirectorExecutor { @Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome") public void biome( - @Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look") + @Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class) IrisBiome biome, @Param(description = "Should you be teleported", descriptionKey = "iris.director.commandfind.param.should_you_be_teleported", defaultValue = "true") boolean teleport @@ -116,6 +121,73 @@ public class CommandFind implements DirectorExecutor { EngineBukkitOps.gotoPOI(e, type, player(), teleport); } + @Director(description = "Find an accepted hydrology feature") + public void river( + @Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class) + String type, + @Param(description = "Should you be teleported", defaultValue = "true") + boolean teleport + ) { + Engine activeEngine = engine(); + VolmitSender commandSender = sender(); + Player target = player(); + if (activeEngine == null || commandSender == null || target == null) { + if (commandSender != null) { + commandSender.sendMessage(C.RED + "Run this command from an Iris world."); + } + return; + } + IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime(); + if (runtime == null) { + commandSender.sendMessage(C.YELLOW + "Hydrology is not active in this world."); + return; + } + HydrologyFeatureQuery query; + try { + query = HydrologyFeatureQuery.parse(type); + } catch (IllegalArgumentException error) { + commandSender.sendMessage(C.RED + error.getMessage()); + return; + } + Location origin = target.getLocation(); + int requestedDistance = Math.min(8192, runtime.settings().routing().tileSize() * 15); + int maximumDistance = runtime.maximumFeatureSearchDistance( + origin.getBlockX(), origin.getBlockZ(), requestedDistance); + commandSender.sendMessage(C.GRAY + "Searching accepted hydrology plans for " + type + "..."); + J.a(() -> { + try { + HydrologyFeatureRef feature = runtime.nearestFeature( + query.types(), + query.profileKey(), + origin.getBlockX(), + origin.getBlockZ(), + maximumDistance + ).orElse(null); + if (feature == null) { + sendStructureMessage(target, commandSender, + C.YELLOW + "No accepted " + type + " hydrology feature found within " + + maximumDistance + " blocks."); + return; + } + int worldY = feature.y() + activeEngine.getDimension().getMinHeight(); + String label = type + " hydrology feature"; + if (!teleport) { + sendStructureMessage(target, commandSender, + C.GREEN + "Found " + label + " at " + feature.x() + ", " + worldY + ", " + feature.z() + "."); + return; + } + Location destination = new Location( + target.getWorld(), feature.x(), worldY, feature.z()); + prepareStructureTeleport( + target, target.getWorld(), commandSender, label, destination, true); + } catch (Throwable error) { + sendStructureMessage(target, commandSender, + C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName()); + Iris.reportError("Could not locate accepted hydrology feature '" + type + "'.", error); + } + }); + } + @Director(description = "Find a structure (a vanilla key like minecraft:village_plains or minecraft:stronghold, or an imported iris structure key)", descriptionKey = "iris.director.commandfind.director.find_structure_vanilla_key_like_minecraft_village_plains_minecraft_stronghold_imported_iris", sync = true) public void structure( @Param(description = "The structure to look for (e.g. minecraft:village_plains, minecraft:stronghold, minecraft_ancient_city)", descriptionKey = "iris.director.commandfind.param.structure_look_e_g_minecraft_village_plains_minecraft_stronghold_minecraft_ancient_city", customHandler = StructureHandler.class) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java index 8f8c32b00..d1ba14c18 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandPack.java @@ -25,7 +25,10 @@ import art.arcane.iris.core.pack.PackValidationCache; import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidator; +import art.arcane.iris.core.service.StudioSVC; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.util.common.director.DirectorExecutor; +import art.arcane.iris.util.common.director.handlers.DimensionHandler; import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Param; @@ -90,6 +93,18 @@ public class CommandPack implements DirectorExecutor { persistValidationCache(packsRoot); } + @Director(description = "Package a validated dimension into a compressed format", descriptionKey = "iris.director.commandstudio.director.package_dimension_into_compressed_format", aliases = "package") + public void pkg( + @Param(name = "dimension", description = "The dimension pack to compress", descriptionKey = "iris.director.commandstudio.param.dimension_pack_compress", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class) + IrisDimension dimension, + @Param(name = "obfuscate", description = "Whether or not to obfuscate the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_obfuscate_pack", defaultValue = "false") + boolean obfuscate, + @Param(name = "minify", description = "Whether or not to minify the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_minify_pack", defaultValue = "true") + boolean minify + ) { + Iris.service(StudioSVC.class).compilePackage(sender(), dimension.getLoadKey(), obfuscate, minify); + } + @Director(description = "Preview or apply unused-resource cleanup", descriptionKey = "iris.director.commandpack.director.preview_apply_unused_resource_cleanup", aliases = {"c"}) public void cleanup( @Param(description = "The pack folder name to clean", descriptionKey = "iris.director.commandpack.param.pack_folder_name_clean") diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java index 61245290d..5d51e271e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/commands/CommandStudio.java @@ -420,18 +420,6 @@ public class CommandStudio implements DirectorExecutor { sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP)); } - @Director(description = "Package a dimension into a compressed format", descriptionKey = "iris.director.commandstudio.director.package_dimension_into_compressed_format", aliases = "package") - public void pkg( - @Param(name = "dimension", description = "The dimension pack to compress", descriptionKey = "iris.director.commandstudio.param.dimension_pack_compress", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class) - IrisDimension dimension, - @Param(name = "obfuscate", description = "Whether or not to obfuscate the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_obfuscate_pack", defaultValue = "false") - boolean obfuscate, - @Param(name = "minify", description = "Whether or not to minify the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_minify_pack", defaultValue = "true") - boolean minify - ) { - Iris.service(StudioSVC.class).compilePackage(sender(), dimension.getLoadKey(), obfuscate, minify); - } - @Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER) public void profile( @Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class) diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java index 5c8d57713..53e31ae33 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/IrisTerrainSVC.java @@ -15,12 +15,12 @@ import art.arcane.iris.core.service.terrain.IrisSampleLimits; import art.arcane.iris.core.service.terrain.IrisSurfaceClassifier; import art.arcane.iris.core.service.terrain.IrisWorldInfoFactory; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.platform.PlatformChunkGenerator; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisServices; @@ -105,14 +105,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { try { int surface = engine.getHeight(blockX, blockZ); - IrisRiverSurfaceSample riverSurface = engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ); - int fluid = (int) Math.round(riverSurface.waterSurfaceY()); + HydrologyColumnSample hydrology = hydrologySample(engine, blockX, blockZ); + int fluid = (int) Math.round( + engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ)); InferredType inferredType = null; if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) { IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ); inferredType = biome == null ? null : biome.getInferredType(); } - return IrisSurfaceClassifier.classify(surface, fluid, inferredType, riverSurface); + return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology); } catch (Throwable error) { reportQueryFault("surfaceKind", world, error); return IrisSurfaceKind.UNKNOWN; @@ -246,13 +247,13 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { return false; } - IrisRiverSurfaceSample riverSurface = wantRiver - ? engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ) + HydrologyColumnSample hydrology = wantRiver + ? hydrologySample(engine, blockX, blockZ) : null; int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0; - int fluid = riverSurface == null - ? engine.getDimension().getFluidHeight() - : (int) Math.round(riverSurface.waterSurfaceY()); + int fluid = wantRiver + ? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ)) + : engine.getDimension().getFluidHeight(); boolean needsBiome = wantBiome || (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)); IrisBiome biome = needsBiome ? engine.getSurfaceBiome(blockX, blockZ) : null; @@ -261,26 +262,29 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { surface, fluid, biome == null ? null : biome.getInferredType(), - riverSurface + hydrology ) : IrisSurfaceKind.UNKNOWN; String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null; int natural = wantNaturalHeight ? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight : IrisColumnSample.UNAVAILABLE_HEIGHT; - boolean riverPresent = riverSurface != null && riverSurface.river().present(); - IrisRiverState riverState = wantRiverState && riverSurface != null - ? riverState(riverSurface) + HydrologyColumnLayer surfaceLayer = hydrology == null + ? null + : hydrology.primarySurfaceLayer().orElse(null); + boolean riverPresent = surfaceLayer != null; + IrisRiverState riverState = wantRiverState + ? riverState(hydrology) : IrisRiverState.NONE; double riverDistance = wantRiverDistance && riverPresent - ? riverSurface.river().distance() + ? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ) : IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE; int riverFlow = wantRiverFlow && riverPresent - ? riverSurface.river().flow() + ? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ)) : IrisColumnSample.UNAVAILABLE_RIVER_FLOW; int riverWaterSurfaceY = wantRiverWaterSurface - && riverPresent - && riverSurface.river().state() == RiverRouteState.WET + && surfaceLayer != null + && surfaceLayer.connectedFluid() ? fluid + minHeight : IrisColumnSample.UNAVAILABLE_HEIGHT; sink.accept(new IrisColumnSample( @@ -304,15 +308,21 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { } } - static IrisRiverState riverState(IrisRiverSurfaceSample surface) { - if (!surface.river().present()) { + static IrisRiverState riverState(HydrologyColumnSample hydrology) { + HydrologyColumnLayer layer = hydrology == null + ? null + : hydrology.primarySurfaceLayer().orElse(null); + if (layer == null || !layer.channel()) { return IrisRiverState.NONE; } - return switch (surface.river().state()) { - case WET -> IrisRiverState.WET; - case DRY -> IrisRiverState.DRY; - case SUPPRESSED -> IrisRiverState.NONE; - }; + return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY; + } + + private static HydrologyColumnSample hydrologySample(Engine engine, int blockX, int blockZ) { + if (engine.getComplex().getHydrologyRuntime() == null) { + return null; + } + return engine.getComplex().getHydrologyRuntime().sample(blockX, blockZ).orElse(null); } private static Optional key(IrisBiome biome) { diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java index 520dfb81e..c1896436e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifier.java @@ -1,10 +1,9 @@ package art.arcane.iris.core.service.terrain; import art.arcane.iris.api.terrain.IrisSurfaceKind; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; import art.arcane.iris.engine.object.InferredType; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; public final class IrisSurfaceClassifier { private IrisSurfaceClassifier() { @@ -30,25 +29,23 @@ public final class IrisSurfaceClassifier { int engineSurfaceHeight, int engineFluidHeight, InferredType inferredType, - IrisRiverSurfaceSample riverSurface + HydrologyColumnSample hydrology ) { if (engineSurfaceHeight <= 0) { return IrisSurfaceKind.VOID; } - if (riverSurface != null && riverSurface.river().present() && !riverSurface.subterranean()) { - if (riverSurface.river().state() == RiverRouteState.DRY) { - return riverSurface.river().section() == RiverSection.DRY_CHANNEL - ? IrisSurfaceKind.DRY_CHANNEL - : IrisSurfaceKind.LAND; + HydrologyColumnLayer layer = hydrology == null + ? null + : hydrology.primarySurfaceLayer().orElse(null); + if (layer != null) { + if (layer.shore()) { + return IrisSurfaceKind.RIVER_SHORE; } - if (riverSurface.river().state() == RiverRouteState.WET) { - RiverSection section = riverSurface.river().section(); - if (section == RiverSection.BANK) { - return IrisSurfaceKind.RIVER_SHORE; - } - if (section == RiverSection.CHANNEL || section == RiverSection.MOUTH) { - return IrisSurfaceKind.RIVER; - } + if (layer.channel()) { + return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL; + } + if (layer.grading()) { + return IrisSurfaceKind.LAND; } } return classify(engineSurfaceHeight, engineFluidHeight, inferredType); diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindBiomeContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindBiomeContractTest.java new file mode 100644 index 000000000..accdfd766 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandFindBiomeContractTest.java @@ -0,0 +1,20 @@ +package art.arcane.iris.core.commands; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.util.common.director.specialhandlers.ReachableBiomeHandler; +import art.arcane.volmlib.util.director.annotations.Param; +import org.junit.Test; + +import java.lang.reflect.Method; + +import static org.junit.Assert.assertEquals; + +public class CommandFindBiomeContractTest { + @Test + public void biomeLookupUsesReachableBiomeHandler() throws Exception { + Method method = CommandFind.class.getMethod("biome", IrisBiome.class, boolean.class); + Param parameter = method.getParameters()[0].getAnnotation(Param.class); + + assertEquals(ReachableBiomeHandler.class, parameter.customHandler()); + } +} diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackOptionalPackContractTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackOptionalPackContractTest.java index 70f091b9c..1e1a52af5 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackOptionalPackContractTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandPackOptionalPackContractTest.java @@ -1,10 +1,12 @@ package art.arcane.iris.core.commands; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility; import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor; import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor; import org.junit.Test; +import java.lang.reflect.Method; import java.util.List; import static org.junit.Assert.assertEquals; @@ -52,8 +54,23 @@ public class CommandPackOptionalPackContractTest { assertFalse(CommandPack.wantsAllPacks("overworld")); } - private static java.lang.reflect.Method findMethod(String name) { - for (java.lang.reflect.Method method : CommandPack.class.getDeclaredMethods()) { + @Test + public void packageCommandIsOwnedByPackNamespace() throws Exception { + Method method = CommandPack.class.getDeclaredMethod( + "pkg", + IrisDimension.class, + boolean.class, + boolean.class + ); + DirectorNodeDescriptor descriptor = DirectorAnnotationCompatibility.fromMethod(method).orElseThrow(); + assertTrue(descriptor.getAliases().contains("package")); + for (Method studioMethod : CommandStudio.class.getDeclaredMethods()) { + assertFalse("Studio must not retain the package command", studioMethod.getName().equals("pkg")); + } + } + + private static Method findMethod(String name) { + for (Method method : CommandPack.class.getDeclaredMethods()) { if (method.getName().equals(name)) { return method; } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java index 90368cd39..9ff8c2112 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/IrisTerrainSVCTest.java @@ -5,12 +5,10 @@ import art.arcane.iris.api.terrain.IrisColumnQuery; import art.arcane.iris.api.terrain.IrisRiverState; import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.api.terrain.IrisTerrainService; -import art.arcane.iris.engine.river.RiverEdgeId; -import art.arcane.iris.engine.river.RiverNodeId; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; import art.arcane.iris.util.common.plugin.IrisService; import org.bukkit.World; import org.junit.Test; @@ -19,6 +17,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.EnumSet; +import java.util.List; import java.util.Optional; import java.util.OptionalInt; import java.util.concurrent.atomic.AtomicInteger; @@ -104,36 +103,46 @@ public class IrisTerrainSVCTest { } @Test - public void riverRouteStatesMapToThePublicDiagnosticStates() { - assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState( - new IrisRiverSurfaceSample(RiverSample.none(), 70D, 70D, 70D, false, false) - )); - assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(RiverRouteState.WET))); - assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(RiverRouteState.DRY))); - assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(river(RiverRouteState.SUPPRESSED))); + public void acceptedHydrologyMapsToThePublicDiagnosticStates() { + assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(null)); + assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(true))); + assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false))); } - private static IrisRiverSurfaceSample river(RiverRouteState state) { - RiverSection section = switch (state) { - case WET -> RiverSection.CHANNEL; - case DRY -> RiverSection.DRY_CHANNEL; - case SUPPRESSED -> RiverSection.NONE; - }; - RiverSample sample = new RiverSample( - true, - state, - section, - 0D, - 0.5D, - 1D, + private static HydrologyColumnSample river(boolean connectedFluid) { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 1L, + HydrologyFeatureType.SURFACE_POOL, + 2L, + 3L, + 0, + 63, + 0, 1, - 1, - 8D, - 4D, - 3D, - false, - RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0)) + 0, + false ); - return new IrisRiverSurfaceSample(sample, 70D, 60D, 63D, false, state == RiverRouteState.WET); + HydrologyColumnLayer layer = new HydrologyColumnLayer( + feature, + 60, + 63, + 63, + true, + false, + false, + connectedFluid, + false, + false, + true, + connectedFluid, + false, + "water", + "river", + "mouth", + "shore", + "dry", + "cave" + ); + return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer)); } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java index 90281845e..648c9127c 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/service/terrain/IrisSurfaceClassifierTest.java @@ -1,15 +1,15 @@ package art.arcane.iris.core.service.terrain; import art.arcane.iris.api.terrain.IrisSurfaceKind; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; import art.arcane.iris.engine.object.InferredType; -import art.arcane.iris.engine.river.RiverEdgeId; -import art.arcane.iris.engine.river.RiverNodeId; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; 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; @@ -59,91 +59,108 @@ public class IrisSurfaceClassifierTest { IrisSurfaceKind expected = IrisSurfaceClassifier.classify(surface, FLUID, null); for (InferredType inferredType : InferredType.values()) { - assertEquals("surface=" + surface + " type=" + inferredType, - expected, IrisSurfaceClassifier.classify(surface, FLUID, inferredType)); + assertEquals( + "surface=" + surface + " type=" + inferredType, + expected, + IrisSurfaceClassifier.classify(surface, FLUID, inferredType) + ); } } } @Test - public void activeRiverGeometryOverridesGenericOceanAndLandKinds() { + public void acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() { assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( 60, 63, InferredType.SEA, - river(RiverRouteState.WET, RiverSection.CHANNEL) + hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true) )); assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( 60, 63, InferredType.SEA, - river(RiverRouteState.WET, RiverSection.MOUTH) + hydrology(HydrologyFeatureType.MOUTH, true, false, true) )); assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify( 64, 63, InferredType.SHORE, - river(RiverRouteState.WET, RiverSection.BANK) + hydrology(HydrologyFeatureType.SURFACE_POOL, false, true, false) )); assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify( 60, 60, InferredType.LAND, - river(RiverRouteState.DRY, RiverSection.DRY_CHANNEL) + hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, false) )); assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify( 60, 60, InferredType.LAND, - river(RiverRouteState.DRY, RiverSection.DRY_BANK) + hydrology(HydrologyFeatureType.SURFACE_POOL, false, false, false) )); } @Test - public void voidClassificationWinsOverRiverGeometry() { - for (RiverSection section : RiverSection.values()) { - if (section == RiverSection.NONE) { - continue; - } - RiverRouteState state = section == RiverSection.DRY_CHANNEL || section == RiverSection.DRY_BANK - ? RiverRouteState.DRY - : RiverRouteState.WET; - assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify( - 0, - 63, - InferredType.LAND, - river(state, section) - )); - } + public void voidClassificationWinsOverAcceptedRiverGeometry() { + assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify( + 0, + 63, + InferredType.LAND, + hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true) + )); } @Test - public void suppressedRoutesDoNotCreatePublicRiverSurfaceKinds() { + public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() { assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify( 64, 63, InferredType.LAND, - river(RiverRouteState.SUPPRESSED, RiverSection.CHANNEL) + null )); } - private static IrisRiverSurfaceSample river(RiverRouteState state, RiverSection section) { - RiverSample sample = new RiverSample( - true, - state, - section, - 0D, - 0.5D, - 1D, + private static HydrologyColumnSample hydrology( + HydrologyFeatureType type, + boolean channel, + boolean shore, + boolean connectedFluid + ) { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 1L, + type, + 2L, + 3L, + 0, + 63, + 0, 1, - 1, - 8D, - 4D, - 3D, - false, - RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0)) + 0, + false ); - double waterSurface = state == RiverRouteState.WET ? 63D : 60D; - return new IrisRiverSurfaceSample(sample, 70D, 60D, waterSurface, false, state == RiverRouteState.WET); + HydrologyColumnLayer layer = new HydrologyColumnLayer( + feature, + 60, + 63, + 63, + channel, + shore, + !channel && !shore, + connectedFluid, + false, + false, + true, + connectedFluid && channel, + false, + "water", + "river", + "mouth", + "shore", + "dry", + "cave" + ); + return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer)); } } diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index de22ce325..1ecc1b3e7 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -114,6 +114,10 @@ dependencies { minecraft("com.mojang:minecraft:${minecraftVersion}") implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}") testImplementation('junit:junit:4.13.2') + testImplementation('org.mockito:mockito-core:5.23.0') + testImplementation(libs.spigot) { + transitive = false + } // registrySync and resourceLoader are LOAD-BEARING despite zero imports anywhere in Iris: // fabric-registry-sync-v0 delays registry freeze past mod init, which is what lets // IrisFabricBootstrap register the CHUNK_GENERATOR codec in diff --git a/adapters/forge/build.gradle b/adapters/forge/build.gradle index 832c1c97e..3df6827e4 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -131,6 +131,10 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath, dependencies { implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}") testImplementation('junit:junit:4.13.2') + testImplementation('org.mockito:mockito-core:5.23.0') + testImplementation(libs.spigot) { + transitive = false + } compileOnly('org.slf4j:slf4j-api:2.0.17') compileOnly(libs.spigot) { transitive = false diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java index 6dc063562..444979642 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/MainWorldService.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.IrisEngineMantle; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -41,7 +42,7 @@ public final class MainWorldService { "region", "entities", "poi", - "mantle", + IrisEngineMantle.STORAGE_FOLDER_NAME, "dimensions/minecraft/overworld", "dimensions/minecraft/the_nether", "dimensions/minecraft/the_end", diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java index 3650e022d..bb18182fe 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/ModdedDimensionStorage.java @@ -18,6 +18,7 @@ package art.arcane.iris.modded; +import art.arcane.iris.engine.IrisEngineMantle; import net.minecraft.resources.ResourceKey; import net.minecraft.server.MinecraftServer; import net.minecraft.world.level.Level; @@ -33,7 +34,12 @@ import java.util.List; import java.util.stream.Stream; public final class ModdedDimensionStorage { - private static final List CHUNK_DATA_FOLDERS = List.of("region", "entities", "poi", "mantle"); + private static final List CHUNK_DATA_FOLDERS = List.of( + "region", + "entities", + "poi", + IrisEngineMantle.STORAGE_FOLDER_NAME + ); private ModdedDimensionStorage() { } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java index 1a76fd2cf..5d8a9ba68 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/IrisModdedCommands.java @@ -325,7 +325,7 @@ public final class IrisModdedCommands { return 0; } String target = downloadDisplayTarget(request); - String downloadSource = request.pack() == null ? "direct ZIP URL" : "built-in beta release"; + String downloadSource = request.pack() == null ? "direct ZIP URL" : "built-in stable release"; ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); if (scheduler == null) { fail(source, IrisLanguage.plain( diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java index 1c4a79d2a..95e6b292a 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandSuggestions.java @@ -25,6 +25,8 @@ import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.StructureReachability; +import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; +import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.modded.IrisModdedChunkGenerator; @@ -62,6 +64,7 @@ final class ModdedCommandSuggestions { static final SuggestionProvider OBJECT_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestObjectKeys(context, builder); static final SuggestionProvider STRUCTURE_KEYS = (CommandContext context, SuggestionsBuilder builder) -> suggestStructureKeys(context, builder); static final SuggestionProvider POI_TYPES = (CommandContext context, SuggestionsBuilder builder) -> SharedSuggestionProvider.suggest(List.of("buried_treasure"), builder); + static final SuggestionProvider HYDROLOGY_TYPES = (CommandContext context, SuggestionsBuilder builder) -> suggestHydrologyTypes(context, builder); static final SuggestionProvider PACK_NAMES = (CommandContext context, SuggestionsBuilder builder) -> suggestPackNames(context, builder); static final SuggestionProvider DIMENSION_NAMES = (CommandContext context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder); @@ -79,7 +82,7 @@ final class ModdedCommandSuggestions { try { Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel()); if (engine != null) { - return SharedSuggestionProvider.suggest(engine.getData().getBiomeLoader().getPossibleKeys(), builder); + return SharedSuggestionProvider.suggest(reachableBiomeKeys(engine), builder); } } catch (Throwable e) { warnTabFailure("biome keys", context.getSource(), e); @@ -87,6 +90,46 @@ final class ModdedCommandSuggestions { return builder.buildFuture(); } + private static CompletableFuture suggestHydrologyTypes( + CommandContext context, + SuggestionsBuilder builder + ) { + ModdedCommandFeedback.tab(context.getSource()); + try { + Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel()); + if (engine != null && engine.getComplex().getHydrologyRuntime() != null) { + return SharedSuggestionProvider.suggest( + engine.getComplex().getHydrologyRuntime().featureQueryKeys(), builder); + } + } catch (Throwable error) { + warnTabFailure("hydrology types", context.getSource(), error); + } + return SharedSuggestionProvider.suggest( + HydrologyFeatureQuery.suggestions(List.of()), builder); + } + + static Set reachableBiomeKeys(Engine engine) { + return reachableBiomeKeys(engine.getAllBiomes()); + } + + static Set reachableBiomeKeys(Iterable biomes) { + Set keys = new TreeSet<>(); + for (IrisBiome biome : biomes) { + if (biome != null && biome.getLoadKey() != null && !biome.getLoadKey().isBlank()) { + keys.add(biome.getLoadKey()); + } + } + return keys; + } + + static boolean isReachableBiome(Engine engine, String biomeKey) { + return isReachableBiome(engine.getAllBiomes(), biomeKey); + } + + static boolean isReachableBiome(Iterable biomes, String biomeKey) { + return biomeKey != null && reachableBiomeKeys(biomes).contains(biomeKey.trim()); + } + private static CompletableFuture suggestRegionKeys(CommandContext context, SuggestionsBuilder builder) { ModdedCommandFeedback.tab(context.getSource()); try { diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java index b42598754..aa793f621 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedCommandTree.java @@ -227,6 +227,9 @@ final class ModdedCommandTree { .then(Commands.literal("object") .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.OBJECT_KEYS) .executes((CommandContext context) -> ModdedLocateCommands.gotoObject(context.getSource(), StringArgumentType.getString(context, "key"))))) + .then(Commands.literal("river") + .then(Commands.argument("type", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.HYDROLOGY_TYPES) + .executes((CommandContext context) -> ModdedLocateCommands.gotoRiver(context.getSource(), StringArgumentType.getString(context, "type"))))) .then(Commands.literal("unregistered") .executes((CommandContext context) -> ModdedUnregisteredStructures.print(context.getSource()))) diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java index 5a808b624..be971a53f 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedLocateCommands.java @@ -29,12 +29,16 @@ import art.arcane.iris.engine.framework.Locator; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.framework.WrongEngineBroException; +import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.util.project.context.IrisContext; +import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.math.Position2; import com.mojang.datafixers.util.Pair; @@ -87,6 +91,10 @@ final class ModdedLocateCommands { IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key))); return 0; } + if (!ModdedCommandSuggestions.isReachableBiome(engine, biome.getLoadKey())) { + IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_IS_NOT_DEFINED_DIMENSION, MessageArgument.untrusted("value", biome.getLoadKey()))); + return 0; + } locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey()); return 1; } @@ -137,6 +145,65 @@ final class ModdedLocateCommands { return 1; } + static int gotoRiver(CommandSourceStack source, String type) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + IrisModdedCommands.fail(source, "This command can only be used by a player."); + return 0; + } + ServerLevel level = source.getLevel(); + Engine engine = IrisModdedCommands.engineFor(level); + if (engine == null) { + IrisModdedCommands.fail(source, "This dimension is not generated by Iris."); + return 0; + } + IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime(); + if (runtime == null) { + IrisModdedCommands.fail(source, "Hydrology is not active in this dimension."); + return 0; + } + HydrologyFeatureQuery query; + try { + query = HydrologyFeatureQuery.parse(type); + } catch (IllegalArgumentException error) { + IrisModdedCommands.fail(source, error.getMessage()); + return 0; + } + int originX = player.blockPosition().getX(); + int originZ = player.blockPosition().getZ(); + int requestedDistance = Math.min(8192, runtime.settings().routing().tileSize() * 15); + int maximumDistance = runtime.maximumFeatureSearchDistance(originX, originZ, requestedDistance); + MinecraftServer server = source.getServer(); + IrisModdedCommands.ok(source, "Searching accepted hydrology plans for " + type + "..."); + MultiBurst.burst.completeValueAsync(() -> runtime.nearestFeature( + query.types(), query.profileKey(), originX, originZ, maximumDistance).orElse(null)) + .whenComplete((HydrologyFeatureRef feature, Throwable error) -> server.execute(() -> { + if (error != null) { + ModdedIrisLog.error("Hydrology locate failed for {}", type, error); + IrisModdedCommands.fail(source, + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName()); + return; + } + if (feature == null) { + IrisModdedCommands.fail(source, + "No accepted " + type + " hydrology feature found within " + + maximumDistance + " blocks."); + return; + } + int worldY = feature.y() + engine.getDimension().getMinHeight() + 2; + teleportToStructure( + source, + level, + player, + feature.x(), + worldY, + feature.z(), + type + " hydrology feature" + ); + })); + return 1; + } + static int gotoStructure(CommandSourceStack source, String keyRaw) { ServerLevel level = source.getLevel(); Engine engine = IrisModdedCommands.engineFor(level); diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java index ffaf14f4c..393fc7a25 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedPregenMethod.java @@ -341,8 +341,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { } markCompleted(); listener.onChunkGenerated(x, z); - cleanupMantleChunk(x, z); - listener.onChunkCleaned(x, z); + cleanupMantleChunksCoveredBy(x, z, listener); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (TimeoutException | ExecutionException e) { @@ -406,8 +405,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod { onSuccess(); markCompleted(); listener.onChunkGenerated(x, z); - cleanupMantleChunk(x, z); - listener.onChunkCleaned(x, z); + cleanupMantleChunksCoveredBy(x, z, listener); } finally { markFinished(); semaphore.release(); @@ -494,9 +492,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod { } } - private void cleanupMantleChunk(int x, int z) { + private void cleanupMantleChunksCoveredBy(int x, int z, PregenListener listener) { try { - engine.getMantle().forceCleanupChunk(x, z); + engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned); } catch (Throwable e) { ModdedIrisLog.debug("Iris pregen mantle cleanup skipped for {},{}: {}", x, z, e.toString()); } diff --git a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java index 6e19788cf..8135f3128 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/service/ModdedChunkUpdateService.java @@ -301,7 +301,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService { } } - private void runUpdatePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk) { + void runUpdatePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk chunk) { PrecisionStopwatch stopwatch = PrecisionStopwatch.start(); int minHeight = engine.getWorld().minHeight(); int baseX = chunkX << 4; diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java index 404788225..da472a744 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/ModdedGenerationLeaseContractTest.java @@ -187,6 +187,17 @@ public class ModdedGenerationLeaseContractTest { assertTrue(logFailure.contains("cause.toString()")); } + @Test + public void pregenReportsOnlyCleanupCallbacksFromCoveredTargets() throws IOException { + String source = source("art/arcane/iris/modded/command/ModdedPregenMethod.java"); + String cleanup = method(source, "private void cleanupMantleChunksCoveredBy("); + + assertTrue(source.contains("cleanupMantleChunksCoveredBy(x, z, listener);")); + assertFalse(source.contains("cleanupMantleChunk(x, z);")); + assertTrue(cleanup.contains("cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned)")); + assertFalse(cleanup.contains("forceCleanupChunk")); + } + @Test public void invalidPregenBoundsFailBeforeRuntimeMutation() throws IOException { String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java"); diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedBiomeCommandTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedBiomeCommandTest.java new file mode 100644 index 000000000..a6b75523c --- /dev/null +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/command/IrisModdedBiomeCommandTest.java @@ -0,0 +1,30 @@ +package art.arcane.iris.modded.command; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.volmlib.util.collection.KList; +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; + +public class IrisModdedBiomeCommandTest { + @Test + public void biomeKeysAreSortedAndLimitedToReachableBiomes() { + KList reachable = new KList<>(); + IrisBiome river = new IrisBiome(); + river.setLoadKey("river"); + IrisBiome natural = new IrisBiome(); + natural.setLoadKey("natural"); + reachable.add(river); + reachable.add(natural); + assertEquals( + List.of("natural", "river"), + List.copyOf(ModdedCommandSuggestions.reachableBiomeKeys(reachable)) + ); + assertTrue(ModdedCommandSuggestions.isReachableBiome(reachable, "river")); + assertFalse(ModdedCommandSuggestions.isReachableBiome(reachable, "unused")); + } +} diff --git a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java index 2e4b82df6..ad50be673 100644 --- a/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java +++ b/adapters/modded-common/src/test/java/art/arcane/iris/modded/service/ModdedChunkUpdateServiceTest.java @@ -1,11 +1,27 @@ package art.arcane.iris.modded.service; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineMetrics; +import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.function.Consumer4; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; import art.arcane.volmlib.util.mantle.runtime.MantleHooks; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterUpdate; +import art.arcane.volmlib.util.matter.slices.UpdateMatter; +import net.minecraft.SharedConstants; +import net.minecraft.core.BlockPos; +import net.minecraft.server.Bootstrap; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LiquidBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.material.FlowingFluid; import org.junit.Test; +import org.mockito.InOrder; import java.lang.reflect.Proxy; import java.util.ArrayList; @@ -15,8 +31,42 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class ModdedChunkUpdateServiceTest { + @Test + public void fallingFluidUpdateRetainsLevelEightStateAndRequestsPhysics() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + IrisWorld world = mock(IrisWorld.class); + when(world.minHeight()).thenReturn(-64); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(world); + when(engine.getMetrics()).thenReturn(new EngineMetrics(8)); + ServerLevel level = mock(ServerLevel.class); + when(level.getMinY()).thenReturn(-64); + when(level.getMaxY()).thenReturn(320); + BlockPos position = new BlockPos(15, -59, 14); + BlockState falling = Blocks.WATER.defaultBlockState().setValue(LiquidBlock.LEVEL, 8); + when(level.getBlockState(position)).thenReturn(falling); + UpdateRecordingMantleChunk chunk = new UpdateRecordingMantleChunk(); + + new ModdedChunkUpdateService().runUpdatePass(engine, level, 0, 0, chunk); + + assertEquals(8, falling.getValue(LiquidBlock.LEVEL).intValue()); + if (falling.getFluidState().hasProperty(FlowingFluid.FALLING)) { + assertTrue(falling.getFluidState().getValue(FlowingFluid.FALLING)); + } + InOrder order = inOrder(level); + order.verify(level).setBlock(eq(position), eq(Blocks.AIR.defaultBlockState()), anyInt()); + order.verify(level).setBlock(position, falling, Block.UPDATE_ALL); + assertTrue(chunk.deleted()); + } + @Test public void scansWhenPlayersArePresent() { assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false)); @@ -84,6 +134,35 @@ public class ModdedChunkUpdateServiceTest { } } + private static final class UpdateRecordingMantleChunk extends MantleChunk { + private boolean deleted; + + private UpdateRecordingMantleChunk() { + super(1, 0, 0, emptyAdapter(), MantleHooks.NONE); + } + + @Override + public void iterate( + Class type, + Consumer4 iterator + ) { + if (type == MatterUpdate.class) { + iterator.accept(-1, 5, -2, type.cast(UpdateMatter.ON)); + } + } + + @Override + public void deleteSlices(Class type) { + if (type == MatterUpdate.class) { + deleted = true; + } + } + + private boolean deleted() { + return deleted; + } + } + @SuppressWarnings("unchecked") private static MantleDataAdapter emptyAdapter() { return (MantleDataAdapter) Proxy.newProxyInstance( diff --git a/adapters/neoforge/build.gradle b/adapters/neoforge/build.gradle index 3177a6e89..f785f3412 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -105,6 +105,10 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath, dependencies { testImplementation('junit:junit:4.13.2') + testImplementation('org.mockito:mockito-core:5.23.0') + testImplementation(libs.spigot) { + transitive = false + } testRuntimeOnly('org.junit.platform:junit-platform-launcher:6.1.2') testRuntimeOnly('org.junit.vintage:junit-vintage-engine:6.1.2') compileOnly('org.slf4j:slf4j-api:2.0.17') diff --git a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java index 352a1462d..b67935ed7 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java +++ b/core/src/main/java/art/arcane/iris/core/gui/VisionGUI.java @@ -23,10 +23,11 @@ import art.arcane.iris.core.localization.IrisLanguage; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.render.IrisRenderer; import art.arcane.iris.engine.framework.render.RenderType; +import art.arcane.iris.engine.hydrology.HydrologyCandidateKind; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.river.RiverSection; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.localization.MessageArgument; @@ -125,6 +126,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi private VisionRenderController.Frame renderFrame; private JComboBox modeSelector; private JToggleButton gridToggle; + private JToggleButton entitiesToggle; private JToggleButton followToggle; private RenderType currentType; private List players; @@ -142,6 +144,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi private double blocksPerPixel; private int paintCadenceFps; private boolean grid; + private boolean entitiesVisible; private boolean follow; private boolean help; private boolean debug; @@ -160,6 +163,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi this.notifications = new LinkedHashMap<>(); this.players = List.of(); this.entities = List.of(); + this.entitiesVisible = false; this.currentType = RenderType.BIOME; this.blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL; this.contentRevision = 1L; @@ -265,6 +269,12 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi vision.gridToggle = createToggle(IrisLanguage.plain(DesktopUiMessages.VISION_GRID), vision.grid); vision.gridToggle.addActionListener(event -> vision.toggleGrid()); trailing.add(vision.gridToggle); + vision.entitiesToggle = createToggle( + IrisLanguage.plain(DesktopUiMessages.VISION_ENTITIES), + vision.entitiesVisible + ); + vision.entitiesToggle.addActionListener(event -> vision.toggleEntities()); + trailing.add(vision.entitiesToggle); vision.followToggle = createToggle(IrisLanguage.plain(DesktopUiMessages.VISION_FOLLOW), vision.follow); vision.followToggle.addActionListener(event -> vision.toggleFollow()); trailing.add(vision.followToggle); @@ -411,11 +421,13 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi } private void renderMarkers(Graphics2D canvas) { - for (GuiMarker marker : entities) { - int screenX = (int) Math.round(worldToScreenX(marker.worldX())); - int screenY = (int) Math.round(worldToScreenZ(marker.worldZ())); - canvas.setColor(MOB_COLOR); - canvas.fillRect(screenX - 2, screenY - 2, 5, 5); + if (entitiesVisible) { + for (GuiMarker marker : entities) { + int screenX = (int) Math.round(worldToScreenX(marker.worldX())); + int screenY = (int) Math.round(worldToScreenZ(marker.worldZ())); + canvas.setColor(MOB_COLOR); + canvas.fillRect(screenX - 2, screenY - 2, 5, 5); + } } for (GuiMarker marker : players) { @@ -430,7 +442,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi int labelWidth = canvas.getFontMetrics().stringWidth(marker.label()); canvas.drawString(marker.label(), screenX - labelWidth / 2, screenY - 14); } - if (detailedHover) { + if (entitiesVisible && detailedHover) { renderNearestEntity(canvas); } } @@ -483,21 +495,40 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi } private void renderRiverLegend(Graphics2D canvas) { - RiverSection[] sections = RiverSection.values(); + HydrologyFeatureType[] types = HydrologyFeatureType.values(); + HydrologyCandidateKind[] candidateKinds = HydrologyCandidateKind.values(); int lineHeight = 18; - int width = 148; - int height = sections.length * lineHeight + CARD_PADDING * 2; + int width = 188; + int height = (types.length + candidateKinds.length + 1) * lineHeight + CARD_PADDING * 2; int x = getWidth() - width - CARD_PADDING; int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - CARD_PADDING; drawCardBackground(canvas, x, y, width, height); canvas.setFont(BODY_FONT); - for (int index = 0; index < sections.length; index++) { - RiverSection section = sections[index]; - int rowY = y + CARD_PADDING + index * lineHeight; - canvas.setColor(new Color(IrisRenderer.riverColor(section))); + int headwaterY = y + CARD_PADDING; + canvas.setColor(new Color(IrisRenderer.headwaterColor())); + canvas.fillRoundRect(x + CARD_PADDING, headwaterY + 2, 12, 12, 4, 4); + canvas.setColor(new Color(IrisRenderer.headwaterDirectionColor())); + canvas.drawLine(x + CARD_PADDING + 3, headwaterY + 8, x + CARD_PADDING + 9, headwaterY + 8); + canvas.drawLine(x + CARD_PADDING + 9, headwaterY + 8, x + CARD_PADDING + 6, headwaterY + 5); + canvas.drawLine(x + CARD_PADDING + 9, headwaterY + 8, x + CARD_PADDING + 6, headwaterY + 11); + canvas.setColor(TEXT_SECONDARY); + canvas.drawString("headwater / source flow", x + CARD_PADDING + 20, headwaterY + 13); + for (int index = 0; index < types.length; index++) { + HydrologyFeatureType type = types[index]; + int rowY = y + CARD_PADDING + (index + 1) * lineHeight; + canvas.setColor(new Color(IrisRenderer.hydrologyFeatureColor(type))); canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4); canvas.setColor(TEXT_SECONDARY); - String label = section.name().toLowerCase(Locale.ROOT).replace('_', ' '); + String label = type.name().toLowerCase(Locale.ROOT).replace('_', ' '); + canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13); + } + for (int index = 0; index < candidateKinds.length; index++) { + HydrologyCandidateKind kind = candidateKinds[index]; + int rowY = y + CARD_PADDING + (types.length + index + 1) * lineHeight; + canvas.setColor(new Color(IrisRenderer.hydrologyDiagnosticColor(kind))); + canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4); + canvas.setColor(TEXT_SECONDARY); + String label = "projected " + kind.name().toLowerCase(Locale.ROOT).replace('_', ' '); canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13); } } @@ -783,12 +814,16 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi try { List nextPlayers = overlay.players(); players = nextPlayers == null ? List.of() : List.copyOf(nextPlayers); - overlay.requestEntities(next -> EventQueue.invokeLater(() -> { - if (!closed) { - entities = next == null ? List.of() : List.copyOf(next); - repaint(); - } - })); + if (entitiesVisible) { + overlay.requestEntities(next -> EventQueue.invokeLater(() -> { + if (!closed && entitiesVisible) { + entities = next == null ? List.of() : List.copyOf(next); + repaint(); + } + })); + } else if (!entities.isEmpty()) { + entities = List.of(); + } if (follow && !players.isEmpty()) { GuiMarker player = players.get(0); if (Math.abs(centerX - player.worldX()) > 0.5D || Math.abs(centerZ - player.worldZ()) > 0.5D) { @@ -852,6 +887,22 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi notifyUser(IrisLanguage.plain(grid ? DesktopUiMessages.VISION_GRID_ENABLED : DesktopUiMessages.VISION_GRID_DISABLED)); } + private void toggleEntities() { + entitiesVisible = !entitiesVisible; + if (entitiesVisible) { + refreshMarkers(); + } else { + entities = List.of(); + repaint(); + } + syncControls(); + notifyUser(IrisLanguage.plain( + entitiesVisible + ? DesktopUiMessages.VISION_ENTITIES_ENABLED + : DesktopUiMessages.VISION_ENTITIES_DISABLED + )); + } + private void toggleFollow() { follow = !follow; if (follow && players.isEmpty()) { @@ -881,6 +932,9 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi if (gridToggle != null) { gridToggle.setSelected(grid); } + if (entitiesToggle != null) { + entitiesToggle.setSelected(entitiesVisible); + } if (followToggle != null) { followToggle.setSelected(follow); } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java index 573c08f83..4bcacfbfe 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/BukkitWorldConfiguration.java @@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.WorldSlotKey; +import art.arcane.iris.engine.IrisEngineMantle; import org.bukkit.NamespacedKey; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.InvalidConfigurationException; @@ -253,7 +254,9 @@ public final class BukkitWorldConfiguration { if (Files.isDirectory(worldRoot.resolve("iris"), LinkOption.NOFOLLOW_LINKS)) { return true; } - for (String directory : new String[]{"region", "entities", "poi", "mantle"}) { + for (String directory : new String[]{ + "region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME + }) { if (hasEntries(worldRoot.resolve(directory))) { return true; } diff --git a/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java b/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java index cec987525..cd33e68d5 100644 --- a/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java +++ b/core/src/main/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantine.java @@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle; import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.WorldSlotKey; +import art.arcane.iris.engine.IrisEngineMantle; import java.io.IOException; import java.nio.file.AtomicMoveNotSupportedException; @@ -37,7 +38,9 @@ import java.util.stream.Stream; public final class HuskWorldQuarantine { private static final String IRIS_NAMESPACE = "iris"; private static final Pattern MANAGED_KEY = Pattern.compile("[a-z0-9_-]+"); - private static final String[] CHUNK_DATA_DIRECTORIES = {"region", "entities", "poi", "mantle"}; + private static final String[] CHUNK_DATA_DIRECTORIES = { + "region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME + }; private static final Set OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini"); private static final String APPLE_DOUBLE_PREFIX = "._"; private static final DateTimeFormatter STAMP = DateTimeFormatter diff --git a/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java b/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java index 545928c7e..4411d5667 100644 --- a/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java +++ b/core/src/main/java/art/arcane/iris/core/localization/DesktopUiMessages.java @@ -9,12 +9,15 @@ public final class DesktopUiMessages { public static final TextKey VISION_TITLE = TextKey.of("iris.desktop.vision.title", "Iris Vision"); public static final TextKey VISION_VIEW = TextKey.of("iris.desktop.vision.view", "View:"); public static final TextKey VISION_GRID = TextKey.of("iris.desktop.vision.grid", "Grid"); + public static final TextKey VISION_ENTITIES = TextKey.of("iris.desktop.vision.entities", "Entities"); public static final TextKey VISION_FOLLOW = TextKey.of("iris.desktop.vision.follow", "Follow"); public static final TextKey VISION_REFRESHING = TextKey.of("iris.desktop.vision.refreshing", "Refreshing"); public static final TextKey VISION_FPS = TextKey.of("iris.desktop.vision.fps", "{fps} FPS"); public static final TextKey VISION_ZOOM_RESET = TextKey.of("iris.desktop.vision.zoom_reset", "Zoom reset"); public static final TextKey VISION_GRID_ENABLED = TextKey.of("iris.desktop.vision.grid_enabled", "Grid enabled"); public static final TextKey VISION_GRID_DISABLED = TextKey.of("iris.desktop.vision.grid_disabled", "Grid disabled"); + public static final TextKey VISION_ENTITIES_ENABLED = TextKey.of("iris.desktop.vision.entities_enabled", "Entities enabled"); + public static final TextKey VISION_ENTITIES_DISABLED = TextKey.of("iris.desktop.vision.entities_disabled", "Entities disabled"); public static final TextKey VISION_FOLLOWING = TextKey.of("iris.desktop.vision.following", "Following {player}"); public static final TextKey VISION_NO_PLAYER = TextKey.of("iris.desktop.vision.no_player", "No player in world"); public static final TextKey VISION_FOLLOW_DISABLED = TextKey.of("iris.desktop.vision.follow_disabled", "Follow disabled"); @@ -158,8 +161,9 @@ public final class DesktopUiMessages { public static final TextKey IMAGEMAP_PREVIEW_STATUS = TextKey.of("iris.desktop.imagemap.preview_status", "X {x} Z {z} | {value} | {scale} blocks/pixel"); private static final List KEYS = List.of( - VISION_TITLE, VISION_VIEW, VISION_GRID, VISION_FOLLOW, + VISION_TITLE, VISION_VIEW, VISION_GRID, VISION_ENTITIES, VISION_FOLLOW, VISION_REFRESHING, VISION_FPS, VISION_ZOOM_RESET, VISION_GRID_ENABLED, VISION_GRID_DISABLED, + VISION_ENTITIES_ENABLED, VISION_ENTITIES_DISABLED, VISION_FOLLOWING, VISION_NO_PLAYER, VISION_FOLLOW_DISABLED, VISION_STATUS_LEFT, VISION_STATUS_RIGHT, VISION_ENTITY_POSITION, VISION_ENTITY_HEALTH, VISION_BLOCK_POSITION, VISION_CHUNK_POSITION, VISION_REGION_POSITION, VISION_BIOME_KEY, diff --git a/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java index 6251b1bbd..a91da1ebf 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java +++ b/core/src/main/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisioner.java @@ -388,7 +388,7 @@ public final class DefaultPackBootstrapProvisioner { try { validCache = validateArchive(archivePath, spec); } catch (IOException exception) { - feedback.accept("Cached Iris " + spec.key() + " beta archive is invalid; downloading a replacement."); + feedback.accept("Cached Iris " + spec.key() + " release archive is invalid; downloading a replacement."); } } String cachedSource = metadata.getProperty("source", ""); @@ -439,7 +439,7 @@ public final class DefaultPackBootstrapProvisioner { copyLimited(input, output, options.maxArchiveBytes(), spec); } if (!validateArchive(temporary, spec)) { - throw new IOException("Downloaded Iris " + spec.key() + " beta archive is invalid"); + throw new IOException("Downloaded Iris " + spec.key() + " release archive is invalid"); } move(temporary, archivePath, true); } finally { @@ -452,11 +452,11 @@ public final class DefaultPackBootstrapProvisioner { updated.setProperty("fetchedAt", Long.toString(options.clock().millis())); updated.setProperty("sha256", sha256(archivePath)); storePropertiesAtomic(metadataPath, updated); - feedback.accept("Downloaded the Iris " + spec.key() + " beta archive."); + feedback.accept("Downloaded the Iris " + spec.key() + " release archive."); return new Archive(archivePath, updated.getProperty("sha256")); } close(response.body()); - IOException statusFailure = new IOException("Iris " + spec.key() + " beta download returned HTTP " + status); + IOException statusFailure = new IOException("Iris " + spec.key() + " release download returned HTTP " + status); if (!retryableStatus(status)) { networkFailure = statusFailure; break; @@ -464,7 +464,7 @@ public final class DefaultPackBootstrapProvisioner { networkFailure = statusFailure; } catch (InterruptedException exception) { Thread.currentThread().interrupt(); - throw new IOException("Iris " + spec.key() + " beta download was interrupted", exception); + throw new IOException("Iris " + spec.key() + " release download was interrupted", exception); } catch (IOException exception) { networkFailure = exception; } @@ -474,7 +474,7 @@ public final class DefaultPackBootstrapProvisioner { } if (cacheMatchesSource) { - feedback.accept("Iris " + spec.key() + " beta download failed; using the validated cached archive."); + feedback.accept("Iris " + spec.key() + " release download failed; using the validated cached archive."); return new Archive(archivePath, sha256(archivePath)); } if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) { @@ -482,8 +482,8 @@ public final class DefaultPackBootstrapProvisioner { return new Archive(null, sourceSha); } throw networkFailure == null - ? new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists") - : new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists", networkFailure); + ? new IOException("Iris " + spec.key() + " release archive is unavailable and no valid cache exists") + : new IOException("Iris " + spec.key() + " release archive is unavailable and no valid cache exists", networkFailure); } private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory, PackSpec spec) { @@ -514,11 +514,11 @@ public final class DefaultPackBootstrapProvisioner { while ((entry = zip.getNextEntry()) != null) { entries++; if (entries > MAX_ARCHIVE_ENTRIES) { - throw new IOException("Iris " + spec.key() + " beta archive contains too many entries"); + throw new IOException("Iris " + spec.key() + " release archive contains too many entries"); } String name = normalizedZipEntry(entry.getName()); if (!paths.add(name)) { - throw new IOException("Iris " + spec.key() + " beta archive contains duplicate path " + name); + throw new IOException("Iris " + spec.key() + " release archive contains duplicate path " + name); } String requiredDimension = "dimensions/" + spec.requiredDimension() + ".json"; if (name.equals(requiredDimension) || name.endsWith("/" + requiredDimension)) { @@ -530,7 +530,7 @@ public final class DefaultPackBootstrapProvisioner { while ((read = zip.read(buffer)) >= 0) { expanded += read; if (expanded > MAX_EXPANDED_BYTES) { - throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit"); + throw new IOException("Iris " + spec.key() + " release archive expands beyond the safety limit"); } } } @@ -542,7 +542,7 @@ public final class DefaultPackBootstrapProvisioner { private static Path extractArchive(Path archive, Path extractionRoot, PackSpec spec) throws IOException { if (archive == null) { - throw new IOException("Cached Iris " + spec.key() + " beta archive is unavailable for required pack rebuild"); + throw new IOException("Cached Iris " + spec.key() + " release archive is unavailable for required pack rebuild"); } long expanded = 0L; int entries = 0; @@ -551,12 +551,12 @@ public final class DefaultPackBootstrapProvisioner { while ((entry = zip.getNextEntry()) != null) { entries++; if (entries > MAX_ARCHIVE_ENTRIES) { - throw new IOException("Iris " + spec.key() + " beta archive contains too many entries"); + throw new IOException("Iris " + spec.key() + " release archive contains too many entries"); } String name = normalizedZipEntry(entry.getName()); Path output = extractionRoot.resolve(name).normalize(); if (!output.startsWith(extractionRoot)) { - throw new IOException("Unsafe path in Iris " + spec.key() + " beta archive: " + entry.getName()); + throw new IOException("Unsafe path in Iris " + spec.key() + " release archive: " + entry.getName()); } if (entry.isDirectory()) { Files.createDirectories(output); @@ -568,7 +568,7 @@ public final class DefaultPackBootstrapProvisioner { while ((read = zip.read(buffer)) >= 0) { expanded += read; if (expanded > MAX_EXPANDED_BYTES) { - throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit"); + throw new IOException("Iris " + spec.key() + " release archive expands beyond the safety limit"); } file.write(buffer, 0, read); } @@ -589,7 +589,7 @@ public final class DefaultPackBootstrapProvisioner { } } if (candidates.size() != 1) { - throw new IOException("Iris " + spec.key() + " beta archive has an invalid root layout"); + throw new IOException("Iris " + spec.key() + " release archive has an invalid root layout"); } return candidates.getFirst(); } @@ -613,7 +613,7 @@ public final class DefaultPackBootstrapProvisioner { private static void validatePackRoot(Path path, PackSpec spec) throws IOException { if (!isPackRoot(path, spec)) { - throw new IOException("Iris " + spec.key() + " beta pack is missing dimensions/" + throw new IOException("Iris " + spec.key() + " release pack is missing dimensions/" + spec.requiredDimension() + ".json at " + path); } } @@ -822,7 +822,7 @@ public final class DefaultPackBootstrapProvisioner { while ((read = input.read(buffer)) >= 0) { total += read; if (total > limit) { - throw new IOException("Iris " + spec.key() + " beta archive exceeds the download size limit"); + throw new IOException("Iris " + spec.key() + " release archive exceeds the download size limit"); } output.write(buffer, 0, read); } diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java index 72092c3a1..67512a170 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackDownloader.java @@ -63,10 +63,10 @@ import java.util.zip.ZipInputStream; public final class PackDownloader { private static final String DEFAULT_OVERWORLD_PACK = "overworld"; private static final String DEFAULT_OVERWORLD_URL = - "https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"; + "https://github.com/IrisDimensions/overworld/releases/download/4002/overworld.zip"; private static final String UNDERWORLD_PACK = "underworld"; private static final String UNDERWORLD_URL = - "https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"; + "https://github.com/IrisDimensions/underworld/releases/download/1005/underworld.zip"; private static final List BUILT_IN_PACK_KEYS = List.of(DEFAULT_OVERWORLD_PACK, UNDERWORLD_PACK); private static final Map BUILT_IN_PACK_URLS = Map.of( DEFAULT_OVERWORLD_PACK, DEFAULT_OVERWORLD_URL, diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java new file mode 100644 index 000000000..c6992b227 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java @@ -0,0 +1,1437 @@ +package art.arcane.iris.core.pack; + +import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; +import art.arcane.iris.engine.object.IrisCoastalRiverGrottoConfig; +import art.arcane.iris.engine.object.IrisDeepFluidConfig; +import art.arcane.iris.engine.object.IrisInlandRiverGrottoConfig; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.io.File; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +final class PackHydrologyValidator { + private static final int MAX_PROFILES = 64; + private static final int MAX_DEEP_FLUIDS = 64; + private static final int MAX_POLICY_REFERENCES = 128; + private static final long MAX_LATTICE_NODES = 65_536L; + private static final long MAX_ROUTE_SAMPLES = 262_144L; + private static final int MAX_REACHABLE_BIOMES = 65_536; + private static final IrisCoastalRiverGrottoConfig DEFAULT_COASTAL_GROTTO = + new IrisCoastalRiverGrottoConfig(); + private static final IrisInlandRiverGrottoConfig DEFAULT_INLAND_GROTTO = + new IrisInlandRiverGrottoConfig(); + private static final IrisDeepFluidConfig DEFAULT_DEEP_FLUID = new IrisDeepFluidConfig(); + private static final Pattern ID_PATTERN = Pattern.compile("[a-z0-9][a-z0-9_-]{0,63}"); + private static final Pattern RESOURCE_PATTERN = Pattern.compile("[a-z0-9][a-z0-9_./-]{0,127}"); + private static final Set PLACEMENT_MODES = Set.of( + "DISABLED", + "TRANSIT_ONLY", + "NATURAL", + "PREFERRED_HEADWATER", + "REQUIRED_HEADWATER" + ); + private static final Set ROUTING_MODES = Set.of("BLOCK", "AVOID", "ALLOW", "PREFER"); + private static final Set INLAND_OUTLETS = Set.of("SINKHOLE_GROTTO"); + private static final List POLICY_BIOME_FIELDS = List.of( + "surfaceBiomes", + "mouthBiomes", + "shoreBiomes", + "dryBiomes", + "floodedCaveBiomes" + ); + private static final List REGION_BIOME_FIELDS = List.of( + "landBiomes", + "seaBiomes", + "shoreBiomes", + "caveBiomes" + ); + + private PackHydrologyValidator() { + } + + static Validation validate(File packFolder, File[] dimensionFiles) { + List errors = new ArrayList<>(); + List warnings = new ArrayList<>(); + if (packFolder == null || !packFolder.isDirectory() || dimensionFiles == null) { + return new Validation(errors, warnings); + } + + Map regions = loadResources(new File(packFolder, "regions")); + Map biomes = loadResources(new File(packFolder, "biomes")); + Map imageMaps = loadResources(new File(packFolder, "image-maps")); + validateResourcePolicies("Region", regions, biomes, errors); + validateResourcePolicies("Biome", biomes, biomes, errors); + + List sortedDimensions = new ArrayList<>(List.of(dimensionFiles)); + sortedDimensions.sort(Comparator.comparing(File::getPath)); + for (File dimensionFile : sortedDimensions) { + JSONObject dimension = PackValidationIo.readJson(dimensionFile); + if (dimension == null) { + continue; + } + String dimensionKey = PackValidationIo.stripExtension(dimensionFile.getName()); + String dimensionPath = "Dimension '" + dimensionKey + "'"; + JSONObject dimensionPolicy = validatePolicy( + dimensionPath, + dimension, + "riverPolicy", + biomes, + errors + ); + HydrologyValidation hydrology = validateHydrology(dimensionPath, dimension, errors, warnings); + if (!hydrology.active()) { + continue; + } + validateMantleCapabilities(dimensionPath + " hydrology", dimension, errors); + if (hydrology.riversEnabled()) { + validateReachablePolicies( + dimensionKey, + dimension, + dimensionPolicy, + hydrology.profileIds(), + regions, + biomes, + imageMaps, + errors + ); + } + } + return new Validation(errors, warnings); + } + + private static HydrologyValidation validateHydrology( + String dimensionPath, + JSONObject dimension, + List errors, + List warnings + ) { + if (!dimension.has("hydrology")) { + return HydrologyValidation.inactive(); + } + JSONObject hydrology = requireObject(dimension, "hydrology", dimensionPath + ".hydrology", errors); + if (hydrology == null) { + return HydrologyValidation.inactive(); + } + + boolean riversEnabled = false; + Set profileIds = Set.of("default"); + if (hydrology.has("rivers")) { + JSONObject rivers = requireObject( + hydrology, + "rivers", + dimensionPath + ".hydrology.rivers", + errors + ); + if (rivers != null) { + riversEnabled = booleanValue(rivers, "enabled", false); + profileIds = validateRivers( + dimensionPath + ".hydrology.rivers", + rivers, + dimension, + riversEnabled, + errors, + warnings + ); + } + } + + boolean deepFluidsActive = validateDeepFluids( + dimensionPath + ".hydrology", + hydrology, + dimension, + profileIds, + errors + ); + return new HydrologyValidation(riversEnabled, deepFluidsActive, profileIds); + } + + private static Set validateRivers( + String path, + JSONObject rivers, + JSONObject dimension, + boolean enabled, + List errors, + List warnings + ) { + PackJsonFieldChecks.validateOptionalBoolean(path, rivers, "enabled", errors); + JSONObject routing = nestedObject(rivers, "routing", path, errors); + JSONObject geometry = nestedObject(rivers, "geometry", path, errors); + JSONObject surface = nestedObject(rivers, "surface", path, errors); + JSONObject underground = nestedObject(rivers, "underground", path, errors); + JSONObject grottos = nestedObject(rivers, "grottos", path, errors); + + RoutingValues routingValues = routing == null + ? RoutingValues.defaults() + : validateRouting(path + ".routing", routing, errors); + if (geometry != null) { + validateGeometry(path + ".geometry", geometry, errors); + } + SourceBudget surfaceBudget = surface == null + ? SourceBudget.defaults() + : validateSurface(path + ".surface", surface, dimension, routingValues, errors, warnings); + SourceBudget undergroundBudget = underground == null + ? SourceBudget.defaults() + : validateUnderground(path + ".underground", underground, dimension, errors, warnings); + if (grottos != null) { + validateGrottos(path + ".grottos", grottos, routingValues, errors); + } + + validateRouteComplexity(path, routingValues, surfaceBudget, undergroundBudget, errors); + Set profileIds = validateProfiles(path, rivers, errors); + if (enabled && profileIds.isEmpty()) { + errors.add(path + ".profiles must contain at least one valid river profile when rivers are enabled."); + } + return profileIds; + } + + private static void validateGeometry(String path, JSONObject geometry, List errors) { + JSONObject meanders = nestedObject(geometry, "meanders", path, errors); + JSONObject surface = nestedObject(geometry, "surface", path, errors); + JSONObject underground = nestedObject(geometry, "underground", path, errors); + JSONObject grottos = nestedObject(geometry, "grottos", path, errors); + JSONObject drops = nestedObject(geometry, "drops", path, errors); + if (meanders != null) { + validateMeanders(path + ".meanders", meanders, errors); + } + if (surface != null) { + validateChannelShape(path + ".surface", surface, errors); + } + if (underground != null) { + validateChannelShape(path + ".underground", underground, errors); + } + if (grottos != null) { + validateChannelShape(path + ".grottos", grottos, errors); + } + if (drops != null) { + validateDropShape(path + ".drops", drops, errors); + } + } + + private static void validateMeanders(String path, JSONObject meanders, List errors) { + PackJsonFieldChecks.validateOptionalIntegerRange(path, meanders, "primaryWavelength", 8, 512, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, meanders, "detailWavelength", 4, 128, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, meanders, "primaryStrength", 0D, 2D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, meanders, "detailStrength", 0D, 2D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, meanders, "maximumOffsetRatio", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, meanders, "smoothingPasses", 0, 4, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, meanders, "maximumTurnDegrees", 10D, 150D, errors); + if (integerValue(meanders, "detailWavelength", 12) + > integerValue(meanders, "primaryWavelength", 64)) { + errors.add(path + ".detailWavelength must not exceed primaryWavelength."); + } + } + + private static void validateChannelShape(String path, JSONObject shape, List errors) { + PackJsonFieldChecks.validateOptionalDoubleRange(path, shape, "bedRoundness", 1D, 6D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, shape, "bedRoughness", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, shape, "wallRoughness", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, shape, "roughnessWavelength", 3, 128, errors); + } + + private static void validateDropShape(String path, JSONObject drops, List errors) { + PackJsonFieldChecks.validateOptionalIntegerRange(path, drops, "cascadeRunPerBlock", 1, 16, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, drops, "cascadeExponent", 0.25D, 6D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, drops, "maximumCascadeStep", 1, 4, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, drops, "flowWidthRatio", 0.25D, 1D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, drops, "maximumFlowDepth", 1, 16, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, drops, "basinWidthRatio", 1D, 4D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, drops, "maximumBasinDepth", 1, 32, errors); + } + + private static RoutingValues validateRouting(String path, JSONObject routing, List errors) { + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "tileSize", 256, 8192, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "sampleSpacing", 8, 512, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "refinementSpacing", 1, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "maximumRouteLength", 256, 32768, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "maximumOutletsPerTile", 1, 256, errors); + PackJsonFieldChecks.validateOptionalBoolean(path, routing, "oceanOutlets", errors); + + JSONObject branching = nestedObject(routing, "branching", path, errors); + if (branching != null) { + PackJsonFieldChecks.validateOptionalIntegerRange( + path + ".branching", + branching, + "minimumSurfaceCourseLength", + 0, + 32768, + errors + ); + PackJsonFieldChecks.validateOptionalIntegerRange( + path + ".branching", + branching, + "minimumUndergroundCourseLength", + 0, + 32768, + errors + ); + } + + int tileSize = integerValue(routing, "tileSize", 2048); + int sampleSpacing = integerValue(routing, "sampleSpacing", 64); + int refinementSpacing = integerValue(routing, "refinementSpacing", 8); + int maximumRouteLength = integerValue(routing, "maximumRouteLength", 16384); + int minimumSurfaceCourseLength = branching == null + ? 384 + : integerValue(branching, "minimumSurfaceCourseLength", 384); + int minimumUndergroundCourseLength = branching == null + ? 192 + : integerValue(branching, "minimumUndergroundCourseLength", 192); + if (sampleSpacing > 0 && tileSize % sampleSpacing != 0) { + errors.add(path + ".tileSize must be divisible by sampleSpacing."); + } + if (refinementSpacing > 0 && sampleSpacing % refinementSpacing != 0) { + errors.add(path + ".sampleSpacing must be divisible by refinementSpacing."); + } + if (minimumSurfaceCourseLength > maximumRouteLength) { + errors.add(path + ".branching.minimumSurfaceCourseLength must not exceed maximumRouteLength."); + } + if (minimumUndergroundCourseLength > maximumRouteLength) { + errors.add(path + ".branching.minimumUndergroundCourseLength must not exceed maximumRouteLength."); + } + + Set inlandOutlets = validateEnumArray( + path, + routing, + "inlandOutlets", + INLAND_OUTLETS, + errors + ); + boolean oceanOutlets = booleanValue(routing, "oceanOutlets", true); + if (!oceanOutlets && inlandOutlets.isEmpty()) { + errors.add(path + " must enable oceanOutlets or select at least one inlandOutlets entry."); + } + return new RoutingValues( + tileSize, + sampleSpacing, + refinementSpacing, + maximumRouteLength, + inlandOutlets + ); + } + + private static SourceBudget validateSurface( + String path, + JSONObject surface, + JSONObject dimension, + RoutingValues routing, + List errors, + List warnings + ) { + PackJsonFieldChecks.validateOptionalBoolean(path, surface, "enabled", errors); + JSONObject sources = nestedObject(surface, "sources", path, errors); + JSONObject channel = nestedObject(surface, "channel", path, errors); + JSONObject hydraulics = nestedObject(surface, "hydraulics", path, errors); + JSONObject ridgeTunnels = nestedObject(surface, "ridgeTunnels", path, errors); + JSONObject mouths = nestedObject(surface, "mouths", path, errors); + + SourceBudget budget = sources == null + ? SourceBudget.defaults() + : validateSources(path + ".sources", sources, true, errors); + if (channel != null) { + validateSurfaceChannel(path + ".channel", channel, errors); + } + if (hydraulics != null) { + validateHydraulics(path + ".hydraulics", hydraulics, errors); + } + if (ridgeTunnels != null) { + validateRidgeTunnels(path + ".ridgeTunnels", ridgeTunnels, routing, errors); + } + if (mouths != null) { + validateMouths(path + ".mouths", mouths, routing, errors); + } + + boolean surfaceEnabled = booleanValue(surface, "enabled", true); + if (surfaceEnabled && budget.maximumExpectedSources() == 0) { + warnings.add(path + + " has no natural source budget; only qualifying REQUIRED_HEADWATER cells can admit sources."); + } + if (surfaceEnabled && sources != null) { + double minimumElevation = doubleValue(sources, "minimumElevation", 88D); + NumericRange dimensionHeight = dimensionHeight(dimension); + if (Double.isFinite(minimumElevation) && minimumElevation >= dimensionHeight.maximum()) { + errors.add(path + ".sources.minimumElevation must be below dimensionHeight.max."); + } + } + return surfaceEnabled ? budget : SourceBudget.disabled(); + } + + private static void validateSurfaceChannel(String path, JSONObject channel, List errors) { + validateRange(path, channel, "width", 1D, 64D, 4D, 12D, errors); + validateRange(path, channel, "depth", 1D, 32D, 1D, 4D, errors); + validateRange(path, channel, "surfaceInset", 1D, 64D, 3D, 7D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "maximumIncision", 0, 64, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, channel, "shoreWidth", 1D, 2D, errors); + NumericRange blend = validateRange( + path, + channel, + "terrainBlendWidth", + 4D, + 64D, + 10D, + 24D, + errors + ); + if (blend.minimum() < doubleValue(channel, "shoreWidth", 2D)) { + errors.add(path + ".terrainBlendWidth must remain independent of and outside shoreWidth."); + } + } + + private static void validateHydraulics(String path, JSONObject hydraulics, List errors) { + NumericRange targetPoolLength = validateRange( + path, + hydraulics, + "targetPoolLength", + 8D, + 4096D, + 80D, + 180D, + errors + ); + PackJsonFieldChecks.validateOptionalIntegerRange(path, hydraulics, "riffleDrop", 0, 16, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, hydraulics, "maximumGradualDrop", 0, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, hydraulics, "maximumGradualLength", 1, 1024, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, hydraulics, "waterfallMinimumDrop", 1, 128, errors); + + int riffleDrop = integerValue(hydraulics, "riffleDrop", 1); + int maximumGradualDrop = integerValue(hydraulics, "maximumGradualDrop", 7); + int maximumGradualLength = integerValue(hydraulics, "maximumGradualLength", 24); + int waterfallMinimumDrop = integerValue(hydraulics, "waterfallMinimumDrop", 8); + if (riffleDrop > maximumGradualDrop) { + errors.add(path + ".riffleDrop must not exceed maximumGradualDrop."); + } + if (waterfallMinimumDrop != maximumGradualDrop + 1) { + errors.add(path + ".waterfallMinimumDrop must equal maximumGradualDrop plus one."); + } + if (maximumGradualLength < maximumGradualDrop) { + errors.add(path + ".maximumGradualLength must be at least maximumGradualDrop."); + } + if (targetPoolLength.minimum() < maximumGradualLength) { + errors.add(path + ".targetPoolLength.min must be at least maximumGradualLength."); + } + } + + private static void validateRidgeTunnels( + String path, + JSONObject ridgeTunnels, + RoutingValues routing, + List errors + ) { + PackJsonFieldChecks.validateOptionalBoolean(path, ridgeTunnels, "enabled", errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, ridgeTunnels, "maximumLength", 1, 4096, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, ridgeTunnels, "headroom", 1, 128, errors); + int maximumLength = integerValue(ridgeTunnels, "maximumLength", 192); + if (maximumLength > routing.maximumRouteLength()) { + errors.add(path + ".maximumLength must not exceed routing.maximumRouteLength."); + } + } + + private static void validateMouths( + String path, + JSONObject mouths, + RoutingValues routing, + List errors + ) { + PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "levelingDistance", 0, 2048, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "maximumOceanApron", 0, 64, errors); + int levelingDistance = integerValue(mouths, "levelingDistance", 64); + if (levelingDistance > routing.maximumRouteLength()) { + errors.add(path + ".levelingDistance must not exceed routing.maximumRouteLength."); + } + } + + private static SourceBudget validateUnderground( + String path, + JSONObject underground, + JSONObject dimension, + List errors, + List warnings + ) { + PackJsonFieldChecks.validateOptionalBoolean(path, underground, "enabled", errors); + JSONObject sources = nestedObject(underground, "sources", path, errors); + SourceBudget budget = sources == null + ? SourceBudget.defaults() + : validateSources(path + ".sources", sources, false, errors); + NumericRange fluidLevel = validateRange( + path, + underground, + "fluidLevel", + -2048D, + 2048D, + -48D, + 50D, + errors + ); + validateRange(path, underground, "channelWidth", 1D, 64D, 4D, 10D, errors); + NumericRange depth = validateRange(path, underground, "depth", 1D, 32D, 1D, 3D, errors); + NumericRange headroom = validateRange(path, underground, "headroom", 1D, 128D, 6D, 14D, errors); + PackJsonFieldChecks.validateOptionalBoolean(path, underground, "connectToExistingCaves", errors); + + NumericRange height = dimensionHeight(dimension); + if (fluidLevel.minimum() - depth.maximum() <= height.minimum()) { + errors.add(path + " fluidLevel.min and depth.max must remain above dimensionHeight.min."); + } + if (fluidLevel.maximum() + headroom.maximum() >= height.maximum()) { + errors.add(path + " fluidLevel.max and headroom.max must remain below dimensionHeight.max."); + } + boolean undergroundEnabled = booleanValue(underground, "enabled", true); + if (undergroundEnabled && budget.maximumExpectedSources() == 0) { + warnings.add(path + + " has no natural source budget; only qualifying REQUIRED_HEADWATER cells can admit sources."); + } + return undergroundEnabled ? budget : SourceBudget.disabled(); + } + + private static SourceBudget validateSources( + String path, + JSONObject sources, + boolean surface, + List errors + ) { + PackJsonFieldChecks.validateOptionalDoubleRange(path, sources, "density", 0D, 64D, errors); + if (surface) { + PackJsonFieldChecks.validateOptionalIntegerRange( + path, + sources, + "minimumElevation", + -2048, + 2048, + errors + ); + } + PackJsonFieldChecks.validateOptionalIntegerRange(path, sources, "minimumPerTile", 0, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, sources, "minimumSpacing", 0, 8192, errors); + return new SourceBudget( + doubleValue(sources, "density", surface ? 0.5D : 0.25D), + integerValue(sources, "minimumPerTile", 0) + ); + } + + private static void validateGrottos( + String path, + JSONObject grottos, + RoutingValues routing, + List errors + ) { + JSONObject coastal = nestedObject(grottos, "coastal", path, errors); + JSONObject inland = nestedObject(grottos, "inland", path, errors); + if (coastal != null) { + validateGrotto(path + ".coastal", coastal, true, errors); + } + if (inland != null) { + validateGrotto(path + ".inland", inland, false, errors); + if (routing.inlandOutlets().contains("SINKHOLE_GROTTO") + && !booleanValue(inland, "enabled", true)) { + errors.add(path + ".inland must be enabled when routing.inlandOutlets selects SINKHOLE_GROTTO."); + } + if (booleanValue(inland, "connectSurfaceRivers", false) + && !booleanValue(inland, "enabled", true)) { + errors.add(path + ".inland.connectSurfaceRivers requires the inland grotto to be enabled."); + } + if (booleanValue(inland, "connectSurfaceRivers", false) + && !routing.inlandOutlets().contains("SINKHOLE_GROTTO")) { + errors.add(path + ".inland.connectSurfaceRivers requires routing.inlandOutlets to select SINKHOLE_GROTTO."); + } + } + } + + private static void validateGrotto( + String path, + JSONObject grotto, + boolean coastal, + List errors + ) { + PackJsonFieldChecks.validateOptionalBoolean(path, grotto, "enabled", errors); + if (!coastal) { + PackJsonFieldChecks.validateOptionalBoolean(path, grotto, "connectSurfaceRivers", errors); + } + PackJsonFieldChecks.validateOptionalIntegerRange(path, grotto, "horizontalRadius", 1, 128, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, grotto, "verticalRadius", 1, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, grotto, "headroom", 1, 63, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, grotto, "maximumVolume", 1, 1_048_576, errors); + if (coastal && grotto.has("poolLevel")) { + Object rawPoolLevel = grotto.opt("poolLevel"); + if (!(rawPoolLevel instanceof String poolLevel) || !"SEA_LEVEL".equals(poolLevel)) { + errors.add(path + ".poolLevel must be SEA_LEVEL."); + } + } + + int verticalRadius = integerValue( + grotto, + "verticalRadius", + coastal ? DEFAULT_COASTAL_GROTTO.getVerticalRadius() : DEFAULT_INLAND_GROTTO.getVerticalRadius() + ); + int headroom = integerValue( + grotto, + "headroom", + coastal ? DEFAULT_COASTAL_GROTTO.getHeadroom() : DEFAULT_INLAND_GROTTO.getHeadroom() + ); + if (headroom >= verticalRadius * 2 + 1) { + errors.add(path + ".headroom must fit inside the grotto height."); + } + } + + private static Set validateProfiles(String path, JSONObject rivers, List errors) { + if (!rivers.has("profiles")) { + return Set.of("default"); + } + Object rawProfiles = rivers.opt("profiles"); + if (!(rawProfiles instanceof JSONArray profiles)) { + errors.add(path + ".profiles must be an array."); + return Set.of(); + } + if (profiles.length() > MAX_PROFILES) { + errors.add(path + ".profiles must contain at most " + MAX_PROFILES + " entries."); + } + + Set profileIds = new LinkedHashSet<>(); + for (int index = 0; index < profiles.length(); index++) { + String profilePath = path + ".profiles[" + index + "]"; + JSONObject profile = profiles.optJSONObject(index); + if (profile == null) { + errors.add(profilePath + " must be an object."); + continue; + } + String id = requiredId(profilePath, profile, errors); + if (id != null && !profileIds.add(id)) { + errors.add(profilePath + ".id must be unique inside hydrology.rivers.profiles."); + } + validateFluidPalette(profilePath, profile, true, errors); + } + return Set.copyOf(profileIds); + } + + private static boolean validateDeepFluids( + String path, + JSONObject hydrology, + JSONObject dimension, + Set riverProfileIds, + List errors + ) { + if (!hydrology.has("deepFluids")) { + return false; + } + Object rawDeepFluids = hydrology.opt("deepFluids"); + if (!(rawDeepFluids instanceof JSONArray deepFluids)) { + errors.add(path + ".deepFluids must be an array."); + return false; + } + if (deepFluids.length() > MAX_DEEP_FLUIDS) { + errors.add(path + ".deepFluids must contain at most " + MAX_DEEP_FLUIDS + " entries."); + } + + boolean active = false; + Set ids = new HashSet<>(); + for (int index = 0; index < deepFluids.length(); index++) { + String fluidPath = path + ".deepFluids[" + index + "]"; + JSONObject deepFluid = deepFluids.optJSONObject(index); + if (deepFluid == null) { + errors.add(fluidPath + " must be an object."); + continue; + } + String id = requiredId(fluidPath, deepFluid, errors); + if (id != null && !ids.add(id)) { + errors.add(fluidPath + ".id must be unique inside hydrology.deepFluids."); + } + if (HydrologyFeatureQuery.isReservedKeyword(id)) { + errors.add(fluidPath + ".id must not use a reserved hydrology feature keyword."); + } + if (id != null && riverProfileIds.contains(id)) { + errors.add(fluidPath + ".id must not duplicate a hydrology.rivers profile id."); + } + validateFluidPalette(fluidPath, deepFluid, true, errors); + NumericRange configuredHeight = validateRange( + fluidPath, + deepFluid, + "height", + -2048D, + 2048D, + -192D, + 32D, + errors + ); + PackJsonFieldChecks.validateOptionalDoubleRange(fluidPath, deepFluid, "density", 0D, 64D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(fluidPath, deepFluid, "spacing", 16, 8192, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(fluidPath, deepFluid, "horizontalRadius", 2, 128, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(fluidPath, deepFluid, "verticalRadius", 2, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(fluidPath, deepFluid, "channelWidth", 1, 32, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(fluidPath, deepFluid, "depth", 1, 32, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(fluidPath, deepFluid, "headroom", 1, 63, errors); + PackJsonFieldChecks.validateOptionalBoolean(fluidPath, deepFluid, "containedPools", errors); + PackJsonFieldChecks.validateOptionalBoolean(fluidPath, deepFluid, "shortChannels", errors); + + int spacing = integerValue(deepFluid, "spacing", DEFAULT_DEEP_FLUID.getSpacing()); + int horizontalRadius = integerValue( + deepFluid, + "horizontalRadius", + DEFAULT_DEEP_FLUID.getHorizontalRadius() + ); + int verticalRadius = integerValue( + deepFluid, + "verticalRadius", + DEFAULT_DEEP_FLUID.getVerticalRadius() + ); + int channelWidth = integerValue(deepFluid, "channelWidth", DEFAULT_DEEP_FLUID.getChannelWidth()); + int depth = integerValue(deepFluid, "depth", DEFAULT_DEEP_FLUID.getDepth()); + int headroom = integerValue(deepFluid, "headroom", DEFAULT_DEEP_FLUID.getHeadroom()); + boolean containedPools = booleanValue( + deepFluid, + "containedPools", + DEFAULT_DEEP_FLUID.isContainedPools() + ); + boolean shortChannels = booleanValue( + deepFluid, + "shortChannels", + DEFAULT_DEEP_FLUID.isShortChannels() + ); + int footprintDiameter = (horizontalRadius + channelWidth) * 2; + if (spacing < footprintDiameter) { + errors.add(fluidPath + ".spacing must be at least " + footprintDiameter + + " to separate complete deep-fluid footprints."); + } + if (depth + headroom >= verticalRadius * 2 + 1) { + errors.add(fluidPath + ".depth and headroom must fit inside verticalRadius."); + } + NumericRange dimensionRange = dimensionHeight(dimension); + int lowerEnvelope = containedPools ? Math.max(depth, verticalRadius) : depth; + if (configuredHeight.minimum() - lowerEnvelope <= dimensionRange.minimum()) { + errors.add(fluidPath + + " height.min and lower footprint envelope must remain above dimensionHeight.min."); + } + if (configuredHeight.maximum() + headroom >= dimensionRange.maximum()) { + errors.add(fluidPath + " height.max and headroom must remain below dimensionHeight.max."); + } + if (doubleValue(deepFluid, "density", DEFAULT_DEEP_FLUID.getDensity()) > 0D + && (containedPools || shortChannels)) { + active = true; + } + } + return active; + } + + private static void validateRouteComplexity( + String path, + RoutingValues routing, + SourceBudget surface, + SourceBudget underground, + List errors + ) { + if (routing.sampleSpacing() <= 0 || routing.refinementSpacing() <= 0) { + return; + } + long latticeAxis = routing.tileSize() / (long) routing.sampleSpacing() + 1L; + long latticeNodes = latticeAxis * latticeAxis; + if (latticeNodes > MAX_LATTICE_NODES) { + errors.add(path + ".routing produces " + latticeNodes + + " coarse lattice nodes, above the safe limit of " + MAX_LATTICE_NODES + "."); + } + + long sources = surface.maximumExpectedSources() + underground.maximumExpectedSources(); + long samplesPerRoute = divideCeiling(routing.maximumRouteLength(), routing.refinementSpacing()) + 1L; + long routeSamples = saturatedMultiply(sources, samplesPerRoute); + if (routeSamples > MAX_ROUTE_SAMPLES) { + errors.add(path + " may refine " + routeSamples + + " route samples per tile, above the safe limit of " + MAX_ROUTE_SAMPLES + "."); + } + } + + private static void validateMantleCapabilities( + String path, + JSONObject dimension, + List errors + ) { + if (!booleanValue(dimension, "useMantle", true)) { + errors.add(path + " requires useMantle to be true."); + } + if (!booleanValue(dimension, "carvingEnabled", true)) { + errors.add(path + " requires carvingEnabled to be true."); + } + if (disabled(dimension, "CARVED")) { + errors.add(path + " requires CARVED to remain enabled."); + } + if (disabled(dimension, "RIVER_HYDROLOGY")) { + errors.add(path + " requires RIVER_HYDROLOGY to remain enabled."); + } + } + + private static void validateResourcePolicies( + String resourceType, + Map resources, + Map biomes, + List errors + ) { + for (Map.Entry entry : resources.entrySet()) { + validatePolicy( + resourceType + " '" + entry.getKey() + "'", + entry.getValue(), + "riverPolicy", + biomes, + errors + ); + } + } + + private static JSONObject validatePolicy( + String ownerPath, + JSONObject owner, + String field, + Map biomes, + List errors + ) { + if (!owner.has(field) || owner.opt(field) == JSONObject.NULL) { + return null; + } + Object rawPolicy = owner.opt(field); + String path = ownerPath + "." + field; + if (!(rawPolicy instanceof JSONObject policy)) { + errors.add(path + " must be an object or null."); + return null; + } + + validateNullableEnum(path, policy, "placement", PLACEMENT_MODES, errors); + validateNullableEnum(path, policy, "routing", ROUTING_MODES, errors); + validateNullableBoolean(path, policy, "outletAdmission", errors); + validatePolicyProfiles(path, policy, errors); + for (String biomeField : POLICY_BIOME_FIELDS) { + validatePolicyBiomes(path, policy, biomeField, biomes, errors); + } + validateNullableDouble(path, policy, "widthMultiplier", 0.0001D, 16D, errors); + validateNullableDouble(path, policy, "depthMultiplier", 0.0001D, 16D, errors); + validateNullableDouble(path, policy, "incisionMultiplier", 0D, 16D, errors); + validateNullableDouble(path, policy, "routingMultiplier", 0D, 64D, errors); + return policy; + } + + private static void validateReachablePolicies( + String dimensionKey, + JSONObject dimension, + JSONObject dimensionPolicy, + Set profiles, + Map regions, + Map biomes, + Map imageMaps, + List errors + ) { + String dimensionPath = "Dimension '" + dimensionKey + "'"; + validatePolicyProfilesInDimension( + dimensionPath + ".riverPolicy", + dimensionPolicy, + dimensionKey, + profiles, + errors + ); + + Deque pending = new ArrayDeque<>(); + addPolicyBiomeReferences(pending, dimensionPolicy); + JSONArray regionKeys = dimension.optJSONArray("regions"); + if (regionKeys != null) { + for (int index = 0; index < regionKeys.length(); index++) { + String regionKey = regionKeys.optString(index, null); + JSONObject region = regionKey == null ? null : regions.get(regionKey); + if (region == null) { + continue; + } + JSONObject policy = region.optJSONObject("riverPolicy"); + validatePolicyProfilesInDimension( + "Region '" + regionKey + "'.riverPolicy", + policy, + dimensionKey, + profiles, + errors + ); + addPolicyBiomeReferences(pending, policy); + for (String biomeField : REGION_BIOME_FIELDS) { + addStringArray(pending, region.optJSONArray(biomeField)); + } + } + } + addDimensionBiomeImageMapTargets(pending, dimension, imageMaps); + addDimensionCarvingBiomes(pending, dimension); + + Set visited = new HashSet<>(); + while (!pending.isEmpty()) { + String biomeKey = pending.removeFirst(); + if (!visited.add(biomeKey)) { + continue; + } + if (visited.size() > MAX_REACHABLE_BIOMES) { + errors.add(dimensionPath + " hydrology reachable biome closure exceeds " + + MAX_REACHABLE_BIOMES + " entries."); + return; + } + JSONObject biome = biomes.get(biomeKey); + if (biome == null) { + continue; + } + JSONObject policy = biome.optJSONObject("riverPolicy"); + validatePolicyProfilesInDimension( + "Biome '" + biomeKey + "'.riverPolicy", + policy, + dimensionKey, + profiles, + errors + ); + addPolicyBiomeReferences(pending, policy); + addStringArray(pending, biome.optJSONArray("children")); + addBiomeReference(pending, biome.optString("carvingBiome", null)); + addFloatingChildren(pending, biome.optJSONArray("floatingChildBiomes")); + } + } + + private static void validatePolicyProfilesInDimension( + String path, + JSONObject policy, + String dimensionKey, + Set profiles, + List errors + ) { + if (policy == null) { + return; + } + JSONArray references = policy.optJSONArray("profiles"); + if (references == null) { + return; + } + for (int index = 0; index < references.length(); index++) { + String profile = references.optString(index, null); + if (profile != null && ID_PATTERN.matcher(profile).matches() && !profiles.contains(profile)) { + addDistinct(errors, path + ".profiles[" + index + "] references unknown river profile '" + + profile + "' in Dimension '" + dimensionKey + "'."); + } + } + } + + private static void validatePolicyProfiles(String path, JSONObject policy, List errors) { + if (!policy.has("profiles") || policy.opt("profiles") == JSONObject.NULL) { + return; + } + Object rawProfiles = policy.opt("profiles"); + if (!(rawProfiles instanceof JSONArray profiles)) { + errors.add(path + ".profiles must be an array or null."); + return; + } + if (profiles.length() > MAX_POLICY_REFERENCES) { + errors.add(path + ".profiles must contain at most " + MAX_POLICY_REFERENCES + " entries."); + } + Set seen = new HashSet<>(); + for (int index = 0; index < profiles.length(); index++) { + Object rawProfile = profiles.opt(index); + String referencePath = path + ".profiles[" + index + "]"; + if (!(rawProfile instanceof String profile) || !ID_PATTERN.matcher(profile).matches()) { + errors.add(referencePath + + " must use 1 to 64 lowercase letters, digits, underscores, or hyphens."); + continue; + } + if (!seen.add(profile)) { + errors.add(referencePath + " duplicates river profile '" + profile + "'."); + } + } + } + + private static void validatePolicyBiomes( + String path, + JSONObject policy, + String field, + Map biomes, + List errors + ) { + if (!policy.has(field) || policy.opt(field) == JSONObject.NULL) { + return; + } + Object rawBiomes = policy.opt(field); + if (!(rawBiomes instanceof JSONArray references)) { + errors.add(path + "." + field + " must be an array or null."); + return; + } + if (references.length() > MAX_POLICY_REFERENCES) { + errors.add(path + "." + field + " must contain at most " + + MAX_POLICY_REFERENCES + " entries."); + } + Set seen = new HashSet<>(); + for (int index = 0; index < references.length(); index++) { + Object rawReference = references.opt(index); + String referencePath = path + "." + field + "[" + index + "]"; + if (!(rawReference instanceof String biomeKey) || !validResourceKey(biomeKey)) { + errors.add(referencePath + " must name a biome resource."); + continue; + } + if (!seen.add(biomeKey)) { + errors.add(referencePath + " duplicates biome '" + biomeKey + "'."); + } + if (!biomes.containsKey(biomeKey)) { + errors.add(referencePath + " references missing biome '" + biomeKey + "'."); + } + } + } + + private static NumericRange validateRange( + String path, + JSONObject owner, + String field, + double minimumAllowed, + double maximumAllowed, + double defaultMinimum, + double defaultMaximum, + List errors + ) { + if (!owner.has(field)) { + return NumericRange.of(defaultMinimum, defaultMaximum); + } + Object rawRange = owner.opt(field); + String rangePath = path + "." + field; + if (!(rawRange instanceof JSONObject range)) { + errors.add(rangePath + " must be an object."); + return NumericRange.of(defaultMinimum, defaultMaximum); + } + PackJsonFieldChecks.validateOptionalDoubleRange( + rangePath, + range, + "min", + minimumAllowed, + maximumAllowed, + errors + ); + PackJsonFieldChecks.validateOptionalDoubleRange( + rangePath, + range, + "max", + minimumAllowed, + maximumAllowed, + errors + ); + validateRangeStyle(rangePath, range, errors); + double minimum = doubleValue(range, "min", defaultMinimum); + double maximum = doubleValue(range, "max", defaultMaximum); + if (Double.isFinite(minimum) && Double.isFinite(maximum) && minimum > maximum) { + errors.add(rangePath + ".min must not exceed max."); + } + return NumericRange.of(minimum, maximum); + } + + private static void validateRangeStyle(String path, JSONObject range, List errors) { + if (!range.has("style") || range.opt("style") == JSONObject.NULL) { + return; + } + Object style = range.opt("style"); + if (style instanceof JSONObject) { + return; + } + if (style instanceof String reference && !reference.isBlank()) { + return; + } + errors.add(path + ".style must be an object or snippet reference."); + } + + private static void validateFluidPalette( + String path, + JSONObject owner, + boolean required, + List errors + ) { + if (!owner.has("fluidPalette")) { + if (required) { + errors.add(path + ".fluidPalette must be configured."); + } + return; + } + Object rawPalette = owner.opt("fluidPalette"); + if (!(rawPalette instanceof JSONObject palette)) { + errors.add(path + ".fluidPalette must be an object."); + return; + } + Object rawEntries = palette.opt("palette"); + if (!(rawEntries instanceof JSONArray entries) || entries.length() == 0) { + errors.add(path + ".fluidPalette.palette must contain at least one fluid block."); + return; + } + if (entries.length() > 64) { + errors.add(path + ".fluidPalette.palette must contain at most 64 entries."); + } + for (int index = 0; index < entries.length(); index++) { + String entryPath = path + ".fluidPalette.palette[" + index + "]"; + JSONObject entry = entries.optJSONObject(index); + if (entry == null) { + errors.add(entryPath + " must be an object."); + continue; + } + Object rawBlock = entry.opt("block"); + if (!(rawBlock instanceof String block) || block.isBlank()) { + errors.add(entryPath + ".block must name a fluid block."); + } else if (definitelyNotFluid(block)) { + errors.add(entryPath + ".block may contain only fluid blocks."); + } + PackJsonFieldChecks.validateOptionalIntegerRange(entryPath, entry, "weight", 1, 64, errors); + } + } + + private static Set validateEnumArray( + String path, + JSONObject owner, + String field, + Set allowed, + List errors + ) { + if (!owner.has(field)) { + return Set.of(); + } + Object rawEntries = owner.opt(field); + if (!(rawEntries instanceof JSONArray entries)) { + errors.add(path + "." + field + " must be an array."); + return Set.of(); + } + Set values = new LinkedHashSet<>(); + for (int index = 0; index < entries.length(); index++) { + Object rawEntry = entries.opt(index); + String entryPath = path + "." + field + "[" + index + "]"; + if (!(rawEntry instanceof String value) || !allowed.contains(value)) { + errors.add(entryPath + " must be one of " + allowed + "."); + continue; + } + if (!values.add(value)) { + errors.add(entryPath + " duplicates outlet '" + value + "'."); + } + } + return Set.copyOf(values); + } + + private static String requiredId(String path, JSONObject object, List errors) { + Object rawId = object.opt("id"); + if (!(rawId instanceof String id) || !ID_PATTERN.matcher(id).matches()) { + errors.add(path + ".id must use 1 to 64 lowercase letters, digits, underscores, or hyphens."); + return null; + } + return id; + } + + private static JSONObject nestedObject( + JSONObject owner, + String field, + String path, + List errors + ) { + if (!owner.has(field)) { + return new JSONObject(); + } + return requireObject(owner, field, path + "." + field, errors); + } + + private static JSONObject requireObject( + JSONObject owner, + String field, + String path, + List errors + ) { + Object rawObject = owner.opt(field); + if (!(rawObject instanceof JSONObject object)) { + errors.add(path + " must be an object."); + return null; + } + return object; + } + + private static void validateNullableEnum( + String path, + JSONObject object, + String field, + Set values, + List errors + ) { + if (!object.has(field) || object.opt(field) == JSONObject.NULL) { + return; + } + Object rawValue = object.opt(field); + if (!(rawValue instanceof String value) || !values.contains(value)) { + errors.add(path + "." + field + " must be one of " + values + " or null."); + } + } + + private static void validateNullableBoolean( + String path, + JSONObject object, + String field, + List errors + ) { + if (!object.has(field) || object.opt(field) == JSONObject.NULL) { + return; + } + if (!(object.opt(field) instanceof Boolean)) { + errors.add(path + "." + field + " must be a boolean or null."); + } + } + + private static void validateNullableDouble( + String path, + JSONObject object, + String field, + double minimum, + double maximum, + List errors + ) { + if (!object.has(field) || object.opt(field) == JSONObject.NULL) { + return; + } + PackJsonFieldChecks.validateOptionalDoubleRange(path, object, field, minimum, maximum, errors); + } + + private static Map loadResources(File folder) { + Map resources = new LinkedHashMap<>(); + if (!folder.isDirectory()) { + return resources; + } + List files = PackValidationIo.listJsonRecursive(folder); + files.sort(Comparator.comparing(File::getPath)); + for (File file : files) { + String key = PackValidationIo.deriveKey(folder, file); + JSONObject resource = PackValidationIo.readJson(file); + if (key != null && resource != null) { + resources.put(key, resource); + } + } + return resources; + } + + private static void addPolicyBiomeReferences(Deque pending, JSONObject policy) { + if (policy == null) { + return; + } + for (String field : POLICY_BIOME_FIELDS) { + addStringArray(pending, policy.optJSONArray(field)); + } + } + + private static void addStringArray(Deque pending, JSONArray values) { + if (values == null) { + return; + } + for (int index = 0; index < values.length(); index++) { + addBiomeReference(pending, values.optString(index, null)); + } + } + + private static void addBiomeReference(Deque pending, String biomeKey) { + if (biomeKey != null && !biomeKey.isBlank() && !"NONE".equalsIgnoreCase(biomeKey)) { + pending.addLast(biomeKey); + } + } + + private static void addDimensionCarvingBiomes(Deque pending, JSONObject dimension) { + JSONArray carving = dimension.optJSONArray("carving"); + if (carving == null) { + return; + } + for (int index = 0; index < carving.length(); index++) { + JSONObject entry = carving.optJSONObject(index); + if (entry != null && booleanValue(entry, "enabled", true)) { + addBiomeReference(pending, entry.optString("biome", null)); + } + } + } + + private static void addDimensionBiomeImageMapTargets( + Deque pending, + JSONObject dimension, + Map imageMaps + ) { + JSONArray bindings = dimension.optJSONArray("imageMaps"); + if (bindings == null) { + return; + } + for (int index = 0; index < bindings.length(); index++) { + JSONObject binding = bindings.optJSONObject(index); + if (binding == null || !"BIOME".equals(binding.optString("application", null))) { + continue; + } + String mapKey = binding.optString("map", null); + JSONObject imageMap = mapKey == null ? null : imageMaps.get(mapKey); + if (imageMap == null) { + continue; + } + JSONObject colors = imageMap.optJSONObject("colors"); + if (colors != null) { + for (String color : colors.keySet()) { + Object rawTarget = colors.opt(color); + if (rawTarget instanceof String target) { + addImageMapBiomeReference(pending, target); + } + } + } + Object rawFallback = imageMap.opt("fallbackTarget"); + if (rawFallback instanceof String fallbackTarget) { + addImageMapBiomeReference(pending, fallbackTarget); + } + } + } + + private static void addImageMapBiomeReference(Deque pending, String target) { + addBiomeReference(pending, target.startsWith("iris:") ? target.substring("iris:".length()) : target); + } + + private static void addFloatingChildren(Deque pending, JSONArray floatingChildren) { + if (floatingChildren == null) { + return; + } + for (int index = 0; index < floatingChildren.length(); index++) { + JSONObject floatingChild = floatingChildren.optJSONObject(index); + if (floatingChild != null) { + addBiomeReference(pending, floatingChild.optString("biome", null)); + } + } + } + + private static NumericRange dimensionHeight(JSONObject dimension) { + JSONObject height = dimension.optJSONObject("dimensionHeight"); + if (height == null) { + return NumericRange.of(-64D, 320D); + } + return NumericRange.of( + doubleValue(height, "min", -64D), + doubleValue(height, "max", 320D) + ); + } + + private static boolean disabled(JSONObject dimension, String flag) { + JSONArray disabled = dimension.optJSONArray("disabledComponents"); + if (disabled == null) { + return false; + } + for (int index = 0; index < disabled.length(); index++) { + if (flag.equals(disabled.optString(index, null))) { + return true; + } + } + return false; + } + + private static boolean validResourceKey(String resourceKey) { + return RESOURCE_PATTERN.matcher(resourceKey).matches() + && !resourceKey.contains("..") + && !resourceKey.startsWith("/") + && !resourceKey.endsWith("/"); + } + + private static boolean definitelyNotFluid(String block) { + String normalized = block.trim().toLowerCase(Locale.ROOT); + int stateStart = normalized.indexOf('['); + if (stateStart >= 0) { + normalized = normalized.substring(0, stateStart); + } + int namespaceSeparator = normalized.indexOf(':'); + String namespace = namespaceSeparator < 0 ? "minecraft" : normalized.substring(0, namespaceSeparator); + String key = namespaceSeparator < 0 ? normalized : normalized.substring(namespaceSeparator + 1); + return "minecraft".equals(namespace) + && !"water".equals(key) + && !"lava".equals(key) + && !"bubble_column".equals(key); + } + + private static boolean booleanValue(JSONObject object, String field, boolean defaultValue) { + Object rawValue = object.opt(field); + return rawValue instanceof Boolean value ? value : defaultValue; + } + + private static int integerValue(JSONObject object, String field, int defaultValue) { + Object rawValue = object.opt(field); + if (!(rawValue instanceof Number number) + || !Double.isFinite(number.doubleValue()) + || number.doubleValue() != StrictMath.rint(number.doubleValue()) + || number.longValue() < Integer.MIN_VALUE + || number.longValue() > Integer.MAX_VALUE) { + return defaultValue; + } + return number.intValue(); + } + + private static double doubleValue(JSONObject object, String field, double defaultValue) { + Object rawValue = object.opt(field); + return rawValue instanceof Number number && Double.isFinite(number.doubleValue()) + ? number.doubleValue() + : defaultValue; + } + + private static long divideCeiling(long dividend, long divisor) { + return dividend / divisor + (dividend % divisor == 0L ? 0L : 1L); + } + + private static long saturatedMultiply(long left, long right) { + if (left == 0L || right == 0L) { + return 0L; + } + if (left > Long.MAX_VALUE / right) { + return Long.MAX_VALUE; + } + return left * right; + } + + private static void addDistinct(List destination, String value) { + if (!destination.contains(value)) { + destination.add(value); + } + } + + record Validation(List errors, List warnings) { + Validation { + errors = List.copyOf(errors); + warnings = List.copyOf(warnings); + } + } + + private record HydrologyValidation( + boolean riversEnabled, + boolean deepFluidsActive, + Set profileIds + ) { + private static HydrologyValidation inactive() { + return new HydrologyValidation(false, false, Set.of()); + } + + private boolean active() { + return riversEnabled || deepFluidsActive; + } + } + + private record RoutingValues( + int tileSize, + int sampleSpacing, + int refinementSpacing, + int maximumRouteLength, + Set inlandOutlets + ) { + private static RoutingValues defaults() { + return new RoutingValues(2048, 64, 8, 16384, Set.of()); + } + } + + private record SourceBudget(double density, int minimumPerTile) { + private static SourceBudget defaults() { + return new SourceBudget(0.5D, 0); + } + + private static SourceBudget disabled() { + return new SourceBudget(0D, 0); + } + + private long maximumExpectedSources() { + if (!Double.isFinite(density) || density <= 0D) { + return Math.max(0, minimumPerTile); + } + return Math.max(Math.max(0, minimumPerTile), (long) StrictMath.ceil(density)); + } + } + + private record NumericRange(double minimum, double maximum) { + private static NumericRange of(double minimum, double maximum) { + return new NumericRange(minimum, maximum); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java deleted file mode 100644 index 50ef7899f..000000000 --- a/core/src/main/java/art/arcane/iris/core/pack/PackRiverValidator.java +++ /dev/null @@ -1,1300 +0,0 @@ -package art.arcane.iris.core.pack; - -import art.arcane.iris.engine.object.NoiseStyle; -import art.arcane.iris.engine.river.RiverTopologyComplexity; -import art.arcane.volmlib.util.json.JSONArray; -import art.arcane.volmlib.util.json.JSONObject; - -import java.io.File; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -final class PackRiverValidator { - private static final Set WATER_MODES = Set.of("FIXED", "TERRACED"); - private static final Set TERMINAL_MODES = Set.of("SUPPRESS", "DRY_CHANNEL", "SINKHOLE_GROTTO"); - private static final Set ROUTING_POLICIES = Set.of("ALLOW", "AVOID", "BLOCK"); - private static final Set CAVE_MODES = Set.of( - "SEALED", - "FLOOD_CLOSED_COMPONENT", - "GENERATE_GROTTO", - "GROTTO_OR_CLOSED_COMPONENT", - "WATERFALL_POOL" - ); - private static final Set CAVE_FALLBACKS = Set.of("SEALED", "GENERATE_GROTTO"); - private static final Set EXISTING_FLUID_POLICIES = Set.of("REJECT", "ALLOW_SAME", "REPLACE"); - private static final Set UNSAFE_RIVER_STREAMS = Set.of("HEIGHT", "HEIGHT_OR_FLUID", "SLOPE"); - private static final Set NOISE_STYLES = noiseStyles(); - - private PackRiverValidator() { - } - - static Validation validate(File packFolder, File[] dimensionFiles) { - List errors = new ArrayList<>(); - List warnings = new ArrayList<>(); - if (packFolder == null || !packFolder.isDirectory() || dimensionFiles == null) { - return new Validation(errors, warnings); - } - - boolean enabled = false; - List contexts = new ArrayList<>(); - List sortedDimensions = new ArrayList<>(List.of(dimensionFiles)); - sortedDimensions.sort(Comparator.comparing(File::getPath)); - for (File dimensionFile : sortedDimensions) { - JSONObject dimension = PackValidationIo.readJson(dimensionFile); - if (dimension == null || !dimension.has("rivers")) { - continue; - } - String dimensionKey = PackValidationIo.stripExtension(dimensionFile.getName()); - String path = "Dimension '" + dimensionKey + "' rivers"; - JSONObject rivers = requireObject(dimension, "rivers", path, errors); - if (rivers == null) { - continue; - } - PackJsonFieldChecks.validateOptionalBoolean(path, rivers, "enabled", errors); - if (!booleanValue(rivers, "enabled", false)) { - continue; - } - enabled = true; - DimensionRiverContext context = new DimensionRiverContext( - dimensionKey, - dimension, - rivers, - referencedKeys(dimension.optJSONArray("regions")) - ); - contexts.add(context); - validateNetwork(packFolder, path, context, errors, warnings); - } - - if (enabled) { - validateOverrides(packFolder, new File(packFolder, "regions"), "Region", contexts, errors, warnings); - validateOverrides(packFolder, new File(packFolder, "biomes"), "Biome", contexts, errors, warnings); - } - return new Validation(errors, warnings); - } - - private static void validateNetwork(File packFolder, String path, DimensionRiverContext context, - List errors, List warnings) { - JSONObject rivers = context.rivers(); - JSONObject topology = nestedObject(rivers, "topology", path, errors); - JSONObject terrain = nestedObject(rivers, "terrain", path, errors); - JSONObject water = nestedObject(rivers, "water", path, errors); - JSONObject biomes = nestedObject(rivers, "biomes", path, errors); - JSONObject caves = nestedObject(rivers, "caves", path, errors); - - if (topology != null) { - validateTopology(packFolder, path + ".topology", topology, errors, warnings); - } - if (terrain != null) { - validateTerrain(packFolder, path + ".terrain", terrain, errors, warnings); - } - if (water != null) { - validateWater(path + ".water", water, context.dimension(), errors); - } - if (biomes != null) { - validateBiomePools( - packFolder, - path + ".biomes", - biomes, - false, - usesOverworldNativeStructureRoles(context), - errors, - warnings - ); - } - boolean sinkholeTerminal = terrain != null - && "SINKHOLE_GROTTO".equals(stringValue(terrain, "terminalMode", "DRY_CHANNEL")); - if (caves != null) { - validateCaves( - packFolder, - path + ".caves", - caves, - context.dimension(), - sinkholeTerminal, - errors, - warnings - ); - } - - if (topology != null && terrain != null) { - WormEnvelope wormEnvelope = wormEnvelope(terrain); - int cellSize = integerValue(topology, "cellSize", 512); - if (wormEnvelope.maximumOffset() > cellSize) { - warnings.add(path + ".terrain.worms maxOffset exceeds topology.cellSize; reaches may require large cache halos."); - } - validateTopologyComplexity(packFolder, path, topology, terrain, errors); - } - if (sinkholeTerminal && caves != null) { - validateSinkholeCapability( - path + ".terrain.terminalMode", - context, - caves, - path + ".caves", - errors - ); - } - } - - private static void validateTopology(File packFolder, String path, JSONObject topology, - List errors, List warnings) { - PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "cellSize", 64, 4096, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "tileCells", 1, 64, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "siteJitter", 0D, 0.49D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "maxRouteReaches", 1, 256, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "minimumSourcesPerTile", 0, 64, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "sinkSearchReaches", 0, 7, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "routingBasinCells", 8, 256, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, topology, "routingDeviationScaleCells", 8, 256, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "routingDeviationStrengthCells", 0D, 32D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "routingPlateauHeight", 1D, 64D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "routingNoiseWeight", 0D, 1024D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "flowAlignmentWeight", 0D, 1024D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "confluenceWeight", 0D, 1024D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "terrainHeightWeight", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "terrainSlopeWeight", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, topology, "oceanAttraction", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalBoolean(path, topology, "requireOcean", errors); - validateNoiseChance(packFolder, topology, "source", path, errors); - validateNoiseChance(packFolder, topology, "continuation", path, errors); - validateStyle(packFolder, topology, "routingStyle", path, errors); - - int tileCells = integerValue(topology, "tileCells", 4); - int minimumSourcesPerTile = integerValue(topology, "minimumSourcesPerTile", 0); - if (tileCells >= 1 && tileCells <= 64 - && minimumSourcesPerTile >= 0 - && minimumSourcesPerTile > tileCells * tileCells) { - errors.add(path + ".minimumSourcesPerTile must not exceed tileCells squared."); - } - - } - - private static void validateTerrain(File packFolder, String path, JSONObject terrain, - List errors, List warnings) { - validateStyledRange(packFolder, terrain, "channelWidth", path, 1D, 2048D, errors, warnings); - validateStyledRange(packFolder, terrain, "bankWidth", path, 0D, 2048D, errors, warnings); - validateStyledRange(packFolder, terrain, "depth", path, 1D, 512D, errors, warnings); - validateStyledRange(packFolder, terrain, "tunnelWidthMultiplier", path, 1D, 8D, errors, warnings); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "channelRadiusBonus", 0D, 64D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "maxChannelWidth", 1D, 2048D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "maxBankWidth", 0D, 2048D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "maxDepth", 1D, 512D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "orderWidthFactor", 0D, 8D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "orderDepthFactor", 0D, 8D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, terrain, "maxIncision", 0, 512, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "bankExponent", 0.125D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "tunnelMouthBlend", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "tunnelFloorVariation", 0D, 8D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "tunnelRoofVariation", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "bedRoughness", 0D, 8D, errors); - PackJsonFieldChecks.validateOptionalEnum(path, terrain, "terminalMode", TERMINAL_MODES, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, terrain, "terminalTaper", 8, 1024, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, terrain, "dryContinuationChance", 0D, 1D, errors); - validateNoiseChance(packFolder, terrain, "incision", path, errors); - validateStyle(packFolder, terrain, "tunnelFloorStyle", path, errors); - validateStyle(packFolder, terrain, "tunnelRoofStyle", path, errors); - validateStyle(packFolder, terrain, "bedRoughnessStyle", path, errors); - validateWorms(path, terrain, errors); - double maximumChannelWidth = doubleValue(terrain, "maxChannelWidth", 10D); - double maximumTunnelWidthMultiplier = styledRangeMaximum( - packFolder, - terrain, - "tunnelWidthMultiplier", - path, - 1D - ); - double tunnelMouthBlend = doubleValue(terrain, "tunnelMouthBlend", 2D); - if (Double.isFinite(maximumChannelWidth) && maximumChannelWidth >= 1D && maximumChannelWidth <= 2048D - && Double.isFinite(maximumTunnelWidthMultiplier) - && maximumTunnelWidthMultiplier >= 1D && maximumTunnelWidthMultiplier <= 8D - && Double.isFinite(tunnelMouthBlend) && tunnelMouthBlend >= 0D && tunnelMouthBlend <= 16D) { - String violation = RiverTopologyComplexity.tunnelPlanViolation( - maximumChannelWidth, - maximumTunnelWidthMultiplier, - tunnelMouthBlend - ); - if (violation != null) { - errors.add(path + " exceeds the safe derived hydrology budget. " + violation); - } - } - } - - private static void validateWater( - String path, - JSONObject water, - JSONObject dimension, - List errors - ) { - PackJsonFieldChecks.validateOptionalEnum(path, water, "mode", WATER_MODES, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "poolLength", 8, 4096, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "maximumPoolRise", 0, 64, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "dropHeight", 1, 32, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, water, "fluidHeight", -2048, 2048, errors); - validateFluidPalette(path, water, errors); - - String mode = stringValue(water, "mode", "FIXED"); - int fluidHeight = integerValue(water, "fluidHeight", 63); - int maximumPoolRise = integerValue(water, "maximumPoolRise", 4); - int dropHeight = integerValue(water, "dropHeight", 1); - if ("TERRACED".equals(mode) && dropHeight > maximumPoolRise) { - errors.add(path + ".dropHeight must not exceed maximumPoolRise in TERRACED mode."); - } - JSONObject dimensionHeight = dimension.optJSONObject("dimensionHeight"); - int minimumHeight = dimensionHeight == null ? -64 : integerValue(dimensionHeight, "min", -64); - int maximumHeight = dimensionHeight == null ? 320 : integerValue(dimensionHeight, "max", 320); - if (fluidHeight < minimumHeight || fluidHeight > maximumHeight) { - errors.add(path + ".fluidHeight must remain inside dimensionHeight."); - } - if ("TERRACED".equals(mode) && fluidHeight + maximumPoolRise > maximumHeight) { - errors.add(path + ".fluidHeight plus maximumPoolRise must remain inside dimensionHeight."); - } - } - - private static void validateFluidPalette(String path, JSONObject water, List errors) { - if (!water.has("fluidPalette")) { - return; - } - Object rawPalette = water.opt("fluidPalette"); - if (!(rawPalette instanceof JSONObject palette)) { - errors.add(path + ".fluidPalette must be an object."); - return; - } - Object rawBlocks = palette.opt("palette"); - if (!(rawBlocks instanceof JSONArray blocks) || blocks.length() < 1) { - errors.add(path + ".fluidPalette.palette must contain at least one fluid block."); - } - } - - private static void validateTopologyComplexity( - File packFolder, - String path, - JSONObject topology, - JSONObject terrain, - List errors - ) { - int cellSize = integerValue(topology, "cellSize", 512); - int tileCells = integerValue(topology, "tileCells", 4); - double siteJitter = doubleValue(topology, "siteJitter", 0.35D); - int maxRouteReaches = integerValue(topology, "maxRouteReaches", 16); - WormEnvelope wormEnvelope = wormEnvelope(terrain); - double maximumChannelWidth = doubleValue(terrain, "maxChannelWidth", 10D); - double maximumBankWidth = doubleValue(terrain, "maxBankWidth", 4D); - double maximumTunnelWidthMultiplier = styledRangeMaximum( - packFolder, - terrain, - "tunnelWidthMultiplier", - path, - 1D - ); - double tunnelMouthBlend = doubleValue(terrain, "tunnelMouthBlend", 2D); - if (cellSize < 64 || cellSize > 4096 - || tileCells < 1 || tileCells > 64 - || !Double.isFinite(siteJitter) || siteJitter < 0D || siteJitter > 0.49D - || maxRouteReaches < 1 || maxRouteReaches > 256 - || wormEnvelope.maximumOffset() < 0D || wormEnvelope.maximumOffset() > 1024D - || wormEnvelope.maximumSegments() < 1 || wormEnvelope.maximumSegments() > 64 - || !Double.isFinite(maximumChannelWidth) || maximumChannelWidth < 1D || maximumChannelWidth > 2048D - || !Double.isFinite(maximumBankWidth) || maximumBankWidth < 0D || maximumBankWidth > 2048D - || !Double.isFinite(maximumTunnelWidthMultiplier) - || maximumTunnelWidthMultiplier < 1D || maximumTunnelWidthMultiplier > 8D - || !Double.isFinite(tunnelMouthBlend) || tunnelMouthBlend < 0D || tunnelMouthBlend > 16D) { - return; - } - double maximumSurfaceRadius = maximumChannelWidth * 0.5D + maximumBankWidth; - double maximumTunnelRadius = maximumChannelWidth * 0.5D * maximumTunnelWidthMultiplier - + tunnelMouthBlend; - double maximumReachRadius = Math.max(maximumSurfaceRadius, maximumTunnelRadius); - RiverTopologyComplexity.Estimate estimate = RiverTopologyComplexity.estimate( - cellSize, - tileCells, - siteJitter, - maxRouteReaches, - maximumReachRadius, - wormEnvelope.maximumOffset(), - wormEnvelope.maximumSegments() - ); - for (String violation : estimate.violations()) { - errors.add(path + " exceeds the safe derived complexity budget. " + violation); - } - } - - private static void validateWorms(String path, JSONObject terrain, List errors) { - Object rawWorms = terrain.opt("worms"); - if (!(rawWorms instanceof JSONArray worms)) { - errors.add(path + ".worms must be an array with at least one Perlin-worm profile."); - return; - } - if (worms.length() < 1) { - errors.add(path + ".worms must contain at least one Perlin-worm profile."); - return; - } - if (worms.length() > 16) { - errors.add(path + ".worms must contain at most 16 root profiles."); - } - Set ids = new HashSet(); - Set seeds = new HashSet(); - int profileCount = validateWormArray(path + ".worms", worms, 1, ids, seeds, errors); - if (profileCount > 128) { - errors.add(path + ".worms hierarchy must contain at most 128 profiles."); - } - } - - private static int validateWormArray( - String path, - JSONArray worms, - int depth, - Set ids, - Set seeds, - List errors - ) { - if (worms.length() > 16) { - errors.add(path + " must contain at most 16 profiles."); - } - double totalWeight = 0D; - int profileCount = 0; - for (int index = 0; index < worms.length(); index++) { - JSONObject worm = worms.optJSONObject(index); - String wormPath = path + "[" + index + "]"; - if (worm == null) { - errors.add(wormPath + " must be an object."); - continue; - } - profileCount++; - validateWormId(wormPath, worm, ids, errors); - long seed = validateWormSeed(wormPath, worm, errors); - if (!seeds.add(seed)) { - errors.add(wormPath + ".seed must be unique inside the worm hierarchy."); - } - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "weight", 0.000001D, 1000000D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "wavelength", 8D, 16384D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "detailWavelength", 8D, 16384D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "tortuosity", 0D, 1D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "detailTortuosity", 0D, 1D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "maxOffset", 0D, 1024D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(wormPath, worm, "segments", 1, 64, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "widthMultiplier", 0.125D, 8D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "bankMultiplier", 0.125D, 8D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "depthMultiplier", 0.125D, 8D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "bodyWavelength", 8D, 16384D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "bodyDetailWavelength", 8D, 16384D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "bodyDetailInfluence", 0D, 1D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "widthVariation", 0D, 0.875D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "bankVariation", 0D, 0.875D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "depthVariation", 0D, 0.875D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "roofVariation", 0D, 0.875D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(wormPath, worm, "branchCap", 1, 8, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "branchDecay", 0D, 1D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "confluenceMultiplier", 0D, 8D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "childChance", 0D, 1D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - wormPath, worm, "branchChildChance", 0D, 1D, errors); - totalWeight += doubleValue(worm, "weight", 1D); - Object rawChildren = worm.opt("children"); - if (rawChildren == null || rawChildren == JSONObject.NULL) { - continue; - } - if (!(rawChildren instanceof JSONArray children)) { - errors.add(wormPath + ".children must be an array."); - continue; - } - if (children.length() > 0 && depth >= 4) { - errors.add(wormPath + ".children exceeds the maximum hierarchy depth of 4."); - continue; - } - profileCount += validateWormArray( - wormPath + ".children", - children, - depth + 1, - ids, - seeds, - errors - ); - } - if (worms.length() > 0 && (!Double.isFinite(totalWeight) || totalWeight <= 0D)) { - errors.add(path + " total weight must be finite and positive."); - } - return profileCount; - } - - private static void validateWormId(String path, JSONObject worm, Set ids, List errors) { - Object rawId = worm.opt("id"); - if (!(rawId instanceof String id) || !id.matches("[a-z0-9][a-z0-9_-]{0,63}")) { - errors.add(path + ".id must use 1 to 64 lowercase letters, digits, underscores, or hyphens."); - return; - } - if (!ids.add(id)) { - errors.add(path + ".id must be unique inside the worm hierarchy."); - } - } - - private static long validateWormSeed(String path, JSONObject worm, List errors) { - Object rawSeed = worm.opt("seed"); - if (rawSeed == null || rawSeed == JSONObject.NULL) { - return 1L; - } - if (!(rawSeed instanceof Number number) - || !Double.isFinite(number.doubleValue()) - || number.doubleValue() != StrictMath.rint(number.doubleValue())) { - errors.add(path + ".seed must be an integer."); - return 1L; - } - return number.longValue(); - } - - private static WormEnvelope wormEnvelope(JSONObject terrain) { - JSONArray worms = terrain.optJSONArray("worms"); - if (worms == null || worms.length() == 0) { - return new WormEnvelope(320D, 48); - } - double maximumOffset = 0D; - int maximumSegments = 1; - WormEnvelope envelope = wormEnvelope(worms, maximumOffset, maximumSegments); - maximumOffset = envelope.maximumOffset(); - maximumSegments = envelope.maximumSegments(); - return new WormEnvelope(maximumOffset, maximumSegments); - } - - private static WormEnvelope wormEnvelope(JSONArray worms, double maximumOffset, int maximumSegments) { - double resolvedOffset = maximumOffset; - int resolvedSegments = maximumSegments; - for (int index = 0; index < worms.length(); index++) { - JSONObject worm = worms.optJSONObject(index); - if (worm == null) { - continue; - } - resolvedOffset = Math.max(resolvedOffset, doubleValue(worm, "maxOffset", 320D)); - resolvedSegments = Math.max(resolvedSegments, integerValue(worm, "segments", 48)); - JSONArray children = worm.optJSONArray("children"); - if (children != null) { - WormEnvelope childEnvelope = wormEnvelope(children, resolvedOffset, resolvedSegments); - resolvedOffset = Math.max(resolvedOffset, childEnvelope.maximumOffset()); - resolvedSegments = Math.max(resolvedSegments, childEnvelope.maximumSegments()); - } - } - return new WormEnvelope(resolvedOffset, resolvedSegments); - } - - private static void validateCaves(File packFolder, String path, JSONObject caves, - JSONObject dimension, - boolean forceGeneratedGrotto, - List errors, List warnings) { - PackJsonFieldChecks.validateOptionalEnum(path, caves, "mode", CAVE_MODES, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "minimumSpacing", 16, 4096, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maximumPerReach", 0, 16, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maxBoreDepth", 1, 256, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "throatRadius", 1, 16, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "waterLevelOffset", -64, 64, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "dryHeadroom", 0, 64, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "grottoHorizontalRadius", 2, 128, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "grottoVerticalRadius", 2, 128, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, caves, "grottoWarpStrength", 0D, 32D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, caves, "parentBiomeInheritance", 0D, 1D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maxFloodRadius", 4, 256, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maxFloodDepth", 4, 256, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, caves, "maxFloodVolume", 64, 1048576, errors); - PackJsonFieldChecks.validateOptionalEnum(path, caves, "fallback", CAVE_FALLBACKS, errors); - PackJsonFieldChecks.validateOptionalEnum(path, caves, "existingFluidPolicy", EXISTING_FLUID_POLICIES, errors); - validateNoiseChance(packFolder, caves, "entry", path, errors); - validateStyle(packFolder, caves, "grottoShapeStyle", path, errors); - validateStyle(packFolder, caves, "grottoWarpStyle", path, errors); - JSONObject deepPools = caves.has("deepPools") - ? requireObject(caves, "deepPools", path + ".deepPools", errors) - : null; - if (deepPools != null) { - validateDeepPools( - packFolder, - path + ".deepPools", - deepPools, - dimension, - errors, - warnings - ); - } - - String mode = stringValue(caves, "mode", "SEALED"); - if ("SEALED".equals(mode) && !forceGeneratedGrotto) { - return; - } - - int maximumPerReach = integerValue(caves, "maximumPerReach", 1); - double entryChance = noiseChanceValue(caves, "entry", 0.12D); - if (maximumPerReach == 0 || (!forceGeneratedGrotto && entryChance == 0D)) { - warnings.add(path + " enables cave hydrology but its entry gate cannot accept any connections."); - } - - int maxBoreDepth = integerValue(caves, "maxBoreDepth", 48); - int throatRadius = integerValue(caves, "throatRadius", 2); - int maxFloodRadius = integerValue(caves, "maxFloodRadius", 48); - int maxFloodDepth = integerValue(caves, "maxFloodDepth", 32); - if (throatRadius >= maxFloodRadius) { - errors.add(path + ".throatRadius must be smaller than maxFloodRadius so the proof boundary can contain the throat."); - } - if (throatRadius >= maxFloodDepth) { - errors.add(path + ".throatRadius must be smaller than maxFloodDepth so the proof boundary can contain the throat."); - } - if (maxBoreDepth > maxFloodDepth) { - warnings.add(path + ".maxBoreDepth exceeds maxFloodDepth; deeper cave targets found by the bore search will be rejected by containment proof."); - } - - String fallback = stringValue(caves, "fallback", "SEALED"); - if (forceGeneratedGrotto || usesGeneratedGrotto(mode, fallback)) { - validateGrotto(path, caves, errors); - } - } - - private static void validateDeepPools( - File packFolder, - String path, - JSONObject deepPools, - JSONObject dimension, - List errors, - List warnings - ) { - PackJsonFieldChecks.validateOptionalBoolean(path, deepPools, "enabled", errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "minimumSpacing", 16, 4096, errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "maximumPerReach", 0, 16, errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "minimumFluidY", -2048, 2048, errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "maximumFluidY", -2048, 2048, errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "searchRadius", 0, 256, errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "searchAttempts", 1, 64, errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "horizontalRadius", 2, 128, errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "verticalRadius", 2, 64, errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "dryHeadroom", 1, 63, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - path, deepPools, "shapeVariation", 0D, 0.75D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange( - path, deepPools, "warpStrength", 0D, 64D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange( - path, deepPools, "maximumVolume", 64, 1048576, errors); - validateNoiseChance(packFolder, deepPools, "reach", path, errors); - validateStyle(packFolder, deepPools, "shapeStyle", path, errors); - validateStyle(packFolder, deepPools, "warpStyle", path, errors); - validateFluidPalette(path, deepPools, errors); - - int minimumFluidY = integerValue(deepPools, "minimumFluidY", -224); - int maximumFluidY = integerValue(deepPools, "maximumFluidY", -104); - int searchRadius = integerValue(deepPools, "searchRadius", 16); - int horizontalRadius = integerValue(deepPools, "horizontalRadius", 18); - int verticalRadius = integerValue(deepPools, "verticalRadius", 8); - int dryHeadroom = integerValue(deepPools, "dryHeadroom", 4); - int maximumVolume = integerValue(deepPools, "maximumVolume", 32768); - if (minimumFluidY > maximumFluidY) { - errors.add(path + ".minimumFluidY must not exceed maximumFluidY."); - } - if (dryHeadroom >= verticalRadius) { - errors.add(path + ".dryHeadroom must be smaller than verticalRadius."); - } - if (searchRadius + horizontalRadius > 128) { - errors.add(path + ".searchRadius plus horizontalRadius must not exceed 128 blocks."); - } - long minimumVolume = grottoVolume(horizontalRadius, verticalRadius); - if (minimumVolume > maximumVolume) { - errors.add(path + ".maximumVolume must be at least " + minimumVolume - + " to contain the base deep-pool chamber."); - } - - if (!booleanValue(deepPools, "enabled", false)) { - return; - } - - JSONObject dimensionHeight = dimension.optJSONObject("dimensionHeight"); - int minimumHeight = dimensionHeight == null ? -64 : integerValue(dimensionHeight, "min", -64); - int maximumHeight = dimensionHeight == null ? 320 : integerValue(dimensionHeight, "max", 320); - int lowestBoundaryY = minimumFluidY - (verticalRadius * 2 - dryHeadroom) - 1; - int highestBoundaryY = maximumFluidY + dryHeadroom + 1; - if (lowestBoundaryY <= minimumHeight || highestBoundaryY >= maximumHeight) { - errors.add(path + " fluid range and chamber envelope must remain inside dimensionHeight."); - } - - int maximumPerReach = integerValue(deepPools, "maximumPerReach", 1); - double reachChance = noiseChanceValue(deepPools, "reach", 1D / 3D); - if (maximumPerReach == 0 || reachChance == 0D) { - warnings.add(path + " is enabled but its reach gate cannot accept any pools."); - } - } - - private static void validateGrotto(String path, JSONObject caves, List errors) { - int throatRadius = integerValue(caves, "throatRadius", 2); - int dryHeadroom = integerValue(caves, "dryHeadroom", 4); - int horizontalRadius = integerValue(caves, "grottoHorizontalRadius", 12); - int verticalRadius = integerValue(caves, "grottoVerticalRadius", 7); - double warpStrength = doubleValue(caves, "grottoWarpStrength", 2D); - int maxFloodRadius = integerValue(caves, "maxFloodRadius", 48); - int maxFloodDepth = integerValue(caves, "maxFloodDepth", 32); - int maxFloodVolume = integerValue(caves, "maxFloodVolume", 8192); - if (throatRadius >= horizontalRadius || throatRadius >= verticalRadius) { - addDistinct(errors, path + ".throatRadius must be smaller than both grotto radii so a sealed chamber can surround the inlet."); - } - if (dryHeadroom >= (verticalRadius * 2) + 1) { - addDistinct(errors, path + ".dryHeadroom must fit inside the generated grotto height."); - } - - int warpEnvelope = Double.isFinite(warpStrength) ? (int) Math.ceil(warpStrength) : 0; - int requiredRadius = horizontalRadius + warpEnvelope + 1; - int requiredDepth = verticalRadius + warpEnvelope + 1; - if (maxFloodRadius < requiredRadius) { - addDistinct(errors, path + ".maxFloodRadius must be at least " + requiredRadius - + " to prove the configured grotto and its sealed shell."); - } - if (maxFloodDepth < requiredDepth) { - addDistinct(errors, path + ".maxFloodDepth must be at least " + requiredDepth - + " to prove the configured grotto and its sealed shell."); - } - - long volume = grottoVolume(horizontalRadius, verticalRadius); - if (volume > maxFloodVolume) { - addDistinct(errors, path + ".maxFloodVolume must be at least " + volume - + " to contain the configured grotto before its throat and shell are considered."); - } - } - - private static long grottoVolume(int horizontalRadius, int verticalRadius) { - long volume = 0L; - double horizontalSquared = (double) horizontalRadius * horizontalRadius; - double verticalSquared = (double) verticalRadius * verticalRadius; - for (int dx = -horizontalRadius; dx <= horizontalRadius; dx++) { - for (int dy = -verticalRadius; dy <= verticalRadius; dy++) { - double remaining = 1D - ((double) dx * dx / horizontalSquared) - - ((double) dy * dy / verticalSquared); - if (remaining < 0D) { - continue; - } - int maximumZ = (int) Math.floor(horizontalRadius * Math.sqrt(remaining)); - volume += (maximumZ * 2L) + 1L; - } - } - return volume; - } - - private static boolean usesGeneratedGrotto(String mode, String fallback) { - return "GENERATE_GROTTO".equals(mode) - || "GROTTO_OR_CLOSED_COMPONENT".equals(mode) - || "WATERFALL_POOL".equals(mode) - || "GENERATE_GROTTO".equals(fallback); - } - - private static void validateSinkholeCapability( - String terminalPath, - DimensionRiverContext context, - JSONObject caves, - String cavesPath, - List errors - ) { - String suffix = " in Dimension '" + context.dimensionKey() + "'."; - if (!booleanValue(context.dimension(), "carvingEnabled", true)) { - addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires carvingEnabled to be true" + suffix); - } - if (!booleanValue(context.dimension(), "useMantle", true)) { - addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires useMantle to be true" + suffix); - } - if (disabled(context.dimension(), "CARVED")) { - addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires CARVED to remain enabled" + suffix); - } - if (disabled(context.dimension(), "RIVER_HYDROLOGY")) { - addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires RIVER_HYDROLOGY to remain enabled" + suffix); - } - if ("SEALED".equals(stringValue(caves, "mode", "SEALED"))) { - addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires a non-SEALED caves.mode" + suffix); - } - if (integerValue(caves, "maximumPerReach", 1) <= 0) { - addDistinct(errors, terminalPath + " SINKHOLE_GROTTO requires caves.maximumPerReach above zero" + suffix); - } - validateGrotto(cavesPath, caves, errors); - } - - private static void validateOverrideSinkhole( - File packFolder, - String terminalPath, - String resourceKey, - String resourceType, - List contexts, - List errors, - List warnings - ) { - boolean referenced = false; - for (DimensionRiverContext context : contexts) { - boolean reachable = "Region".equals(resourceType) - ? context.regionKeys().contains(resourceKey) - : referencedSurfaceBiomes(packFolder, context.regionKeys()).contains(resourceKey); - if (!reachable) { - continue; - } - referenced = true; - JSONObject caves = nestedObject(context.rivers(), "caves", "Dimension '" - + context.dimensionKey() + "' rivers", errors); - if (caves != null) { - validateSinkholeCapability( - terminalPath, - context, - caves, - "Dimension '" + context.dimensionKey() + "' rivers.caves", - errors - ); - } - } - if (!referenced) { - addDistinct(warnings, terminalPath - + " selects SINKHOLE_GROTTO but no enabled river dimension reaches this " - + resourceType.toLowerCase() + "."); - } - } - - private static Set referencedSurfaceBiomes(File packFolder, Set regionKeys) { - Set biomes = new HashSet<>(); - File regionsFolder = new File(packFolder, "regions"); - for (String regionKey : regionKeys) { - JSONObject region = PackValidationIo.readJson(new File(regionsFolder, regionKey + ".json")); - if (region == null) { - continue; - } - collectBiomeKeys(region.optJSONArray("landBiomes"), biomes); - collectBiomeKeys(region.optJSONArray("seaBiomes"), biomes); - collectBiomeKeys(region.optJSONArray("shoreBiomes"), biomes); - } - Set roots = Set.copyOf(biomes); - for (String biomeKey : roots) { - collectBiomeChildren(packFolder, biomeKey, 0, biomes); - } - return biomes; - } - - private static void collectBiomeChildren(File packFolder, String biomeKey, int depth, Set biomes) { - if (depth >= 4) { - return; - } - JSONObject biome = PackValidationIo.readJson(new File(packFolder, "biomes/" + biomeKey + ".json")); - if (biome == null) { - return; - } - JSONArray children = biome.optJSONArray("children"); - if (children == null) { - return; - } - for (int index = 0; index < children.length(); index++) { - String child = children.optString(index, null); - if (child == null || child.isBlank()) { - continue; - } - boolean added = biomes.add(child); - if (added) { - collectBiomeChildren(packFolder, child, depth + 1, biomes); - } - } - } - - private static Set referencedKeys(JSONArray keys) { - Set referenced = new HashSet<>(); - collectBiomeKeys(keys, referenced); - return Set.copyOf(referenced); - } - - private static void collectBiomeKeys(JSONArray keys, Set destination) { - if (keys == null) { - return; - } - for (int index = 0; index < keys.length(); index++) { - String key = keys.optString(index, null); - if (key != null && !key.isBlank()) { - destination.add(key); - } - } - } - - private static boolean disabled(JSONObject dimension, String flag) { - JSONArray disabled = dimension.optJSONArray("disabledComponents"); - if (disabled == null) { - return false; - } - for (int index = 0; index < disabled.length(); index++) { - if (flag.equals(disabled.optString(index, null))) { - return true; - } - } - return false; - } - - private static void addDistinct(List destination, String value) { - if (!destination.contains(value)) { - destination.add(value); - } - } - - private static void validateOverrides(File packFolder, File resourceFolder, String resourceType, - List contexts, - List errors, List warnings) { - if (!resourceFolder.isDirectory()) { - return; - } - List files = PackValidationIo.listJsonRecursive(resourceFolder); - files.sort(Comparator.comparing(File::getPath)); - for (File file : files) { - JSONObject resource = PackValidationIo.readJson(file); - if (resource == null || !resource.has("riverOverride")) { - continue; - } - String key = PackValidationIo.deriveKey(resourceFolder, file); - String path = resourceType + " '" + key + "' riverOverride"; - Object rawOverride = resource.opt("riverOverride"); - if (rawOverride == JSONObject.NULL) { - continue; - } - if (!(rawOverride instanceof JSONObject override)) { - errors.add(path + " must be an object or null."); - continue; - } - validateOverride(packFolder, path, key, resourceType, override, contexts, errors, warnings); - } - } - - private static void validateOverride(File packFolder, String path, String resourceKey, String resourceType, - JSONObject override, List contexts, - List errors, List warnings) { - PackJsonFieldChecks.validateOptionalBoolean(path, override, "allowSources", errors); - PackJsonFieldChecks.validateOptionalEnum(path, override, "routingPolicy", ROUTING_POLICIES, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "routingCostMultiplier", 0D, 64D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "widthMultiplier", 0.0001D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "bankWidthMultiplier", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "depthMultiplier", 0.0001D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "maxIncisionMultiplier", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "continuationChanceMultiplier", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, override, "caveEntryMultiplier", 0D, 16D, errors); - PackJsonFieldChecks.validateOptionalEnum(path, override, "terminalMode", TERMINAL_MODES, errors); - boolean validateSuitability = reachesOverworldNativeStructureRoles( - packFolder, resourceKey, resourceType, contexts); - validateBiomePool(packFolder, path, override, "channelBiomes", RiverBiomeRole.CHANNEL, true, - validateSuitability, errors, warnings); - validateBiomePool(packFolder, path, override, "bankBiomes", RiverBiomeRole.BANK, true, - validateSuitability, errors, warnings); - validateBiomePool(packFolder, path, override, "mouthBiomes", RiverBiomeRole.MOUTH, true, - validateSuitability, errors, warnings); - validateBiomePool(packFolder, path, override, "dryBiomes", RiverBiomeRole.DRY, true, - validateSuitability, errors, warnings); - validateBiomePool(packFolder, path, override, "floodedCaveBiomes", RiverBiomeRole.FLOODED_CAVE, true, - validateSuitability, errors, warnings); - if ("SINKHOLE_GROTTO".equals(stringValue(override, "terminalMode", null))) { - validateOverrideSinkhole( - packFolder, - path + ".terminalMode", - resourceKey, - resourceType, - contexts, - errors, - warnings - ); - } - } - - private static void validateBiomePools(File packFolder, String path, JSONObject biomes, boolean allowNull, - boolean validateSuitability, - List errors, List warnings) { - validateStyle(packFolder, biomes, "selectionStyle", path, errors); - validateBiomePool(packFolder, path, biomes, "channel", RiverBiomeRole.CHANNEL, allowNull, - validateSuitability, errors, warnings); - validateBiomePool(packFolder, path, biomes, "bank", RiverBiomeRole.BANK, allowNull, - validateSuitability, errors, warnings); - validateBiomePool(packFolder, path, biomes, "mouth", RiverBiomeRole.MOUTH, allowNull, - validateSuitability, errors, warnings); - validateBiomePool(packFolder, path, biomes, "dry", RiverBiomeRole.DRY, allowNull, - validateSuitability, errors, warnings); - validateBiomePool(packFolder, path, biomes, "floodedCave", RiverBiomeRole.FLOODED_CAVE, allowNull, - validateSuitability, errors, warnings); - } - - private static void validateBiomePool(File packFolder, String path, JSONObject owner, String field, - RiverBiomeRole role, boolean allowNull, - boolean validateSuitability, - List errors, List warnings) { - if (!owner.has(field)) { - return; - } - Object rawPool = owner.opt(field); - if (rawPool == JSONObject.NULL && allowNull) { - return; - } - if (!(rawPool instanceof JSONArray pool)) { - errors.add(path + "." + field + " must be an array" + (allowNull ? " or null" : "") + "."); - return; - } - - Set seen = new HashSet<>(); - File biomesFolder = new File(packFolder, "biomes"); - for (int index = 0; index < pool.length(); index++) { - Object rawKey = pool.opt(index); - String entryPath = path + "." + field + "[" + index + "]"; - if (!(rawKey instanceof String key) || key.isBlank()) { - errors.add(entryPath + " must name a biome resource."); - continue; - } - if (!seen.add(key)) { - warnings.add(entryPath + " duplicates biome '" + key + "' in the same river pool."); - continue; - } - File biomeFile = new File(biomesFolder, key + ".json"); - if (!biomeFile.isFile()) { - errors.add(entryPath + " references missing biome '" + key + "'."); - continue; - } - if (validateSuitability) { - validateBiomeSuitability(entryPath, key, role, PackValidationIo.readJson(biomeFile), warnings); - } - } - } - - private static boolean usesOverworldNativeStructureRoles(DimensionRiverContext context) { - return "NORMAL".equals(stringValue(context.dimension(), "environment", "NORMAL")); - } - - private static boolean reachesOverworldNativeStructureRoles( - File packFolder, - String resourceKey, - String resourceType, - List contexts - ) { - for (DimensionRiverContext context : contexts) { - if (!usesOverworldNativeStructureRoles(context)) { - continue; - } - boolean reachable = "Region".equals(resourceType) - ? context.regionKeys().contains(resourceKey) - : referencedSurfaceBiomes(packFolder, context.regionKeys()).contains(resourceKey); - if (reachable) { - return true; - } - } - return false; - } - - private static void validateBiomeSuitability(String path, String biomeKey, RiverBiomeRole role, - JSONObject biome, List warnings) { - if (biome == null || role == RiverBiomeRole.DRY || role == RiverBiomeRole.FLOODED_CAVE) { - return; - } - String derivative = stringValue(biome, "vanillaDerivative", null); - if (derivative == null || derivative.isBlank()) { - derivative = stringValue(biome, "derivative", "minecraft:the_void"); - } - String normalized = derivative.indexOf(':') >= 0 ? derivative : "minecraft:" + derivative; - if (!normalized.startsWith("minecraft:")) { - return; - } - boolean suitable = switch (role) { - case CHANNEL, MOUTH -> normalized.contains("ocean") || normalized.endsWith("river"); - case BANK -> normalized.endsWith("beach") || normalized.endsWith("shore"); - default -> true; - }; - if (!suitable) { - warnings.add(path + " assigns biome '" + biomeKey + "' the inferred river role " + role.label - + " but its vanilla derivative '" + normalized - + "' does not match that role; native structure selection will use Iris's safe role fallback."); - } - } - - private static void validateNoiseChance(File packFolder, JSONObject owner, String field, String path, - List errors) { - if (!owner.has(field)) { - return; - } - JSONObject chance = requireObject(owner, field, path + "." + field, errors); - if (chance == null) { - return; - } - String chancePath = path + "." + field; - PackJsonFieldChecks.validateOptionalDoubleRange(chancePath, chance, "chance", 0D, 1D, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(chancePath, chance, "influence", 0D, 1D, errors); - validateStyle(packFolder, chance, "style", chancePath, errors); - } - - private static void validateStyledRange(File packFolder, JSONObject owner, String field, String path, - double minimum, double maximum, - List errors, List warnings) { - if (!owner.has(field)) { - return; - } - String rangePath = path + "." + field; - JSONObject range = resolveObject(packFolder, owner.opt(field), "snippet/style-range/", rangePath, errors); - if (range == null) { - return; - } - boolean hasMinimum = range.has("min") && range.opt("min") != JSONObject.NULL; - boolean hasMaximum = range.has("max") && range.opt("max") != JSONObject.NULL; - if (!hasMinimum && !hasMaximum) { - errors.add(rangePath + " must set min and max explicitly."); - } else if (!hasMinimum || !hasMaximum) { - warnings.add(rangePath - + " should set both min and max explicitly; the omitted bound uses the shared style-range default."); - } - PackJsonFieldChecks.validateOptionalDoubleRange(rangePath, range, "min", minimum, maximum, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(rangePath, range, "max", minimum, maximum, errors); - double minimumValue = doubleValue(range, "min", 16D); - double maximumValue = doubleValue(range, "max", 32D); - if (Double.isFinite(minimumValue) && Double.isFinite(maximumValue) && minimumValue > maximumValue) { - errors.add(rangePath + ".min must not exceed " + rangePath + ".max."); - } - validateStyle(packFolder, range, "style", rangePath, errors); - } - - private static double styledRangeMaximum( - File packFolder, - JSONObject owner, - String field, - String path, - double fallback - ) { - if (!owner.has(field)) { - return fallback; - } - JSONObject range = resolveObject( - packFolder, - owner.opt(field), - "snippet/style-range/", - path + "." + field, - new ArrayList<>() - ); - return range == null ? fallback : doubleValue(range, "max", fallback); - } - - private static void validateStyle(File packFolder, JSONObject owner, String field, String path, - List errors) { - if (!owner.has(field)) { - return; - } - validateStyle(packFolder, owner.opt(field), path + "." + field, errors, new HashSet<>()); - } - - private static void validateStyle(File packFolder, Object rawStyle, String path, - List errors, Set dependencyStack) { - String styleMarker = rawStyle instanceof String reference ? "style:" + reference : null; - if (styleMarker != null && !dependencyStack.add(styleMarker)) { - errors.add(path + " has a cyclic river-noise style snippet dependency."); - return; - } - try { - JSONObject style = resolveObject(packFolder, rawStyle, "snippet/style/", path, errors); - if (style != null) { - validateResolvedStyle(packFolder, style, path, errors, dependencyStack); - } - } finally { - if (styleMarker != null) { - dependencyStack.remove(styleMarker); - } - } - } - - private static void validateResolvedStyle(File packFolder, JSONObject style, String path, - List errors, Set dependencyStack) { - PackJsonFieldChecks.validateOptionalEnum(path, style, "style", NOISE_STYLES, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "cellularFrequency", 0D, Double.MAX_VALUE, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "cellularZoom", 0.00001D, Double.MAX_VALUE, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "zoom", 0.00001D, Double.MAX_VALUE, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "multiplier", - -Double.MAX_VALUE, Double.MAX_VALUE, errors); - PackJsonFieldChecks.validateOptionalDoubleRange(path, style, "exponent", 0.01562D, 64D, errors); - PackJsonFieldChecks.validateOptionalIntegerRange(path, style, "cacheSize", 0, 8192, errors); - - if (style.has("expression") && style.opt("expression") != JSONObject.NULL) { - Object rawExpression = style.opt("expression"); - if (!(rawExpression instanceof String expressionKey) || expressionKey.isBlank()) { - errors.add(path + ".expression must name an expression resource."); - } else { - validateExpression(packFolder, expressionKey, path, errors, dependencyStack); - } - } - if (style.has("fracture") && style.opt("fracture") != JSONObject.NULL) { - validateStyle(packFolder, style.opt("fracture"), path + ".fracture", errors, dependencyStack); - } - } - - private static void validateExpression(File packFolder, String expressionKey, String usePath, - List errors, Set dependencyStack) { - String expressionMarker = "expression:" + expressionKey; - if (!dependencyStack.add(expressionMarker)) { - errors.add(usePath + " has a cyclic river-noise expression dependency through '" + expressionKey + "'."); - return; - } - try { - File expressionFile = new File(new File(packFolder, "expressions"), expressionKey + ".json"); - JSONObject expression = PackValidationIo.readJson(expressionFile); - if (expression == null) { - return; - } - scanExpressionEntries(packFolder, expression, "variables", expressionKey, usePath, errors, dependencyStack); - scanExpressionEntries(packFolder, expression, "functions", expressionKey, usePath, errors, dependencyStack); - } finally { - dependencyStack.remove(expressionMarker); - } - } - - private static void scanExpressionEntries(File packFolder, JSONObject expression, String field, - String expressionKey, String usePath, - List errors, Set dependencyStack) { - JSONArray entries = expression.optJSONArray(field); - if (entries == null) { - return; - } - for (int index = 0; index < entries.length(); index++) { - Object rawEntry = entries.opt(index); - String snippetFolder = "variables".equals(field) - ? "snippet/expression-load/" - : "snippet/expression-function/"; - JSONObject entry = resolveExpressionEntry(packFolder, rawEntry, snippetFolder); - if (entry == null) { - continue; - } - String stream = stringValue(entry, "engineStreamValue", null); - if (stream != null && isUnsafeRiverStream(stream)) { - errors.add(usePath + " uses expression '" + expressionKey + "' " + field + "[" + index - + "].engineStreamValue '" + stream - + "', which depends on final river-shaped terrain and would recurse during river generation."); - } - if (entry.has("styleValue")) { - validateStyle(packFolder, entry.opt("styleValue"), usePath + " -> expression '" + expressionKey - + "' " + field + "[" + index + "].styleValue", errors, dependencyStack); - } - } - } - - private static JSONObject resolveExpressionEntry(File packFolder, Object rawEntry, String snippetFolder) { - if (rawEntry instanceof JSONObject entry) { - return entry; - } - if (!(rawEntry instanceof String reference) || !reference.startsWith("snippet/")) { - return null; - } - String resolved = reference.startsWith(snippetFolder) - ? reference - : snippetFolder + reference.substring("snippet/".length()); - return PackValidationIo.readJson(new File(packFolder, resolved + ".json")); - } - - private static boolean isUnsafeRiverStream(String stream) { - return UNSAFE_RIVER_STREAMS.contains(stream) || stream.startsWith("RIVER_"); - } - - private static Set noiseStyles() { - Set styles = new HashSet<>(); - for (NoiseStyle style : NoiseStyle.values()) { - styles.add(style.name()); - } - return Set.copyOf(styles); - } - - private static JSONObject nestedObject(JSONObject owner, String field, String path, List errors) { - if (!owner.has(field)) { - return new JSONObject(); - } - return requireObject(owner, field, path + "." + field, errors); - } - - private static JSONObject requireObject(JSONObject owner, String field, String path, List errors) { - Object raw = owner.opt(field); - if (!(raw instanceof JSONObject object)) { - errors.add(path + " must be an object."); - return null; - } - return object; - } - - private static JSONObject resolveObject(File packFolder, Object raw, String snippetFolder, - String path, List errors) { - if (raw instanceof JSONObject object) { - return object; - } - if (raw instanceof String reference && reference.startsWith("snippet/")) { - String resolved = reference.startsWith(snippetFolder) - ? reference - : snippetFolder + reference.substring("snippet/".length()); - return PackValidationIo.readJson(new File(packFolder, resolved + ".json")); - } - errors.add(path + " must be an object or snippet reference."); - return null; - } - - private static boolean booleanValue(JSONObject object, String field, boolean defaultValue) { - Object raw = object.opt(field); - return raw instanceof Boolean value ? value : defaultValue; - } - - private static int integerValue(JSONObject object, String field, int defaultValue) { - Object raw = object.opt(field); - if (!(raw instanceof Number number) || !Double.isFinite(number.doubleValue())) { - return defaultValue; - } - return number.intValue(); - } - - private static double doubleValue(JSONObject object, String field, double defaultValue) { - Object raw = object.opt(field); - return raw instanceof Number number ? number.doubleValue() : defaultValue; - } - - private static String stringValue(JSONObject object, String field, String defaultValue) { - Object raw = object.opt(field); - return raw instanceof String value ? value : defaultValue; - } - - private static double noiseChanceValue(JSONObject owner, String field, double defaultValue) { - JSONObject chance = owner.optJSONObject(field); - return chance == null ? defaultValue : doubleValue(chance, "chance", defaultValue); - } - - private record WormEnvelope(double maximumOffset, int maximumSegments) { - } - - record Validation(List errors, List warnings) { - Validation { - errors = List.copyOf(errors); - warnings = List.copyOf(warnings); - } - } - - private record DimensionRiverContext( - String dimensionKey, - JSONObject dimension, - JSONObject rivers, - Set regionKeys - ) { - } - - private enum RiverBiomeRole { - CHANNEL("SEA"), - BANK("SHORE"), - MOUTH("SEA"), - DRY("LAND"), - FLOODED_CAVE("CAVE"); - - private final String label; - - RiverBiomeRole(String label) { - this.label = label; - } - } -} diff --git a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index c06873cc7..0a70e0061 100644 --- a/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java +++ b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java @@ -84,9 +84,10 @@ public final class PackValidator { packFolder, dimensionFiles, validateLiveRegistries); addDistinct(blockingErrors, imageMaps.errors()); addDistinct(warnings, imageMaps.warnings()); - PackRiverValidator.Validation riverValidation = PackRiverValidator.validate(packFolder, dimensionFiles); - addDistinct(blockingErrors, riverValidation.errors()); - addDistinct(warnings, riverValidation.warnings()); + PackHydrologyValidator.Validation hydrologyValidation = + PackHydrologyValidator.validate(packFolder, dimensionFiles); + addDistinct(blockingErrors, hydrologyValidation.errors()); + addDistinct(warnings, hydrologyValidation.warnings()); blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder)); PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder); addDistinct(blockingErrors, lootIssues.errors()); diff --git a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java index 7b01f4418..e8f8eba76 100644 --- a/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java +++ b/core/src/main/java/art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java @@ -481,8 +481,7 @@ public class AsyncPregenMethod implements PregeneratorMethod { listener.onChunkFailed(x, z); } else { listener.onChunkGenerated(x, z); - cleanupMantleChunk(x, z); - listener.onChunkCleaned(x, z); + cleanupMantleChunksCoveredBy(x, z, listener); onChunkCompleted(x, z, chunk); success = true; } @@ -692,11 +691,11 @@ public class AsyncPregenMethod implements PregeneratorMethod { } } - private void cleanupMantleChunk(int x, int z) { + private void cleanupMantleChunksCoveredBy(int x, int z, PregenListener listener) { Engine engine = resolveMetricsEngine(); if (engine != null) { try { - engine.getMantle().forceCleanupChunk(x, z); + engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned); } catch (Throwable ignored) { } } diff --git a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java index 8fe29104a..21aa23752 100644 --- a/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java +++ b/core/src/main/java/art/arcane/iris/core/tools/IrisCreator.java @@ -92,6 +92,7 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML; @Accessors(fluent = true, chain = true) public class IrisCreator { private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L; + private static final long INITIAL_SPAWN_TIMEOUT_SECONDS = 600L; private static final long WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS = 60L; private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L; @@ -619,7 +620,7 @@ public class IrisCreator { generator.getInitialSpawnReady(), "Initial spawn preparation future"); try { - initialSpawnReady.get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS); + initialSpawnReady.get(INITIAL_SPAWN_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (TimeoutException failure) { throw new TimeoutException("Initial spawn preparation timed out for world \"" + worldName + "\"."); diff --git a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java index 75fad5035..883121adc 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -22,7 +22,14 @@ import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyNaturalSample; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntimeContext; import art.arcane.iris.engine.image.IrisImageMapRuntime; +import art.arcane.iris.engine.mantle.components.MantleHydrologyCaveVoxelView; import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDecorationPart; @@ -31,20 +38,11 @@ import art.arcane.iris.engine.object.IrisGenerator; import art.arcane.iris.engine.object.IrisInterpolator; import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisRiverCaves; -import art.arcane.iris.engine.object.IrisRiverDeepPools; -import art.arcane.iris.engine.object.IrisRiverNetwork; -import art.arcane.iris.engine.object.IrisRiverOverride; -import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; -import art.arcane.iris.engine.object.IrisRiverWater; +import art.arcane.iris.engine.object.IrisDeepFluidConfig; +import art.arcane.iris.engine.object.IrisHydrology; +import art.arcane.iris.engine.object.IrisRiverHydrology; +import art.arcane.iris.engine.object.IrisRiverProfile; import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; -import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; -import art.arcane.iris.engine.river.runtime.IrisRiverRuntimeContext; -import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBiome; @@ -67,36 +65,48 @@ import lombok.ToString; import java.io.File; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; +import java.util.EnumMap; import java.util.HashMap; import java.util.HashSet; import java.util.IdentityHashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiFunction; @Data -@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime", "imageMapRuntime"}) -@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime", "imageMapRuntime"}) +@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"}) +@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"}) public class IrisComplex implements DataProvider { private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); private static final int GRID_BOUNDS_CACHE_SIZE = 8192; private static final int HEIGHT_BOUNDS_GRID = 4; + private static final Comparator INTERPOLATOR_ORDER = Comparator + .comparing((IrisInterpolator interpolator) -> interpolator.getFunction().name()) + .thenComparingDouble(IrisInterpolator::getHorizontalScale); + private static final Comparator GENERATOR_ORDER = Comparator.comparing( + IrisGenerator::getLoadKey, + Comparator.nullsFirst(Comparator.naturalOrder()) + ); + private static final InferredType[] INFERRED_BIOME_PREPARATION_ORDER = { + InferredType.LAND, + InferredType.CAVE, + InferredType.SEA, + InferredType.SHORE + }; @Getter(AccessLevel.NONE) private final transient ThreadLocal gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new); - /** - * Immutable snapshot of {@link #generators} taken once at the end of construction, in the exact - * iteration order the map produces. The per-column height paths walk these arrays instead of - * allocating map/set iterators, and the frozen order keeps the floating point accumulation order - * identical to the map iteration it replaces. Mutating {@link #generators} after construction is - * not reflected here. - */ @Getter(AccessLevel.NONE) private final transient IrisInterpolator[] frozenInterpolators; @Getter(AccessLevel.NONE) private final transient IrisGenerator[][] frozenGenerators; + @Getter(AccessLevel.NONE) + private final transient Map>> inferredBiomeStreams; private RNG rng; private double fluidHeight; private IrisData data; @@ -121,9 +131,7 @@ public class IrisComplex implements DataProvider { private ProceduralStream maxHeightStream; private ProceduralStream overlayStream; private ProceduralStream heightFluidStream; - private ProceduralStream naturalSlopeStream; private ProceduralStream slopeStream; - private ProceduralStream riverSurfaceStream; private ProceduralStream riverDistanceStream; private ProceduralStream riverFlowStream; private ProceduralStream riverCarveWeightStream; @@ -138,13 +146,12 @@ public class IrisComplex implements DataProvider { private ProceduralStream shoreSurfaceDecoration; private ProceduralStream rockStream; private ProceduralStream fluidStream; - private ProceduralStream riverFluidStream; - private ProceduralStream riverDeepPoolFluidStream; + private Map> hydrologyFluidStreams; private IrisBiome focusBiome; private IrisRegion focusRegion; private Map> generatorBounds; private Set generatorBiomes; - private IrisRiverRuntime riverRuntime; + private IrisHydrologyRuntime hydrologyRuntime; private transient IrisImageMapRuntime imageMapRuntime; // Copy-on-write: reads happen per column on every burst thread; the synchronizedMap // monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map @@ -170,6 +177,7 @@ public class IrisComplex implements DataProvider { focusBiome = engine.getFocus(); focusRegion = engine.getFocusRegion(); Map> inferredStreams = new HashMap<>(); + KList preparedRegions = new KList<>(); if (focusBiome != null) { focusBiome = focusBiome.withInferredType(InferredType.LAND); @@ -178,7 +186,7 @@ public class IrisComplex implements DataProvider { //@builder if (focusRegion != null) { - prepareInferredBiomes(focusRegion); + prepareInferredBiomes(focusRegion, preparedRegions); focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators); } else { engine.getDimension().getRegions().forEach(regionKey -> { @@ -186,23 +194,26 @@ public class IrisComplex implements DataProvider { if (region == null) { return; } - prepareInferredBiomes(region); + prepareInferredBiomes(region, preparedRegions); region.getNaturalBiomes(this).forEach(this::registerGenerators); }); for (IrisRegion region : imageMapRuntime.getMappedRegions()) { - prepareInferredBiomes(region); + prepareInferredBiomes(region, preparedRegions); region.getNaturalBiomes(this).forEach(this::registerGenerators); } imageMapRuntime.getMappedBiomes().forEach(this::registerGenerators); } - int interpolatorCount = generators.size(); + inferredBiomeStreams = compileInferredBiomeStreams( + preparedRegions, + (region, inferredType) -> compileInferredBiomeStream(engine, region, inferredType, emptyBiome) + ); + GeneratorGroup[] generatorGroups = freezeGeneratorGroups(generators); + int interpolatorCount = generatorGroups.length; frozenInterpolators = new IrisInterpolator[interpolatorCount]; frozenGenerators = new IrisGenerator[interpolatorCount][]; - int frozenIndex = 0; - for (Map.Entry> entry : generators.entrySet()) { - frozenInterpolators[frozenIndex] = entry.getKey(); - frozenGenerators[frozenIndex] = entry.getValue().toArray(new IrisGenerator[0]); - frozenIndex++; + for (int frozenIndex = 0; frozenIndex < generatorGroups.length; frozenIndex++) { + frozenInterpolators[frozenIndex] = generatorGroups[frozenIndex].interpolator(); + frozenGenerators[frozenIndex] = generatorGroups[frozenIndex].generators(); } generatorBounds = buildGeneratorBounds(engine); KList overlayNoise = engine.getDimension().getOverlayNoise(); @@ -221,29 +232,7 @@ public class IrisComplex implements DataProvider { .select(engine.getDimension().getRockPalette().getBlockData(data)); fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream() .select(engine.getDimension().getFluidPalette().getBlockData(data)); - IrisRiverNetwork configuredRivers = engine.getDimension().getRivers(); - IrisRiverWater configuredRiverWater = configuredRivers == null ? null : configuredRivers.getWater(); - riverFluidStream = configuredRivers != null && configuredRivers.isEnabled() - ? configuredFluidStream( - Objects.requireNonNull(configuredRiverWater).getFluidPalette(), - rng.nextParallelRNG(79), - "River water" - ) - : fluidStream; - IrisRiverCaves configuredRiverCaves = configuredRivers == null ? null : configuredRivers.getCaves(); - IrisRiverDeepPools configuredDeepPools = configuredRiverCaves == null - ? null - : configuredRiverCaves.getDeepPools(); - riverDeepPoolFluidStream = configuredRivers != null - && configuredRivers.isEnabled() - && configuredDeepPools != null - && configuredDeepPools.isEnabled() - ? configuredFluidStream( - configuredDeepPools.getFluidPalette(), - rng.nextParallelRNG(80), - "River deep-pool" - ) - : riverFluidStream; + hydrologyFluidStreams = createHydrologyFluidStreams(engine.getDimension().getHydrology()); regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream() .zoom(engine.getDimension().getRegionZoom()); regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE); @@ -262,41 +251,22 @@ public class IrisComplex implements DataProvider { regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i), String.valueOf(i * 38445).hashCode() * 3245556666L)); caveBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) - .convert((r) - -> engine.getDimension().getCaveBiomeStyle().create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream() - .zoom(engine.getDimension().getBiomeZoom()) - .zoom(r.getCaveBiomeZoom()) - .selectRarity(loadInferredBiomes(r.getCaveBiomes(), InferredType.CAVE)) - .onNull(emptyBiome) - ).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize); + .convert((r) -> createInferredBiomeStream(r, InferredType.CAVE)) + .convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize); inferredStreams.put(InferredType.CAVE, caveBiomeStream); landBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) - .convert((r) - -> engine.getDimension().getLandBiomeStyle().create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream() - .zoom(engine.getDimension().getBiomeZoom()) - .zoom(engine.getDimension().getLandZoom()) - .zoom(r.getLandBiomeZoom()) - .selectRarity(loadInferredBiomes(r.getLandBiomes(), InferredType.LAND)) - ).convertAware2D(ProceduralStream::get) + .convert((r) -> createInferredBiomeStream(r, InferredType.LAND)) + .convertAware2D(ProceduralStream::get) .cache2D("landBiomeStream", engine, cacheSize); inferredStreams.put(InferredType.LAND, landBiomeStream); seaBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) - .convert((r) - -> engine.getDimension().getSeaBiomeStyle().create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream() - .zoom(engine.getDimension().getBiomeZoom()) - .zoom(engine.getDimension().getSeaZoom()) - .zoom(r.getSeaBiomeZoom()) - .selectRarity(loadInferredBiomes(r.getSeaBiomes(), InferredType.SEA)) - ).convertAware2D(ProceduralStream::get) + .convert((r) -> createInferredBiomeStream(r, InferredType.SEA)) + .convertAware2D(ProceduralStream::get) .cache2D("seaBiomeStream", engine, cacheSize); inferredStreams.put(InferredType.SEA, seaBiomeStream); shoreBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) - .convert((r) - -> engine.getDimension().getShoreBiomeStyle().create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream() - .zoom(engine.getDimension().getBiomeZoom()) - .zoom(r.getShoreBiomeZoom()) - .selectRarity(loadInferredBiomes(r.getShoreBiomes(), InferredType.SHORE)) - ).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize); + .convert((r) -> createInferredBiomeStream(r, InferredType.SHORE)) + .convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize); inferredStreams.put(InferredType.SHORE, shoreBiomeStream); bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(), Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) : @@ -315,13 +285,10 @@ public class IrisComplex implements DataProvider { return mapped == null ? biome : mapped; }) .cache2D("imageMappedBaseBiomeStream", engine, cacheSize); - naturalHeightStream = ProceduralStream.of((x, z) -> { - IrisBiome b = focusBiome != null ? focusBiome : baseBiomeStream.get(x, z); - double proceduralHeight = getHeight(engine, b, x, z, engine.getSeedManager().getHeight()); - return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight); - }, Interpolated.DOUBLE).cache2DDouble("naturalHeightStream", engine, cacheSize); - naturalSlopeStream = naturalHeightStream.slope(3) - .cache2DDouble("naturalSlopeStream", engine, cacheSize); + naturalHeightStream = ProceduralStream.of( + (x, z) -> sampleNaturalTerrainHeight(engine, x, z), + Interpolated.DOUBLE + ).cache2DDouble("naturalHeightStream", engine, cacheSize); naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, b -> focusBiome)) .cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream @@ -332,46 +299,20 @@ public class IrisComplex implements DataProvider { : mapped; }) .cache2D("naturalTrueBiomeStream", engine, cacheSize); - if (engine.getDimension().getRivers() != null && engine.getDimension().getRivers().isEnabled()) { - ProceduralStream naturalOceanStream = createNaturalOceanStream( - bridgeStream, - focusBiome - ).cache2D("naturalOceanStream", engine, cacheSize); - int riverFluidHeight = engine.getDimension().getRivers().getWater().getFluidHeight() - - engine.getDimension().getMinHeight(); - riverRuntime = new IrisRiverRuntime(new IrisRiverRuntimeContext( + IrisHydrology configuredHydrology = engine.getDimension().getHydrology(); + if (hydrologyActive(configuredHydrology)) { + hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext( engine.getSeedManager().getBodies(), - engine.getDimension().getRivers(), + engine.getHeight(), + engine.getDimension(), data, - riverFluidHeight, - (int) Math.round(fluidHeight), - IrisEngineMantle.isRiverHydrologyEnabled(engine.getDimension()), - IrisEngineMantle.isRiverCaveHydrologyEnabled(engine.getDimension()), - blockingRiverRoutingPossible(engine), - variableMaxIncisionPossible(engine), - biomeRiverOverridesPossible(focusBiome, generatorBiomes), - (blockX, blockZ) -> naturalHeightBounds(engine, overlayNoise, blockX, blockZ), - naturalHeightStream, - naturalSlopeStream, - naturalOceanStream, - naturalTrueBiomeStream, - regionStream + (x, z, naturalHeight) -> sampleHydrologyNatural(engine, x, z, naturalHeight), + (x, z) -> naturalHeightStream.getDouble(x, z), + this::sampleNaturalOcean, + footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint) )); - riverSurfaceStream = ProceduralStream.of( - (x, z) -> riverRuntime.sample(x, z), - Interpolated.of( - IrisRiverSurfaceSample::terrainHeight, - value -> IrisRiverSurfaceSample.none(value, fluidHeight) - ) - ) - .cache2D("riverSurfaceStream", engine, cacheSize); - } else { - riverSurfaceStream = naturalHeightStream.convert( - value -> IrisRiverSurfaceSample.none(value, fluidHeight) - ) - .cache2D("riverSurfaceStream-disabled", engine, cacheSize); } - heightStream = riverSurfaceStream.convert(IrisRiverSurfaceSample::terrainHeight) + heightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z)) .cache2DDouble("heightStream", engine, cacheSize); roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) .round(); @@ -379,20 +320,18 @@ public class IrisComplex implements DataProvider { .slope(3).cache2DDouble("slopeStream", engine, cacheSize); trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, b -> focusBiome)) - .cache2D("trueBiomeStream-focus", engine, cacheSize) : riverSurfaceStream - .convertAware2D((sample, x, z) -> resolveRiverSurfaceBiome(sample, x, z)) + .cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream + .convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z)) .cache2D("trueBiomeStream", engine, cacheSize); trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) .convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize); - riverDistanceStream = riverSurfaceStream.convert(sample -> sample.river().present() - ? sample.river().distance() - : Double.MAX_VALUE) + riverDistanceStream = ProceduralStream.ofDouble(this::resolveHydrologyDistance) .cache2DDouble("riverDistanceStream", engine, cacheSize); - riverFlowStream = riverSurfaceStream.convert(sample -> (double) sample.river().flow()) + riverFlowStream = ProceduralStream.ofDouble(this::resolveHydrologyFlow) .cache2DDouble("riverFlowStream", engine, cacheSize); - riverCarveWeightStream = riverSurfaceStream.convert(sample -> sample.river().carveWeight()) + riverCarveWeightStream = ProceduralStream.ofDouble(this::resolveHydrologyCarveWeight) .cache2DDouble("riverCarveWeightStream", engine, cacheSize); - riverWaterSurfaceStream = riverSurfaceStream.convert(IrisRiverSurfaceSample::waterSurfaceY) + riverWaterSurfaceStream = ProceduralStream.ofDouble(this::resolveHydrologyFluidSurface) .cache2DDouble("riverWaterSurfaceStream", engine, cacheSize); heightFluidStream = ProceduralStream.ofDouble((x, z) -> Math.max( heightStream.get(x, z), @@ -424,79 +363,204 @@ public class IrisComplex implements DataProvider { //@done } - private boolean blockingRiverRoutingPossible(Engine engine) { - if (blocksRiverRouting(focusRegion == null ? null : focusRegion.getRiverOverride()) - || blocksRiverRouting(focusBiome == null ? null : focusBiome.getRiverOverride())) { - return true; - } - KList regions = engine.getDimension().getAllRegions(engine); - for (IrisRegion loadedRegion : regions) { - if (loadedRegion != null && blocksRiverRouting(loadedRegion.getRiverOverride())) { - return true; - } - } - KList biomes = engine.getDimension().getReachableBiomes(engine); - for (IrisBiome biome : biomes) { - if (biome != null && blocksRiverRouting(biome.getRiverOverride())) { - return true; - } - } - return false; - } - - private static boolean blocksRiverRouting(IrisRiverOverride override) { - return override != null && override.getRoutingPolicy() == IrisRiverRoutingPolicy.BLOCK; - } - - private boolean variableMaxIncisionPossible(Engine engine) { - if (changesMaxIncision(focusRegion == null ? null : focusRegion.getRiverOverride()) - || changesMaxIncision(focusBiome == null ? null : focusBiome.getRiverOverride())) { - return true; - } - KList regions = engine.getDimension().getAllRegions(engine); - for (IrisRegion loadedRegion : regions) { - if (loadedRegion != null && changesMaxIncision(loadedRegion.getRiverOverride())) { - return true; - } - } - KList biomes = engine.getDimension().getReachableBiomes(engine); - for (IrisBiome biome : biomes) { - if (biome != null && changesMaxIncision(biome.getRiverOverride())) { - return true; - } - } - return false; - } - - static boolean changesMaxIncision(IrisRiverOverride override) { - if (override == null || override.getMaxIncisionMultiplier() == null) { - return false; - } - double multiplier = override.getMaxIncisionMultiplier(); - return Double.isFinite(multiplier) && Double.compare(Math.max(0D, multiplier), 1D) != 0; - } - - static boolean biomeRiverOverridesPossible(IrisBiome focusBiome, Iterable naturalBiomes) { - if (focusBiome != null) { - return focusBiome.getRiverOverride() != null; - } - for (IrisBiome biome : naturalBiomes) { - if (biome != null && biome.getRiverOverride() != null) { - return true; - } - } - return false; - } - - static ProceduralStream createNaturalOceanStream( + static InferredType resolveNaturalInferredType( ProceduralStream bridgeStream, - IrisBiome focusBiome + IrisBiome focusBiome, + double x, + double z ) { if (focusBiome != null) { - boolean ocean = focusBiome.getInferredType() == InferredType.SEA; - return ProceduralStream.of((x, z) -> ocean, Interpolated.BOOLEAN); + return focusBiome.getInferredType(); } - return bridgeStream.convert(type -> type == InferredType.SEA); + return bridgeStream.get(x, z); + } + + private IrisHydrologyNaturalSample sampleHydrologyNatural( + Engine engine, + int x, + int z, + double naturalHeight + ) { + InferredType inferredType = resolveNaturalInferredType(bridgeStream, focusBiome, x, z); + IrisRegion region = regionStream.get(x, z); + IrisBiome biome = focusBiome == null + ? sampleNaturalBiome(inferredType, region, naturalHeight, x, z) + : focusBiome; + return new IrisHydrologyNaturalSample( + naturalHeight, + inferredType == InferredType.SEA, + biome, + region + ); + } + + private boolean sampleNaturalOcean(int x, int z) { + return resolveNaturalInferredType(bridgeStream, focusBiome, x, z) == InferredType.SEA; + } + + private double sampleNaturalTerrainHeight(Engine engine, double x, double z) { + double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight()); + return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight); + } + + static double calculateNaturalSlope(double naturalHeight, double easternHeight, double southernHeight) { + double deltaX = easternHeight - naturalHeight; + double deltaZ = southernHeight - naturalHeight; + return Math.sqrt(deltaX * deltaX + deltaZ * deltaZ); + } + + private IrisBiome sampleNaturalBiome( + InferredType inferredType, + IrisRegion region, + double naturalHeight, + double x, + double z + ) { + IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); + if (mapped != null) { + return mapped; + } + IrisBiome baseBiome = implode(sampleInferredBiome(region, inferredType, x, z), x, z); + IrisBiome resolved = resolveNaturalSurfaceBiome(naturalHeight, baseBiome, region, x, z); + return resolved == baseBiome ? baseBiome : implode(resolved, x, z); + } + + private IrisBiome resolveNaturalSurfaceBiome( + double height, + IrisBiome biome, + IrisRegion region, + double x, + double z + ) { + if (biome == null || region == null) { + return biome; + } + double shoreHeight = region.getShoreHeight(x, z); + if (height >= fluidHeight - 1 && height <= fluidHeight + shoreHeight && !biome.isShore()) { + return sampleInferredBiome(region, InferredType.SHORE, x, z); + } + if (height > fluidHeight + shoreHeight && !biome.isLand()) { + return sampleInferredBiome(region, InferredType.LAND, x, z); + } + if (height < fluidHeight && !biome.isAquatic()) { + return sampleInferredBiome(region, InferredType.SEA, x, z); + } + if (height == fluidHeight && !biome.isShore()) { + return sampleInferredBiome(region, InferredType.SHORE, x, z); + } + return biome; + } + + private IrisBiome sampleInferredBiome( + IrisRegion region, + InferredType inferredType, + double x, + double z + ) { + return createInferredBiomeStream(region, inferredType).get(x, z); + } + + private ProceduralStream createInferredBiomeStream( + IrisRegion region, + InferredType inferredType + ) { + return preparedInferredBiomeStream(inferredBiomeStreams, region, inferredType); + } + + private ProceduralStream compileInferredBiomeStream( + Engine engine, + IrisRegion region, + InferredType inferredType, + IrisBiome emptyBiome + ) { + return switch (inferredType) { + case CAVE -> engine.getDimension().getCaveBiomeStyle() + .create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream() + .zoom(engine.getDimension().getBiomeZoom()) + .zoom(region.getCaveBiomeZoom()) + .selectRarity(loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE)) + .onNull(emptyBiome); + case LAND -> engine.getDimension().getLandBiomeStyle() + .create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream() + .zoom(engine.getDimension().getBiomeZoom()) + .zoom(engine.getDimension().getLandZoom()) + .zoom(region.getLandBiomeZoom()) + .selectRarity(loadInferredBiomes(region.getLandBiomes(), InferredType.LAND)); + case SEA -> engine.getDimension().getSeaBiomeStyle() + .create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream() + .zoom(engine.getDimension().getBiomeZoom()) + .zoom(engine.getDimension().getSeaZoom()) + .zoom(region.getSeaBiomeZoom()) + .selectRarity(loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA)); + case SHORE -> engine.getDimension().getShoreBiomeStyle() + .create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream() + .zoom(engine.getDimension().getBiomeZoom()) + .zoom(region.getShoreBiomeZoom()) + .selectRarity(loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE)); + }; + } + + private static boolean hydrologyActive(IrisHydrology hydrology) { + if (hydrology == null) { + return false; + } + IrisRiverHydrology rivers = hydrology.getRivers(); + if (rivers != null && rivers.isEnabled()) { + return true; + } + for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) { + if (deepFluid != null + && deepFluid.getDensity() > 0D + && (deepFluid.isContainedPools() || deepFluid.isShortChannels())) { + return true; + } + } + return false; + } + + private Map> createHydrologyFluidStreams(IrisHydrology hydrology) { + if (hydrology == null) { + return Map.of(); + } + LinkedHashMap> streams = new LinkedHashMap<>(); + IrisRiverHydrology rivers = Objects.requireNonNull(hydrology.getRivers(), "hydrology.rivers"); + int streamIndex = 0; + for (IrisRiverProfile profile : rivers.getProfiles()) { + if (profile == null) { + throw new IllegalArgumentException("hydrology.rivers.profiles cannot contain null entries"); + } + String profileKey = requireHydrologyKey(profile.getId(), "hydrology.rivers.profiles[].id"); + ProceduralStream stream = configuredFluidStream( + profile.getFluidPalette(), + rng.nextParallelRNG(7900 + streamIndex++), + "hydrology river profile " + profileKey + ); + if (streams.putIfAbsent(profileKey, stream) != null) { + throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey); + } + } + for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) { + if (deepFluid == null) { + throw new IllegalArgumentException("hydrology.deepFluids cannot contain null entries"); + } + String profileKey = requireHydrologyKey(deepFluid.getId(), "hydrology.deepFluids[].id"); + ProceduralStream stream = configuredFluidStream( + deepFluid.getFluidPalette(), + rng.nextParallelRNG(8900 + streamIndex++), + "hydrology deep-fluid profile " + profileKey + ); + if (streams.putIfAbsent(profileKey, stream) != null) { + throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey); + } + } + return Collections.unmodifiableMap(streams); + } + + private static String requireHydrologyKey(String key, String path) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException(path + " must not be blank"); + } + return key.trim(); } private ProceduralStream configuredFluidStream( @@ -519,21 +583,81 @@ public class IrisComplex implements DataProvider { return palette.getLayerGenerator(fluidRng, data).stream().select(blocks); } - public PlatformBlockState resolveRiverCaveFluid(RiverCaveFluidKind fluidKind, double x, double z) { - return switch (Objects.requireNonNull(fluidKind)) { - case RIVER -> riverFluidStream.get(x, z); - case DEEP_POOL -> riverDeepPoolFluidStream.get(x, z); - }; + public PlatformBlockState resolveHydrologyFluid(String profileKey, double x, double z) { + String key = Objects.requireNonNull(profileKey, "profileKey").trim(); + ProceduralStream stream = hydrologyFluidStreams.get(key); + if (stream == null) { + throw new IllegalArgumentException("Unknown hydrology fluid profile: " + key); + } + return stream.get(x, z); } public PlatformBlockState resolveSurfaceFluid(double x, double z) { - IrisRiverSurfaceSample sample = riverSurfaceStream.get(x, z); - if (sample.river().present() && sample.surfaceFluid()) { - return riverFluidStream.get(x, z); + HydrologyColumnLayer layer = surfaceFluidLayer(x, z); + if (layer != null) { + return resolveHydrologyFluid(layer.profileKey(), x, z); } return fluidStream.get(x, z); } + private double resolveHydrologyTerrainHeight(double x, double z) { + HydrologyColumnSample sample = hydrologySample(x, z); + return sample == null ? naturalHeightStream.get(x, z) : sample.terrainHeight(); + } + + private double resolveHydrologyDistance(double x, double z) { + HydrologyColumnLayer layer = surfaceLayer(x, z); + if (layer == null) { + return Double.MAX_VALUE; + } + if (layer.channel()) { + return 0D; + } + return layer.shore() ? 1D : 2D; + } + + private double resolveHydrologyFlow(double x, double z) { + HydrologyColumnLayer layer = surfaceFluidLayer(x, z); + if (layer == null) { + return 0D; + } + return layer.feature().flowDeltaX() == 0 && layer.feature().flowDeltaZ() == 0 ? 0D : 1D; + } + + private double resolveHydrologyCarveWeight(double x, double z) { + HydrologyColumnLayer layer = surfaceLayer(x, z); + if (layer == null) { + return 0D; + } + if (layer.channel()) { + return 1D; + } + return layer.shore() ? 0.5D : 0.25D; + } + + private double resolveHydrologyFluidSurface(double x, double z) { + HydrologyColumnLayer layer = surfaceFluidLayer(x, z); + return layer == null ? fluidHeight : layer.fluidHeadY(); + } + + private HydrologyColumnSample hydrologySample(double x, double z) { + return hydrologyRuntime == null ? null : hydrologyRuntime.sample(x, z).orElse(null); + } + + private HydrologyColumnLayer surfaceLayer(double x, double z) { + HydrologyColumnSample sample = hydrologySample(x, z); + return sample == null ? null : sample.primarySurfaceLayer().orElse(null); + } + + private HydrologyColumnLayer surfaceFluidLayer(double x, double z) { + HydrologyColumnSample sample = hydrologySample(x, z); + return sample == null ? null : sample.primarySurfaceFluidLayer().orElse(null); + } + + public boolean hasHydrologySurfaceFluid(int x, int z) { + return surfaceFluidLayer(x, z) != null; + } + public ProceduralStream getBiomeStream(InferredType type) { switch (type) { case CAVE: @@ -555,7 +679,7 @@ public class IrisComplex implements DataProvider { if (engine == null) { throw new IllegalArgumentException("Engine is required to sample procedural terrain height"); } - return getHeight(engine, null, worldX, worldZ, engine.getSeedManager().getHeight()); + return getHeight(engine, worldX, worldZ, engine.getSeedManager().getHeight()); } private IrisRegion findRegion(IrisBiome focus, Engine engine) { @@ -594,46 +718,29 @@ public class IrisComplex implements DataProvider { return null; } - private IrisBiome resolveRiverSurfaceBiome(IrisRiverSurfaceSample sample, double x, double z) { + private IrisBiome resolveHydrologySurfaceBiome(double terrainHeight, double x, double z) { + HydrologyColumnSample sample = hydrologySample(x, z); + HydrologyColumnLayer layer = sample == null ? null : sample.primarySurfaceLayer().orElse(null); IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); - if (mapped != null && (riverRuntime == null || !sample.river().present())) { + if (mapped != null && layer == null) { return mapped; } - if (riverRuntime != null) { - if (sample.subterranean()) { - return fixBiomeType( - sample.naturalHeight(), - baseBiomeStream.get(x, z), - regionStream.get(x, z), - x, - z, - fluidHeight - ); + if (layer != null) { + String biomeKey = hydrologySurfaceBiomeKey(sample); + IrisBiome biome = data.getBiomeLoader().load(biomeKey); + if (biome == null) { + throw new IllegalStateException("Hydrology biome does not exist: " + biomeKey); } - IrisBiome riverBiome = riverRuntime.selectSurfaceBiome(sample, x, z); - if (riverBiome != null) { - return implode(riverBiome, x, z); - } - InferredType directFallback = directRiverFallback(sample.river()); - if (directFallback != null) { - IrisBiome baseBiome = baseBiomeStream.get(x, z); - return implode(baseBiome.withInferredType(directFallback), x, z); - } - if (sample.river().present() - && sample.river().state() == RiverRouteState.WET - && sample.river().section() == RiverSection.BANK) { - return fixBiomeType( - sample.terrainHeight(), - baseBiomeStream.get(x, z), - regionStream.get(x, z), - x, - z, - sample.waterSurfaceY() - ); + if (layer.biomeKey() == null) { + return biome; } + InferredType inferredType = layer.shore() + ? InferredType.SHORE + : layer.connectedFluid() ? InferredType.SEA : InferredType.LAND; + return implode(biome.withInferredType(inferredType), x, z); } return fixBiomeType( - sample.terrainHeight(), + terrainHeight, baseBiomeStream.get(x, z), regionStream.get(x, z), x, @@ -642,17 +749,14 @@ public class IrisComplex implements DataProvider { ); } - static InferredType directRiverFallback(RiverSample river) { - if (!river.present()) { - return null; + static String hydrologySurfaceBiomeKey(HydrologyColumnSample sample) { + Objects.requireNonNull(sample, "sample"); + HydrologyColumnLayer layer = sample.primarySurfaceLayer().orElse(null); + if (layer == null) { + return sample.parentBiomeKey(); } - if (river.state() == RiverRouteState.DRY) { - return InferredType.LAND; - } - return switch (river.section()) { - case CHANNEL, MOUTH -> InferredType.SEA; - default -> null; - }; + String biomeKey = layer.biomeKey(); + return biomeKey == null ? sample.parentBiomeKey() : biomeKey; } private IrisBiome fixBiomeType(Double height, IrisBiome biome, IrisRegion region, Double x, Double z, double fluidHeight) { @@ -713,13 +817,7 @@ public class IrisComplex implements DataProvider { double hi = sampledBounds.max(); double lo = sampledBounds.min(); - double d = 0; - - for (IrisGenerator i : generators) { - d += M.lerp(lo, hi, i.getHeight(x, z, seed + 239945)); - } - - return d / generators.length; + return averageGeneratorHeights(generators, lo, hi, x, z, seed + 239945); } private NoiseBounds gridSampleBounds(Engine engine, IrisInterpolator interpolator, int interpolatorIndex, IrisGenerator[] generators, double x, double z) { @@ -742,6 +840,22 @@ public class IrisComplex implements DataProvider { return new NoiseBounds(boundsLow(b00), boundsHigh(b00)); } + if (fz == 0D) { + long b10 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz); + return new NoiseBounds( + biLerp(boundsLow(b00), boundsLow(b10), boundsLow(b00), boundsLow(b10), fx, fz), + biLerp(boundsHigh(b00), boundsHigh(b10), boundsHigh(b00), boundsHigh(b10), fx, fz) + ); + } + + if (fx == 0D) { + long b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz + grid); + return new NoiseBounds( + biLerp(boundsLow(b00), boundsLow(b00), boundsLow(b01), boundsLow(b01), fx, fz), + biLerp(boundsHigh(b00), boundsHigh(b00), boundsHigh(b01), boundsHigh(b01), fx, fz) + ); + } + long b10 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz); long b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz + grid); long b11 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz + grid); @@ -835,15 +949,54 @@ public class IrisComplex implements DataProvider { ); } - private double getHeight(Engine engine, IrisBiome b, double x, double z, long seed) { + private double getHeight(Engine engine, double x, double z, long seed) { return Math.max(Math.min(getInterpolatedHeight(engine, x, z, seed) + fluidHeight + overlayStream.get(x, z), engine.getHeight()), 0); } - private void prepareInferredBiomes(IrisRegion region) { + private void prepareInferredBiomes(IrisRegion region, KList preparedRegions) { loadInferredBiomes(region.getLandBiomes(), InferredType.LAND); loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE); loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA); loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE); + preparedRegions.add(region); + } + + static Map>> compileInferredBiomeStreams( + Iterable regions, + BiFunction> compiler + ) { + IdentityHashMap>> compiled = new IdentityHashMap<>(); + for (IrisRegion region : regions) { + if (compiled.containsKey(region)) { + continue; + } + EnumMap> regionStreams = new EnumMap<>(InferredType.class); + for (InferredType inferredType : INFERRED_BIOME_PREPARATION_ORDER) { + regionStreams.put(inferredType, Objects.requireNonNull(compiler.apply(region, inferredType))); + } + compiled.put(region, Collections.unmodifiableMap(regionStreams)); + } + return Collections.unmodifiableMap(compiled); + } + + static ProceduralStream preparedInferredBiomeStream( + Map>> streams, + IrisRegion region, + InferredType inferredType + ) { + Map> regionStreams = streams.get(region); + if (regionStreams == null) { + String regionKey = region == null || region.getLoadKey() == null || region.getLoadKey().isBlank() + ? "" + : region.getLoadKey(); + throw new IllegalStateException("Inferred-biome streams were not prepared for region '" + + regionKey + "'."); + } + ProceduralStream stream = regionStreams.get(inferredType); + if (stream == null) { + throw new IllegalStateException("Inferred-biome stream was not prepared for type " + inferredType + "."); + } + return stream; } private KList loadInferredBiomes(KList keys, InferredType type) { @@ -863,6 +1016,37 @@ public class IrisComplex implements DataProvider { generators.computeIfAbsent(cachedGenerator.getInterpolator(), (k) -> new HashSet<>()).add(cachedGenerator); } + static GeneratorGroup[] freezeGeneratorGroups(Map> generators) { + GeneratorGroup[] groups = new GeneratorGroup[generators.size()]; + int groupIndex = 0; + for (Map.Entry> entry : generators.entrySet()) { + IrisGenerator[] groupGenerators = entry.getValue().toArray(new IrisGenerator[0]); + Arrays.sort(groupGenerators, GENERATOR_ORDER); + groups[groupIndex] = new GeneratorGroup(entry.getKey(), groupGenerators); + groupIndex++; + } + Arrays.sort(groups, Comparator.comparing(GeneratorGroup::interpolator, INTERPOLATOR_ORDER)); + return groups; + } + + static double averageGeneratorHeights( + IrisGenerator[] generators, + double low, + double high, + double x, + double z, + long seed + ) { + if (generators.length == 0) { + return 0D; + } + double height = 0D; + for (IrisGenerator generator : generators) { + height += M.lerp(low, high, generator.getHeight(x, z, seed)); + } + return height / generators.length; + } + private Map> buildGeneratorBounds(Engine engine) { Map> bounds = new HashMap<>(); KList allBiomes = new KList<>(generatorBiomes); @@ -1265,9 +1449,12 @@ public class IrisComplex implements DataProvider { } } + record GeneratorGroup(IrisInterpolator interpolator, IrisGenerator[] generators) { + } + public void close() { - if (riverRuntime != null) { - riverRuntime.close(); + if (hydrologyRuntime != null) { + hydrologyRuntime.close(); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java index ad3197840..8423f7a4b 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java @@ -30,7 +30,7 @@ import art.arcane.iris.engine.mantle.MantlePass; import art.arcane.iris.engine.mantle.components.MantleCarvingComponent; import art.arcane.iris.engine.mantle.components.MantleFloatingObjectComponent; import art.arcane.iris.engine.mantle.components.MantleObjectComponent; -import art.arcane.iris.engine.mantle.components.MantleRiverHydrologyComponent; +import art.arcane.iris.engine.mantle.components.MantleHydrologyComponent; import art.arcane.iris.engine.mantle.components.IrisStructureComponent; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.IrisLogging; @@ -75,6 +75,7 @@ import java.util.function.Supplier; @EqualsAndHashCode(exclude = "engine") @ToString(exclude = "engine") public class IrisEngineMantle implements EngineMantle { + public static final String STORAGE_FOLDER_NAME = "mantle-hydrology"; private static final boolean BUKKIT_PRESENT = IrisMatterSupport.isBukkitPresent(); private final Engine engine; private final Mantle mantle; @@ -90,7 +91,7 @@ public class IrisEngineMantle implements EngineMantle { this.mantle = createMantle(engine); components = new KMap<>(); registerComponent(new MantleCarvingComponent(this)); - registerComponent(new MantleRiverHydrologyComponent(this)); + registerComponent(new MantleHydrologyComponent(this)); object = new MantleObjectComponent(this); registerComponent(object); registerComponent(new MantleFloatingObjectComponent(this)); @@ -126,7 +127,7 @@ public class IrisEngineMantle implements EngineMantle { List pass = passes.get(i); int passBlockRadius = pass.stream() .filter(MantleComponent::isEnabled) - .mapToInt(MantleComponent::getRadius) + .mapToInt(MantleComponent::getOutputRadius) .max() .orElse(0); int passInputRadius = pass.stream() @@ -180,19 +181,15 @@ public class IrisEngineMantle implements EngineMantle { disabled.addIfMissing(ReservedFlag.CARVED); } if (disabled.contains(ReservedFlag.CARVED) - || !isRiverHydrologyEnabled(getDimension())) { + || !isHydrologyEnabled(getDimension())) { disabled.addIfMissing(ReservedFlag.RIVER_HYDROLOGY); } return Set.copyOf(disabled); }); } - static boolean isRiverHydrologyEnabled(IrisDimension dimension) { - return MantleRiverHydrologyComponent.isEnabledFor(dimension); - } - - static boolean isRiverCaveHydrologyEnabled(IrisDimension dimension) { - return MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension); + static boolean isHydrologyEnabled(IrisDimension dimension) { + return MantleHydrologyComponent.isEnabledFor(dimension); } @Override @@ -202,7 +199,7 @@ public class IrisEngineMantle implements EngineMantle { private static Mantle createMantle(Engine engine) { IrisMatterSupport.ensureRegistered(); - File dataFolder = new File(engine.getWorld().worldFolder(), "mantle"); + File dataFolder = new File(engine.getWorld().worldFolder(), STORAGE_FOLDER_NAME); int worldHeight = engine.getTarget().getHeight(); MantleDataAdapter adapter = createDataAdapter(engine::getData); MantleHooks hooks = createHooks(EnginePanic.scoped("world " + engine.getWorld().name())); diff --git a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java index c05446024..231c6112f 100644 --- a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java +++ b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java @@ -14,7 +14,6 @@ import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.data.DataProvider; -import art.arcane.volmlib.util.math.M; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.interpolation.NoiseBounds; import art.arcane.iris.spi.PlatformBlockState; @@ -112,13 +111,14 @@ public class UpperDimensionContext implements DataProvider { registerBiomeGenerators(mappedBiome, dataProvider, allBiomes, generators); } + IrisComplex.GeneratorGroup[] generatorGroups = IrisComplex.freezeGeneratorGroups(generators); Map> generatorBounds = new HashMap<>(); - for (Map.Entry> entry : generators.entrySet()) { + for (IrisComplex.GeneratorGroup group : generatorGroups) { IdentityHashMap interpolatorBounds = new IdentityHashMap<>(Math.max(allBiomes.size(), 16)); for (IrisBiome biome : allBiomes) { double min = 0D; double max = 0D; - for (IrisGenerator gen : entry.getValue()) { + for (IrisGenerator gen : group.generators()) { String key = gen.getLoadKey(); if (key == null || key.isBlank()) { continue; @@ -128,7 +128,7 @@ public class UpperDimensionContext implements DataProvider { } interpolatorBounds.put(biome, new NoiseBounds(min, max)); } - generatorBounds.put(entry.getKey(), interpolatorBounds); + generatorBounds.put(group.interpolator(), interpolatorBounds); } ProceduralStream regionStyleStream = upperDim.getRegionStyle() @@ -204,10 +204,10 @@ public class UpperDimensionContext implements DataProvider { return mappedTerrainHeight(imageMapRuntime, fluidHeight, x, z); } double interpolatedHeight = 0; - for (Map.Entry> entry : generators.entrySet()) { - IrisInterpolator interpolator = entry.getKey(); - Set gens = entry.getValue(); - if (gens.isEmpty()) { + for (IrisComplex.GeneratorGroup group : generatorGroups) { + IrisInterpolator interpolator = group.interpolator(); + IrisGenerator[] groupGenerators = group.generators(); + if (groupGenerators.length == 0) { continue; } IdentityHashMap cachedBounds = generatorBounds.get(interpolator); @@ -223,7 +223,7 @@ public class UpperDimensionContext implements DataProvider { } double bMin = 0D; double bMax = 0D; - for (IrisGenerator gen : gens) { + for (IrisGenerator gen : groupGenerators) { String key = gen.getLoadKey(); if (key == null || key.isBlank()) { continue; @@ -239,11 +239,14 @@ public class UpperDimensionContext implements DataProvider { }); double hi = sampledBounds.max(); double lo = sampledBounds.min(); - double d = 0; - for (IrisGenerator gen : gens) { - d += M.lerp(lo, hi, gen.getHeight(x, z, heightSeed + 239945)); - } - interpolatedHeight += d / gens.size(); + interpolatedHeight += IrisComplex.averageGeneratorHeights( + groupGenerators, + lo, + hi, + x, + z, + heightSeed + 239945 + ); } double proceduralHeight = Math.max( Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), chunkHeight), diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/HydrologyFluidLayerSelector.java b/core/src/main/java/art/arcane/iris/engine/actuator/HydrologyFluidLayerSelector.java new file mode 100644 index 000000000..34b2d56fa --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/actuator/HydrologyFluidLayerSelector.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.actuator; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; + +final class HydrologyFluidLayerSelector { + private HydrologyFluidLayerSelector() { + } + + static PlatformBlockState select( + KList seaLayers, + int depth, + PlatformBlockState fluid, + boolean hydrologyOwned + ) { + if (!hydrologyOwned && seaLayers != null && seaLayers.hasIndex(depth)) { + return seaLayers.get(depth); + } + return fluid; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java index e7f1dae4d..c57cd9597 100644 --- a/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java +++ b/core/src/main/java/art/arcane/iris/engine/actuator/IrisDecorantActuator.java @@ -26,9 +26,10 @@ import art.arcane.iris.engine.decorator.IrisSurfaceDecorator; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.EngineAssignedActuator; import art.arcane.iris.engine.framework.EngineDecorator; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.documentation.BlockCoordinates; @@ -52,7 +53,7 @@ public class IrisDecorantActuator extends EngineAssignedActuator output, + int x, + int z, + int height, + int fluidHeight + ) { + return height < fluidHeight + && height + 1 < output.getHeight() + && IrisSurfaceDecorator.hasConnectedWater(output, x, height + 1, z); + } + + static boolean hasConnectedWaterColumn( + Hunk output, + int x, + int z, + int lowerY, + int upperY + ) { + int boundedLowerY = Math.max(0, lowerY); + int boundedUpperY = Math.min(output.getHeight() - 1, upperY); + if (boundedLowerY > boundedUpperY) { + return false; + } + for (int y = boundedLowerY; y <= boundedUpperY; y++) { + if (!IrisSurfaceDecorator.hasConnectedWater(output, x, y, z)) { + return false; + } + } + return true; + } + + static void restoreUnsupportedAquaticPlacement( + Hunk output, + int x, + int y, + int z, + PlatformBlockState original + ) { + PlatformBlockState placed = output.get(x, y, z); + if (placed != original && IrisSurfaceDecorator.isAquaticPlacement(placed)) { + output.set(x, y, z, original); + } + } + + static boolean isFallingWaterfallThroat(HydrologyColumnSample sample) { + if (sample == null) { + return false; + } + for (HydrologyColumnLayer layer : sample.layers()) { + if ((layer.feature().type() == HydrologyFeatureType.WATERFALL + || layer.feature().type() == HydrologyFeatureType.SINKHOLE) + && layer.channel() + && layer.fallingFluid()) { + return true; + } + } + return false; } @BlockCoordinates @@ -80,7 +153,6 @@ public class IrisDecorantActuator extends EngineAssignedActuator biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators(); @@ -159,15 +169,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator he && i <= hf) { int fdepth = hf - i; - if (fblocks == null) { + if (hydrologyFluid == null && fblocks == null) { fblocks = biome.generateSeaLayers(realX, realZ, localRng, hf - he, data); } - - if (fblocks.hasIndex(fdepth)) { - h.setRaw(xf, i, zf, fblocks.get(fdepth)); - } else { - h.setRaw(xf, i, zf, fluid); - } + h.setRaw(xf, i, zf, HydrologyFluidLayerSelector.select( + fblocks, + fdepth, + fluid, + hydrologyFluid != null + )); continue; } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java index 2dfd9b2ad..48297bf8b 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisShoreLineDecorator.java @@ -54,6 +54,32 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { return; } + place(x, z, realX, realZ, data, biome, height, max); + } + + public void decorateAcceptedShore( + int x, + int z, + int realX, + int realZ, + Hunk data, + IrisBiome biome, + int height, + int max + ) { + place(x, z, realX, realZ, data, biome, height, max); + } + + private void place( + int x, + int z, + int realX, + int realZ, + Hunk data, + IrisBiome biome, + int height, + int max + ) { RNG rng = getRNG(realX, realZ); IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ); @@ -71,6 +97,8 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { return; } + IrisSurfaceDecorator.AquaticPlacementSnapshot aquaticSnapshot = IrisSurfaceDecorator.captureAquaticPlacement( + decorator, getData(), data, x, z, height, max); if (!decorator.isStacking()) { int targetY = height + 1; if (targetY >= data.getHeight() @@ -80,6 +108,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) { data.set(x, targetY, z, block); + aquaticSnapshot.restoreIfUnsupported(data, x, z); } return; } @@ -100,6 +129,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData()); if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) { data.set(x, targetY, z, block); + aquaticSnapshot.restoreIfUnsupported(data, x, z); } return; } @@ -123,5 +153,6 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator { } data.set(x, targetY, z, block); } + aquaticSnapshot.restoreIfUnsupported(data, x, z); } } diff --git a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java index cf743a475..f1406219c 100644 --- a/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java +++ b/core/src/main/java/art/arcane/iris/engine/decorator/IrisSurfaceDecorator.java @@ -18,11 +18,13 @@ package art.arcane.iris.engine.decorator; +import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorator; +import art.arcane.iris.engine.object.IrisProceduralBlocks; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.volmlib.util.math.RNG; @@ -64,6 +66,9 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { } boolean underwater = isUnderwater(inferredType, height, fluidHeight); + if (underwater && !hasFluidAbove(data, x, height, z)) { + return; + } boolean caveSkipFluid = skipsFluid(inferredType); RNG rng = getRNG(realX, realZ); IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ); @@ -72,6 +77,8 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { return; } + AquaticPlacementSnapshot aquaticSnapshot = captureAquaticPlacement( + decorator, getData(), data, x, z, height, max); if (decorator.isStacking()) { DecoratorCore.PlaceOpts opts = DecoratorCore.SCRATCH_OPTS.get(); opts.reset(); @@ -79,11 +86,13 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { opts.fluidHeight = fluidHeight; opts.caveSkipFluid = caveSkipFluid; DecoratorCore.placeStackUp(decorator, x, z, realX, realZ, height, max, data, rng, getData(), opts); + aquaticSnapshot.restoreIfUnsupported(data, x, z); return; } DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ, data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle()); + aquaticSnapshot.restoreIfUnsupported(data, x, z); } static boolean isUnderwater(InferredType inferredType, int height, int fluidHeight) { @@ -93,4 +102,148 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { static boolean skipsFluid(InferredType inferredType) { return inferredType == InferredType.CAVE; } + + static boolean hasFluidAbove(Hunk data, int x, int height, int z) { + return height + 1 < data.getHeight() && data.get(x, height + 1, z).isFluid(); + } + + public static boolean isAquaticPlacement(PlatformBlockState state) { + if (state == null) { + return false; + } + if (state.isWaterLogged()) { + return true; + } + + String material = IrisProceduralBlocks.materialKey(state); + if (material.equals("minecraft:seagrass") + || material.equals("minecraft:tall_seagrass") + || material.equals("minecraft:kelp") + || material.equals("minecraft:kelp_plant") + || material.equals("minecraft:sea_pickle")) { + return true; + } + return material.startsWith("minecraft:") + && material.contains("_coral") + && !material.contains(":dead_"); + } + + public static boolean hasConnectedWater(Hunk data, int x, int y, int z) { + if (y < 0 || y >= data.getHeight() || !isWater(data.get(x, y, z))) { + return false; + } + return isWaterNeighbor(data, x - 1, y, z) + || isWaterNeighbor(data, x + 1, y, z) + || isWaterNeighbor(data, x, y, z - 1) + || isWaterNeighbor(data, x, y, z + 1); + } + + static AquaticPlacementSnapshot captureAquaticPlacement( + IrisDecorator decorator, + IrisData irisData, + Hunk data, + int x, + int z, + int height, + int max + ) { + if (!mayPlaceAquatic(decorator, irisData)) { + return AquaticPlacementSnapshot.EMPTY; + } + + int highestOffset = decorator.isStacking() + ? maximumStackHeight(decorator, max) + : 2; + int lowerY = Math.max(0, height); + int upperY = Math.min(data.getHeight() - 1, height + highestOffset); + if (lowerY > upperY) { + return AquaticPlacementSnapshot.EMPTY; + } + + PlatformBlockState[] originals = new PlatformBlockState[upperY - lowerY + 1]; + boolean[] connectedWater = new boolean[originals.length]; + for (int i = 0; i < originals.length; i++) { + int y = lowerY + i; + originals[i] = data.get(x, y, z); + connectedWater[i] = hasConnectedWater(data, x, y, z); + } + return new AquaticPlacementSnapshot(lowerY, originals, connectedWater); + } + + private static boolean mayPlaceAquatic(IrisDecorator decorator, IrisData irisData) { + PlatformBlockState[] palette = decorator.getBlockDataArray(irisData); + if (palette != null) { + for (PlatformBlockState state : palette) { + if (isAquaticPlacement(state)) { + return true; + } + } + } + PlatformBlockState[] topPalette = decorator.getBlockDataTopsArray(irisData); + if (topPalette != null) { + for (PlatformBlockState state : topPalette) { + if (isAquaticPlacement(state)) { + return true; + } + } + } + return decorator.getForceBlock() != null + && isAquaticPlacement(decorator.getForceBlock().getBlockData(irisData)); + } + + private static int maximumStackHeight(IrisDecorator decorator, int max) { + if (decorator.isScaleStack()) { + return Math.max(1, decorator.getAbsoluteMaxStack()); + } + return Math.max(1, Math.min(Math.max(1, max), decorator.getStackMax())); + } + + private static boolean isWaterNeighbor(Hunk data, int x, int y, int z) { + if (x < 0 || x >= data.getWidth() || z < 0 || z >= data.getDepth()) { + return false; + } + return isWater(data.get(x, y, z)); + } + + private static boolean isWater(PlatformBlockState state) { + return state != null && state.isWater(); + } + + static final class AquaticPlacementSnapshot { + private static final AquaticPlacementSnapshot EMPTY = new AquaticPlacementSnapshot( + 0, new PlatformBlockState[0], new boolean[0]); + + private final int lowerY; + private final PlatformBlockState[] originals; + private final boolean[] connectedWater; + + private AquaticPlacementSnapshot( + int lowerY, + PlatformBlockState[] originals, + boolean[] connectedWater + ) { + this.lowerY = lowerY; + this.originals = originals; + this.connectedWater = connectedWater; + } + + void restoreIfUnsupported(Hunk data, int x, int z) { + if (!containsUnsupportedAquatic(data, x, z)) { + return; + } + for (int i = 0; i < originals.length; i++) { + data.set(x, lowerY + i, z, originals[i]); + } + } + + private boolean containsUnsupportedAquatic(Hunk data, int x, int z) { + for (int i = 0; i < originals.length; i++) { + PlatformBlockState placed = data.get(x, lowerY + i, z); + if (placed != originals[i] && isAquaticPlacement(placed) && !connectedWater[i]) { + return true; + } + } + return false; + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java index e77b35d36..f4728a3ff 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Engine.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Engine.java @@ -42,8 +42,8 @@ import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisWorld; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; -import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBlockState; @@ -246,7 +246,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default IrisBiome getCaveOrMantleBiome(int x, int y, int z) { - RiverCaveHydrology hydrology = RiverCaveHydrologyStorage.getIfPresent( + HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent( getMantle().getMantle(), x, y, z); if (hydrology != null && !hydrology.floodedBiomeKey().isEmpty()) { IrisBiome biome = getData().getBiomeLoader().load(hydrology.floodedBiomeKey()); @@ -455,6 +455,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, default PlacedObject getObjectPlacement(int x, int y, int z, MantleChunk chunk) { String objectAt = chunk.get(x & 15, y, z & 15, String.class); + return resolveObjectPlacementMarker(x, z, objectAt); + } + + default PlacedObject resolveObjectPlacementMarker(int x, int z, @Nullable String objectAt) { if (objectAt == null || objectAt.isEmpty()) { return null; } @@ -562,7 +566,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, return; } if (IrisSettings.get().getPerformance().isTrimMantleInStudio() || !isStudio()) { - getMantle().cleanupChunk(x, z); + getMantle().cleanupChunksCoveredBy( + x, + z, + false, + EngineMantle.ChunkCleanupCallback.NONE + ); } } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java index b2224af96..4170c5ed4 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/EnginePlatformHooks.java @@ -19,6 +19,7 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.mantle.MantleComponent; import art.arcane.volmlib.util.collection.KList; public interface EnginePlatformHooks { @@ -70,4 +71,8 @@ public interface EnginePlatformHooks { default boolean shouldBypassMantleStages(Engine engine) { return false; } + + default boolean shouldGenerateMantleComponent(Engine engine, MantleComponent component) { + return true; + } } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java index 78794dc00..f0bd5a62b 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/HintedLocator.java @@ -328,7 +328,11 @@ public final class HintedLocator implements Locator { } if (!surfaceRegions.isEmpty()) { - return SearchPlan.of(surfaceCoarse(complex, surfaceRegions, landHosts, seaHosts, shoreHosts), BIOME_STRIDE_CHUNKS, null); + return SearchPlan.of( + surfaceCoarse(complex, surfaceRegions, landHosts, seaHosts, shoreHosts), + BIOME_STRIDE_CHUNKS, + null + ); } if (!caveRegions.isEmpty()) { @@ -341,6 +345,11 @@ public final class HintedLocator implements Locator { return SearchPlan.of(coarse, BIOME_STRIDE_CHUNKS, caveExact); } + for (IrisBiome reachableBiome : engine.getDimension().getReachableBiomes(engine)) { + if (reachableBiome != null && biomeKey.equals(reachableBiome.getLoadKey())) { + return SearchPlan.unpruned(); + } + } return SearchPlan.impossible(); } @@ -576,6 +585,36 @@ public final class HintedLocator implements Locator { return false; } + private KList closure(String rootKey) { + KList biomes = new KList<>(); + if (rootKey == null) { + return biomes; + } + + KSet visited = new KSet<>(); + ArrayDeque queue = new ArrayDeque<>(); + queue.add(rootKey); + + while (!queue.isEmpty()) { + String key = queue.poll(); + if (!visited.add(key)) { + continue; + } + + IrisBiome biome = load(key); + if (biome == null) { + continue; + } + biomes.add(biome); + for (String child : biome.getChildren()) { + if (child != null && !visited.contains(child)) { + queue.add(child); + } + } + } + return biomes; + } + private boolean closurePlacesObject(String rootKey, String objectKey) { if (rootKey == null || objectKey == null) { return false; diff --git a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java index 3fcc116b9..56acbcfc6 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/Locator.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/Locator.java @@ -21,6 +21,7 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.nms.container.BlockPos; import art.arcane.iris.core.nms.container.Pair; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisRegion; @@ -53,10 +54,22 @@ public interface Locator { } static Locator surfaceBiome(String loadKey) { - Locator exact = (e, c) -> e.getSurfaceBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); + Locator exact = (e, c) -> chunkContainsSurfaceBiome(e, c, loadKey); return new HintedLocator<>(exact, (engine) -> HintedLocator.biomePlan(engine, loadKey)); } + static boolean chunkContainsSurfaceBiome(Engine engine, Position2 chunk, String loadKey) { + int minimumX = chunk.getX() << 4; + int minimumZ = chunk.getZ() << 4; + IrisBiome center = engine.getSurfaceBiome(minimumX + 8, minimumZ + 8); + if (center != null && loadKey.equals(center.getLoadKey())) { + return true; + } + IrisHydrologyRuntime hydrology = engine.getComplex().getHydrologyRuntime(); + return hydrology != null + && hydrology.hasAcceptedSurfaceBiomeInChunk(loadKey, chunk.getX(), chunk.getZ()); + } + static Locator structure(String key) { return (e, c) -> IrisStructureLocator.startsInChunk(e, key, c.getX(), c.getZ()); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java b/core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java index 2aba47f07..75a715f29 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolver.java @@ -3,8 +3,8 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructurePlacement; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; -import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterCavern; @@ -288,7 +288,7 @@ public final class StructureCaveAnchorResolver { int mantleY, int blockZ ) { - RiverCaveHydrology hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); + HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ); return acceptsAnchorFluid( placement.isUnderwater(), @@ -310,7 +310,7 @@ public final class StructureCaveAnchorResolver { static boolean acceptsAnchorFluid( boolean underwater, MatterCavern cavern, - RiverCaveHydrology hydrology, + HydrologyCaveCell hydrology, int mantleY, int defaultLavaHeight ) { @@ -332,12 +332,12 @@ public final class StructureCaveAnchorResolver { private static MatterCavern cavernAt(Engine engine, int blockX, int mantleY, int blockZ) { MatterCavern baseline = engine.getMantle().getMantle() .get(blockX, mantleY, blockZ, MatterCavern.class); - RiverCaveHydrology hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); + HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); return hydrology == null ? baseline : hydrology.asCavern(); } - private static RiverCaveHydrology hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) { - return RiverCaveHydrologyStorage.getIfPresent( + private static HydrologyCaveCell hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) { + return HydrologyCaveStorage.getIfPresent( engine.getMantle().getMantle(), blockX, mantleY, blockZ); } diff --git a/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java b/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java index efad60ace..c95073fde 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/render/IrisRenderer.java @@ -20,21 +20,29 @@ package art.arcane.iris.engine.framework.render; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyCandidateKind; +import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate; +import art.arcane.iris.engine.hydrology.HydrologyDiagnosticRenderSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydrologyRenderSample; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiomeGeneratorLink; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; import art.arcane.iris.util.project.stream.ProceduralStream; import java.awt.Color; import java.awt.image.BufferedImage; import java.awt.image.DataBufferInt; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.IdentityHashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.CancellationException; import java.util.function.BooleanSupplier; @@ -43,11 +51,22 @@ public final class IrisRenderer { private static final int BLUE = new Color(45, 91, 156).getRGB(); private static final int YELLOW = new Color(211, 164, 67).getRGB(); private static final int GREEN = new Color(78, 137, 83).getRGB(); + private static final int HEADWATER = new Color(99, 184, 230).getRGB(); + private static final int HEADWATER_DIRECTION = new Color(13, 38, 55).getRGB(); private static final int RIVER_CHANNEL = new Color(48, 112, 190).getRGB(); + private static final int RIVER_RIFFLE = new Color(96, 166, 214).getRGB(); + private static final int RIVER_CASCADE = new Color(135, 204, 232).getRGB(); + private static final int SUBTERRANEAN_CHANNEL = new Color(102, 86, 196).getRGB(); + private static final int RIDGE_TUNNEL = new Color(120, 99, 174).getRGB(); + private static final int COASTAL_GROTTO = new Color(51, 174, 174).getRGB(); + private static final int INLAND_GROTTO = new Color(116, 76, 150).getRGB(); + private static final int RIVER_SINKHOLE = new Color(176, 146, 232).getRGB(); private static final int RIVER_MOUTH = new Color(54, 164, 205).getRGB(); - private static final int RIVER_BANK = new Color(92, 146, 78).getRGB(); - private static final int DRY_CHANNEL = new Color(171, 128, 68).getRGB(); - private static final int DRY_BANK = new Color(132, 105, 62).getRGB(); + private static final int RIVER_WATERFALL = new Color(190, 236, 255).getRGB(); + private static final int CONFIRMED_DEEP_LAVA = new Color(194, 50, 22).getRGB(); + private static final int PROJECTED_SOURCE = new Color(225, 75, 178).getRGB(); + private static final int PROJECTED_OUTLET = new Color(235, 145, 54).getRGB(); + private static final int PROJECTED_DEEP_FLUID = new Color(175, 70, 118).getRGB(); private static final int NO_RIVER = new Color(28, 31, 38).getRGB(); private static final int DEEP_WATER = new Color(20, 48, 92).getRGB(); private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB(); @@ -150,15 +169,53 @@ public final class IrisRenderer { return image; } - public static int riverColor(RiverSection section) { - Objects.requireNonNull(section, "section"); - return switch (section) { - case CHANNEL -> RIVER_CHANNEL; + public static int waterfallColor() { + return RIVER_WATERFALL; + } + + public static int surfaceWaterColor() { + return RIVER_CHANNEL; + } + + public static int subterraneanColor() { + return SUBTERRANEAN_CHANNEL; + } + + public static int confirmedDeepLavaColor() { + return CONFIRMED_DEEP_LAVA; + } + + public static int hydrologyFeatureColor(HydrologyFeatureType type) { + Objects.requireNonNull(type, "type"); + return switch (type) { + case SURFACE_POOL -> RIVER_CHANNEL; + case RIFFLE -> RIVER_RIFFLE; + case CASCADE -> RIVER_CASCADE; + case WATERFALL -> RIVER_WATERFALL; + case SINKHOLE -> RIVER_SINKHOLE; + case RIDGE_BORE -> RIDGE_TUNNEL; + case UNDERGROUND_POOL, UNDERGROUND_DROP -> SUBTERRANEAN_CHANNEL; + case COASTAL_GROTTO -> COASTAL_GROTTO; + case INLAND_GROTTO -> INLAND_GROTTO; case MOUTH -> RIVER_MOUTH; - case BANK -> RIVER_BANK; - case DRY_CHANNEL -> DRY_CHANNEL; - case DRY_BANK -> DRY_BANK; - case NONE -> NO_RIVER; + case DEEP_POOL, DEEP_CHANNEL -> CONFIRMED_DEEP_LAVA; + }; + } + + public static int headwaterColor() { + return HEADWATER; + } + + public static int headwaterDirectionColor() { + return HEADWATER_DIRECTION; + } + + public static int hydrologyDiagnosticColor(HydrologyCandidateKind kind) { + Objects.requireNonNull(kind, "kind"); + return switch (kind) { + case SOURCE -> PROJECTED_SOURCE; + case OUTLET -> PROJECTED_OUTLET; + case DEEP_FLUID -> PROJECTED_DEEP_FLUID; }; } @@ -277,10 +334,11 @@ public final class IrisRenderer { BooleanSupplier cancelled, boolean composite ) { - IrisRiverRuntime runtime = complex.getRiverRuntime(); + IrisHydrologyRuntime runtime = complex.getHydrologyRuntime(); if (runtime == null) { return; } + Map headwaters = new LinkedHashMap<>(); int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step))); int blockPixels = Integer.highestOneBit(maximumPixels); for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) { @@ -295,6 +353,8 @@ public final class IrisRenderer { runtime, cancelled, composite, + !composite, + headwaters, pixelX, pixelZ, Math.min(blockPixels, resolution - pixelX), @@ -302,6 +362,7 @@ public final class IrisRenderer { ); } } + renderHeadwaterDirections(pixels, resolution, startX, startZ, step, headwaters); } private static void renderRiverBlock( @@ -310,58 +371,163 @@ public final class IrisRenderer { double startX, double startZ, double step, - IrisRiverRuntime runtime, + IrisHydrologyRuntime runtime, BooleanSupplier cancelled, boolean composite, + boolean diagnostics, + Map headwaters, int pixelX, int pixelZ, int width, int height ) { checkCancelled(cancelled); - RiverSample sample = runtime.sampleFootprint( + HydrologyRenderSample sample = runtime.sampleRenderFootprint( startX + pixelX * step, startZ + pixelZ * step, startX + (pixelX + width) * step, startZ + (pixelZ + height) * step ); - if (!sample.present()) { + HydrologyDiagnosticRenderSample diagnosticSample = diagnostics + ? runtime.sampleDiagnosticFootprint( + startX + pixelX * step, + startZ + pixelZ * step, + startX + (pixelX + width) * step, + startZ + (pixelZ + height) * step + ) + : null; + collectHeadwaters(sample, headwaters); + if (!sample.present() && (diagnosticSample == null || !diagnosticSample.present())) { return; } if (width == 1 && height == 1) { int index = pixelZ * resolution + pixelX; - int color = riverColor(sample.section()); - pixels[index] = composite ? riverCompositeColor(pixels[index], sample.section(), color) : color; + int color = sample.present() + ? hydrologyColor(sample) + : hydrologyDiagnosticColor(diagnosticSample); + pixels[index] = composite + ? blend(pixels[index], color, 0.72D) + : color; return; } int leftWidth = Math.max(1, width / 2); int rightWidth = width - leftWidth; int topHeight = Math.max(1, height / 2); int bottomHeight = height - topHeight; - renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, + renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics, + headwaters, pixelX, pixelZ, leftWidth, topHeight); if (rightWidth > 0) { - renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, + renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics, + headwaters, pixelX + leftWidth, pixelZ, rightWidth, topHeight); } if (bottomHeight > 0) { - renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, + renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics, + headwaters, pixelX, pixelZ + topHeight, leftWidth, bottomHeight); if (rightWidth > 0) { - renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, + renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics, + headwaters, pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight); } } } - private static int riverCompositeColor(int base, RiverSection section, int river) { - return switch (section) { - case CHANNEL, MOUTH -> river; - case BANK -> blend(base, river, 0.68D); - case DRY_CHANNEL -> blend(base, river, 0.88D); - case DRY_BANK -> blend(base, river, 0.58D); - case NONE -> base; - }; + private static void collectHeadwaters( + HydrologyRenderSample sample, + Map headwaters + ) { + for (HydrologyFeatureRef feature : sample.features()) { + if (!feature.source()) { + continue; + } + HydrologyFeatureRef existing = headwaters.putIfAbsent(feature.id(), feature); + if (existing != null && !existing.equals(feature)) { + throw new IllegalStateException("Hydrology headwater feature id collision in renderer footprint."); + } + } + } + + private static void renderHeadwaterDirections( + int[] pixels, + int resolution, + double startX, + double startZ, + double step, + Map headwaters + ) { + if (resolution < 5 || headwaters.isEmpty()) { + return; + } + ArrayList ordered = new ArrayList<>(headwaters.values()); + ordered.sort(Comparator.comparingLong(HydrologyFeatureRef::id)); + for (HydrologyFeatureRef feature : ordered) { + int pixelX = worldToPixel(feature.x(), startX, step, resolution); + int pixelZ = worldToPixel(feature.z(), startZ, step, resolution); + drawHeadwaterDirection( + pixels, + resolution, + pixelX, + pixelZ, + feature.flowDeltaX(), + feature.flowDeltaZ() + ); + } + } + + private static int worldToPixel(int coordinate, double start, double step, int resolution) { + int pixel = (int) StrictMath.floor((coordinate + 0.5D - start) / step); + int markerRadius = 2; + return Math.max(markerRadius, Math.min(resolution - markerRadius - 1, pixel)); + } + + private static void drawHeadwaterDirection( + int[] pixels, + int resolution, + int centerX, + int centerZ, + int flowX, + int flowZ + ) { + int markerRadius = Math.min(2, (resolution - 1) / 2); + for (int deltaZ = -markerRadius; deltaZ <= markerRadius; deltaZ++) { + for (int deltaX = -markerRadius; deltaX <= markerRadius; deltaX++) { + setPixel(pixels, resolution, centerX + deltaX, centerZ + deltaZ, HEADWATER); + } + } + if (flowX == 0 && flowZ == 0) { + setPixel(pixels, resolution, centerX, centerZ, HEADWATER_DIRECTION); + return; + } + setPixel(pixels, resolution, centerX - flowX * 2, centerZ - flowZ * 2, HEADWATER_DIRECTION); + setPixel(pixels, resolution, centerX - flowX, centerZ - flowZ, HEADWATER_DIRECTION); + setPixel(pixels, resolution, centerX, centerZ, HEADWATER_DIRECTION); + setPixel(pixels, resolution, centerX + flowX, centerZ + flowZ, HEADWATER_DIRECTION); + int tipX = centerX + flowX * 2; + int tipZ = centerZ + flowZ * 2; + setPixel(pixels, resolution, tipX, tipZ, HEADWATER_DIRECTION); + setPixel( + pixels, + resolution, + tipX - flowX - flowZ, + tipZ - flowZ + flowX, + HEADWATER_DIRECTION + ); + setPixel( + pixels, + resolution, + tipX - flowX + flowZ, + tipZ - flowZ - flowX, + HEADWATER_DIRECTION + ); + } + + private static void setPixel(int[] pixels, int resolution, int x, int z, int color) { + if (x < 0 || x >= resolution || z < 0 || z >= resolution) { + return; + } + pixels[z * resolution + x] = color; } private PixelShader biomeShader(ProceduralStream stream, RenderType currentType) { @@ -399,19 +565,47 @@ public final class IrisRenderer { } private int riverColor(IrisComplex complex, double x, double z, double step) { - IrisRiverRuntime runtime = complex.getRiverRuntime(); + IrisHydrologyRuntime runtime = complex.getHydrologyRuntime(); if (runtime == null) { - return riverColor(RiverSection.NONE); + return NO_RIVER; } double endX = x + step; double endZ = z + step; - RiverSample sample = runtime.sampleFootprint( + HydrologyRenderSample sample = runtime.sampleRenderFootprint( StrictMath.min(x, endX), StrictMath.min(z, endZ), StrictMath.max(x, endX), StrictMath.max(z, endZ) ); - return riverColor(sample.section()); + if (sample.present()) { + return hydrologyColor(sample); + } + return hydrologyDiagnosticColor(runtime.sampleDiagnosticFootprint( + StrictMath.min(x, endX), + StrictMath.min(z, endZ), + StrictMath.max(x, endX), + StrictMath.max(z, endZ) + )); + } + + static int hydrologyColor(HydrologyRenderSample sample) { + Objects.requireNonNull(sample, "sample"); + HydrologyFeatureRef feature = sample.primaryFeature().orElse(null); + if (feature == null) { + return NO_RIVER; + } + if (feature.source()) { + return HEADWATER; + } + return hydrologyFeatureColor(feature.type()); + } + + static int hydrologyDiagnosticColor(HydrologyDiagnosticRenderSample sample) { + Objects.requireNonNull(sample, "sample"); + HydrologyDiagnosticCandidate candidate = sample.candidates().isEmpty() + ? null + : sample.candidates().getFirst(); + return candidate == null ? NO_RIVER : hydrologyDiagnosticColor(candidate.kind()); } private int continentColor(double x, double z) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/DrainageEdge.java b/core/src/main/java/art/arcane/iris/engine/hydrology/DrainageEdge.java new file mode 100644 index 000000000..3a226985e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/DrainageEdge.java @@ -0,0 +1,36 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.List; +import java.util.Objects; + +public record DrainageEdge( + long id, + long upstreamNodeId, + long downstreamNodeId, + long outletId, + double cost, + int contributingSurfaceSources, + int contributingUndergroundSources, + List centerline +) { + public DrainageEdge { + if (upstreamNodeId == downstreamNodeId) { + throw new IllegalArgumentException("A drainage edge cannot point to the same node."); + } + if (!Double.isFinite(cost) || cost <= 0D) { + throw new IllegalArgumentException("Drainage edge cost must be finite and positive."); + } + if (contributingSurfaceSources < 0 || contributingUndergroundSources < 0) { + throw new IllegalArgumentException("Contributing source counts cannot be negative."); + } + Objects.requireNonNull(centerline, "centerline"); + centerline = List.copyOf(centerline); + if (centerline.size() < 2) { + throw new IllegalArgumentException("A drainage edge requires at least two centerline points."); + } + } + + public int totalContributingSources() { + return contributingSurfaceSources + contributingUndergroundSources; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/DrainageNode.java b/core/src/main/java/art/arcane/iris/engine/hydrology/DrainageNode.java new file mode 100644 index 000000000..1bba87bd1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/DrainageNode.java @@ -0,0 +1,26 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.Objects; + +public record DrainageNode( + long id, + int x, + int z, + HydrologyTerrainSample terrain, + double potential, + long outletId +) { + public DrainageNode { + Objects.requireNonNull(terrain, "terrain"); + if (!Double.isFinite(potential) || potential < 0D) { + throw new IllegalArgumentException("Drainage potential must be finite and non-negative."); + } + if (terrain.ocean()) { + throw new IllegalArgumentException("Drainage nodes must remain landward of the first ocean column."); + } + } + + public HydrologyPoint naturalPoint() { + return new HydrologyPoint(x, terrain.naturalHeight(), z); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicSegment.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicSegment.java new file mode 100644 index 000000000..736d2b7dd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydraulicSegment.java @@ -0,0 +1,66 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.List; +import java.util.Objects; + +public record HydraulicSegment( + long id, + long courseId, + HydrologyFeatureType type, + int upstreamHeadY, + int downstreamHeadY, + int width, + int depth, + boolean fallingFluid, + boolean receivingPool, + List centerline +) { + public HydraulicSegment { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(centerline, "centerline"); + centerline = List.copyOf(centerline); + if (centerline.isEmpty()) { + throw new IllegalArgumentException("A hydraulic segment requires a centerline."); + } + if (upstreamHeadY < downstreamHeadY) { + throw new IllegalArgumentException("Hydraulic heads cannot rise downstream."); + } + if (width <= 0 || depth <= 0) { + throw new IllegalArgumentException("Hydraulic segment width and depth must be positive."); + } + int drop = upstreamHeadY - downstreamHeadY; + if (fallingFluid && drop == 0) { + throw new IllegalArgumentException("Falling fluid requires a positive head transition."); + } + if (drop > 0 && !type.isDrop()) { + throw new IllegalArgumentException("A positive head transition requires a drop segment type."); + } + if (drop == 0 && type.isDrop()) { + throw new IllegalArgumentException("A drop segment type requires a positive head transition."); + } + if (drop > 0 && !fallingFluid) { + if (centerline.getFirst().y() != upstreamHeadY || centerline.getLast().y() != downstreamHeadY) { + throw new IllegalArgumentException("A graded head transition must span its declared heads."); + } + int previousHead = upstreamHeadY; + for (HydrologyPoint point : centerline) { + if (point.y() > previousHead || point.y() < downstreamHeadY) { + throw new IllegalArgumentException("A graded head transition must descend monotonically."); + } + previousHead = point.y(); + } + } + } + + public int drop() { + return upstreamHeadY - downstreamHeadY; + } + + public HydrologyPoint start() { + return centerline.getFirst(); + } + + public HydrologyPoint end() { + return centerline.getLast(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java new file mode 100644 index 000000000..64e0f587f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.hydrology; + +public enum HydrologyCandidateKind { + SOURCE, + OUTLET, + DEEP_FLUID +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java new file mode 100644 index 000000000..e7dea19f8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java @@ -0,0 +1,25 @@ +package art.arcane.iris.engine.hydrology; + +public enum HydrologyCandidateRejection { + NO_LEGAL_OUTLET, + NO_DRAINAGE_PATH, + ROUTE_LIMIT, + COURSE_TOO_SHORT, + SOURCE_QUOTA, + SOURCE_SPACING, + OUTLET_LIMIT, + RIDGE_CONTAINMENT, + OUTLET_LEVEL, + CAVE_CONTAINMENT, + VOLUME_LIMIT, + SURFACE_HEAD_RANGE, + SURFACE_HEAD_DISTRIBUTION, + SURFACE_RIDGE_TUNNEL_LIMIT, + SURFACE_EXPOSURE, + SURFACE_DROP_UNSUPPORTED, + SURFACE_CORRIDOR_UNSUPPORTED, + SURFACE_TRANSITION_UNSUPPORTED, + SURFACE_SEGMENT_UNSUPPORTED, + SURFACE_SINKHOLE_CLEARANCE, + SURFACE_SHAPE_UNSUPPORTED +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java new file mode 100644 index 000000000..132b447c7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilter.java @@ -0,0 +1,2007 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CavePositionIndex; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCandidate; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveContainmentPlanner; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveFluidPolicy; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveGrottoShape; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveMode; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlannerSettings; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveRejection; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveSource; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +final class HydrologyCaveCourseFilter { + private static final long DIAGNOSTIC_SALT = 0x43415645464c5452L; + private static final int[][] NEIGHBORS = { + {1, 0, 0}, {-1, 0, 0}, + {0, 1, 0}, {0, -1, 0}, + {0, 0, 1}, {0, 0, -1} + }; + private static final int[][] HORIZONTAL_NEIGHBORS = { + {1, 0}, {-1, 0}, {0, 1}, {0, -1} + }; + private static final CaveVoxelView GENERATED_CHANNEL_VIEW = new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return true; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return CaveVoxel.UNCONDITIONAL; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return false; + } + }; + + private final HydrologyCaveContainmentPlanner planner; + private final CaveVoxelView view; + private final Options options; + private final Map candidateCache; + private final HydrologyCaveContainmentPlanner.ValidationCache validationCache; + private final HydrologyObservedPlannedSurface plannedSurface; + + HydrologyCaveCourseFilter(CaveVoxelView view, Options options) { + this(view, options, null, null, null); + } + + HydrologyCaveCourseFilter( + CaveVoxelView view, + Options options, + Map candidateCache + ) { + this(view, options, candidateCache, null, null); + } + + HydrologyCaveCourseFilter( + CaveVoxelView view, + Options options, + Map candidateCache, + HydrologyCaveContainmentPlanner.ValidationCache validationCache, + HydrologyObservedPlannedSurface plannedSurface + ) { + this.planner = new HydrologyCaveContainmentPlanner(); + this.options = Objects.requireNonNull(options); + CaveVoxelView observedView = Objects.requireNonNull(view); + this.view = options.connectToExistingCaves() ? observedView : GENERATED_CHANNEL_VIEW; + this.candidateCache = candidateCache; + this.validationCache = validationCache; + this.plannedSurface = plannedSurface; + } + + Result filter( + List nodes, + List edges, + List outlets, + List courses, + Iterable columns, + List diagnostics + ) { + return filter(nodes, edges, outlets, courses, columns, null, diagnostics); + } + + Result filter( + List nodes, + List edges, + List outlets, + List courses, + HydrologyFootprintCompiler.ValidationRaster validation, + List diagnostics + ) { + return filter(nodes, edges, outlets, courses, null, validation, diagnostics); + } + + private Result filter( + List nodes, + List edges, + List outlets, + List courses, + Iterable columns, + HydrologyFootprintCompiler.ValidationRaster validation, + List diagnostics + ) { + List normalizedCourses = withoutZeroContributionEdges(courses); + LinkedHashMap publishedCoursesById = new LinkedHashMap<>(normalizedCourses.size()); + for (RiverCourse course : normalizedCourses) { + publishedCoursesById.put(course.id(), course); + } + ArrayList caveCourses = new ArrayList<>(normalizedCourses.size()); + for (RiverCourse course : normalizedCourses) { + if (representativeCaveSegment(course) != null) { + caveCourses.add(course); + } + } + LinkedHashMap candidateKeys = new LinkedHashMap<>(caveCourses.size()); + LinkedHashMap cachedCandidates = new LinkedHashMap<>(caveCourses.size()); + ArrayList uncachedCourses = new ArrayList<>(caveCourses.size()); + for (RiverCourse course : caveCourses) { + CandidateKey key = CandidateKey.create(course, options); + candidateKeys.put(course.id(), key); + HydrologyCaveCandidate cached = candidateCache == null ? null : candidateCache.get(key); + if (cached == null) { + uncachedCourses.add(course); + } else { + cachedCandidates.put(course.id(), cached); + } + } + Set preflightRejectedCourseIds = validation == null + ? preflightRejectedCourseIds(uncachedCourses, columns, diagnostics) + : preflightRejectedCourseIds(uncachedCourses, validation, diagnostics); + ArrayList candidateCourses = new ArrayList<>(caveCourses.size()); + for (RiverCourse course : caveCourses) { + if (!preflightRejectedCourseIds.contains(course.id())) { + candidateCourses.add(course); + } + } + uncachedCourses.removeIf((RiverCourse course) -> preflightRejectedCourseIds.contains(course.id())); + LinkedHashMap builders = validation == null + ? candidateBuilders(uncachedCourses, columns) + : candidateBuilders(uncachedCourses, validation); + if (candidateCourses.isEmpty() && preflightRejectedCourseIds.isEmpty()) { + Graph graph = acceptedGraph(nodes, edges, outlets, normalizedCourses); + return new Result( + graph.nodes(), + graph.edges(), + graph.outlets(), + normalizedCourses, + List.of() + ); + } + + HashSet rejectedCourseIds = new HashSet<>(preflightRejectedCourseIds); + ArrayList candidates = new ArrayList<>(candidateCourses.size()); + Set exposureValidatedCandidates = + Collections.newSetFromMap(new IdentityHashMap<>()); + LinkedHashMap coursesById = new LinkedHashMap<>(candidateCourses.size()); + for (RiverCourse course : candidateCourses) { + CandidateKey key = candidateKeys.get(course.id()); + HydrologyCaveCandidate candidate = cachedCandidates.get(course.id()); + if (candidate == null) { + CandidateBuilder builder = builders.get(course.id()); + if (builder == null) { + throw new IllegalStateException("Hydrology cave candidate raster was empty."); + } + candidate = builder.build(options); + if (candidateCache != null) { + candidateCache.put(key, candidate); + } + exposureValidatedCandidates.add(candidate); + } + SurfaceComposition composition = validation == null + ? SurfaceComposition.accepted(candidate) + : composeSurfacePublication(candidate, course, validation, publishedCoursesById); + if (!composition.accepted()) { + if (rejectedCourseIds.add(course.id())) { + HydraulicSegment representative = representativeCaveSegment(course); + addDiagnostic( + course, + representative, + representative.type(), + HydrologyCandidateRejection.CAVE_CONTAINMENT, + diagnostics, + HydrologyCaveRejection.OVERLAPPING_SOURCE.ordinal() + ); + } + continue; + } + candidate = composition.candidate(); + if (course.surfaceSinkholeContinuation()) { + exposureValidatedCandidates.add(candidate); + } + candidates.add(candidate); + coursesById.put(course.id(), course); + } + alignSharedTerminalCandidateActions(candidates, coursesById, exposureValidatedCandidates); + List plans = planner.validateAllPlans( + view, + candidates, + validationCache, + plannedSurface, + exposureValidatedCandidates, + (HydrologyCaveCandidate first, HydrologyCaveCandidate second) -> + compatibleSharedCandidates(first, second, coursesById) + ); + plans = alignSharedTerminalPlanActions(plans, candidates, coursesById); + ArrayList acceptedPlans = new ArrayList<>(); + for (HydrologyCavePlan plan : plans) { + if (plan.accepted()) { + acceptedPlans.add(plan); + continue; + } + long courseId = plan.source().sourceId(); + if (rejectedCourseIds.add(courseId)) { + addDiagnostic(coursesById.get(courseId), plan, diagnostics); + } + } + if (rejectedCourseIds.isEmpty()) { + Graph graph = acceptedGraph(nodes, edges, outlets, normalizedCourses); + return new Result( + graph.nodes(), + graph.edges(), + graph.outlets(), + normalizedCourses, + List.copyOf(acceptedPlans) + ); + } + + ArrayList acceptedCourses = new ArrayList<>(); + for (RiverCourse course : normalizedCourses) { + if (!rejectedCourseIds.contains(course.id())) { + acceptedCourses.add(course); + } + } + Graph graph = acceptedGraph(nodes, edges, outlets, acceptedCourses); + return new Result( + graph.nodes(), + graph.edges(), + graph.outlets(), + List.copyOf(acceptedCourses), + List.copyOf(acceptedPlans) + ); + } + + private boolean compatibleSharedCandidates( + HydrologyCaveCandidate first, + HydrologyCaveCandidate second, + Map coursesById + ) { + if (!first.profileKey().equals(second.profileKey())) { + return false; + } + RiverCourse firstCourse = coursesById.get(first.source().sourceId()); + RiverCourse secondCourse = coursesById.get(second.source().sourceId()); + return sharesDrainageOutlet(firstCourse, secondCourse) + || sharesTerminalGrotto(firstCourse, secondCourse, first); + } + + private List alignSharedTerminalPlanActions( + List plans, + List candidates, + Map coursesById + ) { + HashMap candidatesById = new HashMap<>(candidates.size()); + for (HydrologyCaveCandidate candidate : candidates) { + candidatesById.put(candidate.source().sourceId(), candidate); + } + ArrayList aligned = new ArrayList<>(plans); + for (int firstIndex = 0; firstIndex < aligned.size() - 1; firstIndex++) { + for (int secondIndex = firstIndex + 1; secondIndex < aligned.size(); secondIndex++) { + HydrologyCavePlan first = aligned.get(firstIndex); + HydrologyCavePlan second = aligned.get(secondIndex); + HydrologyCaveCandidate firstCandidate = candidatesById.get(first.source().sourceId()); + HydrologyCaveCandidate secondCandidate = candidatesById.get(second.source().sourceId()); + if (!first.accepted() + || !second.accepted() + || firstCandidate == null + || secondCandidate == null + || !compatibleSharedCandidates(firstCandidate, secondCandidate, coursesById)) { + continue; + } + LinkedHashMap firstActions = null; + LinkedHashMap secondActions = null; + for (Map.Entry entry : first.actions().entrySet()) { + HydrologyCaveAction secondAction = second.actions().get(entry.getKey()); + if (secondAction == null || secondAction == entry.getValue()) { + continue; + } + HydrologyCaveAction sharedAction = sharedTerminalAction(entry.getValue(), secondAction); + if (firstActions == null) { + firstActions = new LinkedHashMap<>(first.actions()); + secondActions = new LinkedHashMap<>(second.actions()); + } + firstActions.put(entry.getKey(), sharedAction); + secondActions.put(entry.getKey(), sharedAction); + } + if (firstActions == null) { + continue; + } + aligned.set(firstIndex, withActions(first, firstActions)); + aligned.set(secondIndex, withActions(second, secondActions)); + } + } + return List.copyOf(aligned); + } + + private HydrologyCavePlan withActions( + HydrologyCavePlan plan, + Map actions + ) { + return new HydrologyCavePlan( + plan.source(), + plan.rejection(), + actions, + plan.baselinePreconditions(), + plan.arbitrationWinnerSourceId() + ); + } + + private void alignSharedTerminalCandidateActions( + List candidates, + Map coursesById, + Set exposureValidatedCandidates + ) { + for (int firstIndex = 0; firstIndex < candidates.size() - 1; firstIndex++) { + for (int secondIndex = firstIndex + 1; secondIndex < candidates.size(); secondIndex++) { + HydrologyCaveCandidate first = candidates.get(firstIndex); + HydrologyCaveCandidate second = candidates.get(secondIndex); + RiverCourse firstCourse = coursesById.get(first.source().sourceId()); + RiverCourse secondCourse = coursesById.get(second.source().sourceId()); + if (!sharesDrainageOutlet(firstCourse, secondCourse) + && !sharesTerminalGrotto(firstCourse, secondCourse, first)) { + continue; + } + LinkedHashMap firstActions = null; + LinkedHashMap secondActions = null; + for (Map.Entry entry : first.actions().entrySet()) { + HydrologyCaveAction secondAction = second.actions().get(entry.getKey()); + if (secondAction == null || secondAction == entry.getValue()) { + continue; + } + HydrologyCaveAction sharedAction = sharedTerminalAction(entry.getValue(), secondAction); + if (firstActions == null) { + firstActions = new LinkedHashMap<>(first.actions()); + secondActions = new LinkedHashMap<>(second.actions()); + } + firstActions.put(entry.getKey(), sharedAction); + secondActions.put(entry.getKey(), sharedAction); + } + if (firstActions == null) { + continue; + } + HydrologyCaveCandidate alignedFirst = withActions(first, firstActions); + HydrologyCaveCandidate alignedSecond = withActions(second, secondActions); + replaceExposureCandidate(exposureValidatedCandidates, first, alignedFirst); + replaceExposureCandidate(exposureValidatedCandidates, second, alignedSecond); + candidates.set(firstIndex, alignedFirst); + candidates.set(secondIndex, alignedSecond); + } + } + } + + private HydrologyCaveAction sharedTerminalAction( + HydrologyCaveAction first, + HydrologyCaveAction second + ) { + return sharedTerminalActionPriority(first) <= sharedTerminalActionPriority(second) + ? first + : second; + } + + private int sharedTerminalActionPriority(HydrologyCaveAction action) { + return switch (action) { + case FALLING_FLUID -> 0; + case WET_SOURCE -> 1; + case DRY_AIR -> 2; + case SEAL_GUARD -> 3; + }; + } + + private HydrologyCaveCandidate withActions( + HydrologyCaveCandidate candidate, + Map actions + ) { + return new HydrologyCaveCandidate( + candidate.source(), + candidate.profileKey(), + candidate.settings(), + candidate.allowDryCaveConnections(), + actions, + candidate.intentionalOpenings() + ); + } + + private void replaceExposureCandidate( + Set exposureValidatedCandidates, + HydrologyCaveCandidate existing, + HydrologyCaveCandidate replacement + ) { + if (exposureValidatedCandidates.remove(existing)) { + exposureValidatedCandidates.add(replacement); + } + } + + private SurfaceComposition composeSurfacePublication( + HydrologyCaveCandidate candidate, + RiverCourse course, + HydrologyFootprintCompiler.ValidationRaster validation, + Map publishedCoursesById + ) { + LinkedHashMap composedActions = null; + for (HydrologyColumnSample caveSample : validation.columnsForCourse(course.id())) { + int maximumCaveY = maximumCaveY(caveSample, course.id()); + if (maximumCaveY == Integer.MIN_VALUE) { + continue; + } + int plannedTerrainHeight = validation.plannedSurface().resolve( + caveSample.x(), + caveSample.z(), + caveSample.naturalHeight() + ); + if (maximumCaveY <= plannedTerrainHeight) { + continue; + } + HydrologyColumnSample surfaceSample = validation.surfaceColumnAt( + caveSample.x(), + caveSample.z(), + caveSample.naturalHeight() + ); + if (surfaceSample == null) { + continue; + } + for (int y = plannedTerrainHeight + 1; y <= maximumCaveY; y++) { + HydrologyColumnSample.SurfacePublicationCell surfaceCell = surfaceSample + .surfacePublicationCellAt(y) + .orElse(null); + if (surfaceCell == null) { + continue; + } + CavePosition position = new CavePosition(caveSample.x(), y, caveSample.z()); + HydrologyCaveAction caveAction = candidate.actions().get(position); + if (caveAction == null) { + continue; + } + long surfaceCourseId = surfaceCell.layer().feature().courseId(); + RiverCourse surfaceCourse = publishedCoursesById.get(surfaceCourseId); + boolean sharedTerminalGrotto = surfaceCourseId != course.id() + && sharesTerminalGrotto(course, surfaceCourse, candidate); + if (!candidate.profileKey().equals(surfaceCell.layer().profileKey()) + || (surfaceCourseId != course.id() + && !candidate.intentionalOpenings().contains(position) + && !sharedTerminalGrotto)) { + return SurfaceComposition.rejected(); + } + if (caveAction == surfaceCell.action()) { + continue; + } + if (surfaceCourseId != course.id() + && !sharesDrainageOutlet(course, surfaceCourse) + && !sharedTerminalGrotto) { + return SurfaceComposition.rejected(); + } + if (composedActions == null) { + composedActions = new LinkedHashMap<>(candidate.actions()); + } + composedActions.put(position, surfaceCell.action()); + } + } + if (composedActions == null) { + return SurfaceComposition.accepted(candidate); + } + return SurfaceComposition.accepted(new HydrologyCaveCandidate( + candidate.source(), + candidate.profileKey(), + candidate.settings(), + candidate.allowDryCaveConnections(), + composedActions, + candidate.intentionalOpenings() + )); + } + + private boolean sharesTerminalGrotto( + RiverCourse first, + RiverCourse second, + HydrologyCaveCandidate candidate + ) { + if (second == null || !first.profileKey().equals(second.profileKey())) { + return false; + } + HydraulicSegment firstGrotto = terminalGrotto(first); + HydraulicSegment secondGrotto = terminalGrotto(second); + if (firstGrotto == null || secondGrotto == null) { + return false; + } + HydrologyPoint firstPoint = firstGrotto.end(); + HydrologyPoint secondPoint = secondGrotto.end(); + int horizontalRadius = Math.addExact( + candidate.settings().grottoHorizontalRadius(), + Math.max(1, (int) StrictMath.ceil(secondGrotto.width() / 2D)) + ); + int verticalRadius = Math.addExact( + candidate.settings().grottoVerticalRadius(), + Math.max(1, secondGrotto.depth()) + ); + return firstPoint.distanceSquared2D(secondPoint) <= (long) horizontalRadius * horizontalRadius + && Math.abs(firstPoint.y() - secondPoint.y()) <= verticalRadius; + } + + private HydraulicSegment terminalGrotto(RiverCourse course) { + if (course == null || course.segments().isEmpty()) { + return null; + } + HydraulicSegment segment = course.segments().getLast(); + return segment.type().isGrotto() ? segment : null; + } + + private boolean sharesDrainageOutlet(RiverCourse first, RiverCourse second) { + return second != null + && first.outletId().isPresent() + && second.outletId().isPresent() + && first.outletId().getAsLong() == second.outletId().getAsLong(); + } + + private int maximumCaveY(HydrologyColumnSample sample, long courseId) { + int maximumY = Integer.MIN_VALUE; + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == courseId + && isCaveLayer(layer) + && !layer.oceanApron() + && layer.channel() + && layer.terrainOwned()) { + maximumY = Math.max(maximumY, layer.ceilingY()); + } + } + return maximumY; + } + + Set preflightRejectedCourseIds( + List courses, + Iterable columns, + List diagnostics + ) { + LinkedHashMap builders = candidateSpanBuilders(courses, columns); + return preflightRejectedCourseIds(builders, diagnostics); + } + + private Set preflightRejectedCourseIds( + List courses, + HydrologyFootprintCompiler.ValidationRaster validation, + List diagnostics + ) { + LinkedHashMap builders = candidateSpanBuilders(courses, validation); + return preflightRejectedCourseIds(builders, diagnostics); + } + + private Set preflightRejectedCourseIds( + LinkedHashMap builders, + List diagnostics + ) { + if (builders.isEmpty()) { + return Set.of(); + } + HashSet rejectedCourseIds = new HashSet<>(); + for (CandidateSpanBuilder builder : builders.values()) { + HydrologyFeatureType oversizedGrotto = builder.oversizedGrotto(options); + boolean oversizedCourse = builder.positionCount() + > HydrologyCavePlannerSettings.MAXIMUM_PLANNED_MUTATIONS; + if (oversizedGrotto == null && !oversizedCourse) { + continue; + } + rejectedCourseIds.add(builder.course().id()); + addDiagnostic( + builder.course(), + builder.representative(), + oversizedGrotto == null ? builder.representative().type() : oversizedGrotto, + HydrologyCandidateRejection.VOLUME_LIMIT, + diagnostics, + HydrologyCandidateRejection.VOLUME_LIMIT.ordinal() + ); + } + for (CandidateSpanBuilder builder : builders.values()) { + if (rejectedCourseIds.contains(builder.course().id()) + || !builder.exposed(view) + || builder.allowsIntentionalSurfaceExposure()) { + continue; + } + rejectedCourseIds.add(builder.course().id()); + addDiagnostic( + builder.course(), + builder.representative(), + builder.representative().type(), + HydrologyCandidateRejection.CAVE_CONTAINMENT, + diagnostics, + HydrologyCaveRejection.OPEN_SURFACE.ordinal() + ); + } + return Set.copyOf(rejectedCourseIds); + } + + private List withoutZeroContributionEdges(List courses) { + ArrayList normalized = new ArrayList<>(courses.size()); + boolean changed = false; + for (RiverCourse course : courses) { + ArrayList contributingEdges = new ArrayList<>(course.drainageEdges().size()); + for (DrainageEdge edge : course.drainageEdges()) { + if (edge.totalContributingSources() > 0) { + contributingEdges.add(edge); + } else { + changed = true; + } + } + if (contributingEdges.size() == course.drainageEdges().size()) { + normalized.add(course); + continue; + } + normalized.add(new RiverCourse( + course.id(), + course.type(), + course.sourceNodeId(), + course.outletId(), + course.profileKey(), + course.discharge(), + contributingEdges, + course.segments() + )); + } + return changed ? List.copyOf(normalized) : courses; + } + + private LinkedHashMap candidateSpanBuilders( + List courses, + Iterable columns + ) { + LinkedHashMap builders = new LinkedHashMap<>(); + for (RiverCourse course : courses) { + HydraulicSegment representative = representativeCaveSegment(course); + if (representative != null) { + builders.put(course.id(), new CandidateSpanBuilder(course, representative)); + } + } + if (builders.isEmpty()) { + return builders; + } + + for (HydrologyColumnSample sample : columns) { + for (HydrologyColumnLayer layer : sample.layers()) { + CandidateSpanBuilder builder = builders.get(layer.feature().courseId()); + if (builder == null || !isCaveLayer(layer) || layer.oceanApron() + || !layer.channel() || !layer.terrainOwned()) { + continue; + } + builder.addAction(sample.x(), sample.z(), layer); + } + for (HydrologyColumnLayer layer : sample.layers()) { + CandidateSpanBuilder builder = builders.get(layer.feature().courseId()); + if (builder != null && layer.oceanApron()) { + builder.addOceanOpening(sample.x(), sample.z(), sample.naturalHeight() + 1, layer.ceilingY()); + } + } + } + builders.values().removeIf(CandidateSpanBuilder::isEmpty); + return builders; + } + + private LinkedHashMap candidateSpanBuilders( + List courses, + HydrologyFootprintCompiler.ValidationRaster validation + ) { + LinkedHashMap builders = new LinkedHashMap<>(); + for (RiverCourse course : courses) { + HydraulicSegment representative = representativeCaveSegment(course); + if (representative == null) { + continue; + } + CandidateSpanBuilder builder = new CandidateSpanBuilder(course, representative); + for (HydrologyColumnSample sample : validation.columnsForCourse(course.id())) { + int maximumCaveY = Integer.MIN_VALUE; + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() != course.id() + || !isCaveLayer(layer) + || layer.oceanApron() + || !layer.channel() + || !layer.terrainOwned()) { + continue; + } + builder.addAction(sample.x(), sample.z(), layer); + maximumCaveY = Math.max(maximumCaveY, layer.ceilingY()); + } + if (maximumCaveY != Integer.MIN_VALUE + && validation.ownsSurfaceChannelAt( + sample.x(), + sample.z(), + sample.naturalHeight(), + course.id() + )) { + int plannedTerrainHeight = validation.plannedSurface().resolve( + sample.x(), + sample.z(), + sample.naturalHeight() + ); + builder.addOpeningNeighborhood( + sample.x(), + sample.z(), + plannedTerrainHeight + 1, + maximumCaveY + ); + } + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == course.id() && layer.oceanApron()) { + builder.addOceanOpening( + sample.x(), + sample.z(), + sample.naturalHeight() + 1, + layer.ceilingY() + ); + } + } + } + builder.addAdjacentSurfaceOpenings(validation); + if (!builder.isEmpty()) { + builders.put(course.id(), builder); + } + } + return builders; + } + + private LinkedHashMap candidateBuilders( + List courses, + Iterable columns + ) { + LinkedHashMap builders = new LinkedHashMap<>(); + for (RiverCourse course : courses) { + HydraulicSegment representative = representativeCaveSegment(course); + if (representative != null) { + builders.put(course.id(), new CandidateBuilder(course, representative)); + } + } + if (builders.isEmpty()) { + return builders; + } + + for (HydrologyColumnSample sample : columns) { + for (HydrologyColumnLayer layer : sample.layers()) { + CandidateBuilder builder = builders.get(layer.feature().courseId()); + if (builder == null || !isCaveLayer(layer) || layer.oceanApron() + || !layer.channel() || !layer.terrainOwned()) { + continue; + } + for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) { + CavePosition position = new CavePosition(sample.x(), y, sample.z()); + builder.addAction(position, actionAt(layer, y)); + builder.addSurfaceOpening(layer, position); + } + } + for (HydrologyColumnLayer layer : sample.layers()) { + CandidateBuilder builder = builders.get(layer.feature().courseId()); + if (builder == null || !layer.oceanApron()) { + continue; + } + for (int y = sample.naturalHeight() + 1; y <= layer.ceilingY(); y++) { + builder.addOpening(new CavePosition(sample.x(), y, sample.z())); + } + } + } + builders.values().removeIf(CandidateBuilder::isEmpty); + return builders; + } + + private LinkedHashMap candidateBuilders( + List courses, + HydrologyFootprintCompiler.ValidationRaster validation + ) { + LinkedHashMap builders = new LinkedHashMap<>(); + for (RiverCourse course : courses) { + HydraulicSegment representative = representativeCaveSegment(course); + if (representative == null) { + continue; + } + CandidateBuilder builder = new CandidateBuilder(course, representative); + for (HydrologyColumnSample sample : validation.columnsForCourse(course.id())) { + int maximumCaveY = Integer.MIN_VALUE; + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() != course.id() + || !isCaveLayer(layer) + || layer.oceanApron() + || !layer.channel() + || !layer.terrainOwned()) { + continue; + } + for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) { + CavePosition position = new CavePosition(sample.x(), y, sample.z()); + builder.addAction(position, actionAt(layer, y)); + builder.addSurfaceOpening(layer, position); + } + maximumCaveY = Math.max(maximumCaveY, layer.ceilingY()); + } + if (maximumCaveY != Integer.MIN_VALUE + && validation.ownsSurfaceChannelAt( + sample.x(), + sample.z(), + sample.naturalHeight(), + course.id() + )) { + int plannedTerrainHeight = validation.plannedSurface().resolve( + sample.x(), + sample.z(), + sample.naturalHeight() + ); + for (int y = plannedTerrainHeight + 1; y <= maximumCaveY; y++) { + builder.addOpeningNeighborhood(new CavePosition(sample.x(), y, sample.z())); + } + } + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() != course.id() || !layer.oceanApron()) { + continue; + } + for (int y = sample.naturalHeight() + 1; y <= layer.ceilingY(); y++) { + builder.addOpening(new CavePosition(sample.x(), y, sample.z())); + } + } + } + builder.addAdjacentSurfaceOpenings(validation); + if (!builder.isEmpty()) { + builders.put(course.id(), builder); + } + } + return builders; + } + + private static HydraulicSegment representativeCaveSegment(RiverCourse course) { + HydraulicSegment selected = null; + for (HydraulicSegment segment : course.segments()) { + if (!segment.type().isUnderground() && !segment.type().isDeepFluid()) { + continue; + } + if (selected == null || candidatePriority(segment.type()) < candidatePriority(selected.type())) { + selected = segment; + } + } + return selected; + } + + private static int candidatePriority(HydrologyFeatureType type) { + if (type.isGrotto()) { + return 0; + } + if (type.isDeepFluid()) { + return 1; + } + return 2; + } + + private boolean isCaveLayer(HydrologyColumnLayer layer) { + return layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid(); + } + + private HydrologyCaveAction actionAt(HydrologyColumnLayer layer, int y) { + if (y > layer.fluidHeadY()) { + return HydrologyCaveAction.DRY_AIR; + } + if (layer.fallingFluid() && y < layer.fluidHeadY()) { + return HydrologyCaveAction.FALLING_FLUID; + } + return HydrologyCaveAction.WET_SOURCE; + } + + private void addDiagnostic( + RiverCourse course, + HydrologyCavePlan plan, + List diagnostics + ) { + HydraulicSegment representative = representativeCaveSegment(course); + addDiagnostic( + course, + representative, + representative.type(), + HydrologyCandidateRejection.CAVE_CONTAINMENT, + diagnostics, + plan.rejection().ordinal() + ); + } + + private void addDiagnostic( + RiverCourse course, + HydraulicSegment representative, + HydrologyFeatureType type, + HydrologyCandidateRejection rejection, + List diagnostics, + int rejectionSalt + ) { + HydrologyCandidateKind kind = course.type() == RiverCourseType.DEEP_FLUID + ? HydrologyCandidateKind.DEEP_FLUID + : type.isGrotto() ? HydrologyCandidateKind.OUTLET : HydrologyCandidateKind.SOURCE; + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix( + course.id(), + DIAGNOSTIC_SALT, + rejectionSalt + ), + kind, + type, + representative.start(), + rejection + )); + } + + private static Graph acceptedGraph( + List nodes, + List edges, + List outlets, + List courses + ) { + LinkedHashSet nodeIds = new LinkedHashSet<>(); + LinkedHashSet edgeIds = new LinkedHashSet<>(); + LinkedHashSet outletIds = new LinkedHashSet<>(); + for (RiverCourse course : courses) { + if (course.sourceNodeId().isPresent()) { + nodeIds.add(course.sourceNodeId().getAsLong()); + } + if (course.outletId().isPresent()) { + outletIds.add(course.outletId().getAsLong()); + } + for (DrainageEdge edge : course.drainageEdges()) { + if (edge.totalContributingSources() == 0) { + continue; + } + edgeIds.add(edge.id()); + nodeIds.add(edge.upstreamNodeId()); + nodeIds.add(edge.downstreamNodeId()); + outletIds.add(edge.outletId()); + } + } + ArrayList acceptedNodes = new ArrayList<>(); + for (DrainageNode node : nodes) { + if (nodeIds.contains(node.id())) { + acceptedNodes.add(node); + outletIds.add(node.outletId()); + } + } + ArrayList acceptedEdges = new ArrayList<>(); + for (DrainageEdge edge : edges) { + if (edgeIds.contains(edge.id()) && edge.totalContributingSources() > 0) { + acceptedEdges.add(edge); + } + } + ArrayList acceptedOutlets = new ArrayList<>(); + for (RiverOutlet outlet : outlets) { + if (outletIds.contains(outlet.id())) { + acceptedOutlets.add(outlet); + } + } + return new Graph( + List.copyOf(acceptedNodes), + List.copyOf(acceptedEdges), + List.copyOf(acceptedOutlets) + ); + } + + static Result withoutCourses(Result result, Set rejectedCourseIds) { + Objects.requireNonNull(result); + Objects.requireNonNull(rejectedCourseIds); + if (rejectedCourseIds.isEmpty()) { + return result; + } + ArrayList acceptedCourses = new ArrayList<>(result.courses().size()); + for (RiverCourse course : result.courses()) { + if (!rejectedCourseIds.contains(course.id())) { + acceptedCourses.add(course); + } + } + ArrayList acceptedPlans = new ArrayList<>(result.cavePlans().size()); + for (HydrologyCavePlan plan : result.cavePlans()) { + if (!rejectedCourseIds.contains(plan.source().sourceId())) { + acceptedPlans.add(plan); + } + } + Graph graph = acceptedGraph( + result.nodes(), + result.edges(), + result.outlets(), + acceptedCourses + ); + return new Result( + graph.nodes(), + graph.edges(), + graph.outlets(), + List.copyOf(acceptedCourses), + List.copyOf(acceptedPlans) + ); + } + + static HydrologyDiagnosticCandidate overlapDiagnostic(RiverCourse course, long winnerSourceId) { + Objects.requireNonNull(course); + HydraulicSegment representative = representativeCaveSegment(course); + if (representative == null) { + throw new IllegalArgumentException("Cross-tile cave arbitration requires a cave-bearing course."); + } + HydrologyFeatureType type = representative.type(); + HydrologyCandidateKind kind = course.type() == RiverCourseType.DEEP_FLUID + ? HydrologyCandidateKind.DEEP_FLUID + : type.isGrotto() ? HydrologyCandidateKind.OUTLET : HydrologyCandidateKind.SOURCE; + return new HydrologyDiagnosticCandidate( + HydrologyHash.mix( + course.id(), + DIAGNOSTIC_SALT, + HydrologyCaveRejection.OVERLAPPING_SOURCE.ordinal(), + winnerSourceId + ), + kind, + type, + representative.start(), + HydrologyCandidateRejection.CAVE_CONTAINMENT + ); + } + + record Result( + List nodes, + List edges, + List outlets, + List courses, + List cavePlans + ) { + } + + private record Graph( + List nodes, + List edges, + List outlets + ) { + } + + private record SurfaceComposition( + HydrologyCaveCandidate candidate, + boolean accepted + ) { + private static SurfaceComposition accepted(HydrologyCaveCandidate candidate) { + return new SurfaceComposition(Objects.requireNonNull(candidate), true); + } + + private static SurfaceComposition rejected() { + return new SurfaceComposition(null, false); + } + } + + record Options( + boolean connectToExistingCaves, + int coastalGrottoMaximumVolume, + int inlandGrottoMaximumVolume + ) { + Options { + if (coastalGrottoMaximumVolume < 1 || inlandGrottoMaximumVolume < 1) { + throw new IllegalArgumentException("Grotto maximum volumes must be positive."); + } + } + + private int maximumVolume(HydrologyFeatureType type) { + return switch (type) { + case COASTAL_GROTTO -> coastalGrottoMaximumVolume; + case INLAND_GROTTO -> inlandGrottoMaximumVolume; + default -> Integer.MAX_VALUE; + }; + } + } + + record CandidateKey( + long courseId, + RiverCourseType courseType, + String profileKey, + List segments, + Options options + ) { + CandidateKey { + Objects.requireNonNull(courseType, "courseType"); + if (profileKey == null || profileKey.isBlank()) { + throw new IllegalArgumentException("profileKey must not be blank"); + } + profileKey = profileKey.trim(); + segments = List.copyOf(Objects.requireNonNull(segments, "segments")); + Objects.requireNonNull(options, "options"); + } + + private static CandidateKey create(RiverCourse course, Options options) { + return new CandidateKey( + course.id(), + course.type(), + course.profileKey(), + course.segments(), + options + ); + } + } + + static final class CandidateCache extends LinkedHashMap { + private static final int DEFAULT_MAXIMUM_ENTRIES = 256; + private static final long DEFAULT_MAXIMUM_RETAINED_POSITIONS = 262_144L; + + private final int maximumEntries; + private final long maximumRetainedPositions; + private long retainedPositions; + + CandidateCache() { + this(DEFAULT_MAXIMUM_ENTRIES, DEFAULT_MAXIMUM_RETAINED_POSITIONS); + } + + CandidateCache(int maximumEntries, long maximumRetainedPositions) { + super(16, 0.75F, true); + if (maximumEntries < 1) { + throw new IllegalArgumentException("Maximum candidate cache entries must be positive."); + } + if (maximumRetainedPositions < 1L) { + throw new IllegalArgumentException("Maximum retained candidate positions must be positive."); + } + this.maximumEntries = maximumEntries; + this.maximumRetainedPositions = maximumRetainedPositions; + } + + @Override + public HydrologyCaveCandidate put(CandidateKey key, HydrologyCaveCandidate candidate) { + Objects.requireNonNull(key); + Objects.requireNonNull(candidate); + HydrologyCaveCandidate previous = super.remove(key); + if (previous != null) { + retainedPositions -= weight(previous); + } + long weight = weight(candidate); + if (weight > maximumRetainedPositions) { + return previous; + } + super.put(key, candidate); + retainedPositions += weight; + trim(); + return previous; + } + + @Override + public HydrologyCaveCandidate remove(Object key) { + HydrologyCaveCandidate removed = super.remove(key); + if (removed != null) { + retainedPositions -= weight(removed); + } + return removed; + } + + @Override + public void putAll(Map candidates) { + for (Map.Entry entry + : candidates.entrySet()) { + put(entry.getKey(), entry.getValue()); + } + } + + @Override + public void clear() { + super.clear(); + retainedPositions = 0L; + } + + long retainedPositions() { + return retainedPositions; + } + + private void trim() { + Iterator> iterator = entrySet().iterator(); + while (iterator.hasNext() + && (size() > maximumEntries || retainedPositions > maximumRetainedPositions)) { + Map.Entry eldest = iterator.next(); + retainedPositions -= weight(eldest.getValue()); + iterator.remove(); + } + } + + private static long weight(HydrologyCaveCandidate candidate) { + return Math.max(1L, (long) candidate.actions().size() + candidate.intentionalOpenings().size()); + } + } + + private static final class CandidateSpanBuilder { + private final RiverCourse course; + private final HydraulicSegment representative; + private final LinkedHashMap actions; + private final LinkedHashMap openings; + private final LinkedHashMap coastalGrottoActions; + private final LinkedHashMap inlandGrottoActions; + private final List surfaceOpenings; + private int minimumX; + private int maximumX; + private int minimumY; + private int maximumY; + private int minimumZ; + private int maximumZ; + + private CandidateSpanBuilder(RiverCourse course, HydraulicSegment representative) { + this.course = course; + this.representative = representative; + this.actions = new LinkedHashMap<>(); + this.openings = new LinkedHashMap<>(); + this.coastalGrottoActions = new LinkedHashMap<>(); + this.inlandGrottoActions = new LinkedHashMap<>(); + this.surfaceOpenings = surfaceOpenings(course); + this.minimumX = Integer.MAX_VALUE; + this.maximumX = Integer.MIN_VALUE; + this.minimumY = Integer.MAX_VALUE; + this.maximumY = Integer.MIN_VALUE; + this.minimumZ = Integer.MAX_VALUE; + this.maximumZ = Integer.MIN_VALUE; + } + + private RiverCourse course() { + return course; + } + + private HydraulicSegment representative() { + return representative; + } + + private boolean isEmpty() { + return actions.isEmpty(); + } + + private boolean allowsIntentionalSurfaceExposure() { + return course.surfaceSinkholeContinuation(); + } + + private void addAction(int x, int z, HydrologyColumnLayer layer) { + int minimumActionY = layer.bedY() + 1; + int maximumActionY = layer.ceilingY(); + if (minimumActionY > maximumActionY) { + return; + } + addSpan(actions, x, z, minimumActionY, maximumActionY); + if (layer.feature().type() == HydrologyFeatureType.COASTAL_GROTTO) { + addSpan(coastalGrottoActions, x, z, minimumActionY, maximumActionY); + } else if (layer.feature().type() == HydrologyFeatureType.INLAND_GROTTO) { + addSpan(inlandGrottoActions, x, z, minimumActionY, maximumActionY); + } + include(x, minimumActionY, z); + include(x, maximumActionY, z); + for (SurfaceOpening opening : surfaceOpenings) { + if (!opening.matchesColumn(layer, x, z)) { + continue; + } + int minimumOpeningY = Math.max(minimumActionY, opening.minimumY()); + if (minimumOpeningY > maximumActionY) { + continue; + } + if (!opening.includeNeighborhood()) { + addOpening(x, z, minimumOpeningY, maximumActionY); + continue; + } + addOpening(x, z, minimumOpeningY - 1, maximumActionY + 1); + for (int[] offset : HORIZONTAL_NEIGHBORS) { + addOpening(x + offset[0], z + offset[1], minimumOpeningY, maximumActionY); + } + } + } + + private void addOceanOpening(int x, int z, int minimumOpeningY, int maximumOpeningY) { + if (minimumOpeningY <= maximumOpeningY) { + addOpening(x, z, minimumOpeningY, maximumOpeningY); + } + } + + private void addOpeningNeighborhood( + int x, + int z, + int minimumOpeningY, + int maximumOpeningY + ) { + if (minimumOpeningY > maximumOpeningY) { + return; + } + addOpening(x, z, minimumOpeningY - 1, maximumOpeningY + 1); + for (int[] offset : HORIZONTAL_NEIGHBORS) { + addOpening(x + offset[0], z + offset[1], minimumOpeningY, maximumOpeningY); + } + } + + private void addAdjacentSurfaceOpenings( + HydrologyFootprintCompiler.ValidationRaster validation + ) { + for (Map.Entry entry : actions.entrySet()) { + int x = RiverFootprint.unpackX(entry.getKey()); + int z = RiverFootprint.unpackZ(entry.getKey()); + for (int[] offset : HORIZONTAL_NEIGHBORS) { + int neighborX = x + offset[0]; + int neighborZ = z + offset[1]; + if (!validation.ownsSurfaceChannelAt(neighborX, neighborZ, course.id())) { + continue; + } + for (YSpan span : entry.getValue().spans()) { + addOpening(neighborX, neighborZ, span.minimumY(), span.maximumY()); + } + } + } + } + + private HydrologyFeatureType oversizedGrotto(Options options) { + if (positionCount(coastalGrottoActions) + > options.maximumVolume(HydrologyFeatureType.COASTAL_GROTTO)) { + return HydrologyFeatureType.COASTAL_GROTTO; + } + if (positionCount(inlandGrottoActions) + > options.maximumVolume(HydrologyFeatureType.INLAND_GROTTO)) { + return HydrologyFeatureType.INLAND_GROTTO; + } + return null; + } + + private long positionCount() { + return positionCount(actions); + } + + private boolean exposed(CaveVoxelView view) { + CandidateBounds bounds = bounds(); + for (Map.Entry entry : actions.entrySet()) { + int x = RiverFootprint.unpackX(entry.getKey()); + int z = RiverFootprint.unpackZ(entry.getKey()); + SpanSet actionSpans = entry.getValue(); + SpanSet openingSpans = openings.get(entry.getKey()); + if (exposedDifference(actionSpans, null, openingSpans, x, z, bounds, view)) { + return true; + } + for (YSpan span : actionSpans.spans()) { + int lowerBoundary = span.minimumY() - 1; + if (!actionSpans.contains(lowerBoundary) + && !contains(openingSpans, lowerBoundary) + && exposed(x, lowerBoundary, z, bounds, view)) { + return true; + } + int upperBoundary = span.maximumY() + 1; + if (!actionSpans.contains(upperBoundary) + && !contains(openingSpans, upperBoundary) + && exposed(x, upperBoundary, z, bounds, view)) { + return true; + } + } + for (int[] offset : HORIZONTAL_NEIGHBORS) { + int neighborX = x + offset[0]; + int neighborZ = z + offset[1]; + long neighborKey = RiverFootprint.pack(neighborX, neighborZ); + if (exposedDifference( + actionSpans, + actions.get(neighborKey), + openings.get(neighborKey), + neighborX, + neighborZ, + bounds, + view + )) { + return true; + } + } + } + return false; + } + + private boolean exposedDifference( + SpanSet source, + SpanSet excludedActions, + SpanSet excludedOpenings, + int x, + int z, + CandidateBounds bounds, + CaveVoxelView view + ) { + for (YSpan span : source.spans()) { + if (exposedDifference( + span, + excludedActions, + excludedOpenings, + x, + z, + bounds, + view + )) { + return true; + } + } + return false; + } + + private boolean exposedDifference( + YSpan source, + SpanSet excludedActions, + SpanSet excludedOpenings, + int x, + int z, + CandidateBounds bounds, + CaveVoxelView view + ) { + List actionSpans = excludedActions == null ? List.of() : excludedActions.spans(); + List openingSpans = excludedOpenings == null ? List.of() : excludedOpenings.spans(); + int actionIndex = 0; + int openingIndex = 0; + long cursor = source.minimumY(); + while (cursor <= source.maximumY()) { + while (actionIndex < actionSpans.size() + && actionSpans.get(actionIndex).maximumY() < cursor) { + actionIndex++; + } + while (openingIndex < openingSpans.size() + && openingSpans.get(openingIndex).maximumY() < cursor) { + openingIndex++; + } + YSpan action = actionIndex < actionSpans.size() ? actionSpans.get(actionIndex) : null; + YSpan opening = openingIndex < openingSpans.size() ? openingSpans.get(openingIndex) : null; + YSpan excluded = first(action, opening); + if (excluded == null || excluded.minimumY() > source.maximumY()) { + return exposed(x, (int) cursor, source.maximumY(), z, bounds, view); + } + if (cursor < excluded.minimumY() + && exposed(x, (int) cursor, excluded.minimumY() - 1, z, bounds, view)) { + return true; + } + cursor = Math.max(cursor, (long) excluded.maximumY() + 1L); + if (excluded == action) { + actionIndex++; + } else { + openingIndex++; + } + } + return false; + } + + private YSpan first(YSpan first, YSpan second) { + if (first == null) { + return second; + } + if (second == null) { + return first; + } + return first.minimumY() <= second.minimumY() ? first : second; + } + + private boolean exposed( + int x, + int minimumY, + int maximumY, + int z, + CandidateBounds bounds, + CaveVoxelView view + ) { + if (!bounds.containsColumn(x, z)) { + return false; + } + int boundedMinimumY = Math.max(minimumY, bounds.minimumY()); + int boundedMaximumY = Math.min(maximumY, bounds.maximumY()); + return boundedMinimumY <= boundedMaximumY + && view.hasAboveTerrainSurface(x, z, boundedMinimumY, boundedMaximumY); + } + + private boolean exposed( + int x, + int y, + int z, + CandidateBounds bounds, + CaveVoxelView view + ) { + CavePosition position = new CavePosition(x, y, z); + return bounds.contains(position) + && view.isInWorld(position) + && view.isAboveTerrainSurface(position); + } + + private CandidateBounds bounds() { + HydrologyPoint start = representative.start(); + int waterHead = representative.upstreamHeadY(); + int entryY = Math.max(waterHead, maximumY); + int horizontalRadius = horizontalRadius( + start.x(), + start.z(), + minimumX, + maximumX, + minimumZ, + maximumZ + ); + int maximumDepth = Math.max(1, entryY - minimumY + 2); + int dryHeadroom = Math.max(0, maximumY - waterHead); + return new CandidateBounds( + start.x(), + entryY, + start.z(), + waterHead, + horizontalRadius, + maximumDepth, + dryHeadroom + ); + } + + private void addOpening(int x, int z, int minimumOpeningY, int maximumOpeningY) { + addSpan(openings, x, z, minimumOpeningY, maximumOpeningY); + include(x, minimumOpeningY, z); + include(x, maximumOpeningY, z); + } + + private void include(int x, int y, int z) { + minimumX = Math.min(minimumX, x); + maximumX = Math.max(maximumX, x); + minimumY = Math.min(minimumY, y); + maximumY = Math.max(maximumY, y); + minimumZ = Math.min(minimumZ, z); + maximumZ = Math.max(maximumZ, z); + } + + private static void addSpan( + Map spans, + int x, + int z, + int minimumY, + int maximumY + ) { + spans.computeIfAbsent(RiverFootprint.pack(x, z), (Long ignored) -> new SpanSet()) + .add(minimumY, maximumY); + } + + private static boolean contains(SpanSet spans, int y) { + return spans != null && spans.contains(y); + } + + private static long positionCount(Map spans) { + long count = 0L; + for (SpanSet spanSet : spans.values()) { + count += spanSet.positionCount(); + } + return count; + } + } + + private static final class SpanSet { + private int[] pending; + private int size; + private List normalized; + + private SpanSet() { + this.pending = new int[4]; + this.normalized = null; + } + + private void add(int minimumY, int maximumY) { + if (minimumY > maximumY) { + return; + } + int requiredLength = Math.multiplyExact(size + 1, 2); + if (requiredLength > pending.length) { + pending = Arrays.copyOf(pending, Math.multiplyExact(pending.length, 2)); + } + pending[size * 2] = minimumY; + pending[size * 2 + 1] = maximumY; + size++; + normalized = null; + } + + private List spans() { + if (normalized != null) { + return normalized; + } + sortPending(); + ArrayList merged = new ArrayList<>(size); + for (int index = 0; index < size; index++) { + YSpan span = new YSpan(pending[index * 2], pending[index * 2 + 1]); + if (merged.isEmpty()) { + merged.add(span); + continue; + } + YSpan previous = merged.getLast(); + if ((long) span.minimumY() > (long) previous.maximumY() + 1L) { + merged.add(span); + continue; + } + merged.set( + merged.size() - 1, + new YSpan(previous.minimumY(), Math.max(previous.maximumY(), span.maximumY())) + ); + } + normalized = List.copyOf(merged); + return normalized; + } + + private void sortPending() { + for (int index = 1; index < size; index++) { + int minimumY = pending[index * 2]; + int maximumY = pending[index * 2 + 1]; + int insertionIndex = index; + while (insertionIndex > 0) { + int previousMinimumY = pending[(insertionIndex - 1) * 2]; + int previousMaximumY = pending[(insertionIndex - 1) * 2 + 1]; + if (previousMinimumY < minimumY + || previousMinimumY == minimumY && previousMaximumY <= maximumY) { + break; + } + pending[insertionIndex * 2] = previousMinimumY; + pending[insertionIndex * 2 + 1] = previousMaximumY; + insertionIndex--; + } + pending[insertionIndex * 2] = minimumY; + pending[insertionIndex * 2 + 1] = maximumY; + } + } + + private boolean contains(int y) { + List spans = spans(); + int minimumIndex = 0; + int maximumIndex = spans.size() - 1; + while (minimumIndex <= maximumIndex) { + int index = (minimumIndex + maximumIndex) >>> 1; + YSpan span = spans.get(index); + if (y < span.minimumY()) { + maximumIndex = index - 1; + } else if (y > span.maximumY()) { + minimumIndex = index + 1; + } else { + return true; + } + } + return false; + } + + private long positionCount() { + long count = 0L; + for (YSpan span : spans()) { + count += (long) span.maximumY() - span.minimumY() + 1L; + } + return count; + } + } + + private record YSpan(int minimumY, int maximumY) { + } + + private record CandidateBounds( + int entryX, + int entryY, + int entryZ, + int waterHeadY, + int horizontalRadius, + int maximumDepth, + int dryHeadroom + ) { + private boolean containsColumn(int x, int z) { + long deltaX = (long) x - entryX; + long deltaZ = (long) z - entryZ; + long radiusSquared = (long) horizontalRadius * horizontalRadius; + return deltaX * deltaX + deltaZ * deltaZ <= radiusSquared; + } + + private int minimumY() { + return Math.toIntExact((long) entryY - maximumDepth); + } + + private int maximumY() { + return Math.max(entryY, Math.addExact(waterHeadY, dryHeadroom + 1)); + } + + private boolean contains(CavePosition position) { + if (!containsColumn(position.x(), position.z())) { + return false; + } + return position.y() >= minimumY() && position.y() <= maximumY(); + } + } + + private static int horizontalRadius( + int entryX, + int entryZ, + int minimumX, + int maximumX, + int minimumZ, + int maximumZ + ) { + long maximumDistanceSquared = 1L; + long[][] corners = { + {minimumX, minimumZ}, + {minimumX, maximumZ}, + {maximumX, minimumZ}, + {maximumX, maximumZ} + }; + for (long[] corner : corners) { + long deltaX = corner[0] - entryX; + long deltaZ = corner[1] - entryZ; + maximumDistanceSquared = Math.max(maximumDistanceSquared, deltaX * deltaX + deltaZ * deltaZ); + } + return Math.max(1, (int) StrictMath.ceil(StrictMath.sqrt(maximumDistanceSquared)) + 2); + } + + private static List surfaceOpenings(RiverCourse course) { + if (course.type() != RiverCourseType.SURFACE) { + return List.of(); + } + ArrayList openings = new ArrayList<>(); + List segments = course.segments(); + for (int index = 0; index < segments.size(); index++) { + HydraulicSegment segment = segments.get(index); + if (!segment.type().isUnderground()) { + continue; + } + int radius = Math.max(1, segment.width() / 2) + 1; + if (segment.type() == HydrologyFeatureType.SINKHOLE) { + HydrologyPoint start = segment.start(); + double maximumDistance = 0D; + for (HydrologyPoint point : segment.centerline()) { + maximumDistance = Math.max( + maximumDistance, + StrictMath.hypot(point.x() - start.x(), point.z() - start.z()) + ); + } + radius += (int) StrictMath.ceil(maximumDistance); + } + long radiusSquared = (long) radius * radius; + int minimumY = segment.type() == HydrologyFeatureType.SINKHOLE + ? segment.downstreamHeadY() + 1 + : Integer.MIN_VALUE; + boolean includeNeighborhood = segment.type() != HydrologyFeatureType.SINKHOLE; + if (index == 0 + || !segments.get(index - 1).type().isUnderground()) { + openings.add(SurfaceOpening.create( + segment, + true, + radiusSquared, + minimumY, + includeNeighborhood + )); + } + boolean terminalCoastalOpening = index == segments.size() - 1 + && segment.type() == HydrologyFeatureType.COASTAL_GROTTO; + if (terminalCoastalOpening + || (index < segments.size() - 1 + && !segments.get(index + 1).type().isUnderground())) { + openings.add(SurfaceOpening.create( + segment, + false, + radiusSquared, + minimumY, + includeNeighborhood + )); + } + } + return List.copyOf(openings); + } + + private static final class CandidateBuilder { + private final RiverCourse course; + private final HydraulicSegment representative; + private final LinkedHashMap actions; + private final LinkedHashSet openings; + private final CavePositionIndex openingIndex; + private final List surfaceOpenings; + private int minimumX; + private int maximumX; + private int minimumY; + private int maximumY; + private int minimumZ; + private int maximumZ; + + private CandidateBuilder(RiverCourse course, HydraulicSegment representative) { + this.course = course; + this.representative = representative; + this.actions = new LinkedHashMap<>(); + this.openings = new LinkedHashSet<>(); + this.openingIndex = new CavePositionIndex(); + this.surfaceOpenings = surfaceOpenings(course); + this.minimumX = Integer.MAX_VALUE; + this.maximumX = Integer.MIN_VALUE; + this.minimumY = Integer.MAX_VALUE; + this.maximumY = Integer.MIN_VALUE; + this.minimumZ = Integer.MAX_VALUE; + this.maximumZ = Integer.MIN_VALUE; + } + + private RiverCourse course() { + return course; + } + + private HydraulicSegment representative() { + return representative; + } + + private boolean isEmpty() { + return actions.isEmpty(); + } + + private void addAction(CavePosition position, HydrologyCaveAction action) { + HydrologyCaveAction existing = actions.get(position); + if (existing == null || actionPriority(action) < actionPriority(existing)) { + actions.put(position, action); + } + include(position); + } + + private void addOpening(CavePosition position) { + if (openingIndex.add(position.x(), position.y(), position.z())) { + openings.add(position); + } + include(position); + } + + private void addOpening(int x, int y, int z) { + if (openingIndex.add(x, y, z)) { + openings.add(new CavePosition(x, y, z)); + } + include(x, y, z); + } + + private void addOpeningNeighborhood(CavePosition position) { + addOpening(position); + for (int[] offset : NEIGHBORS) { + addOpening( + position.x() + offset[0], + position.y() + offset[1], + position.z() + offset[2] + ); + } + } + + private void addAdjacentSurfaceOpenings( + HydrologyFootprintCompiler.ValidationRaster validation + ) { + ArrayList actionPositions = new ArrayList<>(actions.keySet()); + for (CavePosition position : actionPositions) { + for (int[] offset : HORIZONTAL_NEIGHBORS) { + int neighborX = position.x() + offset[0]; + int neighborZ = position.z() + offset[1]; + if (validation.ownsSurfaceChannelAt(neighborX, neighborZ, course.id())) { + addOpening(neighborX, position.y(), neighborZ); + } + } + } + } + + private void addSurfaceOpening(HydrologyColumnLayer layer, CavePosition position) { + for (SurfaceOpening opening : surfaceOpenings) { + if (!opening.matches(layer, position)) { + continue; + } + if (opening.includeNeighborhood()) { + addOpeningNeighborhood(position); + } else { + addOpening(position); + } + } + } + + private void include(CavePosition position) { + include(position.x(), position.y(), position.z()); + } + + private void include(int x, int y, int z) { + minimumX = Math.min(minimumX, x); + maximumX = Math.max(maximumX, x); + minimumY = Math.min(minimumY, y); + maximumY = Math.max(maximumY, y); + minimumZ = Math.min(minimumZ, z); + maximumZ = Math.max(maximumZ, z); + } + + private HydrologyCaveCandidate build(Options options) { + HydrologyPoint start = representative.start(); + int waterHead = representative.upstreamHeadY(); + int entryY = Math.max(waterHead, maximumY); + CavePosition entry = new CavePosition(start.x(), entryY, start.z()); + CavePosition target = new CavePosition(start.x(), waterHead, start.z()); + int horizontalRadius = horizontalRadius( + entry.x(), + entry.z(), + minimumX, + maximumX, + minimumZ, + maximumZ + ); + int maximumDepth = Math.max(1, entryY - minimumY + 2); + int dryHeadroom = Math.max(0, maximumY - waterHead); + int volume = Math.max(1, actions.size()); + HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings( + horizontalRadius, + maximumDepth, + volume, + 1, + 1, + 1, + 1, + dryHeadroom, + HydrologyCaveFluidPolicy.REJECT_EXISTING, + HydrologyCaveGrottoShape.ELLIPSOID, + horizontalRadius, + maximumDepth + ); + return new HydrologyCaveCandidate( + new HydrologyCaveSource( + course.id(), + entry, + target, + waterHead, + HydrologyCaveMode.GENERATED_GROTTO + ), + course.profileKey(), + settings, + options.connectToExistingCaves() && course.type() == RiverCourseType.UNDERGROUND, + actions, + openings + ); + } + + private static int actionPriority(HydrologyCaveAction action) { + return switch (action) { + case WET_SOURCE -> 0; + case FALLING_FLUID -> 1; + case DRY_AIR -> 2; + case SEAL_GUARD -> 3; + }; + } + + } + + private record SurfaceOpening( + long segmentId, + HydrologyPoint point, + double interiorX, + double interiorZ, + long radiusSquared, + int minimumY, + boolean includeNeighborhood + ) { + private static SurfaceOpening create( + HydraulicSegment segment, + boolean start, + long radiusSquared, + int minimumY, + boolean includeNeighborhood + ) { + List centerline = segment.centerline(); + int boundaryIndex = start ? 0 : centerline.size() - 1; + int interiorIndex = start + ? Math.min(1, centerline.size() - 1) + : Math.max(0, centerline.size() - 2); + HydrologyPoint point = centerline.get(boundaryIndex); + HydrologyPoint interior = centerline.get(interiorIndex); + double deltaX = interior.x() - point.x(); + double deltaZ = interior.z() - point.z(); + double lengthSquared = deltaX * deltaX + deltaZ * deltaZ; + double scale = 0D; + if (includeNeighborhood) { + scale = lengthSquared <= radiusSquared + ? 1D + : StrictMath.sqrt(radiusSquared / lengthSquared); + } + return new SurfaceOpening( + segment.id(), + point, + point.x() + deltaX * scale, + point.z() + deltaZ * scale, + radiusSquared, + minimumY, + includeNeighborhood + ); + } + + private boolean matches(HydrologyColumnLayer layer, CavePosition position) { + return matchesColumn(layer, position.x(), position.z()) + && position.y() >= minimumY; + } + + private boolean matchesColumn(HydrologyColumnLayer layer, int x, int z) { + return (includeNeighborhood || layer.feature().segmentId() == segmentId) + && distanceSquared(x, z) <= radiusSquared; + } + + private double distanceSquared(int x, int z) { + double pathX = interiorX - point.x(); + double pathZ = interiorZ - point.z(); + double pathLengthSquared = pathX * pathX + pathZ * pathZ; + double positionX = x - point.x(); + double positionZ = z - point.z(); + double progress = pathLengthSquared == 0D + ? 0D + : (positionX * pathX + positionZ * pathZ) / pathLengthSquared; + double clampedProgress = Math.max(0D, Math.min(1D, progress)); + double deltaX = positionX - pathX * clampedProgress; + double deltaZ = positionZ - pathZ * clampedProgress; + return deltaX * deltaX + deltaZ * deltaZ; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveVoxelViewFactory.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveVoxelViewFactory.java new file mode 100644 index 000000000..9cf46b7b1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCaveVoxelViewFactory.java @@ -0,0 +1,13 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; + +@FunctionalInterface +public interface HydrologyCaveVoxelViewFactory { + CaveVoxelView create(PlannedSurface plannedSurface); + + @FunctionalInterface + interface PlannedSurface { + int resolve(int x, int z, int naturalHeight); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnLayer.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnLayer.java new file mode 100644 index 000000000..bb7400634 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnLayer.java @@ -0,0 +1,89 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.Objects; + +public record HydrologyColumnLayer( + HydrologyFeatureRef feature, + int bedY, + int fluidHeadY, + int ceilingY, + boolean channel, + boolean shore, + boolean grading, + boolean connectedFluid, + boolean fallingFluid, + boolean receivingPool, + boolean terrainOwned, + boolean fluidOwned, + boolean oceanApron, + String profileKey, + String surfaceBiomeKey, + String mouthBiomeKey, + String shoreBiomeKey, + String dryBiomeKey, + String floodedCaveBiomeKey +) { + public HydrologyColumnLayer { + Objects.requireNonNull(feature, "feature"); + profileKey = requireKey(profileKey, "profileKey"); + surfaceBiomeKey = requireKey(surfaceBiomeKey, "surfaceBiomeKey"); + mouthBiomeKey = requireKey(mouthBiomeKey, "mouthBiomeKey"); + shoreBiomeKey = requireKey(shoreBiomeKey, "shoreBiomeKey"); + dryBiomeKey = requireKey(dryBiomeKey, "dryBiomeKey"); + floodedCaveBiomeKey = requireKey(floodedCaveBiomeKey, "floodedCaveBiomeKey"); + if (channel && bedY > fluidHeadY) { + throw new IllegalArgumentException("A channel bed cannot be above its fluid head."); + } + if (ceilingY < fluidHeadY) { + throw new IllegalArgumentException("A hydrology carve ceiling cannot be below its fluid head."); + } + if (!feature.type().isUnderground() && !feature.type().isDeepFluid() && ceilingY != fluidHeadY) { + throw new IllegalArgumentException("Surface hydrology ceilings must equal the fluid head."); + } + if (fluidOwned && !channel) { + throw new IllegalArgumentException("Owned fluid requires a channel footprint."); + } + if (fallingFluid && !connectedFluid) { + throw new IllegalArgumentException("Falling fluid must be connected."); + } + if (receivingPool && !connectedFluid) { + throw new IllegalArgumentException("A receiving pool must be connected."); + } + if (oceanApron && (terrainOwned || fluidOwned || grading || shore)) { + throw new IllegalArgumentException("An ocean apron cannot own river writes or grading."); + } + } + + public String biomeKey() { + if (grading && !channel && !shore) { + return null; + } + if (shore) { + return shoreBiomeKey; + } + if (feature.type() == HydrologyFeatureType.MOUTH || feature.type() == HydrologyFeatureType.COASTAL_GROTTO) { + return mouthBiomeKey; + } + if (feature.type().isUnderground() || feature.type().isDeepFluid()) { + return floodedCaveBiomeKey; + } + return surfaceBiomeKey; + } + + public boolean publishesSurfaceFluid() { + return feature.type().isSurface() + && !oceanApron + && channel + && fluidOwned + && (terrainOwned || fallingFluid) + && (feature.type().isDrop() || fallingFluid || receivingPool) + && fluidHeadY > bedY; + } + + private static String requireKey(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank."); + } + return value.trim(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSample.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSample.java new file mode 100644 index 000000000..6e71a278f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSample.java @@ -0,0 +1,202 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public record HydrologyColumnSample( + int x, + int z, + int naturalHeight, + int seaLevel, + boolean ocean, + String parentBiomeKey, + List layers +) { + private static final Comparator LAYER_ORDER = Comparator + .comparingInt((HydrologyColumnLayer layer) -> layer.feature().type().renderPriority()) + .thenComparing(Comparator.comparingInt(HydrologyColumnLayer::fluidHeadY).reversed()) + .thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().id()); + private static final Comparator SURFACE_LAYER_ORDER = Comparator + .comparingInt(HydrologyColumnSample::surfaceRolePriority) + .thenComparingInt((HydrologyColumnLayer layer) -> layer.feature().type().renderPriority()) + .thenComparing(Comparator.comparingInt(HydrologyColumnLayer::fluidHeadY).reversed()) + .thenComparingInt(HydrologyColumnLayer::bedY) + .thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().id()); + + public HydrologyColumnSample { + if (parentBiomeKey == null || parentBiomeKey.isBlank()) { + throw new IllegalArgumentException("parentBiomeKey must not be blank."); + } + parentBiomeKey = parentBiomeKey.trim(); + Objects.requireNonNull(layers, "layers"); + if (layers.size() < 2) { + layers = List.copyOf(layers); + } else { + ArrayList ordered = new ArrayList<>(layers); + ordered.sort(LAYER_ORDER); + layers = List.copyOf(ordered); + } + if (ocean) { + for (HydrologyColumnLayer layer : layers) { + if (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore()) { + throw new IllegalArgumentException("Ocean columns cannot contain river-owned writes or grading."); + } + if (layer.fluidHeadY() > seaLevel) { + throw new IllegalArgumentException("Ocean column fluid cannot be elevated above sea level."); + } + } + } + if (naturalHeight <= seaLevel) { + for (HydrologyColumnLayer layer : layers) { + if (layer.feature().type().isSurface() + && !layer.oceanApron() + && (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore())) { + throw new IllegalArgumentException( + "Naturally submerged columns cannot contain owned surface hydrology writes." + ); + } + } + } + for (HydrologyColumnLayer layer : layers) { + if (layer.feature().type().isSurface() + && !layer.oceanApron() + && layer.channel() + && layer.fluidOwned() + && layer.fluidHeadY() >= naturalHeight) { + throw new IllegalArgumentException("Owned surface fluid must remain below natural terrain."); + } + } + } + + public boolean present() { + return !layers.isEmpty(); + } + + public boolean hasFeature(HydrologyFeatureType type) { + Objects.requireNonNull(type, "type"); + for (HydrologyColumnLayer layer : layers) { + if (layer.feature().type() == type) { + return true; + } + } + return false; + } + + public boolean hasConnectedFluid() { + for (HydrologyColumnLayer layer : layers) { + if (layer.connectedFluid()) { + return true; + } + } + return false; + } + + public Optional primaryLayer() { + return layers.isEmpty() ? Optional.empty() : Optional.of(layers.getFirst()); + } + + public Optional primarySurfaceLayer() { + return selectSurfaceLayer(false); + } + + public Optional primarySurfaceFluidLayer() { + return selectSurfaceLayer(true); + } + + public Optional surfacePublicationCellAt(int y) { + if (y <= terrainHeight()) { + return Optional.empty(); + } + SurfacePublicationCell selected = null; + for (HydrologyColumnLayer layer : layers) { + if (!layer.publishesSurfaceFluid() || y <= layer.bedY() || y > layer.fluidHeadY()) { + continue; + } + HydrologyCaveAction action = layer.fallingFluid() && y < layer.fluidHeadY() + ? HydrologyCaveAction.FALLING_FLUID + : HydrologyCaveAction.WET_SOURCE; + SurfacePublicationCell candidate = new SurfacePublicationCell(layer, action); + if (selected == null || surfaceActionPriority(action) < surfaceActionPriority(selected.action())) { + selected = candidate; + } + } + return Optional.ofNullable(selected); + } + + public int terrainHeight() { + Optional layer = primarySurfaceLayer(); + if (layer.isEmpty()) { + return naturalHeight; + } + HydrologyColumnLayer primary = layer.get(); + if (primary.channel()) { + return primary.bedY(); + } + int terrainHeight = primary.bedY(); + for (HydrologyColumnLayer candidate : layers) { + if (candidate.feature().type().isSurface() + && candidate.terrainOwned() + && !candidate.channel()) { + terrainHeight = Math.max(terrainHeight, candidate.bedY()); + } + } + return terrainHeight; + } + + public HydrologyRenderSample renderSample() { + ArrayList features = new ArrayList<>(layers.size()); + for (HydrologyColumnLayer layer : layers) { + features.add(layer.feature()); + } + return new HydrologyRenderSample(x, z, features); + } + + private Optional selectSurfaceLayer(boolean fluidOnly) { + HydrologyColumnLayer selected = null; + for (HydrologyColumnLayer layer : layers) { + if (layer.oceanApron() || !layer.feature().type().isSurface()) { + continue; + } + if (fluidOnly) { + if (!layer.channel() || !layer.connectedFluid() || !layer.fluidOwned()) { + continue; + } + } else if (!layer.terrainOwned()) { + continue; + } + if (selected == null || SURFACE_LAYER_ORDER.compare(layer, selected) < 0) { + selected = layer; + } + } + return Optional.ofNullable(selected); + } + + private static int surfaceRolePriority(HydrologyColumnLayer layer) { + if (layer.channel()) { + return 0; + } + return layer.shore() ? 1 : 2; + } + + private static int surfaceActionPriority(HydrologyCaveAction action) { + return action == HydrologyCaveAction.WET_SOURCE ? 0 : 1; + } + + public record SurfacePublicationCell( + HydrologyColumnLayer layer, + HydrologyCaveAction action + ) { + public SurfacePublicationCell { + Objects.requireNonNull(layer, "layer"); + if (action != HydrologyCaveAction.WET_SOURCE + && action != HydrologyCaveAction.FALLING_FLUID) { + throw new IllegalArgumentException("Surface publication cells must contain fluid."); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileCaveAdmission.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileCaveAdmission.java new file mode 100644 index 000000000..cd9f65739 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileCaveAdmission.java @@ -0,0 +1,137 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveConflictPolicy; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +final class HydrologyCrossTileCaveAdmission { + private static final Comparator CLAIM_PRIORITY = Comparator + .comparing((Claim claim) -> claim.plan().source(), HydrologyCaveConflictPolicy.sourcePriority()) + .thenComparing(Claim::profileKey); + private static final Comparator BLOCKER_PRIORITY = Comparator + .comparingInt(RankedClaim::ownerRank) + .thenComparing(RankedClaim::claim, CLAIM_PRIORITY) + .thenComparing(RankedClaim::ownerKey); + + private HydrologyCrossTileCaveAdmission() { + } + + static Result admit( + int currentOwnerRank, + Collection currentClaims, + Collection blockers + ) { + if (currentOwnerRank < 0) { + throw new IllegalArgumentException("currentOwnerRank cannot be negative"); + } + ArrayList orderedClaims = new ArrayList<>(Objects.requireNonNull(currentClaims, "currentClaims")); + orderedClaims.sort(CLAIM_PRIORITY); + ArrayList orderedBlockers = new ArrayList<>(Objects.requireNonNull(blockers, "blockers")); + orderedBlockers.sort(BLOCKER_PRIORITY); + for (RankedClaim blocker : orderedBlockers) { + if (blocker.ownerRank() >= currentOwnerRank) { + throw new IllegalArgumentException("Cross-tile blockers must have a lower owner color rank"); + } + } + + ArrayList rejections = new ArrayList<>(); + for (Claim claim : orderedClaims) { + RankedClaim winner = null; + for (RankedClaim blocker : orderedBlockers) { + if (claim.courseId() == blocker.claim().courseId()) { + throw new IllegalArgumentException("A cave course cannot belong to multiple color-ranked owners"); + } + if (!conflicts(claim, blocker.claim())) { + continue; + } + if (winner == null || BLOCKER_PRIORITY.compare(blocker, winner) < 0) { + winner = blocker; + } + } + if (winner != null) { + rejections.add(new Rejection(claim, winner)); + } + } + return new Result(rejections); + } + + private static boolean conflicts(Claim left, Claim right) { + return HydrologyCaveConflictPolicy.hasIncompatibleOverlap( + left.profileKey(), + left.plan().actions(), + right.profileKey(), + right.plan().actions() + ); + } + + record Claim(String profileKey, HydrologyCavePlan plan) { + Claim { + if (profileKey == null || profileKey.isBlank()) { + throw new IllegalArgumentException("profileKey must not be blank"); + } + profileKey = profileKey.trim(); + Objects.requireNonNull(plan, "plan"); + if (!plan.accepted()) { + throw new IllegalArgumentException("Cross-tile admission requires accepted cave plans"); + } + } + + long courseId() { + return plan.source().sourceId(); + } + } + + record RankedClaim(HydrologyTileKey ownerKey, int ownerRank, Claim claim) { + RankedClaim { + Objects.requireNonNull(ownerKey, "ownerKey"); + if (ownerRank < 0) { + throw new IllegalArgumentException("ownerRank cannot be negative"); + } + Objects.requireNonNull(claim, "claim"); + } + } + + record Result(List rejections) { + Result { + rejections = List.copyOf(Objects.requireNonNull(rejections, "rejections")); + } + + Map loserCourseIdsToWinnerSourceIds() { + TreeMap ordered = new TreeMap<>(); + for (Rejection rejection : rejections) { + ordered.put(rejection.loser().courseId(), rejection.winnerSourceId()); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(ordered)); + } + + Rejection rejection(Claim claim) { + Objects.requireNonNull(claim, "claim"); + for (Rejection rejection : rejections) { + if (rejection.loser().equals(claim)) { + return rejection; + } + } + return null; + } + } + + record Rejection(Claim loser, RankedClaim winner) { + Rejection { + Objects.requireNonNull(loser, "loser"); + Objects.requireNonNull(winner, "winner"); + } + + long winnerSourceId() { + return winner.claim().plan().source().sourceId(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java new file mode 100644 index 000000000..c9a6eb2ae --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmission.java @@ -0,0 +1,163 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +final class HydrologyCrossTileSurfaceAdmission { + private HydrologyCrossTileSurfaceAdmission() { + } + + static Result admit( + List currentClaims, + List blockers, + int minimumSeparation + ) { + ArrayList rejections = new ArrayList<>(); + for (Claim current : currentClaims) { + for (RankedClaim ranked : blockers) { + Claim blocker = ranked.claim(); + if (!conflicts(current, blocker, minimumSeparation)) { + continue; + } + rejections.add(new Rejection(current, blocker.courseId())); + break; + } + } + return new Result(List.copyOf(rejections)); + } + + private static boolean conflicts(Claim first, Claim second, int minimumSeparation) { + if (first.outletId() == second.outletId()) { + return true; + } + long minimumSeparationSquared = (long) minimumSeparation * minimumSeparation; + if (first.centerline().getFirst().distanceSquared2D(second.centerline().getFirst()) + < minimumSeparationSquared) { + return true; + } + if (first.reachesOutlet() && second.reachesOutlet() + && first.terminal().distanceSquared2D(second.terminal()) + < minimumSeparationSquared) { + return true; + } + double clearance = Math.max(4D, (first.maximumWidth() + second.maximumWidth()) / 2D + 4D); + return centerlinesWithin(first.centerline(), second.centerline(), clearance); + } + + private static boolean centerlinesWithin( + List first, + List second, + double clearance + ) { + double clearanceSquared = clearance * clearance; + for (int firstIndex = 1; firstIndex < first.size(); firstIndex++) { + HydrologyPoint firstStart = first.get(firstIndex - 1); + HydrologyPoint firstEnd = first.get(firstIndex); + for (int secondIndex = 1; secondIndex < second.size(); secondIndex++) { + HydrologyPoint secondStart = second.get(secondIndex - 1); + HydrologyPoint secondEnd = second.get(secondIndex); + if (segmentDistanceSquared(firstStart, firstEnd, secondStart, secondEnd) <= clearanceSquared) { + return true; + } + } + } + return false; + } + + private static double segmentDistanceSquared( + HydrologyPoint firstStart, + HydrologyPoint firstEnd, + HydrologyPoint secondStart, + HydrologyPoint secondEnd + ) { + if (segmentsIntersect(firstStart, firstEnd, secondStart, secondEnd)) { + return 0D; + } + return Math.min( + Math.min(pointSegmentDistanceSquared(firstStart, secondStart, secondEnd), + pointSegmentDistanceSquared(firstEnd, secondStart, secondEnd)), + Math.min(pointSegmentDistanceSquared(secondStart, firstStart, firstEnd), + pointSegmentDistanceSquared(secondEnd, firstStart, firstEnd)) + ); + } + + private static boolean segmentsIntersect( + HydrologyPoint firstStart, + HydrologyPoint firstEnd, + HydrologyPoint secondStart, + HydrologyPoint secondEnd + ) { + long firstA = orientation(firstStart, firstEnd, secondStart); + long firstB = orientation(firstStart, firstEnd, secondEnd); + long secondA = orientation(secondStart, secondEnd, firstStart); + long secondB = orientation(secondStart, secondEnd, firstEnd); + return Long.signum(firstA) != Long.signum(firstB) + && Long.signum(secondA) != Long.signum(secondB); + } + + private static long orientation(HydrologyPoint start, HydrologyPoint end, HydrologyPoint point) { + long firstX = end.x() - (long) start.x(); + long firstZ = end.z() - (long) start.z(); + long secondX = point.x() - (long) start.x(); + long secondZ = point.z() - (long) start.z(); + return firstX * secondZ - firstZ * secondX; + } + + private static double pointSegmentDistanceSquared( + HydrologyPoint point, + 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) { + return point.distanceSquared2D(start); + } + double progress = ((point.x() - start.x()) * deltaX + (point.z() - start.z()) * deltaZ) + / lengthSquared; + progress = Math.max(0D, Math.min(1D, progress)); + double closestX = start.x() + deltaX * progress; + double closestZ = start.z() + deltaZ * progress; + double pointX = point.x() - closestX; + double pointZ = point.z() - closestZ; + return pointX * pointX + pointZ * pointZ; + } + + record Claim( + long courseId, + long outletId, + HydrologyPoint terminal, + boolean reachesOutlet, + int maximumWidth, + List centerline + ) { + Claim { + Objects.requireNonNull(terminal, "terminal"); + centerline = List.copyOf(Objects.requireNonNull(centerline, "centerline")); + if (maximumWidth < 1 || centerline.size() < 2) { + throw new IllegalArgumentException("Surface claims require a positive width and centerline."); + } + } + } + + record RankedClaim(HydrologyTileKey ownerKey, int ownerRank, Claim claim) { + RankedClaim { + Objects.requireNonNull(ownerKey, "ownerKey"); + Objects.requireNonNull(claim, "claim"); + } + } + + record Rejection(Claim loser, long winnerCourseId) { + Rejection { + Objects.requireNonNull(loser, "loser"); + } + } + + record Result(List rejections) { + Result { + rejections = List.copyOf(Objects.requireNonNull(rejections, "rejections")); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticCandidate.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticCandidate.java new file mode 100644 index 000000000..67681d976 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticCandidate.java @@ -0,0 +1,18 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.Objects; + +public record HydrologyDiagnosticCandidate( + long id, + HydrologyCandidateKind kind, + HydrologyFeatureType projectedType, + HydrologyPoint point, + HydrologyCandidateRejection rejection +) { + public HydrologyDiagnosticCandidate { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(projectedType, "projectedType"); + Objects.requireNonNull(point, "point"); + Objects.requireNonNull(rejection, "rejection"); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticRenderSample.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticRenderSample.java new file mode 100644 index 000000000..ed887f08e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticRenderSample.java @@ -0,0 +1,28 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public record HydrologyDiagnosticRenderSample( + int x, + int z, + List candidates +) { + private static final Comparator CANDIDATE_ORDER = Comparator + .comparing(HydrologyDiagnosticCandidate::kind) + .thenComparing(HydrologyDiagnosticCandidate::rejection) + .thenComparingLong(HydrologyDiagnosticCandidate::id); + + public HydrologyDiagnosticRenderSample { + Objects.requireNonNull(candidates, "candidates"); + ArrayList ordered = new ArrayList<>(candidates); + ordered.sort(CANDIDATE_ORDER); + candidates = List.copyOf(ordered); + } + + public boolean present() { + return !candidates.isEmpty(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureQuery.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureQuery.java new file mode 100644 index 000000000..86843fc5c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureQuery.java @@ -0,0 +1,96 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +public record HydrologyFeatureQuery(Set types, String profileKey) { + private static final Set SURFACE = Set.of( + HydrologyFeatureType.SURFACE_POOL, + HydrologyFeatureType.RIFFLE, + HydrologyFeatureType.CASCADE, + HydrologyFeatureType.WATERFALL, + HydrologyFeatureType.MOUTH + ); + private static final Set UNDERGROUND = Set.of( + HydrologyFeatureType.RIDGE_BORE, + HydrologyFeatureType.UNDERGROUND_POOL, + HydrologyFeatureType.UNDERGROUND_DROP, + HydrologyFeatureType.SINKHOLE + ); + private static final Set GROTTOS = Set.of( + HydrologyFeatureType.COASTAL_GROTTO, + HydrologyFeatureType.INLAND_GROTTO + ); + private static final Set DEEP = Set.of( + HydrologyFeatureType.DEEP_POOL, + HydrologyFeatureType.DEEP_CHANNEL + ); + private static final Map> BUILT_INS = builtIns(); + + public HydrologyFeatureQuery { + types = Set.copyOf(types); + if (types.isEmpty()) { + throw new IllegalArgumentException("Hydrology feature query requires at least one feature type."); + } + profileKey = profileKey == null || profileKey.isBlank() ? null : profileKey.trim(); + } + + public static HydrologyFeatureQuery parse(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("Hydrology feature type must not be blank."); + } + String normalized = value.trim(); + if (normalized.regionMatches(true, 0, "type=", 0, 5)) { + normalized = normalized.substring(5).trim(); + } + String key = normalizeKeyword(normalized); + Set builtInTypes = BUILT_INS.get(key); + return builtInTypes == null + ? new HydrologyFeatureQuery(DEEP, normalized) + : new HydrologyFeatureQuery(builtInTypes, null); + } + + public static List suggestions(Collection deepFluidIds) { + LinkedHashSet suggestions = new LinkedHashSet<>(BUILT_INS.keySet()); + TreeSet orderedIds = new TreeSet<>(); + if (deepFluidIds != null) { + for (String id : deepFluidIds) { + if (id != null && !id.isBlank() && !isReservedKeyword(id)) { + orderedIds.add(id.trim()); + } + } + } + suggestions.addAll(orderedIds); + return List.copyOf(suggestions); + } + + public static boolean isReservedKeyword(String value) { + return value != null && !value.isBlank() && BUILT_INS.containsKey(normalizeKeyword(value.trim())); + } + + private static String normalizeKeyword(String value) { + return value.toLowerCase(Locale.ROOT).replace('-', '_'); + } + + private static Map> builtIns() { + LinkedHashMap> builtIns = new LinkedHashMap<>(); + builtIns.put("surface", SURFACE); + builtIns.put("waterfall", Set.of(HydrologyFeatureType.WATERFALL)); + builtIns.put("sinkhole", Set.of(HydrologyFeatureType.SINKHOLE)); + builtIns.put("underground", UNDERGROUND); + builtIns.put("grotto", GROTTOS); + builtIns.put("coastal_grotto", Set.of(HydrologyFeatureType.COASTAL_GROTTO)); + builtIns.put("inland_grotto", Set.of(HydrologyFeatureType.INLAND_GROTTO)); + builtIns.put("mouth", Set.of(HydrologyFeatureType.MOUTH)); + builtIns.put("ridge_tunnel", Set.of(HydrologyFeatureType.RIDGE_BORE)); + builtIns.put("deep", DEEP); + return Collections.unmodifiableMap(builtIns); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureRef.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureRef.java new file mode 100644 index 000000000..56dcd110d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureRef.java @@ -0,0 +1,27 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.Objects; + +public record HydrologyFeatureRef( + long id, + HydrologyFeatureType type, + long courseId, + long segmentId, + int x, + int y, + int z, + int flowDeltaX, + int flowDeltaZ, + boolean source +) { + public HydrologyFeatureRef { + Objects.requireNonNull(type, "type"); + if (flowDeltaX < -1 || flowDeltaX > 1 || flowDeltaZ < -1 || flowDeltaZ > 1) { + throw new IllegalArgumentException("Flow direction components must be between -1 and 1."); + } + } + + public HydrologyPoint point() { + return new HydrologyPoint(x, y, z); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureType.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureType.java new file mode 100644 index 000000000..65a8ee8b7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureType.java @@ -0,0 +1,62 @@ +package art.arcane.iris.engine.hydrology; + +public enum HydrologyFeatureType { + SURFACE_POOL, + RIFFLE, + CASCADE, + WATERFALL, + RIDGE_BORE, + UNDERGROUND_POOL, + UNDERGROUND_DROP, + SINKHOLE, + COASTAL_GROTTO, + INLAND_GROTTO, + MOUTH, + DEEP_POOL, + DEEP_CHANNEL; + + public boolean isSurface() { + return switch (this) { + case SURFACE_POOL, RIFFLE, CASCADE, WATERFALL, MOUTH -> true; + default -> false; + }; + } + + public boolean isUnderground() { + return switch (this) { + case RIDGE_BORE, UNDERGROUND_POOL, UNDERGROUND_DROP, SINKHOLE, COASTAL_GROTTO, INLAND_GROTTO -> true; + default -> false; + }; + } + + public boolean isGrotto() { + return this == COASTAL_GROTTO || this == INLAND_GROTTO; + } + + public boolean isDeepFluid() { + return this == DEEP_POOL || this == DEEP_CHANNEL; + } + + public boolean isDrop() { + return this == RIFFLE || this == CASCADE || this == WATERFALL + || this == UNDERGROUND_DROP || this == SINKHOLE; + } + + int renderPriority() { + return switch (this) { + case WATERFALL -> 0; + case SINKHOLE -> 1; + case CASCADE -> 2; + case RIFFLE -> 3; + case COASTAL_GROTTO -> 4; + case INLAND_GROTTO -> 5; + case MOUTH -> 6; + case RIDGE_BORE -> 7; + case UNDERGROUND_DROP -> 8; + case UNDERGROUND_POOL -> 9; + case DEEP_CHANNEL -> 10; + case DEEP_POOL -> 11; + case SURFACE_POOL -> 12; + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java new file mode 100644 index 000000000..ad588b73a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java @@ -0,0 +1,3663 @@ +package art.arcane.iris.engine.hydrology; + +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + +import java.util.ArrayList; +import java.util.Arrays; +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; +import java.util.Objects; +import java.util.Set; + +final class HydrologyFootprintCompiler { + private static final byte CHANNEL_ROLE = 1; + private static final byte SHORE_ROLE = 2; + private static final byte GRADING_ROLE = 3; + private static final int FEATURE_ROLE_COUNT = 7; + private static final int COURSE_FOOTPRINT_CACHE_SIZE = 8; + private static final int VALIDATION_RASTER_CACHE_SIZE = 8; + private static final int MINIMUM_ENDPOINT_PROFILE_LENGTH = 48; + private static final int MAXIMUM_ENDPOINT_PROFILE_LENGTH = 64; + private static final double MINIMUM_ENDPOINT_RAMP_WIDTHS = 2D; + private static final int SURFACE_SHAPE_PROFILE_LENGTH = 32; + private static final int[][] HORIZONTAL_NEIGHBORS = { + {1, 0}, {-1, 0}, {0, 1}, {0, -1} + }; + private static final double MOUTH_FLARE_RATIO = 1.5D; + private static final double SURFACE_THALWEG_RATIO = 0.65D; + private static final long ORGANIC_SHAPE_FIRST_PHASE_SALT = 0x4f5247414e31L; + private static final long ORGANIC_SHAPE_SECOND_PHASE_SALT = 0x4f5247414e32L; + private static final long ORGANIC_BED_VARIATION_SALT = 0x424544564152L; + + private final HydrologyPlannerSettings settings; + private final HydrologyTerrainSampler sampler; + private final HydrologyNaturalTerrainSampler naturalSampler; + private final HydrologyGeometrySampler geometrySampler; + private final Map deepFluids; + private final Long2ObjectOpenHashMap terrainSamples; + private final Long2ObjectOpenHashMap terrainBases; + private final Long2ObjectOpenHashMap naturalClassifications; + private final Map features; + private final LinkedHashMap courseFootprints; + private final LinkedHashMap validationCourseRasters; + private final Map rasterStencils; + private int fullMaterializationCount; + + HydrologyFootprintCompiler( + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler + ) { + this(settings, sampler, HydrologyGeometrySampler.deterministic(0L, sampler)); + } + + HydrologyFootprintCompiler( + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler, + HydrologyGeometrySampler geometrySampler + ) { + this(settings, new Sampling(sampler, geometrySampler, null)); + } + + HydrologyFootprintCompiler( + HydrologyPlannerSettings settings, + Sampling sampling + ) { + this.settings = settings; + this.sampler = sampling.sampler(); + this.naturalSampler = sampling.naturalSampler(); + this.geometrySampler = sampling.geometrySampler(); + HashMap indexed = new HashMap<>(); + for (HydrologyPlannerSettings.DeepFluid deepFluid : settings.deepFluids()) { + indexed.put(deepFluid.id(), deepFluid); + } + this.deepFluids = Map.copyOf(indexed); + this.terrainSamples = new Long2ObjectOpenHashMap<>(); + this.terrainBases = new Long2ObjectOpenHashMap<>(); + this.naturalClassifications = new Long2ObjectOpenHashMap<>(); + this.features = new HashMap<>(); + this.courseFootprints = new LinkedHashMap<>(COURSE_FOOTPRINT_CACHE_SIZE, 1F, true); + this.validationCourseRasters = new LinkedHashMap<>(VALIDATION_RASTER_CACHE_SIZE, 1F, true); + this.rasterStencils = new HashMap<>(); + this.fullMaterializationCount = 0; + } + + RiverFootprint compile(List courses) { + fullMaterializationCount++; + if (courses.isEmpty()) { + return RiverFootprint.empty(); + } + if (courses.size() == 1) { + return compileCourse(courses.getFirst()); + } + Long2ObjectLinkedOpenHashMap columns = new Long2ObjectLinkedOpenHashMap<>(); + for (RiverCourse course : courses) { + RiverFootprint footprint = compileCourse(course); + for (Map.Entry entry : footprint.columns().entrySet()) { + HydrologyColumnSample sample = entry.getValue(); + MutableColumn column = columns.get(entry.getKey()); + if (column == null) { + columns.put(entry.getKey(), new MutableColumn(sample)); + continue; + } + column.merge(sample); + } + } + containSurfaceBanks(columns); + return build(columns); + } + + ValidationRaster compileValidation(List courses) { + boolean caveCandidatePresent = false; + for (RiverCourse course : courses) { + if (hasCaveSegment(course)) { + caveCandidatePresent = true; + break; + } + } + if (!caveCandidatePresent) { + return new ValidationRaster( + List.of(), + new SurfaceRasterIndex(List.of(), new Long2ObjectOpenHashMap<>()) + ); + } + + ArrayList courseRasters = new ArrayList<>(courses.size()); + ArrayList surfaceSweeps = new ArrayList<>(); + Long2ObjectOpenHashMap firstSamples = new Long2ObjectOpenHashMap<>(); + for (RiverCourse course : courses) { + ValidationCourseRaster raster = compileValidationCourse(course); + courseRasters.add(raster); + surfaceSweeps.addAll(raster.surfaceSweeps()); + for (HydrologyColumnSample sample : raster.columns()) { + long packed = RiverFootprint.pack(sample.x(), sample.z()); + HydrologyColumnSample first = firstSamples.putIfAbsent(packed, sample); + if (first != null) { + validateMatchingTerrainMetadata(first, sample); + } + } + } + return new ValidationRaster( + List.copyOf(courseRasters), + new SurfaceRasterIndex(surfaceSweeps, firstSamples) + ); + } + + int fullMaterializationCount() { + return fullMaterializationCount; + } + + boolean surfaceHeadwaterRampSupported(RiverCourse course) { + Objects.requireNonNull(course, "course"); + if (course.type() != RiverCourseType.SURFACE) { + return true; + } + if (settings.routing().branching().minimumSurfaceCourseLength() + < MINIMUM_ENDPOINT_PROFILE_LENGTH) { + return true; + } + RiverFootprint footprint = compileCourse(course); + List raster = surfaceCourseCenterline(course); + if (raster.size() < 2) { + return false; + } + HashSet ownedCells = new HashSet<>(); + for (HydrologyColumnSample sample : footprint.columns().values()) { + HydrologyColumnLayer fluid = sample.primarySurfaceFluidLayer().orElse(null); + if (fluid != null + && fluid.feature().courseId() == course.id() + && fluid.feature().type().isSurface() + && fluid.channel() + && fluid.connectedFluid() + && fluid.fluidOwned() + && !fluid.oceanApron()) { + ownedCells.add(RiverFootprint.pack(sample.x(), sample.z())); + } + } + int maximumWidth = 1; + for (HydraulicSegment segment : course.segments()) { + if (segment.type().isSurface()) { + maximumWidth = Math.max(maximumWidth, segment.width()); + } + } + HydrologyPoint source = raster.getFirst(); + if (!ownedNear(ownedCells, source.x(), source.z(), maximumWidth)) { + return false; + } + int cruiseWidth = cruiseWidth(ownedCells, raster, maximumWidth); + double transitionLength = endpointTransitionLength( + ownedCells, + raster, + maximumWidth, + cruiseWidth + ); + return transitionLength >= cruiseWidth * MINIMUM_ENDPOINT_RAMP_WIDTHS; + } + + boolean surfaceIncisionContained(RiverCourse course) { + Objects.requireNonNull(course, "course"); + if (course.type() != RiverCourseType.SURFACE) { + return true; + } + RiverFootprint footprint = compileCourse(course); + for (HydrologyColumnSample sample : footprint.columns().values()) { + HydrologyColumnLayer fluid = sample.primarySurfaceFluidLayer().orElse(null); + if (fluid == null + || fluid.feature().courseId() != course.id() + || fluid.feature().type() == HydrologyFeatureType.MOUTH + || fluid.oceanApron() + || fluid.fallingFluid() + || !fluid.channel() + || !fluid.fluidOwned()) { + continue; + } + HydrologyTerrainSample basis = sampleTerrainBasis(sample.x(), sample.z()); + HydrologyTerrainSample terrain = sampleTerrain(sample.x(), sample.z()); + if (terrain == null + || basis == null + || (terrain.naturalHeight() - fluid.fluidHeadY() > permittedSurfaceIncision(terrain) + && basis.naturalHeight() - fluid.fluidHeadY() <= permittedSurfaceIncision(basis))) { + return false; + } + } + return true; + } + + boolean surfaceIncisionContained(RiverCourse course, RiverFootprint footprint) { + Objects.requireNonNull(course, "course"); + Objects.requireNonNull(footprint, "footprint"); + if (course.type() != RiverCourseType.SURFACE) { + return true; + } + for (HydrologyColumnSample sample : footprint.columns().values()) { + for (HydrologyColumnLayer fluid : sample.layers()) { + if (fluid.feature().courseId() != course.id() + || fluid.feature().type() == HydrologyFeatureType.MOUTH + || fluid.oceanApron() + || fluid.fallingFluid() + || !fluid.channel() + || !fluid.fluidOwned()) { + continue; + } + HydrologyTerrainSample basis = sampleTerrainBasis(sample.x(), sample.z()); + HydrologyTerrainSample terrain = sampleTerrain(sample.x(), sample.z()); + if (basis == null + || terrain == null + || (terrain.naturalHeight() - fluid.fluidHeadY() + > settings.surface().maximumIncision() + && basis.naturalHeight() - fluid.fluidHeadY() + <= permittedSurfaceIncision(basis))) { + return false; + } + } + } + return true; + } + + boolean surfaceBanksContained(RiverCourse course) { + Objects.requireNonNull(course, "course"); + if (course.type() != RiverCourseType.SURFACE) { + return true; + } + RiverFootprint footprint = compileCourse(course); + if (!surfaceCenterlinePublished(course, footprint) + || !surfaceTransitionCenterlinePublished(course, footprint)) { + return false; + } + for (HydrologyColumnSample sample : footprint.columns().values()) { + HydrologyColumnLayer fluid = sample.primarySurfaceFluidLayer().orElse(null); + if (fluid == null || fluid.oceanApron() || fluid.fallingFluid()) { + continue; + } + for (int[] offset : HORIZONTAL_NEIGHBORS) { + HydrologyColumnSample neighbor = footprint.sample( + sample.x() + offset[0], + sample.z() + offset[1] + ).orElse(null); + if (ownsSurfaceFluid(neighbor, course.id())) { + continue; + } + if (neighbor != null + && fluid.fluidHeadY() <= neighbor.seaLevel() + && (neighbor.ocean() || neighbor.naturalHeight() <= neighbor.seaLevel())) { + continue; + } + if (neighbor == null || neighbor.terrainHeight() <= fluid.fluidHeadY()) { + return false; + } + } + } + return true; + } + + private boolean surfaceCenterlinePublished(RiverCourse course, RiverFootprint footprint) { + for (HydraulicSegment segment : course.segments()) { + if (!segment.type().isSurface()) { + continue; + } + for (HydrologyPoint point : segment.centerline()) { + HydrologyColumnSample sample = footprint.sample(point.x(), point.z()).orElse(null); + if (ownsSurfaceFluid(sample, course.id()) + || segment.type() == HydrologyFeatureType.MOUTH + && ownsOceanApron(sample, course.id())) { + continue; + } + return false; + } + } + return true; + } + + private boolean surfaceTransitionCenterlinePublished(RiverCourse course, RiverFootprint footprint) { + for (int segmentIndex = 0; segmentIndex < course.segments().size(); segmentIndex++) { + HydraulicSegment segment = course.segments().get(segmentIndex); + if (!terrainRoofedSurfaceTransition(course, segment)) { + continue; + } + List centerline = continuousCenterline(segment); + for (int pointIndex = 0; pointIndex < centerline.size(); pointIndex++) { + HydrologyPoint point = centerline.get(pointIndex); + HydrologyColumnSample sample = footprint.sample(point.x(), point.z()).orElse(null); + if (ownsTransitionFluid(sample, course.id(), segment.id(), point.y())) { + continue; + } + boolean joinedStart = pointIndex == 0 + && segmentIndex > 0 + && segmentsJoin(course.segments().get(segmentIndex - 1), segment); + boolean joinedEnd = pointIndex == centerline.size() - 1 + && segmentIndex + 1 < course.segments().size() + && segmentsJoin(segment, course.segments().get(segmentIndex + 1)); + if ((joinedStart || joinedEnd) && ownsCourseFluid(sample, course.id(), point.y())) { + continue; + } + return false; + } + } + return true; + } + + private boolean ownsTransitionFluid( + HydrologyColumnSample sample, + long courseId, + long segmentId, + int fluidHead + ) { + if (sample == null) { + return false; + } + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == courseId + && layer.feature().segmentId() == segmentId + && layer.fluidHeadY() == fluidHead + && layer.channel() + && layer.connectedFluid() + && layer.fluidOwned()) { + return true; + } + } + return false; + } + + private boolean ownsCourseFluid(HydrologyColumnSample sample, long courseId, int fluidHead) { + if (sample == null) { + return false; + } + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == courseId + && layer.fluidHeadY() == fluidHead + && layer.channel() + && layer.connectedFluid() + && layer.fluidOwned()) { + return true; + } + } + return false; + } + + private int permittedSurfaceIncision(HydrologyTerrainSample terrain) { + int maximumIncision = settings.surface().maximumIncision(); + return Math.min( + maximumIncision, + (int) StrictMath.floor(maximumIncision * terrain.incisionMultiplier()) + ); + } + + private boolean ownsSurfaceFluid(HydrologyColumnSample sample, long courseId) { + if (sample == null) { + return false; + } + HydrologyColumnLayer fluid = sample.primarySurfaceFluidLayer().orElse(null); + return fluid != null + && fluid.fluidOwned() + && fluid.feature().courseId() == courseId; + } + + private boolean ownsOceanApron(HydrologyColumnSample sample, long courseId) { + if (sample == null) { + return false; + } + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.oceanApron() && layer.feature().courseId() == courseId) { + return true; + } + } + return false; + } + + private List surfaceCourseCenterline(RiverCourse course) { + ArrayList points = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + if (!segment.type().isSurface() && segment.type() != HydrologyFeatureType.RIDGE_BORE) { + break; + } + for (HydrologyPoint point : segment.centerline()) { + if (points.isEmpty() + || point.x() != points.getLast().x() + || point.z() != points.getLast().z()) { + points.add(point); + } + } + } + return rasterCenterline(points); + } + + private List rasterCenterline(List points) { + ArrayList raster = new ArrayList<>(); + for (int pairIndex = 0; pairIndex < points.size() - 1; pairIndex++) { + HydrologyPoint start = points.get(pairIndex); + HydrologyPoint end = points.get(pairIndex + 1); + int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z())); + if (steps == 0) { + continue; + } + int firstStep = raster.isEmpty() ? 0 : 1; + for (int step = firstStep; step <= steps; step++) { + double progress = step / (double) steps; + raster.add(new HydrologyPoint( + (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress), + (int) StrictMath.round(start.y() + (end.y() - start.y()) * progress), + (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress) + )); + } + } + return List.copyOf(raster); + } + + private boolean ownedNear(Set cells, int x, int z, int radius) { + for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) { + for (int deltaX = -radius; deltaX <= radius; deltaX++) { + if (cells.contains(RiverFootprint.pack(x + deltaX, z + deltaZ))) { + return true; + } + } + } + return false; + } + + private int cruiseWidth(Set cells, List raster, int maximumWidth) { + int start = Math.min(raster.size() - 2, Math.max(48, maximumWidth * 4)); + int end = Math.min(raster.size() - 1, Math.max(start + 16, maximumWidth * 6)); + ArrayList widths = new ArrayList<>(); + for (int offset = start; offset <= end; offset++) { + if (offset <= 0 || offset >= raster.size() - 1) { + continue; + } + int width = crossSectionWidth( + cells, + raster.get(offset), + raster.get(offset - 1), + raster.get(offset + 1), + maximumWidth + ); + if (width > 0) { + widths.add(width); + } + } + if (widths.isEmpty()) { + return maximumWidth; + } + widths.sort(Integer::compare); + return Math.max(1, widths.get(widths.size() / 2)); + } + + private double endpointTransitionLength( + Set cells, + List raster, + int maximumWidth, + int cruiseWidth + ) { + int endpointWidth = crossSectionWidth( + cells, + raster.getFirst(), + raster.getFirst(), + raster.get(1), + maximumWidth + ); + int widthTolerance = Math.max(1, (int) StrictMath.ceil(cruiseWidth * 0.1D)); + if (Math.abs(endpointWidth - cruiseWidth) <= widthTolerance) { + return 0D; + } + int search = Math.max(32, cruiseWidth * 6); + double distance = 0D; + int pointIndex = 1; + while (pointIndex < raster.size() && distance <= search) { + int previousIndex = pointIndex - 1; + int nextIndex = Math.min(raster.size() - 1, pointIndex + 1); + HydrologyPoint previous = raster.get(previousIndex); + HydrologyPoint station = raster.get(pointIndex); + HydrologyPoint next = raster.get(nextIndex); + distance += StrictMath.hypot(station.x() - previous.x(), station.z() - previous.z()); + int width = crossSectionWidth(cells, station, previous, next, maximumWidth); + if (Math.abs(width - cruiseWidth) <= widthTolerance) { + return distance; + } + pointIndex++; + } + return distance; + } + + private int crossSectionWidth( + Set cells, + HydrologyPoint center, + HydrologyPoint before, + HydrologyPoint after, + int maximumWidth + ) { + double tangentX = after.x() - before.x(); + double tangentZ = after.z() - before.z(); + double tangentLength = StrictMath.hypot(tangentX, tangentZ); + if (tangentLength == 0D) { + tangentX = 1D; + tangentLength = 1D; + } + double perpendicularX = -tangentZ / tangentLength; + double perpendicularZ = tangentX / tangentLength; + int limit = Math.max(4, maximumWidth * 2); + HashSet sampled = new HashSet<>(); + for (int offset = -limit; offset <= limit; offset++) { + int x = (int) StrictMath.round(center.x() + perpendicularX * offset); + int z = (int) StrictMath.round(center.z() + perpendicularZ * offset); + long packed = RiverFootprint.pack(x, z); + if (cells.contains(packed)) { + sampled.add(packed); + } + } + return sampled.size(); + } + + private ValidationCourseRaster compileValidationCourse(RiverCourse course) { + CourseRasterKey rasterKey = new CourseRasterKey( + course.id(), + course.type(), + course.profileKey(), + course.segments() + ); + ValidationCourseRaster cached = validationCourseRasters.get(rasterKey); + if (cached != null) { + return cached; + } + + boolean caveCourse = hasCaveSegment(course); + features.clear(); + Long2ObjectLinkedOpenHashMap columns = new Long2ObjectLinkedOpenHashMap<>(); + List surfaceSweeps = surfaceSweeps(course, true); + SurfaceRasterIndex courseSurface = new SurfaceRasterIndex( + surfaceSweeps, + new Long2ObjectOpenHashMap<>() + ); + for (SurfaceSweep sweep : surfaceSweeps) { + rasterizeSurfaceSweep(columns, sweep, true); + } + for (int segmentIndex = 0; segmentIndex < course.segments().size(); segmentIndex++) { + HydraulicSegment segment = course.segments().get(segmentIndex); + boolean firstSegment = segmentIndex == 0; + boolean clipStart = segmentIndex > 0 + && segmentsJoin(course.segments().get(segmentIndex - 1), segment); + boolean clipEnd = segmentIndex + 1 < course.segments().size() + && segmentsJoin(segment, course.segments().get(segmentIndex + 1)); + if (caveCourse && (segment.type().isUnderground() + || segment.type().isDeepFluid())) { + rasterizeSegment( + columns, + course, + segment, + firstSegment, + clipStart, + clipEnd, + true, + courseSurface + ); + } + } + ValidationCourseRaster raster = new ValidationCourseRaster( + course.id(), + buildValidationColumns(columns), + surfaceSweeps + ); + validationCourseRasters.put(rasterKey, raster); + if (validationCourseRasters.size() > VALIDATION_RASTER_CACHE_SIZE) { + validationCourseRasters.remove(validationCourseRasters.sequencedKeySet().getFirst()); + } + return raster; + } + + private boolean hasCaveSegment(RiverCourse course) { + for (HydraulicSegment segment : course.segments()) { + if (segment.type().isUnderground() || segment.type().isDeepFluid()) { + return true; + } + } + return false; + } + + private List buildValidationColumns( + Long2ObjectLinkedOpenHashMap columns + ) { + ArrayList built = new ArrayList<>(columns.size()); + for (MutableColumn column : columns.values()) { + built.add(column.build()); + } + built.sort(Comparator.comparingLong( + (HydrologyColumnSample sample) -> RiverFootprint.pack(sample.x(), sample.z()) + )); + return List.copyOf(built); + } + + private static void validateMatchingTerrainMetadata( + HydrologyColumnSample first, + HydrologyColumnSample second + ) { + if (first.x() != second.x() + || first.z() != second.z() + || first.naturalHeight() != second.naturalHeight() + || first.seaLevel() != second.seaLevel() + || first.ocean() != second.ocean() + || !first.parentBiomeKey().equals(second.parentBiomeKey())) { + throw new IllegalStateException("Hydrology course footprints disagree on terrain metadata at " + + first.x() + "," + first.z() + "."); + } + } + + private RiverFootprint compileCourse(RiverCourse course) { + CourseRasterKey rasterKey = new CourseRasterKey( + course.id(), + course.type(), + course.profileKey(), + course.segments() + ); + RiverFootprint cached = courseFootprints.get(rasterKey); + if (cached != null) { + return cached; + } + features.clear(); + Long2ObjectLinkedOpenHashMap columns = new Long2ObjectLinkedOpenHashMap<>(); + List surfaceSweeps = surfaceSweeps(course, true); + SurfaceRasterIndex courseSurface = new SurfaceRasterIndex( + surfaceSweeps, + new Long2ObjectOpenHashMap<>() + ); + for (SurfaceSweep sweep : surfaceSweeps) { + rasterizeSurfaceSweep(columns, sweep, false); + } + materializeSurfaceBankTerrain(columns); + containSurfaceBanks(columns); + for (int segmentIndex = 0; segmentIndex < course.segments().size(); segmentIndex++) { + HydraulicSegment segment = course.segments().get(segmentIndex); + if (segment.type().isSurface()) { + continue; + } + boolean clipStart = segmentIndex > 0 + && segmentsJoin(course.segments().get(segmentIndex - 1), segment); + boolean clipEnd = segmentIndex + 1 < course.segments().size() + && segmentsJoin(segment, course.segments().get(segmentIndex + 1)); + rasterizeSegment( + columns, + course, + segment, + segmentIndex == 0, + clipStart, + clipEnd, + false, + courseSurface + ); + } + RiverFootprint footprint = build(columns); + courseFootprints.put(rasterKey, footprint); + if (courseFootprints.size() > COURSE_FOOTPRINT_CACHE_SIZE) { + courseFootprints.remove(courseFootprints.sequencedKeySet().getFirst()); + } + return footprint; + } + + private RiverFootprint build(Long2ObjectLinkedOpenHashMap columns) { + LinkedHashMap immutable = new LinkedHashMap<>(); + for (Long2ObjectMap.Entry entry : columns.long2ObjectEntrySet()) { + immutable.put(entry.getLongKey(), entry.getValue().build()); + } + return new RiverFootprint(immutable); + } + + private void containSurfaceBanks(Long2ObjectLinkedOpenHashMap columns) { + for (MutableColumn column : columns.values()) { + if (column.maximumSurfaceFluidHead() != Integer.MIN_VALUE) { + continue; + } + int minimumBankHeight = Integer.MIN_VALUE; + for (int[] offset : HORIZONTAL_NEIGHBORS) { + MutableColumn neighbor = columns.get(RiverFootprint.pack( + column.x + offset[0], + column.z + offset[1] + )); + if (neighbor == null) { + continue; + } + int fluidHead = neighbor.maximumSurfaceFluidHead(); + if (fluidHead != Integer.MIN_VALUE) { + minimumBankHeight = Math.max(minimumBankHeight, Math.addExact(fluidHead, 1)); + } + } + if (minimumBankHeight != Integer.MIN_VALUE) { + column.raiseDrySurfaceBed(minimumBankHeight); + } + } + } + + private void materializeSurfaceBankTerrain(Long2ObjectLinkedOpenHashMap columns) { + ArrayList wetColumns = new ArrayList<>(); + for (Long2ObjectMap.Entry entry : columns.long2ObjectEntrySet()) { + if (entry.getValue().maximumSurfaceFluidHead() != Integer.MIN_VALUE) { + wetColumns.add(entry.getLongKey()); + } + } + for (long packed : wetColumns) { + int x = RiverFootprint.unpackX(packed); + int z = RiverFootprint.unpackZ(packed); + for (int[] offset : HORIZONTAL_NEIGHBORS) { + int bankX = Math.addExact(x, offset[0]); + int bankZ = Math.addExact(z, offset[1]); + long bankKey = RiverFootprint.pack(bankX, bankZ); + if (columns.containsKey(bankKey)) { + continue; + } + classifyNatural(bankX, bankZ); + HydrologyTerrainSample terrain = sampleTerrain(bankX, bankZ); + if (terrain != null) { + columns.put(bankKey, new MutableColumn(bankX, bankZ, terrain, settings.seaLevel())); + } + } + } + } + + private static HydrologyColumnLayer raisedDrySurfaceLayer( + HydrologyColumnLayer layer, + int naturalHeight, + int minimumBed + ) { + if (minimumBed == Integer.MIN_VALUE + || !layer.feature().type().isSurface() + || !layer.terrainOwned() + || layer.channel()) { + return layer; + } + int bed = Math.min(naturalHeight, Math.max(layer.bedY(), minimumBed)); + if (bed == layer.bedY()) { + return layer; + } + return new HydrologyColumnLayer( + layer.feature(), + bed, + bed, + bed, + false, + layer.shore(), + layer.grading(), + false, + false, + false, + true, + false, + false, + layer.profileKey(), + layer.surfaceBiomeKey(), + layer.mouthBiomeKey(), + layer.shoreBiomeKey(), + layer.dryBiomeKey(), + layer.floodedCaveBiomeKey() + ); + } + + private List surfaceSweeps(RiverCourse course, boolean includeFalling) { + ArrayList sweeps = new ArrayList<>(); + int runStart = -1; + for (int segmentIndex = 0; segmentIndex <= course.segments().size(); segmentIndex++) { + boolean sweptSurface = segmentIndex < course.segments().size() + && course.segments().get(segmentIndex).type().isSurface() + && !course.segments().get(segmentIndex).fallingFluid(); + if (sweptSurface && runStart < 0) { + runStart = segmentIndex; + } + if (sweptSurface || runStart < 0) { + continue; + } + sweeps.add(surfaceSweep(course, runStart, segmentIndex)); + runStart = -1; + } + if (includeFalling) { + for (int segmentIndex = 0; segmentIndex < course.segments().size(); segmentIndex++) { + HydraulicSegment segment = course.segments().get(segmentIndex); + if (!segment.type().isSurface() || !segment.fallingFluid()) { + continue; + } + sweeps.addAll(fallingSurfaceSweeps(course, segmentIndex)); + } + } + return List.copyOf(sweeps); + } + + private List fallingSurfaceSweeps(RiverCourse course, int segmentIndex) { + HydraulicSegment segment = course.segments().get(segmentIndex); + List centerline = continuousCenterline(segment); + HydrologyPoint throat = centerline.getFirst(); + int flowX = flowDelta(centerline, 0, true); + int flowZ = flowDelta(centerline, 0, false); + SurfaceSweepPoint throatPoint = new SurfaceSweepPoint( + segment, + throat, + shape( + course, + segment, + throat, + segment.downstreamHeadY() - 1, + segment.upstreamHeadY(), + true, + false + ), + segmentIndex == 0, + false, + true + ); + ArrayList sweeps = new ArrayList<>(); + sweeps.add(new SurfaceSweep( + course, + List.of(throatPoint), + segmentIndex == 0, + false, + flowX, + flowZ + )); + if (centerline.size() == 1) { + return List.copyOf(sweeps); + } + ArrayList downstream = new ArrayList<>(centerline.size() - 1); + for (int pointIndex = 1; pointIndex < centerline.size(); pointIndex++) { + HydrologyPoint point = centerline.get(pointIndex); + boolean receiving = segment.receivingPool() && pointIndex == centerline.size() - 1; + downstream.add(new SurfaceSweepPoint( + segment, + point, + shape( + course, + segment, + point, + point.y() - segment.depth(), + point.y(), + false, + receiving + ), + false, + receiving, + false + )); + } + boolean roundEnd = segmentIndex + 1 >= course.segments().size() + || !course.segments().get(segmentIndex + 1).fallingFluid(); + sweeps.add(new SurfaceSweep(course, List.copyOf(downstream), false, roundEnd, 0, 0)); + return List.copyOf(sweeps); + } + + private SurfaceSweep surfaceSweep(RiverCourse course, int startSegmentIndex, int endSegmentIndex) { + ArrayList points = new ArrayList<>(); + for (int segmentIndex = startSegmentIndex; segmentIndex < endSegmentIndex; segmentIndex++) { + HydraulicSegment segment = course.segments().get(segmentIndex); + List centerline = continuousCenterline(segment); + for (int pointIndex = 0; pointIndex < centerline.size(); pointIndex++) { + HydrologyPoint point = centerline.get(pointIndex); + boolean receiving = segment.receivingPool() && pointIndex == centerline.size() - 1; + LayerShape pointShape = shape( + course, + segment, + point, + point.y() - segment.depth(), + point.y(), + false, + receiving + ); + SurfaceSweepPoint sweepPoint = new SurfaceSweepPoint( + segment, + point, + pointShape, + false, + receiving, + false + ); + if (!points.isEmpty() + && points.getLast().point().x() == point.x() + && points.getLast().point().z() == point.z()) { + SurfaceSweepPoint existing = points.getLast(); + points.set(points.size() - 1, existing.receiving() ? existing : sweepPoint); + } else { + points.add(sweepPoint); + } + } + } + if (points.isEmpty()) { + throw new IllegalStateException("A surface sweep requires at least one centerline point."); + } + HydraulicSegment preceding = startSegmentIndex > 0 + ? course.segments().get(startSegmentIndex - 1) + : null; + HydraulicSegment following = endSegmentIndex < course.segments().size() + ? course.segments().get(endSegmentIndex) + : null; + SurfaceTerminal terminal = surfaceTerminal(points.getLast().segment()); + boolean source = startSegmentIndex == 0; + boolean roundStart = source; + boolean roundEnd = terminal != SurfaceTerminal.NONE; + return new SurfaceSweep( + course, + profileSurfaceSweep(points, source, preceding, terminal, following), + roundStart, + roundEnd, + 0, + 0 + ); + } + + private SurfaceTerminal surfaceTerminal(HydraulicSegment finalSurface) { + if (finalSurface.type() == HydrologyFeatureType.MOUTH) { + return SurfaceTerminal.OCEAN_MOUTH; + } + return SurfaceTerminal.NONE; + } + + private List profileSurfaceSweep( + List rawPoints, + boolean source, + HydraulicSegment preceding, + SurfaceTerminal terminal, + HydraulicSegment following + ) { + double[] distances = new double[rawPoints.size()]; + for (int pointIndex = 1; pointIndex < rawPoints.size(); pointIndex++) { + HydrologyPoint previous = rawPoints.get(pointIndex - 1).point(); + HydrologyPoint current = rawPoints.get(pointIndex).point(); + distances[pointIndex] = distances[pointIndex - 1] + + StrictMath.hypot(current.x() - previous.x(), current.z() - previous.z()); + } + double totalLength = distances[distances.length - 1]; + boolean profileSource = source && totalLength > 0D; + boolean profileBoreEntry = following != null + && following.type().isUnderground() + && segmentsJoin(rawPoints.getLast().segment(), following) + && totalLength > 0D; + boolean profileBoreExit = preceding != null + && preceding.type().isUnderground() + && preceding.type() != HydrologyFeatureType.SINKHOLE + && segmentsJoin(preceding, rawPoints.getFirst().segment()) + && totalLength > 0D; + boolean profileTerminal = terminal != SurfaceTerminal.NONE + && totalLength >= MINIMUM_ENDPOINT_PROFILE_LENGTH; + int portalLength = Math.min( + SURFACE_SHAPE_PROFILE_LENGTH, + Math.max(1, (int) StrictMath.floor(totalLength)) + ); + List transitionShapes = smoothSurfaceSweepShapes(rawPoints, distances); + ArrayList profiled = new ArrayList<>(rawPoints.size()); + for (int pointIndex = 0; pointIndex < rawPoints.size(); pointIndex++) { + SurfaceSweepPoint point = rawPoints.get(pointIndex); + LayerShape pointShape = transitionShapes.get(pointIndex); + if (profileSource) { + int sourceLength = Math.min( + endpointProfileLength(pointShape.channelRadius()), + Math.max(1, (int) StrictMath.floor(totalLength)) + ); + if (profileTerminal) { + sourceLength = Math.min( + sourceLength, + Math.max(1, (int) StrictMath.floor(totalLength / 2D)) + ); + } + double sourceWeight = 1D - smoothStep(Math.min(1D, distances[pointIndex] / sourceLength)); + pointShape = sourceTaper(pointShape, sourceWeight); + } + if (profileBoreExit) { + double exitWeight = 1D - smoothStep(Math.min(1D, distances[pointIndex] / portalLength)); + pointShape = borePortalShape(pointShape, exitWeight); + } + if (profileBoreEntry) { + double remaining = totalLength - distances[pointIndex]; + double entryWeight = 1D - smoothStep(Math.min(1D, remaining / portalLength)); + pointShape = borePortalShape(pointShape, entryWeight); + } + boolean receiving = point.receiving(); + if (profileTerminal) { + int terminalLength = endpointProfileLength(pointShape.channelRadius()); + if (profileSource) { + terminalLength = Math.min( + terminalLength, + Math.max(1, (int) StrictMath.floor(totalLength / 2D)) + ); + } + double remaining = totalLength - distances[pointIndex]; + double terminalWeight = 1D - smoothStep(Math.min(1D, remaining / terminalLength)); + pointShape = terminalShape(pointShape, terminal, terminalWeight); + } + profiled.add(new SurfaceSweepPoint( + point.segment(), + point.point(), + pointShape, + point.source(), + receiving, + point.falling() + )); + } + return List.copyOf(profiled); + } + + private LayerShape borePortalShape(LayerShape shape, double weight) { + return resizedSurfaceShape( + shape, + shape.channelRadius(), + shape.shoreWidth() * (1D - weight), + shape.gradingWidth() * (1D - weight), + shape.fluidHead() - shape.bed() + ); + } + + private List smoothSurfaceSweepShapes( + List points, + double[] distances + ) { + ArrayList smoothed = new ArrayList<>(points.size()); + for (int pointIndex = 0; pointIndex < points.size(); pointIndex++) { + LayerShape current = points.get(pointIndex).shape(); + double channelRadius = current.channelRadius(); + double shoreWidth = current.shoreWidth(); + double gradingWidth = current.gradingWidth(); + double depth = current.fluidHead() - current.bed(); + int firstCandidate = pointIndex; + while (firstCandidate > 0 + && distances[pointIndex] - distances[firstCandidate - 1] <= SURFACE_SHAPE_PROFILE_LENGTH) { + firstCandidate--; + } + int lastCandidate = pointIndex; + while (lastCandidate + 1 < points.size() + && distances[lastCandidate + 1] - distances[pointIndex] <= SURFACE_SHAPE_PROFILE_LENGTH) { + lastCandidate++; + } + for (int candidateIndex = firstCandidate; candidateIndex <= lastCandidate; candidateIndex++) { + double distance = StrictMath.abs(distances[candidateIndex] - distances[pointIndex]); + LayerShape candidate = points.get(candidateIndex).shape(); + double influence = smoothStep(1D - distance / SURFACE_SHAPE_PROFILE_LENGTH); + channelRadius = Math.max( + channelRadius, + channelRadius + (candidate.channelRadius() - channelRadius) * influence + ); + shoreWidth = Math.max( + shoreWidth, + shoreWidth + (candidate.shoreWidth() - shoreWidth) * influence + ); + gradingWidth = Math.max( + gradingWidth, + gradingWidth + (candidate.gradingWidth() - gradingWidth) * influence + ); + int candidateDepth = candidate.fluidHead() - candidate.bed(); + depth = Math.max(depth, depth + (candidateDepth - depth) * influence); + } + smoothed.add(resizedSurfaceShape( + current, + (int) StrictMath.round(channelRadius), + shoreWidth, + gradingWidth, + (int) StrictMath.round(depth) + )); + } + return List.copyOf(smoothed); + } + + private int endpointProfileLength(int channelRadius) { + return Math.max( + MINIMUM_ENDPOINT_PROFILE_LENGTH, + Math.min(MAXIMUM_ENDPOINT_PROFILE_LENGTH, Math.multiplyExact(channelRadius, 6)) + ); + } + + private LayerShape sourceTaper(LayerShape shape, double weight) { + return resizedSurfaceShape( + shape, + blend(shape.channelRadius(), 1, weight), + shape.shoreWidth(), + shape.gradingWidth(), + shape.fluidHead() - shape.bed() + ); + } + + private LayerShape terminalShape( + LayerShape shape, + SurfaceTerminal terminal, + double weight + ) { + return switch (terminal) { + case NONE -> shape; + case OCEAN_MOUTH -> { + int targetRadius = mouthFlareRadius(shape); + yield resizedSurfaceShape( + shape, + blend(shape.channelRadius(), targetRadius, weight), + shape.shoreWidth() + weight, + shape.gradingWidth() + weight * Math.max(2D, shape.channelRadius() * 0.5D), + blend(shape.fluidHead() - shape.bed(), 1, weight) + ); + } + }; + } + + private int mouthFlareRadius(LayerShape shape) { + return Math.max( + Math.addExact(shape.channelRadius(), 2), + (int) StrictMath.ceil(shape.channelRadius() * MOUTH_FLARE_RATIO) + ); + } + + private LayerShape resizedSurfaceShape( + LayerShape shape, + int channelRadius, + double shoreWidth, + double gradingWidth, + int depth + ) { + return new LayerShape( + channelRadius, + shoreWidth, + gradingWidth, + shape.fluidHead() - depth, + shape.fluidHead(), + shape.ceiling(), + shape.ellipsoid(), + shape.archedChannel(), + shape.roundedSurfaceBed(), + shape.organicBoundary(), + shape.fallingThroat() + ); + } + + private int blend(int start, int end, double progress) { + return (int) StrictMath.round(start + (end - start) * progress); + } + + private double smoothStep(double progress) { + return progress * progress * (3D - 2D * progress); + } + + private void rasterizeSurfaceSweep( + Long2ObjectLinkedOpenHashMap columns, + SurfaceSweep sweep, + boolean validationOnly + ) { + Long2ObjectOpenHashMap projections = new Long2ObjectOpenHashMap<>(); + if (sweep.points().size() == 1) { + collectSurfaceEdge(projections, sweep, -1); + } else { + for (int edgeIndex = 0; edgeIndex < sweep.points().size() - 1; edgeIndex++) { + collectSurfaceEdge(projections, sweep, edgeIndex); + } + } + long[] positions = projections.keySet().toLongArray(); + Arrays.sort(positions); + for (long position : positions) { + int x = RiverFootprint.unpackX(position); + int z = RiverFootprint.unpackZ(position); + rasterizeSurfaceProjection(columns, projections.get(position), x, z, validationOnly); + } + } + + private void collectSurfaceEdge( + Long2ObjectOpenHashMap projections, + SurfaceSweep sweep, + int edgeIndex + ) { + SurfaceSweepPoint start = sweep.points().get(Math.max(0, edgeIndex)); + SurfaceSweepPoint end = edgeIndex < 0 ? start : sweep.points().get(edgeIndex + 1); + int radius = (int) StrictMath.ceil(Math.max(start.shape().totalRadius(), end.shape().totalRadius())); + int minimumX = Math.min(start.point().x(), end.point().x()) - radius; + int maximumX = Math.max(start.point().x(), end.point().x()) + radius; + int minimumZ = Math.min(start.point().z(), end.point().z()) - radius; + int maximumZ = Math.max(start.point().z(), end.point().z()) + radius; + for (int z = minimumZ; z <= maximumZ; z++) { + for (int x = minimumX; x <= maximumX; x++) { + SurfaceProjection candidate = surfaceProjection(sweep, edgeIndex, x, z); + if (candidate == null) { + continue; + } + long packed = RiverFootprint.pack(x, z); + SurfaceProjection selected = projections.get(packed); + if (selected == null || prefersSurfaceProjection(candidate, selected)) { + projections.put(packed, candidate); + } + } + } + } + + private SurfaceProjection surfaceProjection(SurfaceSweep sweep, int edgeIndex, int x, int z) { + if (edgeIndex < 0) { + SurfaceSweepPoint point = sweep.points().getFirst(); + double distance = StrictMath.hypot(x - point.point().x(), z - point.point().z()); + if (distance > point.shape().totalRadius() + 0.25D) { + return null; + } + return new SurfaceProjection( + sweep, + edgeIndex, + point.segment(), + point.point(), + point.shape(), + point.source() || sweep.roundStart(), + point.receiving(), + point.falling(), + point.segment().type() == HydrologyFeatureType.MOUTH, + point.point().x(), + point.point().z(), + distance, + sweep.singletonFlowX(), + sweep.singletonFlowZ(), + false + ); + } + SurfaceSweepPoint start = sweep.points().get(edgeIndex); + SurfaceSweepPoint end = sweep.points().get(edgeIndex + 1); + double deltaX = end.point().x() - start.point().x(); + double deltaZ = end.point().z() - start.point().z(); + double lengthSquared = deltaX * deltaX + deltaZ * deltaZ; + if (lengthSquared == 0D) { + return null; + } + double rawProgress = ((x - start.point().x()) * deltaX + (z - start.point().z()) * deltaZ) + / lengthSquared; + boolean extendStart = edgeIndex == 0 && sweep.roundStart(); + boolean extendEnd = edgeIndex == sweep.points().size() - 2 && sweep.roundEnd(); + if (rawProgress < 0D && !extendStart || rawProgress > 1D && !extendEnd) { + return null; + } + double progress = Math.max(0D, Math.min(1D, rawProgress)); + double projectedX = start.point().x() + deltaX * progress; + double projectedZ = start.point().z() + deltaZ * progress; + double distance = StrictMath.hypot(x - projectedX, z - projectedZ); + LayerShape projectedShape = interpolateSurfaceShape(start.shape(), end.shape(), progress); + boolean secondHalf = progress >= 0.5D; + SurfaceSweepPoint selected = secondHalf ? end : start; + if (distance > projectedShape.totalRadius() + 0.25D) { + return null; + } + int tangentIndex = secondHalf ? edgeIndex + 1 : edgeIndex; + int[] tangent = surfaceTangent(sweep.points(), tangentIndex); + return new SurfaceProjection( + sweep, + edgeIndex, + selected.segment(), + selected.point(), + projectedShape, + sweep.roundStart() && edgeIndex == 0 && progress < 0.5D, + selected.receiving(), + selected.falling(), + selected.segment().type() == HydrologyFeatureType.MOUTH + && edgeIndex == sweep.points().size() - 2 + && progress >= 0.5D, + projectedX, + projectedZ, + distance, + tangent[0], + tangent[1], + rawProgress < 0D || rawProgress > 1D + ); + } + + private LayerShape interpolateSurfaceShape(LayerShape start, LayerShape end, double progress) { + LayerShape selected = progress < 0.5D ? start : end; + return new LayerShape( + blend(start.channelRadius(), end.channelRadius(), progress), + start.shoreWidth() + (end.shoreWidth() - start.shoreWidth()) * progress, + start.gradingWidth() + (end.gradingWidth() - start.gradingWidth()) * progress, + blend(start.bed(), end.bed(), progress), + blend(start.fluidHead(), end.fluidHead(), progress), + blend(start.ceiling(), end.ceiling(), progress), + selected.ellipsoid(), + selected.archedChannel(), + selected.roundedSurfaceBed(), + selected.organicBoundary(), + selected.fallingThroat() + ); + } + + private int[] surfaceTangent(List points, int pointIndex) { + int reach = Math.max(2, settings.routing().refinementSpacing() * 2); + HydrologyPoint start = points.get(Math.max(0, pointIndex - reach)).point(); + HydrologyPoint end = points.get(Math.min(points.size() - 1, pointIndex + reach)).point(); + int flowX = end.x() - start.x(); + int flowZ = end.z() - start.z(); + return flowX == 0 && flowZ == 0 ? new int[]{1, 0} : new int[]{flowX, flowZ}; + } + + private boolean prefersSurfaceProjection(SurfaceProjection candidate, SurfaceProjection selected) { + int distanceComparison = Double.compare(candidate.distance(), selected.distance()); + if (distanceComparison != 0) { + return distanceComparison < 0; + } + if (candidate.receiving() != selected.receiving()) { + return candidate.receiving(); + } + return candidate.edgeIndex() < selected.edgeIndex(); + } + + private void rasterizeSurfaceProjection( + Long2ObjectLinkedOpenHashMap columns, + SurfaceProjection projection, + int x, + int z, + boolean validationOnly + ) { + SurfaceCell cell = surfaceCell(projection, x, z); + if (cell == null) { + return; + } + if (cell.layer() != null) { + if (!validationOnly) { + addLayer(columns, x, z, cell.terrain(), cell.layer()); + } + return; + } + HydrologyFeatureRef[] pointFeatures = new HydrologyFeatureRef[FEATURE_ROLE_COUNT]; + double distance = surfaceDistance(projection, x, z); + if (cell.naturalOcean() && oceanApronEligible(projection.segment(), distance)) { + addOceanApron( + columns, + projection.sweep().course(), + projection.segment(), + projection.anchor(), + x, + z, + distance, + projection.flowX(), + projection.flowZ(), + cell.terrain(), + pointFeatures + ); + } + } + + private void rasterizeSegment( + Long2ObjectLinkedOpenHashMap columns, + RiverCourse course, + HydraulicSegment segment, + boolean firstSegment, + boolean clipStart, + boolean clipEnd, + boolean validationOnly, + SurfaceRasterIndex plannedSurface + ) { + List centerline = continuousCenterline(segment); + if (segment.fallingFluid() && centerline.size() > 1) { + HydrologyPoint throat = centerline.getFirst(); + int fluidHead = segment.upstreamHeadY(); + LayerShape throatShape = shape( + course, + segment, + throat, + segment.downstreamHeadY() - 1, + fluidHead, + true, + false + ); + rasterizePoint( + columns, + course, + segment, + throat, + 0, + throatShape, + flowDelta(centerline, 0, true), + flowDelta(centerline, 0, false), + firstSegment, + true, + false, + false, + validationOnly, + plannedSurface + ); + rasterizeSweptSegment( + columns, + course, + segment, + List.copyOf(centerline.subList(1, centerline.size())), + false, + true, + clipEnd, + validationOnly, + plannedSurface + ); + return; + } + if (sweptChannel(segment, centerline)) { + rasterizeSweptSegment( + columns, + course, + segment, + centerline, + firstSegment, + clipStart, + clipEnd, + validationOnly, + plannedSurface + ); + return; + } + for (int pointIndex = 0; pointIndex < centerline.size(); pointIndex++) { + HydrologyPoint point = centerline.get(pointIndex); + boolean oceanConnection = (segment.type() == HydrologyFeatureType.MOUTH + || segment.type() == HydrologyFeatureType.COASTAL_GROTTO) + && pointIndex == centerline.size() - 1; + boolean falling = segment.fallingFluid() && pointIndex == 0; + boolean receiving = segment.receivingPool() && pointIndex == centerline.size() - 1; + int fluidHead = falling ? segment.upstreamHeadY() : point.y(); + int bed = falling + ? segment.downstreamHeadY() - 1 + : fluidHead - segment.depth(); + LayerShape shape = shape(course, segment, point, bed, fluidHead, falling, receiving); + int flowX = flowDelta(centerline, pointIndex, true); + int flowZ = flowDelta(centerline, pointIndex, false); + rasterizePoint( + columns, + course, + segment, + point, + pointIndex, + shape, + flowX, + flowZ, + firstSegment && pointIndex == 0, + falling, + receiving, + oceanConnection, + validationOnly, + plannedSurface + ); + } + } + + private boolean sweptChannel( + HydraulicSegment segment, + List centerline + ) { + if (centerline.size() < 2 || segment.fallingFluid()) { + return false; + } + return segment.type().isSurface() + || segment.type() == HydrologyFeatureType.RIDGE_BORE + || segment.type() == HydrologyFeatureType.UNDERGROUND_POOL + || segment.type() == HydrologyFeatureType.UNDERGROUND_DROP + || segment.type() == HydrologyFeatureType.SINKHOLE + || segment.type() == HydrologyFeatureType.DEEP_CHANNEL; + } + + private void rasterizeSweptSegment( + Long2ObjectLinkedOpenHashMap columns, + RiverCourse course, + HydraulicSegment segment, + List centerline, + boolean firstSegment, + boolean clipStart, + boolean clipEnd, + boolean validationOnly, + SurfaceRasterIndex plannedSurface + ) { + LayerShape[] shapes = new LayerShape[centerline.size()]; + int maximumRadius = 1; + int minimumX = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (int pointIndex = 0; pointIndex < centerline.size(); pointIndex++) { + HydrologyPoint point = centerline.get(pointIndex); + int fluidHead = point.y(); + boolean receiving = segment.receivingPool() && pointIndex == centerline.size() - 1; + LayerShape shape = shape( + course, + segment, + point, + fluidHead - segment.depth(), + fluidHead, + false, + receiving + ); + shapes[pointIndex] = shape; + int rasterRadius = segment.type().isSurface() + ? (int) StrictMath.ceil(shape.totalRadius()) + : shape.channelRadius(); + maximumRadius = Math.max(maximumRadius, rasterRadius); + minimumX = Math.min(minimumX, point.x()); + maximumX = Math.max(maximumX, point.x()); + minimumZ = Math.min(minimumZ, point.z()); + maximumZ = Math.max(maximumZ, point.z()); + } + minimumX = Math.subtractExact(minimumX, maximumRadius); + maximumX = Math.addExact(maximumX, maximumRadius); + minimumZ = Math.subtractExact(minimumZ, maximumRadius); + maximumZ = Math.addExact(maximumZ, maximumRadius); + HydrologyFeatureRef[][] pointFeatures = new HydrologyFeatureRef[centerline.size()][FEATURE_ROLE_COUNT]; + for (int z = minimumZ; z <= maximumZ; z++) { + for (int x = minimumX; x <= maximumX; x++) { + if (!withinLongitudinalBounds(centerline, x, z, clipStart, clipEnd)) { + continue; + } + CenterlineProjection projection = projectCenterline(centerline, x, z); + int pointIndex = projection.pointIndex(); + HydrologyPoint point = centerline.get(pointIndex); + boolean receiving = segment.receivingPool() && pointIndex == centerline.size() - 1; + LayerShape shape = shapes[pointIndex]; + int deltaX = x - point.x(); + int deltaZ = z - point.z(); + double rawDistance = projection.distance(); + double rasterRadius = segment.type().isSurface() + ? shape.totalRadius() + : shape.channelRadius(); + if (rawDistance > rasterRadius + 0.25D) { + continue; + } + int flowX = projection.flowX(); + int flowZ = projection.flowZ(); + double channelDistance = shapedDistance( + shape, + segment, + x, + z, + x - projection.x(), + z - projection.z(), + flowX, + flowZ, + rawDistance + ); + boolean channel = channelDistance <= shape.channelRadius() + 0.25D; + if (!segment.type().isSurface() && !channel) { + continue; + } + boolean shore = !channel + && channelDistance <= shape.channelRadius() + shape.shoreWidth() + 0.25D; + boolean grading = !channel && !shore && rawDistance <= shape.totalRadius() + 0.25D; + if (!channel && !shore && !grading) { + continue; + } + double distance = grading ? rawDistance : channelDistance; + HydrologyRoutingTerrainSampler.NaturalClassification classification = classifyNatural(x, z); + boolean oceanConnection = (segment.type() == HydrologyFeatureType.MOUTH + || segment.type() == HydrologyFeatureType.COASTAL_GROTTO) + && pointIndex == centerline.size() - 1; + boolean apronEligible = oceanApronEligible(segment, distance); + if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + && !apronEligible) { + continue; + } + if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.LAND + && oceanConnection) { + continue; + } + boolean exactSlope = classification != HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + && !oceanConnection + && !channel + && segment.type() != HydrologyFeatureType.WATERFALL + && segment.type() != HydrologyFeatureType.CASCADE; + HydrologyTerrainSample terrain = exactSlope ? sampleTerrain(x, z) : sampleTerrainBasis(x, z); + if (terrain == null) { + continue; + } + boolean naturalOcean = classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + || terrain.ocean() + || naturallySubmergedSurfaceColumn(segment, terrain); + if (oceanConnection || naturalOcean) { + if (naturalOcean && apronEligible) { + addOceanApron(columns, course, segment, point, x, z, distance, flowX, flowZ, + terrain, pointFeatures[pointIndex]); + } + continue; + } + boolean source = firstSegment && pointIndex == 0; + if (validationOnly && segment.type().isSurface()) { + feature(course, segment, x, shape.fluidHead(), z, flowX, flowZ, + source && deltaX == 0 && deltaZ == 0, channel, shore, grading, + false, false, pointFeatures[pointIndex]); + continue; + } + HydrologyColumnLayer layer = regularLayer( + course, + segment, + point, + shape, + terrain, + distance, + deltaX, + deltaZ, + flowX, + flowZ, + source, + channel, + shore, + grading, + false, + receiving, + pointFeatures[pointIndex] + ); + layer = terrainContainedCaveLayer(course, segment, terrain, layer, x, z, plannedSurface); + if (layer == null) { + continue; + } + addLayer(columns, x, z, terrain, layer); + } + } + } + + private CenterlineProjection projectCenterline(List centerline, int x, int z) { + if (centerline.size() == 1) { + HydrologyPoint point = centerline.getFirst(); + return new CenterlineProjection( + 0, + point.x(), + point.z(), + StrictMath.hypot(x - point.x(), z - point.z()), + 1, + 0 + ); + } + int selectedPoint = 0; + double selectedX = centerline.getFirst().x(); + double selectedZ = centerline.getFirst().z(); + double selectedDistanceSquared = Double.POSITIVE_INFINITY; + int selectedFlowX = 1; + int selectedFlowZ = 0; + for (int segmentIndex = 0; segmentIndex < centerline.size() - 1; segmentIndex++) { + HydrologyPoint start = centerline.get(segmentIndex); + HydrologyPoint end = centerline.get(segmentIndex + 1); + double flowX = end.x() - start.x(); + double flowZ = end.z() - start.z(); + double lengthSquared = flowX * flowX + flowZ * flowZ; + if (lengthSquared == 0D) { + continue; + } + double progress = ((x - start.x()) * flowX + (z - start.z()) * flowZ) / lengthSquared; + progress = Math.max(0D, Math.min(1D, progress)); + double projectedX = start.x() + flowX * progress; + double projectedZ = start.z() + flowZ * progress; + double deltaX = x - projectedX; + double deltaZ = z - projectedZ; + double distanceSquared = deltaX * deltaX + deltaZ * deltaZ; + if (distanceSquared >= selectedDistanceSquared) { + continue; + } + selectedDistanceSquared = distanceSquared; + selectedX = projectedX; + selectedZ = projectedZ; + selectedPoint = progress < 0.5D ? segmentIndex : segmentIndex + 1; + selectedFlowX = end.x() - start.x(); + selectedFlowZ = end.z() - start.z(); + } + int tangentReach = Math.max(2, settings.routing().refinementSpacing() * 2); + HydrologyPoint tangentStart = centerline.get(Math.max(0, selectedPoint - tangentReach)); + HydrologyPoint tangentEnd = centerline.get(Math.min(centerline.size() - 1, selectedPoint + tangentReach)); + int tangentX = tangentEnd.x() - tangentStart.x(); + int tangentZ = tangentEnd.z() - tangentStart.z(); + if (tangentX != 0 || tangentZ != 0) { + selectedFlowX = tangentX; + selectedFlowZ = tangentZ; + } + return new CenterlineProjection( + selectedPoint, + selectedX, + selectedZ, + StrictMath.sqrt(selectedDistanceSquared), + selectedFlowX, + selectedFlowZ + ); + } + + private boolean withinLongitudinalBounds( + List centerline, + int x, + int z, + boolean clipStart, + boolean clipEnd + ) { + if (centerline.size() < 2) { + return true; + } + HydrologyPoint start = centerline.getFirst(); + HydrologyPoint afterStart = centerline.get(1); + long startDot = (long) (x - start.x()) * (afterStart.x() - start.x()) + + (long) (z - start.z()) * (afterStart.z() - start.z()); + if (clipStart && startDot < 0L) { + return false; + } + if (!clipEnd) { + return true; + } + HydrologyPoint end = centerline.getLast(); + HydrologyPoint beforeEnd = centerline.get(centerline.size() - 2); + long endDot = (long) (x - end.x()) * (end.x() - beforeEnd.x()) + + (long) (z - end.z()) * (end.z() - beforeEnd.z()); + return endDot <= 0L; + } + + private boolean segmentsJoin(HydraulicSegment upstream, HydraulicSegment downstream) { + HydrologyPoint upstreamEnd = upstream.centerline().getLast(); + HydrologyPoint downstreamStart = downstream.centerline().getFirst(); + return upstreamEnd.x() == downstreamStart.x() + && upstreamEnd.y() == downstreamStart.y() + && upstreamEnd.z() == downstreamStart.z(); + } + + private List continuousCenterline(HydraulicSegment segment) { + List configured = segment.centerline(); + if (configured.size() == 1) { + return configured; + } + ArrayList continuous = new ArrayList<>(); + for (int pairIndex = 0; pairIndex < configured.size() - 1; pairIndex++) { + HydrologyPoint start = configured.get(pairIndex); + HydrologyPoint end = configured.get(pairIndex + 1); + int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z())); + if (steps == 0) { + if (continuous.isEmpty()) { + continuous.add(start); + } + continue; + } + int firstStep = continuous.isEmpty() ? 0 : 1; + for (int step = firstStep; step <= steps; step++) { + 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); + int y = segment.fallingFluid() && !continuous.isEmpty() + ? segment.downstreamHeadY() + : (int) StrictMath.round(start.y() + (end.y() - start.y()) * progress); + HydrologyPoint previous = continuous.isEmpty() ? null : continuous.getLast(); + if (previous == null || previous.x() != x || previous.z() != z) { + continuous.add(new HydrologyPoint(x, y, z)); + } + } + } + return List.copyOf(continuous); + } + + private LayerShape shape( + RiverCourse course, + HydraulicSegment segment, + HydrologyPoint point, + int bed, + int fluidHead, + boolean falling, + boolean receiving + ) { + HydrologyFeatureType type = segment.type(); + int channelRadius = Math.max(1, segment.width() / 2); + double shoreWidth = 0D; + double gradingWidth = 0D; + int ceiling = fluidHead; + boolean ellipsoid = false; + boolean archedChannel = false; + boolean roundedSurfaceBed = false; + boolean organicBoundary = type.isSurface() || type.isUnderground() || type.isDeepFluid() || falling || receiving; + if (course.type() == RiverCourseType.SURFACE && type.isSurface()) { + roundedSurfaceBed = !falling; + shoreWidth = settings.surface().shoreWidth(); + gradingWidth = geometrySampler.sample( + HydrologyGeometrySampler.Field.SURFACE_BLEND_WIDTH, + course.profileKey(), + point.x(), + point.z(), + segment.id(), + settings.surface().minimumTerrainBlendWidth(), + settings.surface().maximumTerrainBlendWidth() + ); + if (type == HydrologyFeatureType.WATERFALL) { + shoreWidth = Math.min(2.5D, shoreWidth); + gradingWidth = Math.min(gradingWidth, Math.max(8D, channelRadius * 4D)); + } + } + if (type == HydrologyFeatureType.RIDGE_BORE + || course.type() == RiverCourseType.SURFACE + && type == HydrologyFeatureType.UNDERGROUND_DROP) { + ceiling = fluidHead + settings.surface().ridgeTunnelHeadroom(); + archedChannel = !falling; + organicBoundary = true; + } else if (type == HydrologyFeatureType.UNDERGROUND_POOL + || type == HydrologyFeatureType.UNDERGROUND_DROP + || type == HydrologyFeatureType.SINKHOLE) { + ceiling = fluidHead + geometrySampler.sample( + HydrologyGeometrySampler.Field.UNDERGROUND_HEADROOM, + course.profileKey(), + point.x(), + point.z(), + course.id(), + settings.underground().minimumHeadroom(), + settings.underground().maximumHeadroom() + ); + archedChannel = !falling; + } else if (type == HydrologyFeatureType.COASTAL_GROTTO) { + HydrologyPlannerSettings.Grotto grotto = settings.outlets().coastalGrotto(); + channelRadius = grotto.horizontalRadius(); + bed = fluidHead - grotto.verticalRadius(); + ceiling = fluidHead + grotto.headroom(); + ellipsoid = true; + organicBoundary = true; + } else if (type == HydrologyFeatureType.INLAND_GROTTO) { + HydrologyPlannerSettings.Grotto grotto = settings.outlets().inlandGrotto(); + channelRadius = grotto.horizontalRadius(); + bed = fluidHead - grotto.verticalRadius(); + ceiling = fluidHead + grotto.headroom(); + ellipsoid = true; + organicBoundary = true; + } else if (type.isDeepFluid()) { + HydrologyPlannerSettings.DeepFluid deepFluid = deepFluids.get(course.profileKey()); + if (deepFluid == null) { + throw new IllegalStateException("Missing deep-fluid planner settings for " + course.profileKey() + "."); + } + if (type == HydrologyFeatureType.DEEP_POOL) { + channelRadius = Math.max(1, segment.width() / 2); + bed = fluidHead - Math.max(segment.depth(), deepFluid.minimumVerticalRadius()); + ellipsoid = true; + } else { + archedChannel = !falling; + } + ceiling = fluidHead + deepFluid.headroom(); + organicBoundary = true; + } + if (receiving) { + HydrologyPlannerSettings.Drops drops = settings.geometry().drops(); + if (course.type() != RiverCourseType.SURFACE || !type.isSurface()) { + channelRadius = Math.max( + channelRadius, + (int) StrictMath.ceil(drops.basinWidth(segment.width()) / 2D) + ); + } + bed = Math.min(bed, fluidHead - drops.basinDepth(segment.depth(), segment.drop())); + if (course.type() == RiverCourseType.SURFACE && type.isSurface()) { + gradingWidth = Math.max( + gradingWidth, + Math.min(settings.surface().maximumTerrainBlendWidth(), channelRadius * 2D) + ); + } + } + if (type == HydrologyFeatureType.UNDERGROUND_POOL && segment.centerline().size() > 1) { + int depthVariation = Math.min( + 2, + Math.max(0, settings.underground().maximumDepth() - (fluidHead - bed)) + ); + bed -= organicVerticalVariation( + HydrologyHash.mix(segment.id(), ORGANIC_BED_VARIATION_SALT), + point.x(), + point.z(), + depthVariation + ); + } + if (falling) { + bed = Math.min(bed, segment.downstreamHeadY() - 1); + ceiling = Math.max(ceiling, segment.upstreamHeadY()); + } + return new LayerShape( + channelRadius, + shoreWidth, + gradingWidth, + bed, + fluidHead, + ceiling, + ellipsoid, + archedChannel, + roundedSurfaceBed, + organicBoundary, + falling + ); + } + + private int organicVerticalVariation( + long seed, + int x, + int z, + int maximum + ) { + if (maximum == 0) { + return 0; + } + double sampled = organicNoise(seed, x, z, 12); + return (int) StrictMath.round(sampled * maximum); + } + + private void rasterizePoint( + Long2ObjectLinkedOpenHashMap columns, + RiverCourse course, + HydraulicSegment segment, + HydrologyPoint point, + int pointIndex, + LayerShape shape, + int flowX, + int flowZ, + boolean source, + boolean falling, + boolean receiving, + boolean oceanConnection, + boolean validationOnly, + SurfaceRasterIndex plannedSurface + ) { + RasterStencil stencil = rasterStencil(shape); + HydrologyFeatureRef[] pointFeatures = new HydrologyFeatureRef[FEATURE_ROLE_COUNT]; + boolean underground = segment.type().isUnderground() || segment.type().isDeepFluid(); + for (int offsetIndex = 0; offsetIndex < stencil.size(); offsetIndex++) { + int deltaX = stencil.deltaXs()[offsetIndex]; + int deltaZ = stencil.deltaZs()[offsetIndex]; + int x = point.x() + deltaX; + int z = point.z() + deltaZ; + double channelDistance = shapedDistance( + shape, + segment, + x, + z, + deltaX, + deltaZ, + flowX, + flowZ, + stencil.distances()[offsetIndex] + ); + boolean channel = channelDistance <= shape.channelRadius() + 0.25D; + if (underground && !channel) { + continue; + } + boolean shore = !channel + && stencil.distances()[offsetIndex] <= shape.channelRadius() + shape.shoreWidth() + 0.25D; + boolean grading = !channel + && !shore + && stencil.distances()[offsetIndex] <= shape.totalRadius() + 0.25D; + double distance = grading ? stencil.distances()[offsetIndex] : channelDistance; + HydrologyRoutingTerrainSampler.NaturalClassification classification = classifyNatural(x, z); + boolean apronEligible = oceanApronEligible(segment, distance); + if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + && !apronEligible) { + continue; + } + if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.LAND + && oceanConnection) { + continue; + } + boolean exactSlope = classification != HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + && !oceanConnection + && !channel + && segment.type() != HydrologyFeatureType.WATERFALL + && segment.type() != HydrologyFeatureType.CASCADE; + HydrologyTerrainSample terrain = exactSlope + ? sampleTerrain(x, z) + : sampleTerrainBasis(x, z); + if (terrain == null) { + continue; + } + boolean naturalOcean = classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + || terrain.ocean() + || naturallySubmergedSurfaceColumn(segment, terrain); + if (oceanConnection) { + if (naturalOcean) { + addOceanApron( + columns, + course, + segment, + point, + x, + z, + distance, + flowX, + flowZ, + terrain, + pointFeatures + ); + } + continue; + } + if (naturalOcean) { + addOceanApron( + columns, + course, + segment, + point, + x, + z, + distance, + flowX, + flowZ, + terrain, + pointFeatures + ); + continue; + } + if (validationOnly && !underground) { + feature( + course, + segment, + x, + shape.fluidHead(), + z, + flowX, + flowZ, + source && deltaX == 0 && deltaZ == 0, + channel, + shore, + grading, + channel && falling, + channel && receiving, + pointFeatures + ); + continue; + } + HydrologyColumnLayer layer = regularLayer( + course, + segment, + point, + shape, + terrain, + distance, + deltaX, + deltaZ, + flowX, + flowZ, + source, + channel, + shore, + grading, + falling, + receiving, + pointFeatures + ); + layer = terrainContainedCaveLayer(course, segment, terrain, layer, x, z, plannedSurface); + if (layer == null) { + continue; + } + addLayer(columns, x, z, terrain, layer); + } + } + + private HydrologyColumnLayer terrainContainedCaveLayer( + RiverCourse course, + HydraulicSegment segment, + HydrologyTerrainSample terrain, + HydrologyColumnLayer layer, + int x, + int z, + SurfaceRasterIndex plannedSurface + ) { + if (!terrainRoofedSurfaceTransition(course, segment)) { + return layer; + } + int plannedTerrainHeight = plannedSurface.resolve( + x, + z, + terrain.naturalHeight() + ); + boolean surfaceChannel = plannedSurface.ownsSurfaceChannelAt( + x, + z, + course.id() + ); + int maximumCeiling = surfaceChannel ? Integer.MAX_VALUE : plannedTerrainHeight - 1; + for (int[] offset : HORIZONTAL_NEIGHBORS) { + int neighborX = x + offset[0]; + int neighborZ = z + offset[1]; + HydrologyTerrainSample neighborTerrain = sampleTerrainBasis(neighborX, neighborZ); + if (neighborTerrain == null) { + continue; + } + if (plannedSurface.ownsSurfaceChannelAt(neighborX, neighborZ, course.id())) { + continue; + } + int neighborSurface = plannedSurface.resolve( + neighborX, + neighborZ, + neighborTerrain.naturalHeight() + ); + maximumCeiling = Math.min(maximumCeiling, neighborSurface - 1); + } + if (maximumCeiling == Integer.MAX_VALUE || layer.ceilingY() <= maximumCeiling) { + return layer; + } + if (maximumCeiling < layer.fluidHeadY()) { + return null; + } + return withCeiling(layer, maximumCeiling); + } + + private HydrologyColumnLayer withCeiling(HydrologyColumnLayer layer, int ceiling) { + return new HydrologyColumnLayer( + layer.feature(), + layer.bedY(), + layer.fluidHeadY(), + Math.min(layer.ceilingY(), ceiling), + layer.channel(), + layer.shore(), + layer.grading(), + layer.connectedFluid(), + layer.fallingFluid(), + layer.receivingPool(), + layer.terrainOwned(), + layer.fluidOwned(), + layer.oceanApron(), + layer.profileKey(), + layer.surfaceBiomeKey(), + layer.mouthBiomeKey(), + layer.shoreBiomeKey(), + layer.dryBiomeKey(), + layer.floodedCaveBiomeKey() + ); + } + + private HydrologyColumnLayer regularLayer( + RiverCourse course, + HydraulicSegment segment, + HydrologyPoint point, + LayerShape shape, + HydrologyTerrainSample terrain, + double distance, + int deltaX, + int deltaZ, + int flowX, + int flowZ, + boolean source, + boolean channel, + boolean shore, + boolean grading, + boolean falling, + boolean receiving, + HydrologyFeatureRef[] pointFeatures + ) { + HydrologyFeatureRef feature = feature( + course, + segment, + point.x() + deltaX, + shape.fluidHead(), + point.z() + deltaZ, + flowX, + flowZ, + source && deltaX == 0 && deltaZ == 0, + channel, + shore, + grading, + channel && falling, + channel && receiving, + pointFeatures + ); + return regularLayer( + course, + segment, + shape, + terrain, + distance, + point.x() + deltaX, + point.z() + deltaZ, + channel, + shore, + grading, + falling, + receiving, + feature + ); + } + + private HydrologyColumnLayer regularLayer( + RiverCourse course, + HydraulicSegment segment, + LayerShape shape, + HydrologyTerrainSample terrain, + double distance, + int worldX, + int worldZ, + boolean channel, + boolean shore, + boolean grading, + boolean falling, + boolean receiving, + HydrologyFeatureRef feature + ) { + HydrologyPlannerSettings.ChannelShape channelShape = channelShape(segment.type()); + double ellipsoidScale = channel && (shape.ellipsoid() || shape.archedChannel()) + ? ellipsoidScale(shape.channelRadius(), distance, channelShape.bedRoundness()) + : 0D; + int bed = resolvedBed( + segment, + shape, + channelShape, + terrain, + distance, + worldX, + worldZ, + channel, + ellipsoidScale + ); + int fluidHead = channel ? shape.fluidHead() : bed; + int ceiling = channel ? localCeiling(shape, ellipsoidScale) : fluidHead; + if (channel && terrainRoofedSurfaceTransition(course, segment)) { + ceiling = Math.max(fluidHead, Math.min(ceiling, terrain.naturalHeight() - 1)); + } + boolean terrainOwned = !falling; + return new HydrologyColumnLayer( + feature, + bed, + fluidHead, + ceiling, + channel, + terrainOwned && shore, + terrainOwned && (grading || shore), + channel, + channel && falling, + channel && receiving, + terrainOwned, + channel, + false, + course.profileKey(), + terrain.surfaceBiomeKey(), + terrain.mouthBiomeKey(), + terrain.shoreBiomeKey(), + terrain.dryBiomeKey(), + terrain.floodedCaveBiomeKey() + ); + } + + private SurfaceCell surfaceCell(SurfaceProjection projection, int x, int z) { + LayerShape shape = projection.shape(); + double channelDistance = surfaceDistance(projection, x, z); + boolean channel = channelDistance <= shape.channelRadius() + 0.25D; + boolean shore = !channel + && channelDistance <= shape.channelRadius() + shape.shoreWidth() + 0.25D; + boolean grading = !channel && !shore && projection.distance() <= shape.totalRadius() + 0.25D; + if (!channel && !shore && !grading) { + return null; + } + double distance = grading ? projection.distance() : channelDistance; + HydraulicSegment segment = projection.segment(); + HydrologyRoutingTerrainSampler.NaturalClassification classification = classifyNatural(x, z); + boolean apronEligible = oceanApronEligible(segment, distance); + if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN && !apronEligible) { + return null; + } + boolean surfaceCourse = projection.sweep().course().type() == RiverCourseType.SURFACE; + if (!surfaceCourse + && classification == HydrologyRoutingTerrainSampler.NaturalClassification.LAND + && projection.oceanConnection()) { + return null; + } + boolean exactSlope = classification != HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + && (!projection.oceanConnection() || surfaceCourse) + && !channel + && segment.type() != HydrologyFeatureType.WATERFALL + && segment.type() != HydrologyFeatureType.CASCADE; + HydrologyTerrainSample terrain = exactSlope ? sampleTerrain(x, z) : sampleTerrainBasis(x, z); + if (terrain == null) { + return null; + } + boolean naturalOcean = classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + || terrain.ocean() + || naturallySubmergedSurfaceColumn(segment, terrain); + if (naturalOcean) { + return apronEligible ? new SurfaceCell(terrain, null, true) : null; + } + if (channel && !projection.falling() && !surfaceChannelTerrainSupported(shape, terrain, x, z)) { + return null; + } + if (!surfaceCourse && projection.oceanConnection()) { + return null; + } + int deltaX = x - projection.anchor().x(); + int deltaZ = z - projection.anchor().z(); + HydrologyFeatureRef[] pointFeatures = new HydrologyFeatureRef[FEATURE_ROLE_COUNT]; + HydrologyColumnLayer layer = regularLayer( + projection.sweep().course(), + segment, + projection.anchor(), + shape, + terrain, + distance, + deltaX, + deltaZ, + projection.flowX(), + projection.flowZ(), + projection.source(), + channel, + shore, + grading, + projection.falling(), + projection.receiving(), + pointFeatures + ); + return new SurfaceCell(terrain, layer, false); + } + + private boolean surfaceChannelTerrainSupported( + LayerShape shape, + HydrologyTerrainSample terrain, + int x, + int z + ) { + if (terrain.naturalHeight() <= shape.fluidHead()) { + return false; + } + for (int[] offset : HORIZONTAL_NEIGHBORS) { + HydrologyTerrainSample neighbor = sampleTerrainBasis(x + offset[0], z + offset[1]); + if (neighbor == null) { + return false; + } + if (shape.fluidHead() <= settings.seaLevel() + && (neighbor.ocean() || neighbor.naturalHeight() <= settings.seaLevel())) { + continue; + } + if (neighbor.naturalHeight() <= shape.fluidHead()) { + return false; + } + } + return true; + } + + private double surfaceDistance(SurfaceProjection projection, int x, int z) { + if (projection.roundedCap()) { + return projection.distance(); + } + return shapedDistance( + projection.shape(), + projection.segment(), + x, + z, + x - projection.x(), + z - projection.z(), + projection.flowX(), + projection.flowZ(), + projection.distance() + ); + } + + private RasterStencil rasterStencil(LayerShape shape) { + RasterStencilKey key = new RasterStencilKey( + shape.channelRadius(), + shape.shoreWidth(), + shape.gradingWidth() + ); + RasterStencil cached = rasterStencils.get(key); + if (cached != null) { + return cached; + } + RasterStencil stencil = buildRasterStencil(key); + rasterStencils.put(key, stencil); + return stencil; + } + + private RasterStencil buildRasterStencil(RasterStencilKey key) { + double totalRadius = key.channelRadius() + key.shoreWidth() + key.gradingWidth(); + int blockRadius = (int) StrictMath.ceil(totalRadius); + RasterStencilBuilder builder = new RasterStencilBuilder(); + for (int deltaZ = -blockRadius; deltaZ <= blockRadius; deltaZ++) { + for (int deltaX = -blockRadius; deltaX <= blockRadius; deltaX++) { + double distance = StrictMath.hypot(deltaX, deltaZ); + if (distance > totalRadius + 0.25D) { + continue; + } + boolean channel = distance <= key.channelRadius() + 0.25D; + boolean shore = !channel && distance <= key.channelRadius() + key.shoreWidth() + 0.25D; + boolean grading = !channel && !shore && distance <= totalRadius + 0.25D; + if (!channel && !shore && !grading) { + continue; + } + byte role = channel ? CHANNEL_ROLE : shore ? SHORE_ROLE : GRADING_ROLE; + builder.add(deltaX, deltaZ, distance, role); + } + } + return builder.build(); + } + + private void addOceanApron( + Long2ObjectLinkedOpenHashMap columns, + RiverCourse course, + HydraulicSegment segment, + HydrologyPoint point, + int x, + int z, + double distance, + int flowX, + int flowZ, + HydrologyTerrainSample terrain, + HydrologyFeatureRef[] pointFeatures + ) { + if (segment.type() != HydrologyFeatureType.MOUTH + && segment.type() != HydrologyFeatureType.COASTAL_GROTTO) { + return; + } + if (distance > settings.outlets().maximumOceanApron() + 0.25D) { + return; + } + int fluidHead = Math.min(settings.seaLevel(), point.y()); + HydrologyFeatureRef feature = feature( + course, + segment, + x, + fluidHead, + z, + flowX, + flowZ, + false, + true, + false, + false, + false, + false, + pointFeatures + ); + HydrologyColumnLayer layer = new HydrologyColumnLayer( + feature, + fluidHead, + fluidHead, + segment.type() == HydrologyFeatureType.COASTAL_GROTTO + ? fluidHead + settings.outlets().coastalGrotto().headroom() + : fluidHead, + true, + false, + false, + true, + false, + false, + false, + false, + true, + course.profileKey(), + terrain.surfaceBiomeKey(), + terrain.mouthBiomeKey(), + terrain.shoreBiomeKey(), + terrain.dryBiomeKey(), + terrain.floodedCaveBiomeKey() + ); + addLayer(columns, x, z, terrain, layer); + } + + private int gradedBed( + LayerShape shape, + HydrologyTerrainSample terrain, + double distance + ) { + double transitionWidth = shape.shoreWidth() + shape.gradingWidth(); + if (transitionWidth <= 0D) { + return terrain.naturalHeight(); + } + double bankDistance = Math.max(0D, distance - shape.channelRadius()); + int targetBed = Math.addExact(shape.fluidHead(), 1); + double progress = smoothStep(Math.min(1D, bankDistance / transitionWidth)); + int blendedBed = (int) StrictMath.round( + targetBed + (terrain.naturalHeight() - targetBed) * progress + ); + return Math.min(terrain.naturalHeight(), blendedBed); + } + + private int resolvedBed( + HydraulicSegment segment, + LayerShape shape, + HydrologyPlannerSettings.ChannelShape channelShape, + HydrologyTerrainSample terrain, + double distance, + int worldX, + int worldZ, + boolean channel, + double ellipsoidScale + ) { + int resolved; + if (shape.ellipsoid() || shape.archedChannel()) { + resolved = channel + ? localBed(segment, shape, channelShape, ellipsoidScale, worldX, worldZ) + : terrain.naturalHeight(); + } else if (!shape.roundedSurfaceBed()) { + resolved = channel ? shape.bed() : gradedBed(shape, terrain, distance); + } else if (channel) { + double normalized = Math.min(1D, distance / Math.max(1D, shape.channelRadius())); + double shoulder = Math.max( + 0D, + (normalized - SURFACE_THALWEG_RATIO) / (1D - SURFACE_THALWEG_RATIO) + ); + int maximumDepth = Math.max(1, shape.fluidHead() - shape.bed()); + double roughness = signedOrganicNoise( + HydrologyHash.mix(segment.id(), ORGANIC_BED_VARIATION_SALT), + worldX, + worldZ, + channelShape.roughnessWavelength() + ) * channelShape.bedRoughness(); + double depthScale = Math.max(0.55D, 1D + roughness); + int localDepth = 1 + (int) StrictMath.floor( + (maximumDepth - 1D) + * (1D - StrictMath.pow(shoulder, channelShape.bedRoundness())) + * depthScale + + 1.0E-9D + ); + localDepth = Math.min(maximumDepth, localDepth); + if (segment.type().isDrop()) { + localDepth = Math.min( + maximumDepth, + Math.max(localDepth, settings.geometry().drops().stepLimit(segment.type()) + 1) + ); + } + resolved = shape.fluidHead() - localDepth; + } else { + resolved = gradedBed(shape, terrain, distance); + } + return Math.min(resolved, terrain.naturalHeight()); + } + + private int localBed( + HydraulicSegment segment, + LayerShape shape, + HydrologyPlannerSettings.ChannelShape channelShape, + double scale, + int worldX, + int worldZ + ) { + if (!shape.ellipsoid() && !shape.archedChannel()) { + return shape.bed(); + } + int lowerExtent = shape.fluidHead() - shape.bed(); + double roughness = signedOrganicNoise( + HydrologyHash.mix(segment.id(), ORGANIC_BED_VARIATION_SALT), + worldX, + worldZ, + channelShape.roughnessWavelength() + ) * channelShape.bedRoughness(); + int localExtent = (int) StrictMath.ceil(lowerExtent * scale * Math.max(0.55D, 1D + roughness)); + localExtent = Math.min(lowerExtent, localExtent); + if (shape.archedChannel()) { + localExtent = Math.max(1, localExtent); + } + if (segment.type().isDrop()) { + localExtent = Math.max(localExtent, settings.geometry().drops().stepLimit(segment.type()) + 1); + } + return shape.fluidHead() - localExtent; + } + + private int localCeiling(LayerShape shape, double scale) { + if (!shape.ellipsoid() && !shape.archedChannel()) { + return shape.ceiling(); + } + int upperExtent = shape.ceiling() - shape.fluidHead(); + int localExtent = (int) StrictMath.floor(upperExtent * scale); + if (shape.archedChannel()) { + localExtent = Math.max(1, localExtent); + } + return shape.fluidHead() + localExtent; + } + + private boolean terrainRoofedSurfaceTransition(RiverCourse course, HydraulicSegment segment) { + return course.type() == RiverCourseType.SURFACE + && (segment.type() == HydrologyFeatureType.RIDGE_BORE + || segment.type() == HydrologyFeatureType.UNDERGROUND_POOL + || segment.type() == HydrologyFeatureType.UNDERGROUND_DROP); + } + + private double ellipsoidScale(int radius, double distance, double roundness) { + double normalized = Math.min(1D, distance / Math.max(1D, radius)); + return StrictMath.pow( + Math.max(0D, 1D - StrictMath.pow(normalized, roundness)), + 1D / roundness + ); + } + + private double shapedDistance( + LayerShape shape, + HydraulicSegment segment, + int worldX, + int worldZ, + double deltaX, + double deltaZ, + int flowX, + int flowZ, + double rawDistance + ) { + if (segment.type() == HydrologyFeatureType.DEEP_POOL) { + return deepPoolDistance(shape, segment, worldX, worldZ, deltaX, deltaZ); + } + if (segment.type().isSurface() && !shape.fallingThroat()) { + return surfaceChannelDistance(shape, segment, worldX, worldZ, deltaX, deltaZ, flowX, flowZ); + } + if (rawDistance == 0D) { + return 0D; + } + double distance = rawDistance; + if (shape.fallingThroat() && (flowX != 0 || flowZ != 0)) { + double flowLength = StrictMath.hypot(flowX, flowZ); + double along = StrictMath.abs(deltaX * flowX + deltaZ * flowZ) / flowLength; + double across = StrictMath.abs(deltaX * -flowZ + deltaZ * flowX) / flowLength; + if (along > 0.75D) { + return shape.totalRadius() + 1D; + } + distance = across; + } + if (!shape.organicBoundary()) { + return distance; + } + if (segment.type() == HydrologyFeatureType.COASTAL_GROTTO + || segment.type() == HydrologyFeatureType.INLAND_GROTTO) { + double orientation = HydrologyHash.unit(HydrologyHash.mix( + segment.courseId(), + ORGANIC_SHAPE_FIRST_PHASE_SALT + )) * StrictMath.PI * 2D; + double cosine = StrictMath.cos(orientation); + double sine = StrictMath.sin(orientation); + double rotatedX = deltaX * cosine + deltaZ * sine; + double rotatedZ = -deltaX * sine + deltaZ * cosine; + double aspect = 0.62D + HydrologyHash.unit(HydrologyHash.mix( + segment.courseId(), + ORGANIC_SHAPE_SECOND_PHASE_SALT + )) * 0.2D; + distance = StrictMath.hypot(rotatedX, rotatedZ / aspect); + } + double angle = StrictMath.atan2(deltaZ, deltaX); + double firstPhase = HydrologyHash.unit(HydrologyHash.mix( + segment.courseId(), + ORGANIC_SHAPE_FIRST_PHASE_SALT + )) * StrictMath.PI * 2D; + double secondPhase = HydrologyHash.unit(HydrologyHash.mix( + segment.courseId(), + ORGANIC_SHAPE_SECOND_PHASE_SALT + )) * StrictMath.PI * 2D; + double firstLobe = 0.5D + 0.5D * StrictMath.sin(angle * 3D + firstPhase); + double secondLobe = 0.5D + 0.5D * StrictMath.sin(angle * 5D + secondPhase); + HydrologyPlannerSettings.ChannelShape channelShape = channelShape(segment.type()); + double coherent = signedOrganicNoise( + segment.courseId(), + worldX, + worldZ, + channelShape.roughnessWavelength() + ); + double detail = signedOrganicNoise( + HydrologyHash.mix(segment.courseId(), ORGANIC_SHAPE_SECOND_PHASE_SALT), + worldX, + worldZ, + Math.max(3, channelShape.roughnessWavelength() / 2) + ); + double radialScale = 0.86D + + (firstLobe - 0.5D) * 0.08D + + (secondLobe - 0.5D) * 0.06D + + coherent * channelShape.wallRoughness() * 0.7D + + detail * channelShape.wallRoughness() * 0.3D; + radialScale = Math.max(0.58D, Math.min(1.18D, radialScale)); + return distance / radialScale; + } + + private double surfaceChannelDistance( + LayerShape shape, + HydraulicSegment segment, + int worldX, + int worldZ, + double deltaX, + double deltaZ, + int flowX, + int flowZ + ) { + double flowLength = StrictMath.hypot(flowX, flowZ); + if (flowLength == 0D) { + return deepPoolDistance(shape, segment, worldX, worldZ, deltaX, deltaZ); + } + double signedCross = (deltaX * -flowZ + deltaZ * flowX) / flowLength; + HydrologyPlannerSettings.ChannelShape channelShape = settings.geometry().surface(); + double thalweg = signedOrganicNoise( + HydrologyHash.mix(segment.courseId(), ORGANIC_BED_VARIATION_SALT), + worldX, + worldZ, + channelShape.roughnessWavelength() + ) * shape.channelRadius() * channelShape.wallRoughness(); + long bankSeed = HydrologyHash.mix(segment.courseId(), ORGANIC_SHAPE_SECOND_PHASE_SALT); + double bankNoise = signedOrganicNoise( + bankSeed, + worldX, + worldZ, + channelShape.roughnessWavelength() + ); + double sideBias = signedCross < thalweg + ? HydrologyHash.unit(HydrologyHash.mix(bankSeed, 1L)) + : HydrologyHash.unit(HydrologyHash.mix(bankSeed, 2L)); + double widthScale = 0.86D + + bankNoise * channelShape.wallRoughness() + + (sideBias - 0.5D) * 0.08D; + widthScale = Math.max(1D, Math.min(1.18D, widthScale)); + double organicDistance = StrictMath.abs(signedCross - thalweg) / widthScale; + return Math.min(StrictMath.abs(signedCross), organicDistance); + } + + private HydrologyPlannerSettings.ChannelShape channelShape(HydrologyFeatureType type) { + if (type.isGrotto() || type == HydrologyFeatureType.DEEP_POOL + || type == HydrologyFeatureType.RIDGE_BORE) { + return settings.geometry().grottos(); + } + if (type.isUnderground() || type == HydrologyFeatureType.DEEP_CHANNEL) { + return settings.geometry().underground(); + } + return settings.geometry().surface(); + } + + private double signedOrganicNoise(long seed, int x, int z, int scale) { + return organicNoise(seed, x, z, scale) * 2D - 1D; + } + + private double deepPoolDistance( + LayerShape shape, + HydraulicSegment segment, + int worldX, + int worldZ, + double deltaX, + double deltaZ + ) { + double radius = Math.max(1D, shape.channelRadius()); + long seed = HydrologyHash.mix(segment.courseId(), ORGANIC_SHAPE_FIRST_PHASE_SALT); + double orientation = HydrologyHash.unit(seed) * StrictMath.PI * 2D; + double cosine = StrictMath.cos(orientation); + double sine = StrictMath.sin(orientation); + double rotatedX = deltaX * cosine + deltaZ * sine; + double rotatedZ = -deltaX * sine + deltaZ * cosine; + double angle = StrictMath.atan2(rotatedZ, rotatedX); + double secondPhase = HydrologyHash.unit(HydrologyHash.mix(seed, 2L)) * StrictMath.PI * 2D; + double thirdPhase = HydrologyHash.unit(HydrologyHash.mix(seed, 3L)) * StrictMath.PI * 2D; + double fifthPhase = HydrologyHash.unit(HydrologyHash.mix(seed, 5L)) * StrictMath.PI * 2D; + double radialScale = 0.84D + + StrictMath.sin(angle * 2D + secondPhase) * 0.07D + + StrictMath.sin(angle * 3D + thirdPhase) * 0.05D + + StrictMath.sin(angle * 5D + fifthPhase) * 0.035D; + radialScale = Math.max(0.68D, Math.min(1D, radialScale)); + double normalizedDistance = StrictMath.hypot( + rotatedX / (radius * 0.92D), + rotatedZ / (radius * 0.7D) + ); + return normalizedDistance * radius / radialScale; + } + + private double organicNoise(long seed, int x, int z, int scale) { + int cellX = Math.floorDiv(x, scale); + int cellZ = Math.floorDiv(z, scale); + double localX = Math.floorMod(x, scale) / (double) scale; + double localZ = Math.floorMod(z, scale) / (double) scale; + double smoothX = localX * localX * (3D - 2D * localX); + double smoothZ = localZ * localZ * (3D - 2D * localZ); + double top = interpolate( + organicCorner(seed, cellX, cellZ), + organicCorner(seed, cellX + 1, cellZ), + smoothX + ); + double bottom = interpolate( + organicCorner(seed, cellX, cellZ + 1), + organicCorner(seed, cellX + 1, cellZ + 1), + smoothX + ); + return interpolate(top, bottom, smoothZ); + } + + private double organicCorner(long seed, int cellX, int cellZ) { + return HydrologyHash.unit(HydrologyHash.mix(seed, cellX, cellZ)); + } + + private double interpolate(double first, double second, double progress) { + return first + (second - first) * progress; + } + + private void addLayer( + Long2ObjectLinkedOpenHashMap columns, + int x, + int z, + HydrologyTerrainSample terrain, + HydrologyColumnLayer layer + ) { + long packed = RiverFootprint.pack(x, z); + MutableColumn column = columns.get(packed); + if (column == null) { + column = new MutableColumn(x, z, terrain, settings.seaLevel()); + columns.put(packed, column); + } + column.add(layer); + } + + private HydrologyTerrainSample sampleTerrain(int x, int z) { + long packed = RiverFootprint.pack(x, z); + HydrologyTerrainSample cached = terrainSamples.get(packed); + if (cached != null) { + return cached; + } + HydrologyTerrainSample sampled = sampler.sample(x, z); + if (sampled != null) { + terrainSamples.put(packed, sampled); + if (naturalSampler == null) { + terrainBases.put(packed, sampled); + } + } + return sampled; + } + + private HydrologyTerrainSample sampleTerrainBasis(int x, int z) { + if (naturalSampler == null) { + return sampleTerrain(x, z); + } + long packed = RiverFootprint.pack(x, z); + HydrologyTerrainSample cached = terrainBases.get(packed); + if (cached != null) { + return cached; + } + HydrologyTerrainSample sampled = naturalSampler.sampleBasis(x, z); + if (sampled != null) { + terrainBases.put(packed, sampled); + } + return sampled; + } + + private HydrologyRoutingTerrainSampler.NaturalClassification classifyNatural(int x, int z) { + if (naturalSampler == null) { + return HydrologyRoutingTerrainSampler.NaturalClassification.UNAVAILABLE; + } + long packed = RiverFootprint.pack(x, z); + HydrologyRoutingTerrainSampler.NaturalClassification cached = naturalClassifications.get(packed); + if (cached != null) { + return cached; + } + HydrologyRoutingTerrainSampler.NaturalClassification sampled = Objects.requireNonNull( + naturalSampler.classifyNatural(x, z), + "Hydrology natural terrain classifier returned null at " + x + "," + z + ); + naturalClassifications.put(packed, sampled); + return sampled; + } + + private boolean naturallySubmergedSurfaceColumn( + HydraulicSegment segment, + HydrologyTerrainSample terrain + ) { + return segment.type().isSurface() && terrain.naturalHeight() <= settings.seaLevel(); + } + + private boolean oceanApronEligible(HydraulicSegment segment, double distance) { + return (segment.type() == HydrologyFeatureType.MOUTH + || segment.type() == HydrologyFeatureType.COASTAL_GROTTO) + && distance <= settings.outlets().maximumOceanApron() + 0.25D; + } + + private HydrologyFeatureRef feature( + RiverCourse course, + HydraulicSegment segment, + int x, + int y, + int z, + int flowX, + int flowZ, + boolean source, + boolean channel, + boolean shore, + boolean grading, + boolean falling, + boolean receiving, + HydrologyFeatureRef[] pointFeatures + ) { + int role = source ? 6 : falling ? 4 : receiving ? 5 : channel ? 1 : shore ? 2 : grading ? 3 : 0; + HydrologyFeatureRef cached = pointFeatures[role]; + if (cached != null) { + return cached; + } + int featureFlowX = Integer.compare(flowX, 0); + int featureFlowZ = Integer.compare(flowZ, 0); + FeatureKey key = new FeatureKey( + course.id(), + segment.id(), + segment.type(), + role, + featureFlowX, + featureFlowZ + ); + HydrologyFeatureRef feature = features.get(key); + if (feature == null) { + feature = new HydrologyFeatureRef( + HydrologyHash.mix( + course.id(), + segment.id(), + segment.type().ordinal(), + role, + featureFlowX, + featureFlowZ + ), + segment.type(), + course.id(), + segment.id(), + x, + y, + z, + featureFlowX, + featureFlowZ, + source + ); + features.put(key, feature); + } + pointFeatures[role] = feature; + return feature; + } + + private int flowDelta(List centerline, int pointIndex, boolean xAxis) { + HydrologyPoint from; + HydrologyPoint to; + if (centerline.size() == 1) { + return 0; + } + if (pointIndex < centerline.size() - 1) { + from = centerline.get(pointIndex); + to = centerline.get(pointIndex + 1); + } else { + from = centerline.get(pointIndex - 1); + to = centerline.get(pointIndex); + } + return Integer.compare(xAxis ? to.x() : to.z(), xAxis ? from.x() : from.z()); + } + + private record LayerShape( + int channelRadius, + double shoreWidth, + double gradingWidth, + int bed, + int fluidHead, + int ceiling, + boolean ellipsoid, + boolean archedChannel, + boolean roundedSurfaceBed, + boolean organicBoundary, + boolean fallingThroat + ) { + private double totalRadius() { + return channelRadius + shoreWidth + gradingWidth; + } + } + + private record SurfaceSweepPoint( + HydraulicSegment segment, + HydrologyPoint point, + LayerShape shape, + boolean source, + boolean receiving, + boolean falling + ) { + } + + private record SurfaceSweep( + RiverCourse course, + List points, + boolean roundStart, + boolean roundEnd, + int singletonFlowX, + int singletonFlowZ + ) { + } + + private record SurfaceSweepEdge( + SurfaceSweep sweep, + int edgeIndex + ) { + } + + private record SurfaceProjection( + SurfaceSweep sweep, + int edgeIndex, + HydraulicSegment segment, + HydrologyPoint anchor, + LayerShape shape, + boolean source, + boolean receiving, + boolean falling, + boolean oceanConnection, + double x, + double z, + double distance, + int flowX, + int flowZ, + boolean roundedCap + ) { + } + + private enum SurfaceTerminal { + NONE, + OCEAN_MOUTH + } + + private record CenterlineProjection( + int pointIndex, + double x, + double z, + double distance, + int flowX, + int flowZ + ) { + } + + private record SurfaceCell( + HydrologyTerrainSample terrain, + HydrologyColumnLayer layer, + boolean naturalOcean + ) { + } + + private record ValidationCourseRaster( + long courseId, + List columns, + List surfaceSweeps + ) { + } + + private record FeatureKey( + long courseId, + long segmentId, + HydrologyFeatureType type, + int role, + int flowX, + int flowZ + ) { + } + + private record RasterStencilKey( + int channelRadius, + double shoreWidth, + double gradingWidth + ) { + } + + private record CourseRasterKey( + long courseId, + RiverCourseType courseType, + String profileKey, + List segments + ) { + } + + private record RasterStencil( + int[] deltaXs, + int[] deltaZs, + double[] distances, + byte[] roles + ) { + private int size() { + return deltaXs.length; + } + } + + private static final class RasterStencilBuilder { + private int[] deltaXs; + private int[] deltaZs; + private double[] distances; + private byte[] roles; + private int size; + + private RasterStencilBuilder() { + this.deltaXs = new int[64]; + this.deltaZs = new int[64]; + this.distances = new double[64]; + this.roles = new byte[64]; + } + + private void add(int deltaX, int deltaZ, double distance, byte role) { + if (size == deltaXs.length) { + int expandedSize = Math.multiplyExact(size, 2); + deltaXs = Arrays.copyOf(deltaXs, expandedSize); + deltaZs = Arrays.copyOf(deltaZs, expandedSize); + distances = Arrays.copyOf(distances, expandedSize); + roles = Arrays.copyOf(roles, expandedSize); + } + deltaXs[size] = deltaX; + deltaZs[size] = deltaZ; + distances[size] = distance; + roles[size] = role; + size++; + } + + private RasterStencil build() { + return new RasterStencil( + Arrays.copyOf(deltaXs, size), + Arrays.copyOf(deltaZs, size), + Arrays.copyOf(distances, size), + Arrays.copyOf(roles, size) + ); + } + } + + record Sampling( + HydrologyTerrainSampler sampler, + HydrologyGeometrySampler geometrySampler, + HydrologyNaturalTerrainSampler naturalSampler + ) { + Sampling { + Objects.requireNonNull(sampler, "sampler"); + Objects.requireNonNull(geometrySampler, "geometrySampler"); + } + } + + final class ValidationRaster { + private final List courseRasters; + private final Long2ObjectOpenHashMap> columnsByCourse; + private final SurfaceRasterIndex surfaceRaster; + private final RiverFootprint materializedSurface; + private final int columnReferenceCount; + private List mergedColumns; + + private ValidationRaster( + List courseRasters, + SurfaceRasterIndex surfaceRaster + ) { + this(courseRasters, surfaceRaster, null); + } + + private ValidationRaster( + List courseRasters, + SurfaceRasterIndex surfaceRaster, + RiverFootprint materializedSurface + ) { + this.courseRasters = List.copyOf(Objects.requireNonNull(courseRasters, "courseRasters")); + this.surfaceRaster = Objects.requireNonNull(surfaceRaster, "surfaceRaster"); + this.materializedSurface = materializedSurface; + this.columnsByCourse = new Long2ObjectOpenHashMap<>(courseRasters.size()); + int references = 0; + for (ValidationCourseRaster raster : courseRasters) { + if (columnsByCourse.put(raster.courseId(), raster.columns()) != null) { + throw new IllegalStateException("Duplicate hydrology validation course " + raster.courseId()); + } + references += raster.columns().size(); + } + this.columnReferenceCount = references; + } + + List columnsForCourse(long courseId) { + List columns = columnsByCourse.get(courseId); + return columns == null ? List.of() : columns; + } + + List columns() { + List cached = mergedColumns; + if (cached != null) { + return cached; + } + Long2ObjectLinkedOpenHashMap merged = new Long2ObjectLinkedOpenHashMap<>(); + for (ValidationCourseRaster raster : courseRasters) { + for (HydrologyColumnSample sample : raster.columns()) { + long packed = RiverFootprint.pack(sample.x(), sample.z()); + MutableColumn column = merged.get(packed); + if (column == null) { + merged.put(packed, new MutableColumn(sample)); + } else { + column.merge(sample); + } + } + } + cached = buildValidationColumns(merged); + mergedColumns = cached; + return cached; + } + + HydrologyCaveVoxelViewFactory.PlannedSurface plannedSurface() { + if (materializedSurface == null) { + return surfaceRaster; + } + return (int x, int z, int naturalHeight) -> materializedSurface.sample(x, z) + .map(HydrologyColumnSample::terrainHeight) + .orElse(naturalHeight); + } + + HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) { + if (materializedSurface != null) { + HydrologyColumnSample sample = materializedSurface.sample(x, z).orElse(null); + if (sample != null && sample.naturalHeight() != naturalHeight) { + throw new IllegalStateException("Hydrology surface and cave rasters disagree on natural terrain at " + + x + "," + z + "."); + } + return sample; + } + return surfaceRaster.surfaceColumnAt(x, z, naturalHeight); + } + + boolean ownsSurfaceChannelAt(int x, int z, int naturalHeight, long courseId) { + if (materializedSurface != null) { + HydrologyColumnSample sample = materializedSurface.sample(x, z).orElse(null); + if (sample == null) { + return false; + } + if (sample.naturalHeight() != naturalHeight) { + throw new IllegalStateException("Hydrology surface and cave rasters disagree on natural terrain at " + + x + "," + z + "."); + } + return ownsSurfaceChannel(sample, courseId); + } + return surfaceRaster.ownsSurfaceChannelAt(x, z, courseId); + } + + boolean ownsSurfaceChannelAt(int x, int z, long courseId) { + if (materializedSurface == null) { + return surfaceRaster.ownsSurfaceChannelAt(x, z, courseId); + } + HydrologyColumnSample sample = materializedSurface.sample(x, z).orElse(null); + return sample != null && ownsSurfaceChannel(sample, courseId); + } + + private boolean ownsSurfaceChannel(HydrologyColumnSample sample, long courseId) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == courseId + && layer.feature().type().isSurface() + && layer.channel() + && layer.terrainOwned()) { + return true; + } + } + return false; + } + + ValidationRaster withMaterializedSurface(RiverFootprint footprint) { + return new ValidationRaster(courseRasters, surfaceRaster, Objects.requireNonNull(footprint)); + } + + int columnReferenceCount() { + return columnReferenceCount; + } + } + + private final class SurfaceRasterIndex implements HydrologyCaveVoxelViewFactory.PlannedSurface { + private final Long2ObjectOpenHashMap> edgesByChunk; + private final Long2ObjectOpenHashMap rawSurfaceColumns; + private final Long2IntOpenHashMap validationNaturalHeights; + private final Long2IntOpenHashMap resolvedHeights; + + private SurfaceRasterIndex( + List surfaceSweeps, + Long2ObjectOpenHashMap validationSamples + ) { + this.edgesByChunk = new Long2ObjectOpenHashMap<>(); + this.rawSurfaceColumns = new Long2ObjectOpenHashMap<>(); + this.validationNaturalHeights = new Long2IntOpenHashMap(validationSamples.size()); + for (HydrologyColumnSample sample : validationSamples.values()) { + validationNaturalHeights.put( + RiverFootprint.pack(sample.x(), sample.z()), + sample.naturalHeight() + ); + } + this.resolvedHeights = new Long2IntOpenHashMap(); + for (SurfaceSweep sweep : surfaceSweeps) { + index(sweep); + } + } + + @Override + public int resolve(int x, int z, int naturalHeight) { + long packed = RiverFootprint.pack(x, z); + if (resolvedHeights.containsKey(packed)) { + return resolvedHeights.get(packed); + } + + MutableColumn column = null; + int rasterNaturalHeight = naturalHeight; + boolean rasterPresent = false; + for (SurfaceProjection projection : projectionsAt(x, z)) { + SurfaceCell cell = surfaceCell(projection, x, z); + if (cell == null) { + continue; + } + if (!rasterPresent) { + rasterNaturalHeight = cell.terrain().naturalHeight(); + rasterPresent = true; + } + if (cell.layer() == null) { + continue; + } + if (column == null) { + column = new MutableColumn(x, z, cell.terrain(), settings.seaLevel()); + } + column.add(cell.layer()); + } + + int resolved; + if (column != null) { + resolved = containedSurfaceBankHeight(column.build()); + } else if (rasterPresent) { + resolved = rasterNaturalHeight; + } else if (validationNaturalHeights.containsKey(packed)) { + resolved = validationNaturalHeights.get(packed); + } else { + return naturalHeight; + } + resolvedHeights.put(packed, resolved); + return resolved; + } + + private int containedSurfaceBankHeight(HydrologyColumnSample sample) { + return containedSurfaceBankSample(sample).terrainHeight(); + } + + private HydrologyColumnSample containedSurfaceBankSample(HydrologyColumnSample sample) { + if (sample.primarySurfaceFluidLayer().isPresent()) { + return sample; + } + int minimumBankHeight = Integer.MIN_VALUE; + for (int[] offset : HORIZONTAL_NEIGHBORS) { + int neighborX = sample.x() + offset[0]; + int neighborZ = sample.z() + offset[1]; + HydrologyTerrainSample terrain = sampleTerrainBasis(neighborX, neighborZ); + if (terrain == null) { + continue; + } + HydrologyColumnSample neighbor = rawSurfaceColumnAt( + neighborX, + neighborZ, + terrain.naturalHeight() + ); + if (neighbor == null) { + continue; + } + HydrologyColumnLayer fluid = neighbor.primarySurfaceFluidLayer().orElse(null); + if (fluid != null && !fluid.oceanApron() && !fluid.fallingFluid()) { + minimumBankHeight = Math.max(minimumBankHeight, Math.addExact(fluid.fluidHeadY(), 1)); + } + } + if (minimumBankHeight == Integer.MIN_VALUE) { + return sample; + } + ArrayList raised = new ArrayList<>(sample.layers().size()); + for (HydrologyColumnLayer layer : sample.layers()) { + raised.add(raisedDrySurfaceLayer(layer, sample.naturalHeight(), minimumBankHeight)); + } + return new HydrologyColumnSample( + sample.x(), + sample.z(), + sample.naturalHeight(), + sample.seaLevel(), + sample.ocean(), + sample.parentBiomeKey(), + raised + ); + } + + private HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) { + HydrologyColumnSample sample = rawSurfaceColumnAt(x, z, naturalHeight); + return sample == null ? null : containedSurfaceBankSample(sample); + } + + private HydrologyColumnSample rawSurfaceColumnAt(int x, int z, int naturalHeight) { + long packed = RiverFootprint.pack(x, z); + HydrologyColumnSample cached = rawSurfaceColumns.get(packed); + if (cached != null) { + if (cached.naturalHeight() != naturalHeight) { + throw new IllegalStateException("Hydrology surface and cave rasters disagree on natural terrain at " + + x + "," + z + "."); + } + return cached; + } + MutableColumn column = null; + for (SurfaceProjection projection : projectionsAt(x, z)) { + SurfaceCell cell = surfaceCell(projection, x, z); + if (cell == null || cell.layer() == null) { + continue; + } + if (column == null) { + column = new MutableColumn(x, z, cell.terrain(), settings.seaLevel()); + } + column.add(cell.layer()); + } + if (column == null) { + return null; + } + HydrologyColumnSample sample = column.build(); + if (sample.naturalHeight() != naturalHeight) { + throw new IllegalStateException("Hydrology surface and cave rasters disagree on natural terrain at " + + x + "," + z + "."); + } + rawSurfaceColumns.put(packed, sample); + return sample; + } + + private boolean ownsSurfaceChannelAt(int x, int z, long courseId) { + for (SurfaceProjection projection : projectionsAt(x, z)) { + SurfaceCell cell = surfaceCell(projection, x, z); + HydrologyColumnLayer layer = cell == null ? null : cell.layer(); + if (layer != null + && layer.feature().courseId() == courseId + && layer.feature().type().isSurface() + && layer.channel() + && layer.terrainOwned()) { + return true; + } + } + return false; + } + + private List projectionsAt(int x, int z) { + ArrayList edges = edgesByChunk.get(RiverFootprint.pack(x >> 4, z >> 4)); + if (edges == null) { + return List.of(); + } + IdentityHashMap selected = new IdentityHashMap<>(); + for (SurfaceSweepEdge edge : edges) { + SurfaceProjection candidate = surfaceProjection(edge.sweep(), edge.edgeIndex(), x, z); + if (candidate == null) { + continue; + } + SurfaceProjection current = selected.get(edge.sweep()); + if (current == null || prefersSurfaceProjection(candidate, current)) { + selected.put(edge.sweep(), candidate); + } + } + ArrayList projections = new ArrayList<>(selected.values()); + projections.sort(Comparator + .comparingLong((SurfaceProjection projection) -> projection.sweep().course().id()) + .thenComparingInt((SurfaceProjection projection) -> projection.sweep().points().getFirst().point().x()) + .thenComparingInt((SurfaceProjection projection) -> projection.sweep().points().getFirst().point().z()) + .thenComparingInt(SurfaceProjection::edgeIndex)); + return List.copyOf(projections); + } + + private void index(SurfaceSweep sweep) { + if (sweep.points().size() == 1) { + index(new SurfaceSweepEdge(sweep, -1)); + return; + } + for (int edgeIndex = 0; edgeIndex < sweep.points().size() - 1; edgeIndex++) { + index(new SurfaceSweepEdge(sweep, edgeIndex)); + } + } + + private void index(SurfaceSweepEdge edge) { + SurfaceSweepPoint start = edge.sweep().points().get(Math.max(0, edge.edgeIndex())); + SurfaceSweepPoint end = edge.edgeIndex() < 0 + ? start + : edge.sweep().points().get(edge.edgeIndex() + 1); + int blockRadius = (int) StrictMath.ceil(Math.max( + start.shape().totalRadius(), + end.shape().totalRadius() + )); + int minimumChunkX = (Math.min(start.point().x(), end.point().x()) - blockRadius) >> 4; + int maximumChunkX = (Math.max(start.point().x(), end.point().x()) + blockRadius) >> 4; + int minimumChunkZ = (Math.min(start.point().z(), end.point().z()) - blockRadius) >> 4; + int maximumChunkZ = (Math.max(start.point().z(), end.point().z()) + blockRadius) >> 4; + for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { + for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { + long chunkKey = RiverFootprint.pack(chunkX, chunkZ); + ArrayList edges = edgesByChunk.get(chunkKey); + if (edges == null) { + edges = new ArrayList<>(); + edgesByChunk.put(chunkKey, edges); + } + edges.add(edge); + } + } + } + } + + private static final class MutableColumn { + private final int x; + private final int z; + private final int naturalHeight; + private final int seaLevel; + private final boolean ocean; + private final String parentBiomeKey; + private HydrologyColumnLayer singleLayer; + private LinkedHashMap layers; + + private MutableColumn(int x, int z, HydrologyTerrainSample terrain, int seaLevel) { + this.x = x; + this.z = z; + this.naturalHeight = terrain.naturalHeight(); + this.seaLevel = seaLevel; + this.ocean = terrain.ocean(); + this.parentBiomeKey = terrain.parentBiomeKey(); + } + + private MutableColumn(HydrologyColumnSample sample) { + this.x = sample.x(); + this.z = sample.z(); + this.naturalHeight = sample.naturalHeight(); + this.seaLevel = sample.seaLevel(); + this.ocean = sample.ocean(); + this.parentBiomeKey = sample.parentBiomeKey(); + for (HydrologyColumnLayer layer : sample.layers()) { + add(layer); + } + } + + private void add(HydrologyColumnLayer layer) { + if (layers == null) { + if (singleLayer == null) { + singleLayer = layer; + return; + } + if (singleLayer.feature().id() == layer.feature().id()) { + singleLayer = merge(singleLayer, layer); + return; + } + layers = new LinkedHashMap<>(); + layers.put(singleLayer.feature().id(), singleLayer); + singleLayer = null; + } + HydrologyColumnLayer existing = layers.get(layer.feature().id()); + layers.put(layer.feature().id(), existing == null ? layer : merge(existing, layer)); + } + + private HydrologyColumnSample build() { + List builtLayers; + if (layers != null) { + builtLayers = resolveSurfaceHeadConflicts(new ArrayList<>(layers.values())); + } else if (singleLayer != null) { + builtLayers = List.of(singleLayer); + } else { + builtLayers = List.of(); + } + return new HydrologyColumnSample( + x, + z, + naturalHeight, + seaLevel, + ocean, + parentBiomeKey, + builtLayers + ); + } + + private int maximumSurfaceFluidHead() { + int maximum = Integer.MIN_VALUE; + if (singleLayer != null) { + return surfaceFluidHead(singleLayer); + } + if (layers == null) { + return maximum; + } + for (HydrologyColumnLayer layer : layers.values()) { + maximum = Math.max(maximum, surfaceFluidHead(layer)); + } + return maximum; + } + + private int surfaceFluidHead(HydrologyColumnLayer layer) { + return layer.feature().type().isSurface() + && layer.channel() + && layer.connectedFluid() + && layer.fluidOwned() + && !layer.oceanApron() + && !layer.fallingFluid() + ? layer.fluidHeadY() + : Integer.MIN_VALUE; + } + + private void raiseDrySurfaceBed(int minimumBed) { + if (singleLayer != null) { + singleLayer = raisedDrySurfaceLayer(singleLayer, naturalHeight, minimumBed); + return; + } + if (layers == null) { + return; + } + for (Map.Entry entry : layers.entrySet()) { + entry.setValue(raisedDrySurfaceLayer(entry.getValue(), naturalHeight, minimumBed)); + } + } + + private List resolveSurfaceHeadConflicts(List candidates) { + HashMap selectedByCourse = new HashMap<>(); + for (HydrologyColumnLayer candidate : candidates) { + if (!conflictingSurfaceCandidate(candidate)) { + continue; + } + long courseId = candidate.feature().courseId(); + HydrologyColumnLayer selected = selectedByCourse.get(courseId); + if (selected == null || prefersSurfaceLayer(candidate, selected)) { + selectedByCourse.put(courseId, candidate); + } + } + if (selectedByCourse.isEmpty()) { + return candidates; + } + ArrayList resolved = new ArrayList<>(candidates.size()); + for (HydrologyColumnLayer candidate : candidates) { + HydrologyColumnLayer selected = selectedByCourse.get(candidate.feature().courseId()); + if (!conflictingSurfaceCandidate(candidate) + || selected == null + || candidate.fluidHeadY() == selected.fluidHeadY()) { + resolved.add(candidate); + } + } + return List.copyOf(resolved); + } + + private boolean conflictingSurfaceCandidate(HydrologyColumnLayer layer) { + return layer.feature().type().isSurface() + && layer.channel() + && layer.connectedFluid() + && layer.fluidOwned() + && !layer.fallingFluid(); + } + + private boolean prefersSurfaceLayer(HydrologyColumnLayer candidate, HydrologyColumnLayer selected) { + if (candidate.receivingPool() != selected.receivingPool()) { + return candidate.receivingPool(); + } + if (candidate.fluidHeadY() != selected.fluidHeadY()) { + return candidate.fluidHeadY() > selected.fluidHeadY(); + } + return candidate.feature().id() < selected.feature().id(); + } + + private void merge(HydrologyColumnSample sample) { + if (sample.x() != x + || sample.z() != z + || sample.naturalHeight() != naturalHeight + || sample.seaLevel() != seaLevel + || sample.ocean() != ocean + || !sample.parentBiomeKey().equals(parentBiomeKey)) { + throw new IllegalStateException("Hydrology course footprints disagree on terrain metadata at " + + x + "," + z + "."); + } + for (HydrologyColumnLayer layer : sample.layers()) { + add(layer); + } + } + + private HydrologyColumnLayer merge(HydrologyColumnLayer first, HydrologyColumnLayer second) { + if (first.equals(second)) { + return first; + } + HydrologyFeatureRef feature = first.feature().y() <= second.feature().y() + ? first.feature() + : second.feature(); + boolean drySurfaceBlend = first.feature().type().isSurface() + && second.feature().type().isSurface() + && !first.channel() + && !second.channel(); + int bedY = drySurfaceBlend + ? Math.max(first.bedY(), second.bedY()) + : Math.min(first.bedY(), second.bedY()); + return new HydrologyColumnLayer( + feature, + bedY, + Math.max(first.fluidHeadY(), second.fluidHeadY()), + Math.max(first.ceilingY(), second.ceilingY()), + first.channel() || second.channel(), + first.shore() || second.shore(), + first.grading() || second.grading(), + first.connectedFluid() || second.connectedFluid(), + first.fallingFluid() || second.fallingFluid(), + first.receivingPool() || second.receivingPool(), + first.terrainOwned() || second.terrainOwned(), + first.fluidOwned() || second.fluidOwned(), + first.oceanApron() || second.oceanApron(), + first.profileKey(), + first.surfaceBiomeKey(), + first.mouthBiomeKey(), + first.shoreBiomeKey(), + first.dryBiomeKey(), + first.floodedCaveBiomeKey() + ); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyGeometrySampler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyGeometrySampler.java new file mode 100644 index 000000000..6157d1258 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyGeometrySampler.java @@ -0,0 +1,115 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.Objects; + +@FunctionalInterface +public interface HydrologyGeometrySampler { + int sample(Request request); + + default int sample( + Field field, + String profileKey, + int x, + int z, + int minimum, + int maximum + ) { + return sample(field, profileKey, x, z, 0L, minimum, maximum); + } + + default int sample( + Field field, + String profileKey, + int x, + int z, + long stableId, + int minimum, + int maximum + ) { + Request request = new Request(field, profileKey, x, z, stableId, minimum, maximum); + int value = sample(request); + if (value < minimum || value > maximum) { + throw new IllegalStateException( + "Hydrology geometry sampler returned " + value + " outside " + minimum + ".." + maximum + + " for " + field + " at " + x + "," + z + "." + ); + } + return value; + } + + static HydrologyGeometrySampler deterministic( + long worldSeed, + HydrologyTerrainSampler terrainSampler + ) { + Objects.requireNonNull(terrainSampler, "terrainSampler"); + return request -> { + if (request.field() == Field.UNDERGROUND_FLUID_LEVEL) { + HydrologyTerrainSample terrain = Objects.requireNonNull( + terrainSampler.sample(request.x(), request.z()), + "Hydrology terrain sampler returned null at " + request.x() + "," + request.z() + ); + return Math.max(request.minimum(), Math.min(request.maximum(), terrain.caveFluidY())); + } + if (request.field() == Field.SURFACE_WIDTH + || request.field() == Field.SURFACE_DEPTH + || request.field() == Field.SURFACE_INSET + || request.field() == Field.UNDERGROUND_WIDTH + || request.field() == Field.UNDERGROUND_DEPTH) { + return request.minimum(); + } + long stable = switch (request.field()) { + case SURFACE_BLEND_WIDTH -> HydrologyHash.mix( + request.stableId(), + request.x(), + request.z(), + 0x424c454e44L + ); + case TARGET_POOL_LENGTH -> HydrologyHash.mix( + worldSeed, + request.stableId(), + 0x504f4f4cL + ); + case UNDERGROUND_HEADROOM -> HydrologyHash.mix( + request.stableId(), + request.x(), + request.z(), + 0x48454144L + ); + case DEEP_FLUID_HEIGHT -> HydrologyHash.mix(request.stableId(), 0x484549474854L); + default -> throw new IllegalStateException("Missing deterministic geometry rule for " + request.field() + "."); + }; + return HydrologyHash.between(stable, request.minimum(), request.maximum()); + }; + } + + enum Field { + SURFACE_WIDTH, + SURFACE_DEPTH, + SURFACE_INSET, + SURFACE_BLEND_WIDTH, + TARGET_POOL_LENGTH, + UNDERGROUND_FLUID_LEVEL, + UNDERGROUND_WIDTH, + UNDERGROUND_DEPTH, + UNDERGROUND_HEADROOM, + DEEP_FLUID_HEIGHT + } + + record Request( + Field field, + String profileKey, + int x, + int z, + long stableId, + int minimum, + int maximum + ) { + public Request { + field = Objects.requireNonNull(field, "field"); + profileKey = profileKey == null ? "" : profileKey; + if (minimum > maximum) { + throw new IllegalArgumentException("Hydrology geometry sample bounds are reversed."); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java new file mode 100644 index 000000000..e86111658 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java @@ -0,0 +1,43 @@ +package art.arcane.iris.engine.hydrology; + +final class HydrologyHash { + private HydrologyHash() { + } + + static long mix(long seed, long... values) { + long mixed = avalanche(seed ^ 0x9e3779b97f4a7c15L); + for (long value : values) { + mixed = avalanche(mixed ^ avalanche(value + 0x9e3779b97f4a7c15L)); + } + return mixed; + } + + static long text(String value) { + long hash = 0xcbf29ce484222325L; + for (int index = 0; index < value.length(); index++) { + hash ^= value.charAt(index); + hash *= 0x100000001b3L; + } + return avalanche(hash); + } + + static double unit(long value) { + return (avalanche(value) >>> 11) * 0x1.0p-53; + } + + static int between(long value, int minimum, int maximum) { + if (minimum > maximum) { + throw new IllegalArgumentException("minimum cannot exceed maximum."); + } + long span = (long) maximum - minimum + 1L; + return minimum + (int) Long.remainderUnsigned(avalanche(value), span); + } + + private static long avalanche(long value) { + value ^= value >>> 30; + value *= 0xbf58476d1ce4e5b9L; + value ^= value >>> 27; + value *= 0x94d049bb133111ebL; + return value ^ value >>> 31; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyNaturalTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyNaturalTerrainSampler.java new file mode 100644 index 000000000..bcdae50ca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyNaturalTerrainSampler.java @@ -0,0 +1,5 @@ +package art.arcane.iris.engine.hydrology; + +public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainSampler { + HydrologyTerrainSample sampleBasis(int blockX, int blockZ); +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyObservedPlannedSurface.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyObservedPlannedSurface.java new file mode 100644 index 000000000..0595fca3c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyObservedPlannedSurface.java @@ -0,0 +1,48 @@ +package art.arcane.iris.engine.hydrology; + +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +public final class HydrologyObservedPlannedSurface implements HydrologyCaveVoxelViewFactory.PlannedSurface { + private final HydrologyCaveVoxelViewFactory.PlannedSurface delegate; + private final Long2ObjectOpenHashMap> observations; + + public HydrologyObservedPlannedSurface(HydrologyCaveVoxelViewFactory.PlannedSurface delegate) { + this.delegate = Objects.requireNonNull(delegate); + this.observations = new Long2ObjectOpenHashMap<>(); + } + + @Override + public int resolve(int x, int z, int naturalHeight) { + int resolvedHeight = delegate.resolve(x, z, naturalHeight); + long key = RiverFootprint.pack(x, z); + ArrayList column = observations.get(key); + if (column == null) { + column = new ArrayList<>(1); + observations.put(key, column); + } + for (Observation observation : column) { + if (observation.naturalHeight() == naturalHeight) { + return resolvedHeight; + } + } + column.add(new Observation(x, z, naturalHeight, resolvedHeight)); + return resolvedHeight; + } + + public List observationsAt(int x, int z) { + ArrayList column = observations.get(RiverFootprint.pack(x, z)); + return column == null ? List.of() : List.copyOf(column); + } + + public record Observation( + int x, + int z, + int naturalHeight, + int resolvedHeight + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefiner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefiner.java new file mode 100644 index 000000000..a53cee907 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefiner.java @@ -0,0 +1,47 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.List; +import java.util.Objects; + +final class HydrologyOceanBoundaryRefiner { + private HydrologyOceanBoundaryRefiner() { + } + + static Result refine( + List crossing, + HydrologyTerrainSampler terrainSampler, + HydrologyRoutingTerrainSampler routingSampler + ) { + Objects.requireNonNull(crossing, "crossing"); + Objects.requireNonNull(terrainSampler, "terrainSampler"); + Objects.requireNonNull(routingSampler, "routingSampler"); + if (crossing.size() < 2) { + return null; + } + HydrologyPoint previous = crossing.getFirst(); + for (int index = 1; index < crossing.size(); index++) { + HydrologyPoint point = crossing.get(index); + HydrologyRoutingTerrainSampler.NaturalClassification classification = + routingSampler.classifyNatural(point.x(), point.z()); + if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.UNAVAILABLE) { + return null; + } + if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN) { + HydrologyTerrainSample landwardTerrain = terrainSampler.sample(previous.x(), previous.z()); + if (landwardTerrain == null || landwardTerrain.ocean()) { + return null; + } + return new Result(previous, point, landwardTerrain); + } + previous = point; + } + return null; + } + + record Result( + HydrologyPoint landwardPoint, + HydrologyPoint oceanPoint, + HydrologyTerrainSample landwardTerrain + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java new file mode 100644 index 000000000..276c557a0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -0,0 +1,9207 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCandidate; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveContainmentPlanner; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.util.common.parallel.MultiBurst; +import art.arcane.iris.util.project.noise.SimplexNoise; +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.LongIterator; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalLong; +import java.util.PriorityQueue; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.IntPredicate; + +public final class HydrologyPlanner { + private static final long NODE_SALT = 0x4e4f4445L; + private static final long OUTLET_SALT = 0x4f55544c4554L; + private static final long EDGE_SALT = 0x45444745L; + private static final long SURFACE_SOURCE_SALT = 0x53555246414345L; + private static final long UNDERGROUND_SOURCE_SALT = 0x554e444552L; + private static final long COURSE_SALT = 0x434f55525345L; + private static final long SEGMENT_SALT = 0x5345474d454e54L; + private static final long DEEP_FLUID_SALT = 0x44454550464cL; + private static final long DEEP_FLUID_X_OFFSET_SALT = 0x44454550584fL; + private static final long DEEP_FLUID_Z_OFFSET_SALT = 0x444545505a4fL; + private static final long DEEP_CHANNEL_HEADING_SALT = 0x44454550484447L; + private static final long DEEP_CHANNEL_FIRST_BEND_SALT = 0x444545504231L; + private static final long DEEP_CHANNEL_SECOND_BEND_SALT = 0x444545504232L; + private static final long GRADED_DROP_APPROACH_SALT = 0x474441505052L; + private static final long GRADED_DROP_RUN_SALT = 0x474452554eL; + private static final long GRADED_DROP_OUTFLOW_SALT = 0x47444f5554L; + private static final long MIXED_BORE_SEGMENT_SALT = 0x4d4958424f5245L; + private static final long DIAGNOSTIC_SALT = 0x444941474e4f5354L; + private static final long ROUTE_ANCHOR_X_SALT = 0x52414e434858L; + private static final long ROUTE_ANCHOR_Z_SALT = 0x52414e43485aL; + private static final long ROUTE_WORM_PRIMARY_SALT = 0x52575052494dL; + private static final long ROUTE_WORM_DETAIL_SALT = 0x525744455441L; + private static final int ROUTING_CONTEXT_CACHE_SIZE = 64; + private static final int RESOLVED_OWNER_CACHE_SIZE = 64; + private static final int REFINED_EDGE_CACHE_SIZE = 4096; + private static final double ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO = 0.32D; + private static final double ROUTE_TANGENT_SCALE = 0.68D; + private static final int ROUTE_TRANSVERSE_CANDIDATES = 2; + private static final int ROUTE_FALLBACK_TRANSVERSE_CANDIDATES = 4; + private static final int SURFACE_COURSE_TRANSVERSE_CANDIDATES = 2; + private static final int OPTIONAL_SOURCE_REJECTIONS_PER_TARGET = 4; + private static final int SURFACE_OPTIONAL_SOURCE_REJECTIONS_PER_TARGET = 8; + private static final double SURFACE_MAXIMUM_LOCAL_TURN_DEGREES = 35D; + private static final double SURFACE_MAXIMUM_RENDERED_TURN_DEGREES = 50D; + private static final double SURFACE_MAXIMUM_ISOLATED_TURN_DEGREES = 35D; + private static final double SURFACE_MAXIMUM_ISOLATED_NEIGHBOR_TURN_DEGREES = 10D; + private static final double SURFACE_MAXIMUM_P95_TURN_DEGREES = 35D; + private static final double SURFACE_MAXIMUM_SINUOSITY = 1.45D; + private static final double SURFACE_GRID_LOCK_TOLERANCE_DEGREES = 5D; + private static final double SURFACE_MAXIMUM_GRID_LOCKED_FRACTION = 0.5D; + private static final double SURFACE_MAXIMUM_GRID_LOCKED_RUN = 64D; + private static final double SURFACE_COURSE_MAXIMUM_TURN_DEGREES = 50D; + private static final double SURFACE_COURSE_TURN_COST = 2.5D; + private static final double SURFACE_MAXIMUM_WIDTH_SCALE = 1.18D; + private static final int SURFACE_MINIMUM_NATURAL_BANK_RISE = 1; + private static final int MAXIMUM_SURFACE_BORE_STEP = 4; + private static final List CARDINAL_OFFSETS = List.of( + new GridOffset(-1, 0), + new GridOffset(1, 0), + new GridOffset(0, -1), + new GridOffset(0, 1) + ); + private static final List ROUTING_OFFSETS = List.of( + new GridOffset(-1, 0), + new GridOffset(1, 0), + new GridOffset(0, -1), + new GridOffset(0, 1), + new GridOffset(-1, -1), + new GridOffset(-1, 1), + new GridOffset(1, -1), + new GridOffset(1, 1) + ); + + private final long worldSeed; + private final HydrologyPlannerSettings settings; + private final HydrologyTerrainSampler sampler; + private final HydrologyRoutingTerrainSampler routingSampler; + private final HydrologyNaturalTerrainSampler naturalSampler; + private final HydrologyGeometrySampler geometrySampler; + private final int minimumY; + private final HydrologyCaveVoxelViewFactory caveViewFactory; + private final Cache routingContexts; + private final Cache resolvedOwners; + private final Cache> refinedEdgeCache; + private final ConcurrentHashMap> resolvingOwners; + private final ThreadLocal planningSamples; + private final SimplexNoise routeAnchorX; + private final SimplexNoise routeAnchorZ; + private final SimplexNoise routeWormPrimary; + private final SimplexNoise routeWormDetail; + + public HydrologyPlanner(long worldSeed, HydrologyPlannerSettings settings, HydrologyTerrainSampler sampler) { + this( + worldSeed, + settings, + sampler, + HydrologyGeometrySampler.deterministic(worldSeed, sampler), + -4096, + footprint -> new HydrologyTerrainCaveVoxelView(sampler, settings.seaLevel(), -4096, 4096) + ); + } + + public HydrologyPlanner( + long worldSeed, + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler, + int worldHeight + ) { + this( + worldSeed, + settings, + sampler, + HydrologyGeometrySampler.deterministic(worldSeed, sampler), + 0, + footprint -> new HydrologyTerrainCaveVoxelView(sampler, settings.seaLevel(), worldHeight) + ); + } + + public HydrologyPlanner( + long worldSeed, + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler, + CaveVoxelView caveView + ) { + this( + worldSeed, + settings, + sampler, + HydrologyGeometrySampler.deterministic(worldSeed, sampler), + Integer.MIN_VALUE, + footprint -> caveView + ); + } + + public HydrologyPlanner( + long worldSeed, + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler, + int minimumY, + HydrologyCaveVoxelViewFactory caveViewFactory + ) { + this( + worldSeed, + settings, + sampler, + HydrologyGeometrySampler.deterministic(worldSeed, sampler), + minimumY, + caveViewFactory + ); + } + + public HydrologyPlanner( + long worldSeed, + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler, + HydrologyGeometrySampler geometrySampler, + int minimumY, + HydrologyCaveVoxelViewFactory caveViewFactory + ) { + this( + worldSeed, + settings, + sampler, + new PointRoutingTerrainSampler(sampler), + geometrySampler, + minimumY, + caveViewFactory + ); + } + + public HydrologyPlanner( + long worldSeed, + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler, + HydrologyRoutingTerrainSampler routingSampler, + HydrologyGeometrySampler geometrySampler, + int minimumY, + HydrologyCaveVoxelViewFactory caveViewFactory + ) { + this.worldSeed = worldSeed; + this.settings = Objects.requireNonNull(settings, "settings"); + this.sampler = Objects.requireNonNull(sampler, "sampler"); + this.routingSampler = Objects.requireNonNull(routingSampler, "routingSampler"); + this.naturalSampler = routingSampler instanceof HydrologyNaturalTerrainSampler pointSampler + ? pointSampler + : null; + this.geometrySampler = Objects.requireNonNull(geometrySampler, "geometrySampler"); + this.minimumY = minimumY; + this.caveViewFactory = Objects.requireNonNull(caveViewFactory, "caveViewFactory"); + this.routingContexts = Caffeine.newBuilder() + .maximumSize(ROUTING_CONTEXT_CACHE_SIZE) + .build(); + this.resolvedOwners = Caffeine.newBuilder() + .maximumSize(RESOLVED_OWNER_CACHE_SIZE) + .build(); + this.refinedEdgeCache = Caffeine.newBuilder() + .maximumSize(REFINED_EDGE_CACHE_SIZE) + .build(); + this.resolvingOwners = new ConcurrentHashMap<>(); + this.planningSamples = new ThreadLocal<>(); + this.routeAnchorX = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_ANCHOR_X_SALT)); + this.routeAnchorZ = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_ANCHOR_Z_SALT)); + this.routeWormPrimary = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_WORM_PRIMARY_SALT)); + this.routeWormDetail = new SimplexNoise(HydrologyHash.mix(worldSeed, ROUTE_WORM_DETAIL_SALT)); + } + + public long worldSeed() { + return worldSeed; + } + + public HydrologyPlannerSettings settings() { + return settings; + } + + public HydrologyTile plan(HydrologyTileKey key) { + return materializeAcceptedTile(resolveCrossTileOwner(key)); + } + + void clearOwnerDrafts() { + resolvedOwners.invalidateAll(); + } + + private HydrologyOwnerDraft compileOwnerDraft( + HydrologyTileKey key, + CrossTileDraftAdmission crossTileAdmission + ) { + PlanningSamples previous = planningSamples.get(); + if (previous == null) { + planningSamples.set(new PlanningSamples()); + } + try { + return compileScopedOwnerDraft(key, crossTileAdmission); + } finally { + if (previous == null) { + planningSamples.remove(); + } + } + } + + private HydrologyOwnerDraft compileScopedOwnerDraft( + HydrologyTileKey key, + CrossTileDraftAdmission crossTileAdmission + ) { + Objects.requireNonNull(key, "key"); + ArrayList diagnostics = new ArrayList<>(); + SampledGrid grid = null; + RoutingPlan surfaceRouting = null; + RoutingPlan undergroundRouting = null; + SourceSelection surfaceSelection = SourceSelection.empty(true); + SourceSelection undergroundSelection = SourceSelection.empty(false); + if (hasRoutedSourceSearch()) { + SourceRoutingContext primaryContext = sourceRoutingContext(key); + grid = primaryContext.grid(); + surfaceRouting = primaryContext.surfaceRouting(); + undergroundRouting = primaryContext.undergroundRouting(); + diagnostics.addAll(primaryContext.diagnostics()); + HashMap sourceRoutingContexts = new HashMap<>(); + sourceRoutingContexts.put(key, primaryContext); + surfaceSelection = selectSources( + key, + grid, + surfaceRouting, + true, + true, + diagnostics, + sourceRoutingContexts + ); + undergroundSelection = selectSources( + key, + grid, + undergroundRouting, + false, + true, + diagnostics, + sourceRoutingContexts + ); + } + HydrologyFootprintCompiler footprintCompiler = new HydrologyFootprintCompiler( + settings, + new HydrologyFootprintCompiler.Sampling( + sampler, + geometrySampler, + naturalSampler + ) + ); + HashMap> refinedEdges = new HashMap<>(); + HashMap sourceCompilations = new HashMap<>(); + settleSelection( + key, + grid, + surfaceRouting, + undergroundRouting, + surfaceSelection, + true, + footprintCompiler, + refinedEdges, + sourceCompilations, + diagnostics + ); + settleSelection( + key, + grid, + surfaceRouting, + undergroundRouting, + undergroundSelection, + false, + footprintCompiler, + refinedEdges, + sourceCompilations, + diagnostics + ); + PublicationAttempt publication = null; + List fallbackOutlets = grid == null || surfaceRouting == null + ? List.of() + : resolveSurfaceFallbackOutlets(grid); + int fallbackOutletIndex = 0; + while (true) { + int maximumPublicationPasses = Math.addExact( + 1, + Math.addExact( + surfaceSelection.candidateCount(), + undergroundSelection.candidateCount() + ) + ); + for (int publicationPass = 0; publicationPass < maximumPublicationPasses; publicationPass++) { + publication = compilePublication( + key, + grid, + surfaceRouting, + undergroundRouting, + surfaceSelection, + undergroundSelection, + footprintCompiler, + refinedEdges, + sourceCompilations, + true + ); + diagnostics.addAll(publication.diagnostics()); + if (crossTileAdmission != null) { + CrossTilePublicationAdmission admitted = crossTileAdmission.admit(publication.result()); + publication = new PublicationAttempt(admitted.result(), publication.diagnostics()); + diagnostics.addAll(admitted.diagnostics()); + } + if (grid == null) { + break; + } + boolean surfaceChanged = surfaceSelection.advanceAfterPublication( + publication.result().courses(), + grid + ); + boolean undergroundChanged = undergroundSelection.advanceAfterPublication( + publication.result().courses(), + grid + ); + if (!surfaceChanged && !undergroundChanged) { + break; + } + if (surfaceChanged) { + settleSelection( + key, + grid, + surfaceRouting, + undergroundRouting, + surfaceSelection, + true, + footprintCompiler, + refinedEdges, + sourceCompilations, + diagnostics + ); + } + if (undergroundChanged) { + settleSelection( + key, + grid, + surfaceRouting, + undergroundRouting, + undergroundSelection, + false, + footprintCompiler, + refinedEdges, + sourceCompilations, + diagnostics + ); + } + if (publicationPass + 1 >= maximumPublicationPasses) { + throw new IllegalStateException("Hydrology source publication exceeded its candidate bound."); + } + } + if (grid == null || !surfaceSelection.needsSurfaceFallback()) { + break; + } + boolean fallbackSelected = false; + while (fallbackOutletIndex < fallbackOutlets.size()) { + OutletCandidate fallbackOutlet = fallbackOutlets.get(fallbackOutletIndex++); + for (int routeMode = 0; routeMode < 2; routeMode++) { + RoutingPlan fallbackRouting = routeMode == 0 + ? requireOrganicSurface(buildRouting(grid, List.of(fallbackOutlet))) + : buildContainedSurfaceRouting(grid, List.of(fallbackOutlet)); + HashMap fallbackContexts = new HashMap<>(); + fallbackContexts.put(key, new SourceRoutingContext( + grid, + fallbackRouting, + undergroundRouting, + List.of() + )); + SourceSelection fallbackSelection = selectSources( + key, + grid, + fallbackRouting, + true, + false, + diagnostics, + fallbackContexts + ); + settleSelection( + key, + grid, + fallbackRouting, + undergroundRouting, + fallbackSelection, + true, + footprintCompiler, + refinedEdges, + sourceCompilations, + diagnostics + ); + if (!fallbackSelection.hasAcceptedSelection()) { + continue; + } + surfaceRouting = fallbackRouting; + surfaceSelection = fallbackSelection; + fallbackSelected = true; + break; + } + if (fallbackSelected) { + break; + } + } + if (!fallbackSelected) { + break; + } + } + if (publication == null) { + throw new IllegalStateException("Hydrology publication did not execute."); + } + if (grid != null) { + surfaceSelection.addFinalAdmissionDiagnostics(grid, diagnostics); + undergroundSelection.addFinalAdmissionDiagnostics(grid, diagnostics); + } + HydrologyCaveCourseFilter.Result result = publication.result(); + List uniqueDiagnostics = uniqueDiagnostics(diagnostics); + HydrologyOwnerDraft draft = new HydrologyOwnerDraft(key, result, uniqueDiagnostics); + validateOwnerDraftReach(draft); + return draft; + } + + private HydrologyTile materializeAcceptedTile(CrossTileResolution resolution) { + HydrologyCaveCourseFilter.Result result = resolution.draft().result(); + ArrayList diagnostics = new ArrayList<>(resolution.draft().diagnostics()); + for (CrossTileRejectedCourse rejection : resolution.observedRejections()) { + diagnostics.add(rejection.course().type() == RiverCourseType.SURFACE + ? surfaceConflictDiagnostic(rejection.course(), rejection.winnerSourceId()) + : HydrologyCaveCourseFilter.overlapDiagnostic( + rejection.course(), + rejection.winnerSourceId() + )); + } + HydrologyFootprintCompiler footprintCompiler = new HydrologyFootprintCompiler( + settings, + new HydrologyFootprintCompiler.Sampling( + sampler, + geometrySampler, + naturalSampler + ) + ); + MaterializedHydrology materialized = materializeFinalHydrology( + result, + diagnostics, + footprintCompiler, + !resolution.observedRejections().isEmpty() + ); + result = materialized.result(); + RiverFootprint footprint = materialized.footprint(); + if (footprintCompiler.fullMaterializationCount() < 1) { + throw new IllegalStateException("Hydrology publication did not materialize its full footprint."); + } + return new HydrologyTile( + resolution.draft().key(), + worldSeed, + settings.fingerprint(), + settings.routing().tileSize(), + result.nodes(), + result.edges(), + result.outlets(), + result.courses(), + result.cavePlans(), + uniqueDiagnostics(diagnostics), + footprint + ); + } + + private MaterializedHydrology materializeFinalHydrology( + HydrologyCaveCourseFilter.Result initial, + List diagnostics, + HydrologyFootprintCompiler footprintCompiler, + boolean revalidationRequired + ) { + HydrologyCaveCourseFilter.Result current = initial; + int maximumPasses = Math.addExact(initial.courses().size(), 1); + for (int pass = 0; pass < maximumPasses; pass++) { + RiverFootprint footprint = footprintCompiler.compile(current.courses()); + HydrologyCaveCourseFilter.Result incisionContained = rejectMaterializedSurfaceIncision( + current, + footprint, + diagnostics, + footprintCompiler + ); + if (!incisionContained.courses().equals(current.courses())) { + current = incisionContained; + revalidationRequired = true; + continue; + } + if (current.cavePlans().isEmpty()) { + return new MaterializedHydrology(current, footprint); + } + HydrologyFootprintCompiler.ValidationRaster compactValidation = footprintCompiler.compileValidation( + current.courses() + ); + if (!revalidationRequired + && compactSurfaceMatchesMaterializedPlans(current, compactValidation, footprint)) { + return new MaterializedHydrology(current, footprint); + } + HydrologyFootprintCompiler.ValidationRaster validation = compactValidation.withMaterializedSurface( + footprint + ); + HydrologyObservedPlannedSurface plannedSurface = new HydrologyObservedPlannedSurface( + validation.plannedSurface() + ); + CaveVoxelView caveView = Objects.requireNonNull( + caveViewFactory.create(plannedSurface), + "Hydrology cave view factory returned null" + ); + HydrologyCaveCourseFilter.Result revalidated = new HydrologyCaveCourseFilter( + caveView, + new HydrologyCaveCourseFilter.Options( + settings.underground().connectToExistingCaves(), + settings.outlets().coastalGrotto().maximumVolume(), + settings.outlets().inlandGrotto().maximumVolume() + ), + null, + null, + plannedSurface + ).filter( + current.nodes(), + current.edges(), + current.outlets(), + current.courses(), + validation, + diagnostics + ); + if (revalidated.courses().equals(current.courses())) { + return new MaterializedHydrology(revalidated, footprint); + } + current = revalidated; + revalidationRequired = true; + } + throw new IllegalStateException("Final hydrology cave publication exceeded its course bound."); + } + + private HydrologyCaveCourseFilter.Result rejectMaterializedSurfaceIncision( + HydrologyCaveCourseFilter.Result current, + RiverFootprint footprint, + List diagnostics, + HydrologyFootprintCompiler footprintCompiler + ) { + ArrayList retained = new ArrayList<>(current.courses().size()); + for (RiverCourse course : current.courses()) { + if (footprintCompiler.surfaceIncisionContained(course, footprint)) { + retained.add(course); + continue; + } + HydrologyPoint source = course.segments().getFirst().centerline().getFirst(); + HydrologyCandidateRejection rejection = HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED; + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(course.id(), DIAGNOSTIC_SALT, rejection.ordinal()), + HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, + source, + rejection + )); + } + if (retained.size() == current.courses().size()) { + return current; + } + HydrologyFootprintCompiler.ValidationRaster validation = footprintCompiler.compileValidation(retained); + HydrologyObservedPlannedSurface plannedSurface = new HydrologyObservedPlannedSurface( + validation.plannedSurface() + ); + CaveVoxelView caveView = Objects.requireNonNull( + caveViewFactory.create(plannedSurface), + "Hydrology cave view factory returned null" + ); + return new HydrologyCaveCourseFilter( + caveView, + new HydrologyCaveCourseFilter.Options( + settings.underground().connectToExistingCaves(), + settings.outlets().coastalGrotto().maximumVolume(), + settings.outlets().inlandGrotto().maximumVolume() + ), + null, + null, + plannedSurface + ).filter( + current.nodes(), + current.edges(), + current.outlets(), + retained, + validation, + diagnostics + ); + } + + private boolean compactSurfaceMatchesMaterializedPlans( + HydrologyCaveCourseFilter.Result result, + HydrologyFootprintCompiler.ValidationRaster validation, + RiverFootprint footprint + ) { + HashSet visitedColumns = new HashSet<>(); + for (HydrologyCavePlan plan : result.cavePlans()) { + for (CavePosition position : plan.baselinePreconditions().keySet()) { + long packed = RiverFootprint.pack(position.x(), position.z()); + if (!visitedColumns.add(packed)) { + continue; + } + HydrologyColumnSample sample = footprint.sample(position.x(), position.z()).orElse(null); + HydrologyTerrainSample terrain = sample == null + ? Objects.requireNonNull(sampler.sample(position.x(), position.z())) + : null; + int naturalHeight = sample == null ? terrain.naturalHeight() : sample.naturalHeight(); + int materializedHeight = sample == null ? naturalHeight : sample.terrainHeight(); + int compactHeight = validation.plannedSurface().resolve( + position.x(), + position.z(), + naturalHeight + ); + if (compactHeight != materializedHeight) { + return false; + } + } + } + return true; + } + + private CrossTileResolution resolveCrossTileOwner(HydrologyTileKey key) { + long started = System.nanoTime(); + CrossTileResolutionContext context = new CrossTileResolutionContext( + key, + maximumCrossTileDependencyOffset(key), + maximumCrossTileDependencyOwners(key) + ); + CrossTileResolvedOwner resolved = resolveCrossTileOwner(key, context); + return new CrossTileResolution( + resolved.draft(), + resolved.observedRejections(), + context.iterations(), + context.ownerCount(), + System.nanoTime() - started + ); + } + + private CrossTileResolvedOwner resolveCrossTileOwner( + HydrologyTileKey key, + CrossTileResolutionContext context + ) { + CrossTileResolvedOwner local = context.resolved(key); + if (local != null) { + return local; + } + CrossTileResolvedOwner cached = resolvedOwners.getIfPresent(key); + if (cached != null) { + context.remember(key, cached); + return cached; + } + CompletableFuture owned = new CompletableFuture<>(); + CompletableFuture existing = resolvingOwners.putIfAbsent(key, owned); + if (existing != null) { + CrossTileResolvedOwner resolved = awaitResolvedOwner(existing); + context.remember(key, resolved); + return resolved; + } + if (!context.begin(key)) { + resolvingOwners.remove(key, owned); + throw new IllegalStateException("Cross-tile owner color dependencies must be acyclic."); + } + try { + int ownerRank = ownerColorRank(key); + ColorRankedDraftAdmission admission = new ColorRankedDraftAdmission(key, ownerRank, context); + HydrologyOwnerDraft draft = compileOwnerDraft(key, admission); + validateOwnerDraftReach(draft); + CrossTileResolvedOwner resolved = new CrossTileResolvedOwner(draft, admission.observedRejections()); + context.remember(key, resolved); + resolvedOwners.put(key, resolved); + owned.complete(resolved); + return resolved; + } catch (Throwable failure) { + owned.completeExceptionally(failure); + throw propagateOwnerFailure(failure); + } finally { + context.end(key); + resolvingOwners.remove(key, owned); + } + } + + private CrossTileBlockers lowerRankBlockers( + HydrologyTileKey ownerKey, + HydrologyCaveCourseFilter.Result result, + int ownerRank, + CrossTileResolutionContext context + ) { + ArrayList blockers = new ArrayList<>(); + ArrayList surfaceBlockers = new ArrayList<>(); + ArrayList candidateKeys = new ArrayList<>(); + for (HydrologyTileKey candidateKey : conflictOwnerKeys(ownerKey, result)) { + if (candidateKey.equals(ownerKey)) { + continue; + } + int candidateRank = ownerColorRank(candidateKey); + if (candidateRank == ownerRank) { + throw new IllegalStateException("Equal owner colors cannot share a cave-action conflict envelope."); + } + if (candidateRank > ownerRank) { + continue; + } + candidateKeys.add(candidateKey); + } + List resolvedCandidates = resolveLowerRankOwners(candidateKeys, context); + for (int index = 0; index < candidateKeys.size(); index++) { + HydrologyTileKey candidateKey = candidateKeys.get(index); + int candidateRank = ownerColorRank(candidateKey); + CrossTileResolvedOwner resolved = resolvedCandidates.get(index); + for (HydrologyCrossTileCaveAdmission.Claim claim : caveClaims(resolved.draft())) { + blockers.add(new HydrologyCrossTileCaveAdmission.RankedClaim( + candidateKey, + candidateRank, + claim + )); + } + for (HydrologyCrossTileSurfaceAdmission.Claim claim : surfaceClaims(resolved.draft())) { + surfaceBlockers.add(new HydrologyCrossTileSurfaceAdmission.RankedClaim( + candidateKey, + candidateRank, + claim + )); + } + } + return new CrossTileBlockers(List.copyOf(blockers), List.copyOf(surfaceBlockers)); + } + + private List resolveLowerRankOwners( + List candidateKeys, + CrossTileResolutionContext context + ) { + if (candidateKeys.size() < 2 + || !IrisPlatforms.isBound() + || MultiBurst.burst.ownsCurrentThread()) { + ArrayList resolved = new ArrayList<>(candidateKeys.size()); + for (HydrologyTileKey candidateKey : candidateKeys) { + resolved.add(resolveCrossTileOwner(candidateKey, context)); + } + return List.copyOf(resolved); + } + ArrayList> futures = new ArrayList<>(candidateKeys.size()); + for (HydrologyTileKey candidateKey : candidateKeys) { + futures.add(CompletableFuture.supplyAsync( + () -> resolveIndependentOwner(candidateKey), + MultiBurst.burst + )); + } + ArrayList resolved = new ArrayList<>(candidateKeys.size()); + for (int index = 0; index < candidateKeys.size(); index++) { + CrossTileResolvedOwner owner = awaitResolvedOwner(futures.get(index)); + context.remember(candidateKeys.get(index), owner); + resolved.add(owner); + } + return List.copyOf(resolved); + } + + private CrossTileResolvedOwner resolveIndependentOwner(HydrologyTileKey key) { + CrossTileResolutionContext context = new CrossTileResolutionContext( + key, + maximumCrossTileDependencyOffset(key), + maximumCrossTileDependencyOwners(key) + ); + return resolveCrossTileOwner(key, context); + } + + private static CrossTileResolvedOwner awaitResolvedOwner( + CompletableFuture future + ) { + try { + return future.join(); + } catch (CompletionException failure) { + throw propagateOwnerFailure(failure.getCause()); + } + } + + private static RuntimeException propagateOwnerFailure(Throwable failure) { + if (failure instanceof RuntimeException runtimeException) { + return runtimeException; + } + if (failure instanceof Error error) { + throw error; + } + return new IllegalStateException("Hydrology owner planning failed.", failure); + } + + private List conflictOwnerKeys(HydrologyOwnerDraft target) { + return conflictOwnerKeys(target.key(), target.result()); + } + + private List conflictOwnerKeys( + HydrologyTileKey ownerKey, + HydrologyCaveCourseFilter.Result result + ) { + int tileSize = settings.routing().tileSize(); + int reach = Math.addExact(settings.publicationRadius(), 1); + int colorPeriod = settings.crossTileColorPeriod(); + LongOpenHashSet columns = new LongOpenHashSet(); + for (HydrologyCavePlan plan : result.cavePlans()) { + for (CavePosition position : plan.actions().keySet()) { + columns.add(RiverFootprint.pack(position.x(), position.z())); + } + } + for (RiverCourse course : result.courses()) { + if (course.type() != RiverCourseType.SURFACE) { + continue; + } + for (HydraulicSegment segment : course.segments()) { + for (HydrologyPoint point : segment.centerline()) { + columns.add(RiverFootprint.pack(point.x(), point.z())); + } + } + } + LinkedHashSet owners = new LinkedHashSet<>(); + owners.add(ownerKey); + LongIterator iterator = columns.iterator(); + while (iterator.hasNext()) { + long column = iterator.nextLong(); + int x = RiverFootprint.unpackX(column); + int z = RiverFootprint.unpackZ(column); + int minimumTileX = ownerCoordinate((long) x - reach, tileSize); + int maximumTileX = ownerCoordinate((long) x + reach, tileSize); + int minimumTileZ = ownerCoordinate((long) z - reach, tileSize); + int maximumTileZ = ownerCoordinate((long) z + reach, tileSize); + long ownerWidth = Math.addExact((long) maximumTileX - minimumTileX, 1L); + long ownerDepth = Math.addExact((long) maximumTileZ - minimumTileZ, 1L); + if (ownerWidth > colorPeriod || ownerDepth > colorPeriod) { + throw new IllegalStateException("A cave action exceeds its bounded owner color neighborhood."); + } + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + owners.add(new HydrologyTileKey(tileX, tileZ)); + } + } + } + int sourceReach = Math.max(0, settings.surface().sources().minimumSpacing() - 1); + for (HydrologyCrossTileSurfaceAdmission.Claim claim : surfaceClaims(result)) { + HydrologyPoint source = claim.centerline().getFirst(); + int minimumTileX = ownerCoordinate((long) source.x() - sourceReach, tileSize); + int maximumTileX = ownerCoordinate((long) source.x() + sourceReach, tileSize); + int minimumTileZ = ownerCoordinate((long) source.z() - sourceReach, tileSize); + int maximumTileZ = ownerCoordinate((long) source.z() + sourceReach, tileSize); + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + owners.add(new HydrologyTileKey(tileX, tileZ)); + } + } + } + ArrayList ordered = new ArrayList<>(owners); + ordered.sort(HydrologyTileKey::compareTo); + return List.copyOf(ordered); + } + + private int ownerColorRank(HydrologyTileKey key) { + int colorPeriod = settings.crossTileColorPeriod(); + int colorX = Math.floorMod(key.tileX(), colorPeriod); + int colorZ = Math.floorMod(key.tileZ(), colorPeriod); + return Math.addExact(Math.multiplyExact(colorZ, colorPeriod), colorX); + } + + int maximumCrossTileDependencyOwners(HydrologyTileKey key) { + long offset = maximumCrossTileDependencyOffset(key); + long width = Math.addExact(Math.multiplyExact(2L, offset), 1L); + return Math.toIntExact(Math.multiplyExact(width, width)); + } + + private long maximumCrossTileDependencyOffset(HydrologyTileKey key) { + return Math.multiplyExact( + (long) ownerColorRank(key), + settings.crossTileColorPeriod() - 1L + ); + } + + private List caveClaims(HydrologyOwnerDraft draft) { + return caveClaims(draft.result()); + } + + private List caveClaims( + HydrologyCaveCourseFilter.Result result + ) { + if (result.cavePlans().isEmpty()) { + return List.of(); + } + Map coursesById = coursesById(result.courses()); + ArrayList claims = + new ArrayList<>(result.cavePlans().size()); + for (HydrologyCavePlan plan : result.cavePlans()) { + RiverCourse course = coursesById.get(plan.source().sourceId()); + if (course == null) { + throw new IllegalStateException("Accepted cave plan has no owner course."); + } + claims.add(new HydrologyCrossTileCaveAdmission.Claim(course.profileKey(), plan)); + } + return List.copyOf(claims); + } + + private List surfaceClaims(HydrologyOwnerDraft draft) { + return surfaceClaims(draft.result()); + } + + private List surfaceClaims( + HydrologyCaveCourseFilter.Result result + ) { + ArrayList claims = new ArrayList<>(); + for (RiverCourse course : result.courses()) { + if (course.type() != RiverCourseType.SURFACE || course.outletId().isEmpty()) { + continue; + } + ArrayList centerline = new ArrayList<>(); + int maximumWidth = 1; + for (HydraulicSegment segment : course.segments()) { + maximumWidth = Math.max(maximumWidth, segment.width()); + for (HydrologyPoint point : segment.centerline()) { + if (centerline.isEmpty() + || point.x() != centerline.getLast().x() + || point.z() != centerline.getLast().z()) { + centerline.add(point); + } + } + } + if (centerline.size() < 2) { + continue; + } + HydrologyFeatureType terminalType = course.segments().getLast().type(); + boolean reachesOutlet = terminalType == HydrologyFeatureType.MOUTH + || terminalType == HydrologyFeatureType.COASTAL_GROTTO + || terminalType == HydrologyFeatureType.INLAND_GROTTO; + if (!reachesOutlet) { + continue; + } + claims.add(new HydrologyCrossTileSurfaceAdmission.Claim( + course.id(), + course.outletId().getAsLong(), + centerline.getLast(), + reachesOutlet, + maximumWidth, + centerline + )); + } + return List.copyOf(claims); + } + + private Map coursesById(List courses) { + LinkedHashMap indexed = new LinkedHashMap<>(courses.size()); + for (RiverCourse course : courses) { + RiverCourse existing = indexed.putIfAbsent(course.id(), course); + if (existing != null && !existing.equals(course)) { + throw new IllegalStateException("Hydrology course id collision."); + } + } + return Map.copyOf(indexed); + } + + private void validateOwnerDraftReach(HydrologyOwnerDraft draft) { + int tileSize = settings.routing().tileSize(); + int reach = Math.addExact(settings.publicationRadius(), 1); + long minimumX = (long) draft.key().minimumBlockX(tileSize) - reach; + long minimumZ = (long) draft.key().minimumBlockZ(tileSize) - reach; + long maximumX = (long) draft.key().minimumBlockX(tileSize) + tileSize - 1L + reach; + long maximumZ = (long) draft.key().minimumBlockZ(tileSize) + tileSize - 1L + reach; + for (HydrologyCavePlan plan : draft.result().cavePlans()) { + for (CavePosition position : plan.actions().keySet()) { + if (position.x() < minimumX || position.x() > maximumX + || position.z() < minimumZ || position.z() > maximumZ) { + throw new IllegalStateException("Hydrology cave plan exceeds its bounded owner reach."); + } + } + } + } + + private static int ownerCoordinate(long blockCoordinate, int tileSize) { + return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize)); + } + + private void settleSelection( + HydrologyTileKey key, + SampledGrid grid, + RoutingPlan surfaceRouting, + RoutingPlan undergroundRouting, + SourceSelection selection, + boolean surface, + HydrologyFootprintCompiler footprintCompiler, + Map> refinedEdges, + Map sourceCompilations, + List diagnostics + ) { + RoutingPlan selectedRouting = surface ? surfaceRouting : undergroundRouting; + if (grid == null + || selectedRouting == null + || selection.selectedCandidateIndices.isEmpty()) { + return; + } + SourceSelection emptySelection = SourceSelection.empty(!surface); + int maximumPasses = Math.addExact(1, selection.candidateCount()); + for (int pass = 0; pass < maximumPasses; pass++) { + PublicationAttempt attempt = compilePublication( + key, + grid, + surfaceRouting, + undergroundRouting, + surface ? selection : emptySelection, + surface ? emptySelection : selection, + footprintCompiler, + refinedEdges, + sourceCompilations, + false + ); + diagnostics.addAll(attempt.diagnostics()); + if (!selection.advanceAfterPublication(attempt.result().courses(), grid)) { + return; + } + } + throw new IllegalStateException("Hydrology source trial exceeded its candidate bound."); + } + + private PublicationAttempt compilePublication( + HydrologyTileKey key, + SampledGrid grid, + RoutingPlan surfaceRouting, + RoutingPlan undergroundRouting, + SourceSelection surfaceSelection, + SourceSelection undergroundSelection, + HydrologyFootprintCompiler footprintCompiler, + Map> refinedEdges, + Map sourceCompilations, + boolean includeDeepFluids + ) { + ArrayList diagnostics = new ArrayList<>(); + ArrayList courses = new ArrayList<>(); + CompiledGraph graph = new CompiledGraph(List.of(), List.of(), List.of(), Map.of()); + if (grid != null) { + List surfaceSources = surfaceSelection.selectedNodeIndices(); + List undergroundSources = undergroundSelection.selectedNodeIndices(); + if (surfaceRouting != null && surfaceRouting == undergroundRouting) { + int[] surfaceContributions = countContributions(surfaceSources, surfaceRouting); + int[] undergroundContributions = countContributions(undergroundSources, surfaceRouting); + graph = compileGraph( + key, + grid, + surfaceRouting, + surfaceContributions, + undergroundContributions, + refinedEdges + ); + if (settings.surface().enabled()) { + compileSurfaceCourses( + key, + grid, + surfaceRouting, + graph, + surfaceSources, + courses, + diagnostics + ); + } + if (settings.underground().enabled()) { + compileUndergroundCourses( + key, + grid, + surfaceRouting, + graph, + undergroundSources, + courses, + diagnostics + ); + } + } else { + CompiledGraph surfaceGraph = new CompiledGraph(List.of(), List.of(), List.of(), Map.of()); + CompiledGraph undergroundGraph = new CompiledGraph(List.of(), List.of(), List.of(), Map.of()); + if (surfaceRouting != null && !surfaceSources.isEmpty()) { + SourceCompilation surfaceCompilation = compileSourceSelection( + true, + key, + grid, + surfaceRouting, + surfaceSources, + refinedEdges, + sourceCompilations + ); + surfaceGraph = surfaceCompilation.graph(); + courses.addAll(surfaceCompilation.courses()); + diagnostics.addAll(surfaceCompilation.diagnostics()); + } + if (undergroundRouting != null && !undergroundSources.isEmpty()) { + SourceCompilation undergroundCompilation = compileSourceSelection( + false, + key, + grid, + undergroundRouting, + undergroundSources, + refinedEdges, + sourceCompilations + ); + undergroundGraph = undergroundCompilation.graph(); + courses.addAll(undergroundCompilation.courses()); + diagnostics.addAll(undergroundCompilation.diagnostics()); + } + graph = mergeGraphs(surfaceGraph, undergroundGraph); + } + } + List normalizedTrunkCourses = normalizeSharedTrunks(courses); + List normalizedOutletCourses = normalizeOutletContinuations(normalizedTrunkCourses); + courses.clear(); + courses.addAll(normalizedOutletCourses); + rejectUncontainedSurfaceCourses(courses, footprintCompiler, diagnostics); + if (includeDeepFluids) { + compileDeepFluidCourses(key, courses, diagnostics); + } + HydrologyFootprintCompiler.ValidationRaster validation = footprintCompiler.compileValidation(courses); + HydrologyObservedPlannedSurface plannedSurface = new HydrologyObservedPlannedSurface( + validation.plannedSurface() + ); + CaveVoxelView caveView = Objects.requireNonNull( + caveViewFactory.create(plannedSurface), + "Hydrology cave view factory returned null" + ); + PlanningSamples samples = planningSamples.get(); + if (samples != null && caveView instanceof HydrologyTerrainCaveVoxelView terrainCaveView) { + caveView = terrainCaveView.withSampleCache(samples.detailed); + } + Map candidateCache = + samples == null ? null : samples.caveCandidates; + HydrologyCaveContainmentPlanner.ValidationCache validationCache = + samples == null ? null : samples.caveValidations; + HydrologyCaveCourseFilter.Result containment = new HydrologyCaveCourseFilter( + caveView, + new HydrologyCaveCourseFilter.Options( + settings.underground().connectToExistingCaves(), + settings.outlets().coastalGrotto().maximumVolume(), + settings.outlets().inlandGrotto().maximumVolume() + ), + candidateCache, + validationCache, + plannedSurface + ).filter( + graph.nodes(), + graph.edges(), + graph.outlets(), + courses, + validation, + diagnostics + ); + return new PublicationAttempt(containment, diagnostics); + } + + private void rejectUncontainedSurfaceCourses( + List courses, + HydrologyFootprintCompiler footprintCompiler, + List diagnostics + ) { + int courseIndex = 0; + while (courseIndex < courses.size()) { + RiverCourse course = courses.get(courseIndex); + if (course.type() != RiverCourseType.SURFACE) { + courseIndex++; + continue; + } + HydrologyCandidateRejection rejection; + if (!footprintCompiler.surfaceIncisionContained(course)) { + rejection = HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED; + } else if (!footprintCompiler.surfaceHeadwaterRampSupported(course)) { + rejection = HydrologyCandidateRejection.SURFACE_EXPOSURE; + } else if (!footprintCompiler.surfaceBanksContained(course)) { + rejection = HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED; + } else { + courseIndex++; + continue; + } + HydrologyPoint source = course.segments().getFirst().centerline().getFirst(); + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix( + course.id(), + DIAGNOSTIC_SALT, + rejection.ordinal() + ), + HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, + source, + rejection + )); + courses.remove(courseIndex); + } + } + + private SourceCompilation compileSourceSelection( + boolean surface, + HydrologyTileKey key, + SampledGrid grid, + RoutingPlan routing, + List sources, + Map> refinedEdges, + Map sourceCompilations + ) { + SourceCompilationKey compilationKey = new SourceCompilationKey(surface, routing, sources); + SourceCompilation cached = sourceCompilations.get(compilationKey); + if (cached != null) { + return cached; + } + int[] contributions = countContributions(sources, routing); + CompiledGraph graph = compileGraph( + key, + grid, + routing, + surface ? contributions : new int[contributions.length], + surface ? new int[contributions.length] : contributions, + refinedEdges + ); + ArrayList courses = new ArrayList<>(); + ArrayList diagnostics = new ArrayList<>(); + if (surface && settings.surface().enabled()) { + compileSurfaceCourses(key, grid, routing, graph, sources, courses, diagnostics); + } else if (!surface && settings.underground().enabled()) { + compileUndergroundCourses(key, grid, routing, graph, sources, courses, diagnostics); + } + SourceCompilation compiled = new SourceCompilation( + graph, + List.copyOf(courses), + List.copyOf(diagnostics) + ); + sourceCompilations.put(compilationKey, compiled); + return compiled; + } + + private List normalizeOutletContinuations(List courses) { + HashMap canonicalCourses = new HashMap<>(); + for (RiverCourse course : courses) { + if (!hasInlandOutletContinuation(course) || course.outletId().isEmpty()) { + continue; + } + OutletCourseKey key = new OutletCourseKey(course.outletId().getAsLong()); + canonicalCourses.merge( + key, + course, + this::preferredOutletContinuation + ); + } + ArrayList normalized = new ArrayList<>(courses.size()); + for (RiverCourse course : courses) { + if (course.outletId().isEmpty()) { + normalized.add(course); + continue; + } + OutletCourseKey key = new OutletCourseKey(course.outletId().getAsLong()); + RiverCourse canonicalCourse = canonicalCourses.get(key); + if (canonicalCourse == null || canonicalCourse.id() == course.id()) { + normalized.add(course); + continue; + } + List segments = course.segments(); + int retainedSize = course.surfaceSinkholeContinuation() + ? segments.size() - 2 + : segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO + ? segments.size() - 1 + : segments.size(); + retainedSize = trimCanonicalOutletOverlap(course, canonicalCourse, retainedSize); + if (retainedSize < 1) { + continue; + } + normalized.add(new RiverCourse( + course.id(), + course.type(), + course.sourceNodeId(), + course.outletId(), + course.profileKey(), + course.discharge(), + course.drainageEdges(), + segments.subList(0, retainedSize) + )); + } + return List.copyOf(normalized); + } + + private int trimCanonicalOutletOverlap( + RiverCourse course, + RiverCourse canonicalCourse, + int retainedSize + ) { + if (!canonicalCourse.surfaceSinkholeContinuation() + || canonicalCourse.segments().isEmpty() + || retainedSize < 1) { + return retainedSize; + } + HydraulicSegment canonicalGrotto = canonicalCourse.segments().getLast(); + if (canonicalGrotto.type() != HydrologyFeatureType.INLAND_GROTTO) { + return retainedSize; + } + int exclusionRadius = Math.addExact( + settings.outlets().inlandGrotto().horizontalRadius(), + Math.addExact((int) StrictMath.ceil(settings.underground().maximumWidth() / 2D), 2) + ); + long exclusionRadiusSquared = (long) exclusionRadius * exclusionRadius; + while (retainedSize > 0 && segmentOverlapsOutlet( + course.segments().get(retainedSize - 1), + canonicalGrotto, + exclusionRadiusSquared + )) { + retainedSize--; + } + return retainedSize; + } + + private boolean segmentOverlapsOutlet( + HydraulicSegment segment, + HydraulicSegment outlet, + long exclusionRadiusSquared + ) { + for (HydrologyPoint point : segment.centerline()) { + for (HydrologyPoint outletPoint : outlet.centerline()) { + if (point.distanceSquared2D(outletPoint) <= exclusionRadiusSquared) { + return true; + } + } + } + return false; + } + + private List normalizeSharedTrunks(List courses) { + HashMap edgeOwners = new HashMap<>(); + for (RiverCourse course : courses) { + if (course.sourceNodeId().isEmpty()) { + continue; + } + for (DrainageEdge edge : course.drainageEdges()) { + CourseEdgeKey key = new CourseEdgeKey(course.type(), edge.id()); + edgeOwners.merge( + key, + course, + this::preferredTrunkCourse + ); + } + } + ArrayList normalized = new ArrayList<>(courses.size()); + for (RiverCourse course : courses) { + if (course.sourceNodeId().isEmpty() || course.drainageEdges().isEmpty()) { + normalized.add(course); + continue; + } + int ownedEdgeCount = 0; + for (DrainageEdge edge : course.drainageEdges()) { + RiverCourse owner = edgeOwners.get(new CourseEdgeKey(course.type(), edge.id())); + if (owner == null || owner.id() != course.id()) { + break; + } + ownedEdgeCount++; + } + if (ownedEdgeCount == course.drainageEdges().size()) { + normalized.add(course); + continue; + } + if (ownedEdgeCount < 1) { + continue; + } + HydrologyPoint boundary = course.drainageEdges() + .get(ownedEdgeCount - 1) + .centerline() + .getLast(); + int retainedSegmentCount = retainedSegmentCount(course.segments(), boundary); + if (retainedSegmentCount < 1) { + normalized.add(course); + continue; + } + normalized.add(new RiverCourse( + course.id(), + course.type(), + course.sourceNodeId(), + course.outletId(), + course.profileKey(), + course.discharge(), + course.drainageEdges().subList(0, ownedEdgeCount), + course.segments().subList(0, retainedSegmentCount) + )); + } + return List.copyOf(normalized); + } + + private RiverCourse preferredTrunkCourse(RiverCourse first, RiverCourse second) { + double firstLength = edgeLength(first.drainageEdges(), first.drainageEdges().size()); + double secondLength = edgeLength(second.drainageEdges(), second.drainageEdges().size()); + if (firstLength != secondLength) { + return firstLength > secondLength ? first : second; + } + if (first.discharge() != second.discharge()) { + return first.discharge() > second.discharge() ? first : second; + } + return Long.compareUnsigned(first.id(), second.id()) <= 0 ? first : second; + } + + private double edgeLength(List edges, int edgeCount) { + double length = 0D; + for (int edgeIndex = 0; edgeIndex < edgeCount; edgeIndex++) { + List centerline = edges.get(edgeIndex).centerline(); + for (int pointIndex = 1; pointIndex < centerline.size(); pointIndex++) { + HydrologyPoint previous = centerline.get(pointIndex - 1); + HydrologyPoint current = centerline.get(pointIndex); + length += StrictMath.hypot(current.x() - previous.x(), current.z() - previous.z()); + } + } + return length; + } + + private int retainedSegmentCount(List segments, HydrologyPoint boundary) { + for (int segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++) { + HydrologyPoint end = segments.get(segmentIndex).centerline().getLast(); + if (end.x() == boundary.x() && end.z() == boundary.z()) { + return segmentIndex + 1; + } + } + return 0; + } + + private RiverCourse preferredOutletContinuation(RiverCourse first, RiverCourse second) { + boolean firstSurfaceSinkhole = first.surfaceSinkholeContinuation(); + boolean secondSurfaceSinkhole = second.surfaceSinkholeContinuation(); + if (firstSurfaceSinkhole != secondSurfaceSinkhole) { + return firstSurfaceSinkhole ? first : second; + } + return Long.compareUnsigned(first.id(), second.id()) <= 0 ? first : second; + } + + private boolean hasInlandOutletContinuation(RiverCourse course) { + return course.surfaceSinkholeContinuation() + || course.segments().getLast().type() == HydrologyFeatureType.INLAND_GROTTO; + } + + private boolean hasRoutedSourceSearch() { + HydrologyPlannerSettings.Source surface = settings.surface().sources(); + if (settings.surface().enabled() && surface.enabled()) { + return true; + } + HydrologyPlannerSettings.Source underground = settings.underground().sources(); + return settings.underground().enabled() + && underground.enabled(); + } + + private List uniqueDiagnostics( + List diagnostics + ) { + LinkedHashMap unique = new LinkedHashMap<>(); + for (HydrologyDiagnosticCandidate diagnostic : diagnostics) { + unique.putIfAbsent(diagnostic.id(), diagnostic); + } + return List.copyOf(unique.values()); + } + + private SampledGrid sample(HydrologyTileKey key) { + HydrologyPlannerSettings.Routing routing = settings.routing(); + int ownerMinimumX = key.minimumBlockX(routing.tileSize()); + int ownerMinimumZ = key.minimumBlockZ(routing.tileSize()); + int halo = Math.min( + routing.maximumRouteLength(), + Math.multiplyExact(routing.sampleSpacing(), 2) + ); + int alignedHalo = Math.floorDiv(halo, routing.sampleSpacing()) * routing.sampleSpacing(); + int minimumX = ownerMinimumX - alignedHalo; + int minimumZ = ownerMinimumZ - alignedHalo; + int width = (routing.tileSize() + alignedHalo * 2) / routing.sampleSpacing() + 1; + int nodeCount = Math.multiplyExact(width, width); + HydrologyRoutingTerrainSampler.GridRequest gridRequest = new HydrologyRoutingTerrainSampler.GridRequest( + minimumX, + minimumZ, + width, + routing.sampleSpacing() + ); + HydrologyTerrainSample[] terrainSamples = Objects.requireNonNull( + routingSampler.sampleGrid(gridRequest), + "Hydrology routing terrain sampler returned null" + ); + if (terrainSamples.length != nodeCount) { + throw new IllegalStateException( + "Hydrology routing terrain sampler returned " + terrainSamples.length + + " samples for a " + width + " by " + width + " grid" + ); + } + ArrayList nodes = new ArrayList<>(nodeCount); + for (int gridZ = 0; gridZ < width; gridZ++) { + int z = minimumZ + gridZ * routing.sampleSpacing(); + for (int gridX = 0; gridX < width; gridX++) { + int x = minimumX + gridX * routing.sampleSpacing(); + int index = gridZ * width + gridX; + HydrologyTerrainSample terrain = Objects.requireNonNull( + terrainSamples[index], + "Hydrology routing terrain sampler returned null at " + x + "," + z + ); + long id = HydrologyHash.mix(worldSeed, NODE_SALT, x, z); + nodes.add(new GridNode(index, gridX, gridZ, x, z, id, terrain)); + } + } + return new SampledGrid( + minimumX, + minimumZ, + ownerMinimumX, + ownerMinimumZ, + routing.tileSize(), + width, + routing.sampleSpacing(), + List.copyOf(nodes) + ); + } + + private List resolveOutlets( + HydrologyTileKey key, + SampledGrid grid, + boolean surface, + List diagnostics + ) { + ArrayList oceanCandidates = oceanOutletCandidates(grid, surface); + sortOutletCandidates(key, grid, surface, oceanCandidates); + int maximum = settings.outlets().maximumPerTile(); + if (!settings.outlets().inlandGrotto().enabled()) { + List selected = limitOutlets( + oceanCandidates, + maximum + ); + addRejectedOutlets(oceanCandidates, selected, diagnostics); + return selected; + } + boolean[] oceanReachable = outletReachable(grid, oceanCandidates); + ArrayList inlandCandidates = inlandOutletCandidates(grid, oceanReachable); + sortInlandOutletCandidates(grid, inlandCandidates); + int inlandMaximum = oceanCandidates.isEmpty() + ? maximum + : Math.min(Math.max(1, maximum / 4), maximum - 1); + List selectedInland = styledInlandOutlets( + grid, + limitOutlets(inlandCandidates, inlandMaximum), + surface + ); + List selectedOcean = limitOutlets( + oceanCandidates, + maximum - selectedInland.size() + ); + addRejectedOutlets(oceanCandidates, selectedOcean, diagnostics); + addRejectedOutlets(inlandCandidates, selectedInland, diagnostics); + ArrayList selected = new ArrayList<>(selectedOcean.size() + selectedInland.size()); + selected.addAll(selectedOcean); + selected.addAll(selectedInland); + return List.copyOf(selected); + } + + private ArrayList oceanOutletCandidates(SampledGrid grid, boolean surface) { + ArrayList oceanCandidates = new ArrayList<>(); + for (GridNode land : grid.nodes()) { + if (land.terrain().ocean() || !land.terrain().transitAllowed() || !land.terrain().outletAllowed()) { + continue; + } + GridNode ocean = firstOceanNeighbor(grid, land); + if (ocean == null) { + continue; + } + HydrologyOceanBoundaryRefiner.Result boundary = refineOceanBoundary(land, ocean); + if (boundary == null + || !boundary.landwardTerrain().transitAllowed() + || !boundary.landwardTerrain().outletAllowed()) { + continue; + } + HydrologyFeatureType type = coastalOutletType(boundary.landwardTerrain()); + if (type == null) { + continue; + } + long outletId = HydrologyHash.mix(worldSeed, OUTLET_SALT, land.id(), ocean.id(), type.ordinal()); + int seaLevel = settingsSeaLevel(ocean.terrain()); + RiverOutlet outlet = new RiverOutlet( + outletId, + type, + land.id(), + withY(boundary.landwardPoint(), seaLevel), + withY(boundary.oceanPoint(), seaLevel), + seaLevel, + true + ); + oceanCandidates.add(new OutletCandidate(land.index(), ocean.index(), outlet)); + } + if (!surface && settings.outlets().inlandGrotto().enabled()) { + oceanCandidates.removeIf((OutletCandidate candidate) -> + candidate.outlet().connectionPoint().y() > settings.underground().maximumFluidY()); + } + return oceanCandidates; + } + + private List resolveSurfaceFallbackOutlets(SampledGrid grid) { + ArrayList candidates = oceanOutletCandidates(grid, true); + if (settings.outlets().surfaceSinkholesEnabled() + && settings.outlets().inlandGrotto().enabled()) { + candidates.addAll(styledInlandOutlets( + grid, + inlandOutletCandidates(grid, new boolean[grid.nodes().size()]), + true + )); + } + if (candidates.isEmpty()) { + return List.of(); + } + sortSurfaceFallbackOutletCandidates(grid, candidates); + int maximumTrials = Math.max( + OPTIONAL_SOURCE_REJECTIONS_PER_TARGET, + Math.multiplyExact(settings.outlets().maximumPerTile(), 16) + ); + maximumTrials = Math.min(16, maximumTrials); + return limitOutlets(candidates, maximumTrials); + } + + private ArrayList inlandOutletCandidates( + SampledGrid grid, + boolean[] oceanReachable + ) { + ArrayList inlandCandidates = new ArrayList<>(); + for (GridNode node : grid.nodes()) { + if (!grid.owns(node.x(), node.z()) + || node.terrain().ocean() || !node.terrain().caveAvailable() + || !node.terrain().transitAllowed() || !node.terrain().outletAllowed() + || oceanReachable[node.index()]) { + continue; + } + InlandConnection connection = inlandConnection(node); + int poolY = clamp( + connection.terrain().caveFluidY(), + settings.underground().minimumFluidY(), + settings.underground().maximumFluidY() + ); + if (settings.outlets().surfaceSinkholesEnabled() + && node.terrain().naturalHeight() - settings.surface().maximumSurfaceInset() <= poolY) { + continue; + } + long outletId = HydrologyHash.mix( + worldSeed, + OUTLET_SALT, + node.id(), + connection.point().x(), + connection.point().z(), + HydrologyFeatureType.INLAND_GROTTO.ordinal() + ); + RiverOutlet outlet = new RiverOutlet( + outletId, + HydrologyFeatureType.INLAND_GROTTO, + node.id(), + new HydrologyPoint(node.x(), node.terrain().naturalHeight(), node.z()), + withY(connection.point(), poolY), + settingsSeaLevel(node.terrain()), + false + ); + inlandCandidates.add(new OutletCandidate(node.index(), -1, outlet)); + } + return inlandCandidates; + } + + private void sortInlandOutletCandidates( + SampledGrid grid, + List inlandCandidates + ) { + inlandCandidates.sort(Comparator + .comparing((OutletCandidate candidate) -> !isDrainageBasin(grid, candidate.landIndex())) + .thenComparingInt((OutletCandidate candidate) -> grid.node(candidate.landIndex()).terrain().naturalHeight()) + .thenComparingLong((OutletCandidate candidate) -> candidate.outlet().id())); + } + + private void sortSurfaceFallbackOutletCandidates( + SampledGrid grid, + List inlandCandidates + ) { + HashMap capacities = new HashMap<>(inlandCandidates.size()); + for (OutletCandidate candidate : inlandCandidates) { + capacities.put(candidate.landIndex(), surfaceOutletCapacity(grid, candidate.landIndex())); + } + inlandCandidates.sort(Comparator + .comparing((OutletCandidate candidate) -> !isDrainageBasin(grid, candidate.landIndex())) + .thenComparingInt((OutletCandidate candidate) -> + capacities.get(candidate.landIndex()) > 0L ? 0 : 1) + .thenComparing(Comparator + .comparingLong((OutletCandidate candidate) -> capacities.get(candidate.landIndex())) + .reversed()) + .thenComparingInt((OutletCandidate candidate) -> + grid.node(candidate.landIndex()).terrain().naturalHeight()) + .thenComparingLong((OutletCandidate candidate) -> candidate.outlet().id())); + } + + private boolean isDrainageBasin(SampledGrid grid, int nodeIndex) { + GridNode node = grid.node(nodeIndex); + int height = node.terrain().naturalHeight(); + for (GridOffset offset : ROUTING_OFFSETS) { + GridNode neighbor = grid.nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z()); + if (neighbor != null + && !neighbor.terrain().ocean() + && neighbor.terrain().transitAllowed() + && neighbor.terrain().naturalHeight() < height) { + return false; + } + } + return true; + } + + private boolean[] outletReachable(SampledGrid grid, List outlets) { + boolean[] reachable = new boolean[grid.nodes().size()]; + int[] queue = new int[reachable.length]; + int readIndex = 0; + int writeIndex = 0; + for (OutletCandidate outlet : outlets) { + if (reachable[outlet.landIndex()]) { + continue; + } + reachable[outlet.landIndex()] = true; + queue[writeIndex++] = outlet.landIndex(); + } + while (readIndex < writeIndex) { + GridNode node = grid.node(queue[readIndex++]); + for (GridOffset offset : ROUTING_OFFSETS) { + GridNode neighbor = grid.nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z()); + if (neighbor == null || reachable[neighbor.index()] || neighbor.terrain().ocean() + || !neighbor.terrain().transitAllowed()) { + continue; + } + reachable[neighbor.index()] = true; + queue[writeIndex++] = neighbor.index(); + } + } + return reachable; + } + + private List styledInlandOutlets( + SampledGrid grid, + List candidates, + boolean surface + ) { + ArrayList styled = new ArrayList<>(candidates.size()); + for (OutletCandidate candidate : candidates) { + GridNode node = grid.node(candidate.landIndex()); + RiverOutlet outlet = candidate.outlet(); + HydrologyPoint landward = routeAnchor(node); + HydrologyTerrainSample landwardTerrain = Objects.requireNonNull( + sampleDetailed(landward.x(), landward.z()), + "Hydrology inland outlet anchor left sampled terrain" + ); + int connectionOffsetX = Integer.compare(outlet.connectionPoint().x(), outlet.landwardPoint().x()); + int connectionOffsetZ = Integer.compare(outlet.connectionPoint().z(), outlet.landwardPoint().z()); + HydrologyPoint connection = new HydrologyPoint( + landward.x() + connectionOffsetX, + outlet.connectionPoint().y(), + landward.z() + connectionOffsetZ + ); + int poolY = sampleGeometry( + HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL, + "", + connection.x(), + connection.z(), + 0L, + settings.underground().minimumFluidY(), + settings.underground().maximumFluidY() + ); + if (surface && settings.outlets().surfaceSinkholesEnabled()) { + poolY = Math.min( + poolY, + landwardTerrain.naturalHeight() - settings.surface().maximumSurfaceInset() - 1 + ); + poolY = Math.max( + poolY, + landwardTerrain.naturalHeight() - settings.routing().sampleSpacing() + ); + } + styled.add(new OutletCandidate( + candidate.landIndex(), + candidate.oceanIndex(), + new RiverOutlet( + outlet.id(), + outlet.type(), + outlet.drainageNodeId(), + withY(landward, landwardTerrain.naturalHeight()), + withY(connection, poolY), + outlet.seaLevel(), + outlet.directOcean() + ) + )); + } + return List.copyOf(styled); + } + + private InlandConnection inlandConnection(GridNode node) { + GridOffset offset = CARDINAL_OFFSETS.getFirst(); + long selectedTie = inlandConnectionTie(node, offset); + for (int index = 1; index < CARDINAL_OFFSETS.size(); index++) { + GridOffset candidate = CARDINAL_OFFSETS.get(index); + long candidateTie = inlandConnectionTie(node, candidate); + if (candidateTie < selectedTie) { + offset = candidate; + selectedTie = candidateTie; + } + } + HydrologyTerrainSample terrain = node.terrain(); + return new InlandConnection( + new HydrologyPoint( + node.x() + offset.x(), + terrain.caveFluidY(), + node.z() + offset.z() + ), + terrain + ); + } + + private long inlandConnectionTie(GridNode node, GridOffset offset) { + return HydrologyHash.mix( + worldSeed, + OUTLET_SALT, + node.id(), + node.x() + offset.x(), + node.z() + offset.z() + ); + } + + private GridNode firstOceanNeighbor(SampledGrid grid, GridNode land) { + ArrayList oceans = new ArrayList<>(); + for (GridOffset offset : CARDINAL_OFFSETS) { + GridNode neighbor = grid.nodeAt(land.gridX() + offset.x(), land.gridZ() + offset.z()); + if (neighbor != null && neighbor.terrain().ocean()) { + oceans.add(neighbor); + } + } + oceans.sort(Comparator.comparingLong(GridNode::id)); + return oceans.isEmpty() ? null : oceans.getFirst(); + } + + private HydrologyOceanBoundaryRefiner.Result refineOceanBoundary(GridNode land, GridNode ocean) { + List crossing = line(land.naturalPoint(), ocean.naturalPoint(), 1); + return HydrologyOceanBoundaryRefiner.refine(crossing, sampler, routingSampler); + } + + private HydrologyFeatureType coastalOutletType(HydrologyTerrainSample land) { + HydrologyPlannerSettings.Outlets outlets = settings.outlets(); + boolean cliff = land.naturalHeight() - settingsSeaLevel(land) + >= outlets.coastalCliffMinimumHeight() + || land.slope() >= outlets.coastalCliffMinimumHeight() * 0.5D; + if (cliff && outlets.coastalGrotto().enabled()) { + return HydrologyFeatureType.COASTAL_GROTTO; + } + return outlets.oceanEnabled() ? HydrologyFeatureType.MOUTH : null; + } + + private Comparator outletComparator(HydrologyTileKey key) { + long tileHash = HydrologyHash.mix(worldSeed, key.tileX(), key.tileZ(), OUTLET_SALT); + return Comparator.comparingLong((OutletCandidate candidate) -> HydrologyHash.mix(tileHash, candidate.outlet().id())); + } + + private void sortOutletCandidates( + HydrologyTileKey key, + SampledGrid grid, + boolean surface, + List candidates + ) { + if (!surface) { + candidates.sort(outletComparator(key)); + return; + } + HashMap capacities = new HashMap<>(candidates.size()); + for (OutletCandidate candidate : candidates) { + capacities.put(candidate.landIndex(), surfaceOutletCapacity(grid, candidate.landIndex())); + } + Comparator stable = outletComparator(key); + candidates.sort(Comparator + .comparingInt((OutletCandidate candidate) -> + capacities.get(candidate.landIndex()) > 0L ? 0 : 1) + .thenComparingInt(this::surfaceOutletPriority) + .thenComparing(Comparator + .comparingLong((OutletCandidate candidate) -> capacities.get(candidate.landIndex())) + .reversed()) + .thenComparing(stable)); + } + + private int surfaceOutletPriority(OutletCandidate candidate) { + return switch (candidate.outlet().type()) { + case MOUTH -> 0; + case COASTAL_GROTTO -> 1; + case INLAND_GROTTO -> 2; + default -> 3; + }; + } + + private long surfaceOutletCapacity(SampledGrid grid, int outletNodeIndex) { + boolean[] visited = new boolean[grid.nodes().size()]; + int[] distances = new int[grid.nodes().size()]; + Arrays.fill(distances, -1); + int[] queue = new int[visited.length]; + int readIndex = 0; + int writeIndex = 0; + visited[outletNodeIndex] = true; + distances[outletNodeIndex] = 0; + queue[writeIndex++] = outletNodeIndex; + long capacity = 0L; + while (readIndex < writeIndex) { + GridNode downstream = grid.node(queue[readIndex++]); + if (grid.owns(downstream.x(), downstream.z()) + && downstream.terrain().surfaceSourceAllowed() + && downstream.terrain().naturalHeight() >= settings.surface().sources().minimumElevation() + && distances[downstream.index()] + >= settings.routing().branching().minimumSurfaceCourseLength()) { + capacity = Math.addExact( + capacity, + 1_000_000L + + distances[downstream.index()] + + Math.max(0, downstream.terrain().naturalHeight() - settings.seaLevel()) + ); + } + for (GridOffset offset : ROUTING_OFFSETS) { + GridNode upstream = grid.nodeAt( + downstream.gridX() + offset.x(), + downstream.gridZ() + offset.z() + ); + if (upstream == null + || visited[upstream.index()] + || upstream.terrain().ocean() + || !upstream.terrain().transitAllowed()) { + continue; + } + visited[upstream.index()] = true; + distances[upstream.index()] = Math.addExact( + distances[downstream.index()], + settings.routing().sampleSpacing() + ); + queue[writeIndex++] = upstream.index(); + } + } + return capacity; + } + + private List limitOutlets(List candidates, int maximum) { + if (maximum <= 0 || candidates.isEmpty()) { + return List.of(); + } + ArrayList selected = new ArrayList<>(Math.min(maximum, candidates.size())); + int minimumSpacing = settings.routing().sampleSpacing() * 2; + long minimumSpacingSquared = (long) minimumSpacing * minimumSpacing; + for (OutletCandidate candidate : candidates) { + boolean tooClose = false; + for (OutletCandidate existing : selected) { + if (candidate.outlet().landwardPoint().distanceSquared2D(existing.outlet().landwardPoint()) + < minimumSpacingSquared) { + tooClose = true; + break; + } + } + if (!tooClose) { + selected.add(candidate); + if (selected.size() == maximum) { + break; + } + } + } + if (selected.isEmpty() && !candidates.isEmpty()) { + selected.add(candidates.getFirst()); + } + return List.copyOf(selected); + } + + private void addRejectedOutlets( + List candidates, + List selected, + List diagnostics + ) { + for (OutletCandidate candidate : candidates) { + boolean accepted = false; + for (OutletCandidate acceptedCandidate : selected) { + if (candidate.outlet().id() == acceptedCandidate.outlet().id()) { + accepted = true; + break; + } + } + if (!accepted) { + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(candidate.outlet().id(), DIAGNOSTIC_SALT), + HydrologyCandidateKind.OUTLET, + candidate.outlet().type(), + candidate.outlet().landwardPoint(), + HydrologyCandidateRejection.OUTLET_LIMIT + )); + } + } + } + + private RoutingPlan buildRouting( + SampledGrid grid, + List outlets + ) { + int nodeCount = grid.nodes().size(); + double[] potential = new double[nodeCount]; + int[] parent = new int[nodeCount]; + int[] outletIndex = new int[nodeCount]; + int[] minimumNeighborHeights = new int[nodeCount]; + for (GridNode node : grid.nodes()) { + minimumNeighborHeights[node.index()] = minimumNeighborHeight(grid, node); + } + Arrays.fill(potential, Double.POSITIVE_INFINITY); + Arrays.fill(parent, -1); + Arrays.fill(outletIndex, -1); + PriorityQueue queue = new PriorityQueue<>(); + for (int index = 0; index < outlets.size(); index++) { + OutletCandidate outlet = outlets.get(index); + potential[outlet.landIndex()] = 0D; + outletIndex[outlet.landIndex()] = index; + queue.add(new RoutingQueueEntry(outlet.landIndex(), 0D, index, Integer.MIN_VALUE)); + } + while (!queue.isEmpty()) { + RoutingQueueEntry currentEntry = queue.poll(); + if (currentEntry.potential() != potential[currentEntry.nodeIndex()] + || currentEntry.outletIndex() != outletIndex[currentEntry.nodeIndex()]) { + continue; + } + GridNode downstream = grid.node(currentEntry.nodeIndex()); + for (GridOffset offset : ROUTING_OFFSETS) { + GridNode upstream = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z()); + if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed()) { + continue; + } + double edgeCost = routeCost( + upstream, + downstream, + offset, + minimumNeighborHeights[downstream.index()] + ); + double candidatePotential = currentEntry.potential() + edgeCost; + int existingParent = parent[upstream.index()]; + boolean replace = candidatePotential < potential[upstream.index()] - 1.0E-9D; + if (!replace && StrictMath.abs(candidatePotential - potential[upstream.index()]) <= 1.0E-9D) { + replace = existingParent < 0 || downstream.id() < grid.node(existingParent).id(); + } + if (!replace) { + continue; + } + potential[upstream.index()] = candidatePotential; + parent[upstream.index()] = downstream.index(); + outletIndex[upstream.index()] = currentEntry.outletIndex(); + queue.add(new RoutingQueueEntry( + upstream.index(), + candidatePotential, + currentEntry.outletIndex(), + Integer.MIN_VALUE + )); + } + } + return new RoutingPlan( + potential, + parent, + outletIndex, + routeLengths(grid, parent), + List.copyOf(outlets), + false, + false + ); + } + + private RoutingPlan requireOrganicSurface(RoutingPlan routing) { + return new RoutingPlan( + routing.potential(), + routing.parent(), + routing.outletIndex(), + routing.routeLengths(), + routing.outlets(), + routing.containedSurface(), + true + ); + } + + private RoutingPlan buildContainedSurfaceRouting( + SampledGrid grid, + List outlets + ) { + int nodeCount = grid.nodes().size(); + double[] potential = new double[nodeCount]; + int[] parent = new int[nodeCount]; + int[] outletIndex = new int[nodeCount]; + int[] minimumNeighborHeights = new int[nodeCount]; + int[] requiredSurfaceHeads = new int[nodeCount]; + for (GridNode node : grid.nodes()) { + minimumNeighborHeights[node.index()] = minimumNeighborHeight(grid, node); + } + Arrays.fill(potential, Double.POSITIVE_INFINITY); + Arrays.fill(parent, -1); + Arrays.fill(outletIndex, -1); + Arrays.fill(requiredSurfaceHeads, Integer.MAX_VALUE); + PriorityQueue queue = new PriorityQueue<>(); + for (int index = 0; index < outlets.size(); index++) { + OutletCandidate outlet = outlets.get(index); + int requiredSurfaceHead = minimumSurfaceRoutingHead(grid.node(outlet.landIndex())); + if (requiredSurfaceHead > maximumSurfaceRoutingHead(grid.node(outlet.landIndex()))) { + continue; + } + potential[outlet.landIndex()] = 0D; + outletIndex[outlet.landIndex()] = index; + requiredSurfaceHeads[outlet.landIndex()] = requiredSurfaceHead; + queue.add(new RoutingQueueEntry(outlet.landIndex(), 0D, index, requiredSurfaceHead)); + } + while (!queue.isEmpty()) { + RoutingQueueEntry currentEntry = queue.poll(); + if (currentEntry.potential() != potential[currentEntry.nodeIndex()] + || currentEntry.outletIndex() != outletIndex[currentEntry.nodeIndex()] + || currentEntry.requiredSurfaceHead() != requiredSurfaceHeads[currentEntry.nodeIndex()]) { + continue; + } + GridNode downstream = grid.node(currentEntry.nodeIndex()); + for (GridOffset offset : ROUTING_OFFSETS) { + GridNode upstream = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z()); + if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed()) { + continue; + } + int requiredSurfaceHead = currentEntry.requiredSurfaceHead(); + if (maximumSurfaceRoutingHead(upstream) < requiredSurfaceHead) { + continue; + } + requiredSurfaceHead = Math.max(requiredSurfaceHead, minimumSurfaceRoutingHead(upstream)); + double edgeCost = routeCost( + upstream, + downstream, + offset, + minimumNeighborHeights[downstream.index()] + ); + double candidatePotential = currentEntry.potential() + edgeCost; + int existingParent = parent[upstream.index()]; + boolean lowerSurfaceRequirement = requiredSurfaceHead < requiredSurfaceHeads[upstream.index()]; + boolean equalSurfaceRequirement = requiredSurfaceHead == requiredSurfaceHeads[upstream.index()]; + boolean replace = lowerSurfaceRequirement || equalSurfaceRequirement + && candidatePotential < potential[upstream.index()] - 1.0E-9D; + if (!replace + && equalSurfaceRequirement + && StrictMath.abs(candidatePotential - potential[upstream.index()]) <= 1.0E-9D) { + replace = existingParent < 0 || downstream.id() < grid.node(existingParent).id(); + } + if (!replace) { + continue; + } + potential[upstream.index()] = candidatePotential; + parent[upstream.index()] = downstream.index(); + outletIndex[upstream.index()] = currentEntry.outletIndex(); + requiredSurfaceHeads[upstream.index()] = requiredSurfaceHead; + queue.add(new RoutingQueueEntry( + upstream.index(), + candidatePotential, + currentEntry.outletIndex(), + requiredSurfaceHead + )); + } + } + return new RoutingPlan( + potential, + parent, + outletIndex, + routeLengths(grid, parent), + List.copyOf(outlets), + true, + true + ); + } + + private int minimumSurfaceRoutingHead(GridNode node) { + int permittedIncision = permittedSurfaceIncision(node.terrain()); + return Math.subtractExact(node.terrain().naturalHeight(), permittedIncision); + } + + private int permittedSurfaceIncision(HydrologyTerrainSample terrain) { + return permittedSurfaceIncision( + settings.surface().maximumIncision(), + terrain.incisionMultiplier() + ); + } + + static int permittedSurfaceIncision(int maximumIncision, double incisionMultiplier) { + return Math.min( + maximumIncision, + (int) StrictMath.floor(maximumIncision * incisionMultiplier) + ); + } + + private int maximumSurfaceRoutingHead(GridNode node) { + return Math.subtractExact( + node.terrain().naturalHeight(), + settings.surface().maximumSurfaceInset() + ); + } + + private double routeCost( + GridNode upstream, + GridNode downstream, + GridOffset offset, + int valleyFloor + ) { + HydrologyPlannerSettings.Routing routing = settings.routing(); + double baseDistance = routing.sampleSpacing() * (offset.x() != 0 && offset.z() != 0 ? StrictMath.sqrt(2D) : 1D); + double downstreamRise = Math.max(0D, downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight()); + double valleyCost = Math.max(0D, downstream.terrain().naturalHeight() - valleyFloor) * routing.valleyPreference(); + double policyCost = upstream.terrain().routingCost() + downstream.terrain().routingCost(); + double policyMultiplier = Math.max(0.01D, + (upstream.terrain().routingMultiplier() + downstream.terrain().routingMultiplier()) * 0.5D); + double slopeCost = (upstream.terrain().slope() + downstream.terrain().slope()) + * 0.5D * routing.slopePenalty(); + double attractionDiscount = routing.confluenceAttraction() + * Math.max(0D, upstream.terrain().naturalHeight() - downstream.terrain().naturalHeight()); + return Math.max(0.001D, + (baseDistance + downstreamRise * routing.uphillPenalty() + valleyCost + slopeCost + policyCost) + * policyMultiplier - attractionDiscount); + } + + private int minimumNeighborHeight(SampledGrid grid, GridNode node) { + int minimum = node.terrain().naturalHeight(); + for (GridOffset offset : ROUTING_OFFSETS) { + GridNode neighbor = grid.nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z()); + if (neighbor != null && !neighbor.terrain().ocean()) { + minimum = Math.min(minimum, neighbor.terrain().naturalHeight()); + } + } + return minimum; + } + + private SourceSelection selectSources( + HydrologyTileKey key, + SampledGrid grid, + RoutingPlan routing, + boolean surface, + boolean enforceGlobalSpacing, + List diagnostics, + Map routingContexts + ) { + HydrologyPlannerSettings.Source sourceSettings = surface + ? settings.surface().sources() + : settings.underground().sources(); + if (!sourceSettings.enabled()) { + return SourceSelection.empty(surface); + } + long sourceSalt = surface ? SURFACE_SOURCE_SALT : UNDERGROUND_SOURCE_SALT; + ArrayList candidates = new ArrayList<>(); + boolean hasRequiredCandidate = false; + for (GridNode node : grid.nodes()) { + if (!grid.owns(node.x(), node.z())) { + continue; + } + HydrologyTerrainSample terrain = node.terrain(); + boolean allowed = surface ? terrain.surfaceSourceAllowed() : terrain.undergroundSourceAllowed(); + boolean required = surface ? terrain.surfaceSourceRequired() : terrain.undergroundSourceRequired(); + if (!allowed || surface && terrain.naturalHeight() < sourceSettings.minimumElevation()) { + continue; + } + double weight = surface ? terrain.surfaceSourceWeight() : terrain.undergroundSourceWeight(); + if (weight <= 0D && !required) { + continue; + } + long stable = sourceStableId(sourceSalt, node.x(), node.z()); + if (routing.outlets().isEmpty()) { + addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.NO_LEGAL_OUTLET, diagnostics); + continue; + } + if (!Double.isFinite(routing.potential()[node.index()]) || routing.parent()[node.index()] < 0) { + addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.NO_DRAINAGE_PATH, diagnostics); + continue; + } + if (!sourceOutletAllowed(node, routing, surface)) { + addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.NO_LEGAL_OUTLET, diagnostics); + continue; + } + if (!surface && settings.underground().connectToExistingCaves() && !terrain.caveAvailable()) { + addSourceDiagnostic(node, false, stable, HydrologyCandidateRejection.CAVE_CONTAINMENT, diagnostics); + continue; + } + int routeLength = routing.routeLengths()[node.index()]; + if (routeLength <= 0 || routeLength > settings.routing().maximumRouteLength()) { + addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.ROUTE_LIMIT, diagnostics); + continue; + } + if (routeLength < settings.routing().branching().minimumCourseLength(surface)) { + addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.COURSE_TOO_SHORT, diagnostics); + continue; + } + double score = (required ? 1.0E15D : 0D) + + weight * 1.0E9D + + terrain.naturalHeight() * 1.0E5D + + routing.potential()[node.index()] + + HydrologyHash.unit(stable); + candidates.add(new SourceCandidate(node.index(), stable, score, required)); + hasRequiredCandidate |= required; + } + int target = expectedCount(sourceSettings.density(), HydrologyHash.mix( + worldSeed, + sourceSalt, + key.tileX(), + key.tileZ() + )); + int requiredMinimum = hasRequiredCandidate + ? Math.max(1, sourceSettings.minimumPerTile()) + : 0; + target = Math.max(requiredMinimum, target); + int maximum = hasRequiredCandidate + ? Math.max(1, sourceSettings.maximumPerTile()) + : sourceSettings.maximumPerTile(); + target = Math.min(maximum, target); + target = effectiveSourceTarget(surface && !enforceGlobalSpacing, target, routing.outlets().size()); + Comparator candidateOrder = Comparator + .comparing(SourceCandidate::required) + .reversed(); + if (surface && routing.containedSurface()) { + candidateOrder = candidateOrder.thenComparing( + Comparator.comparingInt((SourceCandidate candidate) -> + routing.routeLengths()[candidate.nodeIndex()]) + .reversed() + ); + } + candidates.sort(candidateOrder + .thenComparing(Comparator.comparingDouble(SourceCandidate::score).reversed()) + .thenComparingLong(SourceCandidate::stableId)); + prioritizeInlandSource(candidates, routing); + int guaranteed = Math.min(target, requiredMinimum); + int maximumCoursesPerOutlet = surface ? 1 : Integer.MAX_VALUE; + SourceAdmissionSelection admission = selectSourceAdmissionsByOutlet( + candidates, + routing, + target, + guaranteed, + maximumCoursesPerOutlet, + (int candidateIndex) -> { + if (!enforceGlobalSpacing) { + return true; + } + SourceCandidate candidate = candidates.get(candidateIndex); + return globallyAdmittedSource( + grid.node(candidate.nodeIndex()), + sourceSettings, + sourceSalt, + surface, + routingContexts + ); + } + ); + int rejectionsPerTarget = surface + ? SURFACE_OPTIONAL_SOURCE_REJECTIONS_PER_TARGET + : OPTIONAL_SOURCE_REJECTIONS_PER_TARGET; + int maximumOptionalRejections = Math.max( + rejectionsPerTarget, + target * rejectionsPerTarget + ); + int admittedGuaranteed = Math.min(guaranteed, admission.selectedCandidateIndices().size()); + return new SourceSelection( + surface, + candidates, + admission, + admittedGuaranteed, + maximumOptionalRejections + ); + } + + static int effectiveSourceTarget(boolean outletBounded, int requestedTarget, int outletCount) { + if (requestedTarget < 0 || outletCount < 0) { + throw new IllegalArgumentException("Source target bounds cannot be negative."); + } + return outletBounded ? Math.min(requestedTarget, outletCount) : requestedTarget; + } + + private SourceAdmissionSelection selectSourceAdmissionsByOutlet( + List candidates, + RoutingPlan routing, + int target, + int guaranteed, + int maximumCoursesPerOutlet, + IntPredicate globallyAdmitted + ) { + int candidateCount = candidates.size(); + ArrayList selected = new ArrayList<>(Math.min(candidateCount, target)); + boolean[] selectedCandidates = new boolean[candidateCount]; + boolean[] spacingRejectedCandidates = new boolean[candidateCount]; + boolean[] evaluatedCandidates = new boolean[candidateCount]; + int[] outletIndices = new int[candidateCount]; + HashMap selectedByOutlet = new HashMap<>(); + for (int candidateIndex = 0; candidateIndex < candidateCount; candidateIndex++) { + outletIndices[candidateIndex] = routing.outletIndex()[candidates.get(candidateIndex).nodeIndex()]; + } + for (int candidateIndex = 0; + candidateIndex < candidateCount && selected.size() < target; + candidateIndex++) { + int outletIndex = outletIndices[candidateIndex]; + if (selectedByOutlet.getOrDefault(outletIndex, 0) >= maximumCoursesPerOutlet) { + continue; + } + evaluatedCandidates[candidateIndex] = true; + if (!globallyAdmitted.test(candidateIndex)) { + spacingRejectedCandidates[candidateIndex] = true; + continue; + } + selected.add(candidateIndex); + selectedCandidates[candidateIndex] = true; + selectedByOutlet.merge(outletIndex, 1, Math::addExact); + } + for (int candidateIndex = 0; + candidateIndex < candidateCount && selected.size() < guaranteed; + candidateIndex++) { + if (selectedCandidates[candidateIndex]) { + continue; + } + int outletIndex = outletIndices[candidateIndex]; + if (selectedByOutlet.getOrDefault(outletIndex, 0) >= maximumCoursesPerOutlet) { + continue; + } + selected.add(candidateIndex); + selectedCandidates[candidateIndex] = true; + spacingRejectedCandidates[candidateIndex] = false; + selectedByOutlet.merge(outletIndex, 1, Math::addExact); + } + return new SourceAdmissionSelection( + target, + selected, + selectedCandidates, + spacingRejectedCandidates, + evaluatedCandidates, + globallyAdmitted, + outletIndices, + maximumCoursesPerOutlet + ); + } + + private void prioritizeInlandSource(List candidates, RoutingPlan routing) { + if (candidates.size() < 2) { + return; + } + boolean required = candidates.getFirst().required(); + for (int candidateIndex = 0; candidateIndex < candidates.size(); candidateIndex++) { + SourceCandidate candidate = candidates.get(candidateIndex); + if (candidate.required() != required) { + break; + } + if (!isInlandSource(candidate, routing)) { + continue; + } + if (candidateIndex > 0) { + candidates.addFirst(candidates.remove(candidateIndex)); + } + return; + } + } + + private boolean isInlandSource(SourceCandidate candidate, RoutingPlan routing) { + if (candidate == null) { + return false; + } + int outletIndex = routing.outletIndex()[candidate.nodeIndex()]; + return outletIndex >= 0 + && routing.outlets().get(outletIndex).outlet().type() == HydrologyFeatureType.INLAND_GROTTO; + } + + static SourceAdmissionSelection selectSourceAdmissions( + int candidateCount, + int target, + int guaranteed, + IntPredicate globallyAdmitted + ) { + if (candidateCount < 0 || target < 0 || guaranteed < 0 || guaranteed > target) { + throw new IllegalArgumentException("Source admission bounds are invalid."); + } + Objects.requireNonNull(globallyAdmitted, "globallyAdmitted"); + ArrayList selected = new ArrayList<>(Math.min(candidateCount, target)); + boolean[] selectedCandidates = new boolean[candidateCount]; + boolean[] spacingRejectedCandidates = new boolean[candidateCount]; + boolean[] evaluatedCandidates = new boolean[candidateCount]; + for (int candidateIndex = 0; + candidateIndex < candidateCount && selected.size() < target; + candidateIndex++) { + evaluatedCandidates[candidateIndex] = true; + if (!globallyAdmitted.test(candidateIndex)) { + spacingRejectedCandidates[candidateIndex] = true; + continue; + } + selected.add(candidateIndex); + selectedCandidates[candidateIndex] = true; + } + for (int candidateIndex = 0; + candidateIndex < candidateCount && selected.size() < guaranteed; + candidateIndex++) { + if (selectedCandidates[candidateIndex]) { + continue; + } + selected.add(candidateIndex); + selectedCandidates[candidateIndex] = true; + spacingRejectedCandidates[candidateIndex] = false; + } + return new SourceAdmissionSelection( + target, + selected, + selectedCandidates, + spacingRejectedCandidates, + evaluatedCandidates, + globallyAdmitted, + new int[candidateCount], + Integer.MAX_VALUE + ); + } + + private boolean globallyAdmittedSource( + GridNode candidate, + HydrologyPlannerSettings.Source sourceSettings, + long sourceSalt, + boolean surface, + Map routingContexts + ) { + int minimumSpacing = sourceSettings.minimumSpacing(); + boolean required = surface + ? candidate.terrain().surfaceSourceRequired() + : candidate.terrain().undergroundSourceRequired(); + if (minimumSpacing <= 0 || required) { + return true; + } + SourcePriority candidatePriority = sourcePriority(candidate.x(), candidate.z(), candidate.terrain(), sourceSalt, surface); + int sampleSpacing = settings.routing().sampleSpacing(); + int latticeRadius = (int) StrictMath.ceil(minimumSpacing / (double) sampleSpacing); + for (int offsetZ = -latticeRadius; offsetZ <= latticeRadius; offsetZ++) { + for (int offsetX = -latticeRadius; offsetX <= latticeRadius; offsetX++) { + if (offsetX == 0 && offsetZ == 0) { + continue; + } + long deltaX = (long) offsetX * sampleSpacing; + long deltaZ = (long) offsetZ * sampleSpacing; + if (StrictMath.hypot(deltaX, deltaZ) >= minimumSpacing) { + continue; + } + long neighborX = (long) candidate.x() + deltaX; + long neighborZ = (long) candidate.z() + deltaZ; + if (neighborX < Integer.MIN_VALUE || neighborX > Integer.MAX_VALUE + || neighborZ < Integer.MIN_VALUE || neighborZ > Integer.MAX_VALUE) { + continue; + } + int x = (int) neighborX; + int z = (int) neighborZ; + HydrologyTileKey owner = HydrologyTileKey.fromBlock(x, z, settings.routing().tileSize()); + SourceRoutingContext context = routingContexts.computeIfAbsent(owner, this::sourceRoutingContext); + GridNode neighbor = context.grid().nodeAtWorld(x, z); + if (neighbor == null) { + throw new IllegalStateException("Source coordinate is absent from its owner routing lattice."); + } + HydrologyTerrainSample terrain = neighbor.terrain(); + if (!rawSourceEligible(terrain, sourceSettings, surface)) { + continue; + } + if (!routeViable(neighbor, context, surface)) { + continue; + } + SourcePriority neighborPriority = sourcePriority(x, z, terrain, sourceSalt, surface); + if (compareSourcePriority(neighborPriority, candidatePriority) > 0) { + return false; + } + } + } + return true; + } + + private SourceRoutingContext sourceRoutingContext(HydrologyTileKey key) { + return routingContexts.get(key, this::compileSourceRoutingContext); + } + + private SourceRoutingContext compileSourceRoutingContext(HydrologyTileKey key) { + SampledGrid grid = sample(key); + ArrayList diagnostics = new ArrayList<>(); + List surfaceOutlets = resolveOutlets(key, grid, true, diagnostics); + RoutingPlan surfaceRouting = buildRouting(grid, surfaceOutlets); + List undergroundOutlets = settings.underground().enabled() + ? resolveOutlets(key, grid, false, diagnostics) + : surfaceOutlets; + RoutingPlan undergroundRouting = settings.underground().enabled() + ? buildRouting(grid, undergroundOutlets) + : surfaceRouting; + return new SourceRoutingContext( + grid, + surfaceRouting, + undergroundRouting, + List.copyOf(diagnostics) + ); + } + + private boolean routeViable(GridNode node, SourceRoutingContext context, boolean surface) { + RoutingPlan routing = context.routing(surface); + if (routing.outlets().isEmpty() + || !Double.isFinite(routing.potential()[node.index()]) + || routing.parent()[node.index()] < 0 + || !sourceOutletAllowed(node, routing, surface)) { + return false; + } + int routeLength = routing.routeLengths()[node.index()]; + return routeLength >= settings.routing().branching().minimumCourseLength(surface) + && routeLength <= settings.routing().maximumRouteLength(); + } + + private boolean sourceOutletAllowed(GridNode node, RoutingPlan routing, boolean surface) { + int outletIndex = routing.outletIndex()[node.index()]; + if (outletIndex < 0 || outletIndex >= routing.outlets().size()) { + return false; + } + RiverOutlet outlet = routing.outlets().get(outletIndex).outlet(); + return !surface + || outlet.type() != HydrologyFeatureType.INLAND_GROTTO + || settings.outlets().surfaceSinkholesEnabled(); + } + + private boolean rawSourceEligible( + HydrologyTerrainSample terrain, + HydrologyPlannerSettings.Source sourceSettings, + boolean surface + ) { + if (terrain.ocean() || !terrain.transitAllowed()) { + return false; + } + boolean allowed = surface ? terrain.surfaceSourceAllowed() : terrain.undergroundSourceAllowed(); + boolean required = surface ? terrain.surfaceSourceRequired() : terrain.undergroundSourceRequired(); + if (!allowed || surface && terrain.naturalHeight() < sourceSettings.minimumElevation()) { + return false; + } + if (!surface && settings.underground().connectToExistingCaves() && !terrain.caveAvailable()) { + return false; + } + double weight = surface ? terrain.surfaceSourceWeight() : terrain.undergroundSourceWeight(); + return required || weight > 0D; + } + + private SourcePriority sourcePriority( + int x, + int z, + HydrologyTerrainSample terrain, + long sourceSalt, + boolean surface + ) { + return new SourcePriority( + surface ? terrain.surfaceSourceRequired() : terrain.undergroundSourceRequired(), + surface ? terrain.surfaceSourceWeight() : terrain.undergroundSourceWeight(), + terrain.naturalHeight(), + sourceStableId(sourceSalt, x, z), + x, + z + ); + } + + private long sourceStableId(long sourceSalt, int x, int z) { + return HydrologyHash.mix(worldSeed, sourceSalt, x, z); + } + + private int compareSourcePriority(SourcePriority first, SourcePriority second) { + int requiredComparison = Boolean.compare(first.required(), second.required()); + if (requiredComparison != 0) { + return requiredComparison; + } + int weightComparison = Double.compare(first.weight(), second.weight()); + if (weightComparison != 0) { + return weightComparison; + } + int stableComparison = Long.compareUnsigned(first.stableId(), second.stableId()); + if (stableComparison != 0) { + return stableComparison; + } + int heightComparison = Integer.compare(first.naturalHeight(), second.naturalHeight()); + if (heightComparison != 0) { + return heightComparison; + } + int xComparison = Integer.compare(first.x(), second.x()); + return xComparison != 0 ? xComparison : Integer.compare(first.z(), second.z()); + } + + private static void addSourceDiagnostic( + GridNode node, + boolean surface, + long stableId, + HydrologyCandidateRejection rejection, + List diagnostics + ) { + int y = surface ? node.terrain().naturalHeight() : node.terrain().caveFluidY(); + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(stableId, DIAGNOSTIC_SALT, rejection.ordinal()), + HydrologyCandidateKind.SOURCE, + surface ? HydrologyFeatureType.SURFACE_POOL : HydrologyFeatureType.UNDERGROUND_POOL, + new HydrologyPoint(node.x(), y, node.z()), + rejection + )); + } + + private int expectedCount(double density, long stable) { + int count = (int) StrictMath.floor(density); + double remainder = density - count; + return count + (HydrologyHash.unit(stable) < remainder ? 1 : 0); + } + + private int[] routeLengths(SampledGrid grid, int[] parent) { + int[] lengths = new int[parent.length]; + for (int sourceIndex = 0; sourceIndex < parent.length; sourceIndex++) { + lengths[sourceIndex] = routeLength(sourceIndex, grid, parent); + } + return lengths; + } + + private int routeLength(int sourceIndex, SampledGrid grid, int[] parent) { + int current = sourceIndex; + int steps = 0; + double length = 0D; + while (parent[current] >= 0) { + int next = parent[current]; + GridNode currentNode = grid.node(current); + GridNode nextNode = grid.node(next); + length += StrictMath.hypot(currentNode.x() - nextNode.x(), currentNode.z() - nextNode.z()); + if (length > settings.routing().maximumRouteLength() || ++steps > settings.routing().maximumRouteNodes()) { + return Integer.MAX_VALUE; + } + current = next; + } + return (int) StrictMath.ceil(length); + } + + private int[] countContributions(List sources, RoutingPlan routing) { + int[] counts = new int[routing.parent().length]; + for (int source : sources) { + int current = source; + int steps = 0; + while (routing.parent()[current] >= 0 && steps++ < settings.routing().maximumRouteNodes()) { + counts[current]++; + current = routing.parent()[current]; + } + } + return counts; + } + + private CompiledGraph compileGraph( + HydrologyTileKey key, + SampledGrid grid, + RoutingPlan routing, + int[] surfaceContributions, + int[] undergroundContributions, + Map> refinedEdges + ) { + HashSet includedNodeIndices = new HashSet<>(); + ArrayList edges = new ArrayList<>(); + HashMap edgeByUpstream = new HashMap<>(); + for (GridNode upstream : grid.nodes()) { + int downstreamIndex = routing.parent()[upstream.index()]; + if (downstreamIndex < 0 || !Double.isFinite(routing.potential()[upstream.index()])) { + continue; + } + boolean acceptedCourseEdge = surfaceContributions[upstream.index()] > 0 + || undergroundContributions[upstream.index()] > 0; + if (!acceptedCourseEdge) { + continue; + } + GridNode downstream = grid.node(downstreamIndex); + RiverOutlet outlet = routing.outlets().get(routing.outletIndex()[upstream.index()]).outlet(); + long edgeId = HydrologyHash.mix(worldSeed, EDGE_SALT, upstream.id(), downstream.id(), outlet.id()); + List centerline = refinedEdges.get(edgeId); + int transverseCandidates = ROUTE_TRANSVERSE_CANDIDATES; + if (centerline == null) { + HydrologyPoint upstreamAnchor = routeAnchor(upstream); + HydrologyPoint downstreamAnchor = routeAnchor(downstream); + HydrologyPoint continuation = 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, + routing.containedSurface() + ); + centerline = refinedEdgeCache.get( + refinedEdgeKey, + ignored -> refineEdge( + upstream.id(), + downstream.id(), + upstreamAnchor, + downstreamAnchor, + continuation, + transverseCandidates, + routing.containedSurface() + ) + ); + refinedEdges.put(edgeId, centerline); + } + if (centerline.isEmpty()) { + HydrologyPoint upstreamAnchor = routeAnchor(upstream); + HydrologyPoint downstreamAnchor = routeAnchor(downstream); + HydrologyPoint continuation = 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(), + ROUTE_FALLBACK_TRANSVERSE_CANDIDATES, + routing.containedSurface() + ); + centerline = refinedEdgeCache.get( + refinedEdgeKey, + ignored -> refineEdge( + upstream.id(), + downstream.id(), + upstreamAnchor, + downstreamAnchor, + continuation, + ROUTE_FALLBACK_TRANSVERSE_CANDIDATES, + routing.containedSurface() + ) + ); + refinedEdges.put(edgeId, centerline); + } + if (centerline.isEmpty() && !routing.containedSurface()) { + centerline = constrainedTerrainFallbackEdge( + routeAnchor(upstream), + routeAnchor(downstream) + ); + refinedEdges.put(edgeId, centerline); + } + if (centerline.isEmpty()) { + continue; + } + double cost = routing.potential()[upstream.index()] - routing.potential()[downstreamIndex]; + DrainageEdge edge = new DrainageEdge( + edgeId, + upstream.id(), + downstream.id(), + outlet.id(), + cost, + surfaceContributions[upstream.index()], + undergroundContributions[upstream.index()], + centerline + ); + edges.add(edge); + edgeByUpstream.put(upstream.index(), edge); + includedNodeIndices.add(upstream.index()); + includedNodeIndices.add(downstream.index()); + } + ArrayList nodes = new ArrayList<>(includedNodeIndices.size()); + for (GridNode node : grid.nodes()) { + if (!includedNodeIndices.contains(node.index())) { + continue; + } + int outletIndex = routing.outletIndex()[node.index()]; + RiverOutlet outlet = routing.outlets().get(outletIndex).outlet(); + HydrologyPoint anchor = routeAnchor(node); + HydrologyTerrainSample terrain = Objects.requireNonNull( + sampleLandBasis(anchor.x(), anchor.z()), + "Hydrology route anchor left natural land" + ); + nodes.add(new DrainageNode( + node.id(), + anchor.x(), + anchor.z(), + terrain, + routing.potential()[node.index()], + outlet.id() + )); + } + ArrayList outlets = new ArrayList<>(routing.outlets().size()); + for (OutletCandidate outletCandidate : routing.outlets()) { + outlets.add(outletCandidate.outlet()); + } + return new CompiledGraph(List.copyOf(nodes), List.copyOf(edges), List.copyOf(outlets), Map.copyOf(edgeByUpstream)); + } + + private CompiledGraph mergeGraphs(CompiledGraph first, CompiledGraph second) { + LinkedHashMap nodes = new LinkedHashMap<>(); + for (DrainageNode node : first.nodes()) { + nodes.put(node.id(), node); + } + for (DrainageNode node : second.nodes()) { + nodes.putIfAbsent(node.id(), node); + } + LinkedHashMap edges = new LinkedHashMap<>(); + for (DrainageEdge edge : first.edges()) { + edges.put(edge.id(), edge); + } + for (DrainageEdge edge : second.edges()) { + DrainageNode upstream = nodes.get(edge.upstreamNodeId()); + DrainageNode downstream = nodes.get(edge.downstreamNodeId()); + if (upstream != null + && downstream != null + && downstream.potential() < upstream.potential()) { + edges.putIfAbsent(edge.id(), edge); + } + } + LinkedHashMap outlets = new LinkedHashMap<>(); + for (RiverOutlet outlet : first.outlets()) { + outlets.put(outlet.id(), outlet); + } + for (RiverOutlet outlet : second.outlets()) { + outlets.putIfAbsent(outlet.id(), outlet); + } + HashMap edgeByUpstream = new HashMap<>(first.edgeByUpstream()); + for (Map.Entry entry : second.edgeByUpstream().entrySet()) { + if (entry.getValue().equals(edges.get(entry.getValue().id()))) { + edgeByUpstream.putIfAbsent(entry.getKey(), entry.getValue()); + } + } + return new CompiledGraph( + List.copyOf(nodes.values()), + List.copyOf(edges.values()), + List.copyOf(outlets.values()), + Map.copyOf(edgeByUpstream) + ); + } + + private HydrologyPoint edgeContinuation( + SampledGrid grid, + RoutingPlan routing, + GridNode downstream, + RiverOutlet outlet + ) { + int continuationIndex = routing.parent()[downstream.index()]; + if (continuationIndex >= 0) { + return routeAnchor(grid.node(continuationIndex)); + } + HydrologyPoint landward = outlet.landwardPoint(); + if (landward.x() != downstream.x() || landward.z() != downstream.z()) { + return landward; + } + return outlet.connectionPoint(); + } + + private HydrologyPoint routeAnchor(GridNode node) { + PlanningSamples samples = planningSamples.get(); + if (samples != null) { + HydrologyPoint cached = samples.routeAnchors.get(node.id()); + if (cached != null) { + return cached; + } + } + int spacing = settings.routing().sampleSpacing(); + HydrologyPlannerSettings.Meanders meanders = settings.geometry().meanders(); + double maximumOffsetRatio = Math.min( + ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO, + meanders.maximumOffsetRatio() * 0.625D + ); + if (maximumOffsetRatio <= 0D) { + return node.naturalPoint(); + } + double primaryWavelength = meanders.primaryWavelength() * 5D; + double detailWavelength = meanders.detailWavelength() * 8D; + double primaryX = routeAnchorX.noiseSigned( + node.x() / primaryWavelength, + node.z() / primaryWavelength + ); + double primaryZ = routeAnchorZ.noiseSigned( + node.x() / primaryWavelength, + node.z() / primaryWavelength + ); + double detailX = routeAnchorX.noiseSigned( + node.x() / detailWavelength + 31.75D, + node.z() / detailWavelength - 19.25D + ); + double detailZ = routeAnchorZ.noiseSigned( + node.x() / detailWavelength - 27.5D, + node.z() / detailWavelength + 43.5D + ); + double vectorX = primaryX * 0.72D + detailX * 0.28D; + double vectorZ = primaryZ * 0.72D + detailZ * 0.28D; + double vectorLength = StrictMath.hypot(vectorX, vectorZ); + if (vectorLength < 0.1D) { + double angle = HydrologyHash.unit(HydrologyHash.mix(worldSeed, node.id(), ROUTE_ANCHOR_X_SALT)) + * StrictMath.PI * 2D; + vectorX = StrictMath.cos(angle); + vectorZ = StrictMath.sin(angle); + vectorLength = 1D; + } + double maximumOffset = spacing * maximumOffsetRatio; + double displacement = maximumOffset * (0.62D + Math.min(1D, vectorLength) * 0.38D); + int desiredX = (int) StrictMath.round(node.x() + vectorX / vectorLength * displacement); + int desiredZ = (int) StrictMath.round(node.z() + vectorZ / vectorLength * displacement); + int searchRadius = Math.max(settings.routing().refinementSpacing(), spacing / 5); + int innerRadius = Math.max(1, searchRadius / 2); + int[][] offsets = { + {0, 0}, + {innerRadius, 0}, {-innerRadius, 0}, {0, innerRadius}, {0, -innerRadius}, + {innerRadius, innerRadius}, {innerRadius, -innerRadius}, + {-innerRadius, innerRadius}, {-innerRadius, -innerRadius}, + {searchRadius, 0}, {-searchRadius, 0}, {0, searchRadius}, {0, -searchRadius}, + {node.x() - desiredX, node.z() - desiredZ} + }; + HydrologyPoint selected = node.naturalPoint(); + double selectedScore = anchorScore( + node, + selected.x(), + selected.z(), + desiredX, + desiredZ, + maximumOffsetRatio + ); + for (int[] offset : offsets) { + int x = desiredX + offset[0]; + int z = desiredZ + offset[1]; + double score = anchorScore(node, x, z, desiredX, desiredZ, maximumOffsetRatio); + if (score < selectedScore) { + HydrologyTerrainSample terrain = sampleLandBasis(x, z); + selected = new HydrologyPoint(x, terrain.naturalHeight(), z); + selectedScore = score; + } + } + if (samples != null) { + samples.routeAnchors.put(node.id(), selected); + } + return selected; + } + + private double anchorScore( + GridNode node, + int x, + int z, + int desiredX, + int desiredZ, + double maximumOffsetRatio + ) { + HydrologyTerrainSample terrain = sampleLandBasis(x, z); + if (terrain == null || !terrain.transitAllowed()) { + return Double.POSITIVE_INFINITY; + } + double distanceFromDesired = StrictMath.hypot(x - desiredX, z - desiredZ); + double distanceFromNode = StrictMath.hypot(x - node.x(), z - node.z()); + if (distanceFromNode > settings.routing().sampleSpacing() * maximumOffsetRatio + || !traversableHop(node.naturalPoint(), new HydrologyPoint(x, terrain.naturalHeight(), z))) { + return Double.POSITIVE_INFINITY; + } + double score = terrain.naturalHeight() * settings.routing().valleyPreference() + + terrain.slope() * settings.routing().slopePenalty() + + terrain.routingCost() * terrain.routingMultiplier() + + distanceFromDesired * 2.4D + + distanceFromNode * 0.08D; + long tie = HydrologyHash.mix(worldSeed, node.id(), x, z); + return score + HydrologyHash.unit(tie) * 1.0E-6D; + } + + private List refineEdge( + long upstreamId, + long downstreamId, + HydrologyPoint upstream, + HydrologyPoint downstream, + HydrologyPoint continuation, + int transverseCandidates, + boolean containedSurface + ) { + int refinement = containedSurface + ? Math.max(1, settings.routing().refinementSpacing() / 2) + : settings.routing().refinementSpacing(); + int effectiveTransverseCandidates = transverseCandidates; + double deltaX = downstream.x() - upstream.x(); + double deltaZ = downstream.z() - upstream.z(); + double distance = StrictMath.hypot(deltaX, deltaZ); + int steps = Math.max(1, (int) StrictMath.ceil(distance / refinement)); + Direction directTangent = direction(upstream.x(), upstream.z(), downstream.x(), downstream.z()); + Direction startTangent = directTangent; + Direction endTangent = direction(downstream.x(), downstream.z(), continuation.x(), continuation.z()); + if (endTangent.x() == 0D && endTangent.z() == 0D) { + endTangent = startTangent; + } + HydrologyTerrainSample upstreamTerrain = sampleLandBasis(upstream.x(), upstream.z()); + HydrologyTerrainSample downstreamTerrain = sampleLandBasis(downstream.x(), downstream.z()); + int transitionThreshold = settings.hydraulics().waterfallMinimumDrop(); + boolean elevationTransition = Math.abs(upstream.y() - downstream.y()) >= transitionThreshold + || upstreamTerrain != null && upstreamTerrain.slope() >= transitionThreshold + || downstreamTerrain != null && downstreamTerrain.slope() >= transitionThreshold; + if (elevationTransition) { + endTangent = directTangent; + } + double wormBlend = elevationTransition || containedSurface ? 0D : routeWormBlend(); + double tangentMagnitude = distance * (ROUTE_TANGENT_SCALE + wormBlend * 0.42D); + if (wormBlend > 0D) { + startTangent = routeWormDirection(upstream, startTangent, wormBlend); + endTangent = routeWormDirection(downstream, endTangent, wormBlend); + } + ArrayList> layers = new ArrayList<>(steps + 1); + for (int step = 0; step <= steps; step++) { + if (step == 0) { + layers.add(List.of(routeEndpoint(upstream, startTangent))); + continue; + } + if (step == steps) { + layers.add(List.of(routeEndpoint(downstream, endTangent))); + continue; + } + double progress = step / (double) steps; + RoutePosition nominal = routePosition( + upstream, + downstream, + startTangent, + endTangent, + tangentMagnitude, + distance, + progress, + elevationTransition || containedSurface ? 0D : 1D + ); + List candidates = routeCandidates( + upstreamId, + downstreamId, + nominal, + progress, + refinement, + elevationTransition ? 0 : effectiveTransverseCandidates, + containedSurface + ); + if (candidates.isEmpty()) { + return List.of(); + } + layers.add(candidates); + } + return solveTerrainRoute(layers, distance / steps, containedSurface); + } + + private double routeWormBlend() { + HydrologyPlannerSettings.Meanders meanders = settings.geometry().meanders(); + double strength = meanders.primaryStrength() + meanders.detailStrength() * 0.35D; + if (strength <= 0D || meanders.maximumOffsetRatio() <= 0D) { + return 0D; + } + return Math.min(0.18D, meanders.maximumOffsetRatio() * (0.08D + strength * 0.05D)); + } + + private Direction routeWormDirection(HydrologyPoint point, Direction fallback, double blend) { + HydrologyPlannerSettings.Meanders meanders = settings.geometry().meanders(); + double wavelength = Math.max( + settings.routing().sampleSpacing() * 2D, + meanders.primaryWavelength() * 2.5D + ); + double fieldX = routeWormPrimary.noiseSigned(point.x() / wavelength, point.z() / wavelength); + double fieldZ = routeWormDetail.noiseSigned( + point.x() / wavelength + 37.25D, + point.z() / wavelength - 21.75D + ); + double fieldLength = StrictMath.hypot(fieldX, fieldZ); + if (fieldLength < 0.05D) { + return fallback; + } + fieldX /= fieldLength; + fieldZ /= fieldLength; + if (fieldX * fallback.x() + fieldZ * fallback.z() < 0D) { + fieldX = -fieldX; + fieldZ = -fieldZ; + } + double blendedX = fallback.x() * (1D - blend) + fieldX * blend; + double blendedZ = fallback.z() * (1D - blend) + fieldZ * blend; + double blendedLength = StrictMath.hypot(blendedX, blendedZ); + return blendedLength <= 0D + ? fallback + : new Direction(blendedX / blendedLength, blendedZ / blendedLength); + } + + private List constrainedTerrainFallbackEdge( + HydrologyPoint upstream, + HydrologyPoint downstream + ) { + if (crossesTerrainCrevasse(upstream, downstream)) { + return List.of(); + } + double deltaX = downstream.x() - upstream.x(); + double deltaZ = downstream.z() - upstream.z(); + double distance = StrictMath.hypot(deltaX, deltaZ); + if (distance <= 0D) { + return List.of(); + } + int refinement = settings.routing().refinementSpacing(); + double perpendicularX = -deltaZ / distance; + double perpendicularZ = deltaX / distance; + List direct = line(upstream, downstream, refinement); + ArrayList points = new ArrayList<>(direct.size()); + for (int index = 0; index < direct.size(); index++) { + HydrologyPoint point = direct.get(index); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null || !terrain.transitAllowed()) { + return List.of(); + } + if (index > 0 && index < direct.size() - 1 + && hasLateralRouteFreedom(point, perpendicularX, perpendicularZ, refinement)) { + return List.of(); + } + points.add(new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z())); + } + return traversableRoute(points) && !containsTerrainPit(points) + ? List.copyOf(points) + : List.of(); + } + + private boolean hasLateralRouteFreedom( + HydrologyPoint point, + double perpendicularX, + double perpendicularZ, + int refinement + ) { + for (int direction : List.of(-1, 1)) { + int x = (int) StrictMath.round(point.x() + perpendicularX * refinement * direction); + int z = (int) StrictMath.round(point.z() + perpendicularZ * refinement * direction); + HydrologyTerrainSample terrain = sampleLandBasis(x, z); + if (terrain != null && terrain.transitAllowed()) { + return true; + } + } + return false; + } + + private boolean containsTerrainPit(List route) { + int threshold = settings.hydraulics().waterfallMinimumDrop(); + for (int pointIndex = 1; pointIndex < route.size() - 1; pointIndex++) { + HydrologyPoint previous = route.get(pointIndex - 1); + HydrologyPoint point = route.get(pointIndex); + HydrologyPoint next = route.get(pointIndex + 1); + if (previous.y() - point.y() >= threshold && next.y() - point.y() >= threshold) { + return true; + } + } + return false; + } + + private RouteCandidate routeEndpoint( + HydrologyPoint point, + Direction tangent + ) { + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null || !terrain.transitAllowed()) { + return new RouteCandidate( + point, + 0D, + Double.POSITIVE_INFINITY, + Double.POSITIVE_INFINITY, + tangent, + true + ); + } + double terrainScore = routeTerrainScore(terrain); + return new RouteCandidate( + new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()), + 0D, + terrainScore, + terrainScore, + tangent, + true + ); + } + + private RoutePosition routePosition( + HydrologyPoint upstream, + HydrologyPoint downstream, + Direction startTangent, + Direction endTangent, + double tangentMagnitude, + double distance, + double progress, + double meanderScale + ) { + double tangentX = hermiteDerivative( + upstream.x(), + downstream.x(), + startTangent.x() * tangentMagnitude, + endTangent.x() * tangentMagnitude, + progress + ); + double tangentZ = hermiteDerivative( + upstream.z(), + downstream.z(), + startTangent.z() * tangentMagnitude, + endTangent.z() * tangentMagnitude, + progress + ); + double tangentLength = StrictMath.hypot(tangentX, tangentZ); + Direction tangent = tangentLength <= 0D + ? startTangent + : new Direction(tangentX / tangentLength, tangentZ / tangentLength); + double baseX = hermite( + upstream.x(), + downstream.x(), + startTangent.x() * tangentMagnitude, + endTangent.x() * tangentMagnitude, + progress + ); + double baseZ = hermite( + upstream.z(), + downstream.z(), + startTangent.z() * tangentMagnitude, + endTangent.z() * tangentMagnitude, + progress + ); + double envelope = StrictMath.pow(StrictMath.sin(StrictMath.PI * progress), 2D); + HydrologyPlannerSettings.Meanders meanders = settings.geometry().meanders(); + double primary = routeWormPrimary.noiseSigned( + baseX / meanders.primaryWavelength(), + baseZ / meanders.primaryWavelength() + ); + double detail = routeWormDetail.noiseSigned( + baseX / meanders.detailWavelength(), + baseZ / meanders.detailWavelength() + ); + double worm = distance * meanders.maximumOffsetRatio() * envelope * meanderScale * ( + primary * meanders.primaryStrength() + + detail * meanders.detailStrength() + ); + double anchorRatio = Math.min( + ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO, + meanders.maximumOffsetRatio() * 0.625D + ); + double transverseRatio = settings.routing().refinementSpacing() + / (double) settings.routing().sampleSpacing(); + double maximumWorm = distance * Math.max(0D, 0.5D - anchorRatio - transverseRatio); + worm = Math.max(-maximumWorm, Math.min(maximumWorm, worm)); + return new RoutePosition( + baseX - tangent.z() * worm, + baseZ + tangent.x() * worm, + baseX, + baseZ, + tangent + ); + } + + private List routeCandidates( + long upstreamId, + long downstreamId, + RoutePosition nominal, + double progress, + int refinement, + int transverseCandidates, + boolean containedSurface + ) { + 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); + HydrologyTerrainSample terrain = sampleLandBasis(x, z); + if (terrain == null + || !terrain.transitAllowed() + || containedSurface && !containedSurfaceRouteCandidate( + x, + z, + terrain, + nominal.tangent() + )) { + continue; + } + long packed = RiverFootprint.pack(x, z); + double localScore = routeTerrainScore(terrain) + + StrictMath.abs(offset) * 0.18D + + HydrologyHash.unit(HydrologyHash.mix(worldSeed, upstreamId, downstreamId, x, z)) * 1.0E-6D; + RouteCandidate candidate = new RouteCandidate( + new HydrologyPoint(x, terrain.naturalHeight(), z), + offset, + localScore, + routeTerrainScore(terrain), + nominal.tangent(), + true + ); + RouteCandidate current = candidates.get(packed); + if (current == null || candidate.localScore() < current.localScore()) { + candidates.put(packed, candidate); + } + } + int fallbackX = (int) StrictMath.round(nominal.baseX()); + int fallbackZ = (int) StrictMath.round(nominal.baseZ()); + HydrologyTerrainSample fallbackTerrain = sampleLandBasis(fallbackX, fallbackZ); + if (candidates.isEmpty() + && fallbackTerrain != null + && fallbackTerrain.transitAllowed() + && (!containedSurface || containedSurfaceRouteCandidate( + fallbackX, + fallbackZ, + fallbackTerrain, + nominal.tangent() + ))) { + long packed = RiverFootprint.pack(fallbackX, fallbackZ); + double fallbackOffset = (nominal.baseX() - nominal.x()) * -nominal.tangent().z() + + (nominal.baseZ() - nominal.z()) * nominal.tangent().x(); + double localScore = routeTerrainScore(fallbackTerrain) + + StrictMath.abs(fallbackOffset) * 0.18D + + HydrologyHash.unit(HydrologyHash.mix( + worldSeed, + upstreamId, + downstreamId, + fallbackX, + fallbackZ + )) * 1.0E-6D; + RouteCandidate fallback = new RouteCandidate( + new HydrologyPoint(fallbackX, fallbackTerrain.naturalHeight(), fallbackZ), + fallbackOffset, + localScore, + routeTerrainScore(fallbackTerrain), + nominal.tangent(), + true + ); + RouteCandidate current = candidates.get(packed); + if (current == null || fallback.localScore() < current.localScore()) { + candidates.put(packed, fallback); + } + } + return List.copyOf(candidates.values()); + } + + private List unresolvedSurfaceRouteCandidates( + SampledGrid 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( + worldSeed, + upstreamId, + downstreamId, + x, + z + )) * 1.0E-6D; + RouteCandidate candidate = new RouteCandidate( + new HydrologyPoint(x, estimate.height(), z), + 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()); + } + + private RouteTerrainEstimate routeTerrainEstimate(SampledGrid 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 = bilinear( + northwest.naturalHeight(), + northeast.naturalHeight(), + southwest.naturalHeight(), + southeast.naturalHeight(), + progressX, + progressZ + ); + double terrainScore = bilinear( + routeTerrainScore(northwest), + routeTerrainScore(northeast), + routeTerrainScore(southwest), + routeTerrainScore(southeast), + progressX, + progressZ + ); + return new RouteTerrainEstimate((int) StrictMath.round(height), terrainScore); + } + + private 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; + } + + private RouteCandidate resolveRouteCandidate(RouteCandidate candidate, boolean containedSurface) { + if (candidate.terrainResolved()) { + return candidate; + } + HydrologyPoint point = candidate.point(); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null + || !terrain.transitAllowed() + || containedSurface && !containedSurfaceRouteCandidate( + point.x(), + point.z(), + terrain, + candidate.tangent() + )) { + return new RouteCandidate( + point, + candidate.offset(), + Double.POSITIVE_INFINITY, + Double.POSITIVE_INFINITY, + candidate.tangent(), + true + ); + } + double terrainScore = routeTerrainScore(terrain); + double exactLocalScore = candidate.localScore() - candidate.terrainScore() + terrainScore; + return new RouteCandidate( + new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()), + candidate.offset(), + exactLocalScore, + terrainScore, + candidate.tangent(), + true + ); + } + + private double routeTerrainScore(HydrologyTerrainSample terrain) { + return terrain.naturalHeight() * settings.routing().valleyPreference() + + terrain.slope() * settings.routing().slopePenalty() + + terrain.routingCost() * terrain.routingMultiplier(); + } + + private boolean containedSurfaceRouteCandidate( + int x, + int z, + HydrologyTerrainSample terrain, + Direction tangent + ) { + if (terrain.slope() >= settings.hydraulics().waterfallMinimumDrop()) { + return true; + } + int bankDistance = surfaceBankDistance(settings.surface().maximumWidth()); + int head = Math.subtractExact( + terrain.naturalHeight(), + settings.surface().maximumSurfaceInset() + ); + HydrologyPoint point = new HydrologyPoint(x, terrain.naturalHeight(), z); + return surfaceBankSupported( + point, + tangent, + bankDistance, + head, + Integer.MAX_VALUE + ) && surfaceBankSupported( + point, + tangent, + -bankDistance, + head, + Integer.MAX_VALUE + ); + } + + private List solveTerrainRoute( + List> layers, + double targetStepLength, + boolean containedSurface + ) { + ArrayList> availableLayers = new ArrayList<>(layers); + HashSet rejectedTransitions = new HashSet<>(); + while (true) { + RouteCandidate[] route = selectTerrainRoute( + availableLayers, + targetStepLength, + containedSurface, + rejectedTransitions + ); + if (route.length == 0) { + return List.of(); + } + if (!containedSurface) { + TerrainTransitionKey unsupportedTransition = unsupportedTerrainTransition(route); + if (unsupportedTransition != null) { + rejectedTransitions.add(unsupportedTransition); + continue; + } + } + int pitRemoval = 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) { + return List.of(); + } + return containedSurface ? List.copyOf(points) : smoothTerrainRoute(points); + } + } + + private RouteCandidate[] selectTerrainRoute( + List> layers, + double targetStepLength, + boolean containedSurface, + Set rejectedTransitions + ) { + if (containedSurface) { + return selectContainedSurfaceTerrainRoute(layers, targetStepLength); + } + int layerCount = layers.size(); + double[][] costs = new double[layerCount][]; + int[][] predecessors = new int[layerCount][]; + costs[0] = new double[]{layers.getFirst().getFirst().localScore()}; + predecessors[0] = new int[]{-1}; + for (int layerIndex = 1; layerIndex < layerCount; layerIndex++) { + List currentLayer = layers.get(layerIndex); + List previousLayer = layers.get(layerIndex - 1); + costs[layerIndex] = new double[currentLayer.size()]; + predecessors[layerIndex] = new int[currentLayer.size()]; + Arrays.fill(costs[layerIndex], Double.POSITIVE_INFINITY); + Arrays.fill(predecessors[layerIndex], -1); + for (int currentIndex = 0; currentIndex < currentLayer.size(); currentIndex++) { + RouteCandidate current = currentLayer.get(currentIndex); + for (int previousIndex = 0; previousIndex < previousLayer.size(); previousIndex++) { + if (!Double.isFinite(costs[layerIndex - 1][previousIndex])) { + continue; + } + RouteCandidate previous = previousLayer.get(previousIndex); + TerrainTransitionKey transitionKey = new TerrainTransitionKey( + previous.point(), + current.point() + ); + if (rejectedTransitions.contains(transitionKey)) { + continue; + } + double transition = routeTransitionGeometryCost(previous, current, targetStepLength); + if (!Double.isFinite(transition)) { + continue; + } + double cost = costs[layerIndex - 1][previousIndex] + current.localScore() + transition; + if (cost < costs[layerIndex][currentIndex]) { + costs[layerIndex][currentIndex] = cost; + predecessors[layerIndex][currentIndex] = previousIndex; + } + } + } + } + int selectedIndex = 0; + double selectedCost = costs[layerCount - 1][0]; + for (int candidateIndex = 1; candidateIndex < costs[layerCount - 1].length; candidateIndex++) { + if (costs[layerCount - 1][candidateIndex] < selectedCost) { + selectedIndex = candidateIndex; + selectedCost = costs[layerCount - 1][candidateIndex]; + } + } + if (!Double.isFinite(selectedCost)) { + return new RouteCandidate[0]; + } + RouteCandidate[] route = new RouteCandidate[layerCount]; + for (int layerIndex = layerCount - 1; layerIndex >= 0; layerIndex--) { + route[layerIndex] = layers.get(layerIndex).get(selectedIndex); + selectedIndex = predecessors[layerIndex][selectedIndex]; + } + return route; + } + + private TerrainTransitionKey unsupportedTerrainTransition(RouteCandidate[] route) { + for (int routeIndex = 0; routeIndex < route.length - 1; routeIndex++) { + RouteCandidate start = route[routeIndex]; + RouteCandidate end = route[routeIndex + 1]; + if (!traversableTerrainTransition(start.point(), end.point())) { + return new TerrainTransitionKey(start.point(), end.point()); + } + } + return null; + } + + private RouteCandidate[] selectContainedSurfaceTerrainRoute( + List> layers, + double targetStepLength + ) { + double maximumTurn = Math.max( + SURFACE_MAXIMUM_LOCAL_TURN_DEGREES, + settings.geometry().meanders().maximumTurnDegrees() + ); + return selectCurvatureAwareTerrainRoute( + layers, + targetStepLength, + true, + maximumTurn, + 1.25D + ); + } + + private RouteCandidate[] selectCurvatureAwareTerrainRoute( + List> layers, + double targetStepLength, + boolean containedSurface, + 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][][]; + for (int layerIndex = 0; layerIndex < layerCount; layerIndex++) { + int layerSize = layers.get(layerIndex).size(); + localPenalties[layerIndex] = new double[layerSize]; + if (!containedSurface && 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); + } + } + while (true) { + CurvatureRouteSelection selection = selectCurvatureAwareTerrainRoute( + layers, + targetStepLength, + maximumTurn, + turnCost, + localPenalties, + transitionCosts + ); + if (selection.route().length == 0) { + return selection.route(); + } + boolean evaluated = evaluateSelectedSurfaceCosts( + selection, + layers, + containedSurface, + localPenalties, + transitionCosts, + targetStepLength + ); + if (!evaluated) { + return selection.route(); + } + } + } + + private CurvatureRouteSelection selectCurvatureAwareTerrainRoute( + List> layers, + double targetStepLength, + double maximumTurn, + double turnCost, + double[][] localPenalties, + double[][][] transitionCosts + ) { + int layerCount = layers.size(); + double[][][] costs = new double[layerCount][][]; + int[][][] predecessors = new int[layerCount][][]; + List firstLayer = layers.getFirst(); + List secondLayer = layers.get(1); + costs[1] = new double[firstLayer.size()][secondLayer.size()]; + predecessors[1] = new int[firstLayer.size()][secondLayer.size()]; + for (int firstIndex = 0; firstIndex < firstLayer.size(); firstIndex++) { + Arrays.fill(costs[1][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] + ); + if (Double.isFinite(transition)) { + costs[1][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); + costs[layerIndex] = new double[previousLayer.size()][currentLayer.size()]; + predecessors[layerIndex] = new int[previousLayer.size()][currentLayer.size()]; + for (int previousIndex = 0; previousIndex < previousLayer.size(); previousIndex++) { + Arrays.fill(costs[layerIndex][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] + ); + if (!Double.isFinite(transition)) { + continue; + } + double localPenalty = resolvedLocalPenalty(localPenalties[layerIndex][currentIndex]); + for (int beforeIndex = 0; beforeIndex < beforeLayer.size(); beforeIndex++) { + double previousCost = costs[layerIndex - 1][beforeIndex][previousIndex]; + if (!Double.isFinite(previousCost)) { + continue; + } + RouteCandidate before = beforeLayer.get(beforeIndex); + double turn = routeTurnDegrees( + before.point(), + previous.point(), + current.point() + ); + if (turn > maximumTurn) { + continue; + } + double cost = previousCost + + current.localScore() + + localPenalty + + transition + + curvatureCost(turn, maximumTurn, turnCost); + if (cost < costs[layerIndex][previousIndex][currentIndex]) { + costs[layerIndex][previousIndex][currentIndex] = cost; + predecessors[layerIndex][previousIndex][currentIndex] = beforeIndex; + } + } + } + } + } + int lastLayerIndex = layerCount - 1; + int selectedPreviousIndex = -1; + int selectedCurrentIndex = -1; + double selectedCost = Double.POSITIVE_INFINITY; + for (int previousIndex = 0; previousIndex < costs[lastLayerIndex].length; previousIndex++) { + for (int currentIndex = 0; + currentIndex < costs[lastLayerIndex][previousIndex].length; + currentIndex++) { + if (costs[lastLayerIndex][previousIndex][currentIndex] < selectedCost) { + selectedCost = costs[lastLayerIndex][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); + } + + private boolean evaluateSelectedSurfaceCosts( + CurvatureRouteSelection selection, + List> layers, + boolean containedSurface, + 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, containedSurface); + ArrayList updatedLayer = new ArrayList<>(layers.get(layerIndex)); + updatedLayer.set(indices[layerIndex], resolved); + layers.set(layerIndex, List.copyOf(updatedLayer)); + route[layerIndex] = resolved; + evaluated = true; + } + if (!containedSurface) { + 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] = 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 = routeTransitionCost( + previous, + current, + targetStepLength, + containedSurface + ); + if (Double.isFinite(transition)) { + transition += surfaceRouteTransitionPenalty(previous, current, containedSurface); + } + transitionCosts[layerIndex][previousIndex][currentIndex] = transition; + evaluated = true; + } + return evaluated; + } + + private double resolvedLocalPenalty(double penalty) { + return Double.isNaN(penalty) ? 0D : penalty; + } + + private double resolvedTransitionCost( + RouteCandidate previous, + RouteCandidate current, + double targetStepLength, + double exactCost + ) { + return Double.isNaN(exactCost) + ? routeTransitionGeometryCost(previous, current, targetStepLength) + : exactCost; + } + + private double curvatureCost(double turn, double maximumTurn, double turnCost) { + double excess = Math.max(0D, turn - 8D); + return excess * excess * turnCost / maximumTurn; + } + + private double surfaceRouteTransitionPenalty( + RouteCandidate start, + RouteCandidate end, + boolean containedSurface + ) { + if (containedSurface) { + return 0D; + } + int minimumBankDistance = surfaceBankDistance(settings.surface().minimumWidth()); + int maximumBankDistance = surfaceBankDistance(settings.surface().maximumWidth()); + int maximumBankRise = Math.addExact( + settings.surface().maximumIncision(), + (int) StrictMath.ceil(settings.surface().minimumTerrainBlendWidth() * 0.5D) + ); + Direction tangent = 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 = rasterLine(start.point(), end.point()); + int sampleStride = Math.max(1, settings.routing().refinementSpacing() / 4); + for (int pointIndex = 0; pointIndex < raster.size(); pointIndex += sampleStride) { + HydrologyPoint point = raster.get(pointIndex); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null) { + return 1.0E12D; + } + int permittedIncision = permittedSurfaceIncision(terrain); + int ridgeExcess = Math.max( + 0, + terrain.naturalHeight() - endpointCeiling - permittedIncision + ); + penalty += (double) ridgeExcess * ridgeExcess * 65536D; + if (terrain.slope() >= settings.hydraulics().waterfallMinimumDrop()) { + continue; + } + int head = Math.subtractExact( + terrain.naturalHeight(), + settings.surface().maximumSurfaceInset() + ); + penalty += surfaceRouteBankPenalty( + point, + tangent, + minimumBankDistance, + maximumBankDistance, + head, + maximumBankRise + ); + } + if ((raster.size() - 1) % sampleStride != 0) { + HydrologyPoint point = raster.getLast(); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null) { + return 1.0E12D; + } + int permittedIncision = permittedSurfaceIncision(terrain); + int ridgeExcess = Math.max( + 0, + terrain.naturalHeight() - endpointCeiling - permittedIncision + ); + penalty += (double) ridgeExcess * ridgeExcess * 65536D; + if (terrain.slope() < settings.hydraulics().waterfallMinimumDrop()) { + int head = Math.subtractExact( + terrain.naturalHeight(), + settings.surface().maximumSurfaceInset() + ); + penalty += surfaceRouteBankPenalty( + point, + tangent, + minimumBankDistance, + maximumBankDistance, + head, + maximumBankRise + ); + } + } + return penalty; + } + + private int removeTerrainPits(List> layers, RouteCandidate[] route) { + int removed = 0; + for (int routeIndex = 1; routeIndex < route.length - 1; routeIndex++) { + RouteCandidate candidate = route[routeIndex]; + if (!isTerrainPit(candidate)) { + continue; + } + ArrayList candidates = new ArrayList<>(layers.get(routeIndex)); + candidates.remove(candidate); + if (candidates.isEmpty()) { + return -1; + } + layers.set(routeIndex, List.copyOf(candidates)); + removed++; + } + return removed; + } + + private boolean isTerrainPit(RouteCandidate candidate) { + HydrologyPoint point = candidate.point(); + Direction tangent = candidate.tangent(); + int refinement = settings.routing().refinementSpacing(); + int offsetX = (int) StrictMath.round(tangent.x() * refinement); + int offsetZ = (int) StrictMath.round(tangent.z() * refinement); + if (offsetX == 0 && offsetZ == 0) { + return false; + } + HydrologyTerrainSample before = sampleLandBasis(point.x() - offsetX, point.z() - offsetZ); + HydrologyTerrainSample after = sampleLandBasis(point.x() + offsetX, point.z() + offsetZ); + int threshold = settings.hydraulics().waterfallMinimumDrop(); + return before != null + && after != null + && before.naturalHeight() - point.y() >= threshold + && after.naturalHeight() - point.y() >= threshold; + } + + private List smoothTerrainRoute(List route) { + List current = List.copyOf(route); + List best = current; + double bestTurnScore = containedSurfaceTurnScore(current); + HydrologyPlannerSettings.Meanders meanders = settings.geometry().meanders(); + int maximumPasses = Math.max(8, meanders.smoothingPasses() * 4); + for (int pass = 0; pass < maximumPasses; pass++) { + ArrayList smoothed = new ArrayList<>(current.size()); + smoothed.add(current.getFirst()); + for (int pointIndex = 1; pointIndex < current.size() - 1; pointIndex++) { + HydrologyPoint previous = current.get(pointIndex - 1); + HydrologyPoint point = current.get(pointIndex); + HydrologyPoint next = current.get(pointIndex + 1); + int x = (int) StrictMath.round(previous.x() * 0.25D + point.x() * 0.5D + next.x() * 0.25D); + int z = (int) StrictMath.round(previous.z() * 0.25D + point.z() * 0.5D + next.z() * 0.25D); + HydrologyTerrainSample terrain = sampleLandBasis(x, z); + smoothed.add(terrain == null || !terrain.transitAllowed() + ? point + : new HydrologyPoint(x, terrain.naturalHeight(), z)); + } + smoothed.add(current.getLast()); + List candidate = withoutDuplicateRoutePoints(smoothed); + if (candidate.size() < 2 || !traversableRoute(candidate)) { + break; + } + current = candidate; + double candidateTurnScore = containedSurfaceTurnScore(candidate); + if (candidateTurnScore < bestTurnScore) { + best = candidate; + bestTurnScore = candidateTurnScore; + } + } + List smoothed = removeRouteHairpins(best, meanders.maximumTurnDegrees()); + return containsTerrainPit(smoothed) ? List.of() : smoothed; + } + + private List removeRouteHairpins(List route, double maximumTurnDegrees) { + ArrayList points = new ArrayList<>(route); + while (points.size() > 2) { + int sharpestIndex = sharpestRoutePoint(points); + if (sharpestIndex < 1 || routeTurnDegrees( + points.get(sharpestIndex - 1), + points.get(sharpestIndex), + points.get(sharpestIndex + 1) + ) <= maximumTurnDegrees) { + break; + } + HydrologyPoint previous = points.get(sharpestIndex - 1); + HydrologyPoint next = points.get(sharpestIndex + 1); + if (!traversableHop(previous, next) || crossesTerrainCrevasse(previous, next)) { + break; + } + points.remove(sharpestIndex); + } + return List.copyOf(points); + } + + private int sharpestRoutePoint(List route) { + int selectedIndex = -1; + double selectedTurn = 0D; + for (int pointIndex = 1; pointIndex < route.size() - 1; pointIndex++) { + double turn = routeTurnDegrees( + route.get(pointIndex - 1), + route.get(pointIndex), + route.get(pointIndex + 1) + ); + if (turn > selectedTurn) { + selectedIndex = pointIndex; + selectedTurn = turn; + } + } + return selectedIndex; + } + + private double maximumRouteTurnDegrees(List route) { + int sharpestIndex = sharpestRoutePoint(route); + return sharpestIndex < 1 ? 0D : routeTurnDegrees( + route.get(sharpestIndex - 1), + route.get(sharpestIndex), + route.get(sharpestIndex + 1) + ); + } + + private double routeTurnDegrees(HydrologyPoint previous, HydrologyPoint point, HydrologyPoint next) { + double incomingX = point.x() - previous.x(); + double incomingZ = point.z() - previous.z(); + double outgoingX = next.x() - point.x(); + double outgoingZ = next.z() - point.z(); + double incomingLength = StrictMath.hypot(incomingX, incomingZ); + double outgoingLength = StrictMath.hypot(outgoingX, outgoingZ); + if (incomingLength == 0D || outgoingLength == 0D) { + return 0D; + } + double cosine = (incomingX * outgoingX + incomingZ * outgoingZ) + / (incomingLength * outgoingLength); + return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, cosine)))); + } + + private List withoutDuplicateRoutePoints(List points) { + ArrayList unique = new ArrayList<>(points.size()); + for (HydrologyPoint point : points) { + if (unique.isEmpty() + || point.x() != unique.getLast().x() + || point.z() != unique.getLast().z()) { + unique.add(point); + } + } + return List.copyOf(unique); + } + + private boolean traversableRoute(List route) { + for (int pointIndex = 0; pointIndex < route.size() - 1; pointIndex++) { + HydrologyPoint start = route.get(pointIndex); + HydrologyPoint end = route.get(pointIndex + 1); + if (!traversableHop(start, end) || crossesTerrainCrevasse(start, end)) { + return false; + } + } + return true; + } + + private double routeTransitionCost( + RouteCandidate previous, + RouteCandidate current, + double targetStepLength, + boolean containedSurface + ) { + boolean supported = containedSurface + ? containedSurfaceSolverTransitionSupported(previous.point(), current.point()) + : traversableTerrainTransition(previous.point(), current.point()); + if (!supported) { + return Double.POSITIVE_INFINITY; + } + return routeTransitionGeometryCost(previous, current, targetStepLength); + } + + private double routeTransitionGeometryCost( + RouteCandidate previous, + RouteCandidate current, + double targetStepLength + ) { + double deltaX = current.point().x() - previous.point().x(); + double deltaZ = current.point().z() - previous.point().z(); + double length = StrictMath.hypot(deltaX, deltaZ); + if (length <= 0D || length > targetStepLength * 5D + 8D) { + return Double.POSITIVE_INFINITY; + } + double transverseChange = StrictMath.abs(current.offset() - previous.offset()); + double alignment = (deltaX * current.tangent().x() + deltaZ * current.tangent().z()) / length; + if (alignment < -0.5D) { + return Double.POSITIVE_INFINITY; + } + int rise = Math.max(0, current.point().y() - previous.point().y()); + return StrictMath.abs(length - targetStepLength) * 1.4D + + transverseChange * 5D + + (1D - alignment) * 32D + + rise * settings.routing().uphillPenalty(); + } + + private boolean traversableTerrainTransition(HydrologyPoint start, HydrologyPoint end) { + List crossing = rasterLine(start, end); + HydrologyTerrainSample startTerrain = sampleLandBasis(start.x(), start.z()); + HydrologyTerrainSample endTerrain = sampleLandBasis(end.x(), end.z()); + if (startTerrain == null || endTerrain == null) { + return false; + } + int boundaryHeight = Math.min(startTerrain.naturalHeight(), endTerrain.naturalHeight()); + int threshold = settings.hydraulics().waterfallMinimumDrop(); + for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { + HydrologyPoint point = crossing.get(pointIndex); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null + || !terrain.transitAllowed() + || boundaryHeight - terrain.naturalHeight() >= threshold) { + return false; + } + } + return true; + } + + private boolean containedSurfaceSolverTransitionSupported( + HydrologyPoint start, + HydrologyPoint end + ) { + List crossing = rasterLine(start, end); + HydrologyTerrainSample startTerrain = sampleLandBasis(start.x(), start.z()); + HydrologyTerrainSample endTerrain = sampleLandBasis(end.x(), end.z()); + if (startTerrain == null || endTerrain == null) { + return false; + } + int boundaryHeight = Math.min(startTerrain.naturalHeight(), endTerrain.naturalHeight()); + int threshold = settings.hydraulics().waterfallMinimumDrop(); + int surfaceInset = settings.surface().maximumSurfaceInset(); + int upstreamHead = Math.subtractExact(start.y(), surfaceInset); + int downstreamHead = Math.subtractExact(end.y(), surfaceInset); + Direction tangent = direction(start.x(), start.z(), end.x(), end.z()); + for (int pointIndex = 0; pointIndex < crossing.size(); pointIndex++) { + HydrologyPoint point = crossing.get(pointIndex); + HydrologyTerrainSample terrain; + if (pointIndex == 0) { + terrain = startTerrain; + } else if (pointIndex == crossing.size() - 1) { + terrain = endTerrain; + } else { + terrain = sampleLandBasis(point.x(), point.z()); + } + if (terrain == null || !terrain.transitAllowed()) { + return false; + } + if (pointIndex > 0 + && pointIndex < crossing.size() - 1 + && boundaryHeight - terrain.naturalHeight() >= threshold) { + return false; + } + double progress = crossing.size() < 2 + ? 0D + : pointIndex / (double) (crossing.size() - 1); + int head = (int) StrictMath.round( + upstreamHead + (downstreamHead - upstreamHead) * progress + ); + int permittedIncision = permittedSurfaceIncision(terrain); + if (terrain.naturalHeight() - head > permittedIncision + || !containedSurfaceRouteCandidate( + point.x(), + point.z(), + terrain, + tangent + )) { + return false; + } + } + return true; + } + + private boolean containedSurfaceTransitionSupported( + HydrologyPoint start, + HydrologyPoint end + ) { + Direction tangent = direction(start.x(), start.z(), end.x(), end.z()); + for (HydrologyPoint point : rasterLine(start, end)) { + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null + || !terrain.transitAllowed() + || !containedSurfaceRouteCandidate( + point.x(), + point.z(), + terrain, + tangent + )) { + return false; + } + } + return true; + } + + private boolean crossesTerrainCrevasse(HydrologyPoint start, HydrologyPoint end) { + List crossing = rasterLine(start, end); + if (crossing.size() < 3) { + return false; + } + HydrologyTerrainSample startTerrain = sampleLandBasis(start.x(), start.z()); + HydrologyTerrainSample endTerrain = sampleLandBasis(end.x(), end.z()); + if (startTerrain == null || endTerrain == null) { + return true; + } + int boundaryHeight = Math.min(startTerrain.naturalHeight(), endTerrain.naturalHeight()); + int threshold = settings.hydraulics().waterfallMinimumDrop(); + for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { + HydrologyPoint point = crossing.get(pointIndex); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null || boundaryHeight - terrain.naturalHeight() >= threshold) { + return true; + } + } + return false; + } + + private Direction direction(int startX, int startZ, int endX, int endZ) { + double x = endX - startX; + double z = endZ - startZ; + double length = StrictMath.hypot(x, z); + if (length <= 0D) { + return new Direction(0D, 0D); + } + return new Direction(x / length, z / length); + } + + private double hermite(double start, double end, double startTangent, double endTangent, double progress) { + double squared = progress * progress; + double cubed = squared * progress; + double startBasis = 2D * cubed - 3D * squared + 1D; + double startTangentBasis = cubed - 2D * squared + progress; + double endBasis = -2D * cubed + 3D * squared; + double endTangentBasis = cubed - squared; + return startBasis * start + + startTangentBasis * startTangent + + endBasis * end + + endTangentBasis * endTangent; + } + + private double hermiteDerivative( + double start, + double end, + double startTangent, + double endTangent, + double progress + ) { + double squared = progress * progress; + return (6D * squared - 6D * progress) * start + + (3D * squared - 4D * progress + 1D) * startTangent + + (-6D * squared + 6D * progress) * end + + (3D * squared - 2D * progress) * endTangent; + } + + private boolean traversableHop(HydrologyPoint start, HydrologyPoint end) { + List crossing = rasterLine(start, end); + for (int pointIndex = 1; pointIndex < crossing.size() - 1; pointIndex++) { + HydrologyPoint point = crossing.get(pointIndex); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null || terrain.ocean() || !terrain.transitAllowed()) { + return false; + } + } + return true; + } + + private void compileSurfaceCourses( + HydrologyTileKey key, + SampledGrid grid, + RoutingPlan routing, + CompiledGraph graph, + List sources, + List courses, + List diagnostics + ) { + LinkedHashMap> draftsByOutlet = new LinkedHashMap<>(); + for (int source : sources) { + GridNode sourceNode = grid.node(source); + long sourceCourseId = HydrologyHash.mix( + worldSeed, + COURSE_SALT, + SURFACE_SOURCE_SALT, + sourceNode.id() + ); + CoursePath path = coursePath(grid, routing, graph, source, true); + if (path == null) { + addCompiledSourceDiagnostic( + sourceNode, + true, + sourceCourseId, + HydrologyCandidateRejection.NO_DRAINAGE_PATH, + diagnostics + ); + continue; + } + long courseId = HydrologyHash.mix(sourceCourseId, path.outlet().id()); + HydrologyPoint trunkPoint = path.points().getLast(); + HydrologyTerrainSample trunkTerrain = Objects.requireNonNull( + sampleDetailed(trunkPoint.x(), trunkPoint.z()), + "Hydrology surface trunk left sampled terrain" + ); + String profileKey = chooseProfile(trunkTerrain, path.outlet().id()); + draftsByOutlet.computeIfAbsent(path.outlet().id(), (Long ignored) -> new ArrayList<>()) + .add(new SurfaceCourseDraft(sourceNode, courseId, profileKey, path)); + } + for (ArrayList outletDrafts : draftsByOutlet.values()) { + outletDrafts.sort(Comparator + .comparingDouble((SurfaceCourseDraft draft) -> edgeLength( + draft.path().edges(), + draft.path().edges().size() + )) + .reversed() + .thenComparingLong(SurfaceCourseDraft::courseId)); + RiverCourse mainCourse = null; + HashMap mainRejections = new HashMap<>(); + for (SurfaceCourseDraft draft : outletDrafts) { + SurfaceCourseBuild candidate = buildSurfaceCourse( + draft.courseId(), + draft.profileKey(), + draft.source(), + draft.path() + ); + if (candidate.course() == null) { + mainRejections.put(draft.courseId(), candidate.rejection()); + continue; + } + mainCourse = candidate.course(); + courses.add(mainCourse); + break; + } + if (mainCourse == null) { + for (SurfaceCourseDraft draft : outletDrafts) { + addCompiledSourceDiagnostic( + draft.source(), + true, + draft.courseId(), + mainRejections.get(draft.courseId()), + diagnostics + ); + } + } + } + } + + private SurfaceCourseBuild buildSurfaceCourse( + long courseId, + String profileKey, + GridNode source, + CoursePath initialPath + ) { + return buildSurfaceCourseAtPath(courseId, profileKey, source, initialPath); + } + + private SurfaceCourseBuild buildSurfaceCourseAtPath( + long courseId, + String profileKey, + GridNode source, + CoursePath path + ) { + int pairCount = path.points().size() - 1; + if (pairCount < 1) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT); + } + int[] widths = new int[pairCount]; + int[] depths = new int[pairCount]; + for (int index = 0; index < pairCount; index++) { + HydrologyPoint point = path.points().get(index); + HydrologyTerrainSample terrain = sampleBasis(point.x(), point.z()); + int discharge = Math.max(1, path.pairEdges().get(index).contributingSurfaceSources()); + widths[index] = scaledDimension( + sampleGeometry( + HydrologyGeometrySampler.Field.SURFACE_WIDTH, + profileKey, + point.x(), + point.z(), + 0L, + settings.surface().minimumWidth(), + settings.surface().maximumWidth() + ), + settings.surface().minimumWidth(), + settings.surface().maximumWidth(), + discharge, + terrain.widthMultiplier() + ); + depths[index] = scaledDimension( + sampleGeometry( + HydrologyGeometrySampler.Field.SURFACE_DEPTH, + profileKey, + point.x(), + point.z(), + 0L, + settings.surface().minimumDepth(), + settings.surface().maximumDepth() + ), + settings.surface().minimumDepth(), + settings.surface().maximumDepth(), + discharge, + terrain.depthMultiplier() + ); + } + int outletHead = outletHead(path.outlet()); + boolean surfaceSinkhole = path.reachesOutlet() && surfaceSinkhole(path.outlet()); + int minimumPathHead = surfaceSinkhole ? Math.addExact(outletHead, 1) : outletHead; + long hydraulicStableId = path.outlet().id(); + int[] maximumHeads = surfaceMaximumHeads(hydraulicStableId, profileKey, path, widths); + int[] heads = new int[path.points().size()]; + if (maximumHeads[0] < minimumPathHead) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE); + } + heads[0] = maximumHeads[0]; + for (int index = 1; index < path.points().size(); index++) { + if (maximumHeads[index] < minimumPathHead) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE); + } + heads[index] = Math.min(heads[index - 1], maximumHeads[index]); + } + int targetPoolLength = targetPoolLength(hydraulicStableId, profileKey, path); + poolHeads(path, heads, maximumHeads, targetPoolLength); + if (!distributeSurfaceHeadLoss(path, heads, maximumHeads, minimumPathHead)) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_HEAD_DISTRIBUTION); + } + if (!ridgeLengthAccepted(path, heads)) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_RIDGE_TUNNEL_LIMIT); + } + if (!surfaceHeadLossSupportedByTerrain(path, heads, widths, depths)) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_DROP_UNSUPPORTED); + } + if (!surfaceCorridorSupported(path, heads, widths)) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED); + } + ArrayList segments = new ArrayList<>(pairCount + 1); + int index = 0; + while (index < pairCount) { + int upstreamHead = heads[index]; + int downstreamHead = heads[index + 1]; + HydrologyFeatureType pairType = surfaceSegmentType( + path, + index, + upstreamHead, + downstreamHead, + widths[index], + depths[index] + ); + if (!isDirectMouthPair(path, index) + && addMixedSurfaceBoreSegments( + courseId, + index, + upstreamHead, + downstreamHead, + widths[index], + depths[index], + path.points().get(index), + path.points().get(index + 1), + segments + )) { + index++; + continue; + } + if (upstreamHead > downstreamHead + && pairType.isSurface() + && !isDirectMouthPair(path, index)) { + int runEnd = surfaceTransitionRunEnd(path, heads, widths, depths, index, targetPoolLength); + boolean added = addSurfaceTransitionRunSegments( + courseId, + index, + runEnd, + averageDimension(widths, index, runEnd), + averageDimension(depths, index, runEnd), + path, + heads, + segments + ); + if (!added) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_TRANSITION_UNSUPPORTED); + } + index = runEnd + 1; + continue; + } + List centerline = List.of( + withY(path.points().get(index), upstreamHead), + withY(path.points().get(index + 1), downstreamHead) + ); + boolean added = pairType.isSurface() + && isDirectMouthPair(path, index) + && downstreamHead < upstreamHead + ? addCoastalMouthDropSegments( + courseId, + index, + upstreamHead, + downstreamHead, + widths[index], + depths[index], + centerline, + true, + segments + ) + : addHydraulicSegments( + courseId, + index, + pairType, + upstreamHead, + downstreamHead, + widths[index], + depths[index], + centerline, + true, + segments + ); + if (!added) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SEGMENT_UNSUPPORTED); + } + index++; + } + if (surfaceSinkhole && heads[heads.length - 1] <= outletHead) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE); + } + if (path.reachesOutlet()) { + appendOutletSegments( + RiverCourseType.SURFACE, + courseId, + path, + heads[heads.length - 1], + widths, + depths, + segments + ); + } + containUnsupportedSurfaceSegments(segments); + containBriefSurfaceReopenings(segments); + smoothFlatSurfaceDimensions(segments); + if (!surfaceExposureAccepted(segments)) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE); + } + if (path.containedSurface() && !surfaceTransitionRecoveriesAccepted(segments)) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_TRANSITION_UNSUPPORTED); + } + if (path.organicSurfaceRequired() && !surfaceShapeAccepted(path.points())) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED); + } + int discharge = maximumSurfaceDischarge(path.edges()); + return SurfaceCourseBuild.accepted(new RiverCourse( + courseId, + RiverCourseType.SURFACE, + OptionalLong.of(source.id()), + OptionalLong.of(path.outlet().id()), + profileKey, + discharge, + path.edges(), + segments + )); + } + + private void containUnsupportedSurfaceSegments(List segments) { + int conversionLimit = segments.size(); + if (segments.size() >= 3 + && segments.get(segments.size() - 2).type() == HydrologyFeatureType.SINKHOLE + && segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO) { + conversionLimit -= 2; + } + for (int segmentIndex = 0; segmentIndex < conversionLimit; segmentIndex++) { + HydraulicSegment segment = segments.get(segmentIndex); + if (!segment.type().isSurface() || segment.type() == HydrologyFeatureType.MOUTH) { + continue; + } + boolean containedTerrain = false; + for (HydrologyPoint point : segment.centerline()) { + HydrologyTerrainSample terrain = sampleDetailed(point.x(), point.z()); + HydrologyTerrainSample basis = sampleBasis(point.x(), point.z()); + boolean finalTerrainExceedsBasisLimit = terrain != null + && basis != null + && terrain.naturalHeight() - point.y() > permittedSurfaceIncision(terrain) + && basis.naturalHeight() - point.y() <= permittedSurfaceIncision(basis); + boolean flatChannelExceedsLimit = terrain != null + && segment.type() == HydrologyFeatureType.SURFACE_POOL + && terrain.naturalHeight() - point.y() > permittedSurfaceIncision(terrain); + if (terrain == null + || terrain.naturalHeight() <= settings.seaLevel() + || finalTerrainExceedsBasisLimit + || flatChannelExceedsLimit) { + containedTerrain = true; + break; + } + } + if (!containedTerrain) { + continue; + } + HydrologyFeatureType containedType = segment.drop() > 0 + ? HydrologyFeatureType.UNDERGROUND_DROP + : HydrologyFeatureType.RIDGE_BORE; + segments.set(segmentIndex, new HydraulicSegment( + segment.id(), + segment.courseId(), + containedType, + segment.upstreamHeadY(), + segment.downstreamHeadY(), + segment.width(), + segment.depth(), + false, + segment.receivingPool(), + segment.centerline() + )); + } + } + + private boolean surfaceTransitionRecoveriesAccepted(List segments) { + double recovery = Double.POSITIVE_INFINITY; + boolean transitionActive = false; + for (int segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++) { + HydraulicSegment segment = segments.get(segmentIndex); + boolean surfaceDrop = segment.type().isSurface() && segment.type().isDrop(); + boolean coastalOutletDrop = surfaceDrop + && segmentIndex + 1 < segments.size() + && segments.get(segmentIndex + 1).type() == HydrologyFeatureType.MOUTH; + if (coastalOutletDrop) { + continue; + } + if (!segment.type().isSurface()) { + recovery = Double.POSITIVE_INFINITY; + transitionActive = false; + continue; + } + if (surfaceDrop) { + if (!transitionActive + && Double.isFinite(recovery) + && recovery < settings.hydraulics().minimumTargetPoolLength()) { + return false; + } + recovery = 0D; + transitionActive = !segment.receivingPool(); + continue; + } + if (transitionActive && segment.receivingPool()) { + transitionActive = false; + } else if (!transitionActive && Double.isFinite(recovery)) { + recovery += hydraulicSegmentLength(segment); + } + } + return true; + } + + private double hydraulicSegmentLength(HydraulicSegment segment) { + double length = 0D; + for (int pointIndex = 1; pointIndex < segment.centerline().size(); pointIndex++) { + HydrologyPoint previous = segment.centerline().get(pointIndex - 1); + HydrologyPoint current = segment.centerline().get(pointIndex); + length += StrictMath.hypot(current.x() - previous.x(), current.z() - previous.z()); + } + return length; + } + + private boolean surfaceExposureAccepted(List segments) { + int minimumExposedLength = settings.routing().branching().minimumSurfaceCourseLength(); + if (minimumExposedLength > 0 && (segments.isEmpty() + || !segments.getFirst().type().isSurface() + || segments.getFirst().type() == HydrologyFeatureType.MOUTH)) { + return false; + } + double exposedLength = 0D; + double exposedRunLength = 0D; + double longestExposedRunLength = 0D; + for (HydraulicSegment segment : segments) { + if (!segment.type().isSurface() || segment.type() == HydrologyFeatureType.MOUTH) { + exposedRunLength = 0D; + continue; + } + double length = hydraulicSegmentLength(segment); + exposedLength += length; + exposedRunLength += length; + longestExposedRunLength = Math.max(longestExposedRunLength, exposedRunLength); + } + int minimumContinuousLength = Math.min( + minimumExposedLength, + Math.multiplyExact(settings.routing().sampleSpacing(), 2) + ); + return exposedLength >= minimumExposedLength + && longestExposedRunLength >= minimumContinuousLength; + } + + private boolean surfaceShapeAccepted(List routedCenterline) { + List centerline = withoutDuplicateRoutePoints(routedCenterline); + if (centerline.size() < 3) { + return false; + } + double routedLength = centerlineLength(centerline); + HydrologyPoint start = centerline.getFirst(); + HydrologyPoint end = centerline.getLast(); + double directLength = StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); + if (directLength <= 0D + || routedLength / directLength > SURFACE_MAXIMUM_SINUOSITY) { + return false; + } + List sampled = resampleContainedSurfaceCenterline( + centerline, + Math.max(16D, settings.routing().refinementSpacing() * 4D) + ); + double lockedLength = 0D; + double currentLockedRun = 0D; + double longestLockedRun = 0D; + for (int pointIndex = 1; pointIndex < sampled.size(); pointIndex++) { + HydrologyPoint previous = sampled.get(pointIndex - 1); + HydrologyPoint point = sampled.get(pointIndex); + double deltaX = point.x() - previous.x(); + double deltaZ = point.z() - previous.z(); + double length = StrictMath.hypot(deltaX, deltaZ); + if (length <= 0D) { + continue; + } + double angle = StrictMath.toDegrees(StrictMath.atan2(deltaZ, deltaX)); + double remainder = Math.floorMod( + (int) StrictMath.round(angle * 1_000_000D), + 45_000_000 + ) / 1_000_000D; + double gridDistance = Math.min(remainder, 45D - remainder); + if (gridDistance <= SURFACE_GRID_LOCK_TOLERANCE_DEGREES) { + lockedLength += length; + currentLockedRun += length; + longestLockedRun = Math.max(longestLockedRun, currentLockedRun); + } else { + currentLockedRun = 0D; + } + } + if (routedLength >= 96D + && (lockedLength / routedLength > SURFACE_MAXIMUM_GRID_LOCKED_FRACTION + || longestLockedRun > SURFACE_MAXIMUM_GRID_LOCKED_RUN)) { + return false; + } + ArrayList turnAngles = new ArrayList<>(); + for (int pointIndex = 1; pointIndex < sampled.size() - 1; pointIndex++) { + double turn = routeTurnDegrees( + sampled.get(pointIndex - 1), + sampled.get(pointIndex), + sampled.get(pointIndex + 1) + ); + if (turn > SURFACE_MAXIMUM_RENDERED_TURN_DEGREES) { + return false; + } + turnAngles.add(turn); + } + if (turnAngles.isEmpty()) { + return false; + } + for (int turnIndex = 0; turnIndex < turnAngles.size(); turnIndex++) { + double turn = turnAngles.get(turnIndex); + double previous = turnIndex == 0 ? 0D : turnAngles.get(turnIndex - 1); + double next = turnIndex + 1 == turnAngles.size() ? 0D : turnAngles.get(turnIndex + 1); + if (turn > SURFACE_MAXIMUM_ISOLATED_TURN_DEGREES + && previous <= SURFACE_MAXIMUM_ISOLATED_NEIGHBOR_TURN_DEGREES + && next <= SURFACE_MAXIMUM_ISOLATED_NEIGHBOR_TURN_DEGREES) { + return false; + } + } + turnAngles.sort(Double::compare); + int percentileIndex = (int) StrictMath.ceil(0.95D * turnAngles.size()) - 1; + double percentile = turnAngles.get( + Math.max(0, Math.min(percentileIndex, turnAngles.size() - 1)) + ); + if (percentile > SURFACE_MAXIMUM_P95_TURN_DEGREES) { + return false; + } + return true; + } + + private double centerlineLength(List points) { + double length = 0D; + for (int pointIndex = 1; pointIndex < points.size(); pointIndex++) { + HydrologyPoint previous = points.get(pointIndex - 1); + HydrologyPoint current = points.get(pointIndex); + length += StrictMath.hypot(current.x() - previous.x(), current.z() - previous.z()); + } + return length; + } + + private List resampleContainedSurfaceCenterline( + List points, + double spacing + ) { + double[] cumulative = new double[points.size()]; + for (int pointIndex = 1; pointIndex < points.size(); pointIndex++) { + HydrologyPoint previous = points.get(pointIndex - 1); + HydrologyPoint point = points.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 <= spacing) { + return points; + } + int samples = Math.max(2, (int) StrictMath.floor(totalLength / spacing)); + ArrayList sampled = new ArrayList<>(samples + 2); + int segmentIndex = 0; + for (double distance = 0D; distance < totalLength; distance += spacing) { + while (segmentIndex < points.size() - 2 + && cumulative[segmentIndex + 1] < distance) { + segmentIndex++; + } + HydrologyPoint start = points.get(segmentIndex); + HydrologyPoint end = points.get(segmentIndex + 1); + double segmentLength = cumulative[segmentIndex + 1] - cumulative[segmentIndex]; + double progress = segmentLength <= 0D + ? 0D + : (distance - cumulative[segmentIndex]) / segmentLength; + HydrologyPoint point = new HydrologyPoint( + (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress), + (int) StrictMath.round(start.y() + (end.y() - start.y()) * progress), + (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress) + ); + if (sampled.isEmpty() + || point.x() != sampled.getLast().x() + || point.z() != sampled.getLast().z()) { + sampled.add(point); + } + } + HydrologyPoint last = points.getLast(); + HydrologyPoint sampledLast = sampled.isEmpty() ? null : sampled.getLast(); + double remaining = sampledLast == null + ? Double.POSITIVE_INFINITY + : StrictMath.hypot(last.x() - sampledLast.x(), last.z() - sampledLast.z()); + if (remaining >= spacing * 0.5D) { + sampled.add(last); + } + return List.copyOf(sampled); + } + + private void smoothFlatSurfaceDimensions(List segments) { + HydraulicSegment previous = null; + for (int segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++) { + HydraulicSegment segment = segments.get(segmentIndex); + boolean flatSurface = segment.type().isSurface() + && segment.drop() == 0 + && !segment.fallingFluid(); + if (!flatSurface) { + previous = null; + continue; + } + if (previous == null) { + previous = segment; + continue; + } + int width = Math.max( + previous.width() - 2, + Math.min(previous.width() + 2, segment.width()) + ); + int depth = Math.max( + previous.depth() - 1, + Math.min(previous.depth() + 1, segment.depth()) + ); + if (width != segment.width() || depth != segment.depth()) { + segment = new HydraulicSegment( + segment.id(), + segment.courseId(), + segment.type(), + segment.upstreamHeadY(), + segment.downstreamHeadY(), + width, + depth, + segment.fallingFluid(), + segment.receivingPool(), + segment.centerline() + ); + segments.set(segmentIndex, segment); + } + previous = segment; + } + } + + private int surfaceTransitionRunEnd( + CoursePath path, + int[] heads, + int[] widths, + int[] depths, + int startPairIndex, + int minimumPoolLength + ) { + int lastDropPair = startPairIndex; + double recoveryLength = 0D; + for (int pairIndex = startPairIndex + 1; pairIndex < heads.length - 1; pairIndex++) { + if (isDirectMouthPair(path, pairIndex)) { + break; + } + HydrologyPoint start = path.points().get(pairIndex); + HydrologyPoint end = path.points().get(pairIndex + 1); + if (heads[pairIndex] > heads[pairIndex + 1]) { + if (recoveryLength >= minimumPoolLength) { + break; + } + HydrologyFeatureType type = surfaceSegmentType( + path, + pairIndex, + heads[pairIndex], + heads[pairIndex + 1], + widths[pairIndex], + depths[pairIndex] + ); + if (!type.isSurface()) { + break; + } + lastDropPair = pairIndex; + recoveryLength = 0D; + continue; + } + HydrologyFeatureType type = surfaceSegmentType( + path, + pairIndex, + heads[pairIndex], + heads[pairIndex + 1], + widths[pairIndex], + depths[pairIndex] + ); + if (type != HydrologyFeatureType.SURFACE_POOL) { + break; + } + recoveryLength += StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); + } + return lastDropPair; + } + + private boolean addSurfaceTransitionRunSegments( + long courseId, + int startPairIndex, + int endPairIndex, + int width, + int depth, + CoursePath path, + int[] heads, + List segments + ) { + int upstreamHead = heads[startPairIndex]; + int downstreamHead = heads[endPairIndex + 1]; + HydrologyPlannerSettings.Drops drops = settings.geometry().drops(); + List raster = rasterCenterline( + transitionCenterline(path, heads, startPairIndex, endPairIndex) + ); + List graded = terrainBackedSurfaceTransitionGrade( + raster, + upstreamHead, + downstreamHead, + drops + ); + if (graded.size() < 2) { + return false; + } + int segmentStart = 0; + int segmentOrdinal = 0; + for (int pointIndex = 0; pointIndex < graded.size() - 1; pointIndex++) { + int stepDrop = graded.get(pointIndex).y() - graded.get(pointIndex + 1).y(); + if (stepDrop <= 1) { + continue; + } + if (pointIndex > segmentStart) { + addSurfaceTransitionSlice( + courseId, + startPairIndex, + segmentOrdinal++, + graded.subList(segmentStart, pointIndex + 1), + width, + depth, + false, + segments + ); + } + boolean finalSlice = pointIndex + 1 == graded.size() - 1; + segments.add(new HydraulicSegment( + HydrologyHash.mix( + worldSeed, + SEGMENT_SALT, + courseId, + startPairIndex, + GRADED_DROP_RUN_SALT, + segmentOrdinal++ + ), + courseId, + HydrologyFeatureType.WATERFALL, + graded.get(pointIndex).y(), + graded.get(pointIndex + 1).y(), + hydraulicDropWidth(width, true), + drops.flowDepth(depth), + true, + finalSlice, + List.of(graded.get(pointIndex), graded.get(pointIndex + 1)) + )); + segmentStart = pointIndex + 1; + } + if (!addSurfaceTransitionSlice( + courseId, + startPairIndex, + segmentOrdinal, + graded.subList(segmentStart, graded.size()), + width, + depth, + true, + segments + ) && segmentStart < graded.size() - 1) { + return false; + } + return true; + } + + private boolean addSurfaceTransitionSlice( + long courseId, + int startPairIndex, + int segmentOrdinal, + List centerline, + int width, + int depth, + boolean receivingPool, + List segments + ) { + if (centerline.size() < 2) { + return false; + } + int upstreamHead = centerline.getFirst().y(); + int downstreamHead = centerline.getLast().y(); + HydrologyFeatureType type = upstreamHead == downstreamHead + ? HydrologyFeatureType.SURFACE_POOL + : upstreamHead - downstreamHead <= settings.hydraulics().riffleDrop() + ? HydrologyFeatureType.RIFFLE + : HydrologyFeatureType.CASCADE; + int segmentWidth = receivingPool ? Math.addExact(width, 2) : width; + int segmentDepth = receivingPool ? Math.addExact(depth, 1) : depth; + segments.add(new HydraulicSegment( + HydrologyHash.mix( + worldSeed, + SEGMENT_SALT, + courseId, + startPairIndex, + GRADED_DROP_RUN_SALT, + segmentOrdinal + ), + courseId, + type, + upstreamHead, + downstreamHead, + segmentWidth, + segmentDepth, + false, + receivingPool, + List.copyOf(centerline) + )); + return true; + } + + private List terrainBackedSurfaceTransitionGrade( + List points, + int upstreamHead, + int downstreamHead, + HydrologyPlannerSettings.Drops drops + ) { + int pointCount = points.size(); + int drop = upstreamHead - downstreamHead; + if (pointCount < 2 || drop <= 0) { + return List.of(); + } + int[] maximumTerrainHeads = new int[pointCount]; + int[] minimumTerrainHeads = new int[pointCount]; + int[] requiredLowerHeads = new int[pointCount]; + int[] reachableUpperHeads = new int[pointCount]; + int[] stepCapacities = new int[pointCount - 1]; + for (int pointIndex = 0; pointIndex < pointCount; pointIndex++) { + HydrologyPoint point = points.get(pointIndex); + HydrologyTerrainSample terrain = sampleBasis(point.x(), point.z()); + int permittedIncision = permittedSurfaceIncision(terrain); + maximumTerrainHeads[pointIndex] = terrain.naturalHeight() - 1; + for (GridOffset offset : CARDINAL_OFFSETS) { + HydrologyTerrainSample neighbor = sampleBasis( + point.x() + offset.x(), + point.z() + offset.z() + ); + maximumTerrainHeads[pointIndex] = Math.min( + maximumTerrainHeads[pointIndex], + neighbor.naturalHeight() - 1 + ); + } + minimumTerrainHeads[pointIndex] = terrain.naturalHeight() - permittedIncision; + if (pointIndex > 0) { + HydrologyTerrainSample previousTerrain = sampleBasis( + points.get(pointIndex - 1).x(), + points.get(pointIndex - 1).z() + ); + int naturalDrop = previousTerrain.naturalHeight() - terrain.naturalHeight(); + stepCapacities[pointIndex - 1] = naturalDrop >= settings.hydraulics().waterfallMinimumDrop() + ? naturalDrop + : 1; + } + } + reachableUpperHeads[pointCount - 1] = Math.min( + downstreamHead, + maximumTerrainHeads[pointCount - 1] + ); + requiredLowerHeads[pointCount - 1] = Math.max( + downstreamHead, + minimumTerrainHeads[pointCount - 1] + ); + for (int pointIndex = pointCount - 2; pointIndex >= 0; pointIndex--) { + reachableUpperHeads[pointIndex] = Math.min( + maximumTerrainHeads[pointIndex], + Math.addExact(reachableUpperHeads[pointIndex + 1], stepCapacities[pointIndex]) + ); + requiredLowerHeads[pointIndex] = Math.max( + minimumTerrainHeads[pointIndex], + requiredLowerHeads[pointIndex + 1] + ); + } + if (upstreamHead > reachableUpperHeads[0] + || upstreamHead < minimumTerrainHeads[0] + || downstreamHead < minimumTerrainHeads[pointCount - 1]) { + return List.of(); + } + ArrayList graded = new ArrayList<>(pointCount); + int previousHead = upstreamHead; + for (int pointIndex = 0; pointIndex < pointCount; pointIndex++) { + HydrologyPoint point = points.get(pointIndex); + double progress = pointIndex / (double) (pointCount - 1); + int idealHead = upstreamHead - (int) StrictMath.round( + drop * StrictMath.pow(progress, drops.cascadeExponent()) + ); + int maximumStep = pointIndex == 0 ? 0 : stepCapacities[pointIndex - 1]; + int lowerHead = Math.max( + requiredLowerHeads[pointIndex], + Math.max(downstreamHead, previousHead - maximumStep) + ); + int upperHead = Math.min( + previousHead, + Math.min(maximumTerrainHeads[pointIndex], reachableUpperHeads[pointIndex]) + ); + if (lowerHead > upperHead) { + return List.of(); + } + int head = Math.max(lowerHead, Math.min(idealHead, upperHead)); + if (pointIndex == 0) { + head = upstreamHead; + } + if (pointIndex == pointCount - 1) { + head = downstreamHead; + } + if (head < previousHead - maximumStep + || head > reachableUpperHeads[pointIndex] + || head < minimumTerrainHeads[pointIndex] + || head > maximumTerrainHeads[pointIndex]) { + return List.of(); + } + graded.add(new HydrologyPoint(point.x(), head, point.z())); + previousHead = head; + } + return List.copyOf(graded); + } + + private List transitionCenterline( + CoursePath path, + int[] heads, + int startPairIndex, + int endPairIndex + ) { + ArrayList centerline = new ArrayList<>(endPairIndex - startPairIndex + 2); + for (int pointIndex = startPairIndex; pointIndex <= endPairIndex + 1; pointIndex++) { + centerline.add(withY(path.points().get(pointIndex), heads[pointIndex])); + } + return List.copyOf(centerline); + } + + private int averageDimension(int[] dimensions, int startIndex, int endIndex) { + long total = 0L; + for (int index = startIndex; index <= endIndex; index++) { + total += dimensions[index]; + } + return Math.toIntExact(Math.round(total / (double) (endIndex - startIndex + 1))); + } + + private boolean addMixedSurfaceBoreSegments( + long courseId, + int pairIndex, + int upstreamHead, + int downstreamHead, + int width, + int depth, + HydrologyPoint start, + HydrologyPoint end, + List segments + ) { + List raster = rasterLine(start, end); + if (raster.size() < 3) { + return false; + } + int maximumStep = settings.geometry().drops().maximumCascadeStep(); + int drop = upstreamHead - downstreamHead; + if (drop > Math.multiplyExact(raster.size() - 1, maximumStep)) { + return false; + } + List graded = drop == 0 + ? levelSlice(raster, 0, raster.size() - 1, upstreamHead) + : gradedDropCenterline(raster, upstreamHead, downstreamHead, settings.geometry().drops(), maximumStep); + boolean[] boreEdges = new boolean[graded.size() - 1]; + boolean hasBore = false; + boolean hasSurface = false; + for (int edgeIndex = 0; edgeIndex < boreEdges.length; edgeIndex++) { + HydrologyPoint edgeStart = graded.get(edgeIndex); + HydrologyPoint edgeEnd = graded.get(edgeIndex + 1); + boolean bore = requiresBore(edgeStart, edgeStart.y()) || requiresBore(edgeEnd, edgeEnd.y()); + boreEdges[edgeIndex] = bore; + hasBore |= bore; + hasSurface |= !bore; + } + if (!hasBore || !hasSurface) { + return false; + } + int runStart = 0; + int runOrdinal = 0; + while (runStart < boreEdges.length) { + boolean bore = boreEdges[runStart]; + int runEnd = runStart; + while (runEnd + 1 < boreEdges.length && boreEdges[runEnd + 1] == bore) { + runEnd++; + } + List centerline = List.copyOf(graded.subList(runStart, runEnd + 2)); + int runDrop = centerline.getFirst().y() - centerline.getLast().y(); + HydrologyFeatureType type = bore + ? runDrop > 0 ? HydrologyFeatureType.UNDERGROUND_DROP : HydrologyFeatureType.RIDGE_BORE + : surfaceSliceType(centerline, runDrop); + boolean falling = type == HydrologyFeatureType.WATERFALL; + int segmentWidth = runDrop > 0 ? hydraulicDropWidth(width, true) : width; + int segmentDepth = runDrop > 0 ? settings.geometry().drops().flowDepth(depth) : depth; + segments.add(new HydraulicSegment( + HydrologyHash.mix( + worldSeed, + SEGMENT_SALT, + courseId, + pairIndex, + MIXED_BORE_SEGMENT_SALT, + runOrdinal, + type.ordinal() + ), + courseId, + type, + centerline.getFirst().y(), + centerline.getLast().y(), + segmentWidth, + segmentDepth, + falling, + false, + centerline + )); + runOrdinal++; + runStart = runEnd + 1; + } + return true; + } + + private HydrologyFeatureType surfaceSliceType(List centerline, int drop) { + if (drop == 0) { + return HydrologyFeatureType.SURFACE_POOL; + } + if (drop <= settings.hydraulics().riffleDrop()) { + return HydrologyFeatureType.RIFFLE; + } + for (int pointIndex = 1; pointIndex < centerline.size(); pointIndex++) { + if (centerline.get(pointIndex - 1).y() - centerline.get(pointIndex).y() > 1) { + return HydrologyFeatureType.WATERFALL; + } + } + return HydrologyFeatureType.CASCADE; + } + + private void containBriefSurfaceReopenings(List segments) { + int segmentIndex = 1; + while (segmentIndex < segments.size() - 1) { + if (!isSurfaceCourseBore(segments.get(segmentIndex - 1))) { + segmentIndex++; + continue; + } + int surfaceStart = segmentIndex; + double surfaceLength = 0D; + int maximumWidth = 1; + while (segmentIndex < segments.size() && segments.get(segmentIndex).type().isSurface()) { + HydraulicSegment segment = segments.get(segmentIndex); + if (segment.type() == HydrologyFeatureType.MOUTH) { + break; + } + surfaceLength += hydraulicSegmentLength(segment); + maximumWidth = Math.max(maximumWidth, segment.width()); + segmentIndex++; + } + if (surfaceStart == segmentIndex) { + segmentIndex++; + continue; + } + if (segmentIndex >= segments.size() + || !isSurfaceCourseBore(segments.get(segmentIndex))) { + continue; + } + double minimumEmergenceLength = Math.max(16D, maximumWidth * 3D); + if (surfaceLength >= minimumEmergenceLength + || !surfaceRunCanRemainRoofed(segments, surfaceStart, segmentIndex)) { + continue; + } + for (int conversionIndex = surfaceStart; conversionIndex < segmentIndex; conversionIndex++) { + HydraulicSegment segment = segments.get(conversionIndex); + HydrologyFeatureType type = segment.drop() > 0 + ? HydrologyFeatureType.UNDERGROUND_DROP + : HydrologyFeatureType.RIDGE_BORE; + segments.set(conversionIndex, new HydraulicSegment( + segment.id(), + segment.courseId(), + type, + segment.upstreamHeadY(), + segment.downstreamHeadY(), + segment.width(), + segment.depth(), + false, + false, + segment.centerline() + )); + } + } + } + + private boolean surfaceRunCanRemainRoofed( + List segments, + int startIndex, + int endIndex + ) { + for (int segmentIndex = startIndex; segmentIndex < endIndex; segmentIndex++) { + for (HydrologyPoint point : rasterCenterline(segments.get(segmentIndex).centerline())) { + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null + || terrain.ocean() + || terrain.naturalHeight() < Math.addExact(point.y(), 2)) { + return false; + } + } + } + return true; + } + + private boolean isSurfaceCourseBore(HydraulicSegment segment) { + return segment.type() == HydrologyFeatureType.RIDGE_BORE + || segment.type() == HydrologyFeatureType.UNDERGROUND_DROP + || segment.type() == HydrologyFeatureType.UNDERGROUND_POOL; + } + + private void compileUndergroundCourses( + HydrologyTileKey key, + SampledGrid grid, + RoutingPlan routing, + CompiledGraph graph, + List sources, + List courses, + List diagnostics + ) { + for (int sourceIndex : sources) { + GridNode source = grid.node(sourceIndex); + long courseId = HydrologyHash.mix(worldSeed, COURSE_SALT, UNDERGROUND_SOURCE_SALT, source.id()); + CoursePath path = coursePath(null, routing, graph, sourceIndex, false); + if (path == null) { + addCompiledSourceDiagnostic( + source, + false, + courseId, + HydrologyCandidateRejection.NO_DRAINAGE_PATH, + diagnostics + ); + continue; + } + HydrologyPoint sourcePoint = path.points().getFirst(); + HydrologyPoint trunkPoint = path.points().getLast(); + HydrologyTerrainSample trunkTerrain = Objects.requireNonNull( + sampleDetailed(trunkPoint.x(), trunkPoint.z()), + "Hydrology underground trunk left sampled terrain" + ); + String profileKey = chooseProfile(trunkTerrain, path.outlet().id()); + int styledHead = sampleGeometry( + HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL, + profileKey, + sourcePoint.x(), + sourcePoint.z(), + 0L, + settings.underground().minimumFluidY(), + settings.underground().maximumFluidY() + ); + int outletHead = outletHead(path.outlet()); + if (outletHead > settings.underground().maximumFluidY()) { + addCompiledSourceDiagnostic( + source, + false, + courseId, + HydrologyCandidateRejection.OUTLET_LEVEL, + diagnostics + ); + continue; + } + int initialHead = Math.max(styledHead, outletHead); + RiverCourse course = buildUndergroundCourse(courseId, profileKey, source, path, initialHead, outletHead); + if (course == null) { + addCompiledSourceDiagnostic( + source, + false, + courseId, + HydrologyCandidateRejection.CAVE_CONTAINMENT, + diagnostics + ); + continue; + } + courses.add(course); + } + } + + private void addCompiledSourceDiagnostic( + GridNode source, + boolean surface, + long courseId, + HydrologyCandidateRejection rejection, + List diagnostics + ) { + int y = surface ? source.terrain().naturalHeight() : source.terrain().caveFluidY(); + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(courseId, DIAGNOSTIC_SALT, rejection.ordinal()), + HydrologyCandidateKind.SOURCE, + surface ? HydrologyFeatureType.SURFACE_POOL : HydrologyFeatureType.UNDERGROUND_POOL, + new HydrologyPoint(source.x(), y, source.z()), + rejection + )); + } + + private RiverCourse buildUndergroundCourse( + long courseId, + String profileKey, + GridNode source, + CoursePath path, + int initialHead, + int outletHead + ) { + int pairCount = path.points().size() - 1; + if (pairCount < 1) { + return null; + } + int[] heads = new int[path.points().size()]; + int[] preferredHeads = new int[path.points().size()]; + int[] widths = new int[pairCount]; + int[] depths = new int[pairCount]; + preferredHeads[0] = initialHead; + for (int index = 0; index < pairCount; index++) { + HydrologyPoint point = path.points().get(index); + HydrologyTerrainSample terrain = sampleBasis(point.x(), point.z()); + int discharge = Math.max(1, path.pairEdges().get(index).contributingUndergroundSources()); + widths[index] = scaledDimension( + sampleGeometry( + HydrologyGeometrySampler.Field.UNDERGROUND_WIDTH, + profileKey, + point.x(), + point.z(), + 0L, + settings.underground().minimumWidth(), + settings.underground().maximumWidth() + ), + settings.underground().minimumWidth(), + settings.underground().maximumWidth(), + discharge, + terrain.widthMultiplier() + ); + depths[index] = scaledDimension( + sampleGeometry( + HydrologyGeometrySampler.Field.UNDERGROUND_DEPTH, + profileKey, + point.x(), + point.z(), + 0L, + settings.underground().minimumDepth(), + settings.underground().maximumDepth() + ), + settings.underground().minimumDepth(), + settings.underground().maximumDepth(), + discharge, + terrain.depthMultiplier() + ); + HydrologyPoint downstreamPoint = path.points().get(index + 1); + int caveHead = sampleGeometry( + HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL, + profileKey, + downstreamPoint.x(), + downstreamPoint.z(), + 0L, + settings.underground().minimumFluidY(), + settings.underground().maximumFluidY() + ); + preferredHeads[index + 1] = caveHead; + } + int[] minimumHeads = undergroundMinimumHeads(path, depths, outletHead); + int[] optimisticMaximumHeads = undergroundOptimisticMaximumHeads(path); + if (!undergroundHeadsFeasible(minimumHeads, optimisticMaximumHeads, outletHead)) { + return null; + } + int[] maximumHeads = undergroundMaximumHeads(courseId, profileKey, path, widths); + if (!solveUndergroundHeads(preferredHeads, minimumHeads, maximumHeads, outletHead, heads)) { + return null; + } + levelUndergroundMouth(path, heads, outletHead); + ArrayList segments = new ArrayList<>(pairCount + 1); + for (int index = 0; index < pairCount; index++) { + int drop = heads[index] - heads[index + 1]; + HydrologyFeatureType type = drop > 0 + ? HydrologyFeatureType.UNDERGROUND_DROP + : HydrologyFeatureType.UNDERGROUND_POOL; + if (!addHydraulicSegments( + courseId, + index, + type, + heads[index], + heads[index + 1], + widths[index], + depths[index], + List.of( + withY(path.points().get(index), heads[index]), + withY(path.points().get(index + 1), heads[index + 1]) + ), + false, + segments + )) { + return null; + } + } + appendOutletSegments( + RiverCourseType.UNDERGROUND, + courseId, + path, + heads[heads.length - 1], + widths, + depths, + segments + ); + int discharge = maximumUndergroundDischarge(path.edges()); + return new RiverCourse( + courseId, + RiverCourseType.UNDERGROUND, + OptionalLong.of(source.id()), + OptionalLong.of(path.outlet().id()), + profileKey, + discharge, + path.edges(), + segments + ); + } + + private int[] undergroundMaximumHeads( + long courseId, + String profileKey, + CoursePath path, + int[] widths + ) { + int[] segmentCaps = new int[widths.length]; + for (int index = 0; index < widths.length; index++) { + int radius = Math.max(1, widths[index] / 2); + segmentCaps[index] = undergroundSegmentCap( + courseId, + profileKey, + path.points().get(index), + path.points().get(index + 1), + radius + ); + } + return nodeMaximumHeads(segmentCaps); + } + + private int[] undergroundOptimisticMaximumHeads(CoursePath path) { + int segmentCount = path.points().size() - 1; + int[] segmentCaps = new int[segmentCount]; + Arrays.fill(segmentCaps, Integer.MAX_VALUE); + int minimumHeadroom = settings.underground().minimumHeadroom(); + for (int index = 0; index < segmentCount; index++) { + HydrologyPoint start = path.points().get(index); + HydrologyPoint end = path.points().get(index + 1); + segmentCaps[index] = Math.min( + optimisticUndergroundSegmentCap(start, minimumHeadroom), + optimisticUndergroundSegmentCap(end, minimumHeadroom) + ); + } + return nodeMaximumHeads(segmentCaps); + } + + private int optimisticUndergroundSegmentCap(HydrologyPoint point, int minimumHeadroom) { + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + return terrain == null || terrain.ocean() + ? Integer.MAX_VALUE + : terrain.naturalHeight() - minimumHeadroom - 1; + } + + private int undergroundSegmentCap( + long courseId, + String profileKey, + HydrologyPoint start, + HydrologyPoint end, + int radius + ) { + int minimumHeadroom = settings.underground().minimumHeadroom(); + int maximumHeadroom = settings.underground().maximumHeadroom(); + UndergroundSegmentCapKey key = new UndergroundSegmentCapKey( + courseId, + profileKey, + start.x(), + start.z(), + end.x(), + end.z(), + radius, + minimumHeadroom, + maximumHeadroom + ); + PlanningSamples samples = planningSamples.get(); + if (samples != null) { + Integer cached = samples.undergroundSegmentCaps.get(key); + if (cached != null) { + return cached; + } + } + int segmentCap = Integer.MAX_VALUE; + List centerline = line(start, end, 1); + for (HydrologyPoint point : centerline) { + int headroom = sampleGeometry( + HydrologyGeometrySampler.Field.UNDERGROUND_HEADROOM, + profileKey, + point.x(), + point.z(), + courseId, + minimumHeadroom, + maximumHeadroom + ); + int minimumLandHeight = minimumLandHeightWithinRadius(point.x(), point.z(), radius); + if (minimumLandHeight != Integer.MAX_VALUE) { + segmentCap = Math.min(segmentCap, minimumLandHeight - headroom - 1); + } + } + if (samples != null) { + samples.undergroundSegmentCaps.put(key, segmentCap); + } + return segmentCap; + } + + private int[] nodeMaximumHeads(int[] segmentCaps) { + int[] maximumHeads = new int[segmentCaps.length + 1]; + maximumHeads[0] = segmentCaps[0]; + for (int index = 1; index < maximumHeads.length - 1; index++) { + maximumHeads[index] = Math.min(segmentCaps[index - 1], segmentCaps[index]); + } + maximumHeads[maximumHeads.length - 1] = segmentCaps[segmentCaps.length - 1]; + return maximumHeads; + } + + private int minimumLandHeightWithinRadius(int x, int z, int radius) { + PlanningSamples samples = planningSamples.get(); + MinimumLandHeightKey key = new MinimumLandHeightKey(x, z, radius); + if (samples != null) { + Integer cached = samples.minimumLandHeights.get(key); + if (cached != null) { + return cached; + } + } + + List offsets = samples == null + ? radialOffsets(radius) + : samples.radialOffsets.computeIfAbsent(radius, this::radialOffsets); + int minimumHeight = Integer.MAX_VALUE; + for (GridOffset offset : offsets) { + HydrologyTerrainSample terrain = sampleLandBasis(x + offset.x(), z + offset.z()); + if (terrain == null || terrain.ocean()) { + continue; + } + minimumHeight = Math.min(minimumHeight, terrain.naturalHeight()); + } + if (samples != null) { + samples.minimumLandHeights.put(key, minimumHeight); + } + return minimumHeight; + } + + private List radialOffsets(int radius) { + int blockRadius = (int) StrictMath.ceil(radius + 0.25D); + ArrayList offsets = new ArrayList<>(); + for (int deltaZ = -blockRadius; deltaZ <= blockRadius; deltaZ++) { + for (int deltaX = -blockRadius; deltaX <= blockRadius; deltaX++) { + if (StrictMath.hypot(deltaX, deltaZ) <= radius + 0.25D) { + offsets.add(new GridOffset(deltaX, deltaZ)); + } + } + } + return List.copyOf(offsets); + } + + private int[] undergroundMinimumHeads(CoursePath path, int[] depths, int outletHead) { + int[] minimumHeads = new int[path.points().size()]; + for (int index = 0; index < minimumHeads.length; index++) { + int depth = index == 0 + ? depths[0] + : index == depths.length ? depths[depths.length - 1] : Math.max(depths[index - 1], depths[index]); + depth = Math.max(depth, settings.geometry().drops().maximumBasinDepth()); + long worldFloorHead = (long) minimumY + depth + 1L; + int boundedWorldFloor = worldFloorHead < Integer.MIN_VALUE + ? Integer.MIN_VALUE + : worldFloorHead > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) worldFloorHead; + int configuredMinimum = index == minimumHeads.length - 1 + ? outletHead + : settings.underground().minimumFluidY(); + minimumHeads[index] = Math.max(outletHead, Math.max(configuredMinimum, boundedWorldFloor)); + } + return minimumHeads; + } + + private static boolean undergroundHeadsFeasible( + int[] minimumHeads, + int[] maximumHeads, + int outletHead + ) { + if (minimumHeads.length == 0 || minimumHeads.length != maximumHeads.length) { + throw new IllegalArgumentException("Underground head arrays must have the same positive length."); + } + int index = minimumHeads.length - 1; + int requiredHead = Math.max(minimumHeads[index], outletHead); + if (requiredHead > maximumHeads[index]) { + return false; + } + for (index--; index >= 0; index--) { + requiredHead = Math.max(minimumHeads[index], requiredHead); + if (requiredHead > maximumHeads[index]) { + return false; + } + } + return true; + } + + static boolean solveUndergroundHeads( + int[] preferredHeads, + int[] minimumHeads, + int[] maximumHeads, + int outletHead, + int[] solvedHeads + ) { + int length = preferredHeads.length; + if (length == 0 + || minimumHeads.length != length + || maximumHeads.length != length + || solvedHeads.length != length) { + throw new IllegalArgumentException("Underground head arrays must have the same positive length."); + } + int[] requiredHeads = new int[length]; + requiredHeads[length - 1] = Math.max(minimumHeads[length - 1], outletHead); + if (requiredHeads[length - 1] > maximumHeads[length - 1]) { + return false; + } + for (int index = length - 2; index >= 0; index--) { + requiredHeads[index] = Math.max(minimumHeads[index], requiredHeads[index + 1]); + if (requiredHeads[index] > maximumHeads[index]) { + return false; + } + } + solvedHeads[0] = clamp(preferredHeads[0], requiredHeads[0], maximumHeads[0]); + for (int index = 1; index < length; index++) { + int maximum = Math.min(maximumHeads[index], solvedHeads[index - 1]); + if (requiredHeads[index] > maximum) { + return false; + } + solvedHeads[index] = clamp(preferredHeads[index], requiredHeads[index], maximum); + } + solvedHeads[length - 1] = outletHead; + return outletHead >= minimumHeads[length - 1] + && outletHead <= maximumHeads[length - 1] + && (length == 1 || solvedHeads[length - 2] >= outletHead); + } + + private boolean addHydraulicSegments( + long courseId, + int pairIndex, + HydrologyFeatureType type, + int upstreamHead, + int downstreamHead, + int width, + int depth, + List centerline, + boolean surfaceCourse, + List segments + ) { + int drop = upstreamHead - downstreamHead; + if (drop <= 0 || centerline.size() < 2) { + long segmentId = HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, pairIndex, type.ordinal()); + segments.add(new HydraulicSegment( + segmentId, + courseId, + type, + upstreamHead, + downstreamHead, + width, + depth, + false, + false, + centerline + )); + return true; + } + if (type == HydrologyFeatureType.WATERFALL) { + return addFallingDropSegments( + courseId, + pairIndex, + upstreamHead, + downstreamHead, + width, + depth, + centerline, + surfaceCourse, + segments + ); + } + return addGradedDropSegments( + courseId, + pairIndex, + type, + upstreamHead, + downstreamHead, + width, + depth, + centerline, + surfaceCourse, + segments + ); + } + + private boolean addFallingDropSegments( + long courseId, + int pairIndex, + int upstreamHead, + int downstreamHead, + int width, + int depth, + List centerline, + boolean surfaceCourse, + List segments + ) { + List raster = rasterCenterline(centerline); + if (raster.size() < 2) { + return false; + } + int lipIndex = Math.min(waterfallLipIndex(raster), raster.size() - 2); + int receiverIndex = lipIndex + 1; + addGradedTransitionSegment( + courseId, + pairIndex, + GRADED_DROP_APPROACH_SALT, + HydrologyFeatureType.SURFACE_POOL, + upstreamHead, + width, + depth, + levelSlice(raster, 0, lipIndex, upstreamHead), + segments + ); + HydrologyPlannerSettings.Drops drops = settings.geometry().drops(); + int flowWidth = hydraulicDropWidth(width, surfaceCourse); + int flowDepth = drops.flowDepth(depth); + HydrologyPoint lip = withY(raster.get(lipIndex), upstreamHead); + HydrologyPoint receiver = withY(raster.get(receiverIndex), downstreamHead); + segments.add(new HydraulicSegment( + HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, pairIndex, GRADED_DROP_RUN_SALT), + courseId, + HydrologyFeatureType.WATERFALL, + upstreamHead, + downstreamHead, + flowWidth, + flowDepth, + true, + true, + List.of(lip, receiver) + )); + addGradedTransitionSegment( + courseId, + pairIndex, + GRADED_DROP_OUTFLOW_SALT, + HydrologyFeatureType.SURFACE_POOL, + downstreamHead, + width, + depth, + levelSlice(raster, receiverIndex, raster.size() - 1, downstreamHead), + segments + ); + return true; + } + + private boolean addCoastalMouthDropSegments( + long courseId, + int pairIndex, + int upstreamHead, + int downstreamHead, + int width, + int depth, + List centerline, + boolean surfaceCourse, + List segments + ) { + List raster = rasterCenterline(centerline); + if (raster.size() < 2) { + return false; + } + int lipIndex = raster.size() - 2; + addGradedTransitionSegment( + courseId, + pairIndex, + GRADED_DROP_APPROACH_SALT, + HydrologyFeatureType.SURFACE_POOL, + upstreamHead, + width, + depth, + levelSlice(raster, 0, lipIndex, upstreamHead), + segments + ); + HydrologyPlannerSettings.Drops drops = settings.geometry().drops(); + segments.add(new HydraulicSegment( + HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, pairIndex, GRADED_DROP_RUN_SALT), + courseId, + HydrologyFeatureType.WATERFALL, + upstreamHead, + downstreamHead, + hydraulicDropWidth(width, surfaceCourse), + drops.flowDepth(depth), + true, + true, + List.of( + withY(raster.get(lipIndex), upstreamHead), + withY(raster.getLast(), downstreamHead) + ) + )); + return true; + } + + private boolean addGradedDropSegments( + long courseId, + int pairIndex, + HydrologyFeatureType type, + int upstreamHead, + int downstreamHead, + int width, + int depth, + List centerline, + boolean surfaceCourse, + List segments + ) { + int drop = upstreamHead - downstreamHead; + HydrologyPlannerSettings.Drops drops = settings.geometry().drops(); + boolean continuousSurfaceBore = surfaceCourse && type == HydrologyFeatureType.UNDERGROUND_DROP; + List directRaster = continuousSurfaceBore + ? rasterCenterline(centerline) + : List.of(); + int maximumStep = drops.stepLimit(type); + if (continuousSurfaceBore) { + if (directRaster.size() < 2) { + return false; + } + int requiredStep = Math.floorDiv(drop + directRaster.size() - 2, directRaster.size() - 1); + if (requiredStep > MAXIMUM_SURFACE_BORE_STEP) { + return false; + } + maximumStep = Math.max(maximumStep, requiredStep); + } + int minimumRun = Math.floorDiv(drop + maximumStep - 1, maximumStep) + 1; + int maximumGeneratedRun = Math.addExact(settings.routing().maximumRouteLength(), 1); + if (minimumRun > maximumGeneratedRun) { + return false; + } + int preferredRun = Math.addExact(Math.multiplyExact(drop, drops.cascadeRunPerBlock()), 1); + int desiredRun = type.isUnderground() + ? minimumRun + : Math.min(preferredRun, maximumGeneratedRun); + List raster = continuousSurfaceBore + ? directRaster + : type.isUnderground() + ? organicDropRaster(courseId, pairIndex, centerline, desiredRun) + : rasterCenterline(centerline); + if (raster.size() < minimumRun) { + return false; + } + int runLength = Math.min(raster.size(), Math.max(minimumRun, desiredRun)); + int lipIndex = waterfallLipIndex(raster); + int runStart = continuousSurfaceBore + ? 0 + : type.isUnderground() + ? clamp(lipIndex - runLength / 3, 0, raster.size() - runLength) + : 0; + int runEnd = continuousSurfaceBore + ? raster.size() - 1 + : type.isUnderground() ? runStart + runLength - 1 : raster.size() - 1; + HydrologyFeatureType flatType = type.isUnderground() + ? HydrologyFeatureType.UNDERGROUND_POOL + : HydrologyFeatureType.SURFACE_POOL; + if (!continuousSurfaceBore) { + addGradedTransitionSegment( + courseId, + pairIndex, + GRADED_DROP_APPROACH_SALT, + flatType, + upstreamHead, + width, + depth, + levelSlice(raster, 0, runStart, upstreamHead), + segments + ); + } + List graded = gradedDropCenterline( + raster.subList(runStart, runEnd + 1), + upstreamHead, + downstreamHead, + drops, + maximumStep + ); + int dropWidth = hydraulicDropWidth(width, surfaceCourse); + int dropDepth = drops.flowDepth(depth); + segments.add(new HydraulicSegment( + HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, pairIndex, GRADED_DROP_RUN_SALT), + courseId, + type, + upstreamHead, + downstreamHead, + dropWidth, + dropDepth, + false, + !continuousSurfaceBore, + graded + )); + if (!continuousSurfaceBore) { + addGradedTransitionSegment( + courseId, + pairIndex, + GRADED_DROP_OUTFLOW_SALT, + flatType, + downstreamHead, + width, + depth, + levelSlice(raster, runEnd, raster.size() - 1, downstreamHead), + segments + ); + } + return true; + } + + private int hydraulicDropWidth(int channelWidth, boolean surfaceCourse) { + int dropWidth = settings.geometry().drops().flowWidth(channelWidth); + return surfaceCourse ? Math.max(settings.surface().minimumWidth(), dropWidth) : dropWidth; + } + + private List organicDropRaster( + long courseId, + int pairIndex, + List centerline, + int desiredRun + ) { + List base = rasterCenterline(centerline); + if (base.size() >= desiredRun) { + return base; + } + int wavelength = settings.geometry().meanders().detailWavelength(); + double phase = HydrologyHash.unit(HydrologyHash.mix(worldSeed, courseId, pairIndex, GRADED_DROP_RUN_SALT)) + * StrictMath.PI * 2D; + int maximumAmplitude = Math.max(3, settings.routing().sampleSpacing() / 2); + List longest = base; + for (int amplitude = 3; amplitude <= maximumAmplitude; amplitude += 2) { + int cycles = Math.max(1, (int) StrictMath.ceil( + Math.max(0, desiredRun - base.size()) / (2D * amplitude) + )); + int samples = Math.max(base.size(), desiredRun * 3); + ArrayList worm = new ArrayList<>(samples); + for (int sampleIndex = 0; sampleIndex < samples; sampleIndex++) { + double progress = sampleIndex / (double) (samples - 1); + double baseIndex = progress * (base.size() - 1); + int lowerIndex = Math.min(base.size() - 2, (int) StrictMath.floor(baseIndex)); + int upperIndex = lowerIndex + 1; + double localProgress = baseIndex - lowerIndex; + HydrologyPoint lower = base.get(lowerIndex); + HydrologyPoint upper = base.get(upperIndex); + double baseX = lower.x() + (upper.x() - lower.x()) * localProgress; + double baseZ = lower.z() + (upper.z() - lower.z()) * localProgress; + double tangentX = upper.x() - lower.x(); + double tangentZ = upper.z() - lower.z(); + double tangentLength = StrictMath.hypot(tangentX, tangentZ); + if (tangentLength <= 0D) { + continue; + } + double envelope = StrictMath.pow(StrictMath.sin(StrictMath.PI * progress), 2D); + double displacement = StrictMath.sin( + progress * StrictMath.PI * 2D * cycles + + baseIndex * StrictMath.PI * 2D / wavelength + + phase + ) * amplitude * envelope; + int x = (int) StrictMath.round(baseX - tangentZ / tangentLength * displacement); + int z = (int) StrictMath.round(baseZ + tangentX / tangentLength * displacement); + if (worm.isEmpty() || worm.getLast().x() != x || worm.getLast().z() != z) { + worm.add(new HydrologyPoint(x, lower.y(), z)); + } + } + List raster = rasterCenterline(worm); + if (raster.size() > longest.size()) { + longest = raster; + } + if (raster.size() >= desiredRun) { + return raster; + } + } + return longest; + } + + private void addGradedTransitionSegment( + long courseId, + int pairIndex, + long salt, + HydrologyFeatureType type, + int head, + int width, + int depth, + List centerline, + List segments + ) { + if (centerline.size() < 2) { + return; + } + segments.add(new HydraulicSegment( + HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, pairIndex, salt), + courseId, + type, + head, + head, + width, + depth, + false, + false, + List.copyOf(centerline) + )); + } + + private List gradedDropCenterline( + List points, + int upstreamHead, + int downstreamHead, + HydrologyPlannerSettings.Drops drops, + int maximumStep + ) { + int pointCount = points.size(); + int drop = upstreamHead - downstreamHead; + if ((long) (pointCount - 1) * maximumStep < drop) { + throw new IllegalArgumentException("Graded drop centerline cannot contain its configured head loss."); + } + ArrayList graded = new ArrayList<>(pointCount); + int previousHead = upstreamHead; + for (int pointIndex = 0; pointIndex < pointCount; pointIndex++) { + double progress = pointIndex / (double) (pointCount - 1); + int idealHead = upstreamHead - (int) StrictMath.round( + drop * StrictMath.pow(progress, drops.cascadeExponent()) + ); + int remainingSteps = pointCount - pointIndex - 1; + int latestReachableHead = downstreamHead + remainingSteps * maximumStep; + int head = Math.min(previousHead, Math.min(idealHead, latestReachableHead)); + head = Math.max(downstreamHead, Math.max(previousHead - maximumStep, head)); + if (pointIndex == pointCount - 1) { + head = downstreamHead; + } + HydrologyPoint point = points.get(pointIndex); + graded.add(new HydrologyPoint(point.x(), head, point.z())); + previousHead = head; + } + return List.copyOf(graded); + } + + private List levelSlice( + List points, + int startIndex, + int endIndex, + int head + ) { + ArrayList leveled = new ArrayList<>(Math.max(0, endIndex - startIndex + 1)); + for (int pointIndex = startIndex; pointIndex <= endIndex; pointIndex++) { + leveled.add(withY(points.get(pointIndex), head)); + } + return List.copyOf(leveled); + } + + private int waterfallLipIndex(List raster) { + return waterfallLipIndex(raster, 0); + } + + private int waterfallLipIndex(List raster, int requiredDecline) { + int fallback = Math.max(0, raster.size() / 2 - 1); + int selected = fallback; + long strongestDecline = Long.MIN_VALUE; + for (int pointIndex = 0; pointIndex < raster.size() - 1; pointIndex++) { + HydrologyPoint current = raster.get(pointIndex); + HydrologyPoint downstream = raster.get(pointIndex + 1); + HydrologyTerrainSample currentTerrain = sampleBasis(current.x(), current.z()); + HydrologyTerrainSample downstreamTerrain = sampleBasis(downstream.x(), downstream.z()); + long decline = (long) currentTerrain.naturalHeight() - downstreamTerrain.naturalHeight(); + if (decline > strongestDecline) { + strongestDecline = decline; + selected = pointIndex; + } + } + return requiredDecline <= 0 || strongestDecline >= requiredDecline ? selected : -1; + } + + private List rasterCenterline(List centerline) { + ArrayList raster = new ArrayList<>(); + for (int pointIndex = 0; pointIndex < centerline.size() - 1; pointIndex++) { + List span = rasterLine(centerline.get(pointIndex), centerline.get(pointIndex + 1)); + int first = raster.isEmpty() ? 0 : 1; + for (int spanIndex = first; spanIndex < span.size(); spanIndex++) { + raster.add(span.get(spanIndex)); + } + } + return List.copyOf(raster); + } + + private List rasterLine(HydrologyPoint start, HydrologyPoint end) { + int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z())); + if (steps == 0) { + return List.of(start); + } + ArrayList points = new ArrayList<>(steps + 1); + for (int step = 0; step <= steps; step++) { + 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); + points.add(new HydrologyPoint(x, start.y(), z)); + } + return List.copyOf(points); + } + + private CoursePath coursePath( + SampledGrid grid, + RoutingPlan routing, + CompiledGraph graph, + int sourceIndex, + boolean surface + ) { + ArrayList edges = new ArrayList<>(); + ArrayList guide = new ArrayList<>(); + ArrayList guideEdges = new ArrayList<>(); + int current = sourceIndex; + int steps = 0; + while (routing.parent()[current] >= 0) { + DrainageEdge edge = graph.edgeByUpstream().get(current); + if (edge == null) { + return null; + } + edges.add(edge); + int downstream = routing.parent()[current]; + if (surface) { + for (HydrologyPoint point : edge.centerline()) { + if (guide.isEmpty()) { + guide.add(point); + } else if (point.x() != guide.getLast().x() || point.z() != guide.getLast().z()) { + guide.add(point); + guideEdges.add(edge); + } + } + } + current = downstream; + if (++steps > settings.routing().maximumRouteNodes()) { + return null; + } + } + int outletIndex = routing.outletIndex()[sourceIndex]; + if (outletIndex < 0 || edges.isEmpty()) { + return null; + } + RiverOutlet outlet = routing.outlets().get(outletIndex).outlet(); + if (!surface) { + return undergroundCoursePath(edges, outlet); + } + 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 = surfaceSinkhole(outlet) + && (guideEnd.x() != landward.x() || guideEnd.z() != landward.z()); + if (terminateAtSinkhole) { + routedGuide.set(routedGuide.size() - 1, landward); + } + List surfaceGuide = withoutDuplicateRoutePoints(routedGuide); + long geometryId = HydrologyHash.mix( + worldSeed, + COURSE_SALT, + SURFACE_SOURCE_SALT, + edges.getFirst().upstreamNodeId(), + outlet.id() + ); + List points = surfaceCourseCenterline( + Objects.requireNonNull(grid, "Surface course routing grid is required"), + surfaceGuide, + geometryId, + routing.containedSurface() + ); + if (points.size() < 2) { + return null; + } + List pairEdges = mapCoursePairs(points, routedGuide, routedGuideEdges); + if (pairEdges.size() != points.size() - 1) { + return null; + } + return new CoursePath( + points, + pairEdges, + List.copyOf(edges), + outlet, + true, + routing.containedSurface(), + routing.organicSurfaceRequired() + ); + } + + private List surfaceCourseCenterline( + SampledGrid grid, + List guide, + long geometryId, + boolean containedSurface + ) { + return surfaceCourseCenterline(grid, guide, geometryId, containedSurface, !containedSurface); + } + + private List surfaceCourseCenterline( + SampledGrid grid, + List guide, + long geometryId, + boolean containedSurface, + boolean lazyCandidates + ) { + if (guide.size() < 2) { + return List.of(); + } + int refinement = 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 = containedSurface + ? ROUTE_TRANSVERSE_CANDIDATES + : SURFACE_COURSE_TRANSVERSE_CANDIDATES; + for (int pointIndex = 0; pointIndex < nominal.size(); pointIndex++) { + SurfaceCurvePoint point = nominal.get(pointIndex); + Direction tangent = surfaceCurveTangent(nominal, pointIndex); + if (pointIndex == 0 || pointIndex == nominal.size() - 1) { + HydrologyPoint endpoint = pointIndex == 0 ? guide.getFirst() : guide.getLast(); + layers.add(List.of(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 = lazyCandidates + ? unresolvedSurfaceRouteCandidates( + grid, + geometryId, + candidateId, + position, + progress, + refinement, + transverseCandidates + ) + : routeCandidates( + geometryId, + candidateId, + position, + progress, + refinement, + transverseCandidates, + containedSurface + ); + if (candidates.isEmpty()) { + return List.of(); + } + layers.add(candidates); + } + ArrayList> availableLayers = new ArrayList<>(layers); + double maximumTurn = Math.min( + SURFACE_COURSE_MAXIMUM_TURN_DEGREES, + settings.geometry().meanders().maximumTurnDegrees() + ); + while (true) { + RouteCandidate[] route = selectCurvatureAwareTerrainRoute( + availableLayers, + refinement, + containedSurface, + maximumTurn, + SURFACE_COURSE_TURN_COST + ); + if (route.length == 0) { + if (lazyCandidates && addExhaustedSurfaceRouteFallbacks( + grid, + availableLayers, + nominal, + base, + geometryId + )) { + continue; + } + return List.of(); + } + int pitRemoval = 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 || !traversableRoute(points)) { + return List.of(); + } + if (containedSurface) { + return smoothContainedSurfaceRoute(points); + } + return smoothTerrainRoute(points); + } + } + + private boolean addExhaustedSurfaceRouteFallbacks( + SampledGrid 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); + Direction 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 = 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( + 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), + offset, + localScore, + estimate.terrainScore(), + tangent, + false + )); + layers.set(layerIndex, List.copyOf(expanded)); + added = true; + } + return added; + } + + private boolean exhaustedSurfaceRouteLayer(List layer) { + for (RouteCandidate candidate : layer) { + if (!candidate.terrainResolved() || Double.isFinite(candidate.localScore())) { + return false; + } + } + return true; + } + + private List smoothContainedSurfaceRoute(List route) { + List current = List.copyOf(route); + List best = current; + double bestScore = containedSurfaceTurnScore(best); + int maximumPasses = Math.max(8, settings.geometry().meanders().smoothingPasses() * 4); + double[] strengths = new double[]{0.25D, 0.1875D, 0.125D, 0.0625D}; + for (int pass = 0; pass < maximumPasses; pass++) { + List accepted = null; + for (double strength : strengths) { + List candidate = smoothContainedSurfaceRoutePass(current, strength); + if (candidate.size() >= 2 && containedSurfaceRouteSupported(candidate)) { + accepted = candidate; + break; + } + } + if (accepted == null || accepted.equals(current)) { + break; + } + current = accepted; + double score = containedSurfaceTurnScore(current); + if (score < bestScore) { + best = current; + bestScore = score; + } + } + return best; + } + + private List smoothContainedSurfaceRoutePass( + List route, + double strength + ) { + ArrayList smoothed = new ArrayList<>(route.size()); + smoothed.add(route.getFirst()); + for (int pointIndex = 1; pointIndex < route.size() - 1; pointIndex++) { + HydrologyPoint previous = route.get(pointIndex - 1); + HydrologyPoint point = route.get(pointIndex); + HydrologyPoint next = route.get(pointIndex + 1); + int x = (int) StrictMath.round( + previous.x() * strength + + point.x() * (1D - strength * 2D) + + next.x() * strength + ); + int z = (int) StrictMath.round( + previous.z() * strength + + point.z() * (1D - strength * 2D) + + next.z() * strength + ); + HydrologyTerrainSample terrain = sampleLandBasis(x, z); + smoothed.add(terrain == null || !terrain.transitAllowed() + ? point + : new HydrologyPoint(x, terrain.naturalHeight(), z)); + } + smoothed.add(route.getLast()); + return withoutDuplicateRoutePoints(smoothed); + } + + private double containedSurfaceTurnScore(List route) { + List sampled = resampleContainedSurfaceCenterline( + route, + Math.max(16D, settings.routing().refinementSpacing() * 4D) + ); + if (sampled.size() < 3) { + return Double.POSITIVE_INFINITY; + } + ArrayList turns = new ArrayList<>(sampled.size() - 2); + double maximum = 0D; + for (int pointIndex = 1; pointIndex < sampled.size() - 1; pointIndex++) { + double turn = routeTurnDegrees( + sampled.get(pointIndex - 1), + sampled.get(pointIndex), + sampled.get(pointIndex + 1) + ); + turns.add(turn); + maximum = Math.max(maximum, turn); + } + turns.sort(Double::compare); + int percentileIndex = (int) StrictMath.ceil(0.95D * turns.size()) - 1; + double percentile = turns.get(Math.max(0, Math.min(percentileIndex, turns.size() - 1))); + return percentile + maximum * 0.05D; + } + + private boolean containedSurfaceRouteSupported(List points) { + if (!traversableRoute(points) || containsTerrainPit(points)) { + return false; + } + for (int pointIndex = 0; pointIndex < points.size() - 1; pointIndex++) { + if (!containedSurfaceTransitionSupported( + points.get(pointIndex), + points.get(pointIndex + 1) + )) { + return false; + } + } + return true; + } + + private double surfaceRouteCandidateBankPenalty(RouteCandidate candidate) { + int minimumBankDistance = surfaceBankDistance(settings.surface().minimumWidth()); + int maximumBankDistance = surfaceBankDistance(settings.surface().maximumWidth()); + int maximumBankRise = Math.addExact( + settings.surface().maximumIncision(), + (int) StrictMath.ceil(settings.surface().minimumTerrainBlendWidth() * 0.5D) + ); + HydrologyPoint point = candidate.point(); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null) { + return Double.POSITIVE_INFINITY; + } + if (terrain.slope() >= settings.hydraulics().waterfallMinimumDrop()) { + return 0D; + } + int head = Math.subtractExact( + terrain.naturalHeight(), + settings.surface().maximumSurfaceInset() + ); + return surfaceRouteBankPenalty( + point, + candidate.tangent(), + minimumBankDistance, + maximumBankDistance, + head, + maximumBankRise + ); + } + + private double surfaceRouteBankPenalty( + HydrologyPoint point, + Direction tangent, + int minimumDistance, + int maximumDistance, + int head, + int maximumBankRise + ) { + double penalty = 0D; + for (int distance = minimumDistance; distance <= maximumDistance; distance++) { + penalty += surfaceRouteBankPenalty(point, tangent, distance, head, maximumBankRise); + penalty += surfaceRouteBankPenalty(point, tangent, -distance, head, maximumBankRise); + } + return penalty; + } + + private double surfaceRouteBankPenalty( + HydrologyPoint point, + Direction tangent, + int signedDistance, + int head, + int maximumBankRise + ) { + HydrologyTerrainSample bank = surfaceBankTerrain(point, tangent, signedDistance); + if (bank == null) { + return 1.0E12D; + } + int rise = bank.naturalHeight() - head; + int shortfall = 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; + } + + private int surfaceBankDistance(int width) { + int channelRadius = (int) StrictMath.ceil(width / 2D); + return Math.max( + channelRadius + 1, + (int) StrictMath.ceil( + channelRadius * SURFACE_MAXIMUM_WIDTH_SCALE + settings.surface().shoreWidth() + ) + 1 + ); + } + + private 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); + } + + private List smoothSurfaceGuide(List guide) { + if (guide.size() < 3) { + return guide; + } + List current = guide; + int passes = Math.max(2, 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; + } + + private List meanderSurfaceGuide( + List guide, + long geometryId, + int refinement + ) { + HydrologyPlannerSettings.Meanders meanders = 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 = settings.routing().sampleSpacing() * Math.min( + 0.16D, + meanders.maximumOffsetRatio() * 0.32D + ); + double primaryPhase = HydrologyHash.unit(HydrologyHash.mix(geometryId, ROUTE_WORM_PRIMARY_SALT)) + * StrictMath.PI * 2D; + double detailPhase = HydrologyHash.unit(HydrologyHash.mix(geometryId, ROUTE_WORM_DETAIL_SALT)) + * StrictMath.PI * 2D; + double totalLength = guide.getLast().distance(); + double taperLength = Math.max( + refinement * 8D, + Math.min(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); + Direction 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); + } + + private Direction 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 Direction(1D, 0D) + : new Direction(deltaX / length, deltaZ / length); + } + + private CoursePath undergroundCoursePath(List edges, RiverOutlet outlet) { + ArrayList points = new ArrayList<>(); + ArrayList pairEdges = new ArrayList<>(); + for (DrainageEdge edge : edges) { + for (int pointIndex = 0; pointIndex < edge.centerline().size(); pointIndex++) { + HydrologyPoint point = edge.centerline().get(pointIndex); + if (points.isEmpty()) { + points.add(point); + } else if (pointIndex > 0) { + points.add(point); + pairEdges.add(edge); + } + } + } + return points.size() >= 2 && pairEdges.size() == points.size() - 1 + ? new CoursePath( + List.copyOf(points), + List.copyOf(pairEdges), + List.copyOf(edges), + outlet, + true, + false, + false + ) + : null; + } + + private 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); + } + + private 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; + } + + private int[] surfaceMaximumHeads( + long courseId, + String profileKey, + CoursePath path, + int[] widths + ) { + HydrologyPoint origin = path.points().getFirst(); + int surfaceInset = path.containedSurface() + ? containedSurfaceInset() + : sampleGeometry( + HydrologyGeometrySampler.Field.SURFACE_INSET, + profileKey, + origin.x(), + origin.z(), + courseId, + settings.surface().minimumSurfaceInset(), + settings.surface().maximumSurfaceInset() + ); + int[] maximumHeads = new int[path.points().size()]; + for (int pointIndex = 0; pointIndex < path.points().size(); pointIndex++) { + if (path.reachesOutlet() + && path.outlet().directOcean() + && pointIndex == path.points().size() - 1) { + maximumHeads[pointIndex] = outletHead(path.outlet()); + continue; + } + if (!path.reachesOutlet() && pointIndex == path.points().size() - 1) { + maximumHeads[pointIndex] = outletHead(path.outlet()); + continue; + } + HydrologyPoint point = path.points().get(pointIndex); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + int terrainHead = terrain == null + ? Integer.MIN_VALUE + : Math.subtractExact(terrain.naturalHeight(), surfaceInset); + maximumHeads[pointIndex] = path.reachesOutlet() && path.outlet().directOcean() + ? Math.max(outletHead(path.outlet()), terrainHead) + : terrainHead; + } + constrainSurfaceSpanHeads(path, maximumHeads, widths); + return maximumHeads; + } + + private void constrainSurfaceSpanHeads(CoursePath path, int[] maximumHeads, int[] widths) { + int minimumIncision = settings.surface().minimumSurfaceInset(); + for (int pairIndex = 0; pairIndex < path.points().size() - 1; pairIndex++) { + boolean mouthApproach = surfaceMouthApproachPair(path, pairIndex); + HydrologyPoint start = path.points().get(pairIndex); + HydrologyPoint end = path.points().get(pairIndex + 1); + List raster = rasterLine(start, end); + Direction tangent = direction(start.x(), start.z(), end.x(), end.z()); + int bankDistance = surfaceBankDistance(widths[pairIndex]); + int spanCeiling = Integer.MAX_VALUE; + for (HydrologyPoint point : raster) { + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null) { + spanCeiling = Integer.MIN_VALUE; + break; + } + spanCeiling = Math.min( + spanCeiling, + Math.subtractExact(terrain.naturalHeight(), minimumIncision) + ); + for (int bankOffset = -bankDistance; bankOffset <= bankDistance; bankOffset++) { + HydrologyTerrainSample corridor = surfaceBankTerrain(point, tangent, bankOffset); + if (corridor == null) { + if (mouthApproach) { + continue; + } + spanCeiling = Integer.MIN_VALUE; + break; + } + spanCeiling = Math.min( + spanCeiling, + Math.subtractExact(corridor.naturalHeight(), minimumIncision) + ); + } + if (spanCeiling == Integer.MIN_VALUE) { + break; + } + } + if (spanCeiling == Integer.MIN_VALUE) { + maximumHeads[pairIndex] = Integer.MIN_VALUE; + maximumHeads[pairIndex + 1] = Integer.MIN_VALUE; + continue; + } + if (mouthApproach) { + spanCeiling = Math.max(outletHead(path.outlet()), spanCeiling); + } + maximumHeads[pairIndex] = Math.min(maximumHeads[pairIndex], spanCeiling); + maximumHeads[pairIndex + 1] = Math.min(maximumHeads[pairIndex + 1], spanCeiling); + } + } + + private int containedSurfaceInset() { + return settings.surface().maximumSurfaceInset(); + } + + private boolean ridgeLengthAccepted(CoursePath path, int[] heads) { + if (!settings.surface().ridgeTunnelsEnabled()) { + for (int index = 0; index < path.points().size() - 1; index++) { + if (requiresBore( + path.points().get(index), + path.points().get(index + 1), + heads[index], + heads[index + 1] + )) { + return false; + } + } + return true; + } + double boreRunLength = 0D; + boolean subterrainPassage = false; + for (int index = 0; index < path.points().size() - 1; index++) { + if (isDirectMouthPair(path, index)) { + boreRunLength = 0D; + subterrainPassage = false; + continue; + } + HydrologyPoint first = path.points().get(index); + HydrologyPoint second = path.points().get(index + 1); + double pairLength = StrictMath.hypot(second.x() - first.x(), second.z() - first.z()); + boolean bore = requiresBore( + first, + second, + heads[index], + heads[index + 1] + ); + if (!bore) { + boreRunLength = 0D; + subterrainPassage = false; + continue; + } + if (traversesSeaLevelTerrain(first, second)) { + boreRunLength = 0D; + subterrainPassage = true; + continue; + } + if (subterrainPassage) { + continue; + } + boreRunLength += pairLength; + if (boreRunLength > settings.surface().maximumRidgeTunnelLength()) { + return false; + } + } + return true; + } + + private HydrologyFeatureType surfaceSegmentType( + CoursePath path, + int index, + int upstreamHead, + int downstreamHead, + int width, + int depth + ) { + boolean bore = !isDirectMouthPair(path, index) && requiresBore( + path.points().get(index), + path.points().get(index + 1), + upstreamHead, + downstreamHead + ); + int drop = upstreamHead - downstreamHead; + if (bore) { + return drop > 0 ? HydrologyFeatureType.UNDERGROUND_DROP : HydrologyFeatureType.RIDGE_BORE; + } + if (drop == 0) { + return HydrologyFeatureType.SURFACE_POOL; + } + if (drop <= settings.hydraulics().riffleDrop()) { + return HydrologyFeatureType.RIFFLE; + } + HydrologyPoint first = path.points().get(index); + HydrologyPoint second = path.points().get(index + 1); + double length = StrictMath.hypot(second.x() - first.x(), second.z() - first.z()); + int rasterLength = Math.max(Math.abs(second.x() - first.x()), Math.abs(second.z() - first.z())) + 1; + int maximumCascadeStep = settings.geometry().drops().stepLimit(HydrologyFeatureType.CASCADE); + int minimumRun = Math.floorDiv(drop + maximumCascadeStep - 1, maximumCascadeStep) + 1; + if (drop <= settings.hydraulics().maximumGradualDrop() + && drop < settings.hydraulics().waterfallMinimumDrop() + && rasterLength >= minimumRun + && length <= settings.hydraulics().maximumGradualLength()) { + return HydrologyFeatureType.CASCADE; + } + return HydrologyFeatureType.WATERFALL; + } + + private boolean distributeSurfaceHeadLoss( + CoursePath path, + int[] heads, + int[] maximumHeads, + int minimumHead + ) { + for (int index = heads.length - 2; index >= 0; index--) { + int downstreamHead = heads[index + 1]; + int supportedDrop = surfacePairDropCapacity(path, index); + int supportedHead = Math.addExact(downstreamHead, supportedDrop); + heads[index] = Math.min(heads[index], Math.min(maximumHeads[index], supportedHead)); + if (heads[index] < downstreamHead || heads[index] < minimumHead) { + return false; + } + } + return true; + } + + private boolean surfaceHeadLossSupportedByTerrain( + CoursePath path, + int[] heads, + int[] widths, + int[] depths + ) { + if (heads.length != path.points().size() + || widths.length != path.points().size() - 1 + || depths.length != path.points().size() - 1) { + return false; + } + return surfaceHeadLossSupportedByTerrain(path, heads, widths, depths, 0, heads.length - 2); + } + + private boolean surfaceHeadLossSupportedByTerrain( + CoursePath path, + int[] heads, + int[] widths, + int[] depths, + int startPairIndex, + int endPairIndex + ) { + for (int index = startPairIndex; index <= endPairIndex; index++) { + int drop = heads[index] - heads[index + 1]; + if (drop <= 0) { + continue; + } + if (drop > surfacePairDropCapacity(path, index)) { + return false; + } + HydrologyFeatureType type = surfaceSegmentType( + path, + index, + heads[index], + heads[index + 1], + widths[index], + depths[index] + ); + if (type == HydrologyFeatureType.WATERFALL + && !isDirectMouthPair(path, index) + && waterfallLipIndex(rasterLine(path.points().get(index), path.points().get(index + 1)), drop) < 0) { + return false; + } + } + return true; + } + + private int surfacePairDropCapacity(CoursePath path, int index) { + HydrologyPoint upstream = path.points().get(index); + HydrologyPoint downstream = path.points().get(index + 1); + HydrologyTerrainSample upstreamTerrain = sampleBasis(upstream.x(), upstream.z()); + HydrologyTerrainSample downstreamTerrain = sampleBasis(downstream.x(), downstream.z()); + int downstreamNaturalHeight = isDirectMouthPair(path, index) + ? outletHead(path.outlet()) + : downstreamTerrain.naturalHeight(); + long terrainLoss = (long) upstreamTerrain.naturalHeight() - downstreamNaturalHeight; + if (terrainLoss <= 0L) { + return 0; + } + List raster = rasterLine(upstream, downstream); + int boundedTerrainLoss = (int) Math.min(Integer.MAX_VALUE, terrainLoss); + int gradualCapacity = Math.min(settings.hydraulics().riffleDrop(), boundedTerrainLoss); + double length = StrictMath.hypot(downstream.x() - upstream.x(), downstream.z() - upstream.z()); + if (length <= settings.hydraulics().maximumGradualLength()) { + int rasterCapacity = Math.multiplyExact( + raster.size() - 1, + settings.geometry().drops().stepLimit(HydrologyFeatureType.CASCADE) + ); + gradualCapacity = Math.min( + boundedTerrainLoss, + Math.min(settings.hydraulics().maximumGradualDrop(), rasterCapacity) + ); + } + if (isDirectMouthPair(path, index)) { + return Math.max(gradualCapacity, boundedTerrainLoss); + } + int waterfallIndex = waterfallLipIndex(raster, settings.hydraulics().waterfallMinimumDrop()); + if (waterfallIndex < 0) { + return gradualCapacity; + } + HydrologyTerrainSample lip = sampleBasis(raster.get(waterfallIndex).x(), raster.get(waterfallIndex).z()); + HydrologyTerrainSample receiver = sampleBasis( + raster.get(waterfallIndex + 1).x(), + raster.get(waterfallIndex + 1).z() + ); + int waterfallCapacity = lip.naturalHeight() - receiver.naturalHeight(); + return Math.max(gradualCapacity, Math.min(boundedTerrainLoss, waterfallCapacity)); + } + + private boolean isDirectMouthPair(CoursePath path, int index) { + return path.reachesOutlet() + && path.outlet().directOcean() + && index == path.points().size() - 2; + } + + private boolean requiresBore(HydrologyPoint point, int head) { + HydrologyTerrainSample terrain = sampleBasis(point.x(), point.z()); + int permittedIncision = permittedSurfaceIncision(terrain); + return terrain.naturalHeight() <= settings.seaLevel() + || terrain.naturalHeight() - head > permittedIncision; + } + + private boolean requiresBore( + HydrologyPoint start, + HydrologyPoint end, + int upstreamHead, + int downstreamHead + ) { + List raster = rasterLine(start, end); + for (HydrologyPoint point : raster) { + int head = surfaceBoreHead(point, upstreamHead, downstreamHead); + if (requiresBore(point, head)) { + return true; + } + } + return false; + } + + private boolean traversesSeaLevelTerrain(HydrologyPoint start, HydrologyPoint end) { + for (HydrologyPoint point : rasterLine(start, end)) { + HydrologyTerrainSample terrain = sampleBasis(point.x(), point.z()); + if (terrain.naturalHeight() <= settings.seaLevel()) { + return true; + } + } + return false; + } + + private int surfaceBoreHead(HydrologyPoint point, int upstreamHead, int downstreamHead) { + if (upstreamHead == downstreamHead) { + return upstreamHead; + } + HydrologyTerrainSample terrain = sampleBasis(point.x(), point.z()); + int terrainHead = Math.subtractExact( + terrain.naturalHeight(), + settings.surface().minimumSurfaceInset() + ); + return Math.max(downstreamHead, Math.min(upstreamHead, terrainHead)); + } + + private boolean surfaceCorridorSupported( + CoursePath path, + int[] heads, + int[] widths + ) { + for (int pairIndex = 0; pairIndex < widths.length; pairIndex++) { + HydrologyPoint start = path.points().get(pairIndex); + HydrologyPoint end = path.points().get(pairIndex + 1); + if (heads[pairIndex] > heads[pairIndex + 1] + || surfaceMouthApproachPair(path, pairIndex) + || requiresBore( + start, + end, + heads[pairIndex], + heads[pairIndex + 1] + )) { + continue; + } + List raster = rasterLine(start, end); + Direction tangent = direction(start.x(), start.z(), end.x(), end.z()); + int bankDistance = surfaceBankDistance(widths[pairIndex]); + int maximumBankRise = path.containedSurface() + ? Integer.MAX_VALUE + : Math.addExact( + settings.surface().maximumIncision(), + (int) StrictMath.ceil(settings.surface().minimumTerrainBlendWidth() * 0.5D) + ); + for (int pointIndex = 0; pointIndex < raster.size(); pointIndex++) { + HydrologyPoint point = raster.get(pointIndex); + double progress = raster.size() < 2 ? 0D : pointIndex / (double) (raster.size() - 1); + int head = (int) StrictMath.round( + heads[pairIndex] + (heads[pairIndex + 1] - heads[pairIndex]) * progress + ); + HydrologyTerrainSample center = sampleLandBasis(point.x(), point.z()); + if (center == null) { + return false; + } + int permittedIncision = permittedSurfaceIncision(center); + int incision = center.naturalHeight() - head; + int minimumIncision = settings.surface().minimumSurfaceInset(); + if (incision < minimumIncision || incision > permittedIncision) { + return false; + } + boolean naturalTransition = center.slope() >= settings.hydraulics().waterfallMinimumDrop(); + boolean positiveBank = surfaceBankSupported( + point, + tangent, + bankDistance, + head, + maximumBankRise + ); + boolean negativeBank = surfaceBankSupported( + point, + tangent, + -bankDistance, + head, + maximumBankRise + ); + if (!naturalTransition && (!positiveBank || !negativeBank)) { + return false; + } + } + } + return true; + } + + private boolean surfaceMouthApproachPair(CoursePath path, int pairIndex) { + if (!path.reachesOutlet() || !path.outlet().directOcean()) { + return false; + } + double distance = 0D; + for (int index = pairIndex; index < path.points().size() - 1; index++) { + HydrologyPoint start = path.points().get(index); + HydrologyPoint end = path.points().get(index + 1); + distance += StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); + if (distance > settings.outlets().mouthLevelingDistance()) { + return false; + } + } + return true; + } + + private boolean surfaceBankSupported( + HydrologyPoint point, + Direction 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); + HydrologyTerrainSample bank = sampleLandBasis(bankX, bankZ); + if (bank == null) { + return false; + } + int rise = bank.naturalHeight() - head; + return rise >= SURFACE_MINIMUM_NATURAL_BANK_RISE && (rise <= maximumBankRise + || bank.slope() >= settings.hydraulics().waterfallMinimumDrop()); + } + + private HydrologyTerrainSample surfaceBankTerrain( + HydrologyPoint point, + Direction tangent, + int signedDistance + ) { + HydrologyPoint bankPoint = surfaceBankPoint(point, tangent, signedDistance); + int bankX = bankPoint.x(); + int bankZ = bankPoint.z(); + return sampleLandBasis(bankX, bankZ); + } + + private HydrologyPoint surfaceBankPoint(HydrologyPoint point, Direction tangent, int signedDistance) { + return new HydrologyPoint( + (int) StrictMath.round(point.x() - tangent.z() * signedDistance), + point.y(), + (int) StrictMath.round(point.z() + tangent.x() * signedDistance) + ); + } + + private void levelUndergroundMouth(CoursePath path, int[] heads, int outletHead) { + heads[heads.length - 1] = outletHead; + int levelingDistance = settings.outlets().mouthLevelingDistance(); + if (levelingDistance == 0) { + return; + } + double remaining = 0D; + int initialHead = heads[0]; + for (int index = path.points().size() - 2; index >= 0; index--) { + HydrologyPoint current = path.points().get(index); + HydrologyPoint downstream = path.points().get(index + 1); + remaining += StrictMath.hypot(downstream.x() - current.x(), downstream.z() - current.z()); + if (remaining > levelingDistance) { + break; + } + double ratio = remaining / levelingDistance; + int cap = outletHead + (int) StrictMath.ceil((initialHead - outletHead) * ratio); + heads[index] = Math.min(heads[index], cap); + } + for (int index = 1; index < heads.length; index++) { + heads[index] = Math.min(heads[index - 1], heads[index]); + } + heads[heads.length - 1] = outletHead; + } + + private void poolHeads( + CoursePath path, + int[] heads, + int[] maximumHeads, + int targetLength + ) { + if (heads.length < 3) { + return; + } + int[] raw = heads.clone(); + double distanceSinceDrop = 0D; + for (int index = 1; index < heads.length - 1; index++) { + HydrologyPoint upstream = path.points().get(index - 1); + HydrologyPoint current = path.points().get(index); + distanceSinceDrop += StrictMath.hypot(current.x() - upstream.x(), current.z() - upstream.z()); + int heldHead = heads[index - 1]; + if (raw[index] >= heldHead) { + heads[index] = heldHead; + continue; + } + if (maximumHeads != null && heldHead > maximumHeads[index]) { + heads[index] = raw[index]; + distanceSinceDrop = 0D; + continue; + } + int immediateDrop = raw[index - 1] - raw[index]; + if (immediateDrop >= settings.hydraulics().waterfallMinimumDrop()) { + heads[index] = raw[index]; + distanceSinceDrop = 0D; + continue; + } + if (distanceSinceDrop < targetLength) { + heads[index] = heldHead; + continue; + } + int transition = Math.min( + heldHead - raw[index], + settings.hydraulics().maximumGradualDrop() + ); + int transitionedHead = heldHead - Math.max(1, transition); + heads[index] = maximumHeads == null + ? transitionedHead + : Math.max(raw[index], Math.min(maximumHeads[index], transitionedHead)); + distanceSinceDrop = 0D; + } + } + + private int targetPoolLength(long courseId, String profileKey, CoursePath path) { + HydrologyPoint origin = path.points().getFirst(); + return sampleGeometry( + HydrologyGeometrySampler.Field.TARGET_POOL_LENGTH, + profileKey, + origin.x(), + origin.z(), + courseId, + settings.hydraulics().minimumTargetPoolLength(), + settings.hydraulics().maximumTargetPoolLength() + ); + } + + private void appendOutletSegments( + RiverCourseType courseType, + long courseId, + CoursePath path, + int head, + int[] widths, + int[] depths, + List segments + ) { + RiverOutlet outlet = path.outlet(); + int width = widths.length == 0 ? 1 : widths[widths.length - 1]; + int depth = depths.length == 0 ? 1 : depths[depths.length - 1]; + if (courseType != RiverCourseType.SURFACE + && outlet.type() == HydrologyFeatureType.INLAND_GROTTO) { + width = settings.underground().minimumWidth(); + depth = settings.underground().minimumDepth(); + } + HydrologyPoint pathEnd = withY(path.points().getLast(), head); + HydrologyPoint landward = withY(outlet.landwardPoint(), head); + if (courseType == RiverCourseType.SURFACE + && outlet.type() == HydrologyFeatureType.COASTAL_GROTTO) { + appendSurfaceCoastalGrotto( + courseId, + outlet, + head, + width, + depth, + pathEnd, + landward, + segments + ); + return; + } + if (outlet.type() == HydrologyFeatureType.MOUTH) { + if (courseType == RiverCourseType.SURFACE) { + appendSurfaceMouth(courseId, outlet, head, width, depth, pathEnd, segments); + return; + } + HydrologyPoint connection = withY(outlet.connectionPoint(), head); + addFlatSegment( + courseId, + HydrologyFeatureType.MOUTH, + head, + width, + depth, + line(pathEnd, connection, settings.routing().refinementSpacing()), + segments + ); + return; + } + if (courseType == RiverCourseType.SURFACE && surfaceSinkhole(outlet)) { + appendSurfaceSinkhole(courseId, outlet, head, width, depth, pathEnd, landward, segments); + return; + } + HydrologyPoint connection = withY(outlet.connectionPoint(), head); + if (pathEnd.x() != landward.x() || pathEnd.z() != landward.z()) { + HydrologyFeatureType connectorType = courseType == RiverCourseType.SURFACE + ? HydrologyFeatureType.MOUTH + : HydrologyFeatureType.UNDERGROUND_POOL; + addFlatSegment( + courseId, + connectorType, + head, + width, + depth, + line(pathEnd, landward, settings.routing().refinementSpacing()), + segments + ); + } + List centerline = landward.x() == connection.x() && landward.z() == connection.z() + ? List.of(landward) + : List.of(landward, connection); + addFlatSegment(courseId, outlet.type(), head, width, depth, centerline, segments); + } + + private void appendSurfaceMouth( + long courseId, + RiverOutlet outlet, + int surfaceHead, + int width, + int depth, + HydrologyPoint pathEnd, + List segments + ) { + int seaLevel = outletHead(outlet); + HydrologyPoint connection = withY(outlet.connectionPoint(), seaLevel); + if (surfaceHead != seaLevel) { + throw new IllegalStateException("A surface mouth reached the coastal crossing above sea level."); + } + addFlatSegment( + courseId, + HydrologyFeatureType.MOUTH, + seaLevel, + width, + depth, + line(pathEnd, connection, settings.routing().refinementSpacing()), + segments + ); + } + + private void appendSurfaceCoastalGrotto( + long courseId, + RiverOutlet outlet, + int surfaceHead, + int width, + int depth, + HydrologyPoint pathEnd, + HydrologyPoint landward, + List segments + ) { + if (pathEnd.x() != landward.x() || pathEnd.z() != landward.z()) { + addFlatSegment( + courseId, + HydrologyFeatureType.SURFACE_POOL, + surfaceHead, + width, + depth, + line(pathEnd, landward, settings.routing().refinementSpacing()), + segments + ); + } + int seaLevel = outletHead(outlet); + HydrologyPoint connection = withY(outlet.connectionPoint(), seaLevel); + if (surfaceHead > seaLevel) { + addFallingDropSegments( + courseId, + segments.size(), + surfaceHead, + seaLevel, + width, + depth, + List.of(landward, connection), + true, + segments + ); + } + addFlatSegment( + courseId, + HydrologyFeatureType.COASTAL_GROTTO, + seaLevel, + width, + depth, + List.of(withY(outlet.landwardPoint(), seaLevel), connection), + segments + ); + } + + private void appendSurfaceSinkhole( + long courseId, + RiverOutlet outlet, + int surfaceHead, + int surfaceWidth, + int surfaceDepth, + HydrologyPoint pathEnd, + HydrologyPoint landward, + List segments + ) { + if (pathEnd.x() != landward.x() || pathEnd.z() != landward.z()) { + addFlatSegment( + courseId, + HydrologyFeatureType.SURFACE_POOL, + surfaceHead, + surfaceWidth, + surfaceDepth, + line(pathEnd, landward, settings.routing().refinementSpacing()), + segments + ); + } + int undergroundHead = outletHead(outlet); + int throatWidth = Math.max(settings.underground().minimumWidth(), surfaceWidth - 1); + int throatDepth = settings.underground().minimumDepth(); + HydrologyPoint receiving = withY(outlet.connectionPoint(), undergroundHead); + List descent = sinkholeDescent( + courseId, + landward, + receiving, + surfaceHead - undergroundHead + ); + List gradedDescent = gradedDropCenterline( + rasterCenterline(descent), + surfaceHead, + undergroundHead, + settings.geometry().drops(), + settings.geometry().drops().stepLimit(HydrologyFeatureType.SINKHOLE) + ); + int descentWidth = Math.max(throatWidth, hydraulicDropWidth(throatWidth, true)); + segments.add(new HydraulicSegment( + HydrologyHash.mix( + worldSeed, + SEGMENT_SALT, + courseId, + segments.size(), + HydrologyFeatureType.SINKHOLE.ordinal() + ), + courseId, + HydrologyFeatureType.SINKHOLE, + surfaceHead, + undergroundHead, + descentWidth, + throatDepth, + false, + true, + gradedDescent + )); + addFlatSegment( + courseId, + HydrologyFeatureType.INLAND_GROTTO, + undergroundHead, + throatWidth, + throatDepth, + List.of(receiving), + segments + ); + } + + private List sinkholeDescent( + long courseId, + HydrologyPoint landward, + HydrologyPoint receiving, + int drop + ) { + int desiredRun = Math.max( + 4, + (int) StrictMath.ceil(drop / (double) settings.geometry().drops().maximumCascadeStep()) + 1 + ); + double startX = landward.x() - receiving.x(); + double startZ = landward.z() - receiving.z(); + double startRadius = StrictMath.hypot(startX, startZ); + double startAngle = startRadius <= 0D + ? HydrologyHash.unit(HydrologyHash.mix(worldSeed, courseId, SEGMENT_SALT)) * StrictMath.PI * 2D + : StrictMath.atan2(startZ, startX); + double maximumRadius = Math.max(2D, settings.outlets().inlandGrotto().horizontalRadius() - 1D); + int turns = Math.max(1, (int) StrictMath.ceil(desiredRun / (StrictMath.PI * maximumRadius))); + ArrayList points = new ArrayList<>(desiredRun * 4 + 1); + int samples = desiredRun * 4; + for (int sample = 0; sample <= samples; sample++) { + double progress = sample / (double) samples; + double radius = Math.min( + maximumRadius, + startRadius * (1D - progress) + maximumRadius * StrictMath.sin(StrictMath.PI * progress) + ); + double angle = startAngle + turns * StrictMath.PI * 2D * progress; + int x = (int) StrictMath.round(receiving.x() + StrictMath.cos(angle) * radius); + int z = (int) StrictMath.round(receiving.z() + StrictMath.sin(angle) * radius); + if (points.isEmpty() || points.getLast().x() != x || points.getLast().z() != z) { + points.add(new HydrologyPoint(x, landward.y(), z)); + } + } + if (points.getLast().x() != receiving.x() || points.getLast().z() != receiving.z()) { + points.add(receiving); + } + return List.copyOf(points); + } + + private boolean surfaceSinkhole(RiverOutlet outlet) { + return outlet.type() == HydrologyFeatureType.INLAND_GROTTO + && settings.outlets().surfaceSinkholesEnabled(); + } + + private void addFlatSegment( + long courseId, + HydrologyFeatureType type, + int head, + int width, + int depth, + List centerline, + List segments + ) { + long segmentId = HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, segments.size(), type.ordinal()); + segments.add(new HydraulicSegment( + segmentId, + courseId, + type, + head, + head, + width, + depth, + false, + false, + centerline + )); + } + + private void compileDeepFluidCourses( + HydrologyTileKey key, + List courses, + List diagnostics + ) { + for (HydrologyPlannerSettings.DeepFluid deepFluid : settings.deepFluids()) { + if (!deepFluid.enabled() || deepFluid.maximumPerTile() == 0) { + continue; + } + ArrayList sites = deepSites(key, deepFluid, diagnostics); + int target = Math.min( + deepFluid.maximumPerTile(), + expectedCount(deepFluid.density(), HydrologyHash.mix( + worldSeed, + DEEP_FLUID_SALT, + HydrologyHash.text(deepFluid.id()), + key.tileX(), + key.tileZ() + )) + ); + for (int index = 0; index < sites.size(); index++) { + DeepSite site = sites.get(index); + if (index >= target) { + addDeepDiagnostic( + deepFluid, + site, + HydrologyCandidateRejection.SOURCE_QUOTA, + diagnostics + ); + continue; + } + RiverCourse course = buildDeepFluidCourse(deepFluid, site, index); + if (course != null) { + courses.add(course); + } else { + addDeepDiagnostic( + deepFluid, + site, + HydrologyCandidateRejection.VOLUME_LIMIT, + diagnostics + ); + } + } + } + } + + private ArrayList deepSites( + HydrologyTileKey key, + HydrologyPlannerSettings.DeepFluid deepFluid, + List diagnostics + ) { + int tileSize = settings.routing().tileSize(); + int minimumX = key.minimumBlockX(tileSize); + int minimumZ = key.minimumBlockZ(tileSize); + int spacing = deepFluid.spacing(); + long profileSeed = HydrologyHash.mix(worldSeed, DEEP_FLUID_SALT, HydrologyHash.text(deepFluid.id())); + int xOffset = HydrologyHash.between( + HydrologyHash.mix(profileSeed, DEEP_FLUID_X_OFFSET_SALT), + 0, + spacing - 1 + ); + int zOffset = HydrologyHash.between( + HydrologyHash.mix(profileSeed, DEEP_FLUID_Z_OFFSET_SALT), + 0, + spacing - 1 + ); + long firstCellX = ceilDiv((long) minimumX - xOffset, spacing); + long firstCellZ = ceilDiv((long) minimumZ - zOffset, spacing); + long lastCellX = Math.floorDiv((long) minimumX + tileSize - 1L - xOffset, spacing); + long lastCellZ = Math.floorDiv((long) minimumZ + tileSize - 1L - zOffset, spacing); + ArrayList sites = new ArrayList<>(); + for (long cellZ = firstCellZ; cellZ <= lastCellZ; cellZ++) { + for (long cellX = firstCellX; cellX <= lastCellX; cellX++) { + long stable = HydrologyHash.mix( + worldSeed, + DEEP_FLUID_SALT, + HydrologyHash.text(deepFluid.id()), + cellX, + cellZ + ); + int x = Math.toIntExact(xOffset + cellX * spacing); + int z = Math.toIntExact(zOffset + cellZ * spacing); + int head = sampleGeometry( + HydrologyGeometrySampler.Field.DEEP_FLUID_HEIGHT, + deepFluid.id(), + x, + z, + stable, + deepFluid.minimumY(), + deepFluid.maximumY() + ); + DeepSite site = new DeepSite(x, z, head, stable); + HydrologyTerrainSample terrain = sampleLandBasis(x, z); + int verticalRadius = HydrologyHash.between( + HydrologyHash.mix(stable, 3), + deepFluid.minimumVerticalRadius(), + deepFluid.maximumVerticalRadius() + ); + if (!deepSiteFits(terrain, deepFluid, head, verticalRadius, minimumY)) { + if (terrain != null && !terrain.ocean()) { + addDeepDiagnostic( + deepFluid, + site, + HydrologyCandidateRejection.CAVE_CONTAINMENT, + diagnostics + ); + } + continue; + } + sites.add(site); + } + } + sites.sort(Comparator.comparingLong(DeepSite::stableId)); + return sites; + } + + private long ceilDiv(long value, int divisor) { + long quotient = Math.floorDiv(value, divisor); + return quotient * divisor == value ? quotient : quotient + 1L; + } + + static boolean deepSiteFits( + HydrologyTerrainSample terrain, + HydrologyPlannerSettings.DeepFluid deepFluid, + int head, + int verticalRadius, + int minimumY + ) { + if (terrain == null || terrain.ocean()) { + return false; + } + int lowerEnvelope = deepFluid.containedPools() + ? Math.max(deepFluid.channelDepth(), verticalRadius) + : deepFluid.channelDepth(); + return (long) head - lowerEnvelope > minimumY + && head + deepFluid.headroom() < terrain.naturalHeight(); + } + + private void addDeepDiagnostic( + HydrologyPlannerSettings.DeepFluid deepFluid, + DeepSite site, + HydrologyCandidateRejection rejection, + List diagnostics + ) { + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(site.stableId(), DIAGNOSTIC_SALT, rejection.ordinal()), + HydrologyCandidateKind.DEEP_FLUID, + deepFluid.containedPools() ? HydrologyFeatureType.DEEP_POOL : HydrologyFeatureType.DEEP_CHANNEL, + new HydrologyPoint(site.x(), site.head(), site.z()), + rejection + )); + } + + private RiverCourse buildDeepFluidCourse( + HydrologyPlannerSettings.DeepFluid deepFluid, + DeepSite site, + int siteIndex + ) { + long courseId = HydrologyHash.mix( + worldSeed, + COURSE_SALT, + DEEP_FLUID_SALT, + HydrologyHash.text(deepFluid.id()), + site.stableId(), + siteIndex + ); + int horizontalRadius = HydrologyHash.between( + HydrologyHash.mix(site.stableId(), 2), + deepFluid.minimumHorizontalRadius(), + deepFluid.maximumHorizontalRadius() + ); + int verticalRadius = HydrologyHash.between( + HydrologyHash.mix(site.stableId(), 3), + deepFluid.minimumVerticalRadius(), + deepFluid.maximumVerticalRadius() + ); + ArrayList segments = new ArrayList<>(); + HydrologyPoint center = new HydrologyPoint(site.x(), site.head(), site.z()); + long usedVolume = 0L; + if (deepFluid.containedPools()) { + long poolVolume = HydrologyPlannerSettings.ellipsoidVolume( + horizontalRadius, + Math.max(deepFluid.channelDepth(), verticalRadius), + deepFluid.headroom() + ); + if (poolVolume > deepFluid.maximumVolume()) { + return null; + } + usedVolume = poolVolume; + long poolId = HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, HydrologyFeatureType.DEEP_POOL.ordinal()); + segments.add(new HydraulicSegment( + poolId, + courseId, + HydrologyFeatureType.DEEP_POOL, + site.head(), + site.head(), + horizontalRadius * 2 + 1, + Math.max(deepFluid.channelDepth(), verticalRadius), + false, + false, + List.of(center) + )); + } + if (deepFluid.shortChannels() && deepFluid.maximumChannelLength() > 0) { + int volumeBoundedMaximum = deepFluid.maximumChannelLength(); + while (volumeBoundedMaximum >= deepFluid.minimumChannelLength() + && usedVolume + deepChannelVolumeBound(deepFluid, volumeBoundedMaximum) + > deepFluid.maximumVolume()) { + volumeBoundedMaximum--; + } + if (volumeBoundedMaximum >= deepFluid.minimumChannelLength()) { + int length = HydrologyHash.between( + HydrologyHash.mix(site.stableId(), 4), + deepFluid.minimumChannelLength(), + volumeBoundedMaximum + ); + long channelId = HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, HydrologyFeatureType.DEEP_CHANNEL.ordinal()); + segments.add(new HydraulicSegment( + channelId, + courseId, + HydrologyFeatureType.DEEP_CHANNEL, + site.head(), + site.head(), + deepFluid.channelWidth(), + deepFluid.channelDepth(), + false, + false, + organicDeepChannelCenterline( + center, + length, + settings.routing().refinementSpacing(), + site.stableId() + ) + )); + } + } + if (segments.isEmpty()) { + return null; + } + return new RiverCourse( + courseId, + RiverCourseType.DEEP_FLUID, + OptionalLong.empty(), + OptionalLong.empty(), + deepFluid.id(), + 1, + List.of(), + segments + ); + } + + private long deepChannelVolumeBound(HydrologyPlannerSettings.DeepFluid deepFluid, int length) { + int radius = Math.max(1, deepFluid.channelWidth() / 2); + int points = (int) StrictMath.ceil( + length * StrictMath.sqrt(2D) / settings.routing().refinementSpacing() + ) + 1; + long horizontalEnvelope = (long) (radius * 2 + 1) * (radius * 2 + 1); + long verticalEnvelope = deepFluid.channelDepth() + deepFluid.headroom() + 1L; + return horizontalEnvelope * verticalEnvelope * points; + } + + private List organicDeepChannelCenterline( + HydrologyPoint start, + int length, + int spacing, + long stableId + ) { + double heading = HydrologyHash.unit(HydrologyHash.mix(stableId, DEEP_CHANNEL_HEADING_SALT)) + * StrictMath.PI * 2D; + double forwardX = StrictMath.cos(heading); + double forwardZ = StrictMath.sin(heading); + double lateralX = -forwardZ; + double lateralZ = forwardX; + double bendEnvelope = Math.max(1.5D, Math.min(8D, length * 0.3D)); + double firstBend = signedOrganicOffset(stableId, DEEP_CHANNEL_FIRST_BEND_SALT, bendEnvelope); + double secondBend = signedOrganicOffset(stableId, DEEP_CHANNEL_SECOND_BEND_SALT, bendEnvelope); + if (StrictMath.signum(firstBend) == StrictMath.signum(secondBend)) { + secondBend = -secondBend; + } + + double endX = start.x() + forwardX * length; + double endZ = start.z() + forwardZ * length; + double firstControlX = start.x() + forwardX * length / 3D + lateralX * firstBend; + double firstControlZ = start.z() + forwardZ * length / 3D + lateralZ * firstBend; + double secondControlX = start.x() + forwardX * length * 2D / 3D + lateralX * secondBend; + double secondControlZ = start.z() + forwardZ * length * 2D / 3D + lateralZ * secondBend; + int steps = Math.max(4, (int) StrictMath.ceil(length / (double) Math.max(2, spacing)) * 2); + ArrayList points = new ArrayList<>(steps + 1); + for (int step = 0; step <= steps; step++) { + double progress = step / (double) steps; + double inverse = 1D - progress; + double x = inverse * inverse * inverse * start.x() + + 3D * inverse * inverse * progress * firstControlX + + 3D * inverse * progress * progress * secondControlX + + progress * progress * progress * endX; + double z = inverse * inverse * inverse * start.z() + + 3D * inverse * inverse * progress * firstControlZ + + 3D * inverse * progress * progress * secondControlZ + + progress * progress * progress * endZ; + HydrologyPoint point = new HydrologyPoint( + (int) StrictMath.round(x), + start.y(), + (int) StrictMath.round(z) + ); + if (points.isEmpty() || point.x() != points.getLast().x() || point.z() != points.getLast().z()) { + points.add(point); + } + } + return List.copyOf(points); + } + + private double signedOrganicOffset(long stableId, long salt, double envelope) { + double unit = HydrologyHash.unit(HydrologyHash.mix(stableId, salt)); + double magnitude = envelope * (0.55D + unit * 0.45D); + return (HydrologyHash.mix(stableId, salt, 1L) & 1L) == 0L ? magnitude : -magnitude; + } + + private List line(HydrologyPoint start, HydrologyPoint end, int spacing) { + double distance = StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); + int steps = Math.max(1, (int) StrictMath.ceil(distance / spacing)); + ArrayList points = new ArrayList<>(steps + 1); + for (int step = 0; step <= steps; step++) { + 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); + HydrologyPoint point = new HydrologyPoint(x, start.y(), z); + if (points.isEmpty() || point.x() != points.getLast().x() || point.z() != points.getLast().z()) { + points.add(point); + } + } + return List.copyOf(points); + } + + private int scaledDimension( + int styledBase, + int minimum, + int maximum, + int discharge, + double multiplier + ) { + double flowScale = Math.min(1D, StrictMath.log(discharge + 1D) / StrictMath.log(9D)); + int base = styledBase + (int) StrictMath.round((maximum - styledBase) * flowScale); + return clamp((int) StrictMath.round(base * multiplier), minimum, maximum); + } + + private int maximumSurfaceDischarge(List edges) { + int maximum = 1; + for (DrainageEdge edge : edges) { + maximum = Math.max(maximum, edge.contributingSurfaceSources()); + } + return maximum; + } + + private int maximumUndergroundDischarge(List edges) { + int maximum = 1; + for (DrainageEdge edge : edges) { + maximum = Math.max(maximum, edge.contributingUndergroundSources()); + } + return maximum; + } + + private String chooseProfile(HydrologyTerrainSample terrain, long courseId) { + List profiles = terrain.preferredProfileKeys(); + int index = HydrologyHash.between(HydrologyHash.mix(worldSeed, courseId, HydrologyHash.text("profile")), 0, profiles.size() - 1); + return profiles.get(index); + } + + private int outletHead(RiverOutlet outlet) { + return outlet.directOcean() ? outlet.seaLevel() : outlet.connectionPoint().y(); + } + + private int settingsSeaLevel(HydrologyTerrainSample terrain) { + return settings.seaLevel(); + } + + private static HydrologyPoint withY(HydrologyPoint point, int y) { + return new HydrologyPoint(point.x(), y, point.z()); + } + + private static int clamp(int value, int minimum, int maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } + + private int sampleGeometry( + HydrologyGeometrySampler.Field field, + String profileKey, + int x, + int z, + long stableId, + int minimum, + int maximum + ) { + HydrologyGeometrySampler.Request request = new HydrologyGeometrySampler.Request( + field, + profileKey, + x, + z, + stableId, + minimum, + maximum + ); + PlanningSamples samples = planningSamples.get(); + if (samples != null) { + Integer cached = samples.geometry.get(request); + if (cached != null) { + return cached; + } + } + int sampled = geometrySampler.sample(request); + if (sampled < minimum || sampled > maximum) { + throw new IllegalStateException( + "Hydrology geometry sampler returned " + sampled + " outside " + minimum + ".." + maximum + + " for " + field + " at " + x + "," + z + "." + ); + } + if (samples != null) { + samples.geometry.put(request, sampled); + } + return sampled; + } + + private HydrologyTerrainSample sampleBasis(int blockX, int blockZ) { + PlanningSamples samples = planningSamples.get(); + if (samples == null) { + return naturalSampler == null + ? sampler.sample(blockX, blockZ) + : naturalSampler.sampleBasis(blockX, blockZ); + } + long packed = RiverFootprint.pack(blockX, blockZ); + HydrologyTerrainSample cached = samples.basis.get(packed); + if (cached != null || samples.basis.containsKey(packed)) { + return cached; + } + HydrologyTerrainSample sampled = naturalSampler == null + ? sampler.sample(blockX, blockZ) + : naturalSampler.sampleBasis(blockX, blockZ); + samples.basis.put(packed, sampled); + return sampled; + } + + private HydrologyTerrainSample sampleLandBasis(int blockX, int blockZ) { + HydrologyTerrainSample terrain = sampleBasis(blockX, blockZ); + return terrain == null || terrain.ocean() ? null : terrain; + } + + private HydrologyTerrainSample sampleDetailed(int blockX, int blockZ) { + PlanningSamples samples = planningSamples.get(); + if (samples == null) { + return sampler.sample(blockX, blockZ); + } + long packed = RiverFootprint.pack(blockX, blockZ); + HydrologyTerrainSample cached = samples.detailed.get(packed); + if (cached != null || samples.detailed.containsKey(packed)) { + return cached; + } + HydrologyTerrainSample sampled = sampler.sample(blockX, blockZ); + samples.detailed.put(packed, sampled); + return sampled; + } + + private HydrologyRoutingTerrainSampler.NaturalClassification classifyNatural(int blockX, int blockZ) { + PlanningSamples samples = planningSamples.get(); + if (samples == null) { + return naturalSampler.classifyNatural(blockX, blockZ); + } + long packed = RiverFootprint.pack(blockX, blockZ); + HydrologyRoutingTerrainSampler.NaturalClassification cached = samples.classifications.get(packed); + if (cached != null) { + return cached; + } + HydrologyRoutingTerrainSampler.NaturalClassification sampled = Objects.requireNonNull( + naturalSampler.classifyNatural(blockX, blockZ), + "Hydrology natural terrain classifier returned null at " + blockX + "," + blockZ + ); + samples.classifications.put(packed, sampled); + return sampled; + } + + private static final class PointRoutingTerrainSampler implements HydrologyRoutingTerrainSampler { + private final HydrologyTerrainSampler sampler; + + private PointRoutingTerrainSampler(HydrologyTerrainSampler sampler) { + this.sampler = Objects.requireNonNull(sampler, "sampler"); + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + int minimumX = request.minimumX(); + int minimumZ = request.minimumZ(); + int width = request.width(); + int spacing = request.spacing(); + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[Math.multiplyExact(width, width)]; + for (int gridZ = 0; gridZ < width; gridZ++) { + int z = Math.toIntExact((long) minimumZ + (long) gridZ * spacing); + for (int gridX = 0; gridX < width; gridX++) { + int x = Math.toIntExact((long) minimumX + (long) gridX * spacing); + samples[gridZ * width + gridX] = sampler.sample(x, z); + } + } + return samples; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + HydrologyTerrainSample terrain = sampler.sample(blockX, blockZ); + if (terrain == null) { + return NaturalClassification.UNAVAILABLE; + } + return terrain.ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND; + } + } + + private static final class PlanningSamples { + private final Long2ObjectOpenHashMap basis; + private final Long2ObjectOpenHashMap detailed; + private final Long2ObjectOpenHashMap classifications; + private final HashMap geometry; + private final HashMap minimumLandHeights; + private final HashMap> radialOffsets; + private final HashMap undergroundSegmentCaps; + private final HashMap routeAnchors; + private final HydrologyCaveCourseFilter.CandidateCache caveCandidates; + private final HydrologyCaveContainmentPlanner.ValidationCache caveValidations; + + private PlanningSamples() { + this.basis = new Long2ObjectOpenHashMap<>(); + this.detailed = new Long2ObjectOpenHashMap<>(); + this.classifications = new Long2ObjectOpenHashMap<>(); + this.geometry = new HashMap<>(); + this.minimumLandHeights = new HashMap<>(); + this.radialOffsets = new HashMap<>(); + this.undergroundSegmentCaps = new HashMap<>(); + this.routeAnchors = new HashMap<>(); + this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache(); + this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache(); + } + } + + private record GridOffset(int x, int z) { + } + + private record MinimumLandHeightKey(int x, int z, int radius) { + } + + private record UndergroundSegmentCapKey( + long courseId, + String profileKey, + int startX, + int startZ, + int endX, + int endZ, + int radius, + int minimumHeadroom, + int maximumHeadroom + ) { + } + + private record RefinedEdgeKey( + long upstreamId, + long downstreamId, + int upstreamX, + int upstreamZ, + int downstreamX, + int downstreamZ, + int continuationX, + int continuationZ, + int transverseCandidates, + boolean containedSurface + ) { + } + + private record Direction(double x, double z) { + } + + private record SurfaceCurvePoint(double x, double z, double distance) { + } + + private record RoutePosition(double x, double z, double baseX, double baseZ, Direction tangent) { + } + + private record RouteCandidate( + HydrologyPoint point, + double offset, + double localScore, + double terrainScore, + Direction tangent, + boolean terrainResolved + ) { + } + + private record RouteTerrainEstimate(int height, double terrainScore) { + } + + private record CurvatureRouteSelection(RouteCandidate[] route, int[] indices) { + private static CurvatureRouteSelection empty() { + return new CurvatureRouteSelection(new RouteCandidate[0], new int[0]); + } + } + + private record TerrainTransitionKey(HydrologyPoint start, HydrologyPoint end) { + } + + private record GridNode( + int index, + int gridX, + int gridZ, + int x, + int z, + long id, + HydrologyTerrainSample terrain + ) { + HydrologyPoint naturalPoint() { + return new HydrologyPoint(x, terrain.naturalHeight(), z); + } + } + + private record SampledGrid( + int minimumX, + int minimumZ, + int ownerMinimumX, + int ownerMinimumZ, + int ownerSize, + int width, + int spacing, + List nodes + ) { + GridNode node(int index) { + return nodes.get(index); + } + + GridNode nodeAt(int gridX, int gridZ) { + if (gridX < 0 || gridZ < 0 || gridX >= width || gridZ >= width) { + return null; + } + return nodes.get(gridZ * width + gridX); + } + + GridNode nodeAtWorld(int x, int z) { + long deltaX = (long) x - minimumX; + long deltaZ = (long) z - minimumZ; + if (deltaX < 0L || deltaZ < 0L || deltaX % spacing != 0L || deltaZ % spacing != 0L) { + return null; + } + long gridX = deltaX / spacing; + long gridZ = deltaZ / spacing; + if (gridX >= width || gridZ >= width) { + return null; + } + return nodeAt((int) gridX, (int) gridZ); + } + + boolean owns(int x, int z) { + return x >= ownerMinimumX && x < ownerMinimumX + ownerSize + && z >= ownerMinimumZ && z < ownerMinimumZ + ownerSize; + } + } + + private record OutletCandidate(int landIndex, int oceanIndex, RiverOutlet outlet) { + } + + private record InlandConnection(HydrologyPoint point, HydrologyTerrainSample terrain) { + } + + private record RoutingQueueEntry( + int nodeIndex, + double potential, + int outletIndex, + int requiredSurfaceHead + ) + implements Comparable { + @Override + public int compareTo(RoutingQueueEntry other) { + int surfaceHeadComparison = Integer.compare(requiredSurfaceHead, other.requiredSurfaceHead); + if (surfaceHeadComparison != 0) { + return surfaceHeadComparison; + } + int potentialComparison = Double.compare(potential, other.potential); + if (potentialComparison != 0) { + return potentialComparison; + } + int nodeComparison = Integer.compare(nodeIndex, other.nodeIndex); + return nodeComparison != 0 ? nodeComparison : Integer.compare(outletIndex, other.outletIndex); + } + } + + private record RoutingPlan( + double[] potential, + int[] parent, + int[] outletIndex, + int[] routeLengths, + List outlets, + boolean containedSurface, + boolean organicSurfaceRequired + ) { + } + + private record SourceCandidate( + int nodeIndex, + long stableId, + double score, + boolean required + ) { + } + + private static final class SourceSelection { + private final boolean surface; + private final List candidates; + private final SourceAdmissionSelection admission; + private final int guaranteedMinimum; + private final int maximumOptionalRejections; + private final int targetCount; + private final ArrayList selectedCandidateIndices; + private final boolean[] selectedCandidates; + private final boolean[] attemptedCandidates; + private int rejectedOptionalCandidates; + + private SourceSelection( + boolean surface, + List candidates, + SourceAdmissionSelection admission, + int guaranteedMinimum, + int maximumOptionalRejections + ) { + if (guaranteedMinimum < 0 || guaranteedMinimum > admission.selectedCandidateIndices().size()) { + throw new IllegalArgumentException("Guaranteed source minimum is outside the admission bounds."); + } + if (maximumOptionalRejections < 0) { + throw new IllegalArgumentException("Maximum optional source rejections cannot be negative."); + } + this.surface = surface; + this.candidates = List.copyOf(candidates); + this.admission = admission; + this.guaranteedMinimum = guaranteedMinimum; + this.maximumOptionalRejections = maximumOptionalRejections; + this.selectedCandidateIndices = new ArrayList<>(admission.selectedCandidateIndices()); + this.targetCount = admission.targetCount(); + this.selectedCandidates = new boolean[candidates.size()]; + this.attemptedCandidates = new boolean[candidates.size()]; + for (int candidateIndex : selectedCandidateIndices) { + selectedCandidates[candidateIndex] = true; + } + } + + private static SourceSelection empty(boolean surface) { + return new SourceSelection( + surface, + List.of(), + new SourceAdmissionSelection( + 0, + List.of(), + new boolean[0], + new boolean[0], + new boolean[0], + (int candidateIndex) -> false, + new int[0], + Integer.MAX_VALUE + ), + 0, + 0 + ); + } + + private List selectedNodeIndices() { + ArrayList selectedNodes = new ArrayList<>(selectedCandidateIndices.size()); + for (int candidateIndex : selectedCandidateIndices) { + selectedNodes.add(candidates.get(candidateIndex).nodeIndex()); + } + return List.copyOf(selectedNodes); + } + + private int candidateCount() { + return candidates.size(); + } + + private boolean needsSurfaceFallback() { + return surface && targetCount > 0 && selectedCandidateIndices.isEmpty(); + } + + private boolean hasAcceptedSelection() { + return !selectedCandidateIndices.isEmpty(); + } + + private boolean advanceAfterPublication(List acceptedCourses, SampledGrid grid) { + Set acceptedSourceNodeIds = acceptedSourceNodeIds(acceptedCourses); + int acceptedRequired = 0; + boolean changed = false; + int selectedPosition = 0; + while (selectedPosition < selectedCandidateIndices.size()) { + int candidateIndex = selectedCandidateIndices.get(selectedPosition); + SourceCandidate candidate = candidates.get(candidateIndex); + long sourceNodeId = grid.node(candidate.nodeIndex()).id(); + if (acceptedSourceNodeIds.contains(sourceNodeId)) { + if (candidate.required()) { + attemptedCandidates[candidateIndex] = true; + acceptedRequired++; + } + selectedPosition++; + continue; + } + attemptedCandidates[candidateIndex] = true; + if (!candidate.required()) { + rejectedOptionalCandidates++; + } + selectedCandidateIndices.remove(selectedPosition); + selectedCandidates[candidateIndex] = false; + changed = true; + } + int requiredVacancies = Math.max(0, guaranteedMinimum - acceptedRequired); + for (int vacancy = 0; vacancy < requiredVacancies; vacancy++) { + int replacement = nextRequiredCandidate(); + if (replacement < 0) { + break; + } + selectedCandidateIndices.add(replacement); + selectedCandidates[replacement] = true; + changed = true; + } + while (selectedCandidateIndices.size() < targetCount) { + int replacement = nextAdmittedCandidate(); + if (replacement < 0) { + break; + } + selectedCandidateIndices.add(replacement); + selectedCandidates[replacement] = true; + changed = true; + } + return changed; + } + + private Set acceptedSourceNodeIds(List acceptedCourses) { + RiverCourseType expectedType = surface ? RiverCourseType.SURFACE : RiverCourseType.UNDERGROUND; + HashSet accepted = new HashSet<>(); + for (RiverCourse course : acceptedCourses) { + if (course.type() == expectedType && course.sourceNodeId().isPresent()) { + accepted.add(course.sourceNodeId().getAsLong()); + } + } + return accepted; + } + + private int nextRequiredCandidate() { + for (int candidateIndex = 0; candidateIndex < candidates.size(); candidateIndex++) { + SourceCandidate candidate = candidates.get(candidateIndex); + if (candidate.required() + && !selectedCandidates[candidateIndex] + && !attemptedCandidates[candidateIndex] + && admission.outletQuotaAvailable(candidateIndex, selectedCandidateIndices)) { + return candidateIndex; + } + } + return -1; + } + + private int nextAdmittedCandidate() { + if (rejectedOptionalCandidates >= maximumOptionalRejections) { + return -1; + } + for (int candidateIndex = 0; candidateIndex < candidates.size(); candidateIndex++) { + if (!selectedCandidates[candidateIndex] + && !attemptedCandidates[candidateIndex] + && admission.outletQuotaAvailable(candidateIndex, selectedCandidateIndices) + && admission.admitted(candidateIndex)) { + return candidateIndex; + } + } + return -1; + } + + private void addFinalAdmissionDiagnostics( + SampledGrid grid, + List diagnostics + ) { + for (int candidateIndex = 0; candidateIndex < candidates.size(); candidateIndex++) { + if (selectedCandidates[candidateIndex] || attemptedCandidates[candidateIndex]) { + continue; + } + SourceCandidate candidate = candidates.get(candidateIndex); + addSourceDiagnostic( + grid.node(candidate.nodeIndex()), + surface, + candidate.stableId(), + admission.rejectedBySpacing(candidateIndex) + ? HydrologyCandidateRejection.SOURCE_SPACING + : HydrologyCandidateRejection.SOURCE_QUOTA, + diagnostics + ); + } + } + } + + static final class SourceAdmissionSelection { + private final int targetCount; + private final List selectedCandidateIndices; + private final boolean[] selectedCandidates; + private final boolean[] spacingRejectedCandidates; + private final boolean[] evaluatedCandidates; + private final IntPredicate globallyAdmitted; + private final int[] outletIndices; + private final int maximumCoursesPerOutlet; + + private SourceAdmissionSelection( + int targetCount, + List selectedCandidateIndices, + boolean[] selectedCandidates, + boolean[] spacingRejectedCandidates, + boolean[] evaluatedCandidates, + IntPredicate globallyAdmitted, + int[] outletIndices, + int maximumCoursesPerOutlet + ) { + if (outletIndices.length != selectedCandidates.length || maximumCoursesPerOutlet < 1) { + throw new IllegalArgumentException("Source outlet admission bounds are invalid."); + } + this.targetCount = targetCount; + this.selectedCandidateIndices = List.copyOf(selectedCandidateIndices); + this.selectedCandidates = selectedCandidates; + this.spacingRejectedCandidates = spacingRejectedCandidates; + this.evaluatedCandidates = evaluatedCandidates; + this.globallyAdmitted = globallyAdmitted; + this.outletIndices = outletIndices; + this.maximumCoursesPerOutlet = maximumCoursesPerOutlet; + } + + List selectedCandidateIndices() { + return selectedCandidateIndices; + } + + int targetCount() { + return targetCount; + } + + boolean selected(int candidateIndex) { + return selectedCandidates[candidateIndex]; + } + + boolean rejectedBySpacing(int candidateIndex) { + return spacingRejectedCandidates[candidateIndex]; + } + + boolean admitted(int candidateIndex) { + if (!evaluatedCandidates[candidateIndex]) { + evaluatedCandidates[candidateIndex] = true; + spacingRejectedCandidates[candidateIndex] = !globallyAdmitted.test(candidateIndex); + } + return !spacingRejectedCandidates[candidateIndex]; + } + + boolean outletQuotaAvailable(int candidateIndex, List selectedIndices) { + int outletIndex = outletIndices[candidateIndex]; + int selectedForOutlet = 0; + for (int selectedIndex : selectedIndices) { + if (outletIndices[selectedIndex] == outletIndex) { + selectedForOutlet++; + } + } + return selectedForOutlet < maximumCoursesPerOutlet; + } + } + + private record SourcePriority( + boolean required, + double weight, + int naturalHeight, + long stableId, + int x, + int z + ) { + } + + private record SourceRoutingContext( + SampledGrid grid, + RoutingPlan surfaceRouting, + RoutingPlan undergroundRouting, + List diagnostics + ) { + private RoutingPlan routing(boolean surface) { + return surface ? surfaceRouting : undergroundRouting; + } + } + + private record SourceCompilationKey( + boolean surface, + RoutingPlan routing, + List sources + ) { + private SourceCompilationKey { + Objects.requireNonNull(routing, "routing"); + sources = List.copyOf(sources); + } + } + + private record SourceCompilation( + CompiledGraph graph, + List courses, + List diagnostics + ) { + } + + private record CompiledGraph( + List nodes, + List edges, + List outlets, + Map edgeByUpstream + ) { + } + + private record PublicationAttempt( + HydrologyCaveCourseFilter.Result result, + List diagnostics + ) { + } + + private record CrossTilePublicationAdmission( + HydrologyCaveCourseFilter.Result result, + List diagnostics, + boolean rejectedCourses + ) { + private CrossTilePublicationAdmission { + Objects.requireNonNull(result, "result"); + diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics")); + } + } + + private record HydrologyOwnerDraft( + HydrologyTileKey key, + HydrologyCaveCourseFilter.Result result, + List diagnostics + ) { + } + + private record MaterializedHydrology( + HydrologyCaveCourseFilter.Result result, + RiverFootprint footprint + ) { + private MaterializedHydrology { + Objects.requireNonNull(result); + Objects.requireNonNull(footprint); + } + } + + @FunctionalInterface + private interface CrossTileDraftAdmission { + CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result); + } + + private final class ColorRankedDraftAdmission implements CrossTileDraftAdmission { + private final HydrologyTileKey ownerKey; + private final int ownerRank; + private final CrossTileResolutionContext context; + private final Set rejectedCourseIds; + private final LinkedHashMap observedRejections; + + private ColorRankedDraftAdmission( + HydrologyTileKey ownerKey, + int ownerRank, + CrossTileResolutionContext context + ) { + this.ownerKey = Objects.requireNonNull(ownerKey, "ownerKey"); + this.ownerRank = ownerRank; + this.context = Objects.requireNonNull(context, "context"); + this.rejectedCourseIds = new HashSet<>(); + this.observedRejections = new LinkedHashMap<>(); + } + + @Override + public CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result) { + context.recordIteration(); + HydrologyCaveCourseFilter.Result current = HydrologyCaveCourseFilter.withoutCourses( + result, + rejectedCourseIds + ); + CrossTileBlockers blockers = lowerRankBlockers( + ownerKey, + current, + ownerRank, + context + ); + HydrologyCrossTileSurfaceAdmission.Result surfaceAdmission = + HydrologyCrossTileSurfaceAdmission.admit( + surfaceClaims(current), + blockers.surfaceClaims(), + settings.surface().sources().minimumSpacing() + ); + Map currentCourses = coursesById(current.courses()); + HashSet rejectedSurfaceOutlets = new HashSet<>(); + HashMap surfaceWinners = new HashMap<>(); + for (HydrologyCrossTileSurfaceAdmission.Rejection rejection : surfaceAdmission.rejections()) { + RiverCourse course = currentCourses.get(rejection.loser().courseId()); + if (course == null || course.outletId().isEmpty()) { + throw new IllegalStateException("Cross-tile surface admission rejected an unknown current course."); + } + long outletId = course.outletId().getAsLong(); + rejectedSurfaceOutlets.add(outletId); + surfaceWinners.putIfAbsent(outletId, rejection.winnerCourseId()); + } + HashSet newRejectedCourseIds = new HashSet<>(); + ArrayList diagnostics = new ArrayList<>(); + for (RiverCourse course : current.courses()) { + if (course.outletId().isEmpty() + || !rejectedSurfaceOutlets.contains(course.outletId().getAsLong())) { + continue; + } + long winnerCourseId = surfaceWinners.get(course.outletId().getAsLong()); + rejectCrossTileCourse(course, winnerCourseId, newRejectedCourseIds, diagnostics); + } + HydrologyCaveCourseFilter.Result surfaceAccepted = HydrologyCaveCourseFilter.withoutCourses( + current, + newRejectedCourseIds + ); + HydrologyCrossTileCaveAdmission.Result caveAdmission = HydrologyCrossTileCaveAdmission.admit( + ownerRank, + caveClaims(surfaceAccepted), + blockers.caveClaims() + ); + if (newRejectedCourseIds.isEmpty() && caveAdmission.rejections().isEmpty()) { + return new CrossTilePublicationAdmission(current, List.of(), false); + } + Map surfaceAcceptedCourses = coursesById(surfaceAccepted.courses()); + for (HydrologyCrossTileCaveAdmission.Rejection rejection : caveAdmission.rejections()) { + HydrologyCrossTileCaveAdmission.Claim claim = rejection.loser(); + RiverCourse course = surfaceAcceptedCourses.get(claim.courseId()); + if (course == null) { + throw new IllegalStateException("Cross-tile cave admission rejected an unknown current course."); + } + rejectCrossTileCourse(course, rejection.winnerSourceId(), newRejectedCourseIds, diagnostics); + } + return new CrossTilePublicationAdmission( + HydrologyCaveCourseFilter.withoutCourses(current, newRejectedCourseIds), + diagnostics, + true + ); + } + + private void rejectCrossTileCourse( + RiverCourse course, + long winnerCourseId, + Set newRejectedCourseIds, + List diagnostics + ) { + if (!rejectedCourseIds.add(course.id())) { + throw new IllegalStateException("Cross-tile admission repeated a rejected course."); + } + newRejectedCourseIds.add(course.id()); + CrossTileRejectionKey rejectionKey = new CrossTileRejectionKey(course.id(), winnerCourseId); + if (observedRejections.containsKey(rejectionKey)) { + return; + } + observedRejections.put( + rejectionKey, + new CrossTileRejectedCourse(course, winnerCourseId) + ); + diagnostics.add(course.type() == RiverCourseType.SURFACE + ? surfaceConflictDiagnostic(course, winnerCourseId) + : HydrologyCaveCourseFilter.overlapDiagnostic(course, winnerCourseId)); + } + + private List observedRejections() { + return List.copyOf(observedRejections.values()); + } + } + + private HydrologyDiagnosticCandidate surfaceConflictDiagnostic( + RiverCourse course, + long winnerCourseId + ) { + HydraulicSegment representative = course.segments().getFirst(); + return new HydrologyDiagnosticCandidate( + HydrologyHash.mix(course.id(), DIAGNOSTIC_SALT, winnerCourseId), + HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, + representative.start(), + HydrologyCandidateRejection.SOURCE_SPACING + ); + } + + private static final class CrossTileResolutionContext { + private final Map resolved; + private final Set resolving; + private final HydrologyTileKey root; + private final long maximumTileOffset; + private final int maximumOwners; + private int iterations; + + private CrossTileResolutionContext( + HydrologyTileKey root, + long maximumTileOffset, + int maximumOwners + ) { + this.resolved = new HashMap<>(); + this.resolving = new HashSet<>(); + this.root = Objects.requireNonNull(root, "root"); + this.maximumTileOffset = maximumTileOffset; + this.maximumOwners = maximumOwners; + } + + private CrossTileResolvedOwner resolved(HydrologyTileKey key) { + return resolved.get(key); + } + + private void remember(HydrologyTileKey key, CrossTileResolvedOwner owner) { + long offsetX = Math.abs((long) key.tileX() - root.tileX()); + long offsetZ = Math.abs((long) key.tileZ() - root.tileZ()); + if (offsetX > maximumTileOffset || offsetZ > maximumTileOffset) { + throw new IllegalStateException("Cross-tile owner dependency exceeded its color-ranked geometry bound."); + } + CrossTileResolvedOwner existing = resolved.putIfAbsent(key, owner); + if (existing != null && !existing.equals(owner)) { + throw new IllegalStateException("Cross-tile owner resolution produced inconsistent cached results."); + } + if (resolved.size() > maximumOwners) { + throw new IllegalStateException("Cross-tile owner dependencies exceeded their color-ranked count bound."); + } + } + + private boolean begin(HydrologyTileKey key) { + return resolving.add(key); + } + + private void end(HydrologyTileKey key) { + resolving.remove(key); + } + + private void recordIteration() { + iterations++; + } + + private int iterations() { + return iterations; + } + + private int ownerCount() { + return resolved.size(); + } + } + + private record CrossTileResolvedOwner( + HydrologyOwnerDraft draft, + List observedRejections + ) { + private CrossTileResolvedOwner { + Objects.requireNonNull(draft, "draft"); + observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections")); + } + } + + private record CrossTileRejectionKey(long courseId, long winnerCourseId) { + } + + private record CrossTileBlockers( + List caveClaims, + List surfaceClaims + ) { + private CrossTileBlockers { + caveClaims = List.copyOf(Objects.requireNonNull(caveClaims, "caveClaims")); + surfaceClaims = List.copyOf(Objects.requireNonNull(surfaceClaims, "surfaceClaims")); + } + } + + private record CrossTileRejectedCourse(RiverCourse course, long winnerSourceId) { + private CrossTileRejectedCourse { + Objects.requireNonNull(course, "course"); + } + } + + private record CrossTileResolution( + HydrologyOwnerDraft draft, + List observedRejections, + int iterations, + int ownerCount, + long resolutionNanos + ) { + private CrossTileResolution { + Objects.requireNonNull(draft, "draft"); + observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections")); + } + } + + private record CoursePath( + List points, + List pairEdges, + List edges, + RiverOutlet outlet, + boolean reachesOutlet, + boolean containedSurface, + boolean organicSurfaceRequired + ) { + } + + private record SurfaceCourseDraft( + GridNode source, + long courseId, + String profileKey, + CoursePath path + ) { + } + + private record SurfaceCourseBuild( + RiverCourse course, + HydrologyCandidateRejection rejection + ) { + private SurfaceCourseBuild { + if ((course == null) == (rejection == null)) { + throw new IllegalArgumentException("Surface course builds require exactly one result."); + } + } + + private static SurfaceCourseBuild accepted(RiverCourse course) { + return new SurfaceCourseBuild(Objects.requireNonNull(course, "course"), null); + } + + private static SurfaceCourseBuild rejected(HydrologyCandidateRejection rejection) { + return new SurfaceCourseBuild(null, Objects.requireNonNull(rejection, "rejection")); + } + } + + private record OutletCourseKey(long outletId) { + } + + private record CourseEdgeKey(RiverCourseType type, long edgeId) { + private CourseEdgeKey { + Objects.requireNonNull(type, "type"); + } + } + + private record DeepSite(int x, int z, int head, long stableId) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java new file mode 100644 index 000000000..f53099695 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java @@ -0,0 +1,522 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.List; + +public record HydrologyPlannerSettings( + int seaLevel, + Routing routing, + Surface surface, + Hydraulics hydraulics, + Underground underground, + Outlets outlets, + Geometry geometry, + List deepFluids +) { + private static final long PLAN_FORMAT_REVISION = 5L; + private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4; + + public HydrologyPlannerSettings { + if (seaLevel < -2048 || seaLevel > 2048) { + throw new IllegalArgumentException("seaLevel is outside the supported planning range."); + } + if (routing == null || surface == null || hydraulics == null || underground == null + || outlets == null || geometry == null) { + throw new IllegalArgumentException("Hydrology planner settings cannot contain null sections."); + } + deepFluids = deepFluids == null ? List.of() : List.copyOf(deepFluids); + int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids); + if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) { + throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period."); + } + } + + public static HydrologyPlannerSettings defaults() { + Source surfaceSources = new Source(true, 0.5D, 88, 0, 1, 384); + Source undergroundSources = new Source(true, 0.25D, Integer.MIN_VALUE, 0, 1, 512); + return new HydrologyPlannerSettings( + 63, + new Routing(2048, 64, 8, 8192, 8192, new Branching(384, 192), 1.5D, 24D, 2D, 0.2D), + new Surface(true, surfaceSources, 4, 8, 1, 4, 3, 7, 6, 2, 10, 24, true, 192, 10), + new Hydraulics(80, 180, 1, 7, 24, 8), + new Underground(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true), + new Outlets( + true, + new Grotto(true, 18, 8, 8, 32768), + new Grotto(true, 18, 8, 8, 32768), + false, + 12, + 64, + 8, + 12 + ), + Geometry.defaults(), + List.of() + ); + } + + public long fingerprint() { + long hash = HydrologyHash.mix( + 0x485944524f4c4f47L, + PLAN_FORMAT_REVISION, + routing.hashCode(), + surface.hashCode(), + hydraulics.hashCode(), + underground.hashCode(), + outlets.hashCode(), + geometry.hashCode() + ); + for (DeepFluid deepFluid : deepFluids) { + hash = HydrologyHash.mix(hash, deepFluid.hashCode(), HydrologyHash.text(deepFluid.id())); + } + return hash; + } + + public int publicationRadius() { + return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids); + } + + int crossTileColorPeriod() { + return crossTileColorPeriod(publicationRadius(), routing.tileSize()); + } + + private static int publicationRadius( + Routing routing, + Surface surface, + Underground underground, + Outlets outlets, + Geometry geometry, + List deepFluids + ) { + int alignedHalo = Math.floorDiv( + Math.min( + routing.maximumRouteLength(), + Math.multiplyExact(routing.sampleSpacing(), 2) + ), + routing.sampleSpacing() + ) * routing.sampleSpacing(); + int radius = 0; + int routeDisplacement = Math.multiplyExact(routing.sampleSpacing(), 3); + if (surface.enabled() && surface.sources().enabled()) { + int surfaceRadius = (int) StrictMath.ceil( + surface.maximumWidth() / 2D + surface.shoreWidth() + surface.maximumTerrainBlendWidth() + ); + surfaceRadius = Math.max( + surfaceRadius, + (int) StrictMath.ceil(geometry.drops().basinWidth( + geometry.drops().flowWidth(surface.maximumWidth()) + ) / 2D + surface.shoreWidth() + surface.maximumTerrainBlendWidth()) + ); + surfaceRadius = Math.max(surfaceRadius, outlets.coastalGrotto().horizontalRadius()); + surfaceRadius = Math.max(surfaceRadius, outlets.inlandGrotto().horizontalRadius()); + radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact(surfaceRadius, routeDisplacement))); + } + if (underground.enabled() && underground.sources().enabled()) { + int undergroundRadius = (int) StrictMath.ceil(underground.maximumWidth() / 2D); + undergroundRadius = Math.max( + undergroundRadius, + (int) StrictMath.ceil(geometry.drops().basinWidth( + geometry.drops().flowWidth(underground.maximumWidth()) + ) / 2D) + ); + undergroundRadius = Math.max(undergroundRadius, outlets.coastalGrotto().horizontalRadius()); + undergroundRadius = Math.max(undergroundRadius, outlets.inlandGrotto().horizontalRadius()); + radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact(undergroundRadius, routeDisplacement))); + } + for (DeepFluid deepFluid : deepFluids) { + if (!deepFluid.enabled() || deepFluid.maximumPerTile() == 0) { + continue; + } + int poolReach = deepFluid.containedPools() ? deepFluid.maximumHorizontalRadius() : 0; + int channelReach = deepFluid.shortChannels() + ? Math.addExact(deepFluid.maximumChannelLength(), (int) StrictMath.ceil(deepFluid.channelWidth() / 2D)) + : 0; + radius = Math.max(radius, Math.max(poolReach, channelReach)); + } + return radius; + } + + private static int crossTileColorPeriod(int publicationRadius, int tileSize) { + long actionReach = Math.addExact((long) publicationRadius, 1L); + return Math.toIntExact(Math.addExact(Math.floorDiv(Math.multiplyExact(2L, actionReach), tileSize), 2L)); + } + + public record Routing( + int tileSize, + int sampleSpacing, + int refinementSpacing, + int maximumRouteNodes, + int maximumRouteLength, + Branching branching, + double valleyPreference, + double uphillPenalty, + double slopePenalty, + double confluenceAttraction + ) { + public Routing { + if (tileSize < 32 || sampleSpacing < 4 || refinementSpacing < 1 || branching == null + || tileSize % sampleSpacing != 0 || sampleSpacing % refinementSpacing != 0) { + throw new IllegalArgumentException("Routing sizes must form an exact bounded tile lattice."); + } + int latticeWidth = tileSize / sampleSpacing + 1; + int halo = Math.min(maximumRouteLength, Math.multiplyExact(sampleSpacing, 2)); + int alignedHalo = Math.floorDiv(halo, sampleSpacing) * sampleSpacing; + int expandedWidth = latticeWidth + alignedHalo * 2 / sampleSpacing; + long latticeNodes = (long) expandedWidth * expandedWidth; + if (maximumRouteNodes < latticeNodes || maximumRouteNodes > 1_000_000 || maximumRouteLength < 1) { + throw new IllegalArgumentException("maximumRouteNodes must contain the complete lattice and remain bounded."); + } + if (branching.minimumSurfaceCourseLength() > maximumRouteLength + || branching.minimumUndergroundCourseLength() > maximumRouteLength) { + throw new IllegalArgumentException("Minimum course lengths cannot exceed maximum route length."); + } + requireFiniteNonNegative(valleyPreference, "valleyPreference"); + requireFiniteNonNegative(uphillPenalty, "uphillPenalty"); + requireFiniteNonNegative(slopePenalty, "slopePenalty"); + requireFiniteNonNegative(confluenceAttraction, "confluenceAttraction"); + } + } + + public record Branching( + int minimumSurfaceCourseLength, + int minimumUndergroundCourseLength + ) { + public Branching { + if (minimumSurfaceCourseLength < 0 || minimumSurfaceCourseLength > 32_768 + || minimumUndergroundCourseLength < 0 || minimumUndergroundCourseLength > 32_768) { + throw new IllegalArgumentException("Hydrology branching lengths are invalid."); + } + } + + public int minimumCourseLength(boolean surface) { + return surface ? minimumSurfaceCourseLength : minimumUndergroundCourseLength; + } + } + + public record Source( + boolean enabled, + double density, + int minimumElevation, + int minimumPerTile, + int maximumPerTile, + int minimumSpacing + ) { + public Source { + if (!Double.isFinite(density) || density < 0D || density > 64D) { + throw new IllegalArgumentException("Source density must be between 0 and 64 expected sources per tile."); + } + if (minimumPerTile < 0 || maximumPerTile < minimumPerTile || maximumPerTile > 4096) { + throw new IllegalArgumentException("Source tile quotas are invalid."); + } + if (minimumSpacing < 0) { + throw new IllegalArgumentException("Source spacing cannot be negative."); + } + } + } + + public record Surface( + boolean enabled, + Source sources, + int minimumWidth, + int maximumWidth, + int minimumDepth, + int maximumDepth, + int minimumSurfaceInset, + int maximumSurfaceInset, + int maximumIncision, + double shoreWidth, + int minimumTerrainBlendWidth, + int maximumTerrainBlendWidth, + boolean ridgeTunnelsEnabled, + int maximumRidgeTunnelLength, + int ridgeTunnelHeadroom + ) { + public Surface { + if (sources == null) { + throw new IllegalArgumentException("Surface source settings are required."); + } + requireRange(minimumWidth, maximumWidth, 1, 256, "surface width"); + requireRange(minimumDepth, maximumDepth, 1, 64, "surface depth"); + requireRange(minimumSurfaceInset, maximumSurfaceInset, 1, 64, "surface inset"); + if (maximumIncision < 0 || !Double.isFinite(shoreWidth) || shoreWidth < 0D || shoreWidth > 32D) { + throw new IllegalArgumentException("Surface incision and shore width are invalid."); + } + requireRange(minimumTerrainBlendWidth, maximumTerrainBlendWidth, 0, 128, "terrain blend width"); + if (maximumRidgeTunnelLength < 0 || ridgeTunnelHeadroom < 0) { + throw new IllegalArgumentException("Ridge tunnel bounds cannot be negative."); + } + } + } + + public record Hydraulics( + int minimumTargetPoolLength, + int maximumTargetPoolLength, + int riffleDrop, + int maximumGradualDrop, + int maximumGradualLength, + int waterfallMinimumDrop + ) { + public Hydraulics { + requireRange(minimumTargetPoolLength, maximumTargetPoolLength, 1, 4096, "target pool length"); + if (riffleDrop < 0 || maximumGradualDrop < riffleDrop + || maximumGradualLength < Math.max(1, maximumGradualDrop) + || waterfallMinimumDrop != maximumGradualDrop + 1 + || minimumTargetPoolLength < maximumGradualLength) { + throw new IllegalArgumentException("Hydraulic transition thresholds are inconsistent."); + } + } + } + + public record Underground( + boolean enabled, + Source sources, + int minimumFluidY, + int maximumFluidY, + int minimumWidth, + int maximumWidth, + int minimumDepth, + int maximumDepth, + int minimumHeadroom, + int maximumHeadroom, + boolean connectToExistingCaves + ) { + public Underground { + if (sources == null || minimumFluidY > maximumFluidY) { + throw new IllegalArgumentException("Underground source and height settings are invalid."); + } + requireRange(minimumWidth, maximumWidth, 1, 256, "underground width"); + requireRange(minimumDepth, maximumDepth, 1, 64, "underground depth"); + requireRange(minimumHeadroom, maximumHeadroom, 1, 128, "underground headroom"); + } + } + + public record Outlets( + boolean oceanEnabled, + Grotto coastalGrotto, + Grotto inlandGrotto, + boolean surfaceSinkholesEnabled, + int coastalCliffMinimumHeight, + int mouthLevelingDistance, + int maximumOceanApron, + int maximumPerTile + ) { + public Outlets { + if (coastalGrotto == null || inlandGrotto == null) { + throw new IllegalArgumentException("Coastal and inland grotto settings are required."); + } + if (surfaceSinkholesEnabled && !inlandGrotto.enabled()) { + throw new IllegalArgumentException("Surface sinkholes require inland grotto outlets."); + } + if (coastalCliffMinimumHeight < 0 || maximumOceanApron < 0 || maximumOceanApron > 64 + || mouthLevelingDistance < 0 || maximumPerTile < 1 || maximumPerTile > 256) { + throw new IllegalArgumentException("Outlet bounds are invalid."); + } + } + } + + public record Grotto( + boolean enabled, + int horizontalRadius, + int verticalRadius, + int headroom, + int maximumVolume + ) { + public Grotto { + if (horizontalRadius < 1 || horizontalRadius > 128 || verticalRadius < 1 || verticalRadius > 128 + || headroom < 1 || headroom > 128 || maximumVolume < 1 || maximumVolume > 1_048_576) { + throw new IllegalArgumentException("Grotto bounds are invalid."); + } + } + } + + public record Geometry( + Meanders meanders, + ChannelShape surface, + ChannelShape underground, + ChannelShape grottos, + Drops drops + ) { + public Geometry { + if (meanders == null || surface == null || underground == null || grottos == null || drops == null) { + throw new IllegalArgumentException("Hydrology geometry settings cannot contain null sections."); + } + } + + public static Geometry defaults() { + ChannelShape channel = new ChannelShape(2.4D, 0.28D, 0.24D, 11); + return new Geometry( + new Meanders(64, 12, 0.34D, 0.42D, 0.48D, 1, 82D), + channel, + channel, + channel, + new Drops(2, 1.4D, 2, 0.45D, 2, 1.8D, 8) + ); + } + } + + public record Meanders( + int primaryWavelength, + int detailWavelength, + double primaryStrength, + double detailStrength, + double maximumOffsetRatio, + int smoothingPasses, + double maximumTurnDegrees + ) { + public Meanders { + if (primaryWavelength < 8 || primaryWavelength > 512 + || detailWavelength < 4 || detailWavelength > 128 + || detailWavelength > primaryWavelength + || smoothingPasses < 0 || smoothingPasses > 4 + || !Double.isFinite(primaryStrength) || primaryStrength < 0D || primaryStrength > 2D + || !Double.isFinite(detailStrength) || detailStrength < 0D || detailStrength > 2D + || !Double.isFinite(maximumOffsetRatio) || maximumOffsetRatio < 0D || maximumOffsetRatio > 1D + || !Double.isFinite(maximumTurnDegrees) + || maximumTurnDegrees < 10D || maximumTurnDegrees > 150D) { + throw new IllegalArgumentException("Hydrology meander geometry is invalid."); + } + } + } + + public record ChannelShape( + double bedRoundness, + double bedRoughness, + double wallRoughness, + int roughnessWavelength + ) { + public ChannelShape { + if (!Double.isFinite(bedRoundness) || bedRoundness < 1D || bedRoundness > 6D + || !Double.isFinite(bedRoughness) || bedRoughness < 0D || bedRoughness > 1D + || !Double.isFinite(wallRoughness) || wallRoughness < 0D || wallRoughness > 1D + || roughnessWavelength < 3 || roughnessWavelength > 128) { + throw new IllegalArgumentException("Hydrology channel shape is invalid."); + } + } + } + + public record Drops( + int cascadeRunPerBlock, + double cascadeExponent, + int maximumCascadeStep, + double flowWidthRatio, + int maximumFlowDepth, + double basinWidthRatio, + int maximumBasinDepth + ) { + public Drops { + if (cascadeRunPerBlock < 1 || cascadeRunPerBlock > 16 + || !Double.isFinite(cascadeExponent) || cascadeExponent < 0.25D || cascadeExponent > 6D + || maximumCascadeStep < 1 || maximumCascadeStep > 4 + || !Double.isFinite(flowWidthRatio) || flowWidthRatio < 0.25D || flowWidthRatio > 1D + || maximumFlowDepth < 1 || maximumFlowDepth > 16 + || !Double.isFinite(basinWidthRatio) || basinWidthRatio < 1D || basinWidthRatio > 4D + || maximumBasinDepth < maximumFlowDepth || maximumBasinDepth > 32) { + throw new IllegalArgumentException("Hydrology drop geometry is invalid."); + } + } + + public int flowWidth(int channelWidth) { + return Math.max(1, Math.min(channelWidth, (int) StrictMath.ceil(channelWidth * flowWidthRatio))); + } + + public int stepLimit(HydrologyFeatureType type) { + return type.isSurface() ? 1 : maximumCascadeStep; + } + + public int flowDepth(int channelDepth) { + return Math.max(1, Math.min(channelDepth, maximumFlowDepth)); + } + + public int basinWidth(int flowWidth) { + return Math.max(flowWidth, (int) StrictMath.ceil(flowWidth * basinWidthRatio)); + } + + public int basinDepth(int flowDepth, int drop) { + int erodedDepth = Math.addExact(flowDepth, Math.max(1, (int) StrictMath.ceil(StrictMath.sqrt(drop)))); + return Math.max(flowDepth, Math.min(maximumBasinDepth, erodedDepth)); + } + } + + public record DeepFluid( + String id, + boolean enabled, + double density, + int spacing, + int minimumY, + int maximumY, + int minimumHorizontalRadius, + int maximumHorizontalRadius, + int minimumVerticalRadius, + int maximumVerticalRadius, + int minimumChannelLength, + int maximumChannelLength, + int channelWidth, + int channelDepth, + int headroom, + int maximumVolume, + int maximumPerTile, + boolean containedPools, + boolean shortChannels + ) { + public DeepFluid { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("Deep-fluid id must not be blank."); + } + id = id.trim(); + if (!Double.isFinite(density) || density < 0D || density > 64D || spacing < 16 || minimumY > maximumY) { + throw new IllegalArgumentException("Deep-fluid density or height range is invalid."); + } + requireRange(minimumHorizontalRadius, maximumHorizontalRadius, 1, 128, "deep-fluid horizontal radius"); + requireRange(minimumVerticalRadius, maximumVerticalRadius, 1, 128, "deep-fluid vertical radius"); + requireRange(minimumChannelLength, maximumChannelLength, 0, 32_768, "deep-fluid channel length"); + if (channelWidth < 1 || channelWidth > 256 || channelDepth < 1 || channelDepth > 64 || headroom < 1 + || maximumVolume < 64 || maximumVolume > 1_048_576 + || maximumPerTile < 0 || maximumPerTile > 1024) { + throw new IllegalArgumentException("Deep-fluid channel bounds are invalid."); + } + long poolEnvelope = ellipsoidVolume( + maximumHorizontalRadius, + Math.max(channelDepth, maximumVerticalRadius), + headroom + ); + if (enabled && containedPools && poolEnvelope > maximumVolume) { + throw new IllegalArgumentException("Deep-fluid maximumVolume cannot contain its configured pool ellipsoid."); + } + if (enabled && !containedPools && !shortChannels) { + throw new IllegalArgumentException("An enabled deep fluid requires a pool or channel."); + } + if (enabled && shortChannels && minimumChannelLength < 1) { + throw new IllegalArgumentException("Enabled deep-fluid channels require a positive minimum length."); + } + } + } + + private static void requireRange(int minimum, int maximum, int lower, int upper, String name) { + if (minimum < lower || maximum < minimum || maximum > upper) { + throw new IllegalArgumentException(name + " range is invalid."); + } + } + + private static void requireFiniteNonNegative(double value, String name) { + if (!Double.isFinite(value) || value < 0D) { + throw new IllegalArgumentException(name + " must be finite and non-negative."); + } + } + + static long ellipsoidVolume(int radius, int lowerExtent, int upperExtent) { + long volume = 0L; + for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) { + for (int deltaX = -radius; deltaX <= radius; deltaX++) { + double distance = StrictMath.hypot(deltaX, deltaZ); + if (distance > radius + 0.25D) { + continue; + } + double normalized = Math.min(1D, distance / Math.max(1D, radius)); + double scale = StrictMath.sqrt(Math.max(0D, 1D - normalized * normalized)); + int lower = (int) StrictMath.ceil(lowerExtent * scale); + int upper = (int) StrictMath.floor(upperExtent * scale); + volume += lower + upper + 1L; + } + } + return volume; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPoint.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPoint.java new file mode 100644 index 000000000..2842055f0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPoint.java @@ -0,0 +1,13 @@ +package art.arcane.iris.engine.hydrology; + +public record HydrologyPoint(int x, int y, int z) { + public long packedColumn() { + return RiverFootprint.pack(x, z); + } + + public long distanceSquared2D(HydrologyPoint other) { + long deltaX = (long) x - other.x; + long deltaZ = (long) z - other.z; + return deltaX * deltaX + deltaZ * deltaZ; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRenderSample.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRenderSample.java new file mode 100644 index 000000000..b09045d58 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRenderSample.java @@ -0,0 +1,44 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +public record HydrologyRenderSample(int x, int z, List features) { + private static final Comparator FEATURE_ORDER = Comparator + .comparingInt((HydrologyFeatureRef feature) -> feature.type().renderPriority()) + .thenComparingLong(HydrologyFeatureRef::id); + + public HydrologyRenderSample { + Objects.requireNonNull(features, "features"); + ArrayList ordered = new ArrayList<>(features); + ordered.sort(FEATURE_ORDER); + Map unique = new LinkedHashMap<>(); + for (HydrologyFeatureRef feature : ordered) { + unique.putIfAbsent(feature.id(), feature); + } + features = List.copyOf(unique.values()); + } + + public boolean present() { + return !features.isEmpty(); + } + + public boolean hasFeature(HydrologyFeatureType type) { + Objects.requireNonNull(type, "type"); + for (HydrologyFeatureRef feature : features) { + if (feature.type() == type) { + return true; + } + } + return false; + } + + public Optional primaryFeature() { + return features.isEmpty() ? Optional.empty() : Optional.of(features.getFirst()); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRoutingTerrainSampler.java new file mode 100644 index 000000000..d41f7537e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyRoutingTerrainSampler.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.hydrology; + +public interface HydrologyRoutingTerrainSampler { + HydrologyTerrainSample[] sampleGrid(GridRequest request); + + NaturalClassification classifyNatural(int blockX, int blockZ); + + enum NaturalClassification { + LAND, + OCEAN, + UNAVAILABLE + } + + record GridRequest(int minimumX, int minimumZ, int width, int spacing) { + public GridRequest { + if (width < 1 || spacing < 1) { + throw new IllegalArgumentException("Hydrology routing grid dimensions must be positive"); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainCaveVoxelView.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainCaveVoxelView.java new file mode 100644 index 000000000..2fcad66a2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainCaveVoxelView.java @@ -0,0 +1,107 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + +import java.util.Objects; + +final class HydrologyTerrainCaveVoxelView implements CaveVoxelView { + private final HydrologyTerrainSampler sampler; + private final Long2ObjectOpenHashMap samples; + private final int seaLevel; + private final int minimumY; + private final int maximumY; + + HydrologyTerrainCaveVoxelView(HydrologyTerrainSampler sampler, int seaLevel, int worldHeight) { + this(sampler, seaLevel, 0, worldHeight); + } + + HydrologyTerrainCaveVoxelView( + HydrologyTerrainSampler sampler, + int seaLevel, + int minimumY, + int maximumY + ) { + this(sampler, seaLevel, minimumY, maximumY, new Long2ObjectOpenHashMap<>()); + } + + private HydrologyTerrainCaveVoxelView( + HydrologyTerrainSampler sampler, + int seaLevel, + int minimumY, + int maximumY, + Long2ObjectOpenHashMap samples + ) { + this.sampler = Objects.requireNonNull(sampler); + this.samples = Objects.requireNonNull(samples); + this.seaLevel = seaLevel; + if (maximumY - minimumY < 3) { + throw new IllegalArgumentException("The cave view requires at least three vertical blocks"); + } + this.minimumY = minimumY; + this.maximumY = maximumY; + } + + HydrologyTerrainCaveVoxelView withSampleCache( + Long2ObjectOpenHashMap sampleCache + ) { + return new HydrologyTerrainCaveVoxelView( + sampler, + seaLevel, + minimumY, + maximumY, + sampleCache + ); + } + + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > minimumY && position.y() < maximumY - 1; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + HydrologyTerrainSample terrain = sample(position.x(), position.z()); + if (position.y() <= terrain.naturalHeight()) { + return CaveVoxel.SOLID; + } + if (terrain.ocean() && position.y() <= seaLevel) { + return CaveVoxel.COMPATIBLE_FLUID; + } + return CaveVoxel.CAVE_AIR; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return isAboveTerrainSurface(position); + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + HydrologyTerrainSample terrain = sample(position.x(), position.z()); + return position.y() > terrain.naturalHeight(); + } + + @Override + public boolean hasAboveTerrainSurface(int x, int z, int requestedMinimumY, int requestedMaximumY) { + int firstY = Math.max(requestedMinimumY, minimumY + 1); + int lastY = Math.min(requestedMaximumY, maximumY - 2); + return firstY <= lastY && lastY > sample(x, z).naturalHeight(); + } + + private HydrologyTerrainSample sample(int x, int z) { + long key = (long) x << 32 ^ z & 0xffffffffL; + HydrologyTerrainSample cached = samples.get(key); + if (cached != null) { + return cached; + } + HydrologyTerrainSample sampled = Objects.requireNonNull( + sampler.sample(x, z), + "Hydrology terrain sampler returned null at " + x + "," + z + ); + samples.put(key, sampled); + return sampled; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java new file mode 100644 index 000000000..756254746 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java @@ -0,0 +1,192 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.List; + +public record HydrologyTerrainSample( + int naturalHeight, + double slope, + boolean ocean, + boolean caveAvailable, + int caveFloorY, + int caveFluidY, + boolean transitAllowed, + boolean outletAllowed, + boolean surfaceSourceAllowed, + boolean surfaceSourceRequired, + boolean undergroundSourceAllowed, + boolean undergroundSourceRequired, + double routingCost, + double surfaceSourceWeight, + double undergroundSourceWeight, + double widthMultiplier, + double depthMultiplier, + double incisionMultiplier, + double routingMultiplier, + String parentBiomeKey, + String surfaceBiomeKey, + String mouthBiomeKey, + String shoreBiomeKey, + String dryBiomeKey, + String floodedCaveBiomeKey, + List preferredProfileKeys +) { + public HydrologyTerrainSample { + requireFiniteNonNegative(slope, "slope"); + requireFiniteNonNegative(routingCost, "routingCost"); + requireFiniteNonNegative(surfaceSourceWeight, "surfaceSourceWeight"); + requireFiniteNonNegative(undergroundSourceWeight, "undergroundSourceWeight"); + requireFinitePositive(widthMultiplier, "widthMultiplier"); + requireFinitePositive(depthMultiplier, "depthMultiplier"); + requireFiniteNonNegative(incisionMultiplier, "incisionMultiplier"); + requireFiniteNonNegative(routingMultiplier, "routingMultiplier"); + if (caveAvailable && (caveFluidY <= caveFloorY || caveFluidY >= naturalHeight)) { + throw new IllegalArgumentException("Available cave fluid must be above its floor and below natural terrain."); + } + parentBiomeKey = normalizeKey(parentBiomeKey, "parent"); + surfaceBiomeKey = normalizeKey(surfaceBiomeKey, parentBiomeKey); + mouthBiomeKey = normalizeKey(mouthBiomeKey, surfaceBiomeKey); + shoreBiomeKey = normalizeKey(shoreBiomeKey, parentBiomeKey); + dryBiomeKey = normalizeKey(dryBiomeKey, parentBiomeKey); + floodedCaveBiomeKey = normalizeKey(floodedCaveBiomeKey, surfaceBiomeKey); + preferredProfileKeys = normalizeProfiles(preferredProfileKeys); + } + + public static HydrologyTerrainSample openLand(int naturalHeight, double slope, String parentBiomeKey) { + return new HydrologyTerrainSample( + naturalHeight, + slope, + false, + false, + naturalHeight - 32, + naturalHeight - 30, + true, + true, + true, + false, + false, + false, + 0D, + 1D, + 1D, + 1D, + 1D, + 1D, + 1D, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + List.of("default") + ); + } + + public static HydrologyTerrainSample ocean(int naturalHeight, String parentBiomeKey) { + return new HydrologyTerrainSample( + naturalHeight, + 0D, + true, + false, + naturalHeight - 32, + naturalHeight - 30, + false, + false, + false, + false, + false, + false, + 0D, + 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + List.of("default") + ); + } + + public HydrologyTerrainSample withSlope(double replacementSlope) { + return new HydrologyTerrainSample( + naturalHeight, + replacementSlope, + ocean, + caveAvailable, + caveFloorY, + caveFluidY, + transitAllowed, + outletAllowed, + surfaceSourceAllowed, + surfaceSourceRequired, + undergroundSourceAllowed, + undergroundSourceRequired, + routingCost, + surfaceSourceWeight, + undergroundSourceWeight, + widthMultiplier, + depthMultiplier, + incisionMultiplier, + routingMultiplier, + parentBiomeKey, + surfaceBiomeKey, + mouthBiomeKey, + shoreBiomeKey, + dryBiomeKey, + floodedCaveBiomeKey, + preferredProfileKeys + ); + } + + private static List normalizeProfiles(List values) { + if (values == null || values.isEmpty()) { + return List.of("default"); + } + if (profilesNormalized(values)) { + return List.copyOf(values); + } + List normalized = values.stream() + .filter((String value) -> value != null && !value.isBlank()) + .map(String::trim) + .distinct() + .sorted() + .toList(); + return normalized.isEmpty() ? List.of("default") : normalized; + } + + private static boolean profilesNormalized(List values) { + String previous = null; + for (String value : values) { + if (value == null + || value.isBlank() + || !value.equals(value.trim()) + || previous != null && previous.compareTo(value) >= 0) { + return false; + } + previous = value; + } + return true; + } + + private static String normalizeKey(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.trim(); + } + + private static void requireFiniteNonNegative(double value, String name) { + if (!Double.isFinite(value) || value < 0D) { + throw new IllegalArgumentException(name + " must be finite and non-negative."); + } + } + + private static void requireFinitePositive(double value, String name) { + if (!Double.isFinite(value) || value <= 0D) { + throw new IllegalArgumentException(name + " must be finite and positive."); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampler.java new file mode 100644 index 000000000..402db11ad --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampler.java @@ -0,0 +1,6 @@ +package art.arcane.iris.engine.hydrology; + +@FunctionalInterface +public interface HydrologyTerrainSampler { + HydrologyTerrainSample sample(int blockX, int blockZ); +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTile.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTile.java new file mode 100644 index 000000000..77c1d59d0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTile.java @@ -0,0 +1,618 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class HydrologyTile { + private final HydrologyTileKey key; + private final long worldSeed; + private final long settingsFingerprint; + private final int tileSize; + private final List nodes; + private final List edges; + private final List outlets; + private final List courses; + private final List cavePlans; + private final List diagnosticCandidates; + private final RiverFootprint footprint; + private final Map nodesById; + private final Map outletsById; + private final Map cavePlansByCourseId; + private final List features; + + public HydrologyTile( + HydrologyTileKey key, + long worldSeed, + long settingsFingerprint, + int tileSize, + List nodes, + List edges, + List outlets, + List courses, + List cavePlans, + List diagnosticCandidates, + RiverFootprint footprint + ) { + this.key = Objects.requireNonNull(key, "key"); + if (tileSize <= 0) { + throw new IllegalArgumentException("tileSize must be positive."); + } + this.worldSeed = worldSeed; + this.settingsFingerprint = settingsFingerprint; + this.tileSize = tileSize; + this.nodes = sortedCopy(nodes, Comparator.comparingLong(DrainageNode::id)); + this.edges = sortedCopy(edges, Comparator.comparingLong(DrainageEdge::id)); + this.outlets = sortedCopy(outlets, Comparator.comparingLong(RiverOutlet::id)); + this.courses = sortedCopy(courses, Comparator.comparingLong(RiverCourse::id)); + this.cavePlans = sortedCopy( + cavePlans, + Comparator.comparingLong((HydrologyCavePlan plan) -> plan.source().sourceId()) + ); + this.diagnosticCandidates = sortedCopy( + diagnosticCandidates, + Comparator.comparingLong(HydrologyDiagnosticCandidate::id) + ); + this.footprint = Objects.requireNonNull(footprint, "footprint"); + this.nodesById = indexNodes(this.nodes); + this.outletsById = indexOutlets(this.outlets); + this.cavePlansByCourseId = indexCavePlans(this.cavePlans); + validateGraph(); + validateCourses(); + this.features = collectFeatures(footprint); + } + + public HydrologyTileKey key() { + return key; + } + + public long worldSeed() { + return worldSeed; + } + + public long settingsFingerprint() { + return settingsFingerprint; + } + + public int tileSize() { + return tileSize; + } + + public List nodes() { + return nodes; + } + + public List edges() { + return edges; + } + + public List outlets() { + return outlets; + } + + public List courses() { + return courses; + } + + public List cavePlans() { + return cavePlans; + } + + public List diagnosticCandidates() { + return diagnosticCandidates; + } + + public RiverFootprint footprint() { + return footprint; + } + + public Optional node(long id) { + return Optional.ofNullable(nodesById.get(id)); + } + + public Optional outlet(long id) { + return Optional.ofNullable(outletsById.get(id)); + } + + public Optional cavePlan(long courseId) { + return Optional.ofNullable(cavePlansByCourseId.get(courseId)); + } + + public Optional columnAt(int x, int z) { + return footprint.sample(x, z); + } + + public HydrologyRenderSample renderAt(int x, int z) { + return footprint.renderSample(x, z); + } + + public HydrologyDiagnosticRenderSample diagnosticRenderAt(int x, int z, int maximumDistance) { + if (maximumDistance < 0) { + throw new IllegalArgumentException("maximumDistance cannot be negative."); + } + long maximumDistanceSquared = (long) maximumDistance * maximumDistance; + ArrayList selected = new ArrayList<>(); + for (HydrologyDiagnosticCandidate candidate : diagnosticCandidates) { + long distance = candidate.point().distanceSquared2D(new HydrologyPoint(x, candidate.point().y(), z)); + if (distance <= maximumDistanceSquared) { + selected.add(candidate); + } + } + return new HydrologyDiagnosticRenderSample(x, z, selected); + } + + public List features() { + return features; + } + + public Optional nearestFeature( + HydrologyFeatureType type, + int x, + int z, + int maximumDistance + ) { + return nearestFeature(Set.of(Objects.requireNonNull(type, "type")), null, x, z, maximumDistance); + } + + public Optional nearestFeature( + Set types, + String profileKey, + int x, + int z, + int maximumDistance + ) { + Objects.requireNonNull(types, "types"); + if (maximumDistance < 0) { + throw new IllegalArgumentException("maximumDistance cannot be negative."); + } + long maximumDistanceSquared = (long) maximumDistance * maximumDistance; + HydrologyFeatureRef nearest = null; + long nearestDistance = Long.MAX_VALUE; + for (RiverCourse course : courses) { + if (profileKey != null && !profileKey.isBlank() && !profileKey.equals(course.profileKey())) { + continue; + } + for (HydraulicSegment segment : course.segments()) { + if (!types.contains(segment.type())) { + continue; + } + for (HydrologyPoint point : segment.centerline()) { + long deltaX = (long) point.x() - x; + long deltaZ = (long) point.z() - z; + long distance = deltaX * deltaX + deltaZ * deltaZ; + if (distance > maximumDistanceSquared || distance > nearestDistance) { + continue; + } + HydrologyFeatureRef feature = publishedCenterlineFeature(segment, point); + if (feature == null) { + continue; + } + if (distance < nearestDistance || nearest == null || feature.id() < nearest.id()) { + nearest = feature; + nearestDistance = distance; + } + } + } + } + return Optional.ofNullable(nearest); + } + + public boolean acyclic() { + for (DrainageEdge edge : edges) { + DrainageNode upstream = nodesById.get(edge.upstreamNodeId()); + DrainageNode downstream = nodesById.get(edge.downstreamNodeId()); + if (upstream == null || downstream == null || downstream.potential() >= upstream.potential()) { + return false; + } + } + return true; + } + + private void validateGraph() { + if (!acyclic()) { + throw new IllegalArgumentException("Drainage edges must strictly lower potential."); + } + for (DrainageNode node : nodes) { + if (!outletsById.containsKey(node.outletId())) { + throw new IllegalArgumentException("Every drainage node must resolve to an accepted outlet."); + } + } + } + + private void validateCourses() { + LinkedHashMap coursesById = new LinkedHashMap<>(); + for (RiverCourse course : courses) { + if (coursesById.put(course.id(), course) != null) { + throw new IllegalArgumentException("Duplicate river course id: " + course.id()); + } + if (!course.hydraulicallyNonRising()) { + throw new IllegalArgumentException("Course heads must be non-rising downstream."); + } + if (course.sourceNodeId().isPresent() && !nodesById.containsKey(course.sourceNodeId().getAsLong())) { + throw new IllegalArgumentException("Course source is not part of the accepted drainage graph."); + } + if (course.outletId().isPresent() && !outletsById.containsKey(course.outletId().getAsLong())) { + throw new IllegalArgumentException("Course outlet is not accepted by this tile."); + } + if (isCaveBearing(course) != cavePlansByCourseId.containsKey(course.id())) { + throw new IllegalArgumentException("Every cave-bearing course must have one accepted containment plan."); + } + } + validateFootprintCourses(coursesById); + } + + private void validateFootprintCourses(Map coursesById) { + LinkedHashMap> expectedActions = new LinkedHashMap<>(); + for (HydrologyColumnSample sample : footprint.columns().values()) { + ArrayList caveCourseIds = new ArrayList<>(); + for (HydrologyColumnLayer layer : sample.layers()) { + long courseId = layer.feature().courseId(); + if (!coursesById.containsKey(courseId)) { + throw new IllegalArgumentException("Footprint references an unaccepted river course: " + courseId); + } + if (layer.oceanApron() || !layer.channel() || !layer.terrainOwned() + || !isCaveLayer(layer)) { + continue; + } + Map courseActions = expectedActions.computeIfAbsent( + courseId, + (Long ignored) -> new HashMap<>() + ); + if (!caveCourseIds.contains(courseId)) { + caveCourseIds.add(courseId); + } + for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) { + CavePosition position = new CavePosition(sample.x(), y, sample.z()); + HydrologyCaveAction action = actionAt(layer, y); + HydrologyCaveAction existing = courseActions.get(position); + if (existing == null || actionPriority(action) < actionPriority(existing)) { + courseActions.put(position, action); + } + } + } + composeSurfaceActions(sample, caveCourseIds, coursesById, expectedActions); + } + HashSet validatedCourseIds = new HashSet<>(); + for (RiverCourse course : courses) { + if (!isCaveBearing(course)) { + continue; + } + if (!validatedCourseIds.add(course.id())) { + continue; + } + List sharedCourses = sharedOutletCaveCourses(course); + HashMap expected = new HashMap<>(); + HashMap actual = new HashMap<>(); + for (RiverCourse sharedCourse : sharedCourses) { + validatedCourseIds.add(sharedCourse.id()); + mergeCaveActions( + expected, + expectedActions.getOrDefault(sharedCourse.id(), Map.of()) + ); + HydrologyCavePlan plan = cavePlansByCourseId.get(sharedCourse.id()); + plan.forEachAction((CavePosition position, HydrologyCaveAction action) -> { + if (action != HydrologyCaveAction.SEAL_GUARD) { + mergeCaveAction(actual, position, action); + } + }); + } + if (!actual.equals(expected)) { + throw caveFootprintMismatch(course.id(), expected, actual); + } + } + } + + private List sharedOutletCaveCourses(RiverCourse course) { + if (course.outletId().isEmpty()) { + return List.of(course); + } + long outletId = course.outletId().getAsLong(); + ArrayList shared = new ArrayList<>(); + for (RiverCourse candidate : courses) { + if (isCaveBearing(candidate) + && candidate.outletId().isPresent() + && candidate.outletId().getAsLong() == outletId + && candidate.profileKey().equals(course.profileKey())) { + shared.add(candidate); + } + } + return List.copyOf(shared); + } + + private void mergeCaveActions( + Map target, + Map source + ) { + for (Map.Entry entry : source.entrySet()) { + mergeCaveAction(target, entry.getKey(), entry.getValue()); + } + } + + private void mergeCaveAction( + Map actions, + CavePosition position, + HydrologyCaveAction action + ) { + HydrologyCaveAction existing = actions.get(position); + if (existing == null || actionPriority(action) < actionPriority(existing)) { + actions.put(position, action); + } + } + + private IllegalArgumentException caveFootprintMismatch( + long courseId, + Map expected, + Map actual + ) { + for (Map.Entry entry : expected.entrySet()) { + HydrologyCaveAction actualAction = actual.get(entry.getKey()); + if (actualAction != entry.getValue()) { + return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: " + + courseId + " at " + entry.getKey() + ", footprint=" + entry.getValue() + + ", plan=" + actualAction + "."); + } + } + for (Map.Entry entry : actual.entrySet()) { + if (!expected.containsKey(entry.getKey())) { + return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: " + + courseId + " has extra plan action " + entry.getValue() + " at " + entry.getKey() + "."); + } + } + return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: " + + courseId + "."); + } + + private void composeSurfaceActions( + HydrologyColumnSample sample, + List caveCourseIds, + Map coursesById, + Map> expectedActions + ) { + if (caveCourseIds.isEmpty()) { + return; + } + int minimumY = Integer.MAX_VALUE; + int maximumY = Integer.MIN_VALUE; + for (HydrologyColumnLayer layer : sample.layers()) { + if (!layer.publishesSurfaceFluid()) { + continue; + } + minimumY = Math.min(minimumY, layer.bedY() + 1); + maximumY = Math.max(maximumY, layer.fluidHeadY()); + } + for (int y = minimumY; y <= maximumY; y++) { + HydrologyColumnSample.SurfacePublicationCell surfaceCell = sample + .surfacePublicationCellAt(y) + .orElse(null); + if (surfaceCell == null) { + continue; + } + CavePosition position = new CavePosition(sample.x(), y, sample.z()); + for (long courseId : caveCourseIds) { + Map courseActions = expectedActions.get(courseId); + if (!courseActions.containsKey(position)) { + continue; + } + RiverCourse course = coursesById.get(courseId); + if (!course.profileKey().equals(surfaceCell.layer().profileKey())) { + throw new IllegalArgumentException("Surface and cave hydrology profiles conflict at " + + position + "."); + } + courseActions.put(position, surfaceCell.action()); + } + } + } + + private boolean isCaveBearing(RiverCourse course) { + for (HydraulicSegment segment : course.segments()) { + if (segment.type().isUnderground() || segment.type().isDeepFluid()) { + return true; + } + } + return false; + } + + private boolean isCaveLayer(HydrologyColumnLayer layer) { + return layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid(); + } + + private HydrologyCaveAction actionAt(HydrologyColumnLayer layer, int y) { + if (y > layer.fluidHeadY()) { + return HydrologyCaveAction.DRY_AIR; + } + if (layer.fallingFluid() && y < layer.fluidHeadY()) { + return HydrologyCaveAction.FALLING_FLUID; + } + return HydrologyCaveAction.WET_SOURCE; + } + + private int actionPriority(HydrologyCaveAction action) { + return switch (action) { + case WET_SOURCE -> 0; + case FALLING_FLUID -> 1; + case DRY_AIR -> 2; + case SEAL_GUARD -> 3; + }; + } + + private static Map indexNodes(List values) { + LinkedHashMap indexed = new LinkedHashMap<>(); + for (DrainageNode value : values) { + if (indexed.put(value.id(), value) != null) { + throw new IllegalArgumentException("Duplicate drainage node id: " + value.id()); + } + } + return Collections.unmodifiableMap(indexed); + } + + private static Map indexOutlets(List values) { + LinkedHashMap indexed = new LinkedHashMap<>(); + for (RiverOutlet value : values) { + if (indexed.put(value.id(), value) != null) { + throw new IllegalArgumentException("Duplicate river outlet id: " + value.id()); + } + } + return Collections.unmodifiableMap(indexed); + } + + private static Map indexCavePlans(List values) { + LinkedHashMap indexed = new LinkedHashMap<>(); + for (HydrologyCavePlan value : values) { + if (!value.accepted()) { + throw new IllegalArgumentException("Hydrology tiles cannot contain rejected cave plans."); + } + long courseId = value.source().sourceId(); + if (indexed.put(courseId, value) != null) { + throw new IllegalArgumentException("Duplicate cave plan for course: " + courseId); + } + } + return Collections.unmodifiableMap(indexed); + } + + private HydrologyFeatureRef publishedCenterlineFeature( + HydraulicSegment segment, + HydrologyPoint point + ) { + HydrologyColumnSample column = footprint.columns().get(RiverFootprint.pack(point.x(), point.z())); + if (column == null) { + return null; + } + for (HydrologyColumnLayer layer : column.layers()) { + HydrologyFeatureRef feature = layer.feature(); + if (layer.channel() + && feature.segmentId() == segment.id() + && feature.type() == segment.type()) { + if (feature.x() == point.x() && feature.z() == point.z()) { + return feature; + } + return new HydrologyFeatureRef( + feature.id(), + feature.type(), + feature.courseId(), + feature.segmentId(), + point.x(), + layer.fluidHeadY(), + point.z(), + feature.flowDeltaX(), + feature.flowDeltaZ(), + feature.source() + ); + } + } + return null; + } + + static List collectFeatures(RiverFootprint footprint) { + HashMap representatives = new HashMap<>(); + for (HydrologyColumnSample column : footprint.columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + long segmentId = layer.feature().segmentId(); + HydrologyColumnLayer existing = representatives.get(segmentId); + if (existing == null || featureOrder(layer, existing) < 0) { + representatives.put(segmentId, layer); + } + } + } + ArrayList ordered = new ArrayList<>(representatives.size()); + for (HydrologyColumnLayer layer : representatives.values()) { + ordered.add(layer.feature()); + } + ordered.sort(Comparator.comparingInt((HydrologyFeatureRef feature) -> feature.type().renderPriority()) + .thenComparingLong(HydrologyFeatureRef::id)); + return List.copyOf(ordered); + } + + private static int featureOrder(HydrologyColumnLayer first, HydrologyColumnLayer second) { + int role = Integer.compare(featureRole(first), featureRole(second)); + if (role != 0) { + return role; + } + HydrologyFeatureRef firstFeature = first.feature(); + HydrologyFeatureRef secondFeature = second.feature(); + int y = Integer.compare(firstFeature.y(), secondFeature.y()); + if (y != 0) { + return y; + } + int x = Integer.compare(firstFeature.x(), secondFeature.x()); + return x != 0 ? x : Integer.compare(firstFeature.z(), secondFeature.z()); + } + + private static int featureRole(HydrologyColumnLayer layer) { + if (layer.feature().source()) { + return 0; + } + if (layer.fallingFluid()) { + return 1; + } + if (layer.receivingPool()) { + return 2; + } + if (layer.channel() && !layer.oceanApron()) { + return 3; + } + if (layer.channel()) { + return 4; + } + return 5; + } + + private static List sortedCopy(List values, Comparator comparator) { + Objects.requireNonNull(values, "values"); + ArrayList ordered = new ArrayList<>(values); + ordered.sort(comparator); + return List.copyOf(ordered); + } + + @Override + public boolean equals(Object other) { + if (!(other instanceof HydrologyTile tile)) { + return false; + } + return worldSeed == tile.worldSeed + && settingsFingerprint == tile.settingsFingerprint + && tileSize == tile.tileSize + && key.equals(tile.key) + && nodes.equals(tile.nodes) + && edges.equals(tile.edges) + && outlets.equals(tile.outlets) + && courses.equals(tile.courses) + && cavePlans.equals(tile.cavePlans) + && diagnosticCandidates.equals(tile.diagnosticCandidates) + && footprint.equals(tile.footprint); + } + + @Override + public int hashCode() { + return Objects.hash( + key, + worldSeed, + settingsFingerprint, + tileSize, + nodes, + edges, + outlets, + courses, + cavePlans, + diagnosticCandidates, + footprint + ); + } + + @Override + public String toString() { + return "HydrologyTile[key=" + key + ", courses=" + courses.size() + ", candidates=" + + diagnosticCandidates.size() + ", columns=" + footprint.size() + "]"; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java new file mode 100644 index 000000000..b5cf4d186 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileCache.java @@ -0,0 +1,267 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.util.common.parallel.MultiBurst; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicLong; + +public final class HydrologyTileCache implements AutoCloseable { + private static final int DEFAULT_MAXIMUM_ENTRIES = 64; + private static final int MAXIMUM_COMPOSED_CHUNKS = 256; + private static final int CHUNK_SIZE = 16; + private static final int CHUNK_COLUMN_COUNT = CHUNK_SIZE * CHUNK_SIZE; + + private final HydrologyPlanner planner; + private final int maximumEntries; + private final Cache tiles; + private final Cache composedChunks; + private final AtomicLong cacheEpoch; + private final ThreadLocal localChunkColumns; + + public HydrologyTileCache(HydrologyPlanner planner) { + this(planner, DEFAULT_MAXIMUM_ENTRIES); + } + + public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries) { + this.planner = Objects.requireNonNull(planner, "planner"); + if (maximumEntries < 1) { + throw new IllegalArgumentException("maximumEntries must be positive."); + } + this.maximumEntries = maximumEntries; + this.tiles = Caffeine.newBuilder() + .maximumSize(maximumEntries) + .build(); + this.composedChunks = Caffeine.newBuilder() + .maximumSize(MAXIMUM_COMPOSED_CHUNKS) + .build(); + this.cacheEpoch = new AtomicLong(); + this.localChunkColumns = new ThreadLocal<>(); + } + + public HydrologyTile get(HydrologyTileKey key) { + Objects.requireNonNull(key, "key"); + return tiles.get(key, planner::plan); + } + + public Optional columnAt(int blockX, int blockZ) { + return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ); + } + + public void prepareChunkColumns(int blockX, int blockZ) { + chunkColumns(blockX, blockZ); + } + + public HydrologyRenderSample renderAt(int blockX, int blockZ) { + return columnAt(blockX, blockZ) + .map(HydrologyColumnSample::renderSample) + .orElseGet(() -> new HydrologyRenderSample(blockX, blockZ, List.of())); + } + + public void invalidate(HydrologyTileKey key) { + Objects.requireNonNull(key, "key"); + clear(); + } + + public void clear() { + cacheEpoch.incrementAndGet(); + planner.clearOwnerDrafts(); + tiles.invalidateAll(); + composedChunks.invalidateAll(); + localChunkColumns.remove(); + } + + public int size() { + tiles.cleanUp(); + return Math.toIntExact(tiles.estimatedSize()); + } + + public int maximumEntries() { + return maximumEntries; + } + + @Override + public void close() { + clear(); + } + + private ChunkColumns chunkColumns(int blockX, int blockZ) { + int chunkX = Math.floorDiv(blockX, CHUNK_SIZE); + int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE); + long epoch = cacheEpoch.get(); + LocalChunkColumns local = localChunkColumns.get(); + if (local != null && local.epoch() == epoch + && local.columns().chunkX() == chunkX + && local.columns().chunkZ() == chunkZ) { + return local.columns(); + } + long packedChunk = RiverFootprint.pack(chunkX, chunkZ); + ChunkColumns columns = composedChunks.get( + packedChunk, + ignored -> composeChunkColumns(chunkX, chunkZ) + ); + localChunkColumns.set(new LocalChunkColumns(epoch, columns)); + return columns; + } + + private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) { + int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE); + int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE); + int tileSize = planner.settings().routing().tileSize(); + int publicationRadius = planner.settings().publicationRadius(); + int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize); + int maximumTileX = tileCoordinate((long) minimumBlockX + CHUNK_SIZE - 1L + publicationRadius, tileSize); + int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize); + int maximumTileZ = tileCoordinate((long) minimumBlockZ + CHUNK_SIZE - 1L + publicationRadius, tileSize); + ArrayList relevantKeys = new ArrayList<>( + Math.multiplyExact(maximumTileX - minimumTileX + 1, maximumTileZ - minimumTileZ + 1) + ); + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + relevantKeys.add(new HydrologyTileKey(tileX, tileZ)); + } + } + List relevantTiles = loadTiles(relevantKeys); + HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT]; + for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { + for (int localX = 0; localX < CHUNK_SIZE; localX++) { + int blockX = minimumBlockX + localX; + int blockZ = minimumBlockZ + localZ; + columns[localZ * CHUNK_SIZE + localX] = composeColumn( + blockX, + blockZ, + relevantKeys, + relevantTiles + ) + .orElse(null); + } + } + return new ChunkColumns(chunkX, chunkZ, columns); + } + + private List loadTiles(List keys) { + if (keys.size() < 2 + || !IrisPlatforms.isBound()) { + ArrayList loaded = new ArrayList<>(keys.size()); + for (HydrologyTileKey key : keys) { + loaded.add(get(key)); + } + return List.copyOf(loaded); + } + ArrayList> futures = new ArrayList<>(keys.size()); + for (HydrologyTileKey key : keys) { + futures.add(CompletableFuture.supplyAsync(() -> get(key), MultiBurst.burst)); + } + ArrayList loaded = new ArrayList<>(keys.size()); + for (CompletableFuture future : futures) { + loaded.add(awaitTile(future)); + } + return List.copyOf(loaded); + } + + private static HydrologyTile awaitTile(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException("Hydrology tile planning failed.", cause); + } + } + + private Optional composeColumn( + int blockX, + int blockZ, + List relevantKeys, + List relevantTiles + ) { + ArrayList layers = new ArrayList<>(); + HydrologyColumnSample template = null; + int tileSize = planner.settings().routing().tileSize(); + int publicationRadius = planner.settings().publicationRadius(); + int minimumTileX = tileCoordinate((long) blockX - publicationRadius, tileSize); + int maximumTileX = tileCoordinate((long) blockX + publicationRadius, tileSize); + int minimumTileZ = tileCoordinate((long) blockZ - publicationRadius, tileSize); + int maximumTileZ = tileCoordinate((long) blockZ + publicationRadius, tileSize); + for (int tileIndex = 0; tileIndex < relevantTiles.size(); tileIndex++) { + HydrologyTileKey key = relevantKeys.get(tileIndex); + if (key.tileX() < minimumTileX || key.tileX() > maximumTileX + || key.tileZ() < minimumTileZ || key.tileZ() > maximumTileZ) { + continue; + } + HydrologyTile tile = relevantTiles.get(tileIndex); + Optional candidate = tile.columnAt(blockX, blockZ); + if (candidate.isEmpty()) { + continue; + } + HydrologyColumnSample sample = candidate.get(); + if (template == null) { + template = sample; + } else { + requireMatchingTerrain(template, sample); + } + layers.addAll(sample.layers()); + } + if (template == null) { + return Optional.empty(); + } + LinkedHashMap unique = new LinkedHashMap<>(); + for (HydrologyColumnLayer layer : layers) { + HydrologyColumnLayer existing = unique.putIfAbsent(layer.feature().id(), layer); + if (existing != null && !existing.equals(layer)) { + throw new IllegalStateException("Hydrology feature id collision at " + blockX + "," + blockZ + "."); + } + } + return Optional.of(new HydrologyColumnSample( + blockX, + blockZ, + template.naturalHeight(), + template.seaLevel(), + template.ocean(), + template.parentBiomeKey(), + new ArrayList<>(unique.values()) + )); + } + + private static int tileCoordinate(long blockCoordinate, int tileSize) { + return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize)); + } + + private static void requireMatchingTerrain( + HydrologyColumnSample expected, + HydrologyColumnSample actual + ) { + if (expected.naturalHeight() != actual.naturalHeight() + || expected.seaLevel() != actual.seaLevel() + || expected.ocean() != actual.ocean() + || !expected.parentBiomeKey().equals(actual.parentBiomeKey())) { + throw new IllegalStateException( + "Hydrology plans disagree on terrain metadata at " + expected.x() + "," + expected.z() + "." + ); + } + } + + private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) { + private Optional columnAt(int blockX, int blockZ) { + int localX = Math.floorMod(blockX, CHUNK_SIZE); + int localZ = Math.floorMod(blockZ, CHUNK_SIZE); + return Optional.ofNullable(columns[localZ * CHUNK_SIZE + localX]); + } + } + + private record LocalChunkColumns(long epoch, ChunkColumns columns) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileKey.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileKey.java new file mode 100644 index 000000000..fe810c774 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTileKey.java @@ -0,0 +1,31 @@ +package art.arcane.iris.engine.hydrology; + +public record HydrologyTileKey(int tileX, int tileZ) implements Comparable { + public static HydrologyTileKey fromBlock(int blockX, int blockZ, int tileSize) { + if (tileSize <= 0) { + throw new IllegalArgumentException("Tile size must be positive."); + } + return new HydrologyTileKey(Math.floorDiv(blockX, tileSize), Math.floorDiv(blockZ, tileSize)); + } + + public int minimumBlockX(int tileSize) { + return Math.multiplyExact(tileX, tileSize); + } + + public int minimumBlockZ(int tileSize) { + return Math.multiplyExact(tileZ, tileSize); + } + + public boolean contains(int blockX, int blockZ, int tileSize) { + int minimumX = minimumBlockX(tileSize); + int minimumZ = minimumBlockZ(tileSize); + return blockX >= minimumX && blockX < minimumX + tileSize + && blockZ >= minimumZ && blockZ < minimumZ + tileSize; + } + + @Override + public int compareTo(HydrologyTileKey other) { + int xComparison = Integer.compare(tileX, other.tileX); + return xComparison != 0 ? xComparison : Integer.compare(tileZ, other.tileZ); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java new file mode 100644 index 000000000..e304990a0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java @@ -0,0 +1,107 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.List; +import java.util.Objects; +import java.util.OptionalLong; + +public record RiverCourse( + long id, + RiverCourseType type, + OptionalLong sourceNodeId, + OptionalLong outletId, + String profileKey, + int discharge, + List drainageEdges, + List segments +) { + public RiverCourse { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(sourceNodeId, "sourceNodeId"); + Objects.requireNonNull(outletId, "outletId"); + Objects.requireNonNull(drainageEdges, "drainageEdges"); + Objects.requireNonNull(segments, "segments"); + if (profileKey == null || profileKey.isBlank()) { + throw new IllegalArgumentException("profileKey must not be blank."); + } + profileKey = profileKey.trim(); + if (discharge <= 0) { + throw new IllegalArgumentException("Course discharge must be positive."); + } + drainageEdges = List.copyOf(drainageEdges); + segments = List.copyOf(segments); + if (segments.isEmpty()) { + throw new IllegalArgumentException("A river course requires at least one hydraulic segment."); + } + for (HydraulicSegment segment : segments) { + if (segment.courseId() != id) { + throw new IllegalArgumentException("Every segment must belong to its containing course."); + } + } + validateSinkholeContinuation(type, segments); + if (type == RiverCourseType.DEEP_FLUID && (sourceNodeId.isPresent() || outletId.isPresent())) { + throw new IllegalArgumentException("Independent deep-fluid courses cannot reference drainage nodes or river outlets."); + } + if (type != RiverCourseType.DEEP_FLUID && (sourceNodeId.isEmpty() || outletId.isEmpty())) { + throw new IllegalArgumentException("Surface and underground courses require a source and outlet."); + } + } + + public boolean hydraulicallyNonRising() { + int previousHead = Integer.MAX_VALUE; + for (HydraulicSegment segment : segments) { + if (segment.upstreamHeadY() > previousHead || segment.downstreamHeadY() > segment.upstreamHeadY()) { + return false; + } + previousHead = segment.downstreamHeadY(); + } + return true; + } + + public boolean surfaceSinkholeContinuation() { + return type == RiverCourseType.SURFACE + && segments.size() >= 2 + && segments.get(segments.size() - 2).type() == HydrologyFeatureType.SINKHOLE + && segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO; + } + + private static void validateSinkholeContinuation( + RiverCourseType courseType, + List segments + ) { + int sinkholeCount = 0; + for (HydraulicSegment segment : segments) { + if (segment.type() == HydrologyFeatureType.SINKHOLE) { + sinkholeCount++; + } + } + boolean terminalInlandGrotto = courseType == RiverCourseType.SURFACE + && segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO; + if (!terminalInlandGrotto) { + if (sinkholeCount > 0) { + throw new IllegalArgumentException("A sinkhole must link a surface course to its terminal inland grotto."); + } + return; + } + if (sinkholeCount != 1 || segments.size() < 3) { + throw new IllegalArgumentException("A surface inland grotto requires one explicit sinkhole link and surface lip."); + } + HydraulicSegment lip = segments.get(segments.size() - 3); + HydraulicSegment sinkhole = segments.get(segments.size() - 2); + HydraulicSegment grotto = segments.getLast(); + if (!lip.type().isSurface() && lip.type() != HydrologyFeatureType.RIDGE_BORE + || sinkhole.type() != HydrologyFeatureType.SINKHOLE + || sinkhole.drop() <= 0 + || !sinkhole.receivingPool() + || lip.downstreamHeadY() != sinkhole.upstreamHeadY() + || sinkhole.downstreamHeadY() != grotto.upstreamHeadY() + || grotto.upstreamHeadY() != grotto.downstreamHeadY() + || lip.end().y() != sinkhole.upstreamHeadY() + || sinkhole.start().y() != sinkhole.upstreamHeadY() + || sinkhole.end().y() != sinkhole.downstreamHeadY() + || grotto.start().y() != grotto.upstreamHeadY() + || lip.end().distanceSquared2D(sinkhole.start()) != 0L + || sinkhole.end().distanceSquared2D(grotto.start()) != 0L) { + throw new IllegalArgumentException("A surface sinkhole requires a connected lip, graded descent, and receiving grotto."); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java new file mode 100644 index 000000000..f4a11595e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.hydrology; + +public enum RiverCourseType { + SURFACE, + UNDERGROUND, + DEEP_FLUID +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java new file mode 100644 index 000000000..3a6827c23 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java @@ -0,0 +1,115 @@ +package art.arcane.iris.engine.hydrology; + +import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +public final class RiverFootprint { + private final Map columns; + + public RiverFootprint(Map columns) { + if (columns == null) { + throw new IllegalArgumentException("columns must not be null."); + } + TreeMap ordered = new TreeMap<>(); + for (Map.Entry entry : columns.entrySet()) { + HydrologyColumnSample sample = entry.getValue(); + if (sample == null || entry.getKey() != pack(sample.x(), sample.z())) { + throw new IllegalArgumentException("Footprint keys must match their column coordinates."); + } + ordered.put(entry.getKey(), sample); + } + Long2ObjectLinkedOpenHashMap indexed = + new Long2ObjectLinkedOpenHashMap<>(ordered.size()); + for (Map.Entry entry : ordered.entrySet()) { + indexed.put(entry.getKey().longValue(), entry.getValue()); + } + columns = Collections.unmodifiableMap(indexed); + this.columns = columns; + } + + public static RiverFootprint empty() { + return new RiverFootprint(Map.of()); + } + + public Optional sample(int x, int z) { + return Optional.ofNullable(columns.get(pack(x, z))); + } + + public HydrologyRenderSample renderSample(int x, int z) { + HydrologyColumnSample sample = columns.get(pack(x, z)); + return sample == null ? new HydrologyRenderSample(x, z, List.of()) : sample.renderSample(); + } + + public Map columns() { + return columns; + } + + public List columnsIn(int minimumX, int minimumZ, int maximumX, int maximumZ) { + ArrayList selected = new ArrayList<>(); + long width = (long) maximumX - minimumX; + long height = (long) maximumZ - minimumZ; + if (width <= 0L || height <= 0L) { + return List.of(); + } + long area = width > Long.MAX_VALUE / height ? Long.MAX_VALUE : width * height; + if (area < columns.size()) { + for (long z = minimumZ; z < maximumZ; z++) { + for (long x = minimumX; x < maximumX; x++) { + HydrologyColumnSample sample = columns.get(pack((int) x, (int) z)); + if (sample != null) { + selected.add(sample); + } + } + } + return List.copyOf(selected); + } + for (HydrologyColumnSample sample : columns.values()) { + if (sample.x() >= minimumX && sample.x() < maximumX + && sample.z() >= minimumZ && sample.z() < maximumZ) { + selected.add(sample); + } + } + return List.copyOf(selected); + } + + public int size() { + return columns.size(); + } + + public boolean isEmpty() { + return columns.isEmpty(); + } + + public static long pack(int x, int z) { + return ((long) x << 32) ^ (z & 0xffffffffL); + } + + public static int unpackX(long packed) { + return (int) (packed >> 32); + } + + public static int unpackZ(long packed) { + return (int) packed; + } + + @Override + public boolean equals(Object other) { + return other instanceof RiverFootprint footprint && columns.equals(footprint.columns); + } + + @Override + public int hashCode() { + return columns.hashCode(); + } + + @Override + public String toString() { + return "RiverFootprint[columns=" + columns.size() + "]"; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverOutlet.java b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverOutlet.java new file mode 100644 index 000000000..ffab098fe --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverOutlet.java @@ -0,0 +1,30 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.Objects; + +public record RiverOutlet( + long id, + HydrologyFeatureType type, + long drainageNodeId, + HydrologyPoint landwardPoint, + HydrologyPoint connectionPoint, + int seaLevel, + boolean directOcean +) { + public RiverOutlet { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(landwardPoint, "landwardPoint"); + Objects.requireNonNull(connectionPoint, "connectionPoint"); + if (type != HydrologyFeatureType.MOUTH + && type != HydrologyFeatureType.COASTAL_GROTTO + && type != HydrologyFeatureType.INLAND_GROTTO) { + throw new IllegalArgumentException("Unsupported outlet feature type: " + type); + } + if (directOcean && type == HydrologyFeatureType.INLAND_GROTTO) { + throw new IllegalArgumentException("An inland grotto cannot be a direct ocean outlet."); + } + if (directOcean && connectionPoint.y() != seaLevel) { + throw new IllegalArgumentException("A direct ocean connection must be at sea level."); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CavePosition.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CavePosition.java new file mode 100644 index 000000000..1777d5d58 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CavePosition.java @@ -0,0 +1,20 @@ +package art.arcane.iris.engine.hydrology.cave; + +public record CavePosition(int x, int y, int z) { + public CavePosition offset(int dx, int dy, int dz) { + return new CavePosition(x + dx, y + dy, z + dz); + } + + @Override + public int hashCode() { + int hash = 0x811C9DC5; + hash = (hash ^ x) * 0x01000193; + hash = (hash ^ y) * 0x01000193; + hash = (hash ^ z) * 0x01000193; + hash ^= hash >>> 16; + hash *= 0x7FEB352D; + hash ^= hash >>> 15; + hash *= 0x846CA68B; + return hash ^ hash >>> 16; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CavePositionIndex.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CavePositionIndex.java new file mode 100644 index 000000000..19ce6a645 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CavePositionIndex.java @@ -0,0 +1,111 @@ +package art.arcane.iris.engine.hydrology.cave; + +import java.util.Collection; + +public final class CavePositionIndex { + private static final int DEFAULT_CAPACITY = 16; + private static final int MAXIMUM_CAPACITY = 1 << 30; + + private int[] xCoordinates; + private int[] yCoordinates; + private int[] zCoordinates; + private boolean[] occupied; + private int mask; + private int resizeThreshold; + private int size; + + public CavePositionIndex() { + initialize(DEFAULT_CAPACITY); + } + + public CavePositionIndex(int expectedSize) { + initialize(capacityFor(expectedSize)); + } + + public static CavePositionIndex copyOf(Collection positions) { + CavePositionIndex index = new CavePositionIndex(positions.size()); + for (CavePosition position : positions) { + index.add(position.x(), position.y(), position.z()); + } + return index; + } + + public boolean add(int x, int y, int z) { + int slot = findSlot(x, y, z); + if (occupied[slot]) { + return false; + } + occupied[slot] = true; + xCoordinates[slot] = x; + yCoordinates[slot] = y; + zCoordinates[slot] = z; + size++; + if (size > resizeThreshold) { + resize(); + } + return true; + } + + public boolean contains(int x, int y, int z) { + return occupied[findSlot(x, y, z)]; + } + + private void initialize(int capacity) { + xCoordinates = new int[capacity]; + yCoordinates = new int[capacity]; + zCoordinates = new int[capacity]; + occupied = new boolean[capacity]; + mask = capacity - 1; + resizeThreshold = capacity - (capacity >>> 2); + size = 0; + } + + private int findSlot(int x, int y, int z) { + int slot = hash(x, y, z) & mask; + while (occupied[slot] + && (xCoordinates[slot] != x || yCoordinates[slot] != y || zCoordinates[slot] != z)) { + slot = slot + 1 & mask; + } + return slot; + } + + private void resize() { + if (occupied.length == MAXIMUM_CAPACITY) { + resizeThreshold = Integer.MAX_VALUE; + return; + } + int[] previousXCoordinates = xCoordinates; + int[] previousYCoordinates = yCoordinates; + int[] previousZCoordinates = zCoordinates; + boolean[] previousOccupied = occupied; + initialize(occupied.length << 1); + for (int index = 0; index < previousOccupied.length; index++) { + if (previousOccupied[index]) { + add(previousXCoordinates[index], previousYCoordinates[index], previousZCoordinates[index]); + } + } + } + + private static int capacityFor(int expectedSize) { + if (expectedSize < 0) { + throw new IllegalArgumentException("Expected position count must not be negative."); + } + long requiredCapacity = Math.max(DEFAULT_CAPACITY, ((long) expectedSize * 4L + 2L) / 3L); + if (requiredCapacity >= MAXIMUM_CAPACITY) { + return MAXIMUM_CAPACITY; + } + return Integer.highestOneBit((int) requiredCapacity - 1) << 1; + } + + private static int hash(int x, int y, int z) { + int hash = 0x811C9DC5; + hash = (hash ^ x) * 0x01000193; + hash = (hash ^ y) * 0x01000193; + hash = (hash ^ z) * 0x01000193; + hash ^= hash >>> 16; + hash *= 0x7FEB352D; + hash ^= hash >>> 15; + hash *= 0x846CA68B; + return hash ^ hash >>> 16; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxel.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxel.java new file mode 100644 index 000000000..d045e5ff0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxel.java @@ -0,0 +1,10 @@ +package art.arcane.iris.engine.hydrology.cave; + +public enum CaveVoxel { + SOLID, + CAVE_AIR, + COMPATIBLE_FLUID, + LAVA, + INCOMPATIBLE_FLUID, + UNCONDITIONAL +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelPrecondition.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelPrecondition.java similarity index 80% rename from core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelPrecondition.java rename to core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelPrecondition.java index 2bd2ce6a1..368f4834e 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelPrecondition.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelPrecondition.java @@ -1,4 +1,4 @@ -package art.arcane.iris.engine.river.cave; +package art.arcane.iris.engine.hydrology.cave; import java.util.Objects; diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelView.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelView.java new file mode 100644 index 000000000..ac9de345a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelView.java @@ -0,0 +1,26 @@ +package art.arcane.iris.engine.hydrology.cave; + +public interface CaveVoxelView { + boolean isInWorld(CavePosition position); + + CaveVoxel voxelAt(CavePosition position); + + boolean isOpenToSurface(CavePosition position); + + boolean isAboveTerrainSurface(CavePosition position); + + default boolean hasAboveTerrainSurface(int x, int z, int minimumY, int maximumY) { + if (minimumY > maximumY) { + return false; + } + for (int y = minimumY; ; y++) { + CavePosition position = new CavePosition(x, y, z); + if (isInWorld(position) && isAboveTerrainSurface(position)) { + return true; + } + if (y == maximumY) { + return false; + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveAction.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveAction.java new file mode 100644 index 000000000..2e16db766 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveAction.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.hydrology.cave; + +public enum HydrologyCaveAction { + WET_SOURCE, + FALLING_FLUID, + DRY_AIR, + SEAL_GUARD +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveCandidate.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveCandidate.java new file mode 100644 index 000000000..8e163771f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveCandidate.java @@ -0,0 +1,33 @@ +package art.arcane.iris.engine.hydrology.cave; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +public record HydrologyCaveCandidate( + HydrologyCaveSource source, + String profileKey, + HydrologyCavePlannerSettings settings, + boolean allowDryCaveConnections, + Map actions, + Set intentionalOpenings +) { + public HydrologyCaveCandidate { + Objects.requireNonNull(source); + if (profileKey == null || profileKey.isBlank()) { + throw new IllegalArgumentException("profileKey must not be blank"); + } + profileKey = profileKey.trim(); + Objects.requireNonNull(settings); + actions = Collections.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(actions))); + intentionalOpenings = Collections.unmodifiableSet( + new LinkedHashSet<>(Objects.requireNonNull(intentionalOpenings)) + ); + if (actions.isEmpty()) { + throw new IllegalArgumentException("A cave candidate requires at least one planned action"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveCell.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveCell.java new file mode 100644 index 000000000..cf094052f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveCell.java @@ -0,0 +1,98 @@ +package art.arcane.iris.engine.hydrology.cave; + +import art.arcane.volmlib.util.matter.MatterCavern; + +import java.util.Objects; + +public final class HydrologyCaveCell { + private static final String DEFAULT_FLUID_PROFILE_KEY = "default"; + private static final byte LIQUID_FLUID = 1; + private static final byte LIQUID_FORCED_AIR = 3; + + private final HydrologyCaveAction action; + private final String fluidProfileKey; + private final String floodedBiomeKey; + private final MatterCavern cavern; + + public HydrologyCaveCell( + HydrologyCaveAction action, + String fluidProfileKey, + String floodedBiomeKey + ) { + this.action = Objects.requireNonNull(action); + if (fluidProfileKey == null || fluidProfileKey.isBlank()) { + throw new IllegalArgumentException("fluidProfileKey must not be blank"); + } + this.fluidProfileKey = fluidProfileKey.trim(); + this.floodedBiomeKey = floodedBiomeKey == null ? "" : floodedBiomeKey.trim(); + this.cavern = switch (action) { + case WET_SOURCE, FALLING_FLUID -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FLUID); + case DRY_AIR -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FORCED_AIR); + case SEAL_GUARD -> null; + }; + } + + public static HydrologyCaveCell of(HydrologyCaveAction action) { + return new HydrologyCaveCell(action, DEFAULT_FLUID_PROFILE_KEY, ""); + } + + public static HydrologyCaveCell of(HydrologyCaveAction action, String fluidProfileKey) { + return new HydrologyCaveCell(action, fluidProfileKey, ""); + } + + public boolean carves() { + return action != HydrologyCaveAction.SEAL_GUARD; + } + + public boolean isWet() { + return action == HydrologyCaveAction.WET_SOURCE || action == HydrologyCaveAction.FALLING_FLUID; + } + + public boolean isFalling() { + return action == HydrologyCaveAction.FALLING_FLUID; + } + + public boolean protectsPlacement() { + return true; + } + + public MatterCavern asCavern() { + return cavern; + } + + public HydrologyCaveAction action() { + return action; + } + + public String floodedBiomeKey() { + return floodedBiomeKey; + } + + public String fluidProfileKey() { + return fluidProfileKey; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof HydrologyCaveCell hydrology)) { + return false; + } + return action == hydrology.action + && fluidProfileKey.equals(hydrology.fluidProfileKey) + && floodedBiomeKey.equals(hydrology.floodedBiomeKey); + } + + @Override + public int hashCode() { + return (31 * ((31 * action.hashCode()) + fluidProfileKey.hashCode())) + floodedBiomeKey.hashCode(); + } + + @Override + public String toString() { + return "HydrologyCaveCell[action=" + action + ", fluidProfileKey=" + fluidProfileKey + + ", floodedBiomeKey=" + floodedBiomeKey + "]"; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveConflictPolicy.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveConflictPolicy.java new file mode 100644 index 000000000..549b68936 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveConflictPolicy.java @@ -0,0 +1,82 @@ +package art.arcane.iris.engine.hydrology.cave; + +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; + +public final class HydrologyCaveConflictPolicy { + private static final Comparator SOURCE_PRIORITY = Comparator + .comparingInt(HydrologyCaveSource::waterHeadY) + .reversed() + .thenComparingLong(HydrologyCaveSource::sourceId) + .thenComparingInt(source -> source.entry().x()) + .thenComparingInt(source -> source.entry().y()) + .thenComparingInt(source -> source.entry().z()) + .thenComparingInt(source -> source.target().x()) + .thenComparingInt(source -> source.target().y()) + .thenComparingInt(source -> source.target().z()) + .thenComparing(HydrologyCaveSource::mode); + + private HydrologyCaveConflictPolicy() { + } + + public static Comparator sourcePriority() { + return SOURCE_PRIORITY; + } + + public static int compareSources(HydrologyCaveSource left, HydrologyCaveSource right) { + return SOURCE_PRIORITY.compare( + Objects.requireNonNull(left, "left"), + Objects.requireNonNull(right, "right") + ); + } + + public static boolean hasIncompatibleOverlap( + String leftProfileKey, + Map leftActions, + String rightProfileKey, + Map rightActions + ) { + String normalizedLeftProfileKey = requireProfileKey(leftProfileKey, "leftProfileKey"); + String normalizedRightProfileKey = requireProfileKey(rightProfileKey, "rightProfileKey"); + Map normalizedLeftActions = Objects.requireNonNull( + leftActions, + "leftActions" + ); + Map normalizedRightActions = Objects.requireNonNull( + rightActions, + "rightActions" + ); + if (normalizedLeftActions.size() > normalizedRightActions.size()) { + return hasIncompatibleOverlap( + normalizedRightProfileKey, + normalizedRightActions, + normalizedLeftProfileKey, + normalizedLeftActions + ); + } + boolean sameProfile = normalizedLeftProfileKey.equals(normalizedRightProfileKey); + for (Map.Entry entry : normalizedLeftActions.entrySet()) { + CavePosition position = Objects.requireNonNull(entry.getKey(), "left action position"); + if (!normalizedRightActions.containsKey(position)) { + continue; + } + HydrologyCaveAction leftAction = Objects.requireNonNull(entry.getValue(), "left action"); + HydrologyCaveAction rightAction = Objects.requireNonNull( + normalizedRightActions.get(position), + "right action" + ); + if (!sameProfile || leftAction != rightAction) { + return true; + } + } + return false; + } + + private static String requireProfileKey(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + return value.trim(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java new file mode 100644 index 000000000..cc986d34f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlanner.java @@ -0,0 +1,1849 @@ +package art.arcane.iris.engine.hydrology.cave; + +import art.arcane.iris.engine.hydrology.HydrologyObservedPlannedSurface; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.OptionalLong; +import java.util.Queue; +import java.util.Set; +import java.util.function.BiPredicate; + +public final class HydrologyCaveContainmentPlanner { + private static final int MAXIMUM_INITIAL_GUARD_INDEX_SIZE = 16_384; + private static final List DIRECTIONS = List.of( + new CavePosition(1, 0, 0), + new CavePosition(-1, 0, 0), + new CavePosition(0, 1, 0), + new CavePosition(0, -1, 0), + new CavePosition(0, 0, 1), + new CavePosition(0, 0, -1) + ); + private static final Comparator SOURCE_PRIORITY = + HydrologyCaveConflictPolicy.sourcePriority(); + + public HydrologyCavePlan plan( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings + ) { + Objects.requireNonNull(view); + Objects.requireNonNull(source); + Objects.requireNonNull(settings); + + HydrologyCaveRejection sourceRejection = validateSource(source); + if (sourceRejection != HydrologyCaveRejection.NONE) { + return rejected(source, sourceRejection); + } + + PathResult throat = buildThroat(view, source, settings); + if (throat.rejection() != HydrologyCaveRejection.NONE) { + return rejected(source, throat.rejection()); + } + + return switch (source.mode()) { + case CLOSED_COMPONENT -> planClosedComponent(view, source, settings, throat.positions()); + case GENERATED_GROTTO -> planGeneratedGrotto(view, source, settings, throat.positions()); + case GROTTO_OR_CLOSED_COMPONENT -> planGrottoOrClosedComponent( + view, + source, + settings, + throat.positions() + ); + case WATERFALL_POOL -> planWaterfallPool( + view, + source, + settings, + throat.positions() + ); + case DEEP_POOL -> planDeepPool(view, source, settings, throat.positions()); + }; + } + + public HydrologyCavePlanningResult planAll( + CaveVoxelView view, + Collection sources, + HydrologyCavePlannerSettings settings + ) { + Objects.requireNonNull(view); + Objects.requireNonNull(sources); + Objects.requireNonNull(settings); + + List orderedSources = new ArrayList<>(sources); + orderedSources.sort(SOURCE_PRIORITY); + List plans = new ArrayList<>(orderedSources.size()); + Map combinedActions = new LinkedHashMap<>(); + Map claimedBy = new HashMap<>(); + Map combinedPreconditions = new LinkedHashMap<>(); + + for (HydrologyCaveSource source : orderedSources) { + HydrologyCavePlan candidate = plan(view, source, settings); + if (!candidate.accepted()) { + plans.add(candidate); + continue; + } + OptionalLong winnerSourceId = findWinningSourceId( + candidate.actions().keySet(), + claimedBy + ); + if (winnerSourceId.isPresent()) { + plans.add(rejectedOverlap(source, winnerSourceId.getAsLong())); + } else { + plans.add(candidate); + combinedActions.putAll(candidate.actions()); + combinedPreconditions.putAll(candidate.baselinePreconditions()); + } + for (CavePosition position : candidate.actions().keySet()) { + claimedBy.putIfAbsent(position, source); + } + } + + return new HydrologyCavePlanningResult(plans, combinedActions, combinedPreconditions); + } + + public HydrologyCavePlanningResult validateAll( + CaveVoxelView view, + Collection candidates + ) { + return validateAll(view, candidates, null); + } + + public HydrologyCavePlanningResult validateAll( + CaveVoxelView view, + Collection candidates, + ValidationCache validationCache + ) { + return validateAll(view, candidates, validationCache, null); + } + + public HydrologyCavePlanningResult validateAll( + CaveVoxelView view, + Collection candidates, + ValidationCache validationCache, + HydrologyObservedPlannedSurface plannedSurface + ) { + ValidationBatch batch = validateBatch( + view, + candidates, + validationCache, + plannedSurface, + Set.of(), + (HydrologyCaveCandidate first, HydrologyCaveCandidate second) -> false, + true + ); + return new HydrologyCavePlanningResult( + batch.plans(), + batch.actions(), + batch.baselinePreconditions() + ); + } + + public List validateAllPlans( + CaveVoxelView view, + Collection candidates + ) { + return validateAllPlans(view, candidates, null, null); + } + + public List validateAllPlans( + CaveVoxelView view, + Collection candidates, + ValidationCache validationCache, + HydrologyObservedPlannedSurface plannedSurface + ) { + return validateAllPlans(view, candidates, validationCache, plannedSurface, Set.of()); + } + + public List validateAllPlans( + CaveVoxelView view, + Collection candidates, + ValidationCache validationCache, + HydrologyObservedPlannedSurface plannedSurface, + Set exposureValidatedCandidates + ) { + return validateAllPlans( + view, + candidates, + validationCache, + plannedSurface, + exposureValidatedCandidates, + (HydrologyCaveCandidate first, HydrologyCaveCandidate second) -> false + ); + } + + public List validateAllPlans( + CaveVoxelView view, + Collection candidates, + ValidationCache validationCache, + HydrologyObservedPlannedSurface plannedSurface, + Set exposureValidatedCandidates, + BiPredicate compatibleOverlap + ) { + Objects.requireNonNull(exposureValidatedCandidates); + Objects.requireNonNull(compatibleOverlap); + return validateBatch( + view, + candidates, + validationCache, + plannedSurface, + exposureValidatedCandidates, + compatibleOverlap, + false + ).plans(); + } + + private ValidationBatch validateBatch( + CaveVoxelView view, + Collection candidates, + ValidationCache validationCache, + HydrologyObservedPlannedSurface plannedSurface, + Set exposureValidatedCandidates, + BiPredicate compatibleOverlap, + boolean aggregateResults + ) { + Objects.requireNonNull(view); + Objects.requireNonNull(candidates); + + List orderedCandidates = new ArrayList<>(candidates); + orderedCandidates.sort(Comparator.comparing(HydrologyCaveCandidate::source, SOURCE_PRIORITY)); + List plans = new ArrayList<>(orderedCandidates.size()); + Map combinedActions = new LinkedHashMap<>(); + Map combinedPreconditions = new LinkedHashMap<>(); + Map claimedBy = null; + + for (HydrologyCaveCandidate candidate : orderedCandidates) { + boolean exposureValidated = exposureValidatedCandidates.contains(candidate); + HydrologyCavePlan plan = validationCache == null + ? validate(view, candidate, exposureValidated) + : validationCache.validate(this, view, candidate, plannedSurface, exposureValidated); + if (!plan.accepted()) { + plans.add(plan); + continue; + } + OptionalLong winnerSourceId = claimedBy == null + ? OptionalLong.empty() + : findConflictingSourceId(candidate, plan.actions(), claimedBy, compatibleOverlap); + if (winnerSourceId.isPresent()) { + plans.add(rejectedOverlap(candidate.source(), winnerSourceId.getAsLong())); + continue; + } + plans.add(plan); + if (aggregateResults) { + plan.forEachAction(combinedActions::put); + plan.forEachPrecondition(combinedPreconditions::put); + } + if (claimedBy == null) { + claimedBy = HashMap.newHashMap(plan.actions().size()); + } + CaveClaimGroup claimGroup = new CaveClaimGroup(candidate, plan.actions()); + Map claims = claimedBy; + plan.forEachAction((CavePosition position, HydrologyCaveAction ignored) -> + claims.putIfAbsent(position, claimGroup)); + } + + return new ValidationBatch( + List.copyOf(plans), + combinedActions, + combinedPreconditions + ); + } + + private record ValidationBatch( + List plans, + Map actions, + Map baselinePreconditions + ) { + } + + public static final class ValidationCache { + private static final int DEFAULT_MAXIMUM_ENTRIES = 256; + private static final long DEFAULT_MAXIMUM_RETAINED_POSITIONS = 262_144L; + + private final IdentityHashMap validations; + private final HashMap equivalentValidations; + private final int maximumEntries; + private final long maximumRetainedPositions; + private long retainedPositions; + private long hits; + private long misses; + + public ValidationCache() { + this(DEFAULT_MAXIMUM_ENTRIES, DEFAULT_MAXIMUM_RETAINED_POSITIONS); + } + + ValidationCache(int maximumEntries, long maximumRetainedPositions) { + if (maximumEntries < 1) { + throw new IllegalArgumentException("Maximum validation cache entries must be positive."); + } + if (maximumRetainedPositions < 1L) { + throw new IllegalArgumentException("Maximum retained validation positions must be positive."); + } + this.validations = new IdentityHashMap<>(); + this.equivalentValidations = new HashMap<>(); + this.maximumEntries = maximumEntries; + this.maximumRetainedPositions = maximumRetainedPositions; + } + + private HydrologyCavePlan validate( + HydrologyCaveContainmentPlanner planner, + CaveVoxelView view, + HydrologyCaveCandidate candidate, + HydrologyObservedPlannedSurface plannedSurface, + boolean exposureValidated + ) { + CachedValidation cached = validations.get(candidate); + if (cached == null) { + cached = equivalentValidations.get(candidate); + if (cached != null) { + validations.put(candidate, cached); + } + } + if (cached != null) { + boolean matches = plannedSurface == null + ? cached.viewObservations().matches(view) + : cached.surfaceObservations().matches(plannedSurface); + if (matches) { + hits++; + return cached.plan(); + } + remove(cached); + } + misses++; + boolean observeColumnsOnly = plannedSurface != null; + RecordingCaveVoxelView recording = new RecordingCaveVoxelView(view, observeColumnsOnly); + HydrologyCavePlan plan = planner.validate(recording, candidate, exposureValidated); + ViewObservations viewObservations = recording.snapshot(); + SurfaceObservations surfaceObservations = plannedSurface == null + ? SurfaceObservations.empty() + : SurfaceObservations.capture(viewObservations, plannedSurface); + if (observeColumnsOnly) { + viewObservations = ViewObservations.empty(); + } + CachedValidation validation = new CachedValidation(plan, viewObservations, surfaceObservations); + retain(candidate, validation); + return plan; + } + + public long hits() { + return hits; + } + + public void clear() { + clearEntries(); + hits = 0L; + misses = 0L; + } + + private void retain(HydrologyCaveCandidate candidate, CachedValidation validation) { + long weight = validation.retainedPositions(); + if (weight > maximumRetainedPositions) { + return; + } + if (equivalentValidations.size() >= maximumEntries + || retainedPositions + weight > maximumRetainedPositions) { + clearEntries(); + } + validations.put(candidate, validation); + equivalentValidations.put(candidate, validation); + retainedPositions += weight; + } + + private void remove(CachedValidation cached) { + validations.entrySet().removeIf( + (Map.Entry entry) -> entry.getValue() == cached + ); + equivalentValidations.entrySet().removeIf( + (Map.Entry entry) -> entry.getValue() == cached + ); + retainedPositions -= cached.retainedPositions(); + } + + private void clearEntries() { + validations.clear(); + equivalentValidations.clear(); + retainedPositions = 0L; + } + + public long misses() { + return misses; + } + } + + private static final class RecordingCaveVoxelView implements CaveVoxelView { + private static final byte IN_WORLD = 0; + private static final byte VOXEL = 1; + private static final byte OPEN_TO_SURFACE = 2; + private static final byte ABOVE_TERRAIN_SURFACE = 3; + + private final CaveVoxelView delegate; + private final boolean observeColumnsOnly; + private CavePosition[] positions; + private byte[] operations; + private byte[] values; + private CavePositionIndex[] observed; + private int size; + + private RecordingCaveVoxelView(CaveVoxelView delegate, boolean observeColumnsOnly) { + this.delegate = delegate; + this.observeColumnsOnly = observeColumnsOnly; + this.positions = new CavePosition[1024]; + this.operations = observeColumnsOnly ? new byte[0] : new byte[1024]; + this.values = observeColumnsOnly ? new byte[0] : new byte[1024]; + this.observed = observeColumnsOnly + ? new CavePositionIndex[]{new CavePositionIndex()} + : new CavePositionIndex[]{ + new CavePositionIndex(), + new CavePositionIndex(), + new CavePositionIndex(), + new CavePositionIndex() + }; + } + + @Override + public boolean isInWorld(CavePosition position) { + boolean result = delegate.isInWorld(position); + add(position, IN_WORLD, result ? 1 : 0); + return result; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + CaveVoxel result = delegate.voxelAt(position); + add(position, VOXEL, result.ordinal()); + return result; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + boolean result = delegate.isOpenToSurface(position); + add(position, OPEN_TO_SURFACE, result ? 1 : 0); + return result; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + boolean result = delegate.isAboveTerrainSurface(position); + add(position, ABOVE_TERRAIN_SURFACE, result ? 1 : 0); + return result; + } + + private void add(CavePosition position, byte operation, int value) { + int observedIndex = observeColumnsOnly ? 0 : operation; + int observedY = observeColumnsOnly ? 0 : position.y(); + if (!observed[observedIndex].add(position.x(), observedY, position.z())) { + return; + } + if (size == positions.length) { + int expandedSize = Math.multiplyExact(size, 2); + positions = Arrays.copyOf(positions, expandedSize); + if (!observeColumnsOnly) { + operations = Arrays.copyOf(operations, expandedSize); + values = Arrays.copyOf(values, expandedSize); + } + } + positions[size] = position; + if (!observeColumnsOnly) { + operations[size] = operation; + values[size] = (byte) value; + } + size++; + } + + private ViewObservations snapshot() { + observed = null; + if (positions.length == 1024) { + return new ViewObservations( + Arrays.copyOf(positions, size), + observeColumnsOnly ? operations : Arrays.copyOf(operations, size), + observeColumnsOnly ? values : Arrays.copyOf(values, size), + size + ); + } + return new ViewObservations(positions, operations, values, size); + } + } + + private record CachedValidation( + HydrologyCavePlan plan, + ViewObservations viewObservations, + SurfaceObservations surfaceObservations + ) { + private long retainedPositions() { + return Math.max(1L, (long) plan.actions().size() + + plan.baselinePreconditions().size() + + viewObservations.size() + + surfaceObservations.observations().size()); + } + } + + private record SurfaceObservations( + List observations + ) { + private static SurfaceObservations empty() { + return new SurfaceObservations(List.of()); + } + + private static SurfaceObservations capture( + ViewObservations viewObservations, + HydrologyObservedPlannedSurface plannedSurface + ) { + ArrayList observations = new ArrayList<>(); + for (int index = 0; index < viewObservations.size(); index++) { + CavePosition position = viewObservations.positions()[index]; + observations.addAll(plannedSurface.observationsAt(position.x(), position.z())); + } + return new SurfaceObservations(List.copyOf(observations)); + } + + private boolean matches(HydrologyObservedPlannedSurface plannedSurface) { + for (HydrologyObservedPlannedSurface.Observation observation : observations) { + int resolvedHeight = plannedSurface.resolve( + observation.x(), + observation.z(), + observation.naturalHeight() + ); + if (resolvedHeight != observation.resolvedHeight()) { + return false; + } + } + return true; + } + } + + private record ViewObservations( + CavePosition[] positions, + byte[] operations, + byte[] values, + int size + ) { + private static ViewObservations empty() { + return new ViewObservations(new CavePosition[0], new byte[0], new byte[0], 0); + } + + private boolean matches(CaveVoxelView view) { + for (int index = 0; index < size; index++) { + CavePosition position = positions[index]; + int actual = switch (operations[index]) { + case RecordingCaveVoxelView.IN_WORLD -> view.isInWorld(position) ? 1 : 0; + case RecordingCaveVoxelView.VOXEL -> view.voxelAt(position).ordinal(); + case RecordingCaveVoxelView.OPEN_TO_SURFACE -> view.isOpenToSurface(position) ? 1 : 0; + case RecordingCaveVoxelView.ABOVE_TERRAIN_SURFACE -> + view.isAboveTerrainSurface(position) ? 1 : 0; + default -> throw new IllegalStateException("Unknown cave view operation."); + }; + if (actual != Byte.toUnsignedInt(values[index])) { + return false; + } + } + return true; + } + } + + public HydrologyCavePlan validate(CaveVoxelView view, HydrologyCaveCandidate candidate) { + return validate(view, candidate, false); + } + + private HydrologyCavePlan validate( + CaveVoxelView view, + HydrologyCaveCandidate candidate, + boolean exposureValidated + ) { + Objects.requireNonNull(view); + Objects.requireNonNull(candidate); + HydrologyCaveSource source = candidate.source(); + HydrologyCavePlannerSettings settings = candidate.settings(); + HydrologyCaveRejection sourceRejection = validateSource(source); + if (sourceRejection != HydrologyCaveRejection.NONE) { + return rejected(source, sourceRejection); + } + if (exceedsFloodVolume(candidate.actions(), settings.maxFloodVolume())) { + return rejected(source, HydrologyCaveRejection.VOLUME_LIMIT); + } + CavePositionIndex actionIndex = CavePositionIndex.copyOf(candidate.actions().keySet()); + CavePositionIndex openingIndex = CavePositionIndex.copyOf(candidate.intentionalOpenings()); + if (!exposureValidated + && isPlannedVolumeAboveTerrainSurface(view, candidate, actionIndex, openingIndex)) { + return rejected(source, HydrologyCaveRejection.OPEN_SURFACE); + } + + Map plannedActions = candidate.actions(); + LinkedHashMap preconditions = + new LinkedHashMap<>(plannedActions.size()); + ArrayList guards = new ArrayList<>(); + HydrologyCaveRejection boundaryRejection = addPlannedBoundaryGuards( + view, + source, + settings, + candidate.allowDryCaveConnections(), + candidate.intentionalOpenings(), + plannedActions, + preconditions, + actionIndex, + openingIndex, + guards + ); + if (boundaryRejection != HydrologyCaveRejection.NONE) { + return rejected(source, boundaryRejection); + } + + for (Map.Entry entry : plannedActions.entrySet()) { + CavePosition position = entry.getKey(); + HydrologyCaveRejection rejection = validatePlannedPosition( + view, + source, + settings, + position, + preconditions + ); + if (rejection != HydrologyCaveRejection.NONE) { + return rejected(source, rejection); + } + } + LinkedHashMap actions = + new LinkedHashMap<>(plannedActions.size() + guards.size()); + actions.putAll(plannedActions); + for (CavePosition guard : guards) { + actions.put(guard, HydrologyCaveAction.SEAL_GUARD); + } + return accepted(source, actions, preconditions); + } + + private boolean exceedsFloodVolume( + Map actions, + int maximumFloodVolume + ) { + if (actions.size() > HydrologyCavePlannerSettings.MAXIMUM_PLANNED_MUTATIONS) { + return true; + } + int mutationCount = 0; + for (HydrologyCaveAction action : actions.values()) { + if (action != HydrologyCaveAction.SEAL_GUARD && ++mutationCount > maximumFloodVolume) { + return true; + } + } + return false; + } + + private boolean eligibleForTerrainSurfaceCheck( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + CavePosition position + ) { + return view.isInWorld(position) + && validateBounds(source, settings, position) == HydrologyCaveRejection.NONE; + } + + private boolean isPlannedVolumeAboveTerrainSurface( + CaveVoxelView view, + HydrologyCaveCandidate candidate, + CavePositionIndex actionIndex, + CavePositionIndex openingIndex + ) { + HydrologyCaveSource source = candidate.source(); + HydrologyCavePlannerSettings settings = candidate.settings(); + for (CavePosition position : candidate.actions().keySet()) { + if (!openingIndex.contains(position.x(), position.y(), position.z()) + && eligibleForTerrainSurfaceCheck(view, source, settings, position) + && view.isAboveTerrainSurface(position)) { + return true; + } + for (CavePosition direction : DIRECTIONS) { + int neighborX = position.x() + direction.x(); + int neighborY = position.y() + direction.y(); + int neighborZ = position.z() + direction.z(); + if (actionIndex.contains(neighborX, neighborY, neighborZ) + || openingIndex.contains(neighborX, neighborY, neighborZ)) { + continue; + } + CavePosition neighbor = new CavePosition(neighborX, neighborY, neighborZ); + if (!eligibleForTerrainSurfaceCheck(view, source, settings, neighbor)) { + continue; + } + if (view.isAboveTerrainSurface(neighbor)) { + return true; + } + } + } + return false; + } + + private HydrologyCaveRejection validatePlannedPosition( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + CavePosition position, + Map preconditions + ) { + if (preconditions.containsKey(position)) { + return HydrologyCaveRejection.NONE; + } + if (!view.isInWorld(position)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return boundsRejection; + } + CaveVoxel voxel = voxelAt(view, position); + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != HydrologyCaveRejection.NONE) { + return hazard; + } + boolean openToSurface = view.isOpenToSurface(position); + preconditions.put(position, new CaveVoxelPrecondition(voxel, openToSurface)); + return HydrologyCaveRejection.NONE; + } + + private HydrologyCaveRejection addPlannedBoundaryGuards( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + boolean allowDryCaveConnections, + Set intentionalOpenings, + Map actions, + Map preconditions, + CavePositionIndex actionIndex, + CavePositionIndex openingIndex, + List guards + ) { + for (CavePosition opening : intentionalOpenings) { + HydrologyCaveRejection rejection = validateIntentionalOpening( + view, + source, + settings, + opening, + preconditions + ); + if (rejection != HydrologyCaveRejection.NONE) { + return rejection; + } + } + CavePositionIndex guardIndex = new CavePositionIndex( + Math.min(actions.size(), MAXIMUM_INITIAL_GUARD_INDEX_SIZE) + ); + for (Map.Entry entry : actions.entrySet()) { + CavePosition position = entry.getKey(); + for (CavePosition direction : DIRECTIONS) { + int neighborX = position.x() + direction.x(); + int neighborY = position.y() + direction.y(); + int neighborZ = position.z() + direction.z(); + if (actionIndex.contains(neighborX, neighborY, neighborZ) + || openingIndex.contains(neighborX, neighborY, neighborZ)) { + continue; + } + CavePosition neighbor = new CavePosition(neighborX, neighborY, neighborZ); + CaveVoxelPrecondition precondition = preconditions.get(neighbor); + if (precondition == null) { + if (!view.isInWorld(neighbor)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, neighbor); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return boundsRejection; + } + CaveVoxel voxel = voxelAt(view, neighbor); + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != HydrologyCaveRejection.NONE) { + return hazard; + } + precondition = new CaveVoxelPrecondition(voxel, view.isOpenToSurface(neighbor)); + preconditions.put(neighbor, precondition); + } + if (precondition.voxel() == CaveVoxel.UNCONDITIONAL) { + continue; + } + if (entry.getValue() == HydrologyCaveAction.DRY_AIR + && precondition.voxel() == CaveVoxel.CAVE_AIR + && allowDryCaveConnections + && !precondition.openToSurface()) { + continue; + } + if (guardIndex.add(neighborX, neighborY, neighborZ)) { + if (actions.size() + guards.size() + >= HydrologyCavePlannerSettings.MAXIMUM_PLANNED_MUTATIONS) { + return HydrologyCaveRejection.VOLUME_LIMIT; + } + guards.add(neighbor); + } + } + } + return HydrologyCaveRejection.NONE; + } + + private HydrologyCaveRejection validateIntentionalOpening( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + CavePosition position, + Map preconditions + ) { + if (!view.isInWorld(position)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return boundsRejection; + } + CaveVoxel voxel = voxelAt(view, position); + if (voxel == CaveVoxel.LAVA) { + return HydrologyCaveRejection.LAVA_CONTACT; + } + if (voxel == CaveVoxel.INCOMPATIBLE_FLUID) { + return HydrologyCaveRejection.INCOMPATIBLE_FLUID; + } + boolean openToSurface = voxel == CaveVoxel.UNCONDITIONAL || view.isOpenToSurface(position); + preconditions.put(position, new CaveVoxelPrecondition(voxel, openToSurface)); + return HydrologyCaveRejection.NONE; + } + + private OptionalLong findConflictingSourceId( + HydrologyCaveCandidate candidate, + Map actions, + Map claimedBy, + BiPredicate compatibleOverlap + ) { + HydrologyCaveSource winner = null; + for (Map.Entry entry : actions.entrySet()) { + CaveClaimGroup claim = claimedBy.get(entry.getKey()); + if (claim == null + || claim.candidate().profileKey().equals(candidate.profileKey()) + && claim.actions().get(entry.getKey()) == entry.getValue() + || compatibleOverlap.test(claim.candidate(), candidate)) { + continue; + } + HydrologyCaveSource contender = claim.candidate().source(); + if (winner == null || SOURCE_PRIORITY.compare(contender, winner) < 0) { + winner = contender; + } + } + return winner == null ? OptionalLong.empty() : OptionalLong.of(winner.sourceId()); + } + + private HydrologyCavePlan planGrottoOrClosedComponent( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + List throat + ) { + CaveVoxel targetVoxel = voxelAt(view, source.target()); + if (isFluidReachable(targetVoxel, settings)) { + return planClosedComponent(view, source, settings, throat); + } + if (targetVoxel == CaveVoxel.LAVA) { + return rejected(source, HydrologyCaveRejection.LAVA_CONTACT); + } + if (targetVoxel == CaveVoxel.INCOMPATIBLE_FLUID) { + return rejected(source, HydrologyCaveRejection.INCOMPATIBLE_FLUID); + } + return planGeneratedGrotto(view, source, settings, throat); + } + + private HydrologyCavePlan planWaterfallPool( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + List throat + ) { + if (!view.isOpenToSurface(source.target())) { + return planGrottoOrClosedComponent(view, source, settings, throat); + } + HydrologyCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat); + if (dryThroatRejection != HydrologyCaveRejection.NONE) { + return rejected(source, dryThroatRejection); + } + HydrologyCaveRejection shaftRejection = validateWaterfallShaft(view, source, settings, throat); + if (shaftRejection != HydrologyCaveRejection.NONE) { + return rejected(source, shaftRejection); + } + CaveVoxel targetVoxel = voxelAt(view, source.target()); + if (!isFluidReachable(targetVoxel, settings)) { + return rejected(source, rejectionForTarget(targetVoxel, settings)); + } + + Map actions = new LinkedHashMap<>(); + addThroatActions(actions, throat, source); + addSealGuards(view, source, actions); + return accepted(view, source, actions); + } + + private HydrologyCavePlan planClosedComponent( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + List throat + ) { + HydrologyCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat); + if (dryThroatRejection != HydrologyCaveRejection.NONE) { + return rejected(source, dryThroatRejection); + } + HydrologyCaveRejection waterfallRejection = validateWaterfallShaft(view, source, settings, throat); + if (waterfallRejection != HydrologyCaveRejection.NONE) { + return rejected(source, waterfallRejection); + } + + CaveVoxel targetVoxel = voxelAt(view, source.target()); + if (!isFluidReachable(targetVoxel, settings)) { + return rejected(source, rejectionForTarget(targetVoxel, settings)); + } + + ComponentResult component = resolveClosedComponent(view, source, settings, throat); + if (component.rejection() != HydrologyCaveRejection.NONE) { + return rejected(source, component.rejection()); + } + + Map actions = new LinkedHashMap<>(); + addThroatActions(actions, throat, source); + for (CavePosition position : component.positions()) { + actions.put(position, HydrologyCaveAction.WET_SOURCE); + } + addSealGuards(view, source, actions); + return accepted(view, source, actions); + } + + private HydrologyCaveRejection validateDryThroatContacts( + CaveVoxelView view, + HydrologyCaveSource source, + List throat + ) { + Set throatPositions = Set.copyOf(throat); + for (CavePosition position : throat) { + if (position.y() <= source.waterHeadY()) { + continue; + } + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) { + continue; + } + if (!view.isInWorld(neighbor)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + CaveVoxel voxel = voxelAt(view, neighbor); + if (voxel == CaveVoxel.LAVA) { + return HydrologyCaveRejection.LAVA_CONTACT; + } + if (voxel == CaveVoxel.COMPATIBLE_FLUID) { + return HydrologyCaveRejection.EXISTING_FLUID; + } + if (voxel == CaveVoxel.INCOMPATIBLE_FLUID) { + return HydrologyCaveRejection.INCOMPATIBLE_FLUID; + } + } + } + return HydrologyCaveRejection.NONE; + } + + private HydrologyCaveRejection validateWaterfallShaft( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + List throat + ) { + if (source.mode() != HydrologyCaveMode.WATERFALL_POOL) { + return HydrologyCaveRejection.NONE; + } + + Set throatPositions = Set.copyOf(throat); + for (CavePosition position : throat) { + if (position.y() <= source.waterHeadY()) { + continue; + } + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) { + continue; + } + if (!view.isInWorld(neighbor)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, neighbor); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return boundsRejection; + } + CaveVoxel voxel = voxelAt(view, neighbor); + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != HydrologyCaveRejection.NONE) { + return hazard; + } + if (voxel != CaveVoxel.SOLID) { + return HydrologyCaveRejection.WATERFALL_SHAFT_OPEN; + } + } + } + return HydrologyCaveRejection.NONE; + } + + private HydrologyCavePlan planGeneratedGrotto( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + List throat + ) { + GrottoResult grotto = buildGrotto(source, settings); + if (grotto.rejection() != HydrologyCaveRejection.NONE) { + return rejected(source, grotto.rejection()); + } + Set chamber = grotto.positions(); + + Set carve = new LinkedHashSet<>(chamber.size() + throat.size()); + carve.addAll(chamber); + carve.addAll(throat); + HydrologyCaveRejection carveRejection = validateGeneratedCarve(view, source, settings, carve); + if (carveRejection != HydrologyCaveRejection.NONE) { + return rejected(source, carveRejection); + } + + BoundaryResult boundary = validateGeneratedBoundary(view, source, settings, carve); + if (boundary.rejection() != HydrologyCaveRejection.NONE) { + return rejected(source, boundary.rejection()); + } + + Map actions = new LinkedHashMap<>(); + addChamberActions(actions, chamber, source.waterHeadY()); + addThroatActions(actions, throat, source); + for (CavePosition position : boundary.sealGuards()) { + actions.put(position, HydrologyCaveAction.SEAL_GUARD); + } + return accepted(view, source, actions); + } + + private HydrologyCavePlan planDeepPool( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + List throat + ) { + GrottoResult grotto = buildGrotto(source, settings); + if (grotto.rejection() != HydrologyCaveRejection.NONE) { + return rejected(source, grotto.rejection()); + } + Set chamber = grotto.positions(); + Set carve = new LinkedHashSet<>(chamber.size() + throat.size()); + carve.addAll(chamber); + carve.addAll(throat); + + HydrologyCaveRejection carveRejection = validateDeepPoolCarve(view, source, settings, carve); + if (carveRejection != HydrologyCaveRejection.NONE) { + return rejected(source, carveRejection); + } + BoundaryResult boundary = validateDeepPoolBoundary(view, source, settings, carve); + if (boundary.rejection() != HydrologyCaveRejection.NONE) { + return rejected(source, boundary.rejection()); + } + + Map actions = new LinkedHashMap<>(); + addChamberActions(actions, chamber, source.waterHeadY()); + addThroatActions(actions, throat, source); + for (CavePosition position : boundary.sealGuards()) { + actions.put(position, HydrologyCaveAction.SEAL_GUARD); + } + return accepted(view, source, actions); + } + + private HydrologyCaveRejection validateDeepPoolCarve( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + Set carve + ) { + for (CavePosition position : carve) { + if (!view.isInWorld(position)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return boundsRejection; + } + CaveVoxel voxel = voxelAt(view, position); + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != HydrologyCaveRejection.NONE) { + return hazard; + } + if (voxel == CaveVoxel.SOLID) { + continue; + } + if (position.y() > source.waterHeadY() + && voxel == CaveVoxel.CAVE_AIR + && !view.isOpenToSurface(position)) { + continue; + } + return HydrologyCaveRejection.GROTTO_INTERSECTION; + } + return HydrologyCaveRejection.NONE; + } + + private BoundaryResult validateDeepPoolBoundary( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + Set carve + ) { + Set guards = new LinkedHashSet<>(); + for (CavePosition position : carve) { + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (carve.contains(neighbor)) { + continue; + } + if (!view.isInWorld(neighbor)) { + return BoundaryResult.rejected(HydrologyCaveRejection.WORLD_BOUNDARY); + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, neighbor); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return BoundaryResult.rejected(boundsRejection); + } + CaveVoxel voxel = voxelAt(view, neighbor); + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != HydrologyCaveRejection.NONE) { + return BoundaryResult.rejected(hazard); + } + if (voxel == CaveVoxel.SOLID) { + guards.add(neighbor); + continue; + } + if (neighbor.y() > source.waterHeadY() + && voxel == CaveVoxel.CAVE_AIR + && !view.isOpenToSurface(neighbor)) { + continue; + } + return BoundaryResult.rejected(HydrologyCaveRejection.GROTTO_SHELL_OPEN); + } + } + return BoundaryResult.accepted(guards); + } + + private ComponentResult resolveClosedComponent( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + List throat + ) { + Queue queue = new ArrayDeque<>(); + Set queued = new HashSet<>(); + Set resolved = new LinkedHashSet<>(); + + queue.add(source.target()); + queued.add(source.target()); + HydrologyCaveRejection seedRejection = addThroatContacts(view, source, settings, throat, queue, queued); + if (seedRejection != HydrologyCaveRejection.NONE) { + return ComponentResult.rejected(seedRejection); + } + + while (!queue.isEmpty()) { + CavePosition position = queue.remove(); + HydrologyCaveRejection positionRejection = validateReachablePosition(view, source, settings, position); + if (positionRejection != HydrologyCaveRejection.NONE) { + return ComponentResult.rejected(positionRejection); + } + if (!resolved.add(position)) { + continue; + } + if (resolved.size() > settings.maxFloodVolume()) { + return ComponentResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); + } + + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + HydrologyCaveRejection neighborRejection = inspectReachableNeighbor( + view, + source, + settings, + neighbor, + queue, + queued + ); + if (neighborRejection != HydrologyCaveRejection.NONE) { + return ComponentResult.rejected(neighborRejection); + } + } + } + + return ComponentResult.accepted(resolved); + } + + private HydrologyCaveRejection addThroatContacts( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + List throat, + Queue queue, + Set queued + ) { + for (CavePosition position : throat) { + if (position.y() > source.waterHeadY()) { + continue; + } + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + HydrologyCaveRejection rejection = inspectReachableNeighbor( + view, + source, + settings, + neighbor, + queue, + queued + ); + if (rejection != HydrologyCaveRejection.NONE) { + return rejection; + } + } + } + return HydrologyCaveRejection.NONE; + } + + private HydrologyCaveRejection inspectReachableNeighbor( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + CavePosition position, + Queue queue, + Set queued + ) { + if (position.y() > source.waterHeadY()) { + return inspectAboveHeadNeighbor(view, source, position); + } + if (!view.isInWorld(position)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + + CaveVoxel voxel = voxelAt(view, position); + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != HydrologyCaveRejection.NONE) { + return hazard; + } + if (!isFluidReachable(voxel, settings)) { + return HydrologyCaveRejection.NONE; + } + + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return boundsRejection; + } + if (queued.add(position)) { + queue.add(position); + } + return HydrologyCaveRejection.NONE; + } + + private HydrologyCaveRejection inspectAboveHeadNeighbor( + CaveVoxelView view, + HydrologyCaveSource source, + CavePosition position + ) { + if (isInletOpening(source, position) || !view.isInWorld(position)) { + return HydrologyCaveRejection.NONE; + } + CaveVoxel voxel = voxelAt(view, position); + return switch (voxel) { + case LAVA -> HydrologyCaveRejection.LAVA_CONTACT; + case COMPATIBLE_FLUID -> HydrologyCaveRejection.EXISTING_FLUID; + case INCOMPATIBLE_FLUID -> HydrologyCaveRejection.INCOMPATIBLE_FLUID; + default -> HydrologyCaveRejection.NONE; + }; + } + + private HydrologyCaveRejection validateReachablePosition( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + CavePosition position + ) { + if (!view.isInWorld(position)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return boundsRejection; + } + if (view.isOpenToSurface(position)) { + return HydrologyCaveRejection.OPEN_SURFACE; + } + CaveVoxel voxel = voxelAt(view, position); + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != HydrologyCaveRejection.NONE) { + return hazard; + } + return isFluidReachable(voxel, settings) + ? HydrologyCaveRejection.NONE + : HydrologyCaveRejection.NO_CAVE_TARGET; + } + + private PathResult buildThroat( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings + ) { + CavePosition entry = source.entry(); + CavePosition target = source.target(); + int deltaX = target.x() - entry.x(); + int deltaY = target.y() - entry.y(); + int deltaZ = target.z() - entry.z(); + int movesX = Math.abs(deltaX); + int movesY = Math.abs(deltaY); + int movesZ = Math.abs(deltaZ); + int length = movesX + movesY + movesZ; + if (length > settings.maxThroatLength()) { + return PathResult.rejected(HydrologyCaveRejection.THROAT_LIMIT); + } + + int stepX = Integer.signum(deltaX); + int stepY = Integer.signum(deltaY); + int stepZ = Integer.signum(deltaZ); + int usedX = 0; + int usedY = 0; + int usedZ = 0; + CavePosition current = entry; + List positions = new ArrayList<>(length + 1); + + while (true) { + HydrologyCaveRejection positionRejection = validateThroatPosition(view, source, settings, current); + if (positionRejection != HydrologyCaveRejection.NONE) { + return PathResult.rejected(positionRejection); + } + positions.add(current); + if (current.equals(target)) { + return expandThroat(view, source, settings, positions); + } + + int axis = selectNextAxis(source.sourceId(), movesX, movesY, movesZ, usedX, usedY, usedZ); + if (axis == 0) { + current = current.offset(stepX, 0, 0); + usedX++; + } else if (axis == 1) { + current = current.offset(0, stepY, 0); + usedY++; + } else { + current = current.offset(0, 0, stepZ); + usedZ++; + } + } + } + + private PathResult expandThroat( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + List centerline + ) { + int radius = settings.throatRadius(); + int extent = radius - 1; + int radiusSquared = radius * radius; + Set expanded = new LinkedHashSet<>(); + for (CavePosition center : centerline) { + for (int dx = -extent; dx <= extent; dx++) { + for (int dy = -extent; dy <= extent; dy++) { + for (int dz = -extent; dz <= extent; dz++) { + if ((dx * dx) + (dy * dy) + (dz * dz) >= radiusSquared) { + continue; + } + CavePosition position = center.offset(dx, dy, dz); + if (position.y() > source.entry().y()) { + continue; + } + HydrologyCaveRejection rejection = validateThroatPosition(view, source, settings, position); + if (rejection != HydrologyCaveRejection.NONE) { + return PathResult.rejected(rejection); + } + expanded.add(position); + if (expanded.size() > settings.maxFloodVolume()) { + return PathResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); + } + } + } + } + } + return PathResult.accepted(List.copyOf(expanded)); + } + + private int selectNextAxis( + long sourceId, + int movesX, + int movesY, + int movesZ, + int usedX, + int usedY, + int usedZ + ) { + double scoreX = nextAxisScore(movesX, usedX); + double scoreY = nextAxisScore(movesY, usedY); + double scoreZ = nextAxisScore(movesZ, usedZ); + double minimum = Math.min(scoreX, Math.min(scoreY, scoreZ)); + int tieOffset = Math.floorMod(sourceId, 3); + for (int offset = 0; offset < 3; offset++) { + int axis = (tieOffset + offset) % 3; + double score = axis == 0 ? scoreX : axis == 1 ? scoreY : scoreZ; + if (score == minimum) { + return axis; + } + } + throw new IllegalStateException("No remaining throat axis"); + } + + private double nextAxisScore(int moves, int used) { + if (used >= moves) { + return Double.POSITIVE_INFINITY; + } + return ((2D * used) + 1D) / moves; + } + + private HydrologyCaveRejection validateThroatPosition( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + CavePosition position + ) { + if (!view.isInWorld(position)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return boundsRejection; + } + return rejectionForHazard(voxelAt(view, position), settings); + } + + private GrottoResult buildGrotto(HydrologyCaveSource source, HydrologyCavePlannerSettings settings) { + int horizontalRadius = settings.grottoHorizontalRadius(); + int verticalRadius = settings.grottoVerticalRadius(); + Set candidates = new LinkedHashSet<>(); + + for (int dx = -horizontalRadius; dx <= horizontalRadius; dx++) { + for (int dy = -verticalRadius; dy <= verticalRadius; dy++) { + for (int dz = -horizontalRadius; dz <= horizontalRadius; dz++) { + if (settings.grottoShape().contains(source, settings, dx, dy, dz)) { + candidates.add(source.target().offset(dx, dy, dz)); + if (candidates.size() > settings.maxFloodVolume()) { + return GrottoResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); + } + } + } + } + } + candidates.add(source.target()); + for (int offset = 1; offset <= settings.dryHeadroom(); offset++) { + CavePosition headroom = new CavePosition( + source.target().x(), source.waterHeadY() + offset, source.target().z()); + if (Math.abs(headroom.y() - source.target().y()) > verticalRadius) { + return GrottoResult.rejected(HydrologyCaveRejection.DRY_HEADROOM_LIMIT); + } + candidates.add(headroom); + if (candidates.size() > settings.maxFloodVolume()) { + return GrottoResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); + } + } + return GrottoResult.accepted(connectedGrotto(source.target(), candidates)); + } + + private Set connectedGrotto(CavePosition target, Set candidates) { + Queue queue = new ArrayDeque<>(); + Set connected = new LinkedHashSet<>(); + queue.add(target); + connected.add(target); + while (!queue.isEmpty()) { + CavePosition position = queue.remove(); + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (candidates.contains(neighbor) && connected.add(neighbor)) { + queue.add(neighbor); + } + } + } + return connected; + } + + private HydrologyCaveRejection validateGeneratedCarve( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + Set carve + ) { + for (CavePosition position : carve) { + if (!view.isInWorld(position)) { + return HydrologyCaveRejection.WORLD_BOUNDARY; + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, position); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return boundsRejection; + } + CaveVoxel voxel = voxelAt(view, position); + if (isGeneratedInletCarve(view, source, settings, position, voxel)) { + continue; + } + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != HydrologyCaveRejection.NONE) { + return hazard; + } + if (voxel != CaveVoxel.SOLID) { + return HydrologyCaveRejection.GROTTO_INTERSECTION; + } + } + return HydrologyCaveRejection.NONE; + } + + private BoundaryResult validateGeneratedBoundary( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + Set carve + ) { + Set guards = new LinkedHashSet<>(); + for (CavePosition position : carve) { + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (carve.contains(neighbor)) { + continue; + } + if (isInletOpening(source, neighbor) + || isGeneratedInletOpening(view, source, settings, neighbor)) { + continue; + } + if (!view.isInWorld(neighbor)) { + return BoundaryResult.rejected(HydrologyCaveRejection.WORLD_BOUNDARY); + } + HydrologyCaveRejection boundsRejection = validateBounds(source, settings, neighbor); + if (boundsRejection != HydrologyCaveRejection.NONE) { + return BoundaryResult.rejected(boundsRejection); + } + CaveVoxel voxel = voxelAt(view, neighbor); + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + if (hazard != HydrologyCaveRejection.NONE) { + return BoundaryResult.rejected(hazard); + } + if (voxel != CaveVoxel.SOLID) { + return BoundaryResult.rejected(HydrologyCaveRejection.GROTTO_SHELL_OPEN); + } + guards.add(neighbor); + } + } + return BoundaryResult.accepted(guards); + } + + private void addChamberActions( + Map actions, + Collection positions, + int waterHeadY + ) { + for (CavePosition position : positions) { + HydrologyCaveAction action = position.y() <= waterHeadY + ? HydrologyCaveAction.WET_SOURCE + : HydrologyCaveAction.DRY_AIR; + actions.put(position, action); + } + } + + private void addThroatActions( + Map actions, + Collection throat, + HydrologyCaveSource source + ) { + for (CavePosition position : throat) { + HydrologyCaveAction action; + if (position.y() <= source.waterHeadY()) { + action = HydrologyCaveAction.WET_SOURCE; + } else if (source.mode() == HydrologyCaveMode.WATERFALL_POOL + || source.mode() == HydrologyCaveMode.GENERATED_GROTTO) { + action = HydrologyCaveAction.FALLING_FLUID; + } else { + action = HydrologyCaveAction.DRY_AIR; + } + actions.put(position, action); + } + } + + private void addSealGuards( + CaveVoxelView view, + HydrologyCaveSource source, + Map actions + ) { + Set guards = new LinkedHashSet<>(); + for (CavePosition position : List.copyOf(actions.keySet())) { + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (actions.containsKey(neighbor) + || isInletOpening(source, neighbor) + || !view.isInWorld(neighbor)) { + continue; + } + if (voxelAt(view, neighbor) == CaveVoxel.SOLID) { + guards.add(neighbor); + } + } + } + for (CavePosition guard : guards) { + actions.put(guard, HydrologyCaveAction.SEAL_GUARD); + } + } + + private boolean isInletOpening(HydrologyCaveSource source, CavePosition position) { + return position.equals(source.entry().offset(0, 1, 0)); + } + + private boolean isGeneratedInletCarve( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + CavePosition position, + CaveVoxel voxel + ) { + if (voxel != CaveVoxel.CAVE_AIR && voxel != CaveVoxel.COMPATIBLE_FLUID) { + return false; + } + int extent = Math.max(0, settings.throatRadius() - 1); + long deltaX = (long) position.x() - source.entry().x(); + long deltaZ = (long) position.z() - source.entry().z(); + return position.y() >= source.entry().y() - extent + && position.y() <= source.entry().y() + && deltaX * deltaX + deltaZ * deltaZ <= (long) extent * extent + && view.isOpenToSurface(position); + } + + private boolean isGeneratedInletOpening( + CaveVoxelView view, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + CavePosition position + ) { + int radius = Math.max(1, settings.throatRadius()); + long deltaX = (long) position.x() - source.entry().x(); + long deltaZ = (long) position.z() - source.entry().z(); + return position.y() >= source.entry().y() + && position.y() <= source.entry().y() + 1 + && deltaX * deltaX + deltaZ * deltaZ < (long) radius * radius + && view.isOpenToSurface(position); + } + + private HydrologyCaveRejection validateSource(HydrologyCaveSource source) { + if (source.entry().y() < source.waterHeadY()) { + return HydrologyCaveRejection.INVALID_SOURCE; + } + if (source.target().y() > source.waterHeadY()) { + return HydrologyCaveRejection.INVALID_SOURCE; + } + if (source.target().y() > source.entry().y()) { + return HydrologyCaveRejection.INVALID_SOURCE; + } + return HydrologyCaveRejection.NONE; + } + + private HydrologyCaveRejection validateBounds( + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + CavePosition position + ) { + boolean closedComponent = source.mode() == HydrologyCaveMode.CLOSED_COMPONENT + || source.mode() == HydrologyCaveMode.WATERFALL_POOL; + int horizontalRadius = closedComponent + ? settings.maxClosedComponentHorizontalRadius() + : settings.maxHorizontalRadius(); + int maximumDepth = closedComponent + ? settings.maxClosedComponentDepth() + : settings.maxDepth(); + long deltaX = (long) position.x() - source.entry().x(); + long deltaZ = (long) position.z() - source.entry().z(); + long radiusSquared = (long) horizontalRadius * horizontalRadius; + if ((deltaX * deltaX) + (deltaZ * deltaZ) > radiusSquared) { + return HydrologyCaveRejection.RADIUS_LIMIT; + } + long depth = (long) source.entry().y() - position.y(); + int maximumGeneratedY = source.waterHeadY() + settings.dryHeadroom() + 1; + boolean allowedGeneratedHeadroom = !closedComponent + && position.y() <= maximumGeneratedY; + if ((depth < 0L && !allowedGeneratedHeadroom) || depth > maximumDepth) { + return HydrologyCaveRejection.DEPTH_LIMIT; + } + return HydrologyCaveRejection.NONE; + } + + private HydrologyCaveRejection rejectionForTarget( + CaveVoxel voxel, + HydrologyCavePlannerSettings settings + ) { + HydrologyCaveRejection hazard = rejectionForHazard(voxel, settings); + return hazard == HydrologyCaveRejection.NONE ? HydrologyCaveRejection.NO_CAVE_TARGET : hazard; + } + + private HydrologyCaveRejection rejectionForHazard( + CaveVoxel voxel, + HydrologyCavePlannerSettings settings + ) { + return switch (voxel) { + case LAVA -> HydrologyCaveRejection.LAVA_CONTACT; + case INCOMPATIBLE_FLUID -> settings.existingFluidPolicy() == HydrologyCaveFluidPolicy.REPLACE_CONTAINED + ? HydrologyCaveRejection.NONE + : HydrologyCaveRejection.INCOMPATIBLE_FLUID; + case COMPATIBLE_FLUID -> settings.existingFluidPolicy() == HydrologyCaveFluidPolicy.REJECT_EXISTING + ? HydrologyCaveRejection.EXISTING_FLUID + : HydrologyCaveRejection.NONE; + default -> HydrologyCaveRejection.NONE; + }; + } + + private boolean isFluidReachable(CaveVoxel voxel, HydrologyCavePlannerSettings settings) { + return voxel == CaveVoxel.CAVE_AIR + || (voxel == CaveVoxel.COMPATIBLE_FLUID + && settings.existingFluidPolicy() != HydrologyCaveFluidPolicy.REJECT_EXISTING) + || (voxel == CaveVoxel.INCOMPATIBLE_FLUID + && settings.existingFluidPolicy() == HydrologyCaveFluidPolicy.REPLACE_CONTAINED); + } + + private CaveVoxel voxelAt(CaveVoxelView view, CavePosition position) { + return Objects.requireNonNull(view.voxelAt(position)); + } + + private OptionalLong findWinningSourceId( + Set positions, + Map claimedBy + ) { + HydrologyCaveSource winner = null; + for (CavePosition position : positions) { + HydrologyCaveSource contender = claimedBy.get(position); + if (contender == null) { + continue; + } + if (winner == null || SOURCE_PRIORITY.compare(contender, winner) < 0) { + winner = contender; + } + } + return winner == null ? OptionalLong.empty() : OptionalLong.of(winner.sourceId()); + } + + private HydrologyCavePlan accepted( + CaveVoxelView view, + HydrologyCaveSource source, + Map actions + ) { + Map preconditions = new LinkedHashMap<>(actions.size()); + for (CavePosition position : actions.keySet()) { + preconditions.put( + position, + new CaveVoxelPrecondition(voxelAt(view, position), view.isOpenToSurface(position)) + ); + } + return accepted(source, actions, preconditions); + } + + private HydrologyCavePlan accepted( + HydrologyCaveSource source, + Map actions, + Map preconditions + ) { + return new HydrologyCavePlan( + source, + HydrologyCaveRejection.NONE, + actions, + preconditions, + OptionalLong.empty() + ); + } + + private HydrologyCavePlan acceptedDecision(HydrologyCaveSource source) { + return new HydrologyCavePlan( + source, + HydrologyCaveRejection.NONE, + Map.of(), + Map.of(), + OptionalLong.empty() + ); + } + + private HydrologyCavePlan rejected(HydrologyCaveSource source, HydrologyCaveRejection rejection) { + return new HydrologyCavePlan( + source, + rejection, + Map.of(), + Map.of(), + OptionalLong.empty() + ); + } + + private HydrologyCavePlan rejectedOverlap(HydrologyCaveSource source, long winnerSourceId) { + return new HydrologyCavePlan( + source, + HydrologyCaveRejection.OVERLAPPING_SOURCE, + Map.of(), + Map.of(), + OptionalLong.of(winnerSourceId) + ); + } + + private record PathResult(List positions, HydrologyCaveRejection rejection) { + private static PathResult accepted(List positions) { + return new PathResult(List.copyOf(positions), HydrologyCaveRejection.NONE); + } + + private static PathResult rejected(HydrologyCaveRejection rejection) { + return new PathResult(List.of(), rejection); + } + } + + private record IndependentDecision( + HydrologyCaveRejection rejection, + List guards + ) { + private static IndependentDecision accepted(List guards) { + return new IndependentDecision(HydrologyCaveRejection.NONE, List.copyOf(guards)); + } + + private static IndependentDecision rejected(HydrologyCaveRejection rejection) { + return new IndependentDecision(rejection, List.of()); + } + } + + private record ComponentResult(Set positions, HydrologyCaveRejection rejection) { + private static ComponentResult accepted(Set positions) { + return new ComponentResult( + Collections.unmodifiableSet(new LinkedHashSet<>(positions)), + HydrologyCaveRejection.NONE + ); + } + + private static ComponentResult rejected(HydrologyCaveRejection rejection) { + return new ComponentResult(Set.of(), rejection); + } + } + + private record BoundaryResult(Set sealGuards, HydrologyCaveRejection rejection) { + private static BoundaryResult accepted(Set sealGuards) { + return new BoundaryResult( + Collections.unmodifiableSet(new LinkedHashSet<>(sealGuards)), + HydrologyCaveRejection.NONE + ); + } + + private static BoundaryResult rejected(HydrologyCaveRejection rejection) { + return new BoundaryResult(Set.of(), rejection); + } + } + + private record GrottoResult(Set positions, HydrologyCaveRejection rejection) { + private static GrottoResult accepted(Set positions) { + return new GrottoResult( + Collections.unmodifiableSet(new LinkedHashSet<>(positions)), + HydrologyCaveRejection.NONE + ); + } + + private static GrottoResult rejected(HydrologyCaveRejection rejection) { + return new GrottoResult(Set.of(), rejection); + } + } + + private record CaveClaimGroup( + HydrologyCaveCandidate candidate, + Map actions + ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveFluidPolicy.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveFluidPolicy.java new file mode 100644 index 000000000..41f3aae8f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveFluidPolicy.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.hydrology.cave; + +public enum HydrologyCaveFluidPolicy { + REJECT_EXISTING, + ALLOW_COMPATIBLE, + REPLACE_CONTAINED +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveGrottoShape.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveGrottoShape.java similarity index 67% rename from core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveGrottoShape.java rename to core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveGrottoShape.java index 5d57d209b..6d621455d 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveGrottoShape.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveGrottoShape.java @@ -1,8 +1,8 @@ -package art.arcane.iris.engine.river.cave; +package art.arcane.iris.engine.hydrology.cave; @FunctionalInterface -public interface RiverCaveGrottoShape { - RiverCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> { +public interface HydrologyCaveGrottoShape { + HydrologyCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> { double horizontalRadius = settings.grottoHorizontalRadius(); double verticalRadius = settings.grottoVerticalRadius(); double normalized = ((double) dx * dx / (horizontalRadius * horizontalRadius)) @@ -12,8 +12,8 @@ public interface RiverCaveGrottoShape { }; boolean contains( - RiverCaveSource source, - RiverCavePlannerSettings settings, + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, int offsetX, int offsetY, int offsetZ diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveMode.java similarity index 58% rename from core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java rename to core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveMode.java index a617b2baa..319abeb02 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveMode.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveMode.java @@ -1,6 +1,6 @@ -package art.arcane.iris.engine.river.cave; +package art.arcane.iris.engine.hydrology.cave; -public enum RiverCaveMode { +public enum HydrologyCaveMode { CLOSED_COMPONENT, GENERATED_GROTTO, GROTTO_OR_CLOSED_COMPONENT, diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlan.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlan.java new file mode 100644 index 000000000..fc2f080ac --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlan.java @@ -0,0 +1,692 @@ +package art.arcane.iris.engine.hydrology.cave; + +import java.util.AbstractCollection; +import java.util.AbstractMap; +import java.util.AbstractSet; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.OptionalLong; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; + +public record HydrologyCavePlan( + HydrologyCaveSource source, + HydrologyCaveRejection rejection, + Map actions, + Map baselinePreconditions, + OptionalLong arbitrationWinnerSourceId +) { + private static final int ACTION_MASK = 0x03; + private static final int ACTION_PRESENT = 0x04; + private static final int VOXEL_SHIFT = 3; + private static final int VOXEL_MASK = 0x38; + private static final int OPEN_TO_SURFACE = 0x40; + private static final HydrologyCaveAction[] ACTIONS = HydrologyCaveAction.values(); + private static final CaveVoxel[] VOXELS = CaveVoxel.values(); + private static final CaveVoxelPrecondition[][] PRECONDITIONS = createPreconditions(); + + public HydrologyCavePlan { + Objects.requireNonNull(source); + Objects.requireNonNull(rejection); + Map requestedActions = Objects.requireNonNull(actions); + Map requestedPreconditions = + Objects.requireNonNull(baselinePreconditions); + Objects.requireNonNull(arbitrationWinnerSourceId); + if (rejection != HydrologyCaveRejection.NONE + && (!requestedActions.isEmpty() || !requestedPreconditions.isEmpty())) { + throw new IllegalArgumentException("Rejected cave plans cannot contain mutations or preconditions"); + } + if (!requestedPreconditions.keySet().containsAll(requestedActions.keySet())) { + throw new IllegalArgumentException("Every cave action requires a baseline precondition"); + } + if (rejection != HydrologyCaveRejection.OVERLAPPING_SOURCE && arbitrationWinnerSourceId.isPresent()) { + throw new IllegalArgumentException("Only overlap rejections can name an arbitration winner"); + } + if (requestedPreconditions.isEmpty()) { + actions = Map.of(); + baselinePreconditions = Map.of(); + } else { + LinkedHashMap packed = new LinkedHashMap<>(requestedPreconditions.size()); + for (Map.Entry entry : requestedActions.entrySet()) { + CaveVoxelPrecondition precondition = requestedPreconditions.get(entry.getKey()); + packed.put(entry.getKey(), encode(entry.getValue(), precondition)); + } + for (Map.Entry entry : requestedPreconditions.entrySet()) { + packed.putIfAbsent(entry.getKey(), encode(null, entry.getValue())); + } + Map immutablePacked = Collections.unmodifiableMap(packed); + PositionSpatialIndex spatialIndex = new PositionSpatialIndex(immutablePacked); + actions = new ActionMap(immutablePacked, requestedActions.size(), spatialIndex); + baselinePreconditions = new PreconditionMap( + immutablePacked, + List.copyOf(requestedPreconditions.keySet()), + spatialIndex + ); + } + } + + public boolean accepted() { + return rejection == HydrologyCaveRejection.NONE; + } + + public void forEachAction(BiConsumer consumer) { + actions.forEach(consumer); + } + + public void forEachPrecondition(BiConsumer consumer) { + baselinePreconditions.forEach(consumer); + } + + public boolean allPreconditions(BiPredicate predicate) { + Objects.requireNonNull(predicate); + for (CavePosition position : baselinePreconditions.keySet()) { + if (!predicate.test(position, baselinePreconditions.get(position))) { + return false; + } + } + return true; + } + + public boolean intersectsActions( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ + ) { + if (maximumX <= minimumX || maximumZ <= minimumZ || !(actions instanceof ActionMap actionMap)) { + return false; + } + return actionMap.intersects(minimumX, minimumZ, maximumX, maximumZ); + } + + public void forEachActionIn( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + BiConsumer consumer + ) { + Objects.requireNonNull(consumer); + if (maximumX <= minimumX || maximumZ <= minimumZ || !(actions instanceof ActionMap actionMap)) { + return; + } + actionMap.forEachIn(minimumX, minimumZ, maximumX, maximumZ, consumer); + } + + public boolean allPreconditionsIn( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + BiPredicate predicate + ) { + Objects.requireNonNull(predicate); + if (maximumX <= minimumX || maximumZ <= minimumZ + || !(baselinePreconditions instanceof PreconditionMap preconditionMap)) { + return true; + } + return preconditionMap.allIn(minimumX, minimumZ, maximumX, maximumZ, predicate); + } + + private static byte encode( + HydrologyCaveAction action, + CaveVoxelPrecondition precondition + ) { + Objects.requireNonNull(precondition); + int packed = precondition.voxel().ordinal() << VOXEL_SHIFT; + if (precondition.openToSurface()) { + packed |= OPEN_TO_SURFACE; + } + if (action != null) { + packed |= ACTION_PRESENT | action.ordinal(); + } + return (byte) packed; + } + + private static boolean hasAction(byte packed) { + return (packed & ACTION_PRESENT) != 0; + } + + private static HydrologyCaveAction decodeAction(byte packed) { + return ACTIONS[packed & ACTION_MASK]; + } + + private static CaveVoxelPrecondition decodePrecondition(byte packed) { + int unsigned = Byte.toUnsignedInt(packed); + int voxel = unsigned & VOXEL_MASK; + int open = unsigned & OPEN_TO_SURFACE; + return PRECONDITIONS[voxel >>> VOXEL_SHIFT][open == 0 ? 0 : 1]; + } + + private static CaveVoxelPrecondition[][] createPreconditions() { + if (ACTIONS.length > ACTION_MASK + 1) { + throw new IllegalStateException("Hydrology cave action packing capacity exceeded"); + } + if (VOXELS.length > (VOXEL_MASK >>> VOXEL_SHIFT) + 1) { + throw new IllegalStateException("Hydrology cave voxel packing capacity exceeded"); + } + CaveVoxelPrecondition[][] preconditions = new CaveVoxelPrecondition[VOXELS.length][2]; + for (CaveVoxel voxel : VOXELS) { + preconditions[voxel.ordinal()][0] = new CaveVoxelPrecondition(voxel, false); + preconditions[voxel.ordinal()][1] = new CaveVoxelPrecondition(voxel, true); + } + return preconditions; + } + + private static final class ActionMap extends AbstractMap { + private final Map packed; + private final int size; + private final PositionSpatialIndex spatialIndex; + + private ActionMap(Map packed, int size, PositionSpatialIndex spatialIndex) { + this.packed = packed; + this.size = size; + this.spatialIndex = spatialIndex; + } + + @Override + public HydrologyCaveAction get(Object key) { + Byte value = packed.get(key); + return value == null || !hasAction(value) ? null : decodeAction(value); + } + + @Override + public boolean containsKey(Object key) { + Byte value = packed.get(key); + return value != null && hasAction(value); + } + + @Override + public int size() { + return size; + } + + @Override + public Set keySet() { + return new ActionKeySet(packed, size); + } + + @Override + public Set> entrySet() { + return new ActionEntrySet(packed, size); + } + + @Override + public Collection values() { + return new ActionValues(packed, size); + } + + @Override + public void forEach(BiConsumer consumer) { + Objects.requireNonNull(consumer); + for (Map.Entry entry : packed.entrySet()) { + if (hasAction(entry.getValue())) { + consumer.accept(entry.getKey(), decodeAction(entry.getValue())); + } + } + } + + private boolean intersects(int minimumX, int minimumZ, int maximumX, int maximumZ) { + return spatialIndex.anyIn(minimumX, minimumZ, maximumX, maximumZ, + (CavePosition position) -> hasAction(packed.get(position))); + } + + private void forEachIn( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + BiConsumer consumer + ) { + spatialIndex.forEachIn(minimumX, minimumZ, maximumX, maximumZ, (CavePosition position) -> { + byte value = packed.get(position); + if (hasAction(value)) { + consumer.accept(position, decodeAction(value)); + } + }); + } + } + + private static final class PreconditionMap extends AbstractMap { + private final Map packed; + private final List order; + private final PositionSpatialIndex spatialIndex; + + private PreconditionMap( + Map packed, + List order, + PositionSpatialIndex spatialIndex + ) { + this.packed = packed; + this.order = order; + this.spatialIndex = spatialIndex; + } + + @Override + public CaveVoxelPrecondition get(Object key) { + Byte value = packed.get(key); + return value == null ? null : decodePrecondition(value); + } + + @Override + public boolean containsKey(Object key) { + return packed.containsKey(key); + } + + @Override + public int size() { + return order.size(); + } + + @Override + public Set keySet() { + return new PreconditionKeySet(packed, order); + } + + @Override + public Set> entrySet() { + return new PreconditionEntrySet(packed, order); + } + + @Override + public Collection values() { + return new PreconditionValues(packed, order); + } + + @Override + public void forEach(BiConsumer consumer) { + Objects.requireNonNull(consumer); + for (CavePosition position : order) { + consumer.accept(position, decodePrecondition(packed.get(position))); + } + } + + private boolean allIn( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + BiPredicate predicate + ) { + return spatialIndex.allIn(minimumX, minimumZ, maximumX, maximumZ, + (CavePosition position) -> predicate.test(position, decodePrecondition(packed.get(position)))); + } + } + + private static final class PositionSpatialIndex { + private static final int CHUNK_SHIFT = 4; + + private final Map> positionsByChunk; + + private PositionSpatialIndex(Map packed) { + HashMap> mutable = new HashMap<>(); + for (CavePosition position : packed.keySet()) { + long chunkKey = packChunk(position.x() >> CHUNK_SHIFT, position.z() >> CHUNK_SHIFT); + mutable.computeIfAbsent(chunkKey, ignored -> new ArrayList<>()).add(position); + } + HashMap> immutable = HashMap.newHashMap(mutable.size()); + for (Map.Entry> entry : mutable.entrySet()) { + immutable.put(entry.getKey(), List.copyOf(entry.getValue())); + } + positionsByChunk = Map.copyOf(immutable); + } + + private boolean anyIn( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + java.util.function.Predicate predicate + ) { + int minimumChunkX = minimumX >> CHUNK_SHIFT; + int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT; + int minimumChunkZ = minimumZ >> CHUNK_SHIFT; + int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT; + for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { + for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { + List positions = positionsByChunk.get(packChunk(chunkX, chunkZ)); + if (positions == null) { + continue; + } + for (CavePosition position : positions) { + if (inside(position, minimumX, minimumZ, maximumX, maximumZ) + && predicate.test(position)) { + return true; + } + } + } + } + return false; + } + + private boolean allIn( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + java.util.function.Predicate predicate + ) { + int minimumChunkX = minimumX >> CHUNK_SHIFT; + int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT; + int minimumChunkZ = minimumZ >> CHUNK_SHIFT; + int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT; + for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { + for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { + List positions = positionsByChunk.get(packChunk(chunkX, chunkZ)); + if (positions == null) { + continue; + } + for (CavePosition position : positions) { + if (inside(position, minimumX, minimumZ, maximumX, maximumZ) + && !predicate.test(position)) { + return false; + } + } + } + } + return true; + } + + private void forEachIn( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + java.util.function.Consumer consumer + ) { + int minimumChunkX = minimumX >> CHUNK_SHIFT; + int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT; + int minimumChunkZ = minimumZ >> CHUNK_SHIFT; + int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT; + for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) { + for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) { + List positions = positionsByChunk.get(packChunk(chunkX, chunkZ)); + if (positions == null) { + continue; + } + for (CavePosition position : positions) { + if (inside(position, minimumX, minimumZ, maximumX, maximumZ)) { + consumer.accept(position); + } + } + } + } + } + + private static boolean inside( + CavePosition position, + int minimumX, + int minimumZ, + int maximumX, + int maximumZ + ) { + return position.x() >= minimumX && position.x() < maximumX + && position.z() >= minimumZ && position.z() < maximumZ; + } + + private static long packChunk(int chunkX, int chunkZ) { + return (long) chunkX << 32 ^ chunkZ & 0xffffffffL; + } + } + + private static final class ActionKeySet extends AbstractSet { + private final Map packed; + private final int size; + + private ActionKeySet(Map packed, int size) { + this.packed = packed; + this.size = size; + } + + @Override + public Iterator iterator() { + return new ActionKeyIterator(packed.entrySet().iterator()); + } + + @Override + public int size() { + return size; + } + + @Override + public boolean contains(Object value) { + Byte packedValue = packed.get(value); + return packedValue != null && hasAction(packedValue); + } + } + + private static final class ActionEntrySet extends AbstractSet> { + private final Map packed; + private final int size; + + private ActionEntrySet(Map packed, int size) { + this.packed = packed; + this.size = size; + } + + @Override + public Iterator> iterator() { + return new ActionEntryIterator(packed.entrySet().iterator()); + } + + @Override + public int size() { + return size; + } + } + + private static final class PreconditionEntrySet + extends AbstractSet> { + private final Map packed; + private final List order; + + private PreconditionEntrySet(Map packed, List order) { + this.packed = packed; + this.order = order; + } + + @Override + public Iterator> iterator() { + return new PreconditionEntryIterator(packed, order.iterator()); + } + + @Override + public int size() { + return order.size(); + } + } + + private static final class PreconditionKeySet extends AbstractSet { + private final Map packed; + private final List order; + + private PreconditionKeySet(Map packed, List order) { + this.packed = packed; + this.order = order; + } + + @Override + public Iterator iterator() { + return order.iterator(); + } + + @Override + public int size() { + return order.size(); + } + + @Override + public boolean contains(Object value) { + return packed.containsKey(value); + } + } + + private static final class ActionValues extends AbstractCollection { + private final Map packed; + private final int size; + + private ActionValues(Map packed, int size) { + this.packed = packed; + this.size = size; + } + + @Override + public Iterator iterator() { + return new ActionValueIterator(packed.entrySet().iterator()); + } + + @Override + public int size() { + return size; + } + } + + private static final class PreconditionValues extends AbstractCollection { + private final Map packed; + private final List order; + + private PreconditionValues(Map packed, List order) { + this.packed = packed; + this.order = order; + } + + @Override + public Iterator iterator() { + return new PreconditionValueIterator(packed, order.iterator()); + } + + @Override + public int size() { + return order.size(); + } + } + + private abstract static class FilteredActionIterator implements Iterator { + private final Iterator> entries; + private Map.Entry next; + + private FilteredActionIterator(Iterator> entries) { + this.entries = entries; + } + + @Override + public boolean hasNext() { + advance(); + return next != null; + } + + @Override + public T next() { + advance(); + if (next == null) { + throw new NoSuchElementException(); + } + Map.Entry selected = next; + next = null; + return map(selected); + } + + protected abstract T map(Map.Entry entry); + + private void advance() { + while (next == null && entries.hasNext()) { + Map.Entry candidate = entries.next(); + if (hasAction(candidate.getValue())) { + next = candidate; + } + } + } + } + + private static final class ActionKeyIterator extends FilteredActionIterator { + private ActionKeyIterator(Iterator> entries) { + super(entries); + } + + @Override + protected CavePosition map(Map.Entry entry) { + return entry.getKey(); + } + } + + private static final class ActionEntryIterator + extends FilteredActionIterator> { + private ActionEntryIterator(Iterator> entries) { + super(entries); + } + + @Override + protected Entry map(Map.Entry entry) { + return Map.entry(entry.getKey(), decodeAction(entry.getValue())); + } + } + + private static final class ActionValueIterator extends FilteredActionIterator { + private ActionValueIterator(Iterator> entries) { + super(entries); + } + + @Override + protected HydrologyCaveAction map(Map.Entry entry) { + return decodeAction(entry.getValue()); + } + } + + private static final class PreconditionEntryIterator + implements Iterator> { + private final Map packed; + private final Iterator positions; + + private PreconditionEntryIterator( + Map packed, + Iterator positions + ) { + this.packed = packed; + this.positions = positions; + } + + @Override + public boolean hasNext() { + return positions.hasNext(); + } + + @Override + public Entry next() { + CavePosition position = positions.next(); + return Map.entry(position, decodePrecondition(packed.get(position))); + } + } + + private static final class PreconditionValueIterator implements Iterator { + private final Map packed; + private final Iterator positions; + + private PreconditionValueIterator( + Map packed, + Iterator positions + ) { + this.packed = packed; + this.positions = positions; + } + + @Override + public boolean hasNext() { + return positions.hasNext(); + } + + @Override + public CaveVoxelPrecondition next() { + return decodePrecondition(packed.get(positions.next())); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlannerSettings.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlannerSettings.java similarity index 83% rename from core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlannerSettings.java rename to core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlannerSettings.java index a7aab41e5..b7ac33797 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlannerSettings.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlannerSettings.java @@ -1,8 +1,8 @@ -package art.arcane.iris.engine.river.cave; +package art.arcane.iris.engine.hydrology.cave; import java.util.Objects; -public record RiverCavePlannerSettings( +public record HydrologyCavePlannerSettings( int maxHorizontalRadius, int maxDepth, int maxFloodVolume, @@ -11,12 +11,14 @@ public record RiverCavePlannerSettings( int grottoHorizontalRadius, int grottoVerticalRadius, int dryHeadroom, - RiverCaveFluidPolicy existingFluidPolicy, - RiverCaveGrottoShape grottoShape, + HydrologyCaveFluidPolicy existingFluidPolicy, + HydrologyCaveGrottoShape grottoShape, int maxClosedComponentHorizontalRadius, int maxClosedComponentDepth ) { - public RiverCavePlannerSettings( + public static final int MAXIMUM_PLANNED_MUTATIONS = 262_144; + + public HydrologyCavePlannerSettings( int maxHorizontalRadius, int maxDepth, int maxFloodVolume, @@ -25,8 +27,8 @@ public record RiverCavePlannerSettings( int grottoHorizontalRadius, int grottoVerticalRadius, int dryHeadroom, - RiverCaveFluidPolicy existingFluidPolicy, - RiverCaveGrottoShape grottoShape + HydrologyCaveFluidPolicy existingFluidPolicy, + HydrologyCaveGrottoShape grottoShape ) { this( maxHorizontalRadius, @@ -44,14 +46,14 @@ public record RiverCavePlannerSettings( ); } - public RiverCavePlannerSettings( + public HydrologyCavePlannerSettings( int maxHorizontalRadius, int maxDepth, int maxFloodVolume, int maxThroatLength, int grottoHorizontalRadius, int grottoVerticalRadius, - RiverCaveFluidPolicy existingFluidPolicy + HydrologyCaveFluidPolicy existingFluidPolicy ) { this( maxHorizontalRadius, @@ -63,11 +65,11 @@ public record RiverCavePlannerSettings( grottoVerticalRadius, 0, existingFluidPolicy, - RiverCaveGrottoShape.ELLIPSOID + HydrologyCaveGrottoShape.ELLIPSOID ); } - public RiverCavePlannerSettings { + public HydrologyCavePlannerSettings { Objects.requireNonNull(existingFluidPolicy); Objects.requireNonNull(grottoShape); if (maxHorizontalRadius < 1) { diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlanningResult.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlanningResult.java new file mode 100644 index 000000000..558e91351 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlanningResult.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.hydrology.cave; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public record HydrologyCavePlanningResult( + List plans, + Map actions, + Map baselinePreconditions +) { + public HydrologyCavePlanningResult { + plans = List.copyOf(Objects.requireNonNull(plans)); + actions = Collections.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(actions))); + baselinePreconditions = Collections.unmodifiableMap( + new LinkedHashMap<>(Objects.requireNonNull(baselinePreconditions)) + ); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveRejection.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveRejection.java similarity index 80% rename from core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveRejection.java rename to core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveRejection.java index 5ae85fda6..a0b1baec3 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveRejection.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveRejection.java @@ -1,6 +1,6 @@ -package art.arcane.iris.engine.river.cave; +package art.arcane.iris.engine.hydrology.cave; -public enum RiverCaveRejection { +public enum HydrologyCaveRejection { NONE, INVALID_SOURCE, NO_CAVE_TARGET, diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveSource.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveSource.java similarity index 64% rename from core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveSource.java rename to core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveSource.java index 592d96cf2..ceb21285b 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveSource.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveSource.java @@ -1,15 +1,15 @@ -package art.arcane.iris.engine.river.cave; +package art.arcane.iris.engine.hydrology.cave; import java.util.Objects; -public record RiverCaveSource( +public record HydrologyCaveSource( long sourceId, CavePosition entry, CavePosition target, int waterHeadY, - RiverCaveMode mode + HydrologyCaveMode mode ) { - public RiverCaveSource { + public HydrologyCaveSource { Objects.requireNonNull(entry); Objects.requireNonNull(target); Objects.requireNonNull(mode); diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorage.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorage.java similarity index 67% rename from core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorage.java rename to core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorage.java index 3adfaf956..df5b4346f 100644 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorage.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorage.java @@ -1,15 +1,15 @@ -package art.arcane.iris.engine.river.cave; +package art.arcane.iris.engine.hydrology.cave; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; import art.arcane.volmlib.util.matter.Matter; -public final class RiverCaveHydrologyStorage { - private RiverCaveHydrologyStorage() { +public final class HydrologyCaveStorage { + private HydrologyCaveStorage() { } - public static RiverCaveHydrology getIfPresent(Mantle mantle, int x, int y, int z) { + public static HydrologyCaveCell getIfPresent(Mantle mantle, int x, int y, int z) { if (y < 0 || y >= mantle.getWorldHeight()) { return null; } @@ -23,7 +23,7 @@ public final class RiverCaveHydrologyStorage { return getIfPresent(chunk, x, y, z); } - public static RiverCaveHydrology getIfPresent(MantleChunk chunk, int x, int y, int z) { + public static HydrologyCaveCell getIfPresent(MantleChunk chunk, int x, int y, int z) { if (chunk == null || y < 0) { return null; } @@ -32,10 +32,10 @@ public final class RiverCaveHydrologyStorage { return null; } Matter matter = chunk.get(section); - if (matter == null || !matter.hasSlice(RiverCaveHydrology.class)) { + if (matter == null || !matter.hasSlice(HydrologyCaveCell.class)) { return null; } - return matter.getSlice(RiverCaveHydrology.class) + return matter.getSlice(HydrologyCaveCell.class) .get(x & 15, y & 15, z & 15); } } diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java new file mode 100644 index 000000000..c5c1a3bf7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java @@ -0,0 +1,58 @@ +package art.arcane.iris.engine.hydrology.policy; + +import art.arcane.iris.engine.object.IrisRiverPlacementMode; +import art.arcane.iris.engine.object.IrisRiverRoutingMode; + +import java.util.List; +import java.util.Objects; + +public record EffectiveRiverPolicy( + IrisRiverPlacementMode placement, + IrisRiverRoutingMode routing, + boolean outletAdmission, + List profiles, + List surfaceBiomes, + List mouthBiomes, + List shoreBiomes, + List dryBiomes, + List floodedCaveBiomes, + double widthMultiplier, + double depthMultiplier, + double incisionMultiplier, + double routingMultiplier +) { + public EffectiveRiverPolicy { + placement = Objects.requireNonNull(placement); + routing = Objects.requireNonNull(routing); + profiles = List.copyOf(profiles); + surfaceBiomes = List.copyOf(surfaceBiomes); + mouthBiomes = List.copyOf(mouthBiomes); + shoreBiomes = List.copyOf(shoreBiomes); + dryBiomes = List.copyOf(dryBiomes); + floodedCaveBiomes = List.copyOf(floodedCaveBiomes); + } + + public boolean allowsSources() { + return switch (placement) { + case DISABLED, TRANSIT_ONLY -> false; + case NATURAL, PREFERRED_HEADWATER, REQUIRED_HEADWATER -> true; + }; + } + + public boolean allowsTransit() { + return placement != IrisRiverPlacementMode.DISABLED; + } + + public boolean prefersHeadwaters() { + return placement == IrisRiverPlacementMode.PREFERRED_HEADWATER + || placement == IrisRiverPlacementMode.REQUIRED_HEADWATER; + } + + public boolean requiresHeadwaters() { + return placement == IrisRiverPlacementMode.REQUIRED_HEADWATER; + } + + public boolean allowsRouting() { + return routing != IrisRiverRoutingMode.BLOCK; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java new file mode 100644 index 000000000..a55eba56c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java @@ -0,0 +1,114 @@ +package art.arcane.iris.engine.hydrology.policy; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverPlacementMode; +import art.arcane.iris.engine.object.IrisRiverPolicy; +import art.arcane.iris.engine.object.IrisRiverRoutingMode; + +import java.util.List; + +public final class RiverPolicyResolver { + private RiverPolicyResolver() { + } + + public static EffectiveRiverPolicy resolve(IrisDimension dimension, IrisRegion region, IrisBiome biome) { + return resolve( + dimension == null ? null : dimension.getRiverPolicy(), + region == null ? null : region.getRiverPolicy(), + biome == null ? null : biome.getRiverPolicy() + ); + } + + public static EffectiveRiverPolicy resolve( + IrisRiverPolicy dimensionPolicy, + IrisRiverPolicy regionPolicy, + IrisRiverPolicy biomePolicy + ) { + State state = new State(); + state.apply(dimensionPolicy); + state.apply(regionPolicy); + state.apply(biomePolicy); + return state.build(); + } + + private static final class State { + private IrisRiverPlacementMode placement = IrisRiverPlacementMode.NATURAL; + private IrisRiverRoutingMode routing = IrisRiverRoutingMode.ALLOW; + private boolean outletAdmission = true; + private List profiles = List.of(); + private List surfaceBiomes = List.of(); + private List mouthBiomes = List.of(); + private List shoreBiomes = List.of(); + private List dryBiomes = List.of(); + private List floodedCaveBiomes = List.of(); + private double widthMultiplier = 1D; + private double depthMultiplier = 1D; + private double incisionMultiplier = 1D; + private double routingMultiplier = 1D; + + private void apply(IrisRiverPolicy policy) { + if (policy == null) { + return; + } + if (policy.getPlacement() != null) { + placement = policy.getPlacement(); + } + if (policy.getRouting() != null) { + routing = policy.getRouting(); + } + if (policy.getOutletAdmission() != null) { + outletAdmission = policy.getOutletAdmission(); + } + if (policy.getProfiles() != null) { + profiles = List.copyOf(policy.getProfiles()); + } + if (policy.getSurfaceBiomes() != null) { + surfaceBiomes = List.copyOf(policy.getSurfaceBiomes()); + } + if (policy.getMouthBiomes() != null) { + mouthBiomes = List.copyOf(policy.getMouthBiomes()); + } + if (policy.getShoreBiomes() != null) { + shoreBiomes = List.copyOf(policy.getShoreBiomes()); + } + if (policy.getDryBiomes() != null) { + dryBiomes = List.copyOf(policy.getDryBiomes()); + } + if (policy.getFloodedCaveBiomes() != null) { + floodedCaveBiomes = List.copyOf(policy.getFloodedCaveBiomes()); + } + if (policy.getWidthMultiplier() != null) { + widthMultiplier = policy.getWidthMultiplier(); + } + if (policy.getDepthMultiplier() != null) { + depthMultiplier = policy.getDepthMultiplier(); + } + if (policy.getIncisionMultiplier() != null) { + incisionMultiplier = policy.getIncisionMultiplier(); + } + if (policy.getRoutingMultiplier() != null) { + routingMultiplier = policy.getRoutingMultiplier(); + } + } + + private EffectiveRiverPolicy build() { + return new EffectiveRiverPolicy( + placement, + routing, + outletAdmission, + profiles, + surfaceBiomes, + mouthBiomes, + shoreBiomes, + dryBiomes, + floodedCaveBiomes, + widthMultiplier, + depthMultiplier, + incisionMultiplier, + routingMultiplier + ); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchBounds.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchBounds.java new file mode 100644 index 000000000..5068add5c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchBounds.java @@ -0,0 +1,47 @@ +package art.arcane.iris.engine.hydrology.runtime; + +final class HydrologyFeatureSearchBounds { + private HydrologyFeatureSearchBounds() { + } + + static int maximumDistance( + int x, + int z, + int requestedDistance, + int tileSize, + int publicationRadius, + int maximumTiles + ) { + if (requestedDistance < 0 || tileSize <= 0 || publicationRadius < 0 || maximumTiles < 1) { + throw new IllegalArgumentException("Hydrology feature search bounds are invalid."); + } + if (tileCount(x, z, 0, tileSize, publicationRadius) > maximumTiles) { + throw new IllegalArgumentException("Hydrology publication radius exceeds the bounded tile limit."); + } + int minimum = 0; + int maximum = requestedDistance; + int accepted = 0; + while (minimum <= maximum) { + int candidate = minimum + (maximum - minimum) / 2; + if (tileCount(x, z, candidate, tileSize, publicationRadius) <= maximumTiles) { + accepted = candidate; + minimum = candidate + 1; + } else { + maximum = candidate - 1; + } + } + return accepted; + } + + static long tileCount(int x, int z, int distance, int tileSize, int publicationRadius) { + int minimumTileX = tileCoordinate((long) x - distance - publicationRadius, tileSize); + int maximumTileX = tileCoordinate((long) x + distance + publicationRadius, tileSize); + int minimumTileZ = tileCoordinate((long) z - distance - publicationRadius, tileSize); + int maximumTileZ = tileCoordinate((long) z + distance + publicationRadius, tileSize); + return (long) (maximumTileX - minimumTileX + 1) * (maximumTileZ - minimumTileZ + 1); + } + + private static int tileCoordinate(long blockCoordinate, int tileSize) { + return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalHeightProvider.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalHeightProvider.java new file mode 100644 index 000000000..1deda2801 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalHeightProvider.java @@ -0,0 +1,6 @@ +package art.arcane.iris.engine.hydrology.runtime; + +@FunctionalInterface +public interface IrisHydrologyNaturalHeightProvider { + double sample(int blockX, int blockZ); +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalOceanClassifier.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalOceanClassifier.java new file mode 100644 index 000000000..a77975763 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalOceanClassifier.java @@ -0,0 +1,6 @@ +package art.arcane.iris.engine.hydrology.runtime; + +@FunctionalInterface +public interface IrisHydrologyNaturalOceanClassifier { + boolean isOcean(int blockX, int blockZ); +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalSample.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalSample.java new file mode 100644 index 000000000..8ca2e263e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalSample.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; + +public record IrisHydrologyNaturalSample( + double naturalHeight, + boolean ocean, + IrisBiome biome, + IrisRegion region +) { +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalSampleProvider.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalSampleProvider.java new file mode 100644 index 000000000..fa31e6cb9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyNaturalSampleProvider.java @@ -0,0 +1,6 @@ +package art.arcane.iris.engine.hydrology.runtime; + +@FunctionalInterface +public interface IrisHydrologyNaturalSampleProvider { + IrisHydrologyNaturalSample sample(int blockX, int blockZ, double naturalHeight); +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java new file mode 100644 index 000000000..278a5e7fc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java @@ -0,0 +1,379 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.core.IrisSettings; +import art.arcane.iris.engine.hydrology.HydrologyNaturalTerrainSampler; +import art.arcane.iris.engine.hydrology.HydrologyRoutingTerrainSampler; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.util.common.parallel.MultiBurst; +import it.unimi.dsi.fastutil.longs.Long2DoubleLinkedOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; + +final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrainSampler, AutoCloseable { + private static final int MINIMUM_PARALLEL_BASIS_SAMPLES = 64; + private static final int COORDINATE_MIX = 0x9E3779B9; + + private final BasisProvider basisProvider; + private final IrisHydrologyNaturalHeightProvider heightProvider; + private final IrisHydrologyNaturalOceanClassifier oceanClassifier; + private final int maximumEntries; + private final SamplingOptions samplingOptions; + private final Object lock; + private final Long2ObjectLinkedOpenHashMap bases; + private final Long2DoubleLinkedOpenHashMap naturalHeights; + private final Long2ObjectLinkedOpenHashMap oceanClassifications; + + IrisHydrologyRoutingTerrainSampler(Sources sources, SamplingOptions samplingOptions) { + Objects.requireNonNull(sources, "sources"); + this.samplingOptions = Objects.requireNonNull(samplingOptions, "samplingOptions"); + this.basisProvider = Objects.requireNonNull(sources.basisProvider(), "basisProvider"); + this.heightProvider = Objects.requireNonNull(sources.heightProvider(), "heightProvider"); + this.oceanClassifier = Objects.requireNonNull(sources.oceanClassifier(), "oceanClassifier"); + int maximumEntries = samplingOptions.maximumEntries(); + this.maximumEntries = maximumEntries; + this.lock = new Object(); + this.bases = new Long2ObjectLinkedOpenHashMap<>(maximumEntries); + this.naturalHeights = new Long2DoubleLinkedOpenHashMap(maximumEntries); + this.naturalHeights.defaultReturnValue(Double.NaN); + this.oceanClassifications = new Long2ObjectLinkedOpenHashMap<>(maximumEntries); + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + int minimumX = request.minimumX(); + int minimumZ = request.minimumZ(); + int width = request.width(); + int spacing = request.spacing(); + int planeWidth = Math.addExact(width, 1); + TerrainBasis[] plane = new TerrainBasis[Math.multiplyExact(planeWidth, planeWidth)]; + fillBasisPlane(plane, planeWidth, minimumX, minimumZ, spacing); + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[Math.multiplyExact(width, width)]; + double slopeScale = 3D / spacing; + int sampleIndex = 0; + for (int gridZ = 0; gridZ < width; gridZ++) { + int planeIndex = gridZ * planeWidth; + for (int gridX = 0; gridX < width; gridX++) { + TerrainBasis center = plane[planeIndex]; + double slope = scaledRoutingSlope( + center.naturalHeight(), + plane[planeIndex + 1].naturalHeight(), + plane[planeIndex + planeWidth].naturalHeight(), + slopeScale + ); + samples[sampleIndex++] = center.terrain().withSlope(slope); + planeIndex++; + } + } + return samples; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + long packed = pack(blockX, blockZ); + synchronized (lock) { + TerrainBasis basis = bases.getAndMoveToLast(packed); + if (basis != null) { + return basis.terrain().ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND; + } + NaturalClassification cached = oceanClassifications.getAndMoveToLast(packed); + if (cached != null) { + return cached; + } + } + NaturalClassification sampled = oceanClassifier.isOcean(blockX, blockZ) + ? NaturalClassification.OCEAN + : NaturalClassification.LAND; + synchronized (lock) { + TerrainBasis basis = bases.getAndMoveToLast(packed); + if (basis != null) { + return basis.terrain().ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND; + } + NaturalClassification existing = oceanClassifications.getAndMoveToLast(packed); + if (existing != null) { + return existing; + } + oceanClassifications.putAndMoveToLast(packed, sampled); + evictOldest(oceanClassifications); + } + return sampled; + } + + @Override + public HydrologyTerrainSample sampleBasis(int blockX, int blockZ) { + return basis(blockX, blockZ).terrain(); + } + + TerrainBasis basis(int blockX, int blockZ) { + long packed = pack(blockX, blockZ); + synchronized (lock) { + TerrainBasis cached = bases.getAndMoveToLast(packed); + if (cached != null) { + return cached; + } + } + double naturalHeight = naturalHeight(packed, blockX, blockZ); + TerrainBasis sampled = basisProvider.sample(blockX, blockZ, naturalHeight); + if (sampled == null) { + throw new NullPointerException( + "Hydrology terrain basis provider returned null at " + blockX + "," + blockZ + ); + } + synchronized (lock) { + TerrainBasis existing = bases.getAndMoveToLast(packed); + if (existing != null) { + return existing; + } + bases.putAndMoveToLast(packed, sampled); + oceanClassifications.remove(packed); + evictOldest(bases); + } + return sampled; + } + + int basisCacheSize() { + synchronized (lock) { + return bases.size(); + } + } + + int naturalHeightCacheSize() { + synchronized (lock) { + return naturalHeights.size(); + } + } + + int oceanClassificationCacheSize() { + synchronized (lock) { + return oceanClassifications.size(); + } + } + + @Override + public void close() { + synchronized (lock) { + bases.clear(); + naturalHeights.clear(); + oceanClassifications.clear(); + } + } + + static double localSlope(double naturalHeight, double easternHeight, double southernHeight) { + double deltaX = easternHeight - naturalHeight; + double deltaZ = southernHeight - naturalHeight; + return Math.sqrt(deltaX * deltaX + deltaZ * deltaZ); + } + + double localSlope(int blockX, int blockZ, double naturalHeight) { + double easternHeight = naturalHeight(blockX + 3, blockZ); + double southernHeight = naturalHeight(blockX, blockZ + 3); + return localSlope(naturalHeight, easternHeight, southernHeight); + } + + static double routingSlope( + double naturalHeight, + double easternHeight, + double southernHeight, + int spacing + ) { + if (spacing < 1) { + throw new IllegalArgumentException("spacing must be positive"); + } + double scale = 3D / spacing; + return scaledRoutingSlope(naturalHeight, easternHeight, southernHeight, scale); + } + + private static double scaledRoutingSlope( + double naturalHeight, + double easternHeight, + double southernHeight, + double scale + ) { + double deltaX = (easternHeight - naturalHeight) * scale; + double deltaZ = (southernHeight - naturalHeight) * scale; + return StrictMath.sqrt(deltaX * deltaX + deltaZ * deltaZ); + } + + private void fillBasisPlane( + TerrainBasis[] plane, + int planeWidth, + int minimumX, + int minimumZ, + int spacing + ) { + int workerCount = gridWorkerCount(planeWidth, plane.length); + if (workerCount == 1) { + fillBasisRows(plane, planeWidth, minimumX, minimumZ, spacing, 0, planeWidth); + return; + } + int rowsPerWorker = Math.ceilDiv(planeWidth, workerCount); + ArrayList> futures = new ArrayList<>(workerCount); + for (int workerIndex = 0; workerIndex < workerCount; workerIndex++) { + int minimumRow = workerIndex * rowsPerWorker; + if (minimumRow >= planeWidth) { + break; + } + int maximumRow = Math.min(planeWidth, minimumRow + rowsPerWorker); + futures.add(CompletableFuture.runAsync( + () -> fillBasisRows( + plane, + planeWidth, + minimumX, + minimumZ, + spacing, + minimumRow, + maximumRow + ), + samplingOptions.executor() + )); + } + await(futures); + } + + private void fillBasisRows( + TerrainBasis[] plane, + int planeWidth, + int minimumX, + int minimumZ, + int spacing, + int minimumRow, + int maximumRow + ) { + for (int gridZ = minimumRow; gridZ < maximumRow; gridZ++) { + int z = Math.toIntExact((long) minimumZ + (long) gridZ * spacing); + int planeIndex = gridZ * planeWidth; + for (int gridX = 0; gridX < planeWidth; gridX++) { + int x = Math.toIntExact((long) minimumX + (long) gridX * spacing); + plane[planeIndex++] = basis(x, z); + } + } + } + + private int gridWorkerCount(int planeWidth, int planeSize) { + if (samplingOptions.maximumWorkers() == 1 + || planeSize < MINIMUM_PARALLEL_BASIS_SAMPLES) { + return 1; + } + return Math.min(planeWidth, samplingOptions.maximumWorkers()); + } + + private static void await(List> futures) { + CompletableFuture[] completions = futures.toArray(new CompletableFuture[futures.size()]); + try { + CompletableFuture.allOf(completions).join(); + } catch (CompletionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (cause instanceof Error error) { + throw error; + } + throw new IllegalStateException("Hydrology routing terrain sampling failed.", cause); + } + } + + private void evictOldest(Long2ObjectLinkedOpenHashMap cache) { + if (cache.size() <= maximumEntries) { + return; + } + cache.removeFirst(); + } + + private void evictOldest(Long2DoubleLinkedOpenHashMap cache) { + if (cache.size() <= maximumEntries) { + return; + } + cache.removeFirstDouble(); + } + + private double naturalHeight(int blockX, int blockZ) { + return naturalHeight(pack(blockX, blockZ), blockX, blockZ); + } + + private double naturalHeight(long packed, int blockX, int blockZ) { + synchronized (lock) { + double cached = naturalHeights.getAndMoveToLast(packed); + if (!Double.isNaN(cached) || naturalHeights.containsKey(packed)) { + return cached; + } + } + double sampled = heightProvider.sample(blockX, blockZ); + synchronized (lock) { + double existing = naturalHeights.getAndMoveToLast(packed); + if (!Double.isNaN(existing) || naturalHeights.containsKey(packed)) { + return existing; + } + naturalHeights.putAndMoveToLast(packed, sampled); + evictOldest(naturalHeights); + } + return sampled; + } + + private static long pack(int blockX, int blockZ) { + int mixedZ = blockZ ^ Integer.rotateLeft(blockX * COORDINATE_MIX, 16); + return ((long) blockX << 32) ^ (mixedZ & 0xffffffffL); + } + + @FunctionalInterface + interface BasisProvider { + TerrainBasis sample(int blockX, int blockZ, double naturalHeight); + } + + record Sources( + BasisProvider basisProvider, + IrisHydrologyNaturalHeightProvider heightProvider, + IrisHydrologyNaturalOceanClassifier oceanClassifier + ) { + Sources { + Objects.requireNonNull(basisProvider, "basisProvider"); + Objects.requireNonNull(heightProvider, "heightProvider"); + Objects.requireNonNull(oceanClassifier, "oceanClassifier"); + } + } + + record SamplingOptions( + int maximumEntries, + int maximumWorkers, + Executor executor + ) { + SamplingOptions { + if (maximumEntries < 1) { + throw new IllegalArgumentException("maximumEntries must be positive"); + } + if (maximumWorkers < 1) { + throw new IllegalArgumentException("maximumWorkers must be positive"); + } + Objects.requireNonNull(executor, "executor"); + } + + static SamplingOptions production(int maximumEntries) { + int configuredWorkers = IrisSettings.getThreadCount( + IrisSettings.get().getConcurrency().getParallelism() + ); + return new SamplingOptions( + maximumEntries, + Math.max(1, configuredWorkers), + MultiBurst.burst + ); + } + + static SamplingOptions serial(int maximumEntries) { + return new SamplingOptions(maximumEntries, 1, Runnable::run); + } + } + + record TerrainBasis(double naturalHeight, HydrologyTerrainSample terrain) { + TerrainBasis { + if (!Double.isFinite(naturalHeight)) { + throw new IllegalArgumentException("naturalHeight must be finite"); + } + Objects.requireNonNull(terrain, "terrain"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java new file mode 100644 index 000000000..f8f421cb7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -0,0 +1,913 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate; +import art.arcane.iris.engine.hydrology.HydrologyDiagnosticRenderSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.engine.hydrology.HydrologyPlanner; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyRenderSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTile; +import art.arcane.iris.engine.hydrology.HydrologyTileCache; +import art.arcane.iris.engine.hydrology.HydrologyTileKey; +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.policy.EffectiveRiverPolicy; +import art.arcane.iris.engine.hydrology.policy.RiverPolicyResolver; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisCoastalRiverGrottoConfig; +import art.arcane.iris.engine.object.IrisDeepFluidConfig; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisHydrology; +import art.arcane.iris.engine.object.IrisInlandRiverGrottoConfig; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverGrottoConfig; +import art.arcane.iris.engine.object.IrisRiverChannelShapeConfig; +import art.arcane.iris.engine.object.IrisRiverDropShapeConfig; +import art.arcane.iris.engine.object.IrisRiverGeometryConfig; +import art.arcane.iris.engine.object.IrisRiverHydraulicsConfig; +import art.arcane.iris.engine.object.IrisRiverHydrology; +import art.arcane.iris.engine.object.IrisRiverInlandOutlet; +import art.arcane.iris.engine.object.IrisRiverMeanderConfig; +import art.arcane.iris.engine.object.IrisRiverProfile; +import art.arcane.iris.engine.object.IrisRiverRoutingConfig; +import art.arcane.iris.engine.object.IrisRiverRoutingMode; +import art.arcane.iris.engine.object.IrisStyledRange; +import art.arcane.iris.engine.object.IrisSurfaceRiverChannelConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverSourceConfig; +import art.arcane.iris.engine.object.IrisUndergroundRiverConfig; +import art.arcane.iris.engine.object.IrisUndergroundRiverSourceConfig; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.math.RNG; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +public final class IrisHydrologyRuntime implements AutoCloseable { + private static final int MAXIMUM_CACHE_TILES = 64; + private static final int MAXIMUM_TERRAIN_SAMPLES = 65_536; + private static final int MAXIMUM_FEATURE_SEARCH_TILES = 1_089; + private static final int BIOME_PATCH_SCALE = 32; + private static final long SURFACE_WIDTH_SALT = 0x5355524657494454L; + private static final long SURFACE_DEPTH_SALT = 0x5355524644455054L; + private static final long SURFACE_INSET_SALT = 0x53555246494e5345L; + private static final long SURFACE_BLEND_SALT = 0x53555246424c454eL; + private static final long TARGET_POOL_SALT = 0x544152474554504fL; + private static final long UNDERGROUND_LEVEL_SALT = 0x554e4445524c564cL; + private static final long UNDERGROUND_WIDTH_SALT = 0x554e444552574944L; + private static final long UNDERGROUND_DEPTH_SALT = 0x554e444552444550L; + private static final long UNDERGROUND_HEADROOM_SALT = 0x554e444552484541L; + private static final long DEEP_HEIGHT_SALT = 0x4445455048454947L; + + private final IrisHydrologyRuntimeContext context; + private final IrisRiverHydrology rivers; + private final HydrologyPlannerSettings settings; + private final HydrologyTileCache cache; + private final IrisHydrologyRoutingTerrainSampler routingTerrainSampler; + private final Set profileKeys; + private final String defaultProfileKey; + private final Object terrainSampleLock; + private final LinkedHashMap terrainSamples; + + public IrisHydrologyRuntime(IrisHydrologyRuntimeContext context) { + this.context = Objects.requireNonNull(context); + IrisHydrology hydrology = Objects.requireNonNull(context.dimension().getHydrology()); + this.rivers = Objects.requireNonNull(hydrology.getRivers()); + this.profileKeys = profileKeys(rivers.getProfiles()); + this.defaultProfileKey = profileKeys.isEmpty() ? "default" : profileKeys.iterator().next(); + this.terrainSampleLock = new Object(); + this.terrainSamples = new LinkedHashMap<>(MAXIMUM_TERRAIN_SAMPLES, 0.75F, true); + this.settings = createSettings(context.dimension(), hydrology); + HydrologyGeometrySampler geometrySampler = geometrySampler(context, hydrology); + IrisHydrologyRoutingTerrainSampler.Sources terrainSources = new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double rawNaturalHeight) -> ProceduralStream.bypass2DCaches( + () -> createTerrainBasis(x, z, rawNaturalHeight) + ), + (int x, int z) -> context.naturalHeightProvider().sample(x, z), + (int x, int z) -> ProceduralStream.bypass2DCaches( + () -> context.naturalOceanClassifier().isOcean(x, z) + ) + ); + this.routingTerrainSampler = new IrisHydrologyRoutingTerrainSampler( + terrainSources, + IrisHydrologyRoutingTerrainSampler.SamplingOptions.production(MAXIMUM_TERRAIN_SAMPLES) + ); + HydrologyPlanner planner = new HydrologyPlanner( + context.seed(), + settings, + this::sampleTerrain, + routingTerrainSampler, + geometrySampler, + 0, + context.caveViewFactory() + ); + this.cache = new HydrologyTileCache(planner, MAXIMUM_CACHE_TILES); + } + + public HydrologyPlannerSettings settings() { + return settings; + } + + public HydrologyTile tile(HydrologyTileKey key) { + return cache.get(key); + } + + public Optional sample(double x, double z) { + int blockX = (int) StrictMath.floor(x); + int blockZ = (int) StrictMath.floor(z); + return cache.columnAt(blockX, blockZ); + } + + public void prepareChunkColumns(int blockX, int blockZ) { + cache.prepareChunkColumns(blockX, blockZ); + } + + public HydrologyRenderSample renderSample(double x, double z) { + int blockX = (int) StrictMath.floor(x); + int blockZ = (int) StrictMath.floor(z); + return cache.renderAt(blockX, blockZ); + } + + public boolean hasAcceptedSurfaceBiomeInChunk(String biomeKey, int chunkX, int chunkZ) { + if (biomeKey == null || biomeKey.isBlank()) { + return false; + } + int minimumX = chunkX << 4; + int minimumZ = chunkZ << 4; + int maximumX = Math.addExact(minimumX, 16); + int maximumZ = Math.addExact(minimumZ, 16); + int tileSize = settings.routing().tileSize(); + int publicationRadius = settings.publicationRadius(); + int minimumTileX = tileCoordinate((long) minimumX - publicationRadius, tileSize); + int maximumTileX = tileCoordinate((long) maximumX - 1L + publicationRadius, tileSize); + int minimumTileZ = tileCoordinate((long) minimumZ - publicationRadius, tileSize); + int maximumTileZ = tileCoordinate((long) maximumZ - 1L + publicationRadius, tileSize); + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ)); + for (HydrologyColumnSample column : tile.footprint().columnsIn( + minimumX, + minimumZ, + maximumX, + maximumZ + )) { + HydrologyColumnLayer layer = column.primarySurfaceLayer().orElse(null); + if (layer != null && biomeKey.equals(layer.biomeKey())) { + return true; + } + } + } + } + return false; + } + + public List cavePlansIn( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ + ) { + if (maximumX <= minimumX || maximumZ <= minimumZ) { + return List.of(); + } + int tileSize = settings.routing().tileSize(); + int publicationRadius = settings.publicationRadius(); + int minimumTileX = tileCoordinate((long) minimumX - publicationRadius, tileSize); + int maximumTileX = tileCoordinate((long) maximumX - 1L + publicationRadius, tileSize); + int minimumTileZ = tileCoordinate((long) minimumZ - publicationRadius, tileSize); + int maximumTileZ = tileCoordinate((long) maximumZ - 1L + publicationRadius, tileSize); + LinkedHashMap plans = new LinkedHashMap<>(); + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ)); + for (HydrologyCavePlan plan : tile.cavePlans()) { + HydrologyCavePlan existing = plans.putIfAbsent(plan.source().sourceId(), plan); + if (existing != null && !existing.equals(plan)) { + throw new IllegalStateException("Hydrology cave plan id collision."); + } + } + } + } + return List.copyOf(plans.values()); + } + + public HydrologyRenderSample sampleRenderFootprint( + double minimumX, + double minimumZ, + double maximumX, + double maximumZ + ) { + int minX = (int) StrictMath.floor(Math.min(minimumX, maximumX)); + int minZ = (int) StrictMath.floor(Math.min(minimumZ, maximumZ)); + int maxX = (int) StrictMath.ceil(Math.max(minimumX, maximumX)); + int maxZ = (int) StrictMath.ceil(Math.max(minimumZ, maximumZ)); + if (maxX <= minX) { + maxX = minX + 1; + } + if (maxZ <= minZ) { + maxZ = minZ + 1; + } + LinkedHashMap features = new LinkedHashMap<>(); + int tileSize = settings.routing().tileSize(); + int publicationRadius = settings.publicationRadius(); + int minimumTileX = tileCoordinate((long) minX - publicationRadius, tileSize); + int maximumTileX = tileCoordinate((long) maxX - 1L + publicationRadius, tileSize); + int minimumTileZ = tileCoordinate((long) minZ - publicationRadius, tileSize); + int maximumTileZ = tileCoordinate((long) maxZ - 1L + publicationRadius, tileSize); + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ)); + for (HydrologyColumnSample column : tile.footprint().columnsIn(minX, minZ, maxX, maxZ)) { + for (HydrologyFeatureRef feature : column.renderSample().features()) { + HydrologyFeatureRef existing = features.putIfAbsent(feature.id(), feature); + if (existing != null && !existing.equals(feature)) { + throw new IllegalStateException("Hydrology feature id collision in renderer footprint."); + } + } + } + } + } + return new HydrologyRenderSample(minX, minZ, List.copyOf(features.values())); + } + + public HydrologyDiagnosticRenderSample sampleDiagnosticFootprint( + double minimumX, + double minimumZ, + double maximumX, + double maximumZ + ) { + int minX = (int) StrictMath.floor(Math.min(minimumX, maximumX)); + int minZ = (int) StrictMath.floor(Math.min(minimumZ, maximumZ)); + int maxX = (int) StrictMath.ceil(Math.max(minimumX, maximumX)); + int maxZ = (int) StrictMath.ceil(Math.max(minimumZ, maximumZ)); + if (maxX <= minX) { + maxX = minX + 1; + } + if (maxZ <= minZ) { + maxZ = minZ + 1; + } + int tileSize = settings.routing().tileSize(); + int publicationRadius = settings.publicationRadius(); + int minimumTileX = tileCoordinate((long) minX - publicationRadius, tileSize); + int maximumTileX = tileCoordinate((long) maxX - 1L + publicationRadius, tileSize); + int minimumTileZ = tileCoordinate((long) minZ - publicationRadius, tileSize); + int maximumTileZ = tileCoordinate((long) maxZ - 1L + publicationRadius, tileSize); + LinkedHashMap candidates = new LinkedHashMap<>(); + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ)); + for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + if (candidate.point().x() < minX || candidate.point().x() >= maxX + || candidate.point().z() < minZ || candidate.point().z() >= maxZ) { + continue; + } + HydrologyDiagnosticCandidate existing = candidates.putIfAbsent(candidate.id(), candidate); + if (existing != null && !existing.equals(candidate)) { + throw new IllegalStateException("Hydrology diagnostic candidate id collision."); + } + } + } + } + return new HydrologyDiagnosticRenderSample(minX, minZ, List.copyOf(candidates.values())); + } + + public Optional nearestFeature( + Set types, + String profileKey, + int x, + int z, + int maximumDistance + ) { + Objects.requireNonNull(types); + if (types.isEmpty() || maximumDistance < 0) { + return Optional.empty(); + } + int tileSize = settings.routing().tileSize(); + int publicationRadius = settings.publicationRadius(); + int minimumTileX = tileCoordinate((long) x - maximumDistance - publicationRadius, tileSize); + int maximumTileX = tileCoordinate((long) x + maximumDistance + publicationRadius, tileSize); + int minimumTileZ = tileCoordinate((long) z - maximumDistance - publicationRadius, tileSize); + int maximumTileZ = tileCoordinate((long) z + maximumDistance + publicationRadius, tileSize); + long tileCount = HydrologyFeatureSearchBounds.tileCount( + x, z, maximumDistance, tileSize, publicationRadius); + if (tileCount > MAXIMUM_FEATURE_SEARCH_TILES) { + throw new IllegalArgumentException("Hydrology feature search exceeds the bounded tile limit."); + } + HydrologyFeatureRef nearest = null; + long nearestDistanceSquared = Long.MAX_VALUE; + for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { + for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { + HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ)); + HydrologyFeatureRef feature = tile.nearestFeature( + types, profileKey, x, z, maximumDistance).orElse(null); + if (feature != null) { + long deltaX = (long) feature.x() - x; + long deltaZ = (long) feature.z() - z; + long distanceSquared = deltaX * deltaX + deltaZ * deltaZ; + if (nearest == null || distanceSquared < nearestDistanceSquared + || distanceSquared == nearestDistanceSquared && feature.id() < nearest.id()) { + nearest = feature; + nearestDistanceSquared = distanceSquared; + } + } + } + } + return Optional.ofNullable(nearest); + } + + public int maximumFeatureSearchDistance(int x, int z, int requestedDistance) { + return HydrologyFeatureSearchBounds.maximumDistance( + x, + z, + requestedDistance, + settings.routing().tileSize(), + settings.publicationRadius(), + MAXIMUM_FEATURE_SEARCH_TILES + ); + } + + public Set profileKeys() { + return profileKeys; + } + + public List featureQueryKeys() { + ArrayList deepFluidIds = new ArrayList<>(settings.deepFluids().size()); + for (HydrologyPlannerSettings.DeepFluid deepFluid : settings.deepFluids()) { + deepFluidIds.add(deepFluid.id()); + } + return HydrologyFeatureQuery.suggestions(deepFluidIds); + } + + @Override + public void close() { + cache.close(); + routingTerrainSampler.close(); + synchronized (terrainSampleLock) { + terrainSamples.clear(); + } + } + + private HydrologyTerrainSample sampleTerrain(int x, int z) { + long packed = ((long) x << 32) ^ (z & 0xffffffffL); + synchronized (terrainSampleLock) { + HydrologyTerrainSample cached = terrainSamples.get(packed); + if (cached != null) { + return cached; + } + } + HydrologyTerrainSample sampled = ProceduralStream.bypass2DCaches(() -> createDetailedTerrainSample(x, z)); + synchronized (terrainSampleLock) { + HydrologyTerrainSample existing = terrainSamples.get(packed); + if (existing != null) { + return existing; + } + terrainSamples.put(packed, sampled); + if (terrainSamples.size() > MAXIMUM_TERRAIN_SAMPLES) { + Iterator iterator = terrainSamples.keySet().iterator(); + iterator.next(); + iterator.remove(); + } + } + return sampled; + } + + private HydrologyTerrainSample createDetailedTerrainSample(int x, int z) { + IrisHydrologyRoutingTerrainSampler.TerrainBasis basis = routingTerrainSampler.basis(x, z); + double slope = routingTerrainSampler.localSlope(x, z, basis.naturalHeight()); + return basis.terrain().withSlope(slope); + } + + private IrisHydrologyRoutingTerrainSampler.TerrainBasis createTerrainBasis( + int x, + int z, + double rawNaturalHeight + ) { + IrisHydrologyNaturalSample naturalSample = Objects.requireNonNull( + context.naturalSampleProvider().sample(x, z, rawNaturalHeight), + "Hydrology natural sample provider returned null at " + x + "," + z + ); + double sampledNaturalHeight = naturalSample.naturalHeight(); + double resolvedNaturalHeight = Double.isFinite(sampledNaturalHeight) + ? sampledNaturalHeight + : rawNaturalHeight; + if (!Double.isFinite(resolvedNaturalHeight)) { + throw new IllegalStateException("Hydrology natural height was not finite at " + x + "," + z); + } + int naturalHeight = (int) StrictMath.round(resolvedNaturalHeight); + boolean ocean = naturalSample.ocean(); + IrisBiome biome = naturalSample.biome(); + IrisRegion region = naturalSample.region(); + EffectiveRiverPolicy policy = RiverPolicyResolver.resolve(context.dimension(), region, biome); + String parentBiomeKey = requireBiomeKey(biome); + List profiles = validProfiles(policy.profiles()); + int configuredFluidY = settings.underground().minimumFluidY() + + (settings.underground().maximumFluidY() - settings.underground().minimumFluidY()) / 2; + if (!rivers.getUnderground().getFluidLevel().isFlat()) { + int lowestFluidY = Math.max( + settings.underground().minimumFluidY(), + settings.underground().maximumDepth() + 1 + ); + int highestFluidY = Math.min( + settings.underground().maximumFluidY(), + naturalHeight - settings.underground().minimumHeadroom() - 1 + ); + if (lowestFluidY <= highestFluidY) { + configuredFluidY = lowestFluidY + (highestFluidY - lowestFluidY) / 2; + } + } + int caveFloorY = configuredFluidY - settings.underground().maximumDepth(); + boolean caveAvailable = caveFloorY > 0 + && configuredFluidY + settings.underground().maximumHeadroom() < naturalHeight; + boolean routingAllowed = policy.allowsTransit() && policy.allowsRouting() && !ocean; + boolean sourceAllowed = routingAllowed && policy.allowsSources(); + double sourceWeight = switch (policy.placement()) { + case DISABLED, TRANSIT_ONLY -> 0D; + case NATURAL -> 1D; + case PREFERRED_HEADWATER -> 4D; + case REQUIRED_HEADWATER -> 8D; + }; + double routingCost = policy.routing() == IrisRiverRoutingMode.AVOID ? 1024D : 0D; + double routingPreference = policy.routing() == IrisRiverRoutingMode.PREFER ? 0.5D : 1D; + double biomePatchNoise = coherentPatchNoise(context.seed(), x, z); + HydrologyTerrainSample terrain = new HydrologyTerrainSample( + naturalHeight, + 0D, + ocean, + caveAvailable, + caveFloorY, + configuredFluidY, + routingAllowed, + routingAllowed && policy.outletAdmission(), + sourceAllowed, + sourceAllowed && policy.requiresHeadwaters(), + sourceAllowed && caveAvailable, + sourceAllowed && caveAvailable && policy.requiresHeadwaters(), + routingCost, + sourceWeight, + sourceWeight, + policy.widthMultiplier(), + policy.depthMultiplier(), + policy.incisionMultiplier(), + policy.routingMultiplier() * routingPreference, + parentBiomeKey, + selectKey(policy.surfaceBiomes(), parentBiomeKey, biomePatchNoise, 1), + selectKey(policy.mouthBiomes(), parentBiomeKey, biomePatchNoise, 2), + selectKey(policy.shoreBiomes(), parentBiomeKey, biomePatchNoise, 3), + selectKey(policy.dryBiomes(), parentBiomeKey, biomePatchNoise, 4), + selectKey(policy.floodedCaveBiomes(), parentBiomeKey, biomePatchNoise, 5), + profiles + ); + return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(resolvedNaturalHeight, terrain); + } + + private List validProfiles(List requested) { + if (requested == null || requested.isEmpty()) { + return List.of(defaultProfileKey); + } + ArrayList valid = new ArrayList<>(); + for (String key : requested) { + if (key != null && profileKeys.contains(key) && !valid.contains(key)) { + valid.add(key); + } + } + return valid.isEmpty() ? List.of(defaultProfileKey) : List.copyOf(valid); + } + + private String selectKey(List keys, String fallback, double patchNoise, int salt) { + if (keys == null || keys.isEmpty()) { + return fallback; + } + return keys.get(keyIndex(patchNoise, salt, keys.size())); + } + + static int coherentKeyIndex(long seed, int x, int z, int salt, int keyCount) { + if (keyCount <= 0) { + throw new IllegalArgumentException("keyCount must be positive"); + } + if (keyCount == 1) { + return 0; + } + return keyIndex(coherentPatchNoise(seed, x, z), salt, keyCount); + } + + private static int keyIndex(double patchNoise, int salt, int keyCount) { + double shifted = patchNoise + salt * 0.3819660112501051D; + shifted -= StrictMath.floor(shifted); + return Math.min(keyCount - 1, (int) StrictMath.floor(shifted * keyCount)); + } + + private static double coherentPatchNoise(long seed, int x, int z) { + int cellX = Math.floorDiv(x, BIOME_PATCH_SCALE); + int cellZ = Math.floorDiv(z, BIOME_PATCH_SCALE); + double localX = Math.floorMod(x, BIOME_PATCH_SCALE) / (double) BIOME_PATCH_SCALE; + double localZ = Math.floorMod(z, BIOME_PATCH_SCALE) / (double) BIOME_PATCH_SCALE; + double smoothX = localX * localX * (3D - 2D * localX); + double smoothZ = localZ * localZ * (3D - 2D * localZ); + double top = interpolate( + keyNoise(seed, cellX, cellZ), + keyNoise(seed, cellX + 1, cellZ), + smoothX + ); + double bottom = interpolate( + keyNoise(seed, cellX, cellZ + 1), + keyNoise(seed, cellX + 1, cellZ + 1), + smoothX + ); + return interpolate(top, bottom, smoothZ); + } + + private static double keyNoise(long seed, int cellX, int cellZ) { + long mixed = avalanche(seed ^ 0x9e3779b97f4a7c15L); + mixed = avalanche(mixed ^ avalanche(cellX + 0x9e3779b97f4a7c15L)); + mixed = avalanche(mixed ^ avalanche(cellZ + 0x9e3779b97f4a7c15L)); + return (avalanche(mixed) >>> 11) * 0x1.0p-53; + } + + private static long avalanche(long value) { + value ^= value >>> 30; + value *= 0xbf58476d1ce4e5b9L; + value ^= value >>> 27; + value *= 0x94d049bb133111ebL; + return value ^ value >>> 31; + } + + private static double interpolate(double first, double second, double progress) { + return first + (second - first) * progress; + } + + private static HydrologyGeometrySampler geometrySampler( + IrisHydrologyRuntimeContext context, + IrisHydrology hydrology + ) { + IrisRiverHydrology rivers = hydrology.getRivers(); + IrisSurfaceRiverConfig surface = rivers.getSurface(); + IrisSurfaceRiverChannelConfig channel = surface.getChannel(); + IrisRiverHydraulicsConfig hydraulics = surface.getHydraulics(); + IrisUndergroundRiverConfig underground = rivers.getUnderground(); + ProceduralStream surfaceWidth = styledStream(context, channel.getWidth(), SURFACE_WIDTH_SALT); + ProceduralStream surfaceDepth = styledStream(context, channel.getDepth(), SURFACE_DEPTH_SALT); + ProceduralStream surfaceInset = styledStream(context, channel.getSurfaceInset(), SURFACE_INSET_SALT); + ProceduralStream surfaceBlend = styledStream( + context, + channel.getTerrainBlendWidth(), + SURFACE_BLEND_SALT + ); + ProceduralStream targetPool = styledStream( + context, + hydraulics.getTargetPoolLength(), + TARGET_POOL_SALT + ); + ProceduralStream undergroundLevel = styledStream( + context, + underground.getFluidLevel(), + UNDERGROUND_LEVEL_SALT + ); + ProceduralStream undergroundWidth = styledStream( + context, + underground.getChannelWidth(), + UNDERGROUND_WIDTH_SALT + ); + ProceduralStream undergroundDepth = styledStream( + context, + underground.getDepth(), + UNDERGROUND_DEPTH_SALT + ); + ProceduralStream undergroundHeadroom = styledStream( + context, + underground.getHeadroom(), + UNDERGROUND_HEADROOM_SALT + ); + LinkedHashMap> deepHeights = new LinkedHashMap<>(); + for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) { + long profileSalt = DEEP_HEIGHT_SALT ^ deepFluid.getId().hashCode(); + deepHeights.put(deepFluid.getId(), styledStream(context, deepFluid.getHeight(), profileSalt)); + } + int minimumWorldY = context.dimension().getMinHeight(); + return request -> { + ProceduralStream stream = switch (request.field()) { + case SURFACE_WIDTH -> surfaceWidth; + case SURFACE_DEPTH -> surfaceDepth; + case SURFACE_INSET -> surfaceInset; + case SURFACE_BLEND_WIDTH -> surfaceBlend; + case TARGET_POOL_LENGTH -> targetPool; + case UNDERGROUND_FLUID_LEVEL -> undergroundLevel; + case UNDERGROUND_WIDTH -> undergroundWidth; + case UNDERGROUND_DEPTH -> undergroundDepth; + case UNDERGROUND_HEADROOM -> undergroundHeadroom; + case DEEP_FLUID_HEIGHT -> Objects.requireNonNull( + deepHeights.get(request.profileKey()), + "Missing deep-fluid height style for " + request.profileKey() + "." + ); + }; + double sampled = stream.get(request.x(), request.z()); + if (!Double.isFinite(sampled)) { + throw new IllegalStateException( + "Hydrology geometry style returned a non-finite value for " + request.field() + "." + ); + } + long rounded = StrictMath.round(sampled); + if (request.field() == HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL + || request.field() == HydrologyGeometrySampler.Field.DEEP_FLUID_HEIGHT) { + rounded = Math.subtractExact(rounded, minimumWorldY); + } + int value = Math.toIntExact(rounded); + return Math.max(request.minimum(), Math.min(request.maximum(), value)); + }; + } + + private static ProceduralStream styledStream( + IrisHydrologyRuntimeContext context, + IrisStyledRange range, + long salt + ) { + return range.stream(new RNG(context.seed() ^ salt), context.data()); + } + + static HydrologyPlannerSettings createSettings( + IrisDimension dimension, + IrisHydrology hydrology + ) { + IrisRiverHydrology rivers = hydrology.getRivers(); + IrisRiverRoutingConfig routing = rivers.getRouting(); + IrisSurfaceRiverConfig surface = rivers.getSurface(); + IrisSurfaceRiverSourceConfig surfaceSources = surface.getSources(); + IrisSurfaceRiverChannelConfig channel = surface.getChannel(); + IrisRiverHydraulicsConfig hydraulics = surface.getHydraulics(); + IrisUndergroundRiverConfig underground = rivers.getUnderground(); + IrisUndergroundRiverSourceConfig undergroundSources = underground.getSources(); + int routeNodes = maximumRouteNodes(routing); + int minimumWorldY = dimension.getMinHeight(); + boolean riversEnabled = rivers.isEnabled(); + boolean surfaceEnabled = riversEnabled && surface.isEnabled(); + boolean undergroundEnabled = riversEnabled && underground.isEnabled(); + HydrologyPlannerSettings.Routing plannerRouting = new HydrologyPlannerSettings.Routing( + routing.getTileSize(), + routing.getSampleSpacing(), + routing.getRefinementSpacing(), + routeNodes, + routing.getMaximumRouteLength(), + new HydrologyPlannerSettings.Branching( + routing.getBranching().getMinimumSurfaceCourseLength(), + routing.getBranching().getMinimumUndergroundCourseLength() + ), + 1.5D, + 24D, + 2D, + 0.2D + ); + HydrologyPlannerSettings.Source plannerSurfaceSources = new HydrologyPlannerSettings.Source( + surfaceEnabled, + surfaceSources.getDensity(), + surfaceSources.getMinimumElevation() - minimumWorldY, + surfaceSources.getMinimumPerTile(), + maximumSources(surfaceSources.getDensity(), surfaceSources.getMinimumPerTile()), + surfaceSources.getMinimumSpacing() + ); + HydrologyPlannerSettings.Surface plannerSurface = new HydrologyPlannerSettings.Surface( + surfaceEnabled, + plannerSurfaceSources, + minimumInt(channel.getWidth()), + maximumInt(channel.getWidth()), + minimumInt(channel.getDepth()), + maximumInt(channel.getDepth()), + minimumInt(channel.getSurfaceInset()), + maximumInt(channel.getSurfaceInset()), + channel.getMaximumIncision(), + channel.getShoreWidth(), + minimumInt(channel.getTerrainBlendWidth()), + maximumInt(channel.getTerrainBlendWidth()), + surface.getRidgeTunnels().isEnabled(), + surface.getRidgeTunnels().getMaximumLength(), + surface.getRidgeTunnels().getHeadroom() + ); + HydrologyPlannerSettings.Hydraulics plannerHydraulics = new HydrologyPlannerSettings.Hydraulics( + minimumInt(hydraulics.getTargetPoolLength()), + maximumInt(hydraulics.getTargetPoolLength()), + hydraulics.getRiffleDrop(), + hydraulics.getMaximumGradualDrop(), + hydraulics.getMaximumGradualLength(), + hydraulics.getWaterfallMinimumDrop() + ); + HydrologyPlannerSettings.Source plannerUndergroundSources = new HydrologyPlannerSettings.Source( + undergroundEnabled, + undergroundSources.getDensity(), + Integer.MIN_VALUE, + undergroundSources.getMinimumPerTile(), + maximumSources(undergroundSources.getDensity(), undergroundSources.getMinimumPerTile()), + undergroundSources.getMinimumSpacing() + ); + HydrologyPlannerSettings.Underground plannerUnderground = new HydrologyPlannerSettings.Underground( + undergroundEnabled, + plannerUndergroundSources, + minimumInt(underground.getFluidLevel()) - minimumWorldY, + maximumInt(underground.getFluidLevel()) - minimumWorldY, + minimumInt(underground.getChannelWidth()), + maximumInt(underground.getChannelWidth()), + minimumInt(underground.getDepth()), + maximumInt(underground.getDepth()), + minimumInt(underground.getHeadroom()), + maximumInt(underground.getHeadroom()), + underground.isConnectToExistingCaves() + ); + IrisRiverGrottoConfig grottos = rivers.getGrottos(); + IrisCoastalRiverGrottoConfig coastal = grottos.getCoastal(); + IrisInlandRiverGrottoConfig inland = grottos.getInland(); + boolean inlandEnabled = inland.isEnabled() + && routing.getInlandOutlets().contains(IrisRiverInlandOutlet.SINKHOLE_GROTTO); + HydrologyPlannerSettings.Outlets plannerOutlets = new HydrologyPlannerSettings.Outlets( + routing.isOceanOutlets(), + grotto(coastal.isEnabled(), coastal.getHorizontalRadius(), coastal.getVerticalRadius(), + coastal.getHeadroom(), coastal.getMaximumVolume()), + grotto(inlandEnabled, inland.getHorizontalRadius(), inland.getVerticalRadius(), + inland.getHeadroom(), inland.getMaximumVolume()), + inlandEnabled && inland.isConnectSurfaceRivers(), + Math.max(4, coastal.getVerticalRadius()), + surface.getMouths().getLevelingDistance(), + surface.getMouths().getMaximumOceanApron(), + routing.getMaximumOutletsPerTile() + ); + HydrologyPlannerSettings.Geometry plannerGeometry = geometry(rivers.getGeometry()); + return new HydrologyPlannerSettings( + dimension.getFluidHeight(), + plannerRouting, + plannerSurface, + plannerHydraulics, + plannerUnderground, + plannerOutlets, + plannerGeometry, + deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing) + ); + } + + private static HydrologyPlannerSettings.Geometry geometry(IrisRiverGeometryConfig geometry) { + IrisRiverMeanderConfig meanders = geometry.getMeanders(); + IrisRiverDropShapeConfig drops = geometry.getDrops(); + return new HydrologyPlannerSettings.Geometry( + new HydrologyPlannerSettings.Meanders( + meanders.getPrimaryWavelength(), + meanders.getDetailWavelength(), + meanders.getPrimaryStrength(), + meanders.getDetailStrength(), + meanders.getMaximumOffsetRatio(), + meanders.getSmoothingPasses(), + meanders.getMaximumTurnDegrees() + ), + channelShape(geometry.getSurface()), + channelShape(geometry.getUnderground()), + channelShape(geometry.getGrottos()), + new HydrologyPlannerSettings.Drops( + drops.getCascadeRunPerBlock(), + drops.getCascadeExponent(), + drops.getMaximumCascadeStep(), + drops.getFlowWidthRatio(), + drops.getMaximumFlowDepth(), + drops.getBasinWidthRatio(), + drops.getMaximumBasinDepth() + ) + ); + } + + private static HydrologyPlannerSettings.ChannelShape channelShape(IrisRiverChannelShapeConfig shape) { + return new HydrologyPlannerSettings.ChannelShape( + shape.getBedRoundness(), + shape.getBedRoughness(), + shape.getWallRoughness(), + shape.getRoughnessWavelength() + ); + } + + private static HydrologyPlannerSettings.Grotto grotto( + boolean enabled, + int horizontalRadius, + int verticalRadius, + int headroom, + int maximumVolume + ) { + return new HydrologyPlannerSettings.Grotto( + enabled, + horizontalRadius, + verticalRadius, + headroom, + maximumVolume + ); + } + + private static List deepFluids( + List configurations, + int minimumWorldY, + IrisRiverRoutingConfig routing + ) { + ArrayList deepFluids = new ArrayList<>(); + for (IrisDeepFluidConfig configuration : configurations) { + int radius = configuration.getHorizontalRadius(); + int verticalRadius = configuration.getVerticalRadius(); + int maximumVolume = boundedVolume(radius, verticalRadius, configuration.getHeadroom()); + int maximumPerTile = Math.min(64, maximumSources(configuration.getDensity(), 0)); + int maximumChannelLength = maximumDeepChannelLength(configuration, routing); + int minimumChannelLength = maximumChannelLength == 0 + ? 0 + : Math.min(maximumChannelLength, Math.max(routing.getRefinementSpacing(), radius)); + deepFluids.add(new HydrologyPlannerSettings.DeepFluid( + configuration.getId(), + configuration.getDensity() > 0D + && (configuration.isContainedPools() || configuration.isShortChannels()), + configuration.getDensity(), + configuration.getSpacing(), + minimumInt(configuration.getHeight()) - minimumWorldY, + maximumInt(configuration.getHeight()) - minimumWorldY, + radius, + radius, + verticalRadius, + verticalRadius, + minimumChannelLength, + maximumChannelLength, + configuration.getChannelWidth(), + configuration.getDepth(), + configuration.getHeadroom(), + maximumVolume, + maximumPerTile, + configuration.isContainedPools(), + configuration.isShortChannels() + )); + } + return List.copyOf(deepFluids); + } + + static int maximumDeepChannelLength( + IrisDeepFluidConfig configuration, + IrisRiverRoutingConfig routing + ) { + if (!configuration.isShortChannels()) { + return 0; + } + int configuredLength = Math.max( + routing.getRefinementSpacing(), configuration.getSpacing() / 3); + return Math.min(configuredLength, routing.getTileSize() / 2); + } + + private static int maximumRouteNodes(IrisRiverRoutingConfig routing) { + int halo = Math.min(routing.getMaximumRouteLength(), routing.getTileSize() / 2); + int alignedHalo = Math.floorDiv(halo, routing.getSampleSpacing()) * routing.getSampleSpacing(); + int width = routing.getTileSize() / routing.getSampleSpacing() + 1 + + alignedHalo * 2 / routing.getSampleSpacing(); + long nodes = (long) width * width; + if (nodes > 1_000_000L) { + throw new IllegalArgumentException("Hydrology routing lattice exceeds one million nodes."); + } + return (int) nodes; + } + + private static int tileCoordinate(long blockCoordinate, int tileSize) { + return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize)); + } + + private static int maximumSources(double density, int minimumPerTile) { + int expected = (int) Math.ceil(Math.max(0D, density)); + return Math.min(64, Math.max(minimumPerTile, expected)); + } + + private static int boundedVolume(int radius, int verticalRadius, int headroom) { + long diameter = radius * 2L + 1L; + long volume = diameter * diameter * (verticalRadius + headroom + 1L); + return (int) Math.min(1_048_576L, Math.max(64L, volume)); + } + + private static int minimumInt(IrisStyledRange range) { + return (int) StrictMath.floor(Math.min(range.getMin(), range.getMax())); + } + + private static int maximumInt(IrisStyledRange range) { + return (int) StrictMath.ceil(Math.max(range.getMin(), range.getMax())); + } + + private static Set profileKeys(List profiles) { + LinkedHashSet keys = new LinkedHashSet<>(); + for (IrisRiverProfile profile : profiles) { + if (profile != null && profile.getId() != null && !profile.getId().isBlank()) { + keys.add(profile.getId().trim()); + } + } + return Collections.unmodifiableSet(new LinkedHashSet<>(keys)); + } + + private static String requireBiomeKey(IrisBiome biome) { + if (biome == null || biome.getLoadKey() == null || biome.getLoadKey().isBlank()) { + throw new IllegalStateException("Hydrology terrain sampling requires a loaded parent biome."); + } + return biome.getLoadKey(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeContext.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeContext.java new file mode 100644 index 000000000..8f153b6b3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeContext.java @@ -0,0 +1,30 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory; +import art.arcane.iris.engine.object.IrisDimension; + +import java.util.Objects; + +public record IrisHydrologyRuntimeContext( + long seed, + int worldHeight, + IrisDimension dimension, + IrisData data, + IrisHydrologyNaturalSampleProvider naturalSampleProvider, + IrisHydrologyNaturalHeightProvider naturalHeightProvider, + IrisHydrologyNaturalOceanClassifier naturalOceanClassifier, + HydrologyCaveVoxelViewFactory caveViewFactory +) { + public IrisHydrologyRuntimeContext { + if (worldHeight < 3) { + throw new IllegalArgumentException("worldHeight must be at least three"); + } + Objects.requireNonNull(dimension); + Objects.requireNonNull(data); + Objects.requireNonNull(naturalSampleProvider); + Objects.requireNonNull(naturalHeightProvider); + Objects.requireNonNull(naturalOceanClassifier); + Objects.requireNonNull(caveViewFactory); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java index 489a867a8..091b7c940 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/EngineMantle.java @@ -27,9 +27,10 @@ import art.arcane.iris.engine.framework.EngineTarget; import art.arcane.iris.engine.framework.TreeBlockMaterial; import art.arcane.iris.engine.mantle.components.MantleObjectComponent; import art.arcane.iris.engine.object.IrisDimension; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; -import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage; import art.arcane.iris.engine.object.IrisPosition; +import art.arcane.iris.util.project.matter.PreObjectMatterCell; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.documentation.BlockCoordinates; @@ -49,6 +50,7 @@ import org.jetbrains.annotations.UnmodifiableView; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.TimeUnit; public interface EngineMantle extends MatterGenerator { @@ -112,7 +114,7 @@ public interface EngineMantle extends MatterGenerator { } default boolean isCarved(int x, int h, int z) { - RiverCaveHydrology hydrology = RiverCaveHydrologyStorage.getIfPresent(getMantle(), x, h, z); + HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent(getMantle(), x, h, z); if (hydrology != null) { return hydrology.carves(); } @@ -244,7 +246,7 @@ public interface EngineMantle extends MatterGenerator { MantleObjectComponent getObjectComponent(); default boolean isCovered(int x, int z) { - int s = getRealRadius(); + int s = Math.max(getRadius(), getRealRadius()); for (int i = -s; i <= s; i++) { for (int j = -s; j <= s; j++) { @@ -259,42 +261,68 @@ public interface EngineMantle extends MatterGenerator { return true; } - default void cleanupChunk(int x, int z) { - if (!isCovered(x, z)) return; - doCleanupChunk(x, z); + default boolean cleanupChunk(int x, int z) { + return cleanupTargetIfCovered(x, z, false); } - default void forceCleanupChunk(int x, int z) { + default boolean forceCleanupChunk(int x, int z) { + return cleanupTargetIfCovered(x, z, true); + } + + default void cleanupChunksCoveredBy( + int newRealX, + int newRealZ, + boolean force, + ChunkCleanupCallback callback + ) { + Objects.requireNonNull(callback, "callback"); + int radius = Math.max(getRadius(), getRealRadius()); + for (int offsetX = -radius; offsetX <= radius; offsetX++) { + int candidateX = newRealX + offsetX; + for (int offsetZ = -radius; offsetZ <= radius; offsetZ++) { + int candidateZ = newRealZ + offsetZ; + if (getMantle().hasFlag(candidateX, candidateZ, MantleFlag.CLEANED)) { + continue; + } + if (cleanupTargetIfCovered(candidateX, candidateZ, force)) { + callback.onChunkCleaned(candidateX, candidateZ); + } + } + } + } + + private boolean cleanupTargetIfCovered(int x, int z, boolean force) { + if (getMantle().hasFlag(x, z, MantleFlag.CLEANED) || !isCovered(x, z)) { + return false; + } MantleChunk chunk = getMantle().getChunk(x, z).use(); try { - chunk.raiseFlagUnchecked(MantleFlag.CLEANED, () -> { - MantleSliceRetention.deleteUnlessRetained(chunk, PlatformBlockState.class); - MantleSliceRetention.deleteUnlessRetained(chunk, String.class); - MantleSliceRetention.deleteUnlessRetained(chunk, UpdateMatter.class); - MantleSliceRetention.deleteUnlessRetained(chunk, MatterCavern.class); - MantleSliceRetention.deleteUnlessRetained(chunk, MatterFluidBody.class); - MantleSliceRetention.deleteUnlessRetained(chunk, MatterMarker.class); - MantleSliceRetention.deleteUnlessRetained(chunk, TreeBlockMaterial.class); - chunk.trimSlices(); - }); + synchronized (chunk) { + if (chunk.isFlagged(MantleFlag.CLEANED)) { + return false; + } + chunk.raiseFlagUnchecked(MantleFlag.CLEANED, () -> cleanupSlices(chunk, force)); + return true; + } } finally { chunk.release(); } } - private void doCleanupChunk(int x, int z) { - MantleChunk chunk = getMantle().getChunk(x, z).use(); - try { - chunk.raiseFlagUnchecked(MantleFlag.CLEANED, () -> { - MantleSliceRetention.deleteUnlessRetained(chunk, PlatformBlockState.class); - MantleSliceRetention.deleteUnlessRetained(chunk, UpdateMatter.class); - MantleSliceRetention.deleteUnlessRetained(chunk, MatterCavern.class); - MantleSliceRetention.deleteUnlessRetained(chunk, MatterFluidBody.class); - chunk.trimSlices(); - }); - } finally { - chunk.release(); + private void cleanupSlices(MantleChunk chunk, boolean force) { + MantleSliceRetention.deleteUnlessRetained(chunk, PlatformBlockState.class); + if (force) { + MantleSliceRetention.deleteUnlessRetained(chunk, String.class); } + MantleSliceRetention.deleteUnlessRetained(chunk, UpdateMatter.class); + MantleSliceRetention.deleteUnlessRetained(chunk, MatterCavern.class); + MantleSliceRetention.deleteUnlessRetained(chunk, MatterFluidBody.class); + if (force) { + MantleSliceRetention.deleteUnlessRetained(chunk, MatterMarker.class); + MantleSliceRetention.deleteUnlessRetained(chunk, TreeBlockMaterial.class); + } + chunk.deleteSlices(PreObjectMatterCell.class); + chunk.trimSlices(); } default int getUnloadRegionCount() { @@ -304,4 +332,12 @@ public interface EngineMantle extends MatterGenerator { default double getAdjustedIdleDuration() { return getMantle().getAdjustedIdleDuration(); } + + @FunctionalInterface + interface ChunkCleanupCallback { + ChunkCleanupCallback NONE = (x, z) -> { + }; + + void onChunkCleaned(int x, int z); + } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java index bdaa9abad..e7c262958 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleComponent.java @@ -34,6 +34,10 @@ public interface MantleComponent extends Comparable { int getRadius(); + default int getOutputRadius() { + return getRadius(); + } + default int getInputRadius() { return 0; } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantlePass.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantlePass.java index ea338bfc9..1123d32f3 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantlePass.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantlePass.java @@ -25,7 +25,7 @@ import java.util.List; * * @param components every component registered at this priority, enabled or not * @param passChunkRadius chunk ring this pass must complete: ceilDiv of this pass' widest - * component reach plus every later pass' reach + * component output reach plus every later pass' reach * @param downstreamBlockRadius block reach summed over all LATER passes only. A component must be * generated across its own reach plus this, or later passes read * half-written data from it diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java index 43cd97fae..99715b6d1 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MantleWriter.java @@ -21,17 +21,18 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.iris.util.project.matter.PreObjectMatterCell; import com.google.common.collect.ImmutableList; import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.core.tools.WorldMaintenance; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.IrisGeneratorStyle; import art.arcane.iris.engine.object.IrisPosition; import art.arcane.iris.engine.object.TileData; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.mantle.components.ObjectPassPlacer; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.volmlib.util.collection.KSet; import art.arcane.volmlib.util.documentation.ChunkCoordinates; import art.arcane.volmlib.util.function.Function3; @@ -44,19 +45,27 @@ import art.arcane.volmlib.util.matter.MatterSlice; import art.arcane.iris.util.project.noise.CNG; import art.arcane.iris.util.common.scheduling.J; import lombok.Data; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import org.bukkit.util.Vector; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.concurrent.atomic.AtomicReferenceArray; import static art.arcane.iris.engine.mantle.EngineMantle.AIR; @Data -public class MantleWriter implements IObjectPlacer, AutoCloseable { +public class MantleWriter implements ObjectPassPlacer, AutoCloseable { + private static final int OBJECT_COMPONENT_PRIORITY = 2; + private final EngineMantle engineMantle; private final Mantle mantle; private final int radius; @@ -64,6 +73,11 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { private final int z; private final int windowSide; private final AtomicReferenceArray> window; + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + @EqualsAndHashCode.Exclude + @ToString.Exclude + private final ThreadLocal activeComponentPriority = new ThreadLocal<>(); public MantleWriter(EngineMantle engineMantle, Mantle mantle, int x, int z, int radius, boolean multicore) { this(engineMantle, mantle, x, z, radius, radius * 2, multicore); @@ -124,6 +138,34 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { return Math.max(1, availableProcessors / 2); } + public void withComponentPriority(int priority, Runnable task) { + Objects.requireNonNull(task, "task"); + Integer previous = activeComponentPriority.get(); + activeComponentPriority.set(priority); + try { + task.run(); + } finally { + if (previous == null) { + activeComponentPriority.remove(); + } else { + activeComponentPriority.set(previous); + } + } + } + + @ChunkCoordinates + public void withChunkFence(int chunkX, int chunkZ, Runnable task) { + Objects.requireNonNull(task, "task"); + MantleChunk chunk = acquireChunk(chunkX, chunkZ); + if (chunk == null) { + throw new IllegalArgumentException("Chunk fence exceeds the prepared Mantle radius at " + + chunkX + "," + chunkZ); + } + synchronized (chunk) { + task.run(); + } + } + private static Set getBallooned(Set vset, double radius) { Set returnset = new HashSet<>(); int ceilrad = (int) Math.ceil(radius); @@ -206,16 +248,19 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { MantleChunk chunk = acquireChunk(cx, cz); if (chunk == null) return; - Matter matter = chunk.getOrCreate(y >> 4); - if ((t instanceof PlatformBlockState || t instanceof MatterCavern) - && hasProtectedHydrology(matter, x, y, z)) { - return; + synchronized (chunk) { + Matter matter = chunk.getOrCreate(y >> 4); + if ((t instanceof PlatformBlockState || t instanceof MatterCavern) + && hasProtectedHydrology(matter, x, y, z)) { + return; + } + if (t instanceof PlatformBlockState) { + clearDeferredPlacement(matter, x, y, z); + } + Class sliceType = t instanceof PlatformBlockState ? PlatformBlockState.class : matter.getClass(t); + capturePreObjectOriginal(matter, x, y, z, sliceType); + matter.slice(sliceType).set(x & 15, y & 15, z & 15, t); } - if (t instanceof PlatformBlockState) { - clearDeferredPlacement(matter, x, y, z); - } - Class sliceType = t instanceof PlatformBlockState ? PlatformBlockState.class : matter.getClass(t); - matter.slice(sliceType).set(x & 15, y & 15, z & 15, t); } public boolean setDataIfAbsent(int x, int y, int z, MatterCavern value) { @@ -235,17 +280,28 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { return false; } - Matter matter = chunk.getOrCreate(y >> 4); - if (hasProtectedHydrology(matter, x, y, z)) { - return false; - } - MatterCavern existing = matter.slice(MatterCavern.class).get(x & 15, y & 15, z & 15); - if (existing != null) { - return false; - } + synchronized (chunk) { + Matter matter = chunk.getOrCreate(y >> 4); + if (hasProtectedHydrology(matter, x, y, z)) { + return false; + } + MatterSlice cavernSlice = matter.hasSlice(MatterCavern.class) + ? matter.getSlice(MatterCavern.class) + : null; + MatterCavern existing = cavernSlice == null + ? null + : cavernSlice.get(x & 15, y & 15, z & 15); + if (existing != null) { + return false; + } - matter.slice(MatterCavern.class).set(x & 15, y & 15, z & 15, value); - return true; + capturePreObjectOriginal(matter, x, y, z, MatterCavern.class); + if (cavernSlice == null) { + cavernSlice = matter.slice(MatterCavern.class); + } + cavernSlice.set(x & 15, y & 15, z & 15, value); + return true; + } } public boolean carveDataIfAbsent(int x, int y, int z, MatterCavern value) { @@ -258,20 +314,30 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { return false; } - Matter matter = chunk.getOrCreate(y >> 4); - if (hasProtectedHydrology(matter, x, y, z)) { - return false; + synchronized (chunk) { + Matter matter = chunk.getOrCreate(y >> 4); + if (hasProtectedHydrology(matter, x, y, z)) { + return false; + } + if (matter.hasSlice(PlatformBlockState.class)) { + capturePreObjectOriginal(matter, x, y, z, PlatformBlockState.class); + matter.getSlice(PlatformBlockState.class) + .set(x & 15, y & 15, z & 15, null); + } + clearDeferredPlacement(matter, x, y, z); + MatterSlice cavernSlice = matter.hasSlice(MatterCavern.class) + ? matter.getSlice(MatterCavern.class) + : null; + if (cavernSlice != null && cavernSlice.get(x & 15, y & 15, z & 15) != null) { + return false; + } + capturePreObjectOriginal(matter, x, y, z, MatterCavern.class); + if (cavernSlice == null) { + cavernSlice = matter.slice(MatterCavern.class); + } + cavernSlice.set(x & 15, y & 15, z & 15, value); + return true; } - if (matter.hasSlice(PlatformBlockState.class)) { - matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); - } - clearDeferredPlacement(matter, x, y, z); - MatterSlice cavernSlice = matter.slice(MatterCavern.class); - if (cavernSlice.get(x & 15, y & 15, z & 15) != null) { - return false; - } - cavernSlice.set(x & 15, y & 15, z & 15, value); - return true; } public void setForcedCarve(int x, int y, int z, MatterCavern value) { @@ -283,15 +349,20 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { throw new IllegalStateException("Forced structure carve exceeded its prepared Mantle radius at " + x + "," + y + "," + z); } - Matter matter = chunk.getOrCreate(y >> 4); - if (hasProtectedHydrology(matter, x, y, z)) { - return; + synchronized (chunk) { + Matter matter = chunk.getOrCreate(y >> 4); + if (hasProtectedHydrology(matter, x, y, z)) { + return; + } + if (matter.hasSlice(PlatformBlockState.class)) { + capturePreObjectOriginal(matter, x, y, z, PlatformBlockState.class); + matter.getSlice(PlatformBlockState.class) + .set(x & 15, y & 15, z & 15, null); + } + clearDeferredPlacement(matter, x, y, z); + capturePreObjectOriginal(matter, x, y, z, MatterCavern.class); + matter.slice(MatterCavern.class).set(x & 15, y & 15, z & 15, value); } - if (matter.hasSlice(PlatformBlockState.class)) { - matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); - } - clearDeferredPlacement(matter, x, y, z); - matter.slice(MatterCavern.class).set(x & 15, y & 15, z & 15, value); } public void clearBlock(int x, int y, int z) { @@ -306,17 +377,21 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { if (!chunk.exists(section)) { return; } - Matter matter = chunk.get(section); - if (matter == null) { - return; + synchronized (chunk) { + Matter matter = chunk.get(section); + if (matter == null) { + return; + } + if (hasProtectedHydrology(matter, x, y, z)) { + return; + } + if (matter.hasSlice(PlatformBlockState.class)) { + capturePreObjectOriginal(matter, x, y, z, PlatformBlockState.class); + matter.getSlice(PlatformBlockState.class) + .set(x & 15, y & 15, z & 15, null); + } + clearDeferredPlacement(matter, x, y, z); } - if (hasProtectedHydrology(matter, x, y, z)) { - return; - } - if (matter.hasSlice(PlatformBlockState.class)) { - matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); - } - clearDeferredPlacement(matter, x, y, z); } public T getData(int x, int y, int z, Class type) { @@ -355,6 +430,159 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { return matter.getSlice(type).get(x & 15, y & 15, z & 15); } + public T getPrerequisiteDataIfPresent(int x, int y, int z, Class type) { + Objects.requireNonNull(type, "type"); + if (y < 0 || y >= mantle.getWorldHeight()) { + return null; + } + + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); + int section = y >> 4; + if (chunk == null) { + return null; + } + + synchronized (chunk) { + if (!chunk.exists(section)) { + return null; + } + Matter matter = chunk.get(section); + if (matter == null) { + return null; + } + return prerequisiteValue(matter, x, y, z, type); + } + } + + public PlatformBlockState getPrerequisiteBlock(int x, int y, int z) { + PlatformBlockState block = getPrerequisiteDataIfPresent(x, y, z, PlatformBlockState.class); + return block == null ? AIR : block; + } + + public boolean isPrerequisiteCarved(int x, int y, int z) { + HydrologyCaveCell hydrology = getPrerequisiteDataIfPresent(x, y, z, HydrologyCaveCell.class); + if (hydrology != null) { + return hydrology.carves(); + } + return getPrerequisiteDataIfPresent(x, y, z, MatterCavern.class) != null; + } + + public byte[] getPrerequisiteCarvedColumn(int x, int z, int height) { + int cappedHeight = Math.min(Math.max(height, 0), mantle.getWorldHeight()); + byte[] carvedColumn = new byte[cappedHeight]; + if (cappedHeight <= 0) { + return carvedColumn; + } + + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); + if (chunk == null) { + return carvedColumn; + } + + synchronized (chunk) { + int lastSection = (cappedHeight - 1) >> 4; + for (int section = 0; section <= lastSection; section++) { + if (!chunk.exists(section)) { + continue; + } + Matter matter = chunk.get(section); + if (matter == null) { + continue; + } + MatterSlice journalSlice = matter.hasSlice(PreObjectMatterCell.class) + ? matter.getSlice(PreObjectMatterCell.class) + : null; + MatterSlice cavernSlice = matter.hasSlice(MatterCavern.class) + ? matter.getSlice(MatterCavern.class) + : null; + MatterSlice hydrologySlice = matter.hasSlice(HydrologyCaveCell.class) + ? matter.getSlice(HydrologyCaveCell.class) + : null; + int sectionBaseY = section << 4; + int sectionMaxY = Math.min(cappedHeight, sectionBaseY + 16); + for (int y = sectionBaseY; y < sectionMaxY; y++) { + int localY = y & 15; + HydrologyCaveCell hydrology = hydrologySlice == null + ? null + : hydrologySlice.get(x & 15, localY, z & 15); + if (hydrology != null) { + carvedColumn[y] = hydrology.carves() ? (byte) 1 : 0; + continue; + } + PreObjectMatterCell cell = journalSlice == null + ? null + : journalSlice.get(x & 15, localY, z & 15); + MatterCavern cavern = cell != null && cell.cavernCaptured() + ? cell.cavern() + : cavernSlice == null ? null : cavernSlice.get(x & 15, localY, z & 15); + if (cavern != null) { + carvedColumn[y] = 1; + } + } + } + } + return carvedColumn; + } + + public boolean restorePrerequisiteData(int x, int y, int z, Class type) { + Objects.requireNonNull(type, "type"); + if (y < 0 || y >= mantle.getWorldHeight() || !isPreObjectType(type)) { + return false; + } + + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); + int section = y >> 4; + if (chunk == null) { + return false; + } + + synchronized (chunk) { + if (!chunk.exists(section)) { + return false; + } + Matter matter = chunk.get(section); + PreObjectMatterCell cell = preObjectCell(matter, x, y, z); + if (cell == null || !cell.captures(type)) { + return false; + } + restoreRaw(matter, x, y, z, type, cell.original(type)); + return true; + } + } + + public boolean restorePrerequisiteCell(int x, int y, int z) { + if (y < 0 || y >= mantle.getWorldHeight()) { + return false; + } + + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); + int section = y >> 4; + if (chunk == null) { + return false; + } + + synchronized (chunk) { + if (!chunk.exists(section)) { + return false; + } + Matter matter = chunk.get(section); + PreObjectMatterCell cell = preObjectCell(matter, x, y, z); + if (cell == null) { + return false; + } + if (cell.blockCaptured()) { + restoreRaw(matter, x, y, z, PlatformBlockState.class, cell.block()); + } + if (cell.stringCaptured()) { + restoreRaw(matter, x, y, z, String.class, cell.string()); + } + if (cell.cavernCaptured()) { + restoreRaw(matter, x, y, z, MatterCavern.class, cell.cavern()); + } + return true; + } + } + public void clearData(int x, int y, int z, Class type) { if (y < 0 || y >= mantle.getWorldHeight()) { return; @@ -366,22 +594,25 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { return; } - Matter matter = chunk.get(section); - if (matter == null || !matter.hasSlice(type)) { - return; + synchronized (chunk) { + Matter matter = chunk.get(section); + if (matter == null || !matter.hasSlice(type)) { + return; + } + if ((type == PlatformBlockState.class || type == MatterCavern.class) + && hasProtectedHydrology(matter, x, y, z)) { + return; + } + capturePreObjectOriginal(matter, x, y, z, type); + matter.getSlice(type).set(x & 15, y & 15, z & 15, null); } - if ((type == PlatformBlockState.class || type == MatterCavern.class) - && hasProtectedHydrology(matter, x, y, z)) { - return; - } - matter.getSlice(type).set(x & 15, y & 15, z & 15, null); } private static boolean hasProtectedHydrology(Matter matter, int x, int y, int z) { - if (!matter.hasSlice(RiverCaveHydrology.class)) { + if (!matter.hasSlice(HydrologyCaveCell.class)) { return false; } - RiverCaveHydrology hydrology = matter.getSlice(RiverCaveHydrology.class) + HydrologyCaveCell hydrology = matter.getSlice(HydrologyCaveCell.class) .get(x & 15, y & 15, z & 15); return hydrology != null && hydrology.protectsPlacement(); } @@ -392,6 +623,108 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { } } + private void setCustomBlock(int x, int y, int z, PlatformBlockState baseState, Identifier identifier) { + if (y < 0 || y >= mantle.getWorldHeight()) { + return; + } + if (y == 0 && engineMantle.getEngine().getDimension().isBedrock()) { + return; + } + + MantleChunk chunk = acquireChunk(x >> 4, z >> 4); + if (chunk == null) { + return; + } + + synchronized (chunk) { + Matter matter = chunk.getOrCreate(y >> 4); + if (hasProtectedHydrology(matter, x, y, z)) { + return; + } + MatterSlice blockSlice = matter.slice(PlatformBlockState.class); + MatterSlice identifierSlice = matter.slice(Identifier.class); + capturePreObjectOriginal(matter, x, y, z, PlatformBlockState.class); + blockSlice.set(x & 15, y & 15, z & 15, baseState); + identifierSlice.set(x & 15, y & 15, z & 15, identifier); + } + } + + private void capturePreObjectOriginal(Matter matter, int x, int y, int z, Class type) { + Integer priority = activeComponentPriority.get(); + if (priority == null || priority < OBJECT_COMPONENT_PRIORITY || !isPreObjectType(type)) { + return; + } + + int localX = x & 15; + int localY = y & 15; + int localZ = z & 15; + MatterSlice journal = matter.slice(PreObjectMatterCell.class); + PreObjectMatterCell current = journal.get(localX, localY, localZ); + if (current != null && current.captures(type)) { + return; + } + Object original = rawValue(matter, localX, localY, localZ, type); + PreObjectMatterCell updated; + if (type == PlatformBlockState.class) { + updated = current == null + ? PreObjectMatterCell.block((PlatformBlockState) original) + : current.captureBlock((PlatformBlockState) original); + } else if (type == String.class) { + updated = current == null + ? PreObjectMatterCell.string((String) original) + : current.captureString((String) original); + } else { + updated = current == null + ? PreObjectMatterCell.cavern((MatterCavern) original) + : current.captureCavern((MatterCavern) original); + } + journal.set(localX, localY, localZ, updated); + } + + private static boolean isPreObjectType(Class type) { + return type == PlatformBlockState.class || type == String.class || type == MatterCavern.class; + } + + private static PreObjectMatterCell preObjectCell(Matter matter, int x, int y, int z) { + if (matter == null || !matter.hasSlice(PreObjectMatterCell.class)) { + return null; + } + return matter.getSlice(PreObjectMatterCell.class) + .get(x & 15, y & 15, z & 15); + } + + private static T prerequisiteValue(Matter matter, int x, int y, int z, Class type) { + PreObjectMatterCell cell = preObjectCell(matter, x, y, z); + if (cell != null && cell.captures(type)) { + return cell.original(type); + } + if (!matter.hasSlice(type)) { + return null; + } + return matter.getSlice(type).get(x & 15, y & 15, z & 15); + } + + private static Object rawValue(Matter matter, int x, int y, int z, Class type) { + if (!matter.hasSlice(type)) { + return null; + } + return matter.getSlice(type).get(x, y, z); + } + + @SuppressWarnings("unchecked") + private static void restoreRaw(Matter matter, int x, int y, int z, Class type, Object value) { + MatterSlice slice; + if (value == null) { + if (!matter.hasSlice(type)) { + return; + } + slice = (MatterSlice) matter.getSlice(type); + } else { + slice = (MatterSlice) matter.slice(type); + } + slice.set(x & 15, y & 15, z & 15, value); + } + @ChunkCoordinates public MantleChunk acquireChunk(int cx, int cz) { int index = windowIndex(cx, cz); @@ -456,8 +789,8 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { String placementKey = s.deferredPlacementKey(); PlatformBlockState baseState = s.placementBaseState(); if (s.isCustom() && placementKey != null && baseState != null) { - setData(x, y, z, baseState); - setData(x, y, z, Identifier.fromString(placementKey)); + Identifier identifier = Identifier.fromString(placementKey); + setCustomBlock(x, y, z, baseState, identifier); return; } setData(x, y, z, s); @@ -480,7 +813,7 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { @Override public boolean isCarved(int x, int y, int z) { - RiverCaveHydrology hydrology = getDataIfPresent(x, y, z, RiverCaveHydrology.class); + HydrologyCaveCell hydrology = getDataIfPresent(x, y, z, HydrologyCaveCell.class); if (hydrology != null) { return hydrology.carves(); } @@ -515,8 +848,8 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { MatterSlice cavernSlice = matter.hasSlice(MatterCavern.class) ? matter.getSlice(MatterCavern.class) : null; - MatterSlice hydrologySlice = matter.hasSlice(RiverCaveHydrology.class) - ? matter.getSlice(RiverCaveHydrology.class) + MatterSlice hydrologySlice = matter.hasSlice(HydrologyCaveCell.class) + ? matter.getSlice(HydrologyCaveCell.class) : null; if (cavernSlice == null && hydrologySlice == null) { continue; @@ -524,7 +857,7 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { int sectionBaseY = section << 4; int sectionMaxY = Math.min(cappedHeight, sectionBaseY + 16); for (int y = sectionBaseY; y < sectionMaxY; y++) { - RiverCaveHydrology hydrology = hydrologySlice == null + HydrologyCaveCell hydrology = hydrologySlice == null ? null : hydrologySlice.get(localX, y & 15, localZ); if (hydrology != null) { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java index 4be1cafc0..68b09fe72 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/MatterGenerator.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.util.common.parallel.MultiBurst; import art.arcane.iris.util.project.context.ChunkContext; @@ -43,6 +44,10 @@ public interface MatterGenerator { if (!getEngine().getDimension().isUseMantle()) { return; } + generateMatterWindow(x, z, multicore, context); + } + + private void generateMatterWindow(int x, int z, boolean multicore, ChunkContext context) { MatterGenerationPlan generationPlan = resolveGenerationPlan(x, z, context); MatterPassPlan[] passPlans = generationPlan.passPlans(); @@ -74,10 +79,10 @@ public interface MatterGenerator { int[] componentPassRadii = new int[passComponents.size()]; int enabledComponentCount = 0; for (MantleComponent component : passComponents) { - if (component.isEnabled()) { + if (shouldGenerateComponent(component)) { // A component must cover its own reach plus every later pass' reach, or a // later pass reads this component's data from chunks it never wrote. - int componentReach = component.getRadius() + passPlan.downstreamBlockRadius(); + int componentReach = component.getOutputRadius() + passPlan.downstreamBlockRadius(); componentPassRadii[enabledComponentCount] = componentReach > 0 ? Math.ceilDiv(componentReach, 16) : 0; enabledComponents[enabledComponentCount++] = component; } @@ -89,7 +94,8 @@ public interface MatterGenerator { // A dispatcher thread runs its components inline: dispatching from inside the pool // only trades a worker for a blocked worker. - boolean asyncComponents = multicore && !DISPATCHER.ownsCurrentThread(); + boolean asyncComponents = multicore + && !DISPATCHER.ownsCurrentThread(); if (asyncComponents && outstandingTasks == null) { outstandingTasks = new ArrayList<>(); } @@ -222,12 +228,12 @@ public interface MatterGenerator { int passAccessInputRadius = 0; int passGenerationInputRadius = 0; for (MantleComponent component : pass.components()) { - if (!component.isEnabled()) { + if (!shouldGenerateComponent(component)) { continue; } - int componentRadius = component.getRadius(); - passBlockRadius = Math.max(passBlockRadius, componentRadius); - int componentReach = componentRadius + generationDownstreamBlockRadius; + int componentOutputRadius = component.getOutputRadius(); + passBlockRadius = Math.max(passBlockRadius, componentOutputRadius); + int componentReach = componentOutputRadius + generationDownstreamBlockRadius; int invocationChunkRadius = componentReach > 0 ? Math.ceilDiv(componentReach, 16) : 0; @@ -250,6 +256,15 @@ public interface MatterGenerator { return new MatterGenerationPlan(plans, writerChunkRadius * 2); } + private boolean shouldGenerateComponent(MantleComponent component) { + if (!component.isEnabled()) { + return false; + } + Engine engine = getEngine(); + EnginePlatformHooks hooks = engine.getPlatformHooks(); + return hooks == null || hooks.shouldGenerateMantleComponent(engine, component); + } + private MatterComponentTask runComponentAsync( MantleChunk chunk, MantleComponent component, @@ -438,7 +453,10 @@ public interface MatterGenerator { int chunkZ, ChunkContext context ) { - chunk.raiseFlagSuspend(component.getFlag(), () -> component.generateLayer(writer, chunkX, chunkZ, context)); + chunk.raiseFlagSuspend(component.getFlag(), () -> writer.withComponentPriority( + component.getPriority(), + () -> component.generateLayer(writer, chunkX, chunkZ, context) + )); } /** diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java index 9c3755002..6b0be485b 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransaction.java @@ -23,7 +23,7 @@ import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.TileData; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.collection.KList; @@ -72,8 +72,8 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { discard(); return CommitResult.REJECTED_BOUNDS; } - RiverCaveHydrology hydrology = delegate.getData( - mutation.x(), mutation.y(), mutation.z(), RiverCaveHydrology.class); + HydrologyCaveCell hydrology = delegate.getData( + mutation.x(), mutation.y(), mutation.z(), HydrologyCaveCell.class); if (hydrology != null && hydrology.protectsPlacement()) { discard(); return CommitResult.REJECTED_HYDROLOGY; diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java deleted file mode 100644 index 70c765349..000000000 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/ConfiguredRiverGrottoShape.java +++ /dev/null @@ -1,69 +0,0 @@ -package art.arcane.iris.engine.mantle.components; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.object.IrisGeneratorStyle; -import art.arcane.iris.engine.object.NoiseStyle; -import art.arcane.iris.engine.river.cave.RiverCaveGrottoShape; -import art.arcane.iris.engine.river.cave.RiverCavePlannerSettings; -import art.arcane.iris.engine.river.cave.RiverCaveSource; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.volmlib.util.math.RNG; - -final class ConfiguredRiverGrottoShape implements RiverCaveGrottoShape { - private static final long SHAPE_SALT = 0x3C6EF372FE94F82BL; - private static final long WARP_X_SALT = 0xA54FF53A5F1D36F1L; - private static final long WARP_Y_SALT = 0x510E527FADE682D1L; - private static final long WARP_Z_SALT = 0x9B05688C2B3E6C1FL; - - private final CNG shape; - private final CNG warpX; - private final CNG warpY; - private final CNG warpZ; - private final double warpStrength; - private final double boundaryVariation; - - ConfiguredRiverGrottoShape( - long seed, - IrisData data, - IrisGeneratorStyle shapeStyle, - IrisGeneratorStyle warpStyle, - double warpStrength, - double boundaryVariation - ) { - IrisGeneratorStyle resolvedShape = shapeStyle == null - ? new IrisGeneratorStyle(NoiseStyle.FLAT) - : shapeStyle; - IrisGeneratorStyle resolvedWarp = warpStyle == null - ? new IrisGeneratorStyle(NoiseStyle.FLAT) - : warpStyle; - shape = resolvedShape.createNoCache(new RNG(seed ^ SHAPE_SALT), data); - warpX = resolvedWarp.createNoCache(new RNG(seed ^ WARP_X_SALT), data); - warpY = resolvedWarp.createNoCache(new RNG(seed ^ WARP_Y_SALT), data); - warpZ = resolvedWarp.createNoCache(new RNG(seed ^ WARP_Z_SALT), data); - this.warpStrength = Math.max(0D, warpStrength); - this.boundaryVariation = Math.max(0D, Math.min(0.75D, boundaryVariation)); - } - - @Override - public boolean contains( - RiverCaveSource source, - RiverCavePlannerSettings settings, - int offsetX, - int offsetY, - int offsetZ - ) { - double worldX = source.target().x() + offsetX; - double worldY = source.target().y() + offsetY; - double worldZ = source.target().z() + offsetZ; - double warpedX = offsetX + warpX.fitDouble(-warpStrength, warpStrength, worldX, worldY, worldZ); - double warpedY = offsetY + warpY.fitDouble(-warpStrength, warpStrength, worldY, worldZ, worldX); - double warpedZ = offsetZ + warpZ.fitDouble(-warpStrength, warpStrength, worldZ, worldX, worldY); - double horizontalRadius = settings.grottoHorizontalRadius(); - double verticalRadius = settings.grottoVerticalRadius(); - double normalized = (warpedX * warpedX / (horizontalRadius * horizontalRadius)) - + (warpedY * warpedY / (verticalRadius * verticalRadius)) - + (warpedZ * warpedZ / (horizontalRadius * horizontalRadius)); - double boundary = shape.fitDouble(-boundaryVariation, boundaryVariation, worldX, worldY, worldZ); - return normalized <= 1D + boundary; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java index 44ab58f22..139da6364 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponent.java @@ -472,12 +472,12 @@ public class MantleCarvingComponent extends IrisMantleComponent { } private void prefillProfileFieldSamples(int startX, int startZ, IrisComplex complex, BlendScratch blendScratch) { - fillFieldHeights(complex.getHeightStream(), startX, startZ, blendScratch.fieldSurfaceHeights); - fillFieldHeights(complex.getRiverWaterSurfaceStream(), startX, startZ, blendScratch.fieldFluidHeights); + fillFieldHeights(complex.getNaturalHeightStream(), startX, startZ, blendScratch.fieldSurfaceHeights); + Arrays.fill(blendScratch.fieldFluidHeights, complex.getFluidHeight()); fillFieldFluidPresence(complex, startX, startZ, blendScratch.fieldSurfaceHeights, blendScratch.fieldFluidHeights, blendScratch.fieldHasFluid); fillFieldObjects(complex.getRegionStream(), startX, startZ, blendScratch.fieldRegions); - fillFieldObjects(complex.getTrueBiomeStream(), startX, startZ, blendScratch.fieldSurfaceBiomes); + fillFieldObjects(complex.getNaturalTrueBiomeStream(), startX, startZ, blendScratch.fieldSurfaceBiomes); fillFieldObjects(complex.getCaveBiomeStream(), startX, startZ, blendScratch.fieldCaveBiomes); } @@ -511,7 +511,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { int worldX = startX + fieldX; for (int fieldZ = 0; fieldZ < FIELD_SIZE; fieldZ++) { int fieldIndex = (fieldX * FIELD_SIZE) + fieldZ; - target[fieldIndex] = B.isFluid(complex.resolveSurfaceFluid(worldX, startZ + fieldZ)) + target[fieldIndex] = B.isFluid(complex.getFluidStream().get(worldX, startZ + fieldZ)) && Math.round(surfaceHeights[fieldIndex]) < Math.round(fluidHeights[fieldIndex]); } } @@ -545,36 +545,26 @@ public class MantleCarvingComponent extends IrisMantleComponent { return 1; } - private int[] prepareChunkSurfaceHeights(int chunkX, int chunkZ, ChunkContext context, int[] scratch) { + static int[] prepareChunkSurfaceHeights(int chunkX, int chunkZ, ChunkContext context, int[] scratch) { int[] surfaceHeights = scratch; int baseX = PowerOfTwoCoordinates.chunkToBlock(chunkX); int baseZ = PowerOfTwoCoordinates.chunkToBlock(chunkZ); - boolean useContextHeight = context != null - && context.getHeight() != null - && context.getX() == baseX - && context.getZ() == baseZ; + ProceduralStream naturalHeight = context.getComplex().getNaturalHeightStream(); double[] cachedChunkHeights = null; - if (!useContextHeight && context != null) { - ProceduralStream heightStream = context.getComplex().getHeightStream(); - if (heightStream instanceof ChunkFillableDoubleStream2D cachedHeightStream) { - cachedChunkHeights = BLEND_SCRATCH.get().chunkSurfaceHeightSamples; - cachedHeightStream.fillChunkDoubles(baseX, baseZ, cachedChunkHeights); - } + if (naturalHeight instanceof ChunkFillableDoubleStream2D cachedHeightStream) { + cachedChunkHeights = BLEND_SCRATCH.get().chunkSurfaceHeightSamples; + cachedHeightStream.fillChunkDoubles(baseX, baseZ, cachedChunkHeights); } for (int localX = 0; localX < CHUNK_SIZE; localX++) { int worldX = baseX + localX; for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) { int worldZ = baseZ + localZ; int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); - if (useContextHeight) { - surfaceHeights[columnIndex] = context.getRoundedHeight(localX, localZ); - continue; - } if (cachedChunkHeights != null) { surfaceHeights[columnIndex] = (int) Math.round(cachedChunkHeights[(localZ << 4) + localX]); continue; } - surfaceHeights[columnIndex] = getEngineMantle().getEngine().getHeight(worldX, worldZ); + surfaceHeights[columnIndex] = (int) Math.round(naturalHeight.getDouble(worldX, worldZ)); } } return surfaceHeights; diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelView.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelView.java new file mode 100644 index 000000000..d8254bcd0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelView.java @@ -0,0 +1,265 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.RiverFootprint; +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mantle.MantleComponent; +import art.arcane.iris.engine.mantle.MantleWriter; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.volmlib.util.function.Function2; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongOpenHashSet; + +import java.util.Objects; +import java.util.function.BiConsumer; + +public final class MantleHydrologyCaveVoxelView implements CaveVoxelView { + private static final int CLOSED_COLUMN = Integer.MAX_VALUE; + private static final int CACHE_MISS = Integer.MIN_VALUE; + + private final Mantle mantle; + private final int worldHeight; + private final Function2 surfaceHeight; + private final BiConsumer chunkLoader; + private final LongOpenHashSet loadedChunks; + private final Long2IntOpenHashMap openFloorCache; + private final Long2IntOpenHashMap surfaceHeightCache; + + public MantleHydrologyCaveVoxelView( + Engine engine, + IrisComplex complex, + RiverFootprint footprint + ) { + this( + Objects.requireNonNull(engine).getMantle(), + Objects.requireNonNull(complex), + Objects.requireNonNull(footprint) + ); + } + + public MantleHydrologyCaveVoxelView( + Engine engine, + IrisComplex complex, + HydrologyCaveVoxelViewFactory.PlannedSurface plannedSurface + ) { + this( + Objects.requireNonNull(engine).getMantle(), + Objects.requireNonNull(complex), + Objects.requireNonNull(plannedSurface) + ); + } + + private MantleHydrologyCaveVoxelView( + EngineMantle engineMantle, + IrisComplex complex, + RiverFootprint footprint + ) { + this( + engineMantle.getMantle(), + engineMantle.getMantle().getWorldHeight(), + (x, z) -> plannedSurfaceHeight(complex, footprint, x, z), + (chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ) + ); + } + + private MantleHydrologyCaveVoxelView( + EngineMantle engineMantle, + IrisComplex complex, + HydrologyCaveVoxelViewFactory.PlannedSurface plannedSurface + ) { + this( + engineMantle.getMantle(), + engineMantle.getMantle().getWorldHeight(), + (x, z) -> plannedSurface.resolve( + x, + z, + (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z)) + ), + (chunkX, chunkZ) -> generateCarvingInput(engineMantle, complex, chunkX, chunkZ) + ); + } + + MantleHydrologyCaveVoxelView( + Mantle mantle, + int worldHeight, + Function2 surfaceHeight, + BiConsumer chunkLoader + ) { + this.mantle = Objects.requireNonNull(mantle); + if (worldHeight < 3) { + throw new IllegalArgumentException("worldHeight must be at least three"); + } + this.worldHeight = worldHeight; + this.surfaceHeight = Objects.requireNonNull(surfaceHeight); + this.chunkLoader = Objects.requireNonNull(chunkLoader); + this.loadedChunks = new LongOpenHashSet(); + this.openFloorCache = new Long2IntOpenHashMap(); + this.openFloorCache.defaultReturnValue(CACHE_MISS); + this.surfaceHeightCache = new Long2IntOpenHashMap(); + this.surfaceHeightCache.defaultReturnValue(CACHE_MISS); + } + + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > 0 && position.y() < worldHeight - 1; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + Objects.requireNonNull(position); + MatterCavern cavern = dataIfPresent(position, MatterCavern.class); + if (cavern != null) { + if (cavern.isLava()) { + return CaveVoxel.LAVA; + } + if (cavern.getLiquid() == 1) { + return CaveVoxel.COMPATIBLE_FLUID; + } + return CaveVoxel.CAVE_AIR; + } + PlatformBlockState block = dataIfPresent(position, PlatformBlockState.class); + if (block != null) { + if (!block.isFluid()) { + return CaveVoxel.SOLID; + } + return IrisProceduralBlocks.materialKey(block).endsWith(":lava") + ? CaveVoxel.LAVA + : CaveVoxel.INCOMPATIBLE_FLUID; + } + return position.y() > surfaceY(position.x(), position.z()) + ? CaveVoxel.CAVE_AIR + : CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + if (!isInWorld(position) || voxelAt(position) == CaveVoxel.SOLID) { + return false; + } + if (isAboveTerrainSurface(position)) { + return true; + } + long key = RiverFootprint.pack(position.x(), position.z()); + int openFloor = openFloorCache.get(key); + if (openFloor == CACHE_MISS) { + openFloor = resolveOpenFloor(position.x(), position.z()); + openFloorCache.put(key, openFloor); + } + return openFloor != CLOSED_COLUMN && position.y() >= openFloor; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return position.y() > surfaceY(position.x(), position.z()); + } + + @Override + public boolean hasAboveTerrainSurface(int x, int z, int minimumY, int maximumY) { + int firstY = Math.max(minimumY, 1); + int lastY = Math.min(maximumY, worldHeight - 2); + return firstY <= lastY && lastY > surfaceY(x, z); + } + + private int resolveOpenFloor(int x, int z) { + int top = surfaceY(x, z); + if (voxelAt(new CavePosition(x, top, z)) == CaveVoxel.SOLID) { + return CLOSED_COLUMN; + } + int y = top; + while (y > 0 && voxelAt(new CavePosition(x, y - 1, z)) != CaveVoxel.SOLID) { + y--; + } + return y; + } + + private int surfaceY(int x, int z) { + long key = RiverFootprint.pack(x, z); + int cached = surfaceHeightCache.get(key); + if (cached != CACHE_MISS) { + return cached; + } + int resolved = Math.max(1, Math.min(worldHeight - 2, surfaceHeight.apply(x, z))); + surfaceHeightCache.put(key, resolved); + return resolved; + } + + private T dataIfPresent(CavePosition position, Class type) { + int chunkX = position.x() >> 4; + int chunkZ = position.z() >> 4; + long chunkKey = Mantle.key(chunkX, chunkZ); + if (loadedChunks.add(chunkKey)) { + chunkLoader.accept(chunkX, chunkZ); + } + return mantle.get(position.x(), position.y(), position.z(), type); + } + + private static int plannedSurfaceHeight( + IrisComplex complex, + RiverFootprint footprint, + int x, + int z + ) { + HydrologyColumnSample sample = footprint.sample(x, z).orElse(null); + return sample == null + ? (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z)) + : sample.terrainHeight(); + } + + static void generateCarvingInput( + EngineMantle engineMantle, + IrisComplex complex, + int chunkX, + int chunkZ + ) { + MantleComponent carving = engineMantle.getRegisteredComponents().get(ReservedFlag.CARVED); + if (carving == null || !carving.isEnabled()) { + throw new IllegalStateException("Hydrology containment requires the carving component"); + } + if (!requiresCarvingInput(engineMantle.getMantle(), chunkX, chunkZ)) { + return; + } + ChunkContext context = new ChunkContext( + chunkX << 4, + chunkZ << 4, + complex, + false, + ChunkContext.PrefillPlan.NONE, + null + ); + try (MantleWriter writer = new MantleWriter( + engineMantle, + engineMantle.getMantle(), + chunkX, + chunkZ, + 0, + false + )) { + MantleChunk chunk = writer.acquireChunk(chunkX, chunkZ); + if (chunk == null) { + throw new IllegalStateException("Hydrology containment could not acquire carving input at " + + chunkX + "," + chunkZ); + } + chunk.raiseFlagSuspend( + ReservedFlag.CARVED, + () -> carving.generateLayer(writer, chunkX, chunkZ, context) + ); + } + } + + static boolean requiresCarvingInput(Mantle mantle, int chunkX, int chunkZ) { + return !mantle.hasFlag(chunkX, chunkZ, ReservedFlag.CARVED); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java new file mode 100644 index 000000000..1d7a810ce --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java @@ -0,0 +1,850 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.mantle.ComponentFlag; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mantle.IrisMantleComponent; +import art.arcane.iris.engine.mantle.MantleWriter; +import art.arcane.iris.engine.object.IrisDeepFluidConfig; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisHydrology; +import art.arcane.iris.engine.object.IrisProceduralBlocks; +import art.arcane.iris.engine.object.IrisRiverHydrology; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.volmlib.util.mantle.flag.MantleFlag; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; +import art.arcane.volmlib.util.matter.slices.UpdateMatter; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +@ComponentFlag(ReservedFlag.RIVER_HYDROLOGY) +public final class MantleHydrologyComponent extends IrisMantleComponent { + private static final int CHUNK_SIZE = 16; + private static final int SAMPLE_HALO = 1; + private static final int[][] NEIGHBORS = { + {1, 0, 0}, {-1, 0, 0}, + {0, 1, 0}, {0, -1, 0}, + {0, 0, 1}, {0, 0, -1} + }; + private static final Comparator POSITION_ORDER = Comparator + .comparingInt(CavePosition::x) + .thenComparingInt(CavePosition::y) + .thenComparingInt(CavePosition::z); + private static final MantleFlag[] PREREQUISITES = {ReservedFlag.CARVED}; + static final int PRIORITY = 1; + + public MantleHydrologyComponent(EngineMantle engineMantle) { + super(engineMantle, ReservedFlag.RIVER_HYDROLOGY, PRIORITY); + } + + @Override + public MantleFlag[] getPrerequisiteFlags() { + return PREREQUISITES; + } + + @Override + public boolean isInputGenerationLazy() { + return true; + } + + @Override + public void generateLayer(MantleWriter writer, int chunkX, int chunkZ, ChunkContext context) { + IrisHydrologyRuntime runtime = context.getComplex().getHydrologyRuntime(); + if (runtime == null) { + return; + } + int minimumX = chunkX << 4; + int minimumZ = chunkZ << 4; + int maximumX = minimumX + CHUNK_SIZE; + int maximumZ = minimumZ + CHUNK_SIZE; + List cavePlans = runtime.cavePlansIn( + minimumX - SAMPLE_HALO, + minimumZ - SAMPLE_HALO, + maximumX + SAMPLE_HALO, + maximumZ + SAMPLE_HALO + ); + CaveVoxelView caveView = new MantleHydrologyCaveVoxelView( + writer.getMantle(), + writer.getMantle().getWorldHeight(), + (x, z) -> runtime.sample(x, z) + .map(HydrologyColumnSample::terrainHeight) + .orElseGet(() -> (int) Math.round( + context.getComplex().getNaturalHeightStream().getDouble(x, z) + )), + (inputChunkX, inputChunkZ) -> MantleHydrologyCaveVoxelView.generateCarvingInput( + getEngineMantle(), + context.getComplex(), + inputChunkX, + inputChunkZ + ) + ); + Publication publication = compilePublication( + chunkX, + chunkZ, + writer.getMantle().getWorldHeight(), + runtime::sample, + cavePlans, + caveView + ); + publish(writer, context, publication); + } + + @Override + protected int computeRadius() { + return 0; + } + + public static boolean isEnabledFor(IrisDimension dimension) { + if (!dimension.isUseMantle() + || !dimension.isCarvingEnabled() + || dimension.getDisabledComponents().contains(ReservedFlag.CARVED) + || dimension.getDisabledComponents().contains(ReservedFlag.RIVER_HYDROLOGY)) { + return false; + } + IrisHydrology hydrology = dimension.getHydrology(); + if (hydrology == null) { + return false; + } + IrisRiverHydrology rivers = hydrology.getRivers(); + if (rivers != null && rivers.isEnabled()) { + return true; + } + for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) { + if (deepFluid != null + && deepFluid.getDensity() > 0D + && (deepFluid.isContainedPools() || deepFluid.isShortChannels())) { + return true; + } + } + return false; + } + + static Publication compilePublication( + int chunkX, + int chunkZ, + int worldHeight, + ColumnSampler sampler, + Collection cavePlans, + CaveVoxelView caveView + ) { + if (worldHeight < 2) { + throw new IllegalArgumentException("worldHeight must be at least two"); + } + Objects.requireNonNull(sampler); + Objects.requireNonNull(cavePlans); + Objects.requireNonNull(caveView); + int minimumX = chunkX << 4; + int minimumZ = chunkZ << 4; + int maximumX = minimumX + CHUNK_SIZE - 1; + int maximumZ = minimumZ + CHUNK_SIZE - 1; + Map samples = sampleColumns( + minimumX - SAMPLE_HALO, + minimumZ - SAMPLE_HALO, + maximumX + SAMPLE_HALO, + maximumZ + SAMPLE_HALO, + sampler + ); + if (samples.isEmpty()) { + return Publication.empty(); + } + if (!preconditionsHold( + cavePlans, + caveView, + samples, + minimumX, + minimumZ, + maximumX, + maximumZ + )) { + return Publication.empty(); + } + Map cavePlansByCourseId = indexCavePlans(cavePlans); + + Map caveCells = new HashMap<>(); + Map surfaceCells = new HashMap<>(); + Map surfaceGuards = new HashMap<>(); + for (HydrologyColumnSample sample : samples.values()) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.oceanApron() || !layer.terrainOwned()) { + continue; + } + validateLayer(layer, worldHeight); + if (layer.channel() && isCaveLayer(layer)) { + addCaveVolume(sample, layer, cavePlansByCourseId, caveCells); + } + } + addSurfaceVolume(sample, surfaceCells); + addSurfaceBedGuards(sample, surfaceGuards); + } + + LinkedHashMap publishedCaveCells = new LinkedHashMap<>(); + LinkedHashMap surfaceWrites = new LinkedHashMap<>(); + HashSet fluidUpdates = new HashSet<>(); + copyOwnedCells(caveCells, minimumX, minimumZ, maximumX, maximumZ, publishedCaveCells, fluidUpdates); + copySurfaceGuards( + surfaceGuards, + minimumX, + minimumZ, + maximumX, + maximumZ, + publishedCaveCells + ); + copySurfaceWrites(surfaceCells, minimumX, minimumZ, maximumX, maximumZ, surfaceWrites, fluidUpdates); + addAcceptedGuards( + caveCells, + cavePlans, + publishedCaveCells, + minimumX, + minimumZ, + maximumX, + maximumZ + ); + suppressSurfaceWritesCoveredByCaves(publishedCaveCells, surfaceWrites, caveCells, samples); + return new Publication(publishedCaveCells, surfaceWrites, fluidUpdates); + } + + private static void suppressSurfaceWritesCoveredByCaves( + Map caveCells, + Map surfaceWrites, + Map plannedCaveCells, + Map samples + ) { + Iterator> iterator = caveCells.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + CavePosition position = entry.getKey(); + SurfaceFluidWrite surfaceWrite = surfaceWrites.get(position); + if (surfaceWrite == null) { + continue; + } + HydrologyCaveCell caveCell = entry.getValue(); + if (!caveCell.fluidProfileKey().equals(surfaceWrite.profileKey())) { + throw new IllegalStateException("Accepted surface and cave hydrology disagree at " + position + + ": surface=" + surfaceWrite.profileKey() + "/" + surfaceWrite.action() + + ", cave=" + caveCell.fluidProfileKey() + "/" + caveCell.action()); + } + if (caveCell.action() == HydrologyCaveAction.SEAL_GUARD) { + iterator.remove(); + continue; + } + if (caveCell.action() != surfaceWrite.action()) { + PlannedCell plannedCaveCell = plannedCaveCells.get(position); + if (plannedCaveCell == null) { + throw new IllegalStateException("Accepted cave hydrology has no publication owner at " + + position + "."); + } + caveCells.put(position, new HydrologyCaveCell( + surfaceWrite.action(), + caveCell.fluidProfileKey(), + floodedBiomeKeyAt(position, plannedCaveCell, samples) + )); + } + surfaceWrites.remove(position); + } + } + + private static String floodedBiomeKeyAt( + CavePosition position, + PlannedCell plannedCaveCell, + Map samples + ) { + HydrologyColumnSample sample = samples.get(pack(position.x(), position.z())); + if (sample != null) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == plannedCaveCell.courseId() + && isCaveLayer(layer) + && position.y() > layer.bedY() + && position.y() <= layer.ceilingY()) { + return layer.floodedCaveBiomeKey(); + } + } + } + throw new IllegalStateException("Accepted cave hydrology has no flooded biome owner at " + position + "."); + } + + private static Map sampleColumns( + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + ColumnSampler sampler + ) { + LinkedHashMap samples = new LinkedHashMap<>(); + for (int x = minimumX; x <= maximumX; x++) { + for (int z = minimumZ; z <= maximumZ; z++) { + Optional sampled = Objects.requireNonNull(sampler.sample(x, z)); + if (sampled.isPresent()) { + HydrologyColumnSample sample = sampled.get(); + if (sample.x() != x || sample.z() != z) { + throw new IllegalStateException("Hydrology sample coordinates do not match the requested column"); + } + samples.put(pack(x, z), sample); + } + } + } + return samples; + } + + private static void addCaveVolume( + HydrologyColumnSample sample, + HydrologyColumnLayer layer, + Map cavePlansByCourseId, + Map caveCells + ) { + HydrologyCavePlan plan = cavePlansByCourseId.get(layer.feature().courseId()); + if (plan == null) { + throw new IllegalStateException("Accepted cave layer has no containment plan: " + + layer.feature().courseId()); + } + for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) { + CavePosition position = new CavePosition(sample.x(), y, sample.z()); + HydrologyCaveAction action = plan.actions().get(position); + if (action == null || action == HydrologyCaveAction.SEAL_GUARD) { + throw new IllegalStateException("Accepted cave layer is absent from its containment plan at " + + position + "."); + } + String biomeKey = action == HydrologyCaveAction.DRY_AIR + ? layer.dryBiomeKey() + : layer.floodedCaveBiomeKey(); + PlannedCell candidate = new PlannedCell( + new HydrologyCaveCell(action, layer.profileKey(), biomeKey), + layer.feature().courseId() + ); + mergeCaveCell(caveCells, position, candidate); + } + } + + private static void addSurfaceVolume( + HydrologyColumnSample sample, + Map surfaceCells + ) { + int minimumY = Integer.MAX_VALUE; + int maximumY = Integer.MIN_VALUE; + for (HydrologyColumnLayer layer : sample.layers()) { + if (!layer.publishesSurfaceFluid()) { + continue; + } + minimumY = Math.min(minimumY, layer.bedY() + 1); + maximumY = Math.max(maximumY, layer.fluidHeadY()); + } + if (minimumY == Integer.MAX_VALUE) { + return; + } + for (int y = minimumY; y <= maximumY; y++) { + HydrologyColumnSample.SurfacePublicationCell surfaceCell = sample.surfacePublicationCellAt(y) + .orElse(null); + if (surfaceCell == null) { + continue; + } + HydrologyColumnLayer layer = surfaceCell.layer(); + CavePosition position = new CavePosition(sample.x(), y, sample.z()); + PlannedCell candidate = new PlannedCell( + new HydrologyCaveCell(surfaceCell.action(), layer.profileKey(), ""), + layer.feature().courseId() + ); + surfaceCells.put(position, candidate); + } + } + + private static void addSurfaceBedGuards( + HydrologyColumnSample sample, + Map surfaceGuards + ) { + HydrologyColumnLayer layer = sample.primarySurfaceLayer().orElse(null); + if (layer == null || layer.oceanApron() || !layer.terrainOwned()) { + return; + } + int minimumY = Math.max(1, layer.bedY() - 2); + for (int y = minimumY; y <= layer.bedY(); y++) { + CavePosition position = new CavePosition(sample.x(), y, sample.z()); + PlannedCell guard = new PlannedCell( + new HydrologyCaveCell(HydrologyCaveAction.SEAL_GUARD, layer.profileKey(), ""), + layer.feature().courseId() + ); + mergeCell(surfaceGuards, position, guard); + } + } + + private static void mergeCell( + Map cells, + CavePosition position, + PlannedCell candidate + ) { + PlannedCell existing = cells.get(position); + if (existing == null || actionPriority(candidate.cell().action()) < actionPriority(existing.cell().action())) { + cells.put(position, candidate); + } + } + + private static void mergeCaveCell( + Map cells, + CavePosition position, + PlannedCell candidate + ) { + PlannedCell existing = cells.get(position); + if (existing != null) { + assertCompatibleCaveOverlap(position, existing, candidate); + } + mergeCell(cells, position, candidate); + } + + private static void assertCompatibleCaveOverlap( + CavePosition position, + PlannedCell existing, + PlannedCell candidate + ) { + long existingCourseId = existing.courseId(); + long candidateCourseId = candidate.courseId(); + if (existing.cell().action() == candidate.cell().action() + && existing.cell().fluidProfileKey().equals(candidate.cell().fluidProfileKey())) { + return; + } + throw new IllegalStateException("Incompatible accepted cave courses " + + existingCourseId + " and " + candidateCourseId + " overlap at " + position + + ": " + existing.cell().fluidProfileKey() + "/" + existing.cell().action() + + " versus " + candidate.cell().fluidProfileKey() + "/" + candidate.cell().action()); + } + + private static void copyOwnedCells( + Map planned, + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + Map published, + Set fluidUpdates + ) { + for (Map.Entry entry : sortedEntries(planned)) { + CavePosition position = entry.getKey(); + if (!inside(position, minimumX, minimumZ, maximumX, maximumZ)) { + continue; + } + HydrologyCaveCell cell = entry.getValue().cell(); + published.put(position, cell); + } + } + + private static void copySurfaceWrites( + Map planned, + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + Map published, + Set fluidUpdates + ) { + for (Map.Entry entry : sortedEntries(planned)) { + CavePosition position = entry.getKey(); + if (!inside(position, minimumX, minimumZ, maximumX, maximumZ)) { + continue; + } + HydrologyCaveCell cell = entry.getValue().cell(); + published.put(position, new SurfaceFluidWrite(cell.fluidProfileKey(), cell.action())); + } + for (Map.Entry entry : published.entrySet()) { + SurfaceFluidWrite write = entry.getValue(); + if (write.action() != HydrologyCaveAction.WET_SOURCE) { + continue; + } + CavePosition position = entry.getKey(); + PlannedCell below = planned.get(new CavePosition(position.x(), position.y() - 1, position.z())); + if (below != null + && below.cell().action() == HydrologyCaveAction.FALLING_FLUID + && below.cell().fluidProfileKey().equals(write.profileKey())) { + fluidUpdates.add(position); + continue; + } + if (hasLowerHorizontalFluidFace(planned, position, write.profileKey())) { + fluidUpdates.add(position); + } + } + } + + private static boolean hasLowerHorizontalFluidFace( + Map planned, + CavePosition position, + String profileKey + ) { + int[] offsetsX = {-1, 1, 0, 0}; + int[] offsetsZ = {0, 0, -1, 1}; + for (int index = 0; index < offsetsX.length; index++) { + CavePosition adjacent = new CavePosition( + position.x() + offsetsX[index], + position.y(), + position.z() + offsetsZ[index] + ); + PlannedCell level = planned.get(adjacent); + if (level != null && level.cell().fluidProfileKey().equals(profileKey)) { + continue; + } + PlannedCell lower = planned.get(new CavePosition(adjacent.x(), adjacent.y() - 1, adjacent.z())); + if (lower != null + && lower.cell().action() == HydrologyCaveAction.WET_SOURCE + && lower.cell().fluidProfileKey().equals(profileKey)) { + return true; + } + } + return false; + } + + private static void copySurfaceGuards( + Map planned, + int minimumX, + int minimumZ, + int maximumX, + int maximumZ, + Map published + ) { + for (Map.Entry entry : sortedEntries(planned)) { + CavePosition position = entry.getKey(); + if (!inside(position, minimumX, minimumZ, maximumX, maximumZ)) { + continue; + } + published.put(position, entry.getValue().cell()); + } + } + + private static void addAcceptedGuards( + Map caveCells, + Collection cavePlans, + Map published, + int minimumX, + int minimumZ, + int maximumX, + int maximumZ + ) { + HashMap guardCells = new HashMap<>(); + ArrayList orderedPlans = new ArrayList<>(cavePlans); + orderedPlans.sort(Comparator.comparingLong((HydrologyCavePlan plan) -> plan.source().sourceId())); + for (HydrologyCavePlan plan : orderedPlans) { + ArrayList guardPositions = new ArrayList<>(); + plan.forEachActionIn(minimumX, minimumZ, maximumX + 1, maximumZ + 1, + (CavePosition position, HydrologyCaveAction action) -> { + if (action == HydrologyCaveAction.SEAL_GUARD) { + guardPositions.add(position); + } + }); + guardPositions.sort(POSITION_ORDER); + for (CavePosition position : guardPositions) { + PlannedCell owner = guardOwner(position, plan, caveCells); + if (owner == null) { + throw new IllegalStateException("Accepted containment guard has no adjacent cave volume at " + + position); + } + PlannedCell candidate = new PlannedCell( + new HydrologyCaveCell( + HydrologyCaveAction.SEAL_GUARD, + owner.cell().fluidProfileKey(), + "" + ), + plan.source().sourceId() + ); + PlannedCell caveVolume = caveCells.get(position); + if (caveVolume != null) { + assertCompatibleCaveOverlap(position, caveVolume, candidate); + } + mergeCaveCell(guardCells, position, candidate); + } + } + for (Map.Entry entry : sortedEntries(guardCells)) { + published.putIfAbsent(entry.getKey(), entry.getValue().cell()); + } + } + + private static PlannedCell guardOwner( + CavePosition guard, + HydrologyCavePlan plan, + Map caveCells + ) { + for (int[] offset : NEIGHBORS) { + CavePosition ownerPosition = guard.offset(-offset[0], -offset[1], -offset[2]); + PlannedCell owner = caveCells.get(ownerPosition); + HydrologyCaveAction plannedAction = plan.actions().get(ownerPosition); + if (owner != null + && plannedAction != null + && plannedAction != HydrologyCaveAction.SEAL_GUARD + && owner.cell().action() == plannedAction) { + return owner; + } + } + return null; + } + + private static boolean preconditionsHold( + Collection cavePlans, + CaveVoxelView caveView, + Map samples, + int minimumX, + int minimumZ, + int maximumX, + int maximumZ + ) { + for (HydrologyCavePlan plan : cavePlans) { + if (!plan.allPreconditionsIn( + minimumX, + minimumZ, + maximumX + 1, + maximumZ + 1, + (CavePosition position, CaveVoxelPrecondition expected) -> preconditionMatches( + plan, + position, + expected, + caveView, + samples.get(pack(position.x(), position.z())) + ))) { + return false; + } + } + return true; + } + + private static boolean preconditionMatches( + HydrologyCavePlan plan, + CavePosition position, + CaveVoxelPrecondition expected, + CaveVoxelView caveView, + HydrologyColumnSample sample + ) { + if (expected.voxel() == CaveVoxel.UNCONDITIONAL) { + return true; + } + CaveVoxel actual = caveView.voxelAt(position); + boolean actualOpen = caveView.isOpenToSurface(position); + if (actual == expected.voxel() && actualOpen == expected.openToSurface()) { + return true; + } + HydrologyCaveAction action = plan.actions().get(position); + if (expected.voxel() == CaveVoxel.CAVE_AIR + && expected.openToSurface() + && actual == CaveVoxel.CAVE_AIR + && !actualOpen + && action != HydrologyCaveAction.DRY_AIR) { + return true; + } + if (expected.voxel() != CaveVoxel.CAVE_AIR + || !expected.openToSurface() + || actual != CaveVoxel.SOLID + || actualOpen) { + return false; + } + if (action == null || action == HydrologyCaveAction.SEAL_GUARD) { + return true; + } + if (action != HydrologyCaveAction.WET_SOURCE + && action != HydrologyCaveAction.FALLING_FLUID) { + return false; + } + if (sample == null) { + return false; + } + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == plan.source().sourceId() + && layer.feature().type().isSurface() + && !layer.oceanApron() + && layer.channel() + && layer.terrainOwned() + && layer.fluidOwned() + && layer.connectedFluid() + && position.y() > layer.bedY() + && position.y() <= layer.fluidHeadY()) { + return true; + } + } + return false; + } + + private static Map indexCavePlans( + Collection cavePlans + ) { + LinkedHashMap indexed = new LinkedHashMap<>(); + for (HydrologyCavePlan plan : cavePlans) { + if (!plan.accepted()) { + throw new IllegalArgumentException("Mantle publication cannot consume rejected cave plans"); + } + long courseId = plan.source().sourceId(); + HydrologyCavePlan existing = indexed.putIfAbsent(courseId, plan); + if (existing != null && !existing.equals(plan)) { + throw new IllegalArgumentException("Conflicting cave plans for course " + courseId); + } + } + return indexed; + } + + static void publish( + MantleWriter writer, + ChunkContext context, + Publication publication + ) { + LinkedHashMap resolvedSurfaceWrites = new LinkedHashMap<>(); + for (Map.Entry entry : sortedEntries(publication.surfaceWrites())) { + CavePosition position = entry.getKey(); + SurfaceFluidWrite write = entry.getValue(); + PlatformBlockState fluid = Objects.requireNonNull(context.getComplex().resolveHydrologyFluid( + write.profileKey(), + position.x(), + position.z() + )); + PlatformBlockState state = write.action() == HydrologyCaveAction.FALLING_FLUID + ? fallingFluidState(fluid) + : fluid; + resolvedSurfaceWrites.put(position, state); + } + HashSet resolvedCaveFluids = new HashSet<>(); + for (Map.Entry entry : sortedEntries(publication.caveCells())) { + HydrologyCaveCell cell = entry.getValue(); + CavePosition position = entry.getKey(); + FluidCoordinate coordinate = new FluidCoordinate( + cell.fluidProfileKey(), + position.x(), + position.z() + ); + if (cell.isWet() && resolvedCaveFluids.add(coordinate)) { + Objects.requireNonNull(context.getComplex().resolveHydrologyFluid( + coordinate.profileKey(), + coordinate.x(), + coordinate.z() + )); + } + } + for (Map.Entry entry : resolvedSurfaceWrites.entrySet()) { + CavePosition position = entry.getKey(); + writer.setData(position.x(), position.y(), position.z(), entry.getValue()); + } + for (CavePosition position : sortedPositions(publication.fluidUpdates())) { + writer.setData(position.x(), position.y(), position.z(), UpdateMatter.ON); + } + for (Map.Entry entry : sortedEntries(publication.caveCells())) { + CavePosition position = entry.getKey(); + writer.setData(position.x(), position.y(), position.z(), entry.getValue()); + } + } + + private static void validateLayer(HydrologyColumnLayer layer, int worldHeight) { + if (layer.bedY() < 0 + || layer.fluidHeadY() < layer.bedY() + || layer.ceilingY() < layer.fluidHeadY() + || layer.ceilingY() >= worldHeight) { + throw new IllegalStateException("Accepted hydrology layer exceeds the mantle bounds: " + layer); + } + } + + private static boolean isCaveLayer(HydrologyColumnLayer layer) { + return layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid(); + } + + private static HydrologyCaveAction actionAt(HydrologyColumnLayer layer, int y) { + if (y > layer.fluidHeadY()) { + return HydrologyCaveAction.DRY_AIR; + } + if (layer.fallingFluid() && y < layer.fluidHeadY()) { + return HydrologyCaveAction.FALLING_FLUID; + } + return HydrologyCaveAction.WET_SOURCE; + } + + private static int actionPriority(HydrologyCaveAction action) { + return switch (action) { + case WET_SOURCE -> 0; + case FALLING_FLUID -> 1; + case DRY_AIR -> 2; + case SEAL_GUARD -> 3; + }; + } + + private static PlatformBlockState fallingFluidState(PlatformBlockState fluid) { + if (!IrisProceduralBlocks.hasProperty(fluid, "level") + || "8".equals(IrisProceduralBlocks.propertyValue(fluid, "level"))) { + return fluid; + } + return fluid.withProperty("level", "8"); + } + + private static boolean inside( + CavePosition position, + int minimumX, + int minimumZ, + int maximumX, + int maximumZ + ) { + return position.x() >= minimumX && position.x() <= maximumX + && position.z() >= minimumZ && position.z() <= maximumZ; + } + + private static long pack(int x, int z) { + return ((long) x << 32) ^ (z & 0xffffffffL); + } + + private static List> sortedEntries(Map values) { + ArrayList> entries = new ArrayList<>(values.entrySet()); + entries.sort(Map.Entry.comparingByKey(POSITION_ORDER)); + return entries; + } + + private static List sortedPositions(Set values) { + ArrayList positions = new ArrayList<>(values); + positions.sort(POSITION_ORDER); + return positions; + } + + @FunctionalInterface + interface ColumnSampler { + Optional sample(int x, int z); + } + + record Publication( + Map caveCells, + Map surfaceWrites, + Set fluidUpdates + ) { + Publication { + caveCells = Map.copyOf(Objects.requireNonNull(caveCells)); + surfaceWrites = Map.copyOf(Objects.requireNonNull(surfaceWrites)); + fluidUpdates = Set.copyOf(Objects.requireNonNull(fluidUpdates)); + } + + static Publication empty() { + return new Publication(Map.of(), Map.of(), Set.of()); + } + } + + record SurfaceFluidWrite(String profileKey, HydrologyCaveAction action) { + SurfaceFluidWrite { + if (profileKey == null || profileKey.isBlank()) { + throw new IllegalArgumentException("profileKey must not be blank"); + } + profileKey = profileKey.trim(); + if (action != HydrologyCaveAction.WET_SOURCE + && action != HydrologyCaveAction.FALLING_FLUID) { + throw new IllegalArgumentException("Surface hydrology writes must contain fluid"); + } + } + } + + private record FluidCoordinate(String profileKey, int x, int z) { + } + + private record PlannedCell(HydrologyCaveCell cell, long courseId) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java index 58da33f51..fe2cad55f 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleObjectComponent.java @@ -48,7 +48,7 @@ import art.arcane.iris.engine.object.IrisProceduralPlacement; import art.arcane.iris.engine.object.IrisProceduralTree; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.ObjectPlaceMode; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -65,6 +65,7 @@ import art.arcane.iris.util.project.noise.NoiseType; import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.DataProvider; import art.arcane.iris.util.common.math.IrisBlockVector; import java.io.IOException; @@ -83,8 +84,15 @@ public class MantleObjectComponent extends IrisMantleComponent { private static final Map CAVE_REJECT_LOG_STATE = new ConcurrentHashMap<>(); private static final Set MISSING_LOAD_KEY_WARNED = ConcurrentHashMap.newKeySet(); + private final Object collisionRuleLock; + private final ObjectSourcePlanCache sourcePlans; + private volatile int collisionRuleState; + public MantleObjectComponent(EngineMantle engineMantle) { super(engineMantle, ReservedFlag.OBJECT, 2); + this.collisionRuleLock = new Object(); + this.sourcePlans = new ObjectSourcePlanCache(); + this.collisionRuleState = -1; } private static String placementMarker(IrisObject object, int id, String context) { @@ -112,8 +120,68 @@ public class MantleObjectComponent extends IrisMantleComponent { placer.setData(x, y, z, TreeBlockMaterial.of(data)); } + @Override + public int getOutputRadius() { + return hasCollisionRules() ? 0 : getRadius(); + } + + @Override + public int getInputRadius() { + return hasCollisionRules() ? calculateInputRadius(getRadius(), true) : 0; + } + + @Override + public void hotload() { + super.hotload(); + synchronized (collisionRuleLock) { + collisionRuleState = -1; + sourcePlans.clear(); + } + } + @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, + z, + getRadius(), + (sourceX, sourceZ) -> transaction.apply(sourcePlans.get( + sourceX, + sourceZ, + () -> buildSourcePlan(writer, sourceX, sourceZ, context) + )) + ); + transaction.commit(); + } + + private ObjectSourcePlan buildSourcePlan( + MantleWriter writer, + int sourceChunkX, + int sourceChunkZ, + ChunkContext context + ) { + ObjectDestinationTransaction scratch = new ObjectDestinationTransaction( + writer, + sourceChunkX, + sourceChunkZ + ); + replaySourcePredecessors( + sourceChunkX, + sourceChunkZ, + getRadius(), + (predecessorX, predecessorZ) -> generateOrigin(scratch, predecessorX, predecessorZ, context) + ); + int checkpoint = scratch.mutationCheckpoint(); + generateOrigin(scratch, sourceChunkX, sourceChunkZ, context); + return scratch.sourcePlanSince(checkpoint); + } + + void generateOrigin(ObjectPassPlacer writer, int x, int z, ChunkContext context) { IrisComplex complex = context.getComplex(); boolean traceRegen = isRegenTraceThread(); RNG rng = applyNoise(x, z, Cache.key(x, z) + seed()); @@ -177,6 +245,117 @@ public class MantleObjectComponent extends IrisMantleComponent { } } + static int sourceChunkRadius(int radius) { + return radius > 0 ? Math.ceilDiv(radius, 16) : 0; + } + + static int calculateInputRadius(int radius, boolean sourceAnchoredCollisions) { + int normalizedRadius = Math.max(0, radius); + long sourceChunkRadius = sourceChunkRadius(normalizedRadius); + long sourceLegs = sourceAnchoredCollisions ? 2L : 1L; + long inputRadius = (sourceChunkRadius * 16L * sourceLegs) + normalizedRadius + 1L; + return inputRadius >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) inputRadius; + } + + static void replaySourceChunks( + int destinationChunkX, + int destinationChunkZ, + int radius, + SourceChunkConsumer consumer + ) { + int chunkRadius = sourceChunkRadius(radius); + for (int offsetX = -chunkRadius; offsetX <= chunkRadius; offsetX++) { + int sourceChunkX = destinationChunkX + offsetX; + for (int offsetZ = -chunkRadius; offsetZ <= chunkRadius; offsetZ++) { + consumer.accept(sourceChunkX, destinationChunkZ + offsetZ); + } + } + } + + static void replaySourcePredecessors( + int sourceChunkX, + int sourceChunkZ, + int radius, + SourceChunkConsumer consumer + ) { + int chunkRadius = sourceChunkRadius(radius); + for (int offsetX = -chunkRadius; offsetX <= 0; offsetX++) { + int predecessorX = sourceChunkX + offsetX; + int maximumOffsetZ = offsetX < 0 ? chunkRadius : -1; + for (int offsetZ = -chunkRadius; offsetZ <= maximumOffsetZ; offsetZ++) { + consumer.accept(predecessorX, sourceChunkZ + offsetZ); + } + } + } + + private boolean hasCollisionRules() { + int state = collisionRuleState; + if (state >= 0) { + return state == 1; + } + synchronized (collisionRuleLock) { + state = collisionRuleState; + if (state < 0) { + state = scanCollisionRules() ? 1 : 0; + collisionRuleState = state; + } + return state == 1; + } + } + + private boolean scanCollisionRules() { + if (scanCollisionRules(getDimension(), this::getData)) { + return true; + } + UpperDimensionContext upperContext = activeUpperContext(); + return upperContext != null && scanCollisionRules(upperContext.getDimension(), upperContext); + } + + private static boolean scanCollisionRules(IrisDimension dimension, DataProvider dataProvider) { + for (IrisRegion region : dimension.getAllRegions(dataProvider)) { + if (region != null && (hasCollisionRules(region.getObjects()) + || hasCollisionRules(region.getProceduralObjects()))) { + return true; + } + } + for (IrisBiome biome : dimension.getReachableBiomes(dataProvider)) { + if (biome != null && (hasCollisionRules(biome.getObjects()) + || hasCollisionRules(biome.getProceduralObjects()))) { + return true; + } + } + return false; + } + + private static boolean hasCollisionRules(KList placements) { + if (placements == null) { + return false; + } + for (IrisObjectPlacement placement : placements) { + if (hasCollisionRules(placement)) { + return true; + } + } + return false; + } + + private static boolean hasCollisionRules(IrisObjectPlacement placement) { + return placement != null && (!placement.getAllowedCollisions().isEmpty() + || !placement.getForbiddenCollisions().isEmpty()); + } + + private static boolean hasCollisionRules(IrisProceduralObjects proceduralObjects) { + if (proceduralObjects == null || proceduralObjects.isEmpty()) { + return false; + } + for (IrisProceduralPlacement placement : proceduralObjects.getAllPlacements()) { + if (placement != null && hasCollisionRules(placement.asPlacement())) { + return true; + } + } + return false; + } + private RNG applyNoise(int x, int z, long seed) { CNG noise = CNG.signatureFast(new RNG(seed), NoiseType.WHITE, NoiseType.GLOB); return new RNG((long) (seed * noise.noise(x, z))); @@ -218,7 +397,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } @ChunkCoordinates - private ObjectPlacementSummary placeObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region, IrisComplex complex, boolean traceRegen) { + private ObjectPlacementSummary placeObjects(ObjectPassPlacer writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region, IrisComplex complex, boolean traceRegen) { int biomeSurfaceChecked = 0; int biomeSurfaceTriggered = 0; int biomeCaveChecked = 0; @@ -380,7 +559,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } @ChunkCoordinates - private void placeProceduralObjects(MantleWriter writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region) { + private void placeProceduralObjects(ObjectPassPlacer writer, RNG rng, int x, int z, IrisBiome surfaceBiome, IrisBiome caveBiome, IrisRegion region) { IrisCaveProfile surfaceCaveProfile = resolveCaveProfile(surfaceBiome.getCaveProfile(), region.getCaveProfile()); IrisCaveProfile regionCaveProfile = resolveCaveProfile(region.getCaveProfile(), caveBiome == null ? null : caveBiome.getCaveProfile()); placeProceduralFrom(writer, rng, x, z, surfaceBiome.getProceduralObjects(), surfaceBiome.getName(), surfaceCaveProfile, surfaceBiome.getLoadKey()); @@ -393,7 +572,7 @@ public class MantleObjectComponent extends IrisMantleComponent { @ChunkCoordinates private void placeProceduralFrom( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int x, int z, @@ -552,7 +731,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } private CavePlacementAnchor findCavePlacementAnchor( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int minX, int minZ, @@ -577,9 +756,9 @@ public class MantleObjectComponent extends IrisMantleComponent { minDepthBelowSurface, anchorCache ); - RiverCaveHydrology hydrology = candidateY < 0 + HydrologyCaveCell hydrology = candidateY < 0 ? null - : writer.getDataIfPresent(candidateX, candidateY, candidateZ, RiverCaveHydrology.class); + : writer.getDataIfPresent(candidateX, candidateY, candidateZ, HydrologyCaveCell.class); MatterCavern cavern = candidateY < 0 ? null : writer.getDataIfPresent(candidateX, candidateY, candidateZ, MatterCavern.class); @@ -605,7 +784,7 @@ public class MantleObjectComponent extends IrisMantleComponent { static boolean acceptsCaveAnchorFluid( boolean underwater, MatterCavern cavern, - RiverCaveHydrology hydrology, + HydrologyCaveCell hydrology, int y, int lavaHeight ) { @@ -660,7 +839,7 @@ public class MantleObjectComponent extends IrisMantleComponent { @BlockCoordinates private ObjectPlacementResult placeObject( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int x, int z, @@ -788,7 +967,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } private ObjectPlacementResult placeCaveObject( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int chunkX, int chunkZ, @@ -982,7 +1161,7 @@ public class MantleObjectComponent extends IrisMantleComponent { @ChunkCoordinates private void placeUpperObjects( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int chunkX, int chunkZ, @@ -1034,7 +1213,7 @@ public class MantleObjectComponent extends IrisMantleComponent { @ChunkCoordinates private void placeUpperObject( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int chunkX, int chunkZ, @@ -1256,7 +1435,7 @@ public class MantleObjectComponent extends IrisMantleComponent { return maxAnchorY + 1; } - private int findCaveAnchorY(MantleWriter writer, RNG rng, int x, int z, IrisCaveAnchorMode anchorMode, int anchorScanStep, int objectMinDepthBelowSurface, CaveAnchorCache anchorCache) { + private int findCaveAnchorY(ObjectPassPlacer writer, RNG rng, int x, int z, IrisCaveAnchorMode anchorMode, int anchorScanStep, int objectMinDepthBelowSurface, CaveAnchorCache anchorCache) { KList anchors = anchorCache.get(writer, anchorMode, anchorScanStep, objectMinDepthBelowSurface, x, z); if (anchors.isEmpty()) { return -1; @@ -1269,7 +1448,7 @@ public class MantleObjectComponent extends IrisMantleComponent { return anchors.get(rng.i(anchors.size())); } - private KList scanCaveAnchorColumn(MantleWriter writer, IrisCaveAnchorMode anchorMode, int anchorScanStep, int objectMinDepthBelowSurface, int x, int z, CaveAnchorCache anchorCache) { + private KList scanCaveAnchorColumn(ObjectPassPlacer writer, IrisCaveAnchorMode anchorMode, int anchorScanStep, int objectMinDepthBelowSurface, int x, int z, CaveAnchorCache anchorCache) { int height = getEngineMantle().getEngine().getHeight(); int step = Math.max(1, anchorScanStep); int surfaceY = anchorCache.getSurfaceHeight(x, z); @@ -1471,30 +1650,56 @@ public class MantleObjectComponent extends IrisMantleComponent { } protected int computeRadius() { - IrisDimension dimension = getDimension(); - KMap sizeCache = new KMap<>(); KSet warnedLargeObjects = new KSet<>(); + int radius = computeDimensionRadius(getDimension(), this::getData, warnedLargeObjects); + UpperDimensionContext upperContext = activeUpperContext(); + if (upperContext != null) { + radius = Math.max(radius, computeDimensionRadius( + upperContext.getDimension(), + upperContext, + warnedLargeObjects + )); + } + return radius; + } + + private int computeDimensionRadius( + IrisDimension dimension, + DataProvider dataProvider, + KSet warnedLargeObjects + ) { + KMap sizeCache = new KMap<>(); int radius = 0; - for (IrisRegion region : dimension.getAllRegions(this::getData)) { + for (IrisRegion region : dimension.getAllRegions(dataProvider)) { if (region == null) { continue; } - radius = Math.max(radius, computePlacementRadius(region.getObjects(), sizeCache, warnedLargeObjects)); - radius = Math.max(radius, computeProceduralRadius(region.getProceduralObjects())); + radius = Math.max(radius, computePlacementRadius( + region.getObjects(), + dataProvider, + sizeCache, + warnedLargeObjects + )); + radius = Math.max(radius, computeProceduralRadius(region.getProceduralObjects(), dataProvider)); } - for (IrisBiome biome : dimension.getReachableBiomes(this::getData)) { + for (IrisBiome biome : dimension.getReachableBiomes(dataProvider)) { if (biome == null) { continue; } - radius = Math.max(radius, computePlacementRadius(biome.getObjects(), sizeCache, warnedLargeObjects)); - radius = Math.max(radius, computeProceduralRadius(biome.getProceduralObjects())); + radius = Math.max(radius, computePlacementRadius( + biome.getObjects(), + dataProvider, + sizeCache, + warnedLargeObjects + )); + radius = Math.max(radius, computeProceduralRadius(biome.getProceduralObjects(), dataProvider)); } - return radius; } private int computePlacementRadius( KList placements, + DataProvider dataProvider, KMap sizeCache, KSet warnedLargeObjects ) { @@ -1505,7 +1710,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } for (String objectKey : placement.getPlace()) { try { - IrisBlockVector size = loadObjectSize(sizeCache, objectKey); + IrisBlockVector size = loadObjectSize(dataProvider, sizeCache, objectKey); if (size == null) { continue; } @@ -1522,7 +1727,7 @@ public class MantleObjectComponent extends IrisMantleComponent { return radius; } - private int computeProceduralRadius(IrisProceduralObjects procedural) { + private int computeProceduralRadius(IrisProceduralObjects procedural, DataProvider dataProvider) { if (procedural == null || procedural.isEmpty()) { return 0; } @@ -1531,7 +1736,7 @@ public class MantleObjectComponent extends IrisMantleComponent { if (placement == null) { continue; } - KList variants = placement.getVariantObjects(getData()); + KList variants = placement.getVariantObjects(dataProvider.getData()); if (variants == null) { continue; } @@ -1596,10 +1801,14 @@ public class MantleObjectComponent extends IrisMantleComponent { return rotateY ? (int) Math.ceil(Math.hypot(x, z)) : (int) Math.max(Math.abs(x), Math.abs(z)); } - private IrisBlockVector loadObjectSize(KMap sizeCache, String objectKey) { + private IrisBlockVector loadObjectSize( + DataProvider dataProvider, + KMap sizeCache, + String objectKey + ) { return sizeCache.computeIfAbsent(objectKey, k -> { try { - return IrisObject.sampleSize(getData().getObjectLoader().findFile(objectKey)); + return IrisObject.sampleSize(dataProvider.getData().getObjectLoader().findFile(objectKey)); } catch (IOException e) { IrisLogging.reportError(e); } @@ -1608,6 +1817,14 @@ public class MantleObjectComponent extends IrisMantleComponent { }); } + private UpperDimensionContext activeUpperContext() { + if (!getDimension().isUpperDimensionObjects()) { + return null; + } + UpperDimensionContext upperContext = getEngineMantle().getEngine().getUpperContext(); + return upperContext == null || upperContext.isSelfReferencing() ? null : upperContext; + } + private final class CaveAnchorCache { private final Long2ObjectOpenHashMap>> settingCaches; private final Long2ObjectOpenHashMap carvedColumns; @@ -1620,7 +1837,7 @@ public class MantleObjectComponent extends IrisMantleComponent { this.surfaceHeights.defaultReturnValue(Integer.MIN_VALUE); } - private KList get(MantleWriter writer, IrisCaveAnchorMode anchorMode, int anchorScanStep, int objectMinDepthBelowSurface, int x, int z) { + private KList get(ObjectPassPlacer writer, IrisCaveAnchorMode anchorMode, int anchorScanStep, int objectMinDepthBelowSurface, int x, int z) { if (anchorScanStep > 65535 || objectMinDepthBelowSurface > 65535) { return scanCaveAnchorColumn(writer, anchorMode, anchorScanStep, objectMinDepthBelowSurface, x, z, this); } @@ -1643,7 +1860,7 @@ public class MantleObjectComponent extends IrisMantleComponent { return anchors; } - private byte[] getCarvedColumn(MantleWriter writer, int x, int z, int height) { + private byte[] getCarvedColumn(ObjectPassPlacer writer, int x, int z, int height) { long columnKey = Cache.key(x, z); byte[] carvedColumn = carvedColumns.get(columnKey); if (carvedColumn != null && carvedColumn.length == height) { @@ -1668,4 +1885,9 @@ public class MantleObjectComponent extends IrisMantleComponent { } } + @FunctionalInterface + interface SourceChunkConsumer { + void accept(int chunkX, int chunkZ); + } + } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java deleted file mode 100644 index 68ffda007..000000000 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelView.java +++ /dev/null @@ -1,174 +0,0 @@ -package art.arcane.iris.engine.mantle.components; - -import art.arcane.iris.engine.river.cave.CavePosition; -import art.arcane.iris.engine.river.cave.CaveVoxel; -import art.arcane.iris.engine.river.cave.CaveVoxelView; -import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; -import art.arcane.iris.engine.data.cache.Cache; -import art.arcane.iris.engine.object.IrisProceduralBlocks; -import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.volmlib.util.function.Function2; -import art.arcane.volmlib.util.mantle.runtime.Mantle; -import art.arcane.volmlib.util.mantle.runtime.MantleChunk; -import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; -import art.arcane.volmlib.util.matter.Matter; -import art.arcane.volmlib.util.matter.MatterCavern; -import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; -import it.unimi.dsi.fastutil.longs.LongOpenHashSet; - -import java.util.Objects; -import java.util.function.BiConsumer; - -final class MantleRiverCaveVoxelView implements MantleRiverHydrologyComponent.TunnelVoxelView { - private static final int CLOSED_COLUMN = Integer.MAX_VALUE; - private static final int CACHE_MISS = Integer.MIN_VALUE; - - private final Mantle mantle; - private final int worldHeight; - private final Function2 surfaceHeight; - private final Function2 compatibleFluid; - private final RiverCaveFluidKind planningFluidKind; - private final BiConsumer chunkLoader; - private final LongOpenHashSet loadedChunks; - private final Long2IntOpenHashMap openFloorCache; - private final Long2IntOpenHashMap surfaceHeightCache; - - MantleRiverCaveVoxelView( - Mantle mantle, - int worldHeight, - Function2 surfaceHeight, - Function2 compatibleFluid, - RiverCaveFluidKind planningFluidKind, - BiConsumer chunkLoader - ) { - this.mantle = Objects.requireNonNull(mantle); - this.worldHeight = worldHeight; - this.surfaceHeight = Objects.requireNonNull(surfaceHeight); - this.compatibleFluid = Objects.requireNonNull(compatibleFluid); - this.planningFluidKind = Objects.requireNonNull(planningFluidKind); - this.chunkLoader = Objects.requireNonNull(chunkLoader); - loadedChunks = new LongOpenHashSet(); - openFloorCache = new Long2IntOpenHashMap(); - openFloorCache.defaultReturnValue(CACHE_MISS); - surfaceHeightCache = new Long2IntOpenHashMap(); - surfaceHeightCache.defaultReturnValue(CACHE_MISS); - } - - @Override - public boolean isInWorld(CavePosition position) { - return position.y() > 0 && position.y() < worldHeight - 1; - } - - @Override - public CaveVoxel voxelAt(CavePosition position) { - RiverCaveHydrology hydrology = dataIfPresent(position, RiverCaveHydrology.class); - if (hydrology != null && hydrology.fluidKind() != planningFluidKind) { - return CaveVoxel.INCOMPATIBLE_FLUID; - } - MatterCavern cavern = dataIfPresent(position, MatterCavern.class); - if (cavern != null) { - if (cavern.isLava()) { - PlatformBlockState expected = compatibleFluid.apply(position.x(), position.z()); - return expected != null - && IrisProceduralBlocks.materialKey(expected).endsWith(":lava") - ? CaveVoxel.COMPATIBLE_FLUID - : CaveVoxel.LAVA; - } - if (cavern.getLiquid() == 1) { - return CaveVoxel.COMPATIBLE_FLUID; - } - return CaveVoxel.CAVE_AIR; - } - PlatformBlockState block = dataIfPresent(position, PlatformBlockState.class); - if (block == null) { - return position.y() > surfaceY(position.x(), position.z()) - ? CaveVoxel.CAVE_AIR - : CaveVoxel.SOLID; - } - if (!block.isFluid()) { - return CaveVoxel.SOLID; - } - PlatformBlockState expected = compatibleFluid.apply(position.x(), position.z()); - if (expected != null - && IrisProceduralBlocks.materialKey(expected).equals(IrisProceduralBlocks.materialKey(block))) { - return CaveVoxel.COMPATIBLE_FLUID; - } - return IrisProceduralBlocks.materialKey(block).endsWith(":lava") - ? CaveVoxel.LAVA - : CaveVoxel.INCOMPATIBLE_FLUID; - } - - @Override - public boolean isOpenToSurface(CavePosition position) { - if (!isInWorld(position) || voxelAt(position) == CaveVoxel.SOLID) { - return false; - } - if (position.y() > surfaceY(position.x(), position.z())) { - return true; - } - long key = Cache.key(position.x(), position.z()); - int openFloor = openFloorCache.get(key); - if (openFloor == CACHE_MISS) { - openFloor = resolveOpenFloor(position.x(), position.z()); - openFloorCache.put(key, openFloor); - } - return openFloor != CLOSED_COLUMN && position.y() >= openFloor; - } - - @Override - public RiverCaveHydrology riverHydrologyAt(CavePosition position) { - return dataIfPresent(position, RiverCaveHydrology.class); - } - - private int resolveOpenFloor(int x, int z) { - int top = surfaceY(x, z); - CavePosition surface = new CavePosition(x, top, z); - if (voxelAt(surface) == CaveVoxel.SOLID) { - return CLOSED_COLUMN; - } - int y = top; - while (y > 0 && voxelAt(new CavePosition(x, y - 1, z)) != CaveVoxel.SOLID) { - y--; - } - return y; - } - - private int surfaceY(int x, int z) { - long key = Cache.key(x, z); - int cached = surfaceHeightCache.get(key); - if (cached != CACHE_MISS) { - return cached; - } - int resolved = Math.max(1, Math.min(worldHeight - 2, surfaceHeight.apply(x, z))); - surfaceHeightCache.put(key, resolved); - return resolved; - } - - private T dataIfPresent(CavePosition position, Class type) { - int chunkX = position.x() >> 4; - int chunkZ = position.z() >> 4; - long chunkKey = Mantle.key(chunkX, chunkZ); - if (loadedChunks.add(chunkKey)) { - chunkLoader.accept(chunkX, chunkZ); - } - TectonicPlate plate = mantle.getLoadedRegions().get(Mantle.key(chunkX >> 5, chunkZ >> 5)); - if (plate == null || plate.isClosed()) { - return null; - } - MantleChunk chunk = plate.get(chunkX & 31, chunkZ & 31); - int section = position.y() >> 4; - if (chunk == null || !chunk.exists(section)) { - return null; - } - Matter matter = chunk.get(section); - if (matter == null || !matter.hasSlice(type)) { - return null; - } - return matter.getSlice(type).get( - position.x() & 15, - position.y() & 15, - position.z() & 15 - ); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java deleted file mode 100644 index b85b60a44..000000000 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponent.java +++ /dev/null @@ -1,1206 +0,0 @@ -package art.arcane.iris.engine.mantle.components; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.data.cache.Cache; -import art.arcane.iris.engine.mantle.ComponentFlag; -import art.arcane.iris.engine.mantle.EngineMantle; -import art.arcane.iris.engine.mantle.IrisMantleComponent; -import art.arcane.iris.engine.mantle.MantleComponent; -import art.arcane.iris.engine.mantle.MantleWriter; -import art.arcane.iris.engine.object.IrisRiverCaveFallback; -import art.arcane.iris.engine.object.IrisRiverCaveMode; -import art.arcane.iris.engine.object.IrisRiverCaves; -import art.arcane.iris.engine.object.IrisRiverDeepPools; -import art.arcane.iris.engine.object.IrisRiverExistingFluidPolicy; -import art.arcane.iris.engine.object.IrisDimension; -import art.arcane.iris.engine.object.IrisRiverNetwork; -import art.arcane.iris.engine.river.RiverAnchor; -import art.arcane.iris.engine.river.RiverNetwork; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.RiverTopologyComplexity; -import art.arcane.iris.engine.river.cave.CavePosition; -import art.arcane.iris.engine.river.cave.CaveVoxel; -import art.arcane.iris.engine.river.cave.CaveVoxelPrecondition; -import art.arcane.iris.engine.river.cave.CaveVoxelView; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveContainmentPlanner; -import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; -import art.arcane.iris.engine.river.cave.RiverCaveFluidPolicy; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; -import art.arcane.iris.engine.river.cave.RiverCaveMode; -import art.arcane.iris.engine.river.cave.RiverCavePlan; -import art.arcane.iris.engine.river.cave.RiverCavePlannerSettings; -import art.arcane.iris.engine.river.cave.RiverCavePlanningResult; -import art.arcane.iris.engine.river.cave.RiverCaveSource; -import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; -import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; -import art.arcane.iris.engine.river.runtime.IrisRiverTunnelSample; -import art.arcane.iris.util.project.context.ChunkContext; -import art.arcane.volmlib.util.mantle.flag.MantleFlag; -import art.arcane.volmlib.util.mantle.flag.ReservedFlag; -import art.arcane.volmlib.util.mantle.runtime.MantleChunk; -import art.arcane.volmlib.util.math.BlockPosition; -import art.arcane.volmlib.util.matter.Matter; -import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; - -@ComponentFlag(ReservedFlag.RIVER_HYDROLOGY) -public final class MantleRiverHydrologyComponent extends IrisMantleComponent { - private static final long CANDIDATE_SALT = 0x6A09E667F3BCC909L; - private static final long DEEP_POOL_CANDIDATE_SALT = 0xBB67AE8584CAA73BL; - private static final long DEEP_POOL_POSITION_SALT = 0x3C6EF372FE94F82BL; - static final int PRIORITY = 1; - private static final int[] FALLBACK_X = {0, 1, -1, 0, 0}; - private static final int[] FALLBACK_Z = {0, 0, 0, 1, -1}; - private static final MantleFlag[] PREREQUISITES = {ReservedFlag.CARVED}; - private static final int[][] NEIGHBORS = { - {1, 0, 0}, {-1, 0, 0}, - {0, 1, 0}, {0, -1, 0}, - {0, 0, 1}, {0, 0, -1} - }; - private static final Comparator> ACTION_ORDER = Comparator - .comparingInt((Map.Entry entry) -> entry.getKey().x()) - .thenComparingInt(entry -> entry.getKey().y()) - .thenComparingInt(entry -> entry.getKey().z()); - - private final RiverCaveContainmentPlanner planner; - - public MantleRiverHydrologyComponent(EngineMantle engineMantle) { - super(engineMantle, ReservedFlag.RIVER_HYDROLOGY, PRIORITY); - planner = new RiverCaveContainmentPlanner(); - } - - @Override - public MantleFlag[] getPrerequisiteFlags() { - return PREREQUISITES; - } - - @Override - public boolean isInputGenerationLazy() { - return true; - } - - @Override - public int getInputRadius() { - if (!getDimension().isCarvingEnabled() - || getDimension().getRivers() == null - || !getDimension().getRivers().isEnabled()) { - return 0; - } - IrisRiverRuntime runtime = getComplex().getRiverRuntime(); - if (runtime == null) { - return 0; - } - return inputRadius(runtime.caveSettings(), tunnelHalo(runtime)); - } - - @Override - public int getInputRadius( - int targetChunkX, - int targetChunkZ, - int invocationChunkRadius, - ChunkContext context - ) { - if (!getDimension().isCarvingEnabled() - || getDimension().getRivers() == null - || !getDimension().getRivers().isEnabled()) { - return 0; - } - if (context == null || context.getComplex() == null) { - return getInputRadius(); - } - IrisRiverRuntime runtime = context.getComplex().getRiverRuntime(); - if (runtime == null) { - return getInputRadius(); - } - IrisRiverCaves caves = runtime.caveSettings(); - int tunnelRadius = tunnelHalo(runtime); - int radius = hasRiverFootprint( - runtime, - targetChunkX, - targetChunkZ, - invocationChunkRadius, - tunnelRadius - ) ? tunnelRadius : 0; - if (caves.getMode() != IrisRiverCaveMode.SEALED - && caves.getMaximumPerReach() > 0) { - radius = Math.max(radius, hasAcceptedCaveAnchor( - runtime, - caves, - targetChunkX, - targetChunkZ, - invocationChunkRadius - ) ? planningHalo(caves) : 0); - } - IrisRiverDeepPools deepPools = caves.getDeepPools(); - if (deepPools != null - && deepPools.isEnabled() - && deepPools.getMaximumPerReach() > 0) { - radius = Math.max(radius, hasAcceptedDeepPoolAnchor( - runtime, - deepPools, - targetChunkX, - targetChunkZ, - invocationChunkRadius - ) ? deepPoolPlanningHalo(deepPools) : 0); - } - return radius; - } - - private static boolean hasRiverFootprint( - IrisRiverRuntime runtime, - int targetChunkX, - int targetChunkZ, - int invocationChunkRadius, - int tunnelRadius - ) { - return runtime.hasRiverFootprint( - ((targetChunkX - invocationChunkRadius) << 4) - tunnelRadius, - ((targetChunkZ - invocationChunkRadius) << 4) - tunnelRadius, - ((targetChunkX + invocationChunkRadius + 1) << 4) + tunnelRadius, - ((targetChunkZ + invocationChunkRadius + 1) << 4) + tunnelRadius - ); - } - - private static boolean hasAcceptedCaveAnchor( - IrisRiverRuntime runtime, - IrisRiverCaves caves, - int targetChunkX, - int targetChunkZ, - int invocationChunkRadius - ) { - int candidateHalo = candidateHalo(caves); - List anchors = runtime.candidateAnchors( - ((targetChunkX - invocationChunkRadius) << 4) - candidateHalo, - ((targetChunkZ - invocationChunkRadius) << 4) - candidateHalo, - ((targetChunkX + invocationChunkRadius + 1) << 4) + candidateHalo, - ((targetChunkZ + invocationChunkRadius + 1) << 4) + candidateHalo, - caves.getMinimumSpacing(), - CANDIDATE_SALT - ); - for (RiverAnchor anchor : anchors) { - if (runtime.acceptsCaveAnchor(anchor)) { - return true; - } - } - return false; - } - - private static boolean hasAcceptedDeepPoolAnchor( - IrisRiverRuntime runtime, - IrisRiverDeepPools deepPools, - int targetChunkX, - int targetChunkZ, - int invocationChunkRadius - ) { - int candidateHalo = deepPoolCandidateHalo(deepPools); - List anchors = runtime.candidateAnchors( - ((targetChunkX - invocationChunkRadius) << 4) - candidateHalo, - ((targetChunkZ - invocationChunkRadius) << 4) - candidateHalo, - ((targetChunkX + invocationChunkRadius + 1) << 4) + candidateHalo, - ((targetChunkZ + invocationChunkRadius + 1) << 4) + candidateHalo, - deepPools.getMinimumSpacing(), - DEEP_POOL_CANDIDATE_SALT - ); - for (RiverAnchor anchor : anchors) { - if (runtime.acceptsDeepPoolAnchor(anchor)) { - return true; - } - } - return false; - } - - @Override - public void generateLayer(MantleWriter writer, int chunkX, int chunkZ, ChunkContext context) { - IrisRiverRuntime runtime = context.getComplex().getRiverRuntime(); - if (runtime == null || !getDimension().isCarvingEnabled()) { - return; - } - publishTunnels(writer, context, runtime, chunkX, chunkZ); - - IrisRiverCaves caves = runtime.caveSettings(); - if (caves.getMode() != IrisRiverCaveMode.SEALED && caves.getMaximumPerReach() > 0) { - publishCaveConnections(writer, context, runtime, caves, chunkX, chunkZ); - } - publishDeepPools(writer, context, runtime, caves.getDeepPools(), chunkX, chunkZ); - } - - private void publishCaveConnections( - MantleWriter writer, - ChunkContext context, - IrisRiverRuntime runtime, - IrisRiverCaves caves, - int chunkX, - int chunkZ - ) { - MantleRiverCaveVoxelView view = createView(writer, context, RiverCaveFluidKind.RIVER); - int candidateHalo = candidateHalo(caves); - int minimumX = (chunkX << 4) - candidateHalo; - int minimumZ = (chunkZ << 4) - candidateHalo; - int maximumX = ((chunkX + 1) << 4) + candidateHalo; - int maximumZ = ((chunkZ + 1) << 4) + candidateHalo; - List anchors = runtime.candidateAnchors( - minimumX, - minimumZ, - maximumX, - maximumZ, - caves.getMinimumSpacing(), - CANDIDATE_SALT - ); - if (anchors.isEmpty()) { - return; - } - - RiverCavePlannerSettings settings = plannerSettings(caves, seed(), getData()); - List sources = new ArrayList<>(); - Map floodedBiomes = new HashMap<>(); - for (RiverAnchor anchor : anchors) { - if (!runtime.acceptsCaveAnchor(anchor)) { - continue; - } - SourceCandidate candidate = sourceFor(runtime, view, caves, anchor); - if (candidate == null) { - continue; - } - RiverCaveSource source = candidate.source(); - RiverCavePlan initial = planner.plan(view, source, settings); - if (!initial.accepted() - && caves.getFallback() == IrisRiverCaveFallback.GENERATE_GROTTO) { - source = fallbackSource(view, caves, settings, candidate, source); - } - if (source == null) { - continue; - } - sources.add(source); - floodedBiomes.put(source.sourceId(), runtime.selectFloodedCaveBiome(anchor)); - } - if (sources.isEmpty()) { - return; - } - - RiverCavePlanningResult result = planner.planAll(view, sources, settings); - MantleRiverCaveVoxelView revalidationView = createView( - writer, - context, - RiverCaveFluidKind.RIVER - ); - if (!preconditionsHold(revalidationView, result.baselinePreconditions())) { - return; - } - publishLocal( - writer, - chunkX, - chunkZ, - result, - floodedBiomes, - RiverCaveFluidKind.RIVER - ); - } - - private void publishDeepPools( - MantleWriter writer, - ChunkContext context, - IrisRiverRuntime runtime, - IrisRiverDeepPools deepPools, - int chunkX, - int chunkZ - ) { - if (deepPools == null || !deepPools.isEnabled() || deepPools.getMaximumPerReach() <= 0) { - return; - } - MantleRiverCaveVoxelView view = createView( - writer, - context, - RiverCaveFluidKind.DEEP_POOL - ); - int candidateHalo = deepPoolCandidateHalo(deepPools); - int minimumX = (chunkX << 4) - candidateHalo; - int minimumZ = (chunkZ << 4) - candidateHalo; - int maximumX = ((chunkX + 1) << 4) + candidateHalo; - int maximumZ = ((chunkZ + 1) << 4) + candidateHalo; - List anchors = runtime.candidateAnchors( - minimumX, - minimumZ, - maximumX, - maximumZ, - deepPools.getMinimumSpacing(), - DEEP_POOL_CANDIDATE_SALT - ); - if (anchors.isEmpty()) { - return; - } - - RiverCavePlannerSettings settings = deepPoolPlannerSettings(deepPools, seed(), getData()); - List sources = new ArrayList<>(); - Map floodedBiomes = new HashMap<>(); - for (RiverAnchor anchor : anchors) { - if (!runtime.acceptsDeepPoolAnchor(anchor)) { - continue; - } - RiverCaveSource source = deepPoolSourceFor( - view, - deepPools, - anchor, - getDimension().getMinHeight(), - seed() - ); - if (source == null) { - continue; - } - sources.add(source); - floodedBiomes.put(source.sourceId(), runtime.selectFloodedCaveBiome(anchor)); - } - if (sources.isEmpty()) { - return; - } - - RiverCavePlanningResult result = planner.planAll(view, sources, settings); - MantleRiverCaveVoxelView revalidationView = createView( - writer, - context, - RiverCaveFluidKind.DEEP_POOL - ); - if (!preconditionsHold(revalidationView, result.baselinePreconditions())) { - return; - } - publishLocal( - writer, - chunkX, - chunkZ, - result, - floodedBiomes, - RiverCaveFluidKind.DEEP_POOL - ); - } - - static RiverCavePlannerSettings deepPoolPlannerSettings( - IrisRiverDeepPools deepPools, - long seed, - IrisData data - ) { - int verticalDepth = deepPools.getVerticalRadius() * 2 - deepPools.getDryHeadroom() + 1; - int proofRadius = (int) StrictMath.ceil( - StrictMath.sqrt(2D) * deepPools.getHorizontalRadius() - ) + 1; - return new RiverCavePlannerSettings( - proofRadius, - verticalDepth, - deepPools.getMaximumVolume(), - deepPools.getVerticalRadius(), - 1, - deepPools.getHorizontalRadius(), - deepPools.getVerticalRadius(), - deepPools.getDryHeadroom(), - RiverCaveFluidPolicy.REJECT_EXISTING, - new ConfiguredRiverGrottoShape( - seed ^ DEEP_POOL_POSITION_SALT, - data, - deepPools.getShapeStyle(), - deepPools.getWarpStyle(), - deepPools.getWarpStrength(), - deepPools.getShapeVariation() - ), - proofRadius, - verticalDepth - ); - } - - static RiverCaveSource deepPoolSourceFor( - CaveVoxelView view, - IrisRiverDeepPools deepPools, - RiverAnchor anchor, - int worldMinimumY, - long seed - ) { - int minimumHead = deepPools.getMinimumFluidY() - worldMinimumY; - int maximumHead = deepPools.getMaximumFluidY() - worldMinimumY; - int headRange = maximumHead - minimumHead + 1; - if (headRange <= 0) { - return null; - } - int searchRadius = deepPools.getSearchRadius(); - int searchWidth = searchRadius * 2 + 1; - int targetDepth = Math.max( - 1, - deepPools.getVerticalRadius() - deepPools.getDryHeadroom() - ); - for (int attempt = 0; attempt < deepPools.getSearchAttempts(); attempt++) { - long hash = RiverNetwork.mix( - seed - ^ anchor.stableId() - ^ DEEP_POOL_POSITION_SALT - ^ (long) attempt * 0x9E3779B97F4A7C15L - ); - int offsetX = searchRadius == 0 - ? 0 - : Math.floorMod((int) hash, searchWidth) - searchRadius; - int offsetZ = searchRadius == 0 - ? 0 - : Math.floorMod((int) (hash >>> 32), searchWidth) - searchRadius; - if ((long) offsetX * offsetX + (long) offsetZ * offsetZ - > (long) searchRadius * searchRadius) { - continue; - } - int x = (int) StrictMath.floor(anchor.x()) + offsetX; - int z = (int) StrictMath.floor(anchor.z()) + offsetZ; - int startOffset = (int) StrictMath.floor(unit(hash) * headRange); - for (int scanned = 0; scanned < headRange; scanned++) { - int headY = maximumHead - Math.floorMod(startOffset + scanned, headRange); - CavePosition floor = new CavePosition(x, headY, z); - CavePosition above = new CavePosition(x, headY + 1, z); - CavePosition target = new CavePosition(x, headY - targetDepth, z); - if (!view.isInWorld(target) - || !view.isInWorld(above) - || view.voxelAt(floor) != CaveVoxel.SOLID - || view.voxelAt(above) != CaveVoxel.CAVE_AIR - || view.isOpenToSurface(above)) { - continue; - } - long sourceId = RiverNetwork.mix( - anchor.stableId() - ^ BlockPosition.toLong(x, headY, z) - ^ DEEP_POOL_POSITION_SALT - ); - return new RiverCaveSource( - sourceId, - floor, - target, - headY, - RiverCaveMode.DEEP_POOL - ); - } - } - return null; - } - - private static double unit(long hash) { - return (hash >>> 11) * 0x1.0p-53; - } - - @Override - protected int computeRadius() { - return 0; - } - - public static boolean isEnabledFor(IrisDimension dimension) { - IrisRiverNetwork rivers = dimension.getRivers(); - if (!dimension.isUseMantle() - || !dimension.isCarvingEnabled() - || dimension.getDisabledComponents().contains(ReservedFlag.CARVED) - || dimension.getDisabledComponents().contains(ReservedFlag.RIVER_HYDROLOGY) - || rivers == null - || !rivers.isEnabled()) { - return false; - } - return true; - } - - public static boolean isCaveConnectionsEnabledFor(IrisDimension dimension) { - if (!isEnabledFor(dimension)) { - return false; - } - IrisRiverCaves caves = dimension.getRivers().getCaves(); - if (caves == null) { - return false; - } - boolean caveConnections = caves.getMode() != IrisRiverCaveMode.SEALED - && caves.getMaximumPerReach() > 0; - IrisRiverDeepPools deepPools = caves.getDeepPools(); - return caveConnections || deepPools != null - && deepPools.isEnabled() - && deepPools.getMaximumPerReach() > 0; - } - - static int planningHalo(IrisRiverCaves caves) { - return cavePublicationRadius(caves) * 4; - } - - static int inputRadius(IrisRiverCaves caves, int tunnelRadius) { - int radius = tunnelRadius; - if (caves.getMode() != IrisRiverCaveMode.SEALED && caves.getMaximumPerReach() > 0) { - radius = Math.max(radius, planningHalo(caves)); - } - IrisRiverDeepPools deepPools = caves.getDeepPools(); - if (deepPools != null && deepPools.isEnabled() && deepPools.getMaximumPerReach() > 0) { - radius = Math.max(radius, deepPoolPlanningHalo(deepPools)); - } - return radius; - } - - static int candidateHalo(IrisRiverCaves caves) { - return cavePublicationRadius(caves) * 3; - } - - static int deepPoolPlanningHalo(IrisRiverDeepPools deepPools) { - return deepPoolPublicationRadius(deepPools) * 4; - } - - static int deepPoolCandidateHalo(IrisRiverDeepPools deepPools) { - return deepPoolPublicationRadius(deepPools) * 3; - } - - static int deepPoolPublicationRadius(IrisRiverDeepPools deepPools) { - return deepPools.getSearchRadius() + deepPools.getHorizontalRadius() + 1; - } - - static int cavePublicationRadius(IrisRiverCaves caves) { - int generatedRadius = generatedGrottoPublicationRadius(caves); - return switch (caves.getMode()) { - case SEALED -> 0; - case GENERATE_GROTTO -> generatedRadius; - case FLOOD_CLOSED_COMPONENT, GROTTO_OR_CLOSED_COMPONENT, WATERFALL_POOL -> - Math.max(closedComponentPublicationRadius(caves), generatedRadius); - }; - } - - static int closedComponentPublicationRadius(IrisRiverCaves caves) { - return caves.getMaxFloodRadius() + 1; - } - - static int generatedGrottoPublicationRadius(IrisRiverCaves caves) { - int targetOffset = caves.getFallback() == IrisRiverCaveFallback.GENERATE_GROTTO - ? caves.getThroatRadius() + 2 - : 0; - long maximumX = (long) targetOffset + caves.getGrottoHorizontalRadius() + 1L; - long maximumZ = caves.getGrottoHorizontalRadius(); - int grottoRadius = (int) StrictMath.ceil(StrictMath.sqrt( - maximumX * maximumX + maximumZ * maximumZ - )); - int throatRadius = targetOffset + caves.getThroatRadius(); - return Math.max(grottoRadius, throatRadius); - } - - static int tunnelHalo(IrisRiverRuntime runtime) { - return RiverTopologyComplexity.tunnelHalo( - runtime.maximumChannelWidth(), - runtime.maximumTunnelWidthMultiplier(), - runtime.tunnelMouthBlend() - ); - } - - static int waterHeadY(IrisRiverSurfaceSample sample, IrisRiverCaves caves) { - return (int) Math.round(sample.waterSurfaceY()) + caves.getWaterLevelOffset(); - } - - static boolean owns(int chunkX, int chunkZ, CavePosition position) { - return (position.x() >> 4) == chunkX && (position.z() >> 4) == chunkZ; - } - - static RiverCaveFluidPolicy fluidPolicy(IrisRiverExistingFluidPolicy policy) { - return switch (policy) { - case REJECT -> RiverCaveFluidPolicy.REJECT_EXISTING; - case ALLOW_SAME -> RiverCaveFluidPolicy.ALLOW_COMPATIBLE; - case REPLACE -> RiverCaveFluidPolicy.REPLACE_CONTAINED; - }; - } - - static boolean preconditionsHold( - CaveVoxelView view, - Map preconditions - ) { - for (Map.Entry entry : preconditions.entrySet()) { - CaveVoxelPrecondition expected = entry.getValue(); - if (view.voxelAt(entry.getKey()) != expected.voxel() - || view.isOpenToSurface(entry.getKey()) != expected.openToSurface()) { - return false; - } - } - return true; - } - - static TunnelPlan planTunnels( - CaveVoxelView view, - int chunkX, - int chunkZ, - int halo, - int dryHeadroom, - FootprintSampler footprintSampler, - TunnelSampler tunnelSampler, - SurfaceSampler surfaceSampler - ) { - int minimumX = (chunkX << 4) - halo; - int minimumZ = (chunkZ << 4) - halo; - int maximumX = ((chunkX + 1) << 4) + halo; - int maximumZ = ((chunkZ + 1) << 4) + halo; - if (!footprintSampler.sample(minimumX, minimumZ, maximumX, maximumZ).present()) { - return TunnelPlan.empty(); - } - ArrayList solidColumns = new ArrayList<>(); - for (int x = minimumX; x < maximumX; x++) { - for (int z = minimumZ; z < maximumZ; z++) { - IrisRiverTunnelSample sample = tunnelSampler.sample(x, z); - TunnelColumn column = createTunnelColumn(view, x, z, sample); - if (column != null) { - solidColumns.add(column); - } - } - } - if (solidColumns.isEmpty()) { - return TunnelPlan.empty(); - } - - ArrayList containedColumns = new ArrayList<>(solidColumns); - boolean changed; - do { - changed = false; - Long2ObjectOpenHashMap candidateColumns = indexColumns(containedColumns); - for (int index = containedColumns.size() - 1; index >= 0; index--) { - TunnelColumn column = containedColumns.get(index); - if (!isTunnelColumnContained( - view, - column, - candidateColumns, - dryHeadroom, - surfaceSampler - )) { - containedColumns.remove(index); - changed = true; - } - } - } while (changed && !containedColumns.isEmpty()); - Map actions = mergeActions(containedColumns); - LinkedHashMap preconditions = new LinkedHashMap<>(); - for (CavePosition position : actions.keySet()) { - preconditions.put(position, new CaveVoxelPrecondition( - view.voxelAt(position), - view.isOpenToSurface(position) - )); - } - for (CavePosition position : List.copyOf(actions.keySet())) { - RiverCaveAction action = actions.get(position); - for (int[] offset : NEIGHBORS) { - CavePosition neighbor = offset(position, offset); - if (actions.containsKey(neighbor) || !view.isInWorld(neighbor)) { - continue; - } - CaveVoxel neighborVoxel = view.voxelAt(neighbor); - boolean sealsSolidBoundary = neighborVoxel == CaveVoxel.SOLID; - boolean sealsCaveWaterline = action == RiverCaveAction.WET_SOURCE - && neighborVoxel == CaveVoxel.CAVE_AIR; - if (!sealsSolidBoundary && !sealsCaveWaterline) { - if (action == RiverCaveAction.DRY_AIR - && neighborVoxel == CaveVoxel.CAVE_AIR - && !view.isOpenToSurface(neighbor)) { - preconditions.putIfAbsent( - neighbor, - new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false) - ); - } - continue; - } - actions.putIfAbsent(neighbor, RiverCaveAction.SEAL_GUARD); - preconditions.putIfAbsent(neighbor, new CaveVoxelPrecondition( - neighborVoxel, - view.isOpenToSurface(neighbor) - )); - } - } - return new TunnelPlan( - Collections.unmodifiableMap(actions), - Collections.unmodifiableMap(preconditions) - ); - } - - private static TunnelColumn createTunnelColumn( - CaveVoxelView view, - int x, - int z, - IrisRiverTunnelSample sample - ) { - if (sample == null) { - return null; - } - int minimumY = sample.bedY() + 1; - int maximumY = sample.ceilingY(); - for (int y = minimumY; y <= maximumY; y++) { - CavePosition position = new CavePosition(x, y, z); - RiverCaveAction action = y <= sample.waterHeadY() - ? RiverCaveAction.WET_SOURCE - : RiverCaveAction.DRY_AIR; - if (!view.isInWorld(position) - || (!canCarveTunnelVoxel(view, position) - && !matchesPublishedAction(view, position, action))) { - return null; - } - } - return minimumY > maximumY - ? null - : new TunnelColumn(x, z, minimumY, sample.waterHeadY(), maximumY); - } - - private static boolean isTunnelColumnContained( - CaveVoxelView view, - TunnelColumn column, - Long2ObjectOpenHashMap candidateColumns, - int dryHeadroom, - SurfaceSampler surfaceSampler - ) { - for (int y = column.minimumY(); y <= column.maximumY(); y++) { - CavePosition position = new CavePosition(column.x(), y, column.z()); - for (int[] offset : NEIGHBORS) { - CavePosition neighbor = offset(position, offset); - if (containsAction(candidateColumns, neighbor)) { - continue; - } - if (!view.isInWorld(neighbor)) { - return false; - } - CaveVoxel neighborVoxel = view.voxelAt(neighbor); - if (neighborVoxel == CaveVoxel.SOLID - || (neighborVoxel == CaveVoxel.CAVE_AIR - && !view.isOpenToSurface(neighbor)) - || isSurfaceMouth(neighbor, dryHeadroom, surfaceSampler)) { - continue; - } - return false; - } - } - return true; - } - - private static boolean canCarveTunnelVoxel(CaveVoxelView view, CavePosition position) { - CaveVoxel voxel = view.voxelAt(position); - return voxel == CaveVoxel.SOLID - || (voxel == CaveVoxel.CAVE_AIR && !view.isOpenToSurface(position)); - } - - private static boolean isSurfaceMouth( - CavePosition position, - int dryHeadroom, - SurfaceSampler surfaceSampler - ) { - IrisRiverSurfaceSample sample = surfaceSampler.sample(position.x(), position.z()); - if (!sample.river().present() - || sample.river().state() != RiverRouteState.WET - || sample.subterranean()) { - return false; - } - int bedY = (int) Math.round(sample.terrainHeight()); - int headY = (int) Math.round(sample.waterSurfaceY()); - return position.y() > bedY && position.y() <= headY + Math.max(0, dryHeadroom); - } - - private static Map mergeActions(List columns) { - LinkedHashMap actions = new LinkedHashMap<>(); - for (TunnelColumn column : columns) { - for (int y = column.minimumY(); y <= column.maximumY(); y++) { - actions.put( - new CavePosition(column.x(), y, column.z()), - y <= column.waterHeadY() - ? RiverCaveAction.WET_SOURCE - : RiverCaveAction.DRY_AIR - ); - } - } - return actions; - } - - private static Long2ObjectOpenHashMap indexColumns(List columns) { - Long2ObjectOpenHashMap indexed = new Long2ObjectOpenHashMap<>(columns.size()); - for (TunnelColumn column : columns) { - indexed.put(Cache.key(column.x(), column.z()), column); - } - return indexed; - } - - private static boolean containsAction( - Long2ObjectOpenHashMap columns, - CavePosition position - ) { - TunnelColumn column = columns.get(Cache.key(position.x(), position.z())); - return column != null - && position.y() >= column.minimumY() - && position.y() <= column.maximumY(); - } - - private static boolean matchesPublishedAction( - CaveVoxelView view, - CavePosition position, - RiverCaveAction action - ) { - if (!(view instanceof TunnelVoxelView tunnelView)) { - return false; - } - RiverCaveHydrology hydrology = tunnelView.riverHydrologyAt(position); - return hydrology != null - && hydrology.action() == action - && hydrology.fluidKind() == RiverCaveFluidKind.RIVER; - } - - private static CavePosition offset(CavePosition position, int[] offset) { - return new CavePosition( - position.x() + offset[0], - position.y() + offset[1], - position.z() + offset[2] - ); - } - - private MantleRiverCaveVoxelView createView( - MantleWriter writer, - ChunkContext context, - RiverCaveFluidKind fluidKind - ) { - return new MantleRiverCaveVoxelView( - writer.getMantle(), - writer.getMantle().getWorldHeight(), - (x, z) -> context.getComplex().getRoundedHeighteightStream().get(x, z), - (x, z) -> context.getComplex().resolveRiverCaveFluid(fluidKind, x, z), - fluidKind, - (chunkX, chunkZ) -> generateCarvingInput(writer, context, chunkX, chunkZ) - ); - } - - private void generateCarvingInput( - MantleWriter writer, - ChunkContext context, - int chunkX, - int chunkZ - ) { - MantleComponent carving = getEngineMantle().getRegisteredComponents().get(ReservedFlag.CARVED); - if (carving == null || !carving.isEnabled()) { - throw new IllegalStateException("River hydrology requires the carving component"); - } - MantleChunk chunk = writer.acquireChunk(chunkX, chunkZ); - if (chunk == null) { - throw new IllegalStateException("River hydrology read exceeded the prepared mantle radius at " - + chunkX + "," + chunkZ); - } - chunk.raiseFlagSuspend(ReservedFlag.CARVED, () -> carving.generateLayer(writer, chunkX, chunkZ, context)); - } - - private void publishTunnels( - MantleWriter writer, - ChunkContext context, - IrisRiverRuntime runtime, - int chunkX, - int chunkZ - ) { - for (int attempt = 0; attempt < 2; attempt++) { - MantleRiverCaveVoxelView view = createView( - writer, - context, - RiverCaveFluidKind.RIVER - ); - TunnelPlan plan = planTunnels( - view, - chunkX, - chunkZ, - tunnelHalo(runtime), - runtime.maximumTunnelHeadroom(), - runtime::sampleFootprint, - runtime::sampleTunnel, - runtime::sample - ); - MantleRiverCaveVoxelView revalidationView = createView( - writer, - context, - RiverCaveFluidKind.RIVER - ); - if (preconditionsHold(revalidationView, plan.preconditions())) { - publishTunnelLocal(writer, chunkX, chunkZ, plan); - return; - } - } - } - - static RiverCavePlannerSettings plannerSettings(IrisRiverCaves caves, long seed, IrisData data) { - int horizontalRadius = generatedGrottoPublicationRadius(caves); - int maximumDepth = caves.getMaxBoreDepth() + caves.getGrottoVerticalRadius() + 1; - int throatLength = caves.getMaxBoreDepth() + horizontalRadius; - return new RiverCavePlannerSettings( - horizontalRadius, - maximumDepth, - caves.getMaxFloodVolume(), - throatLength, - caves.getThroatRadius(), - caves.getGrottoHorizontalRadius(), - caves.getGrottoVerticalRadius(), - caves.getDryHeadroom(), - fluidPolicy(caves.getExistingFluidPolicy()), - new ConfiguredRiverGrottoShape( - seed, - data, - caves.getGrottoShapeStyle(), - caves.getGrottoWarpStyle(), - caves.getGrottoWarpStrength(), - 0.2D - ), - caves.getMaxFloodRadius(), - caves.getMaxFloodDepth() - ); - } - - private SourceCandidate sourceFor( - IrisRiverRuntime runtime, - CaveVoxelView view, - IrisRiverCaves caves, - RiverAnchor anchor - ) { - int x = (int) StrictMath.floor(anchor.x()); - int z = (int) StrictMath.floor(anchor.z()); - IrisRiverSurfaceSample sample = runtime.sample(x, z); - IrisRiverTunnelSample tunnel = runtime.sampleTunnel(x, z); - if (!isWetChannelBed(sample) && tunnel == null) { - return null; - } - int bedY = tunnel == null - ? (int) Math.round(sample.terrainHeight()) - : tunnel.bedY(); - int headY = tunnel == null - ? waterHeadY(sample, caves) - : tunnel.waterHeadY() + caves.getWaterLevelOffset(); - int entryY = Math.max(bedY, headY); - CavePosition entry = new CavePosition(x, entryY, z); - if (!view.isInWorld(entry)) { - return null; - } - - CavePosition existingTarget = findExistingTarget(view, caves, x, z, bedY, headY); - RiverCaveMode requestedMode = sourceMode( - caves.getMode(), - runtime.isTerminalCaveAnchor(anchor) - ); - CavePosition target; - RiverCaveMode sourceMode; - if (requestedMode == RiverCaveMode.GENERATED_GROTTO) { - target = findGeneratedTarget(view, caves, entry, headY, 0, 0); - sourceMode = RiverCaveMode.GENERATED_GROTTO; - } else if (requestedMode == RiverCaveMode.GROTTO_OR_CLOSED_COMPONENT) { - target = existingTarget; - sourceMode = RiverCaveMode.CLOSED_COMPONENT; - if (target == null) { - target = findGeneratedTarget(view, caves, entry, headY, 0, 0); - sourceMode = RiverCaveMode.GENERATED_GROTTO; - } - } else { - target = existingTarget; - sourceMode = requestedMode; - } - if (target == null && caves.getFallback() == IrisRiverCaveFallback.GENERATE_GROTTO) { - target = findGeneratedTarget(view, caves, entry, headY, 0, 0); - sourceMode = RiverCaveMode.GENERATED_GROTTO; - } - if (target == null) { - return null; - } - RiverCaveSource source = new RiverCaveSource(anchor.stableId(), entry, target, headY, sourceMode); - return new SourceCandidate(entry, headY, source); - } - - static boolean isWetChannelBed(IrisRiverSurfaceSample sample) { - return sample.river().present() - && sample.river().state() == RiverRouteState.WET - && sample.river().section() == RiverSection.CHANNEL - && sample.surfaceFluid(); - } - - static CavePosition findExistingTarget( - CaveVoxelView view, - IrisRiverCaves caves, - int x, - int z, - int bedY, - int headY - ) { - int maximumY = Math.min(bedY - 1, headY); - int minimumY = Math.max(1, bedY - caves.getMaxBoreDepth()); - for (int y = maximumY; y >= minimumY; y--) { - CavePosition position = new CavePosition(x, y, z); - CaveVoxel voxel = view.voxelAt(position); - if (voxel != CaveVoxel.SOLID) { - return position; - } - } - return null; - } - - static CavePosition findGeneratedTarget( - CaveVoxelView view, - IrisRiverCaves caves, - CavePosition entry, - int headY, - int offsetX, - int offsetZ - ) { - int preferredY = Math.min( - headY + caves.getDryHeadroom() - caves.getGrottoVerticalRadius(), - entry.y() - caves.getGrottoVerticalRadius() - 1 - ); - int maximumY = Math.min(Math.min(entry.y() - 1, headY), preferredY); - int minimumY = Math.max(1, entry.y() - caves.getMaxBoreDepth()); - for (int y = maximumY; y >= minimumY; y--) { - CavePosition target = new CavePosition(entry.x() + offsetX, y, entry.z() + offsetZ); - if (view.isInWorld(target) && view.voxelAt(target) == CaveVoxel.SOLID) { - return target; - } - } - return null; - } - - private RiverCaveSource fallbackSource( - CaveVoxelView view, - IrisRiverCaves caves, - RiverCavePlannerSettings settings, - SourceCandidate candidate, - RiverCaveSource rejected - ) { - int fallbackDistance = caves.getThroatRadius() + 2; - for (int index = 0; index < FALLBACK_X.length; index++) { - int offsetX = FALLBACK_X[index] * fallbackDistance; - int offsetZ = FALLBACK_Z[index] * fallbackDistance; - CavePosition target = findGeneratedTarget( - view, - caves, - candidate.entry(), - candidate.waterHeadY(), - offsetX, - offsetZ - ); - if (target == null || target.equals(rejected.target())) { - continue; - } - RiverCaveSource fallback = new RiverCaveSource( - rejected.sourceId(), - candidate.entry(), - target, - candidate.waterHeadY(), - RiverCaveMode.GENERATED_GROTTO - ); - if (planner.plan(view, fallback, settings).accepted()) { - return fallback; - } - } - return null; - } - - static RiverCaveMode sourceMode(IrisRiverCaveMode mode, boolean forcedTerminal) { - if (forcedTerminal) { - return RiverCaveMode.GENERATED_GROTTO; - } - return switch (mode) { - case FLOOD_CLOSED_COMPONENT -> RiverCaveMode.CLOSED_COMPONENT; - case GENERATE_GROTTO -> RiverCaveMode.GENERATED_GROTTO; - case GROTTO_OR_CLOSED_COMPONENT -> RiverCaveMode.GROTTO_OR_CLOSED_COMPONENT; - case WATERFALL_POOL -> RiverCaveMode.WATERFALL_POOL; - case SEALED -> throw new IllegalArgumentException("Sealed river caves do not create sources"); - }; - } - - private void publishLocal( - MantleWriter writer, - int chunkX, - int chunkZ, - RiverCavePlanningResult result, - Map floodedBiomes, - RiverCaveFluidKind fluidKind - ) { - Map owners = actionOwners(result); - ArrayList> actions = new ArrayList<>(result.actions().entrySet()); - actions.sort(ACTION_ORDER); - for (Map.Entry entry : actions) { - CavePosition position = entry.getKey(); - if (!owns(chunkX, chunkZ, position)) { - continue; - } - RiverCaveSource source = owners.get(position); - String biome = source == null ? "" : floodedBiomes.getOrDefault(source.sourceId(), ""); - if (entry.getValue() == RiverCaveAction.SEAL_GUARD) { - biome = ""; - } - writer.setData( - position.x(), - position.y(), - position.z(), - new RiverCaveHydrology(entry.getValue(), biome, fluidKind) - ); - } - } - - private void publishTunnelLocal( - MantleWriter writer, - int chunkX, - int chunkZ, - TunnelPlan plan - ) { - ArrayList> actions = new ArrayList<>(plan.actions().entrySet()); - actions.sort(ACTION_ORDER); - for (Map.Entry entry : actions) { - CavePosition position = entry.getKey(); - if (owns(chunkX, chunkZ, position)) { - writer.setData( - position.x(), - position.y(), - position.z(), - RiverCaveHydrology.of(entry.getValue(), RiverCaveFluidKind.RIVER) - ); - } - } - } - - private Map actionOwners(RiverCavePlanningResult result) { - Map owners = new LinkedHashMap<>(); - for (RiverCavePlan plan : result.plans()) { - if (!plan.accepted()) { - continue; - } - for (CavePosition position : plan.actions().keySet()) { - owners.put(position, plan.source()); - } - } - return owners; - } - - private record SourceCandidate( - CavePosition entry, - int waterHeadY, - RiverCaveSource source - ) { - } - - @FunctionalInterface - interface FootprintSampler { - RiverSample sample(double minimumX, double minimumZ, double maximumX, double maximumZ); - } - - @FunctionalInterface - interface TunnelSampler { - IrisRiverTunnelSample sample(int x, int z); - } - - @FunctionalInterface - interface SurfaceSampler { - IrisRiverSurfaceSample sample(int x, int z); - } - - interface TunnelVoxelView extends CaveVoxelView { - RiverCaveHydrology riverHydrologyAt(CavePosition position); - } - - record TunnelPlan( - Map actions, - Map preconditions - ) { - static TunnelPlan empty() { - return new TunnelPlan(Map.of(), Map.of()); - } - } - - private record TunnelColumn( - int x, - int z, - int minimumY, - int waterHeadY, - int maximumY - ) { - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransaction.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransaction.java new file mode 100644 index 000000000..86674bc38 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransaction.java @@ -0,0 +1,366 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.mantle.EngineMantle; +import art.arcane.iris.engine.mantle.MantleWriter; +import art.arcane.iris.engine.object.TileData; +import art.arcane.iris.engine.object.IObjectPlacer; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +final class ObjectDestinationTransaction implements ObjectPassPlacer { + private static final Object CLEARED = new Object(); + + private final MantleWriter writer; + private final int destinationChunkX; + private final int destinationChunkZ; + private final int worldHeight; + private final List mutations; + private final Map overlay; + + ObjectDestinationTransaction(MantleWriter writer, int destinationChunkX, int destinationChunkZ) { + this.writer = writer; + this.destinationChunkX = destinationChunkX; + this.destinationChunkZ = destinationChunkZ; + this.worldHeight = writer.getMantle().getWorldHeight(); + this.mutations = new ArrayList<>(); + this.overlay = new HashMap<>(); + } + + void commit() { + writer.withChunkFence(destinationChunkX, destinationChunkZ, this::commitFenced); + } + + int mutationCheckpoint() { + return mutations.size(); + } + + ObjectSourcePlan sourcePlanSince(int checkpoint) { + if (checkpoint < 0 || checkpoint > mutations.size()) { + throw new IllegalArgumentException("Mutation checkpoint is outside the transaction"); + } + return new ObjectSourcePlan(mutations.subList(checkpoint, mutations.size())); + } + + void apply(ObjectSourcePlan plan) { + for (Mutation mutation : plan.mutations()) { + mutation.apply(this); + } + } + + private void commitFenced() { + LinkedHashMap originals = new LinkedHashMap<>(); + try { + for (Mutation mutation : mutations) { + if (!isDestination(mutation.x(), mutation.z())) { + continue; + } + captureOriginal(originals, mutation.key()); + if (mutation.key().type() == PlatformBlockState.class) { + captureOriginal(originals, new DataKey( + mutation.key().x(), + mutation.key().y(), + mutation.key().z(), + Identifier.class + )); + } + mutation.apply(writer); + } + } catch (Throwable failure) { + rollback(originals, failure); + if (failure instanceof RuntimeException runtimeException) { + throw runtimeException; + } + if (failure instanceof Error error) { + throw error; + } + throw new IllegalStateException("Object destination publication failed", failure); + } + } + + @Override + public int getHighest(int x, int z, IrisData data) { + return writer.getHighest(x, z, data); + } + + @Override + public int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { + return writer.getHighest(x, z, data, ignoreFluid); + } + + @Override + public void set(int x, int y, int z, PlatformBlockState state) { + if (state == null) { + return; + } + String placementKey = state.deferredPlacementKey(); + PlatformBlockState baseState = state.placementBaseState(); + if (state.isCustom() && placementKey != null && baseState != null) { + if (!canSetBlock(x, y, z)) { + return; + } + DataKey blockKey = new DataKey(x, y, z, PlatformBlockState.class); + Identifier identifier = Identifier.fromString(placementKey); + overlay.put(blockKey, baseState); + overlay.put(new DataKey(x, y, z, Identifier.class), identifier); + mutations.add(new CustomBlockMutation(blockKey, state)); + return; + } + setData(x, y, z, state); + } + + @Override + public PlatformBlockState get(int x, int y, int z) { + DataKey key = new DataKey(x, y, z, PlatformBlockState.class); + Object value = overlay.get(key); + if (value == CLEARED) { + return EngineMantle.AIR; + } + return value instanceof PlatformBlockState state + ? state + : writer.getPrerequisiteBlock(x, y, z); + } + + @Override + public boolean isPreventingDecay() { + return writer.isPreventingDecay(); + } + + @Override + public boolean isCarved(int x, int y, int z) { + HydrologyCaveCell hydrology = getDataIfPresent(x, y, z, HydrologyCaveCell.class); + if (hydrology != null) { + return hydrology.carves(); + } + MatterCavern cavern = getDataIfPresent(x, y, z, MatterCavern.class); + return cavern != null || writer.isPrerequisiteCarved(x, y, z); + } + + @Override + public boolean isSurfaceSolid(int x, int y, int z) { + return writer.isSurfaceSolid(x, y, z); + } + + @Override + public boolean isSolid(int x, int y, int z) { + return B.isSolid(get(x, y, z)); + } + + @Override + public boolean isUnderwater(int x, int z) { + return writer.isUnderwater(x, z); + } + + @Override + public int getFluidHeight() { + return writer.getFluidHeight(); + } + + @Override + public boolean isDebugSmartBore() { + return writer.isDebugSmartBore(); + } + + @Override + public void setTile(int x, int y, int z, TileData tile) { + if (tile != null) { + setData(x, y, z, new TileWrapper(tile)); + } + } + + @Override + public void setData(int x, int y, int z, T data) { + if (data == null || y < 0 || y >= worldHeight) { + return; + } + if (data instanceof PlatformBlockState && !canSetBlock(x, y, z)) { + return; + } + if (data instanceof MatterCavern && hasProtectedHydrology(x, y, z)) { + return; + } + Class type = data instanceof PlatformBlockState ? PlatformBlockState.class : data.getClass(); + if (data instanceof PlatformBlockState) { + overlay.put(new DataKey(x, y, z, Identifier.class), CLEARED); + } + DataKey key = new DataKey(x, y, z, type); + overlay.put(key, data); + mutations.add(new SetMutation(key, data)); + } + + @Override + public @Nullable T getDataIfPresent(int x, int y, int z, Class type) { + DataKey key = new DataKey(x, y, z, type); + Object value = overlay.get(key); + if (value == CLEARED) { + return null; + } + if (value != null) { + return type.cast(value); + } + return writer.getPrerequisiteDataIfPresent(x, y, z, type); + } + + @Override + public byte[] getCarvedColumn(int x, int z, int height) { + int cappedHeight = Math.min(Math.max(height, 0), worldHeight); + byte[] carved = writer.getPrerequisiteCarvedColumn(x, z, cappedHeight); + if (carved.length != cappedHeight) { + byte[] resized = new byte[cappedHeight]; + System.arraycopy(carved, 0, resized, 0, Math.min(carved.length, cappedHeight)); + carved = resized; + } else { + carved = carved.clone(); + } + for (int y = 0; y < cappedHeight; y++) { + DataKey hydrologyKey = new DataKey(x, y, z, HydrologyCaveCell.class); + Object hydrologyValue = overlay.get(hydrologyKey); + if (hydrologyValue instanceof HydrologyCaveCell hydrology) { + carved[y] = hydrology.carves() ? (byte) 1 : 0; + continue; + } + DataKey cavernKey = new DataKey(x, y, z, MatterCavern.class); + Object cavernValue = overlay.get(cavernKey); + if (cavernValue != null) { + carved[y] = cavernValue == CLEARED ? (byte) 0 : (byte) 1; + } + } + return carved; + } + + @Override + public Engine getEngine() { + return writer.getEngine(); + } + + private Object prerequisiteOrCleared(int x, int y, int z, Class type) { + Object value = writer.getPrerequisiteDataIfPresent(x, y, z, type); + return value == null ? CLEARED : value; + } + + private void captureOriginal(LinkedHashMap originals, DataKey key) { + originals.computeIfAbsent( + key, + candidate -> prerequisiteOrCleared( + candidate.x(), + candidate.y(), + candidate.z(), + candidate.type() + ) + ); + } + + private boolean isDestination(int x, int z) { + return (x >> 4) == destinationChunkX && (z >> 4) == destinationChunkZ; + } + + private boolean canSetBlock(int x, int y, int z) { + if (y < 0 || y >= worldHeight) { + return false; + } + if (y == 0 && writer.getEngine().getDimension().isBedrock()) { + return false; + } + return !hasProtectedHydrology(x, y, z); + } + + private boolean hasProtectedHydrology(int x, int y, int z) { + HydrologyCaveCell hydrology = getDataIfPresent(x, y, z, HydrologyCaveCell.class); + return hydrology != null && hydrology.protectsPlacement(); + } + + private void rollback(LinkedHashMap originals, Throwable failure) { + ArrayList> entries = new ArrayList<>(originals.entrySet()); + entries.sort((first, second) -> Boolean.compare( + second.getKey().type() == PlatformBlockState.class, + first.getKey().type() == PlatformBlockState.class + )); + for (Map.Entry entry : entries) { + DataKey key = entry.getKey(); + try { + if (key.type() == PlatformBlockState.class + && writer.restorePrerequisiteCell(key.x(), key.y(), key.z())) { + continue; + } + if (writer.restorePrerequisiteData(key.x(), key.y(), key.z(), key.type())) { + continue; + } + writer.clearData(key.x(), key.y(), key.z(), key.type()); + Object original = entry.getValue(); + if (original != CLEARED) { + writer.setData(key.x(), key.y(), key.z(), original); + } + } catch (Throwable rollbackFailure) { + failure.addSuppressed(rollbackFailure); + } + } + } + + record DataKey(int x, int y, int z, Class type) { + } + + interface Mutation { + int x(); + + int z(); + + DataKey key(); + + void apply(IObjectPlacer placer); + + default int weight() { + return 1; + } + } + + record SetMutation(DataKey key, Object value) implements Mutation { + @Override + public int x() { + return key.x(); + } + + @Override + public int z() { + return key.z(); + } + + @Override + public void apply(IObjectPlacer placer) { + placer.setData(key.x(), key.y(), key.z(), value); + } + } + + record CustomBlockMutation(DataKey key, PlatformBlockState state) implements Mutation { + @Override + public int x() { + return key.x(); + } + + @Override + public int z() { + return key.z(); + } + + @Override + public void apply(IObjectPlacer placer) { + placer.set(key.x(), key.y(), key.z(), state); + } + + @Override + public int weight() { + return 2; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectPassPlacer.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectPassPlacer.java new file mode 100644 index 000000000..3e6ca45e0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectPassPlacer.java @@ -0,0 +1,15 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.object.IObjectPlacer; +import org.jetbrains.annotations.Nullable; + +public interface ObjectPassPlacer extends IObjectPlacer { + @Nullable T getDataIfPresent(int x, int y, int z, Class type); + + byte[] getCarvedColumn(int x, int z, int height); + + @Override + default @Nullable T getData(int x, int y, int z, Class type) { + return getDataIfPresent(x, y, z, type); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlan.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlan.java new file mode 100644 index 000000000..87e68e3a1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlan.java @@ -0,0 +1,25 @@ +package art.arcane.iris.engine.mantle.components; + +import java.util.List; + +final class ObjectSourcePlan { + private final List mutations; + private final int mutationWeight; + + ObjectSourcePlan(List mutations) { + this.mutations = List.copyOf(mutations); + long weight = 1L; + for (ObjectDestinationTransaction.Mutation mutation : this.mutations) { + weight += mutation.weight(); + } + this.mutationWeight = weight >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) weight; + } + + List mutations() { + return mutations; + } + + int mutationWeight() { + return mutationWeight; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlanCache.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlanCache.java new file mode 100644 index 000000000..75a6ba81e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlanCache.java @@ -0,0 +1,43 @@ +package art.arcane.iris.engine.mantle.components; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +import java.util.Objects; +import java.util.function.Supplier; + +import static art.arcane.iris.engine.data.cache.Cache.key; + +final class ObjectSourcePlanCache { + static final long MAXIMUM_MUTATION_WEIGHT = 1_048_576L; + + private final Cache plans; + + ObjectSourcePlanCache() { + this(MAXIMUM_MUTATION_WEIGHT); + } + + ObjectSourcePlanCache(long maximumMutationWeight) { + if (maximumMutationWeight <= 0L) { + throw new IllegalArgumentException("Maximum mutation weight must be positive"); + } + this.plans = Caffeine.newBuilder() + .maximumWeight(maximumMutationWeight) + .weigher((Long key, ObjectSourcePlan plan) -> plan.mutationWeight()) + .build(); + } + + ObjectSourcePlan get(int sourceChunkX, int sourceChunkZ, Supplier builder) { + Objects.requireNonNull(builder, "Source plan builder"); + return plans.get(key(sourceChunkX, sourceChunkZ), ignored -> builder.get()); + } + + void clear() { + plans.invalidateAll(); + } + + long estimatedSize() { + plans.cleanUp(); + return plans.estimatedSize(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java index a2f1e161c..eb4f301c1 100644 --- a/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java +++ b/core/src/main/java/art/arcane/iris/engine/mode/ModeOverworld.java @@ -130,4 +130,5 @@ public class ModeOverworld extends IrisEngineMode implements EngineMode { } return active; } + } diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java index 489af7a6b..9ab217c0b 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveModifier.java @@ -28,9 +28,8 @@ import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; import art.arcane.iris.engine.object.IrisProceduralBlocks; -import art.arcane.iris.engine.object.IrisRiverCaves; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.documentation.ChunkCoordinates; @@ -59,8 +58,7 @@ public class IrisCarveModifier extends EngineAssignedModifier { + mantleChunk.iterate(HydrologyCaveCell.class, (xx, yy, zz, hydrology) -> { if (dataIfPresent(mantleChunk, xx, yy, zz, MatterCavern.class) == null) { carveResolver.apply(xx, yy, zz, null, hydrology); } @@ -144,9 +142,9 @@ public class IrisCarveModifier extends EngineAssignedModifier { - RiverCaveHydrology hydrology = dataIfPresent( - mantleChunk, rx, yy, rz, RiverCaveHydrology.class); + walls.forEach((rx, yy, rz, cavern) -> { + HydrologyCaveCell hydrology = dataIfPresent( + mantleChunk, rx, yy, rz, HydrologyCaveCell.class); if (hydrology != null && hydrology.protectsPlacement()) { return; } @@ -245,6 +243,10 @@ public class IrisCarveModifier extends EngineAssignedModifier= context.worldHeightSpan() || y <= 0) { return; @@ -345,7 +347,7 @@ public class IrisCarveModifier extends EngineAssignedModifier= 0) { if (rz < 15 && !columnMasks[columnIndex + 1].contains(yy)) { - walls.put(rx, yy, rz + 1, BASIC_CAVERN, false); + walls.put(rx, yy, rz + 1, BASIC_CAVERN); } if (rx < 15 && !columnMasks[columnIndex + 16].contains(yy)) { - walls.put(rx + 1, yy, rz, BASIC_CAVERN, false); + walls.put(rx + 1, yy, rz, BASIC_CAVERN); } if (rz > 0 && !columnMasks[columnIndex - 1].contains(yy)) { - walls.put(rx, yy, rz - 1, BASIC_CAVERN, false); + walls.put(rx, yy, rz - 1, BASIC_CAVERN); } if (rx > 0 && !columnMasks[columnIndex - 16].contains(yy)) { - walls.put(rx - 1, yy, rz, BASIC_CAVERN, false); + walls.put(rx - 1, yy, rz, BASIC_CAVERN); } yy = columnMask.nextSetBit(yy + 1); } @@ -448,19 +450,17 @@ public class IrisCarveModifier extends EngineAssignedModifier= 0) { MatterCavern cavern = composedCavernAt(mc, rx, yy, rz); if (cavern != null) { - RiverCaveHydrology hydrology = dataIfPresent(mc, rx, yy, rz, RiverCaveHydrology.class); - boolean riverBoundary = hydrology != null && !hydrology.floodedBiomeKey().isEmpty(); if (rz < 15 && composedCavernAt(mc, rx, yy, rz + 1) == null) { - walls.put(rx, yy, rz + 1, cavern, riverBoundary); + walls.put(rx, yy, rz + 1, cavern); } if (rx < 15 && composedCavernAt(mc, rx + 1, yy, rz) == null) { - walls.put(rx + 1, yy, rz, cavern, riverBoundary); + walls.put(rx + 1, yy, rz, cavern); } if (rz > 0 && composedCavernAt(mc, rx, yy, rz - 1) == null) { - walls.put(rx, yy, rz - 1, cavern, riverBoundary); + walls.put(rx, yy, rz - 1, cavern); } if (rx > 0 && composedCavernAt(mc, rx - 1, yy, rz) == null) { - walls.put(rx - 1, yy, rz, cavern, riverBoundary); + walls.put(rx - 1, yy, rz, cavern); } } yy = columnMask.nextSetBit(yy + 1); @@ -536,10 +536,7 @@ public class IrisCarveModifier extends EngineAssignedModifier mantleChunk, int x, int y, int z) { MatterCavern baseline = dataIfPresent(mantleChunk, x, y, z, MatterCavern.class); - RiverCaveHydrology hydrology = dataIfPresent(mantleChunk, x, y, z, RiverCaveHydrology.class); + HydrologyCaveCell hydrology = dataIfPresent(mantleChunk, x, y, z, HydrologyCaveCell.class); return composeCavern(baseline, hydrology); } @@ -682,17 +679,17 @@ public class IrisCarveModifier extends EngineAssignedModifier floorLayers = floorBiome.generateLayers( getDimension(), worldX, worldZ, rng, 3, zoneFloor, getData(), getComplex()); for (int i = 0; i < zoneFloor - 1; i++) { @@ -703,17 +700,18 @@ public class IrisCarveModifier extends EngineAssignedModifier= worldMaxY) { break; } - RiverCaveHydrology hydrology = dataIfPresent( - mantleChunk, rx, ceilingY, rz, RiverCaveHydrology.class); + HydrologyCaveCell hydrology = dataIfPresent( + mantleChunk, rx, ceilingY, rz, HydrologyCaveCell.class); if (hydrology != null && hydrology.protectsPlacement() - && hydrology.action() != RiverCaveAction.SEAL_GUARD) { + && hydrology.action() != HydrologyCaveAction.SEAL_GUARD) { continue; } PlatformBlockState existing = output.getRaw(rx, ceilingY, rz); @@ -746,7 +744,7 @@ public class IrisCarveModifier extends EngineAssignedModifier floorBlocks = floorBiome.generateLayers(getDimension(), xx, zz, rng, 3, zone.floor, getData(), getComplex()); for (int i = 0; i < zone.floor - 1; i++) { if (!floorBlocks.hasIndex(i)) { break; } int y = zone.floor - i - 1; - RiverCaveHydrology hydrology = dataIfPresent(mc, rx, y, rz, RiverCaveHydrology.class); + HydrologyCaveCell hydrology = dataIfPresent(mc, rx, y, rz, HydrologyCaveCell.class); if (hydrology != null && hydrology.protectsPlacement() - && hydrology.action() != RiverCaveAction.SEAL_GUARD) { + && hydrology.action() != HydrologyCaveAction.SEAL_GUARD) { continue; } - PlatformBlockState block = floorBlocks.get(i); + PlatformBlockState block = resolveSubmergedCaveFloorLayer( + output, rx, y, rz, floorBlocks.get(i), floorHydrology); PlatformBlockState existing = output.getRaw(rx, y, rz); if (!B.isSolid(existing) - || !canReplaceRiverGuard(hydrology, block, false) + || !canReplaceHydrologyGuard(hydrology, block, false) || !canReplaceCaveFloorLayer(output, rx, y, rz, block)) { continue; } @@ -838,15 +839,15 @@ public class IrisCarveModifier extends EngineAssignedModifier= maxY) { break; } - RiverCaveHydrology hydrology = dataIfPresent(mc, rx, cy, rz, RiverCaveHydrology.class); + HydrologyCaveCell hydrology = dataIfPresent(mc, rx, cy, rz, HydrologyCaveCell.class); if (hydrology != null && hydrology.protectsPlacement() - && hydrology.action() != RiverCaveAction.SEAL_GUARD) { + && hydrology.action() != HydrologyCaveAction.SEAL_GUARD) { continue; } PlatformBlockState block = ceilingBlocks.get(i); PlatformBlockState existing = output.getRaw(rx, cy, rz); - if (!B.isSolid(existing) || !canReplaceRiverGuard(hydrology, block, true)) { + if (!B.isSolid(existing) || !canReplaceHydrologyGuard(hydrology, block, true)) { continue; } if (B.isOre(existing)) { @@ -886,16 +887,16 @@ public class IrisCarveModifier extends EngineAssignedModifier mantleChunk, int x, int y, int z, int worldX, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { MatterCavern cavern = composedCavernAt(mantleChunk, x, y, z); - RiverCaveHydrology hydrology = dataIfPresent( - mantleChunk, x, y, z, RiverCaveHydrology.class); return resolveCaveBoundaryBiome( - cavern, worldX, y, worldZ, resolverState, caveBiomeCache, customBiomeCache, - hydrology != null && !hydrology.floodedBiomeKey().isEmpty()); + cavern, worldX, y, worldZ, resolverState, caveBiomeCache, customBiomeCache); } IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { - return resolveCaveBoundaryBiome( - cavern, worldX, y, worldZ, resolverState, caveBiomeCache, customBiomeCache, false); - } - - private IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache, boolean riverBoundary) { if (cavern != null && !cavern.getCustomBiome().isEmpty()) { - if (riverBoundary && selectsParentRiverBiome( - getEngine().getSeedManager().getCarve(), - worldX, - worldZ, - riverParentBiomeInheritance())) { - IrisBiome parent = resolveRiverParentBiome( - caveBiomeCache, worldX, y, worldZ, resolverState); - if (parent != null) { - return parent; - } - } return resolveCustomBiome(customBiomeCache, cavern.getCustomBiome()); } return resolveCaveBiome(caveBiomeCache, worldX, y, worldZ, resolverState); } - static boolean selectsParentRiverBiome(long seed, int worldX, int worldZ, double inheritance) { - if (inheritance <= 0D) { - return false; - } - if (inheritance >= 1D) { - return true; - } - int cellX = Math.floorDiv(worldX, RIVER_BIOME_INHERITANCE_CELL_SIZE); - int cellZ = Math.floorDiv(worldZ, RIVER_BIOME_INHERITANCE_CELL_SIZE); - long hash = (seed + RIVER_BIOME_INHERITANCE_SALT) ^ BlockPosition.toLong(cellX, 0, cellZ); - hash = (hash ^ (hash >>> 30)) * 0xBF58476D1CE4E5B9L; - hash = (hash ^ (hash >>> 27)) * 0x94D049BB133111EBL; - hash ^= hash >>> 31; - double roll = (hash >>> 11) * 0x1.0p-53; - return roll < inheritance; - } - - static boolean canReplaceRiverGuard( - RiverCaveHydrology hydrology, + static boolean canReplaceHydrologyGuard( + HydrologyCaveCell hydrology, PlatformBlockState layer, boolean ceiling ) { - if (hydrology == null || hydrology.action() != RiverCaveAction.SEAL_GUARD) { + if (hydrology == null || hydrology.action() != HydrologyCaveAction.SEAL_GUARD) { return true; } return layer != null @@ -974,33 +939,43 @@ public class IrisCarveModifier extends EngineAssignedModifier caveBiomeCache, - int worldX, - int y, - int worldZ, - IrisDimensionCarvingResolver.State resolverState - ) { - IrisBiome parent = resolveCaveBiome(caveBiomeCache, worldX, y, worldZ, resolverState); - if (parent != null && parent == getEngine().getSurfaceBiome(worldX, worldZ)) { - IrisBiome natural = getComplex().getNaturalTrueBiomeStream().get(worldX, worldZ); - return natural == null ? parent : natural; - } - return parent; - } static boolean canReplaceCaveFloorLayer(Hunk output, int x, int y, int z, PlatformBlockState layer) { return !isGravityAffected(layer) || y > 0 && B.isSolid(output.getRaw(x, y - 1, z)); } + static PlatformBlockState resolveSubmergedCaveFloorLayer( + Hunk output, + int x, + int y, + int z, + PlatformBlockState layer, + HydrologyCaveCell hydrologyAbove + ) { + if (hydrologyAbove == null || !hydrologyAbove.isWet() || !isVegetatedHydrologyBed(layer)) { + return layer; + } + int minimumY = Math.max(0, y - SUBMERGED_FLOOR_SUBSTRATE_DEPTH); + for (int substrateY = y - 1; substrateY >= minimumY; substrateY--) { + PlatformBlockState substrate = output.getRaw(x, substrateY, z); + if (B.isSolid(substrate) + && !B.isFluid(substrate) + && !isVegetatedHydrologyBed(substrate) + && !isGravityAffected(substrate)) { + return substrate; + } + } + return layer; + } + + private static boolean isVegetatedHydrologyBed(PlatformBlockState state) { + if (state == null) { + return false; + } + String key = IrisProceduralBlocks.materialKey(state); + return key.equals("minecraft:grass_block") || key.equals("minecraft:moss_block"); + } + static boolean hasStableCaveFloorSupport(Hunk output, int x, int floorY, int z) { if (floorY <= 0) { return false; diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveScratch.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveScratch.java index 8397feef3..678fbd38c 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveScratch.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisCarveScratch.java @@ -140,7 +140,6 @@ final class CarveWallBuffer { private int[] keys; private MatterCavern[] values; - private boolean[] riverBoundaries; private int mask; private int resizeAt; private int size; @@ -155,12 +154,11 @@ final class CarveWallBuffer { keys = new int[capacity]; Arrays.fill(keys, EMPTY_KEY); values = new MatterCavern[capacity]; - riverBoundaries = new boolean[capacity]; mask = capacity - 1; resizeAt = Math.max(1, (int) (capacity * LOAD_FACTOR)); } - void put(int x, int y, int z, MatterCavern value, boolean riverBoundary) { + void put(int x, int y, int z, MatterCavern value) { int key = pack(x, y, z); int index = mix(key) & mask; @@ -169,7 +167,6 @@ final class CarveWallBuffer { if (existingKey == EMPTY_KEY) { keys[index] = key; values[index] = value; - riverBoundaries[index] = riverBoundary; size++; if (size >= resizeAt) { resize(); @@ -179,7 +176,6 @@ final class CarveWallBuffer { if (existingKey == key) { values[index] = value; - riverBoundaries[index] = riverBoundaries[index] || riverBoundary; return; } @@ -202,21 +198,6 @@ final class CarveWallBuffer { } } - boolean isRiverBoundary(int x, int y, int z) { - int key = pack(x, y, z); - int index = mix(key) & mask; - while (true) { - int existingKey = keys[index]; - if (existingKey == EMPTY_KEY) { - return false; - } - if (existingKey == key) { - return riverBoundaries[index]; - } - index = (index + 1) & mask; - } - } - void forEach(Consumer consumer) { for (int index = 0; index < keys.length; index++) { int key = keys[index]; @@ -226,7 +207,7 @@ final class CarveWallBuffer { MatterCavern cavern = values[index]; if (cavern != null) { - consumer.accept(unpackX(key), unpackY(key), unpackZ(key), cavern, riverBoundaries[index]); + consumer.accept(unpackX(key), unpackY(key), unpackZ(key), cavern); } } } @@ -234,19 +215,16 @@ final class CarveWallBuffer { void clear() { Arrays.fill(keys, EMPTY_KEY); Arrays.fill(values, null); - Arrays.fill(riverBoundaries, false); size = 0; } private void resize() { int[] oldKeys = keys; MatterCavern[] oldValues = values; - boolean[] oldRiverBoundaries = riverBoundaries; int nextCapacity = oldKeys.length << 1; keys = new int[nextCapacity]; Arrays.fill(keys, EMPTY_KEY); values = new MatterCavern[nextCapacity]; - riverBoundaries = new boolean[nextCapacity]; mask = nextCapacity - 1; resizeAt = Math.max(1, (int) (nextCapacity * LOAD_FACTOR)); size = 0; @@ -255,12 +233,12 @@ final class CarveWallBuffer { int key = oldKeys[index]; MatterCavern value = oldValues[index]; if (key != EMPTY_KEY && value != null) { - reinsert(key, value, oldRiverBoundaries[index]); + reinsert(key, value); } } } - private void reinsert(int key, MatterCavern value, boolean riverBoundary) { + private void reinsert(int key, MatterCavern value) { int index = mix(key) & mask; while (keys[index] != EMPTY_KEY) { index = (index + 1) & mask; @@ -268,7 +246,6 @@ final class CarveWallBuffer { keys[index] = key; values[index] = value; - riverBoundaries[index] = riverBoundary; size++; } @@ -295,6 +272,6 @@ final class CarveWallBuffer { @FunctionalInterface interface Consumer { - void accept(int x, int y, int z, MatterCavern cavern, boolean riverBoundary); + void accept(int x, int y, int z, MatterCavern cavern); } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java index 4841cbf1f..985dcae2f 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiome.java @@ -130,8 +130,8 @@ public class IrisBiome extends IrisRegistrant implements IRare { private int lockLayersMax = 7; @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); - @Desc("Biome-level river routing, shape, cave-entry, and biome-pool overrides. Omit to inherit region and dimension settings.") - private IrisRiverOverride riverOverride = null; + @Desc("Biome-level river placement, routing, profile, biome, and geometry policy. Omit to inherit region and dimension settings.") + private IrisRiverPolicy riverPolicy = null; @MinNumber(1) @MaxNumber(512) @Desc("The rarity of this biome (integer)") diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomePaletteLayer.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomePaletteLayer.java index 693f98eba..a39845070 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomePaletteLayer.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomePaletteLayer.java @@ -20,6 +20,8 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.data.cache.LazyBoundedCache; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.DependsOn; import art.arcane.iris.engine.object.annotations.Desc; @@ -30,12 +32,17 @@ import art.arcane.iris.engine.object.annotations.Snippet; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.noise.CNG; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; import art.arcane.iris.spi.PlatformBlockState; +import lombok.Setter; import lombok.experimental.Accessors; +import java.util.concurrent.atomic.AtomicReference; + @Snippet("biome-palette") @Accessors(chain = true) @NoArgsConstructor @@ -43,8 +50,16 @@ import lombok.experimental.Accessors; @Desc("A layer of surface / subsurface material in biomes") @Data public class IrisBiomePaletteLayer { + private static final int LAYER_GENERATOR_CACHE_SIZE = 32; + private final transient AtomicCache> blockData = new AtomicCache<>(); - private final transient AtomicCache layerGenerator = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache layerGenerators = + new LazyBoundedCache<>(LAYER_GENERATOR_CACHE_SIZE); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient AtomicReference recentLayerGenerator = new AtomicReference<>(); private final transient AtomicCache heightGenerator = new AtomicCache<>(); @Desc("The style of noise") private IrisGeneratorStyle style = NoiseStyle.STATIC.style(); @@ -112,17 +127,20 @@ public class IrisBiomePaletteLayer { } public CNG getLayerGenerator(RNG parent, int signature, IrisData data) { - CNG cached = layerGenerator.getIfPresent(); - - if (cached != null) { - return cached; + Engine engine = data == null ? null : data.getEngine(); + long generatorSeed = parent.getSeed() + signature + minHeight + maxHeight + getBlockData(data).size(); + CachedLayerGenerator recent = recentLayerGenerator.get(); + if (recent != null && recent.key.matches(data, engine, generatorSeed)) { + return recent.generator; } - return layerGenerator.aquire(() -> - { - RNG rngx = parent.nextParallelRNG(signature).nextParallelRNG(minHeight + maxHeight + getBlockData(data).size()); - return style.create(rngx, data); - }); + LayerGeneratorKey key = new LayerGeneratorKey(data, engine, generatorSeed); + CNG generator = layerGenerators.computeIfAbsent(key, + ignored -> style.create(new RNG(generatorSeed), data, engine)); + if (generator != null) { + recentLayerGenerator.set(new CachedLayerGenerator(key, generator)); + } + return generator; } public KList add(String b) { @@ -158,4 +176,49 @@ public class IrisBiomePaletteLayer { palette.clear(); return this; } + + private static final class LayerGeneratorKey { + private final IrisData data; + private final Engine engine; + private final long seed; + + private LayerGeneratorKey(IrisData data, Engine engine, long seed) { + this.data = data; + this.engine = engine; + this.seed = seed; + } + + private boolean matches(IrisData data, Engine engine, long seed) { + return this.data == data && this.engine == engine && this.seed == seed; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof LayerGeneratorKey other)) { + return false; + } + return data == other.data && engine == other.engine && seed == other.seed; + } + + @Override + public int hashCode() { + int result = System.identityHashCode(data); + result = 31 * result + System.identityHashCode(engine); + result = 31 * result + Long.hashCode(seed); + return result; + } + } + + private static final class CachedLayerGenerator { + private final LayerGeneratorKey key; + private final CNG generator; + + private CachedLayerGenerator(LayerGeneratorKey key, CNG generator) { + this.key = key; + this.generator = generator; + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisCoastalRiverGrottoConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisCoastalRiverGrottoConfig.java new file mode 100644 index 000000000..c02ba3384 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisCoastalRiverGrottoConfig.java @@ -0,0 +1,40 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls direct, contained river-to-ocean grotto outlets.") +@Data +public class IrisCoastalRiverGrottoConfig { + @Desc("Allow a coastal grotto to serve as an explicit accepted outlet.") + private boolean enabled = true; + + @Desc("Hydraulic level assigned to the contained coastal grotto pool.") + private IrisGrottoPoolLevel poolLevel = IrisGrottoPoolLevel.SEA_LEVEL; + + @MinNumber(1) + @MaxNumber(128) + @Desc("Maximum horizontal radius in blocks of an accepted coastal grotto.") + private int horizontalRadius = 12; + + @MinNumber(1) + @MaxNumber(64) + @Desc("Maximum vertical radius in blocks of an accepted coastal grotto.") + private int verticalRadius = 7; + + @MinNumber(1) + @MaxNumber(63) + @Desc("Required dry clearance above a coastal grotto pool.") + private int headroom = 10; + + @MinNumber(1) + @MaxNumber(1048576) + @Desc("Maximum accepted coastal grotto volume in blocks.") + private int maximumVolume = 8192; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDeepFluidConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDeepFluidConfig.java new file mode 100644 index 000000000..1b70e4404 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDeepFluidConfig.java @@ -0,0 +1,71 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.Required; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Independent deep-fluid configuration outside the surface and underground river source budgets.") +@Data +public class IrisDeepFluidConfig { + @Required + @Desc("Deep-fluid system identifier.") + private String id = "deep_lava"; + + @Required + @Desc("Fluid palette used by accepted deep pools and short channels.") + private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("lava"); + + @MinNumber(0) + @MaxNumber(64) + @Desc("Expected independent deep-fluid sources per hydrology tile.") + private double density = 0.125D; + + @MinNumber(16) + @MaxNumber(8192) + @Desc("Nominal spacing in blocks between independent deep-fluid source sites.") + private int spacing = 768; + + @Desc("Deep-fluid elevation in world Y.") + private IrisStyledRange height = new IrisStyledRange( + -192D, + 32D, + new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(1024D) + ); + + @MinNumber(2) + @MaxNumber(128) + @Desc("Maximum horizontal radius in blocks of an accepted deep-fluid body.") + private int horizontalRadius = 14; + + @MinNumber(2) + @MaxNumber(64) + @Desc("Maximum vertical radius in blocks of an accepted deep-fluid body.") + private int verticalRadius = 6; + + @MinNumber(1) + @MaxNumber(32) + @Desc("Wet width in blocks of an accepted short deep-fluid channel.") + private int channelWidth = 3; + + @MinNumber(1) + @MaxNumber(32) + @Desc("Wet depth in blocks of an accepted deep-fluid body.") + private int depth = 1; + + @MinNumber(1) + @MaxNumber(63) + @Desc("Required dry clearance above an accepted deep-fluid body.") + private int headroom = 6; + + @Desc("Allow accepted sources to form fully contained pools.") + private boolean containedPools = true; + + @Desc("Allow accepted sources to form short contained channels.") + private boolean shortChannels = false; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDepositGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDepositGenerator.java index cc9f49615..eef20e1a7 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDepositGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDepositGenerator.java @@ -49,6 +49,9 @@ import lombok.experimental.Accessors; @Desc("Creates ore & other block deposits underground") @Data public class IrisDepositGenerator { + private static final long FNV_OFFSET_BASIS = 0xcbf29ce484222325L; + private static final long FNV_PRIME = 0x100000001b3L; + private final transient ConcurrentMap> objects = new ConcurrentHashMap<>(); private final transient AtomicCache> blockData = new AtomicCache<>(); private final transient AtomicCache ore = new AtomicCache<>(); @@ -135,7 +138,7 @@ public class IrisDepositGenerator { ClumpCacheKey cacheKey = new ClumpCacheKey(engine.getSeedManager().getDeposit(), minSize, maxSize); KList objects = this.objects.computeIfAbsent(cacheKey, key -> { - RNG rngv = new RNG(key.depositSeed() + hashCode()); + RNG rngv = new RNG(key.depositSeed() + stableClumpSalt(rdata)); KList objectsf = new KList<>(); for (int i = 0; i < varience; i++) { @@ -163,7 +166,7 @@ public class IrisDepositGenerator { engine.getSeedManager().getDeposit(), scaledMinSize, scaledMaxSize); KList objects = scaledObjects.computeIfAbsent(cacheKey, key -> { long sizeSeed = ((long) key.minSize() << 32) ^ (key.maxSize() & 0xffffffffL); - RNG rngv = new RNG(key.depositSeed() + hashCode() + sizeSeed); + RNG rngv = new RNG(key.depositSeed() + stableClumpSalt(rdata) + sizeSeed); KList generated = new KList<>(); for (int i = 0; i < varience; i++) { @@ -184,6 +187,64 @@ public class IrisDepositGenerator { return Math.max(0, Math.min(8192, (int) Math.round(size * multiplier))); } + long stableClumpSalt(IrisData rdata) { + long hash = FNV_OFFSET_BASIS; + hash = mix(hash, minHeight); + hash = mix(hash, maxHeight); + hash = mixEnum(hash, heightDistribution); + hash = mixEnum(hash, placementScope); + hash = mix(hash, surfaceClearance); + hash = mix(hash, minSize); + hash = mix(hash, maxSize); + hash = mixEnum(hash, shape); + hash = mix(hash, maxPerChunk); + hash = mix(hash, minPerChunk); + hash = mix(hash, Double.doubleToLongBits(spawnChance)); + hash = mix(hash, Double.doubleToLongBits(perClumpSpawnChance)); + hash = mix(hash, Double.doubleToLongBits(discardChanceOnAirExposure)); + KList resolvedPalette = getBlockData(rdata); + hash = mix(hash, resolvedPalette.size()); + for (PlatformBlockState block : resolvedPalette) { + hash = mixString(hash, block == null ? null : block.key()); + } + hash = mix(hash, varience); + hash = mixStrings(hash, replaceableBlocks); + hash = mixEnum(hash, biomeScope); + hash = mixStrings(hash, includedBiomes); + hash = mixStrings(hash, excludedBiomes); + return mix(hash, replaceBedrock ? 1L : 0L); + } + + private static long mix(long hash, long value) { + return (hash ^ value) * FNV_PRIME; + } + + private static long mixEnum(long hash, Enum value) { + return mixString(hash, value == null ? null : value.name()); + } + + private static long mixString(long hash, String value) { + if (value == null) { + return mix(hash, -1L); + } + long mixed = mix(hash, value.length()); + for (int i = 0; i < value.length(); i++) { + mixed = mix(mixed, value.charAt(i)); + } + return mixed; + } + + private static long mixStrings(long hash, KList values) { + if (values == null) { + return mix(hash, -1L); + } + long mixed = mix(hash, values.size()); + for (String value : values) { + mixed = mixString(mixed, value); + } + return mixed; + } + private IrisObject generateConfiguredClumpObject(RNG rng, IrisData rdata, int clumpMinSize, int clumpMaxSize) { int size = rng.i(clumpMinSize, clumpMaxSize + 1); return switch (shape) { diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index 6433ee185..4b6baa885 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java @@ -165,8 +165,10 @@ public class IrisDimension extends IrisRegistrant { private KList carving = new KList<>(); @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); - @Desc("Connected surface rivers and contained river cave-water generation.") - private IrisRiverNetwork rivers = new IrisRiverNetwork(); + @Desc("Dimension-owned surface, underground, grotto, and deep-fluid hydrology configuration.") + private IrisHydrology hydrology = new IrisHydrology(); + @Desc("Dimension-level river placement, routing, profile, biome, and geometry policy.") + private IrisRiverPolicy riverPolicy = new IrisRiverPolicy(); @Desc("Refuse to place surface objects and trees over carved surface openings.") private boolean requireObjectSurfaceSupport = true; @MinNumber(0) @@ -515,17 +517,27 @@ public class IrisDimension extends IrisRegistrant { } Deque pending = new ArrayDeque<>(); - IrisRiverNetwork riverNetwork = getRivers(); - boolean riversEnabled = riverNetwork != null && riverNetwork.isEnabled(); - if (riversEnabled && riverNetwork.getBiomes() != null) { - addReachableBiomeKeys(pending, riverNetwork.getBiomes().getAllBiomeIds()); + IrisHydrology configuredHydrology = getHydrology(); + IrisRiverHydrology configuredRivers = configuredHydrology == null ? null : configuredHydrology.getRivers(); + boolean hydrologyPoliciesActive = configuredRivers != null && configuredRivers.isEnabled(); + if (!hydrologyPoliciesActive && configuredHydrology != null) { + for (IrisDeepFluidConfig deepFluid : configuredHydrology.getDeepFluids()) { + if (deepFluid != null && deepFluid.getDensity() > 0D + && (deepFluid.isContainedPools() || deepFluid.isShortChannels())) { + hydrologyPoliciesActive = true; + break; + } + } + } + if (hydrologyPoliciesActive && getRiverPolicy() != null) { + addReachableBiomeKeys(pending, getRiverPolicy().getAllBiomeIds()); } for (IrisRegion region : getAllRegions(g)) { if (region == null) { continue; } addReachableBiomeKeys(pending, - riversEnabled ? region.getAllBiomeIds() : region.getNaturalBiomeIds()); + hydrologyPoliciesActive ? region.getAllBiomeIds() : region.getNaturalBiomeIds()); } for (IrisImageMapBinding binding : getImageMaps()) { if (binding == null || binding.getApplication() != IrisImageMapApplication.BIOME) { @@ -571,8 +583,8 @@ public class IrisDimension extends IrisRegistrant { biomes.put(loadKey, biome); addReachableBiomeKeys(pending, biome.getChildren()); addReachableBiomeKey(pending, biome.getCarvingBiome()); - if (riversEnabled && biome.getRiverOverride() != null) { - addReachableBiomeKeys(pending, biome.getRiverOverride().getAllBiomeIds()); + if (hydrologyPoliciesActive && biome.getRiverPolicy() != null) { + addReachableBiomeKeys(pending, biome.getRiverPolicy().getAllBiomeIds()); } KList floatingChildren = biome.getFloatingChildBiomes(); diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisGrottoPoolLevel.java b/core/src/main/java/art/arcane/iris/engine/object/IrisGrottoPoolLevel.java new file mode 100644 index 000000000..047b2f0ca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisGrottoPoolLevel.java @@ -0,0 +1,5 @@ +package art.arcane.iris.engine.object; + +public enum IrisGrottoPoolLevel { + SEA_LEVEL +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisHydrology.java b/core/src/main/java/art/arcane/iris/engine/object/IrisHydrology.java new file mode 100644 index 000000000..8e56f5a4e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisHydrology.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.volmlib.util.collection.KList; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Dimension-owned physical hydrology configuration.") +@Data +public class IrisHydrology { + @Desc("Terrain-guided surface and underground river configuration.") + private IrisRiverHydrology rivers = new IrisRiverHydrology(); + + @ArrayType(type = IrisDeepFluidConfig.class) + @Desc("Independent deep-fluid systems that do not consume the surface or underground river source budgets.") + private KList deepFluids = new KList<>(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisInlandRiverGrottoConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisInlandRiverGrottoConfig.java new file mode 100644 index 000000000..ee0a3947c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisInlandRiverGrottoConfig.java @@ -0,0 +1,40 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls contained inland grotto outlets.") +@Data +public class IrisInlandRiverGrottoConfig { + @Desc("Allow a contained inland grotto to serve as an explicit accepted outlet.") + private boolean enabled = true; + + @Desc("Continue eligible surface rivers through a falling sinkhole into the contained inland grotto.") + private boolean connectSurfaceRivers = false; + + @MinNumber(1) + @MaxNumber(128) + @Desc("Maximum horizontal radius in blocks of an accepted inland grotto.") + private int horizontalRadius = 10; + + @MinNumber(1) + @MaxNumber(64) + @Desc("Maximum vertical radius in blocks of an accepted inland grotto.") + private int verticalRadius = 6; + + @MinNumber(1) + @MaxNumber(63) + @Desc("Required dry clearance above an inland grotto pool.") + private int headroom = 10; + + @MinNumber(1) + @MaxNumber(1048576) + @Desc("Maximum accepted inland grotto volume in blocks.") + private int maximumVolume = 8192; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisInterpolator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisInterpolator.java index e7f7f3f9f..d336d93c4 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisInterpolator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisInterpolator.java @@ -52,9 +52,7 @@ public class IrisInterpolator { @Override public int hashCode() { - // Bit-identical to Objects.hash(horizontalScale, function) without the Object[] + Double boxing. - // The exact value is load bearing: it decides HashMap bucket order for the generator maps in - // IrisComplex, and that order fixes the floating point summation order of interpolated heights. + // Bit-identical to Objects.hash(horizontalScale, function) without the Object[] or Double boxing. int result = 31 + Double.hashCode(horizontalScale); return (31 * result) + (function == null ? 0 : function.hashCode()); } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisMaterialPalette.java b/core/src/main/java/art/arcane/iris/engine/object/IrisMaterialPalette.java index d31215127..4d10aa6c7 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisMaterialPalette.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisMaterialPalette.java @@ -20,21 +20,27 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.data.cache.LazyBoundedCache; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MinNumber; import art.arcane.iris.engine.object.annotations.Required; import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.noise.CNG; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; -import art.arcane.iris.util.project.noise.CNG; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; -import art.arcane.iris.spi.PlatformBlockState; +import lombok.Setter; import lombok.experimental.Accessors; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; @Snippet("palette") @Accessors(chain = true) @@ -43,8 +49,17 @@ import java.util.Optional; @Desc("A palette of materials") @Data public class IrisMaterialPalette { + private static final int LAYER_GENERATOR_CACHE_SIZE = 32; + private static final int LAYER_GENERATOR_SALT = -23_498_896; + private final transient AtomicCache> blockData = new AtomicCache<>(); - private final transient AtomicCache layerGenerator = new AtomicCache<>(); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache layerGenerators = + new LazyBoundedCache<>(LAYER_GENERATOR_CACHE_SIZE); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient AtomicReference recentLayerGenerator = new AtomicReference<>(); private final transient AtomicCache heightGenerator = new AtomicCache<>(); @Desc("The style of noise") private IrisGeneratorStyle style = NoiseStyle.STATIC.style(); @@ -82,11 +97,21 @@ public class IrisMaterialPalette { } public CNG getLayerGenerator(RNG rng, IrisData rdata) { - return layerGenerator.aquire(() -> - { - RNG rngx = rng.nextParallelRNG(-23498896 + getBlockData(rdata).size()); - return style.create(rngx, rdata); - }); + Engine engine = rdata == null ? null : rdata.getEngine(); + int generatorSignature = LAYER_GENERATOR_SALT + getBlockData(rdata).size(); + long generatorSeed = rng.getSeed() + generatorSignature; + CachedLayerGenerator recent = recentLayerGenerator.get(); + if (recent != null && recent.key.matches(rdata, engine, generatorSeed)) { + return recent.generator; + } + + LayerGeneratorKey key = new LayerGeneratorKey(rdata, engine, generatorSeed); + CNG generator = layerGenerators.computeIfAbsent(key, + ignored -> style.create(new RNG(generatorSeed), rdata, engine)); + if (generator != null) { + recentLayerGenerator.set(new CachedLayerGenerator(key, generator)); + } + return generator; } public IrisMaterialPalette qclear() { @@ -127,4 +152,49 @@ public class IrisMaterialPalette { palette.clear(); return this; } + + private static final class LayerGeneratorKey { + private final IrisData data; + private final Engine engine; + private final long seed; + + private LayerGeneratorKey(IrisData data, Engine engine, long seed) { + this.data = data; + this.engine = engine; + this.seed = seed; + } + + private boolean matches(IrisData data, Engine engine, long seed) { + return this.data == data && this.engine == engine && this.seed == seed; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof LayerGeneratorKey other)) { + return false; + } + return data == other.data && engine == other.engine && seed == other.seed; + } + + @Override + public int hashCode() { + int result = System.identityHashCode(data); + result = 31 * result + System.identityHashCode(engine); + result = 31 * result + Long.hashCode(seed); + return result; + } + } + + private static final class CachedLayerGenerator { + private final LayerGeneratorKey key; + private final CNG generator; + + private CachedLayerGenerator(LayerGeneratorKey key, CNG generator) { + this.key = key; + this.generator = generator; + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisNoiseGenerator.java b/core/src/main/java/art/arcane/iris/engine/object/IrisNoiseGenerator.java index 1c4d1b459..fdce91245 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisNoiseGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisNoiseGenerator.java @@ -19,7 +19,8 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.data.cache.AtomicCache; +import art.arcane.iris.engine.data.cache.LazyBoundedCache; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; import art.arcane.iris.engine.object.annotations.MaxNumber; @@ -30,11 +31,16 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import art.arcane.iris.util.project.interpolation.IrisInterpolation; import art.arcane.iris.util.project.noise.CNG; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import lombok.experimental.Accessors; +import java.util.concurrent.atomic.AtomicReference; + @Snippet("generator") @Accessors(chain = true) @NoArgsConstructor @@ -42,7 +48,16 @@ import lombok.experimental.Accessors; @Desc("A noise generator") @Data public class IrisNoiseGenerator { - private final transient AtomicCache generator = new AtomicCache<>(); + private static final int GENERATOR_CACHE_SIZE = 32; + private static final long GENERATOR_SEED_SALT = 33_955_677L; + + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient LazyBoundedCache generators = + new LazyBoundedCache<>(GENERATOR_CACHE_SIZE); + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) + private final transient AtomicReference recentGenerator = new AtomicReference<>(); @MinNumber(0.0001) @Desc("The coordinate input zoom") private double zoom = 1; @@ -87,7 +102,20 @@ public class IrisNoiseGenerator { } protected CNG getGenerator(long superSeed, IrisData data) { - return generator.aquire(() -> style.create(new RNG(superSeed + 33955677 - seed), data).oct(octaves)); + Engine engine = data == null ? null : data.getEngine(); + long generatorSeed = superSeed + GENERATOR_SEED_SALT - seed; + CachedGenerator recent = recentGenerator.get(); + if (recent != null && recent.key.matches(data, engine, generatorSeed)) { + return recent.generator; + } + + GeneratorKey key = new GeneratorKey(data, engine, generatorSeed); + CNG generator = generators.computeIfAbsent(key, + ignored -> style.createNoCache(new RNG(generatorSeed), data).oct(octaves)); + if (generator != null) { + recentGenerator.set(new CachedGenerator(key, generator)); + } + return generator; } public double getMax() { @@ -136,4 +164,49 @@ public class IrisNoiseGenerator { return g; } + + private static final class GeneratorKey { + private final IrisData data; + private final Engine engine; + private final long seed; + + private GeneratorKey(IrisData data, Engine engine, long seed) { + this.data = data; + this.engine = engine; + this.seed = seed; + } + + private boolean matches(IrisData data, Engine engine, long seed) { + return this.data == data && this.engine == engine && this.seed == seed; + } + + @Override + public boolean equals(Object object) { + if (this == object) { + return true; + } + if (!(object instanceof GeneratorKey other)) { + return false; + } + return data == other.data && engine == other.engine && seed == other.seed; + } + + @Override + public int hashCode() { + int result = System.identityHashCode(data); + result = 31 * result + System.identityHashCode(engine); + result = 31 * result + Long.hashCode(seed); + return result; + } + } + + private static final class CachedGenerator { + private final GeneratorKey key; + private final CNG generator; + + private CachedGenerator(GeneratorKey key, CNG generator) { + this.key = key; + this.generator = generator; + } + } } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index c894795de..93a780e8e 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java @@ -302,6 +302,15 @@ final class IrisObjectPlacementRunner { return -1; } + if (yv < 0 + && config.getCarvingSupport().supportsSurface() + && config.getMode() != ObjectPlaceMode.FLOATING + && !rawStructurePiece + && IrisSurfaceSupport.intersectsHydrology(oplacer, x, z, config.getTranslate(), + config.getRotation(), spinx, spiny, spinz, self.getSurfaceSupportOffsets())) { + return -1; + } + // Surface-anchored placements may never roof or bridge a carved hole. Explicit-Y anchors are only // guarded for SURFACE_ONLY: ANYWHERE covers the inverted upper dimension and CARVING_ONLY covers // cave anchors, and neither reads the terrain surface this stencil samples. @@ -358,7 +367,8 @@ final class IrisObjectPlacementRunner { for (int i = worldBounds.minX(); i <= worldBounds.maxX(); i++) { for (int j = worldBounds.minY(); j <= worldBounds.maxY(); j++) { for (int k = worldBounds.minZ(); k <= worldBounds.maxZ(); k++) { - PlacedObject p = engine.getObjectPlacement(i, j, k); + String placementMarker = placer.getData(i, j, k, String.class); + PlacedObject p = engine.resolveObjectPlacementMarker(i, k, placementMarker); if (p == null) continue; IrisObject o = p.getObject(); if (o == null) continue; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java index 0f2475884..97b30d808 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRegion.java @@ -118,8 +118,8 @@ public class IrisRegion extends IrisRegistrant implements IRare { private double caveBiomeZoom = 1; @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); - @Desc("Region-level river routing, shape, cave-entry, and biome-pool overrides. Omit to inherit dimension settings.") - private IrisRiverOverride riverOverride = null; + @Desc("Region-level river placement, routing, profile, biome, and geometry policy. Omit to inherit dimension settings.") + private IrisRiverPolicy riverPolicy = null; @RegistryListResource(IrisBiome.class) @Required @ArrayType(min = 1, type = String.class) @@ -280,8 +280,8 @@ public class IrisRegion extends IrisRegistrant implements IRare { public KSet getAllBiomeIds() { KSet names = getNaturalBiomeIds(); - if (riverOverride != null) { - names.addAll(riverOverride.getAllBiomeIds()); + if (riverPolicy != null) { + names.addAll(riverPolicy.getAllBiomeIds()); } return names; } diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverBiomes.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverBiomes.java deleted file mode 100644 index 95fd64bad..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverBiomes.java +++ /dev/null @@ -1,61 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.ArrayType; -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.RegistryListResource; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.collection.KSet; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("Biome pools used by dimension-level river sections and contained river caves.") -@Data -public class IrisRiverBiomes { - @Desc("Noise used to select a biome inside the active river-section pool.") - private IrisGeneratorStyle selectionStyle = new IrisGeneratorStyle(NoiseStyle.CELLULAR_IRIS_DOUBLE) - .zoomed(512D); - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Biome pool for wet river channels.") - private KList channel = new KList<>(); - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Biome pool for river banks outside the wet channel.") - private KList bank = new KList<>(); - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Biome pool for river reaches meeting natural sea.") - private KList mouth = new KList<>(); - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Biome pool for dry river channels and terminal tapers.") - private KList dry = new KList<>(); - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Cave biome pool for accepted contained river cave bodies.") - private KList floodedCave = new KList<>(); - - public KSet getAllBiomeIds() { - KSet biomeIds = new KSet<>(); - addAll(biomeIds, channel); - addAll(biomeIds, bank); - addAll(biomeIds, mouth); - addAll(biomeIds, dry); - addAll(biomeIds, floodedCave); - return biomeIds; - } - - private static void addAll(KSet destination, KList source) { - if (source != null) { - destination.addAll(source); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverBranchingConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverBranchingConfig.java new file mode 100644 index 000000000..54e8d9dc4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverBranchingConfig.java @@ -0,0 +1,25 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls minimum complete river lengths before a route is published.") +@Data +public class IrisRiverBranchingConfig { + @MinNumber(0) + @MaxNumber(32768) + @Desc("Minimum complete surface source-to-outlet route length in blocks.") + private int minimumSurfaceCourseLength = 384; + + @MinNumber(0) + @MaxNumber(32768) + @Desc("Minimum complete underground source-to-outlet route length in blocks.") + private int minimumUndergroundCourseLength = 192; + +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveFallback.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveFallback.java deleted file mode 100644 index d79e9b56b..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveFallback.java +++ /dev/null @@ -1,12 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; - -@Desc("Selects the fallback when a requested river cave connection cannot be proven safe.") -public enum IrisRiverCaveFallback { - @Desc("Keep the rejected connection sealed.") - SEALED, - - @Desc("Try a bounded generated grotto instead of the rejected existing cave.") - GENERATE_GROTTO -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveMode.java deleted file mode 100644 index 40b6e3924..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaveMode.java +++ /dev/null @@ -1,21 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; - -@Desc("Selects the contained cave-water behavior available to river entry events.") -public enum IrisRiverCaveMode { - @Desc("Keep the surface reservoir sealed from caves.") - SEALED, - - @Desc("Flood only an existing cave component whose complete fluid-reachable boundary is proven closed.") - FLOOD_CLOSED_COMPONENT, - - @Desc("Generate a bounded grotto with a guaranteed solid shell.") - GENERATE_GROTTO, - - @Desc("Use a proven closed cave component when available, otherwise generate a bounded grotto.") - GROTTO_OR_CLOSED_COMPONENT, - - @Desc("Generate a controlled falling column into a proven contained pool.") - WATERFALL_POOL -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java deleted file mode 100644 index 603a5dee7..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverCaves.java +++ /dev/null @@ -1,103 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MaxNumber; -import art.arcane.iris.engine.object.annotations.MinNumber; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("Controls bounded and transactionally validated river-to-cave connections.") -@Data -public class IrisRiverCaves { - @Desc("The contained cave-water behavior available to river entry events.") - private IrisRiverCaveMode mode = IrisRiverCaveMode.SEALED; - - @Desc("Selects cave-entry stations at stable river-reach anchors.") - private IrisRiverNoiseChance entry = new IrisRiverNoiseChance() - .setChance(0.12D) - .setStyle(new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(1024D)) - .setInfluence(0.4D); - - @MinNumber(16) - @MaxNumber(4096) - @Desc("The minimum distance in blocks between cave-entry candidates.") - private int minimumSpacing = 128; - - @MinNumber(0) - @MaxNumber(16) - @Desc("The maximum entry-noise-eligible cave anchors accepted on one ordinary reach. A forced sinkhole terminal uses its reach exclusively and requires this value above zero.") - private int maximumPerReach = 1; - - @MinNumber(1) - @MaxNumber(256) - @Desc("The maximum vertical distance searched while boring from river bed to cave.") - private int maxBoreDepth = 48; - - @MinNumber(1) - @MaxNumber(16) - @Desc("The radius in blocks of a generated river-to-cave throat.") - private int throatRadius = 2; - - @MinNumber(-64) - @MaxNumber(64) - @Desc("The offset applied to river water height when filling an accepted cave body.") - private int waterLevelOffset = 0; - - @MinNumber(0) - @MaxNumber(64) - @Desc("The minimum dry headroom retained above water in generated grottos.") - private int dryHeadroom = 4; - - @MinNumber(2) - @MaxNumber(128) - @Desc("The horizontal radius in blocks of a generated sealed grotto.") - private int grottoHorizontalRadius = 12; - - @MinNumber(2) - @MaxNumber(128) - @Desc("The vertical radius in blocks of a generated sealed grotto.") - private int grottoVerticalRadius = 7; - - @Desc("Noise shaping the boundary of generated sealed grottos.") - private IrisGeneratorStyle grottoShapeStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(24D); - - @Desc("Noise warping the coordinate field used for generated sealed grottos.") - private IrisGeneratorStyle grottoWarpStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(48D); - - @MinNumber(0) - @MaxNumber(32) - @Desc("The maximum coordinate warp applied to generated sealed grottos in blocks.") - private double grottoWarpStrength = 2D; - - @MinNumber(0) - @MaxNumber(1) - @Desc("The fraction of river-cave boundary columns that inherit the naturally resolved parent cave or surface biome instead of a flooded-cave override.") - private double parentBiomeInheritance = 0.5D; - - @MinNumber(4) - @MaxNumber(256) - @Desc("The horizontal proof radius for an existing closed cave component.") - private int maxFloodRadius = 48; - - @MinNumber(4) - @MaxNumber(256) - @Desc("The vertical proof depth for an existing closed cave component.") - private int maxFloodDepth = 32; - - @MinNumber(64) - @MaxNumber(1048576) - @Desc("The greatest cave-component volume that may be fully proven and flooded.") - private int maxFloodVolume = 8192; - - @Desc("The behavior used when a requested cave connection cannot be proven safe.") - private IrisRiverCaveFallback fallback = IrisRiverCaveFallback.SEALED; - - @Desc("The policy for fluid already present in a candidate contained cave body.") - private IrisRiverExistingFluidPolicy existingFluidPolicy = IrisRiverExistingFluidPolicy.REJECT; - - @Desc("Sparse, independently filled cave-floor pools generated at deep river-network anchors.") - private IrisRiverDeepPools deepPools = new IrisRiverDeepPools(); -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverChannelShapeConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverChannelShapeConfig.java new file mode 100644 index 000000000..7b8215933 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverChannelShapeConfig.java @@ -0,0 +1,34 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls rounded beds and rough, coherent channel walls.") +@Data +public class IrisRiverChannelShapeConfig { + @MinNumber(1) + @MaxNumber(6) + @Desc("Cross-section exponent. Larger values broaden the rounded U-shaped bed.") + private double bedRoundness = 2.4D; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Coherent vertical variation applied to the bed as a fraction of channel depth.") + private double bedRoughness = 0.28D; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Coherent radial variation applied to carved walls and banks.") + private double wallRoughness = 0.24D; + + @MinNumber(3) + @MaxNumber(128) + @Desc("Wavelength in blocks of bed and wall roughness.") + private int roughnessWavelength = 11; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverDeepPools.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverDeepPools.java deleted file mode 100644 index 283a98989..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverDeepPools.java +++ /dev/null @@ -1,92 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MaxNumber; -import art.arcane.iris.engine.object.annotations.MinNumber; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("Controls sparse, river-anchored fluid pools attached to deep cave floors.") -@Data -public class IrisRiverDeepPools { - @Desc("Enables independently configured deep cave pools along eligible wet river reaches.") - private boolean enabled = false; - - @Desc("Selects complete wet river reaches that may host deep pools.") - private IrisRiverNoiseChance reach = new IrisRiverNoiseChance() - .setChance(1D / 3D) - .setStyle(new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(4096D)) - .setInfluence(0.08D); - - @MinNumber(16) - @MaxNumber(4096) - @Desc("The minimum distance in blocks between deep-pool candidates.") - private int minimumSpacing = 768; - - @MinNumber(0) - @MaxNumber(16) - @Desc("The maximum accepted deep pools on one river reach.") - private int maximumPerReach = 1; - - @MinNumber(-2048) - @MaxNumber(2048) - @Desc("The lowest absolute world Y considered for the pool fluid surface.") - private int minimumFluidY = -224; - - @MinNumber(-2048) - @MaxNumber(2048) - @Desc("The highest absolute world Y considered for the pool fluid surface.") - private int maximumFluidY = -104; - - @MinNumber(0) - @MaxNumber(256) - @Desc("The horizontal distance searched from a river anchor for a contained cave floor.") - private int searchRadius = 16; - - @MinNumber(1) - @MaxNumber(64) - @Desc("The number of deterministic nearby columns tested for a contained cave floor.") - private int searchAttempts = 12; - - @MinNumber(2) - @MaxNumber(128) - @Desc("The horizontal radius of the generated deep-pool chamber.") - private int horizontalRadius = 18; - - @MinNumber(2) - @MaxNumber(64) - @Desc("The vertical radius of the generated deep-pool chamber.") - private int verticalRadius = 8; - - @MinNumber(1) - @MaxNumber(63) - @Desc("The dry chamber height retained above the deep-pool fluid surface.") - private int dryHeadroom = 4; - - @Desc("Noise shaping the deep-pool chamber boundary.") - private IrisGeneratorStyle shapeStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(12D); - - @MinNumber(0) - @MaxNumber(0.75) - @Desc("The proportional noise displacement applied to the deep-pool chamber boundary.") - private double shapeVariation = 0.5D; - - @Desc("Noise warping the deep-pool chamber coordinate field.") - private IrisGeneratorStyle warpStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(24D); - - @MinNumber(0) - @MaxNumber(64) - @Desc("The maximum coordinate warp applied to the deep-pool chamber in blocks.") - private double warpStrength = 6D; - - @MinNumber(64) - @MaxNumber(1048576) - @Desc("The greatest generated deep-pool chamber volume that may be transactionally published.") - private int maximumVolume = 32768; - - @Desc("The fluid palette used only by accepted deep pools.") - private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("lava"); -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverDropShapeConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverDropShapeConfig.java new file mode 100644 index 000000000..b83aa2e4c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverDropShapeConfig.java @@ -0,0 +1,49 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls low-volume cascades, falling faces, and organic receiving basins.") +@Data +public class IrisRiverDropShapeConfig { + @MinNumber(1) + @MaxNumber(16) + @Desc("Preferred horizontal cascade run per block of head loss.") + private int cascadeRunPerBlock = 2; + + @MinNumber(0.25) + @MaxNumber(6) + @Desc("Exponent of the graded cascade profile. Values above one accelerate toward the receiver.") + private double cascadeExponent = 1.4D; + + @MinNumber(1) + @MaxNumber(4) + @Desc("Maximum head loss between adjacent underground drop faces. Exposed cascades always use one-block steps.") + private int maximumCascadeStep = 2; + + @MinNumber(0.25) + @MaxNumber(1) + @Desc("Drop-flow width as a fraction of the connected channel width.") + private double flowWidthRatio = 0.45D; + + @MinNumber(1) + @MaxNumber(16) + @Desc("Maximum wetted depth along a descending flow path.") + private int maximumFlowDepth = 2; + + @MinNumber(1) + @MaxNumber(4) + @Desc("Receiving-basin width as a fraction of the descending flow width.") + private double basinWidthRatio = 1.8D; + + @MinNumber(1) + @MaxNumber(32) + @Desc("Maximum receiving-basin depth after drop-scaled erosion.") + private int maximumBasinDepth = 8; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverExistingFluidPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverExistingFluidPolicy.java deleted file mode 100644 index 356d8f71c..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverExistingFluidPolicy.java +++ /dev/null @@ -1,15 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; - -@Desc("Controls how river cave hydrology treats fluid already present in a candidate cave body.") -public enum IrisRiverExistingFluidPolicy { - @Desc("Reject a candidate containing any existing fluid.") - REJECT, - - @Desc("Accept only fluid compatible with the river fluid palette.") - ALLOW_SAME, - - @Desc("Replace contained existing fluid with the river fluid palette.") - REPLACE -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverGeometryConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverGeometryConfig.java new file mode 100644 index 000000000..1b7d9840d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverGeometryConfig.java @@ -0,0 +1,27 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls organic river routing, beds, carved walls, and descending water geometry.") +@Data +public class IrisRiverGeometryConfig { + @Desc("Surface and underground centerline meanders.") + private IrisRiverMeanderConfig meanders = new IrisRiverMeanderConfig(); + + @Desc("Surface channel bed and bank shape.") + private IrisRiverChannelShapeConfig surface = new IrisRiverChannelShapeConfig(); + + @Desc("Underground channel bed and wall shape.") + private IrisRiverChannelShapeConfig underground = new IrisRiverChannelShapeConfig(); + + @Desc("Grotto pool bed and wall shape.") + private IrisRiverChannelShapeConfig grottos = new IrisRiverChannelShapeConfig(); + + @Desc("Cascade, cataract, waterfall, and sinkhole descent shape.") + private IrisRiverDropShapeConfig drops = new IrisRiverDropShapeConfig(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverGrottoConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverGrottoConfig.java new file mode 100644 index 000000000..55939edd8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverGrottoConfig.java @@ -0,0 +1,18 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls explicitly accepted coastal and inland river grottos.") +@Data +public class IrisRiverGrottoConfig { + @Desc("Coastal grotto configuration.") + private IrisCoastalRiverGrottoConfig coastal = new IrisCoastalRiverGrottoConfig(); + + @Desc("Contained inland grotto configuration.") + private IrisInlandRiverGrottoConfig inland = new IrisInlandRiverGrottoConfig(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverHydraulicsConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverHydraulicsConfig.java new file mode 100644 index 000000000..212c66588 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverHydraulicsConfig.java @@ -0,0 +1,41 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls deterministic river head transitions and hydraulic segment classification.") +@Data +public class IrisRiverHydraulicsConfig { + @Desc("Target longitudinal length of level pools between head transitions.") + private IrisStyledRange targetPoolLength = range(80D, 180D, 1024D); + + @MinNumber(0) + @MaxNumber(16) + @Desc("Normal one-step head loss represented by a riffle.") + private int riffleDrop = 1; + + @MinNumber(0) + @MaxNumber(64) + @Desc("Largest head loss represented as a gradual cascade.") + private int maximumGradualDrop = 7; + + @MinNumber(1) + @MaxNumber(1024) + @Desc("Largest longitudinal distance used for one gradual cascade transition.") + private int maximumGradualLength = 24; + + @MinNumber(1) + @MaxNumber(128) + @Desc("Minimum required head loss represented by a waterfall.") + private int waterfallMinimumDrop = 8; + + private static IrisStyledRange range(double min, double max, double zoom) { + return new IrisStyledRange(min, max, new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(zoom)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverHydrology.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverHydrology.java new file mode 100644 index 000000000..1c53eeaa1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverHydrology.java @@ -0,0 +1,36 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.volmlib.util.collection.KList; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Dimension-owned terrain-guided river configuration.") +@Data +public class IrisRiverHydrology { + @Desc("Enable river planning for this dimension.") + private boolean enabled = false; + + @Desc("Drainage sampling, refinement, and outlet configuration.") + private IrisRiverRoutingConfig routing = new IrisRiverRoutingConfig(); + + @Desc("Organic centerline, bed, wall, and descending-water geometry.") + private IrisRiverGeometryConfig geometry = new IrisRiverGeometryConfig(); + + @Desc("Surface river source, channel, hydraulic, ridge-tunnel, and mouth configuration.") + private IrisSurfaceRiverConfig surface = new IrisSurfaceRiverConfig(); + + @Desc("Independent underground river source and channel configuration.") + private IrisUndergroundRiverConfig underground = new IrisUndergroundRiverConfig(); + + @Desc("Contained coastal and inland grotto configuration.") + private IrisRiverGrottoConfig grottos = new IrisRiverGrottoConfig(); + + @ArrayType(min = 1, type = IrisRiverProfile.class) + @Desc("Reusable river fluid profiles selected by river policies.") + private KList profiles = new KList().qadd(new IrisRiverProfile()); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverInlandOutlet.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverInlandOutlet.java new file mode 100644 index 000000000..834f8facf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverInlandOutlet.java @@ -0,0 +1,5 @@ +package art.arcane.iris.engine.object; + +public enum IrisRiverInlandOutlet { + SINKHOLE_GROTTO +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMeanderConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMeanderConfig.java new file mode 100644 index 000000000..b5e0465a9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMeanderConfig.java @@ -0,0 +1,49 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls multi-scale terrain-guided river meanders.") +@Data +public class IrisRiverMeanderConfig { + @MinNumber(8) + @MaxNumber(512) + @Desc("Broad meander wavelength in blocks.") + private int primaryWavelength = 64; + + @MinNumber(4) + @MaxNumber(128) + @Desc("Fine worm wavelength in blocks. Smaller values change direction more frequently.") + private int detailWavelength = 12; + + @MinNumber(0) + @MaxNumber(2) + @Desc("Strength of broad meanders.") + private double primaryStrength = 0.34D; + + @MinNumber(0) + @MaxNumber(2) + @Desc("Strength of fine worm movement.") + private double detailStrength = 0.42D; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Maximum lateral route displacement as a fraction of one drainage edge.") + private double maximumOffsetRatio = 0.48D; + + @MinNumber(0) + @MaxNumber(4) + @Desc("Terrain-safe centerline smoothing passes after route solving.") + private int smoothingPasses = 1; + + @MinNumber(10) + @MaxNumber(150) + @Desc("Maximum retained centerline turn angle in degrees.") + private double maximumTurnDegrees = 82D; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMouthConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMouthConfig.java new file mode 100644 index 000000000..d47e7bfea --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverMouthConfig.java @@ -0,0 +1,24 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls river leveling before a coastal reservoir and the final ocean footprint.") +@Data +public class IrisRiverMouthConfig { + @MinNumber(0) + @MaxNumber(2048) + @Desc("Distance in blocks over which an ocean-bound river reaches sea level before the coastline.") + private int levelingDistance = 64; + + @MinNumber(0) + @MaxNumber(64) + @Desc("Maximum number of blocks the river footprint may enter an ocean reservoir before terminating.") + private int maximumOceanApron = 8; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNetwork.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNetwork.java deleted file mode 100644 index 623043946..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNetwork.java +++ /dev/null @@ -1,30 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("Dimension-owned configuration for connected surface rivers and contained river cave water.") -@Data -public class IrisRiverNetwork { - @Desc("Enable the river network for this dimension.") - private boolean enabled = false; - - @Desc("Dimension-owned connected graph and source-selection settings.") - private IrisRiverTopology topology = new IrisRiverTopology(); - - @Desc("Channel geometry, terrain incision, meanders, and terminal behavior.") - private IrisRiverTerrain terrain = new IrisRiverTerrain(); - - @Desc("River water-surface settings.") - private IrisRiverWater water = new IrisRiverWater(); - - @Desc("Dimension-level biome pools for river sections and contained river caves.") - private IrisRiverBiomes biomes = new IrisRiverBiomes(); - - @Desc("Bounded river-to-cave connection settings.") - private IrisRiverCaves caves = new IrisRiverCaves(); -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNoiseChance.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNoiseChance.java deleted file mode 100644 index 2c66d92ff..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverNoiseChance.java +++ /dev/null @@ -1,27 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MaxNumber; -import art.arcane.iris.engine.object.annotations.MinNumber; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("A deterministic graph-event chance modulated by configurable noise.") -@Data -public class IrisRiverNoiseChance { - @MinNumber(0) - @MaxNumber(1) - @Desc("The base probability before noise modulation.") - private double chance = 1D; - - @Desc("The noise sampled once at the stable graph-event anchor.") - private IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.FLAT); - - @MinNumber(0) - @MaxNumber(1) - @Desc("The maximum centered noise contribution added to the base probability.") - private double influence = 0D; -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverOverride.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverOverride.java deleted file mode 100644 index f5c7b9cf8..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverOverride.java +++ /dev/null @@ -1,103 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.ArrayType; -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MaxNumber; -import art.arcane.iris.engine.object.annotations.MinNumber; -import art.arcane.iris.engine.object.annotations.RegistryListResource; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.collection.KSet; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("Nullable river settings overridden by a region or natural biome without changing graph identity.") -@Data -public class IrisRiverOverride { - @Desc("Whether new river sources may begin in this area. Existing trunks are unaffected.") - private Boolean allowSources = null; - - @Desc("How downstream routing treats this area.") - private IrisRiverRoutingPolicy routingPolicy = null; - - @MinNumber(0) - @MaxNumber(64) - @Desc("Multiplier applied to downstream routing cost.") - private Double routingCostMultiplier = null; - - @MinNumber(0.0001) - @MaxNumber(16) - @Desc("Multiplier applied to wet channel width.") - private Double widthMultiplier = null; - - @MinNumber(0) - @MaxNumber(16) - @Desc("Multiplier applied to river bank width.") - private Double bankWidthMultiplier = null; - - @MinNumber(0.0001) - @MaxNumber(16) - @Desc("Multiplier applied to river-bed depth.") - private Double depthMultiplier = null; - - @MinNumber(0) - @MaxNumber(16) - @Desc("Multiplier applied to maximum terrain incision.") - private Double maxIncisionMultiplier = null; - - @MinNumber(0) - @MaxNumber(16) - @Desc("Multiplier applied to reach continuation probability.") - private Double continuationChanceMultiplier = null; - - @MinNumber(0) - @MaxNumber(16) - @Desc("Multiplier applied to cave-entry probability.") - private Double caveEntryMultiplier = null; - - @Desc("Optional terminal behavior override for failed routes in this area.") - private IrisRiverTerminalMode terminalMode = null; - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Optional replacement biome pool for wet river channels. Empty explicitly disables this pool.") - private KList channelBiomes = null; - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Optional replacement biome pool for river banks. Empty explicitly disables this pool.") - private KList bankBiomes = null; - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Optional replacement biome pool for river mouths. Empty explicitly disables this pool.") - private KList mouthBiomes = null; - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Optional replacement biome pool for dry river channels. Empty explicitly disables this pool.") - private KList dryBiomes = null; - - @RegistryListResource(IrisBiome.class) - @ArrayType(type = String.class) - @Desc("Optional replacement cave biome pool for accepted contained river cave bodies. Empty explicitly disables this pool.") - private KList floodedCaveBiomes = null; - - public KSet getAllBiomeIds() { - KSet biomeIds = new KSet<>(); - addAll(biomeIds, channelBiomes); - addAll(biomeIds, bankBiomes); - addAll(biomeIds, mouthBiomes); - addAll(biomeIds, dryBiomes); - addAll(biomeIds, floodedCaveBiomes); - return biomeIds; - } - - private static void addAll(KSet destination, KList source) { - if (source != null) { - destination.addAll(source); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPlacementMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPlacementMode.java new file mode 100644 index 000000000..d0685387c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPlacementMode.java @@ -0,0 +1,9 @@ +package art.arcane.iris.engine.object; + +public enum IrisRiverPlacementMode { + DISABLED, + TRANSIT_ONLY, + NATURAL, + PREFERRED_HEADWATER, + REQUIRED_HEADWATER +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java new file mode 100644 index 000000000..2d6f51733 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java @@ -0,0 +1,92 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KSet; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Nullable river policy values inherited from dimension to region to biome.") +@Data +public class IrisRiverPolicy { + @Desc("Controls source and transit admission in this area.") + private IrisRiverPlacementMode placement = null; + + @Desc("Controls the routing preference or prohibition applied to this area.") + private IrisRiverRoutingMode routing = null; + + @Desc("Whether this area may contain an accepted river outlet.") + private Boolean outletAdmission = null; + + @ArrayType(type = String.class) + @Desc("Preferred dimension-owned river profile identifiers. An empty list explicitly clears inherited profiles.") + private KList profiles = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Surface channel biome selection. An empty list explicitly clears the inherited selection.") + private KList surfaceBiomes = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("River mouth biome selection. An empty list explicitly clears the inherited selection.") + private KList mouthBiomes = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Narrow river shore biome selection. An empty list explicitly clears the inherited selection.") + private KList shoreBiomes = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Dry river footprint biome selection. An empty list explicitly clears the inherited selection.") + private KList dryBiomes = null; + + @RegistryListResource(IrisBiome.class) + @ArrayType(type = String.class) + @Desc("Flooded cave and grotto biome selection. An empty list explicitly clears the inherited selection.") + private KList floodedCaveBiomes = null; + + @MinNumber(0.0001) + @MaxNumber(16) + @Desc("Multiplier applied to accepted river width.") + private Double widthMultiplier = null; + + @MinNumber(0.0001) + @MaxNumber(16) + @Desc("Multiplier applied to accepted river depth.") + private Double depthMultiplier = null; + + @MinNumber(0) + @MaxNumber(16) + @Desc("Multiplier applied to maximum terrain incision.") + private Double incisionMultiplier = null; + + @MinNumber(0) + @MaxNumber(64) + @Desc("Multiplier applied to terrain-guided routing cost.") + private Double routingMultiplier = null; + + public KSet getAllBiomeIds() { + KSet biomeIds = new KSet<>(); + addAll(biomeIds, surfaceBiomes); + addAll(biomeIds, mouthBiomes); + addAll(biomeIds, shoreBiomes); + addAll(biomeIds, dryBiomes); + addAll(biomeIds, floodedCaveBiomes); + return biomeIds; + } + + private static void addAll(KSet destination, KList source) { + if (source != null) { + destination.addAll(source); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverProfile.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverProfile.java new file mode 100644 index 000000000..fb50bacc5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverProfile.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.Required; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Reusable river fluid profile selected by river policies.") +@Data +public class IrisRiverProfile { + @Required + @Desc("Profile identifier referenced by dimension, region, and biome river policies.") + private String id = "default"; + + @Required + @Desc("Fluid palette used by accepted river footprints using this profile.") + private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("water"); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRidgeTunnelConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRidgeTunnelConfig.java new file mode 100644 index 000000000..4e211b498 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRidgeTunnelConfig.java @@ -0,0 +1,27 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls short contained ridge bores on otherwise viable surface courses.") +@Data +public class IrisRiverRidgeTunnelConfig { + @Desc("Allow short ridge bores when they preserve the accepted drainage course.") + private boolean enabled = true; + + @MinNumber(1) + @MaxNumber(4096) + @Desc("Maximum accepted ridge-bore length in blocks.") + private int maximumLength = 192; + + @MinNumber(1) + @MaxNumber(128) + @Desc("Dry vertical clearance above the river head inside a ridge bore.") + private int headroom = 10; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java new file mode 100644 index 000000000..e0f4064d3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java @@ -0,0 +1,51 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.volmlib.util.collection.KList; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls bounded drainage planning and legal river outlets.") +@Data +public class IrisRiverRoutingConfig { + @MinNumber(256) + @MaxNumber(8192) + @Desc("Hydrology planning tile size in blocks.") + private int tileSize = 2048; + + @MinNumber(8) + @MaxNumber(512) + @Desc("Spacing in blocks between terrain samples used to build the drainage graph.") + private int sampleSpacing = 64; + + @MinNumber(1) + @MaxNumber(64) + @Desc("Spacing in blocks used to refine accepted terrain-guided centerlines.") + private int refinementSpacing = 8; + + @Desc("Minimum route and tributary lengths used to suppress chopped, clustered branches.") + private IrisRiverBranchingConfig branching = new IrisRiverBranchingConfig(); + + @MinNumber(256) + @MaxNumber(32768) + @Desc("Maximum bounded length in blocks of one accepted river route.") + private int maximumRouteLength = 16384; + + @MinNumber(1) + @MaxNumber(256) + @Desc("Maximum drainage roots selected per planning tile. Fewer roots produce longer, more strongly branching river trees.") + private int maximumOutletsPerTile = 4; + + @Desc("Allow rivers to terminate at an ocean reservoir.") + private boolean oceanOutlets = true; + + @ArrayType(type = IrisRiverInlandOutlet.class) + @Desc("Contained inland outlet kinds available when no legal ocean outlet is selected.") + private KList inlandOutlets = new KList<>(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingMode.java new file mode 100644 index 000000000..9315da332 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingMode.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.object; + +public enum IrisRiverRoutingMode { + BLOCK, + AVOID, + ALLOW, + PREFER +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingPolicy.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingPolicy.java deleted file mode 100644 index 5b75c5ba3..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingPolicy.java +++ /dev/null @@ -1,15 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; - -@Desc("Controls how river routing treats a region or biome.") -public enum IrisRiverRoutingPolicy { - @Desc("Allow normal river routing through this area.") - ALLOW, - - @Desc("Increase the routing cost while still permitting established river trunks.") - AVOID, - - @Desc("Forbid river reaches from crossing this area.") - BLOCK -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerminalMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerminalMode.java deleted file mode 100644 index 483b30469..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerminalMode.java +++ /dev/null @@ -1,15 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; - -@Desc("Selects how a river route ends when it cannot continue to an outlet.") -public enum IrisRiverTerminalMode { - @Desc("Suppress the failed route instead of generating it.") - SUPPRESS, - - @Desc("Continue as a dry channel that tapers back into natural terrain.") - DRY_CHANNEL, - - @Desc("End in a contained underground grotto when cave hydrology accepts the connection.") - SINKHOLE_GROTTO -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java deleted file mode 100644 index 4c94a056b..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTerrain.java +++ /dev/null @@ -1,123 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.ArrayType; -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MaxNumber; -import art.arcane.iris.engine.object.annotations.MinNumber; -import art.arcane.iris.engine.object.annotations.Required; -import art.arcane.volmlib.util.collection.KList; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("Controls river channel geometry, banks, incision, meanders, and terminal tapering.") -@Data -public class IrisRiverTerrain { - @Desc("The wet channel width in blocks before stream-order scaling.") - private IrisStyledRange channelWidth = range(8D, 20D, NoiseStyle.IRIS, 1024D); - - @Desc("The bank width outside the wet channel in blocks.") - private IrisStyledRange bankWidth = range(5D, 18D, NoiseStyle.IRIS, 1024D); - - @Desc("The wet-bed depth below the local water surface, or dry-channel depth below natural terrain, in blocks.") - private IrisStyledRange depth = range(2D, 7D, NoiseStyle.IRIS, 768D); - - @MinNumber(0) - @MaxNumber(64) - @Desc("The radius added to every channel after worm, regional, local, and stream-order shaping.") - private double channelRadiusBonus = 0D; - - @MinNumber(1) - @MaxNumber(2048) - @Desc("The final wet-channel width cap after region, biome, and stream-order scaling.") - private double maxChannelWidth = 10D; - - @MinNumber(0) - @MaxNumber(2048) - @Desc("The final bank-width cap on each side after region and biome scaling.") - private double maxBankWidth = 4D; - - @MinNumber(1) - @MaxNumber(512) - @Desc("The final river-depth cap after region, biome, and stream-order scaling.") - private double maxDepth = 10D; - - @MinNumber(0) - @MaxNumber(8) - @Desc("Additional channel-width fraction applied for each merged upstream flow order.") - private double orderWidthFactor = 0.35D; - - @MinNumber(0) - @MaxNumber(8) - @Desc("Additional river-bed depth fraction applied for each merged upstream flow order.") - private double orderDepthFactor = 0.2D; - - @Desc("Selects whether a complete graph reach may incise terrain. A rejected reach follows terminal behavior.") - private IrisRiverNoiseChance incision = new IrisRiverNoiseChance(); - - @MinNumber(0) - @MaxNumber(512) - @Desc("The greatest permitted vertical incision below natural terrain.") - private int maxIncision = 48; - - @MinNumber(0.125) - @MaxNumber(16) - @Desc("The exponent shaping the channel-to-bank cross-section transition.") - private double bankExponent = 2D; - - @MinNumber(0) - @MaxNumber(16) - @Desc("The longitudinal transition length and maximum lateral and roof flare where a surface river enters or exits solid terrain.") - private double tunnelMouthBlend = 2D; - - @Desc("Noise modulating the submerged floor of river tunnels.") - private IrisGeneratorStyle tunnelFloorStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(48D); - - @Desc("The subterranean tunnel width multiplier relative to the surface river width.") - private IrisStyledRange tunnelWidthMultiplier = range(1D, 1D, NoiseStyle.FLAT, 1D); - - @MinNumber(0) - @MaxNumber(8) - @Desc("The maximum vertical floor variation in river tunnels.") - private double tunnelFloorVariation = 2D; - - @Desc("Noise modulating the dry roof of river tunnels.") - private IrisGeneratorStyle tunnelRoofStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(64D); - - @MinNumber(0) - @MaxNumber(16) - @Desc("The maximum vertical roof variation in river tunnels.") - private double tunnelRoofVariation = 3D; - - @Required - @ArrayType(min = 1, type = IrisRiverWorm.class) - @Desc("Weighted root Perlin-worm families with inherited child styles for trunks and tributaries.") - private KList worms = new KList(); - - @Desc("Modulates small river-bed height variation after the connected channel shape is solved.") - private IrisGeneratorStyle bedRoughnessStyle = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(96D); - - @MinNumber(0) - @MaxNumber(8) - @Desc("The maximum river-bed roughness in blocks.") - private double bedRoughness = 0.75D; - - @Desc("The behavior used when a graph route cannot continue as a wet channel.") - private IrisRiverTerminalMode terminalMode = IrisRiverTerminalMode.DRY_CHANNEL; - - @MinNumber(8) - @MaxNumber(1024) - @Desc("The distance in blocks over which a terminal channel returns to natural terrain.") - private int terminalTaper = 64; - - @MinNumber(0) - @MaxNumber(1) - @Desc("The probability that a failed wet route continues as a tapered dry channel.") - private double dryContinuationChance = 1D; - - private static IrisStyledRange range(double min, double max, NoiseStyle style, double zoom) { - return new IrisStyledRange(min, max, new IrisGeneratorStyle(style).zoomed(zoom)); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java deleted file mode 100644 index 0a23e651c..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverTopology.java +++ /dev/null @@ -1,112 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MaxNumber; -import art.arcane.iris.engine.object.annotations.MinNumber; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("Dimension-owned settings for the deterministic connected river graph.") -@Data -public class IrisRiverTopology { - @MinNumber(64) - @MaxNumber(4096) - @Desc("The routing-cell width in blocks. This controls graph identity and cannot be overridden by regions or biomes.") - private int cellSize = 512; - - @MinNumber(1) - @MaxNumber(64) - @Desc("The number of routing cells grouped into one immutable river cache tile.") - private int tileCells = 4; - - @MinNumber(0) - @MaxNumber(0.49) - @Desc("The fraction of a routing cell used to jitter its graph node away from the center.") - private double siteJitter = 0.35D; - - @MinNumber(1) - @MaxNumber(256) - @Desc("The maximum number of directed graph reaches followed by one source route.") - private int maxRouteReaches = 16; - - @MinNumber(0) - @MaxNumber(64) - @Desc("The minimum number of noise-weighted source nodes selected in each routing tile while source chance is above zero.") - private int minimumSourcesPerTile = 0; - - @MinNumber(0) - @MaxNumber(7) - @Desc("The number of alternate downstream reaches inspected before declaring a sink.") - private int sinkSearchReaches = 4; - - @MinNumber(8) - @MaxNumber(256) - @Desc("The spacing of deterministic drainage-basin sinks in routing cells. Larger values produce longer trunks and wider tributary trees.") - private int routingBasinCells = 64; - - @MinNumber(8) - @MaxNumber(256) - @Desc("The wavelength in routing cells of the smooth domain warp applied to drainage distance.") - private int routingDeviationScaleCells = 24; - - @MinNumber(0) - @MaxNumber(32) - @Desc("The maximum drainage-domain displacement in routing cells. Zero keeps straight radial basin gradients.") - private double routingDeviationStrengthCells = 0D; - - @MinNumber(1) - @MaxNumber(64) - @Desc("The horizontal basin-distance span in routing cells per one block of terraced water rise.") - private double routingPlateauHeight = 8D; - - @Desc("Selects complete river source routes at stable graph nodes.") - private IrisRiverNoiseChance source = new IrisRiverNoiseChance() - .setChance(0.05D) - .setStyle(new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(8192D)) - .setInfluence(0.035D); - - @Desc("Selects complete continuation reaches. A rejected reach terminates or suppresses its route rather than creating a gap.") - private IrisRiverNoiseChance continuation = new IrisRiverNoiseChance() - .setChance(0.99D) - .setStyle(new IrisGeneratorStyle(NoiseStyle.VASCULAR).zoomed(4096D)) - .setInfluence(0.01D); - - @Desc("Adds deterministic cost variation while choosing downstream graph neighbors.") - private IrisGeneratorStyle routingStyle = new IrisGeneratorStyle(NoiseStyle.VASCULAR).zoomed(8192D); - - @MinNumber(0) - @MaxNumber(1024) - @Desc("The maximum routing-cost contribution from routingStyle.") - private double routingNoiseWeight = 24D; - - @MinNumber(0) - @MaxNumber(1024) - @Desc("The penalty for choosing a downstream edge that does not follow the local routingStyle tangent.") - private double flowAlignmentWeight = 24D; - - @MinNumber(0) - @MaxNumber(1024) - @Desc("The deterministic attraction toward shared downstream nodes. Larger values form stronger tributary trees and confluences.") - private double confluenceWeight = 0D; - - @MinNumber(0) - @MaxNumber(16) - @Desc("The contribution of natural terrain height to downstream routing cost.") - private double terrainHeightWeight = 0.7D; - - @MinNumber(0) - @MaxNumber(16) - @Desc("The contribution of natural terrain slope to downstream routing cost.") - private double terrainSlopeWeight = 0.35D; - - @MinNumber(0) - @MaxNumber(16) - @Desc("The routing preference toward natural sea outlets.") - private double oceanAttraction = 1D; - - @Desc("Require every wet source route to reach natural sea or a proven sea-reaching trunk.") - private boolean requireOcean = false; -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java deleted file mode 100644 index c66d6d36a..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWater.java +++ /dev/null @@ -1,40 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MaxNumber; -import art.arcane.iris.engine.object.annotations.MinNumber; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("Controls the river water-surface solver.") -@Data -public class IrisRiverWater { - @Desc("The strategy used to determine river water-surface height.") - private IrisRiverWaterMode mode = IrisRiverWaterMode.FIXED; - - @MinNumber(-2048) - @MaxNumber(2048) - @Desc("The base river fluid surface in absolute world Y, independent of the dimension ocean height.") - private int fluidHeight = 63; - - @Desc("The river fluid palette used by surface channels, contained tunnels, grottos, and waterfall throats.") - private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("water"); - - @MinNumber(8) - @MaxNumber(4096) - @Desc("The target length of each flat terraced pool in blocks.") - private int poolLength = 96; - - @MinNumber(0) - @MaxNumber(64) - @Desc("The greatest terraced river height permitted above fluidHeight.") - private int maximumPoolRise = 4; - - @MinNumber(1) - @MaxNumber(32) - @Desc("The vertical height of controlled drops between terraced pools.") - private int dropHeight = 1; -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java deleted file mode 100644 index 5e61e92f8..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWaterMode.java +++ /dev/null @@ -1,12 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.Desc; - -@Desc("Selects how a river determines its surface fluid height.") -public enum IrisRiverWaterMode { - @Desc("Use the river water configuration's fixed fluid height for every wet reach.") - FIXED, - - @Desc("Use flat pools connected by controlled vertical drops.") - TERRACED -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java deleted file mode 100644 index e00c0840c..000000000 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisRiverWorm.java +++ /dev/null @@ -1,138 +0,0 @@ -package art.arcane.iris.engine.object; - -import art.arcane.iris.engine.object.annotations.ArrayType; -import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MaxNumber; -import art.arcane.iris.engine.object.annotations.MinNumber; -import art.arcane.iris.engine.object.annotations.Required; -import art.arcane.volmlib.util.collection.KList; -import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.experimental.Accessors; - -@Accessors(chain = true) -@NoArgsConstructor -@Desc("One weighted Perlin-worm river shape and its channel proportions.") -@Data -public class IrisRiverWorm { - @Required - @Desc("Unique lowercase identifier for this root or child style.") - private String id = "river"; - - @Desc("Stable salt for this Perlin field pair.") - private long seed = 1L; - - @MinNumber(0.000001) - @MaxNumber(1000000) - @Desc("Relative probability when selecting this root family or one child transition.") - private double weight = 1D; - - @MinNumber(8) - @MaxNumber(16384) - @Desc("Primary gradient-Perlin wavelength in blocks.") - private double wavelength = 1024D; - - @MinNumber(8) - @MaxNumber(16384) - @Desc("Secondary gradient-Perlin wavelength in blocks.") - private double detailWavelength = 256D; - - @MinNumber(0) - @MaxNumber(1) - @Desc("Primary heading deviation as a fraction of 180 degrees.") - private double tortuosity = 0.5D; - - @MinNumber(0) - @MaxNumber(1) - @Desc("Secondary heading deviation as a fraction of 180 degrees.") - private double detailTortuosity = 0.15D; - - @MinNumber(0) - @MaxNumber(1024) - @Desc("Maximum endpoint-bridged displacement from the reach chord in blocks.") - private double maxOffset = 320D; - - @MinNumber(1) - @MaxNumber(64) - @Desc("Number of deterministic Perlin-worm steps used to resolve the reach.") - private int segments = 48; - - @MinNumber(0.125) - @MaxNumber(8) - @Desc("Channel-width multiplier for reaches selecting this worm.") - private double widthMultiplier = 1D; - - @MinNumber(0.125) - @MaxNumber(8) - @Desc("Bank-width multiplier for reaches selecting this worm.") - private double bankMultiplier = 1D; - - @MinNumber(0.125) - @MaxNumber(8) - @Desc("Depth multiplier for reaches selecting this worm.") - private double depthMultiplier = 1D; - - @MinNumber(8) - @MaxNumber(16384) - @Desc("Primary world-space wavelength controlling longitudinal body swelling and pinching.") - private double bodyWavelength = 512D; - - @MinNumber(8) - @MaxNumber(16384) - @Desc("Detail wavelength adding smaller changes to the longitudinal body profile.") - private double bodyDetailWavelength = 128D; - - @MinNumber(0) - @MaxNumber(1) - @Desc("Share of the longitudinal body field supplied by bodyDetailWavelength; the remainder uses bodyWavelength.") - private double bodyDetailInfluence = 0.3D; - - @MinNumber(0) - @MaxNumber(0.875) - @Desc("Maximum proportional channel-width variation along this style's body.") - private double widthVariation = 0D; - - @MinNumber(0) - @MaxNumber(0.875) - @Desc("Maximum proportional bank or basin-width variation along this style's body.") - private double bankVariation = 0D; - - @MinNumber(0) - @MaxNumber(0.875) - @Desc("Maximum proportional bed-depth variation along this style's body.") - private double depthVariation = 0D; - - @MinNumber(0) - @MaxNumber(0.875) - @Desc("Maximum downward variation of tunnel roof clearance without exceeding the authored cave headroom.") - private double roofVariation = 0D; - - @MinNumber(1) - @MaxNumber(8) - @Desc("Number of upstream children admitted before additional siblings decay probabilistically.") - private int branchCap = 4; - - @MinNumber(0) - @MaxNumber(1) - @Desc("Multiplicative survival probability for every sibling beyond branchCap.") - private double branchDecay = 0.35D; - - @MinNumber(0) - @MaxNumber(8) - @Desc("Multiplier applied to the dimension confluence attraction for this style.") - private double confluenceMultiplier = 1D; - - @MinNumber(0) - @MaxNumber(1) - @Desc("Chance that an upstream continuation mutates from this style into one weighted child.") - private double childChance = 0D; - - @MinNumber(0) - @MaxNumber(1) - @Desc("Additional child-mutation chance for each sibling slot beyond the primary branch.") - private double branchChildChance = 0D; - - @ArrayType(type = IrisRiverWorm.class) - @Desc("Weighted descendant styles inherited by the complete upstream lineage after mutation.") - private KList children = new KList(); -} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverChannelConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverChannelConfig.java new file mode 100644 index 000000000..20948c9d4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverChannelConfig.java @@ -0,0 +1,47 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls the accepted surface-river terrain and biome footprint.") +@Data +public class IrisSurfaceRiverChannelConfig { + @Desc("Wet channel width in blocks.") + private IrisStyledRange width = range(4D, 8D, 1024D); + + @Desc("Wet bed depth in blocks.") + private IrisStyledRange depth = range(1D, 4D, 768D, 2D); + + @Desc("Vertical distance from the natural terrain corridor to the solved water surface.") + private IrisStyledRange surfaceInset = range(3D, 7D, 768D, 2D); + + @MinNumber(0) + @MaxNumber(64) + @Desc("Maximum permitted channel incision below natural terrain.") + private int maximumIncision = 6; + + @MinNumber(1) + @MaxNumber(2) + @Desc("Narrow biome-selection band immediately outside the wet channel.") + private double shoreWidth = 2D; + + @Desc("Wider terrain-grading distance outside the shore band.") + private IrisStyledRange terrainBlendWidth = range(10D, 24D, 1024D); + + private static IrisStyledRange range(double min, double max, double zoom) { + return range(min, max, zoom, 1D); + } + + private static IrisStyledRange range(double min, double max, double zoom, double exponent) { + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.IRIS) + .zoomed(zoom) + .setExponent(exponent); + return new IrisStyledRange(min, max, style); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java new file mode 100644 index 000000000..f59cd73a4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java @@ -0,0 +1,30 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls terrain-guided surface rivers.") +@Data +public class IrisSurfaceRiverConfig { + @Desc("Enable the independent surface-river source budget.") + private boolean enabled = true; + + @Desc("Surface-river source allocation.") + private IrisSurfaceRiverSourceConfig sources = new IrisSurfaceRiverSourceConfig(); + + @Desc("Surface channel and surrounding terrain footprint.") + private IrisSurfaceRiverChannelConfig channel = new IrisSurfaceRiverChannelConfig(); + + @Desc("Pool, riffle, cascade, and waterfall classification thresholds.") + private IrisRiverHydraulicsConfig hydraulics = new IrisRiverHydraulicsConfig(); + + @Desc("Short ridge-bore configuration for otherwise viable surface routes.") + private IrisRiverRidgeTunnelConfig ridgeTunnels = new IrisRiverRidgeTunnelConfig(); + + @Desc("Coastal mouth leveling and ocean-apron limits.") + private IrisRiverMouthConfig mouths = new IrisRiverMouthConfig(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverSourceConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverSourceConfig.java new file mode 100644 index 000000000..37adc5717 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverSourceConfig.java @@ -0,0 +1,34 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls surface-river source allocation.") +@Data +public class IrisSurfaceRiverSourceConfig { + @MinNumber(0) + @MaxNumber(64) + @Desc("Expected natural surface headwaters per qualifying hydrology tile.") + private double density = 0.5D; + + @MinNumber(-2048) + @MaxNumber(2048) + @Desc("Minimum natural terrain elevation eligible for a surface headwater.") + private int minimumElevation = 88; + + @MinNumber(0) + @MaxNumber(64) + @Desc("Minimum source quota considered for qualifying tiles when policy requires headwaters and a legal outlet exists.") + private int minimumPerTile = 0; + + @MinNumber(0) + @MaxNumber(8192) + @Desc("Minimum distance in blocks between natural surface headwaters.") + private int minimumSpacing = 384; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceSupport.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceSupport.java index fbb0c086d..9ad400911 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceSupport.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceSupport.java @@ -1,6 +1,8 @@ package art.arcane.iris.engine.object; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.util.common.math.IrisBlockVector; import java.util.Arrays; @@ -29,6 +31,21 @@ public final class IrisSurfaceSupport { return stencil.anyColumnUnsupported(placer, data, x, z, Math.max(1, minSolidDepth)); } + public static boolean intersectsHydrology(IObjectPlacer placer, int x, int z, + IrisObjectTranslate translate, IrisObjectRotation rotation, + int spinX, int spinY, int spinZ, + List supportOffsets) { + Engine engine = placer.getEngine(); + if (engine == null || engine.getComplex() == null) { + return false; + } + Stencil stencil = STENCIL.get(); + if (!stencil.build(supportOffsets, translate, rotation, spinX, spinY, spinZ, 0)) { + return true; + } + return stencil.anyColumnIntersectsHydrology(engine.getComplex(), x, z); + } + private static IrisBlockVector transform(IrisBlockVector offset, IrisObjectTranslate translate, IrisObjectRotation rotation, int spinX, int spinY, int spinZ) { IrisBlockVector transformed = offset.clone(); @@ -180,6 +197,22 @@ public final class IrisSurfaceSupport { return false; } + private boolean anyColumnIntersectsHydrology(IrisComplex complex, int x, int z) { + for (int stencilX = 0; stencilX < width; stencilX++) { + for (int stencilZ = 0; stencilZ < depth; stencilZ++) { + if (!cells[(stencilX * depth) + stencilZ]) { + continue; + } + int columnX = x + minX + stencilX; + int columnZ = z + minZ + stencilZ; + if (complex.hasHydrologySurfaceFluid(columnX, columnZ)) { + return true; + } + } + } + return false; + } + private boolean isColumnUnsupported(IObjectPlacer placer, IrisData data, int x, int z, int stencilX, int stencilZ, int minSolidDepth) { if (stencilX < 0 || stencilX >= width || stencilZ < 0 || stencilZ >= depth diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverConfig.java new file mode 100644 index 000000000..a1a439bcf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverConfig.java @@ -0,0 +1,37 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls an independent underground river network.") +@Data +public class IrisUndergroundRiverConfig { + @Desc("Enable the independent underground-river source budget.") + private boolean enabled = true; + + @Desc("Underground-river source allocation.") + private IrisUndergroundRiverSourceConfig sources = new IrisUndergroundRiverSourceConfig(); + + @Desc("Underground river fluid elevation in world Y.") + private IrisStyledRange fluidLevel = range(-48D, 50D, 1024D); + + @Desc("Underground wet channel width in blocks.") + private IrisStyledRange channelWidth = range(3D, 8D, 768D); + + @Desc("Underground wet bed depth in blocks.") + private IrisStyledRange depth = range(1D, 3D, 768D); + + @Desc("Dry vertical clearance above underground river fluid.") + private IrisStyledRange headroom = range(6D, 14D, 768D); + + @Desc("Allow accepted underground courses to connect transactionally to existing caves.") + private boolean connectToExistingCaves = true; + + private static IrisStyledRange range(double min, double max, double zoom) { + return new IrisStyledRange(min, max, new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(zoom)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverSourceConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverSourceConfig.java new file mode 100644 index 000000000..101056a1e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverSourceConfig.java @@ -0,0 +1,29 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("Controls underground-river source allocation independently from surface rivers.") +@Data +public class IrisUndergroundRiverSourceConfig { + @MinNumber(0) + @MaxNumber(64) + @Desc("Expected natural underground sources per qualifying hydrology tile.") + private double density = 0.25D; + + @MinNumber(0) + @MaxNumber(64) + @Desc("Minimum underground source quota considered for qualifying tiles when policy requires headwaters and a legal outlet exists.") + private int minimumPerTile = 0; + + @MinNumber(0) + @MaxNumber(8192) + @Desc("Minimum distance in blocks between natural underground sources.") + private int minimumSpacing = 512; +} diff --git a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java index d5640e4dd..533e930db 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/BukkitChunkGenerator.java @@ -311,14 +311,6 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun } private int resolveInitialSpawnY(World world, Location initialSpawn, int minY, int maxY) { - Engine activeEngine = engine; - if (activeEngine != null && activeEngine.getComplex() != null && activeEngine.getComplex().getHeightStream() != null) { - int generatedY = activeEngine.getMinHeight() - + activeEngine.getComplex().getHeightStream().get(initialSpawn.getX(), initialSpawn.getZ()).intValue() - + 1; - return Math.max(minY, Math.min(maxY, generatedY)); - } - return Math.max(minY, Math.min(maxY, world.getHighestBlockYAt(initialSpawn) + 1)); } diff --git a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java index 51a5b035a..7970d600a 100644 --- a/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java +++ b/core/src/main/java/art/arcane/iris/engine/platform/EngineBukkitOps.java @@ -130,51 +130,7 @@ public final class EngineBukkitOps { try { Runnable tileTask = () -> materializeTiles(engine, c, chunk); Runnable customTask = () -> materializeCustomBlocks(engine, c, chunk); - - Runnable updateTask = () -> { - PrecisionStopwatch p = PrecisionStopwatch.start(); - int[][] grid = new int[16][16]; - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - grid[x][z] = Integer.MIN_VALUE; - } - } - - chunk.iterate(MatterCavern.class, (x, yf, z, v) -> { - int y = yf + engine.getWorld().minHeight(); - x &= 15; - z &= 15; - Block block = c.getBlock(x, y, z); - if (!BukkitBlockResolution.isFluid(block.getBlockData())) { - return; - } - boolean u = BukkitBlockResolution.isAir(block.getRelative(BlockFace.DOWN).getBlockData()) - || BukkitBlockResolution.isAir(block.getRelative(BlockFace.WEST).getBlockData()) - || BukkitBlockResolution.isAir(block.getRelative(BlockFace.EAST).getBlockData()) - || BukkitBlockResolution.isAir(block.getRelative(BlockFace.SOUTH).getBlockData()) - || BukkitBlockResolution.isAir(block.getRelative(BlockFace.NORTH).getBlockData()); - - if (u) grid[x][z] = Math.max(grid[x][z], y); - }); - - for (int x = 0; x < 16; x++) { - for (int z = 0; z < 16; z++) { - if (grid[x][z] == Integer.MIN_VALUE) { - continue; - } - update(engine, x, grid[x][z], z, c, chunk); - } - } - - chunk.iterate(MatterUpdate.class, (x, yf, z, v) -> { - int y = yf + engine.getWorld().minHeight(); - if (v != null && v.isUpdate()) { - update(engine, x, y, z, c, chunk); - } - }); - chunk.deleteSlices(MatterUpdate.class); - engine.getMetrics().getUpdates().put(p.getMilliseconds()); - }; + Runnable updateTask = () -> materializeUpdates(engine, c, chunk); if (shouldRunChunkUpdateInline(c)) { runMaterializationPasses(chunk, tileTask, customTask, updateTask); @@ -219,6 +175,67 @@ public final class EngineBukkitOps { mantleChunk.deleteSlices(Identifier.class); } + static void materializeUpdates(Engine engine, Chunk chunk, MantleChunk mantleChunk) { + materializeUpdates( + engine, + chunk, + mantleChunk, + (x, y, z) -> update(engine, x, y, z, chunk, mantleChunk) + ); + } + + static void materializeUpdates( + Engine engine, + Chunk chunk, + MantleChunk mantleChunk, + UpdateDispatcher dispatcher + ) { + PrecisionStopwatch stopwatch = PrecisionStopwatch.start(); + int[][] grid = new int[16][16]; + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + grid[x][z] = Integer.MIN_VALUE; + } + } + + mantleChunk.iterate(MatterCavern.class, (x, yf, z, value) -> { + int y = yf + engine.getWorld().minHeight(); + x &= 15; + z &= 15; + Block block = chunk.getBlock(x, y, z); + if (!BukkitBlockResolution.isFluid(block.getBlockData())) { + return; + } + boolean exposed = BukkitBlockResolution.isAir(block.getRelative(BlockFace.DOWN).getBlockData()) + || BukkitBlockResolution.isAir(block.getRelative(BlockFace.WEST).getBlockData()) + || BukkitBlockResolution.isAir(block.getRelative(BlockFace.EAST).getBlockData()) + || BukkitBlockResolution.isAir(block.getRelative(BlockFace.SOUTH).getBlockData()) + || BukkitBlockResolution.isAir(block.getRelative(BlockFace.NORTH).getBlockData()); + + if (exposed) { + grid[x][z] = Math.max(grid[x][z], y); + } + }); + + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + if (grid[x][z] == Integer.MIN_VALUE) { + continue; + } + dispatcher.update(x, grid[x][z], z); + } + } + + mantleChunk.iterate(MatterUpdate.class, (x, yf, z, value) -> { + int y = yf + engine.getWorld().minHeight(); + if (value != null && value.isUpdate()) { + dispatcher.update(x, y, z); + } + }); + mantleChunk.deleteSlices(MatterUpdate.class); + engine.getMetrics().getUpdates().put(stopwatch.getMilliseconds()); + } + static void runMaterializationPasses( MantleChunk chunk, Runnable tileTask, @@ -309,11 +326,20 @@ public final class EngineBukkitOps { } } } else { - block.setType(Material.AIR, false); - block.setBlockData(data, true); + applyPhysicsUpdate(block, data); } } + static void applyPhysicsUpdate(Block block, BlockData data) { + block.setType(Material.AIR, false); + block.setBlockData(data, true); + } + + @FunctionalInterface + interface UpdateDispatcher { + void update(int x, int y, int z); + } + public static boolean isCanonicalContainer(Block block) { if (!(block.getBlockData() instanceof Chest chest) || chest.getType() == Chest.Type.SINGLE) { return true; diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverAnchor.java b/core/src/main/java/art/arcane/iris/engine/river/RiverAnchor.java deleted file mode 100644 index 80324dd24..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverAnchor.java +++ /dev/null @@ -1,27 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.Objects; - -public record RiverAnchor( - RiverEdgeId reachId, - int index, - long stableId, - double samplingSpacing, - long samplingSalt, - double x, - double z, - double alongReach, - RiverRouteState state, - int flow, - int order -) { - public RiverAnchor { - Objects.requireNonNull(reachId); - Objects.requireNonNull(state); - if (index < 0 || !Double.isFinite(samplingSpacing) || samplingSpacing <= 0D - || !Double.isFinite(x) || !Double.isFinite(z) - || !Double.isFinite(alongReach) || alongReach < 0.0 || alongReach > 1.0) { - throw new IllegalArgumentException("River anchor index and coordinates must be valid"); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java b/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java deleted file mode 100644 index 071a162ce..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverBodyProfile.java +++ /dev/null @@ -1,191 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.Arrays; - -public final class RiverBodyProfile { - private final double[] positions; - private final double[] widths; - private final double[] bankWidths; - private final double[] depths; - private final double[] roofScales; - private final double maximumWidth; - private final double maximumBankWidth; - private final double maximumDepth; - - public RiverBodyProfile( - double[] positions, - double[] widths, - double[] bankWidths, - double[] depths, - double[] roofScales - ) { - if (positions == null || widths == null || bankWidths == null || depths == null || roofScales == null - || positions.length < 2 - || positions.length != widths.length - || positions.length != bankWidths.length - || positions.length != depths.length - || positions.length != roofScales.length) { - throw new IllegalArgumentException("River body profiles require matching dimension samples"); - } - this.positions = positions.clone(); - this.widths = widths.clone(); - this.bankWidths = bankWidths.clone(); - this.depths = depths.clone(); - this.roofScales = roofScales.clone(); - double resolvedMaximumWidth = 0D; - double resolvedMaximumBankWidth = 0D; - double resolvedMaximumDepth = 0D; - for (int index = 0; index < this.positions.length; index++) { - double position = this.positions[index]; - if (!Double.isFinite(position) || position < 0D || position > 1D - || index > 0 && position <= this.positions[index - 1]) { - throw new IllegalArgumentException("River body profile positions must increase from zero to one"); - } - requirePositive(this.widths[index], "width"); - requireNonNegative(this.bankWidths[index], "bank width"); - requirePositive(this.depths[index], "depth"); - requireUnitScale(this.roofScales[index], "roof scale"); - resolvedMaximumWidth = StrictMath.max(resolvedMaximumWidth, this.widths[index]); - resolvedMaximumBankWidth = StrictMath.max(resolvedMaximumBankWidth, this.bankWidths[index]); - resolvedMaximumDepth = StrictMath.max(resolvedMaximumDepth, this.depths[index]); - } - if (this.positions[0] != 0D || this.positions[this.positions.length - 1] != 1D) { - throw new IllegalArgumentException("River body profile positions must include zero and one"); - } - maximumWidth = resolvedMaximumWidth; - maximumBankWidth = resolvedMaximumBankWidth; - maximumDepth = resolvedMaximumDepth; - } - - public static RiverBodyProfile constant(double width, double bankWidth, double depth) { - return new RiverBodyProfile( - new double[]{0D, 1D}, - new double[]{width, width}, - new double[]{bankWidth, bankWidth}, - new double[]{depth, depth}, - new double[]{1D, 1D} - ); - } - - public double width(double alongReach) { - return sample(widths, alongReach); - } - - public double bankWidth(double alongReach) { - return sample(bankWidths, alongReach); - } - - public double depth(double alongReach) { - return sample(depths, alongReach); - } - - public double roofScale(double alongReach) { - return sample(roofScales, alongReach); - } - - public double maximumWidth() { - return maximumWidth; - } - - public double maximumBankWidth() { - return maximumBankWidth; - } - - public double maximumDepth() { - return maximumDepth; - } - - public int size() { - return positions.length; - } - - public double position(int index) { - return positions[index]; - } - - public double widthAtIndex(int index) { - return widths[index]; - } - - public double bankWidthAtIndex(int index) { - return bankWidths[index]; - } - - public double depthAtIndex(int index) { - return depths[index]; - } - - public double roofScaleAtIndex(int index) { - return roofScales[index]; - } - - public int intervalIndex(double alongReach) { - double position = StrictMath.max(0D, StrictMath.min(1D, alongReach)); - int index = Arrays.binarySearch(positions, position); - if (index >= 0) { - return StrictMath.min(index, positions.length - 2); - } - return StrictMath.max(0, StrictMath.min(-index - 2, positions.length - 2)); - } - - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - if (!(object instanceof RiverBodyProfile profile)) { - return false; - } - return Arrays.equals(positions, profile.positions) - && Arrays.equals(widths, profile.widths) - && Arrays.equals(bankWidths, profile.bankWidths) - && Arrays.equals(depths, profile.depths) - && Arrays.equals(roofScales, profile.roofScales); - } - - @Override - public int hashCode() { - int hash = Arrays.hashCode(positions); - hash = 31 * hash + Arrays.hashCode(widths); - hash = 31 * hash + Arrays.hashCode(bankWidths); - hash = 31 * hash + Arrays.hashCode(depths); - return 31 * hash + Arrays.hashCode(roofScales); - } - - private double sample(double[] values, double alongReach) { - double position = StrictMath.max(0D, StrictMath.min(1D, alongReach)); - int index = Arrays.binarySearch(positions, position); - if (index >= 0) { - return values[index]; - } - int upper = -index - 1; - if (upper <= 0) { - return values[0]; - } - if (upper >= positions.length) { - return values[values.length - 1]; - } - int lower = upper - 1; - double range = positions[upper] - positions[lower]; - double interpolation = range <= 0D ? 0D : (position - positions[lower]) / range; - return values[lower] + (values[upper] - values[lower]) * interpolation; - } - - private static void requirePositive(double value, String name) { - if (!Double.isFinite(value) || value <= 0D) { - throw new IllegalArgumentException("River body profile " + name + " must be finite and positive"); - } - } - - private static void requireNonNegative(double value, String name) { - if (!Double.isFinite(value) || value < 0D) { - throw new IllegalArgumentException("River body profile " + name + " must be finite and non-negative"); - } - } - - private static void requireUnitScale(double value, String name) { - if (!Double.isFinite(value) || value <= 0D || value > 1D) { - throw new IllegalArgumentException("River body profile " + name + " must be greater than zero and at most one"); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverEdgeId.java b/core/src/main/java/art/arcane/iris/engine/river/RiverEdgeId.java deleted file mode 100644 index 9b25e5957..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverEdgeId.java +++ /dev/null @@ -1,34 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.Objects; - -public record RiverEdgeId(RiverNodeId first, RiverNodeId second) implements Comparable { - public RiverEdgeId { - Objects.requireNonNull(first); - Objects.requireNonNull(second); - if (first.compareTo(second) >= 0) { - throw new IllegalArgumentException("River edge endpoints must be distinct and canonical"); - } - } - - public static RiverEdgeId of(RiverNodeId first, RiverNodeId second) { - Objects.requireNonNull(first); - Objects.requireNonNull(second); - if (first.equals(second)) { - throw new IllegalArgumentException("River edge endpoints must be distinct"); - } - return first.compareTo(second) < 0 - ? new RiverEdgeId(first, second) - : new RiverEdgeId(second, first); - } - - public long stableId() { - return RiverNetwork.mix(first.stableId() ^ Long.rotateLeft(second.stableId(), 29)); - } - - @Override - public int compareTo(RiverEdgeId other) { - int firstComparison = first.compareTo(other.first); - return firstComparison != 0 ? firstComparison : second.compareTo(other.second); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java deleted file mode 100644 index 9cfeb7723..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverNetwork.java +++ /dev/null @@ -1,1343 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -public final class RiverNetwork { - private static final long NODE_X_SALT = 0x6A09E667F3BCC909L; - private static final long NODE_Z_SALT = 0xBB67AE8584CAA73BL; - private static final long NODE_RANK_SALT = 0x3C6EF372FE94F82BL; - private static final long BASIN_X_SALT = 0xCBBB9D5DC1059ED8L; - private static final long BASIN_Z_SALT = 0x629A292A367CD507L; - private static final long BASIN_DEVIATION_X_SALT = 0xA4093822299F31D0L; - private static final long BASIN_DEVIATION_Z_SALT = 0x082EFA98EC4E6C89L; - private static final long DIAGONAL_SALT = 0xA54FF53A5F1D36F1L; - private static final long SOURCE_SALT = 0x510E527FADE682D1L; - private static final long SOURCE_FLOOR_SALT = 0xD6E8FEB86659FD93L; - private static final long REACH_SALT = 0x9B05688C2B3E6C1FL; - private static final long DRY_SALT = 0x1F83D9ABFB41BD6BL; - private static final long WORM_FAMILY_SALT = 0x5BE0CD19137E2179L; - private static final long WORM_CHILD_GATE_SALT = 0x452821E638D01377L; - private static final long WORM_CHILD_SELECTION_SALT = 0xBE5466CF34E90C6CL; - private static final long WORM_PRIMARY_SALT = 0x243F6A8885A308D3L; - private static final long WORM_DETAIL_SALT = 0x13198A2E03707344L; - private static final long BODY_WIDTH_PRIMARY_SALT = 0xA4093822299F31D0L; - private static final long BODY_WIDTH_DETAIL_SALT = 0x082EFA98EC4E6C89L; - private static final long BODY_BANK_PRIMARY_SALT = 0x452821E638D01377L; - private static final long BODY_BANK_DETAIL_SALT = 0xBE5466CF34E90C6CL; - private static final long BODY_DEPTH_PRIMARY_SALT = 0xC0AC29B7C97C50DDL; - private static final long BODY_DEPTH_DETAIL_SALT = 0x3F84D5B5B5470917L; - private static final long BODY_ROOF_PRIMARY_SALT = 0xD1310BA698DFB5ACL; - private static final long BODY_ROOF_DETAIL_SALT = 0x2FFD72DBD01ADFB7L; - private static final long CONFLUENCE_SALT = 0x9E3779B97F4A7C15L; - private static final long BRANCH_SLOT_SALT = 0x94D049BB133111EBL; - private static final long BRANCH_GATE_SALT = 0x2545F4914F6CDD1DL; - private static final int MINIMUM_BODY_PROFILE_SAMPLES = 12; - private static final int MAXIMUM_BODY_PROFILE_SAMPLES = 512; - private static final double PERLIN_NORMALIZATION = 1.4142135623730951D; - - private final RiverNetworkOptions options; - - public RiverNetwork(RiverNetworkOptions options) { - this.options = Objects.requireNonNull(options); - } - - public RiverNetworkOptions options() { - return options; - } - - public RiverNode nodeAtCell(long cellX, long cellZ, RiverTerrainSampler terrain) { - return createNode(new RiverNodeId(cellX, cellZ), Objects.requireNonNull(terrain)); - } - - public RiverNode nodeAtWorld(int blockX, int blockZ, RiverTerrainSampler terrain) { - long cellX = Math.floorDiv(blockX, options.cellSize()); - long cellZ = Math.floorDiv(blockZ, options.cellSize()); - return nodeAtCell(cellX, cellZ, terrain); - } - - public int tileXForBlock(int blockX) { - return Math.floorDiv(blockX, options.cellSize() * options.tileCells()); - } - - public int tileZForBlock(int blockZ) { - return Math.floorDiv(blockZ, options.cellSize() * options.tileCells()); - } - - public RiverTile buildTileForBlock(int blockX, int blockZ, RiverTerrainSampler terrain) { - return buildTile(tileXForBlock(blockX), tileZForBlock(blockZ), terrain); - } - - public RiverSample sample(int blockX, int blockZ, RiverTerrainSampler terrain) { - return buildTileForBlock(blockX, blockZ, terrain).sample(blockX, blockZ); - } - - public List neighbors(RiverNodeId id) { - Objects.requireNonNull(id); - ArrayList neighbors = new ArrayList<>(8); - addUnique(neighbors, new RiverNodeId(id.cellX() - 1L, id.cellZ())); - addUnique(neighbors, new RiverNodeId(id.cellX() + 1L, id.cellZ())); - addUnique(neighbors, new RiverNodeId(id.cellX(), id.cellZ() - 1L)); - addUnique(neighbors, new RiverNodeId(id.cellX(), id.cellZ() + 1L)); - for (long squareX = id.cellX() - 1L; squareX <= id.cellX(); squareX++) { - for (long squareZ = id.cellZ() - 1L; squareZ <= id.cellZ(); squareZ++) { - RiverNodeId diagonal = diagonalNeighbor(id, squareX, squareZ); - if (diagonal != null) { - addUnique(neighbors, diagonal); - } - } - } - neighbors.sort(Comparator.naturalOrder()); - return List.copyOf(neighbors); - } - - public RiverNode downstream(RiverNodeId id, RiverTerrainSampler terrain) { - Objects.requireNonNull(id); - Objects.requireNonNull(terrain); - NodeResolver resolver = new NodeResolver(terrain); - RiverNode node = resolver.resolve(id); - List candidates = resolver.downstreamCandidates(node); - for (RiverNode candidate : candidates) { - RiverRoutingContext context = resolver.routingContext(node, candidate); - if (!resolver.reachFeasible(context)) { - continue; - } - return resolver.continuationPermitted(context) ? candidate : null; - } - return null; - } - - public List downstreamCandidates(RiverNodeId id, RiverTerrainSampler terrain) { - Objects.requireNonNull(id); - Objects.requireNonNull(terrain); - NodeResolver resolver = new NodeResolver(terrain); - return resolver.downstreamCandidates(resolver.resolve(id)); - } - - public RiverRoute trace(RiverNodeId source, RiverTerrainSampler terrain) { - Objects.requireNonNull(source); - Objects.requireNonNull(terrain); - return trace(source, new NodeResolver(terrain)); - } - - public RiverTile buildTile(int tileX, int tileZ, RiverTerrainSampler terrain) { - Objects.requireNonNull(terrain); - long tileWorldSize = (long) options.cellSize() * options.tileCells(); - long minimumX = (long) tileX * tileWorldSize; - long minimumZ = (long) tileZ * tileWorldSize; - long maximumX = minimumX + tileWorldSize; - long maximumZ = minimumZ + tileWorldSize; - requireWorldBounds(minimumX, minimumZ, maximumX, maximumZ); - - int geometryPadding = geometryPaddingCells(); - long targetMinimumCellX = (long) tileX * options.tileCells() - geometryPadding; - long targetMinimumCellZ = (long) tileZ * options.tileCells() - geometryPadding; - long targetMaximumCellX = (long) (tileX + 1) * options.tileCells() - 1L + geometryPadding; - long targetMaximumCellZ = (long) (tileZ + 1) * options.tileCells() - 1L + geometryPadding; - long sourceMinimumCellX = targetMinimumCellX - options.maxRouteReaches(); - long sourceMinimumCellZ = targetMinimumCellZ - options.maxRouteReaches(); - long sourceMaximumCellX = targetMaximumCellX + options.maxRouteReaches(); - long sourceMaximumCellZ = targetMaximumCellZ + options.maxRouteReaches(); - - NodeResolver resolver = new NodeResolver(terrain); - int sourceWidth = Math.toIntExact(sourceMaximumCellX - sourceMinimumCellX + 1L); - int sourceDepth = Math.toIntExact(sourceMaximumCellZ - sourceMinimumCellZ + 1L); - int sourceCount = Math.multiplyExact(sourceWidth, sourceDepth); - ArrayList routes = new ArrayList<>(sourceCount); - for (long cellX = sourceMinimumCellX; cellX <= sourceMaximumCellX; cellX++) { - for (long cellZ = sourceMinimumCellZ; cellZ <= sourceMaximumCellZ; cellZ++) { - routes.add(trace(new RiverNodeId(cellX, cellZ), resolver)); - } - } - LinkedHashMap accumulators = new LinkedHashMap<>(); - for (RiverRoute route : routes) { - accumulate(route, resolver, accumulators); - } - - ArrayList reaches = new ArrayList<>(accumulators.size()); - for (ReachAccumulator accumulator : accumulators.values()) { - if (!potentiallyIntersects( - accumulator.from, - accumulator.to, - minimumX, - minimumZ, - maximumX, - maximumZ - )) { - continue; - } - RiverReach reach = accumulator.build(); - if (intersects(reach, minimumX, minimumZ, maximumX, maximumZ)) { - reaches.add(reach); - } - } - reaches.sort(Comparator.comparing(RiverReach::id)); - return new RiverTile( - tileX, - tileZ, - (int) minimumX, - (int) minimumZ, - (int) maximumX, - (int) maximumZ, - reaches - ); - } - - public static long mix(long value) { - long mixed = value; - mixed ^= mixed >>> 30; - mixed *= 0xBF58476D1CE4E5B9L; - mixed ^= mixed >>> 27; - mixed *= 0x94D049BB133111EBL; - mixed ^= mixed >>> 31; - return mixed; - } - - private RiverNode createNode(RiverNodeId id, RiverTerrainSampler terrain) { - NodePosition position = nodePosition(id); - double x = position.x(); - double z = position.z(); - int blockX = position.blockX(); - int blockZ = position.blockZ(); - RiverTerrainNodeSample terrainSample = terrain.sampleNode(blockX, blockZ); - double naturalHeight = finiteOrZero(terrainSample.naturalHeight()); - boolean ocean = terrainSample.ocean(); - boolean riverAllowed = terrainSample.riverAllowed(); - double routingNoise = centered(hash(id, NODE_RANK_SALT)); - double routingScore = naturalHeight * options.terrainHeightWeight() - + routingNoise * options.routingNoiseWeight() - + finiteOrZero(terrainSample.routingCost()); - double drainageDistance = drainageBasin(id).distance(); - double hydraulicHeight = ocean - ? options.hydraulicBaseHeight() - : options.hydraulicBaseHeight() - + StrictMath.floor(drainageDistance / options.routingPlateauHeight()); - double rank = ocean ? -Double.MAX_VALUE : drainageDistance; - return new RiverNode( - id, - x, - z, - naturalHeight, - hydraulicHeight, - rank, - finiteOrZero(routingScore), - ocean, - riverAllowed - ); - } - - private double drainageDistance(RiverNodeId id) { - return drainageBasin(id).distance(); - } - - private DrainageBasin drainageBasin(RiverNodeId id) { - int basinCells = options.routingBasinCells(); - double nodeX = id.cellX() + 0.5D; - double nodeZ = id.cellZ() + 0.5D; - double deviationStrength = options.routingDeviationStrengthCells(); - if (deviationStrength > 0D) { - int deviationScale = options.routingDeviationScaleCells(); - double originalX = nodeX; - double originalZ = nodeZ; - nodeX += smoothCellNoise(originalX, originalZ, deviationScale, BASIN_DEVIATION_X_SALT) - * deviationStrength; - nodeZ += smoothCellNoise(originalX, originalZ, deviationScale, BASIN_DEVIATION_Z_SALT) - * deviationStrength; - } - long basinX = (long) StrictMath.floor(nodeX / basinCells); - long basinZ = (long) StrictMath.floor(nodeZ / basinCells); - double jitterRadius = basinCells * 0.45D; - double nearestDistance = Double.MAX_VALUE; - DrainageBasinId nearestId = null; - for (long candidateX = basinX - 1L; candidateX <= basinX + 1L; candidateX++) { - for (long candidateZ = basinZ - 1L; candidateZ <= basinZ + 1L; candidateZ++) { - double siteX = (candidateX + 0.5D) * basinCells - + centered(hash(candidateX, candidateZ, BASIN_X_SALT)) * jitterRadius; - double siteZ = (candidateZ + 0.5D) * basinCells - + centered(hash(candidateX, candidateZ, BASIN_Z_SALT)) * jitterRadius; - double distance = StrictMath.hypot(nodeX - siteX, nodeZ - siteZ); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestId = new DrainageBasinId(candidateX, candidateZ); - } - } - } - return new DrainageBasin(nearestId, nearestDistance); - } - - private double smoothCellNoise(double x, double z, int scale, long salt) { - double scaledX = x / scale; - double scaledZ = z / scale; - long minimumX = (long) StrictMath.floor(scaledX); - long minimumZ = (long) StrictMath.floor(scaledZ); - double fractionX = scaledX - minimumX; - double fractionZ = scaledZ - minimumZ; - double fadeX = fractionX * fractionX * (3D - 2D * fractionX); - double fadeZ = fractionZ * fractionZ * (3D - 2D * fractionZ); - double northwest = centered(hash(minimumX, minimumZ, salt)); - double northeast = centered(hash(minimumX + 1L, minimumZ, salt)); - double southwest = centered(hash(minimumX, minimumZ + 1L, salt)); - double southeast = centered(hash(minimumX + 1L, minimumZ + 1L, salt)); - double north = northwest + (northeast - northwest) * fadeX; - double south = southwest + (southeast - southwest) * fadeX; - return north + (south - north) * fadeZ; - } - - private NodePosition nodePosition(RiverNodeId id) { - double centerX = ((double) id.cellX() + 0.5D) * options.cellSize(); - double centerZ = ((double) id.cellZ() + 0.5D) * options.cellSize(); - double jitterRadius = options.siteJitter() * options.cellSize() * 0.5D; - double x = centerX + centered(hash(id, NODE_X_SALT)) * jitterRadius; - double z = centerZ + centered(hash(id, NODE_Z_SALT)) * jitterRadius; - return new NodePosition( - x, - z, - clampToInt(StrictMath.round(x)), - clampToInt(StrictMath.round(z)) - ); - } - - private List computeDownstreamCandidates(RiverNode node, NodeResolver resolver) { - if (node.ocean() || !node.riverAllowed()) { - return List.of(); - } - ArrayList ranked = new ArrayList<>(8); - for (RiverNodeId neighborId : neighbors(node.id())) { - RiverNode neighbor = resolver.resolve(neighborId); - if (!neighbor.riverAllowed()) { - continue; - } - if (compareRank(neighbor, node) >= 0) { - continue; - } - RiverWorm worm = resolver.worm(node, neighbor); - if (!branchPermitted(node, neighbor, resolver, worm)) { - continue; - } - RiverRoutingContext context = resolver.routingContext(node, neighbor); - double routingCost = finiteNonNegative(resolver.terrain.reachRoutingCost(context)); - double oceanAttraction = neighbor.ocean() ? options.oceanAttraction() : 0.0; - double flowAlignmentCost = flowAlignmentCost(node, neighbor, resolver); - double confluenceAttraction = unit(hash(neighbor.id(), CONFLUENCE_SALT)) - * options.confluenceWeight() - * worm.confluenceMultiplier(); - ranked.add(new RankedCandidate( - neighbor, - neighbor.routingScore() + routingCost + flowAlignmentCost - - oceanAttraction - confluenceAttraction - )); - } - ranked.sort((first, second) -> { - int costComparison = Double.compare(first.cost(), second.cost()); - return costComparison != 0 ? costComparison : compareRank(first.node(), second.node()); - }); - ArrayList candidates = new ArrayList<>(ranked.size()); - for (RankedCandidate candidate : ranked) { - candidates.add(candidate.node()); - } - return List.copyOf(candidates); - } - - private boolean branchPermitted( - RiverNode child, - RiverNode parent, - NodeResolver resolver, - RiverWorm worm - ) { - RiverEdgeId childEdge = RiverEdgeId.of(child.id(), parent.id()); - int childSlot = resolver.branchSlot(parent, child); - if (childSlot < worm.branchCap()) { - return true; - } - double survivalChance = 1D; - for (int overflow = worm.branchCap(); overflow <= childSlot; overflow++) { - survivalChance *= worm.branchDecay(); - } - return gate(hash(childEdge, BRANCH_GATE_SALT), survivalChance); - } - - private RiverRoute trace(RiverNodeId sourceId, NodeResolver resolver) { - if (!resolver.sourcePermitted(sourceId)) { - return new RiverRoute(sourceId, RiverRouteState.SUPPRESSED, List.of(), false, false); - } - RiverNode source = resolver.resolve(sourceId); - - ArrayList edges = new ArrayList<>(options.maxRouteReaches()); - RiverNode current = source; - boolean reachedOcean = false; - boolean exhaustedHorizon = true; - for (int reachIndex = 0; reachIndex < options.maxRouteReaches(); reachIndex++) { - RiverNode next = null; - int examined = 0; - for (RiverNode candidate : resolver.downstreamCandidates(current)) { - if (examined >= options.downstreamCandidateLimit()) { - break; - } - examined++; - RiverRoutingContext context = resolver.routingContext(current, candidate); - if (resolver.reachFeasible(context)) { - next = candidate; - break; - } - } - if (next == null) { - exhaustedHorizon = false; - break; - } - RiverEdgeId edgeId = RiverEdgeId.of(current.id(), next.id()); - if (!resolver.continuationPermitted(resolver.routingContext(current, next))) { - exhaustedHorizon = false; - break; - } - edges.add(edgeId); - current = next; - if (current.ocean()) { - reachedOcean = true; - break; - } - } - - if (reachedOcean) { - return new RiverRoute(sourceId, RiverRouteState.WET, edges, true, false); - } - if (exhaustedHorizon && !options.requireOcean()) { - return new RiverRoute(sourceId, RiverRouteState.WET, edges, false, false); - } - if (!edges.isEmpty()) { - RiverTerminalPolicy terminalPolicy = resolver.terminalPolicy(current); - if (terminalPolicy == RiverTerminalPolicy.WET - || (terminalPolicy == RiverTerminalPolicy.INHERIT && !options.requireOcean())) { - return new RiverRoute(sourceId, RiverRouteState.WET, edges, false, true); - } - if ((terminalPolicy == RiverTerminalPolicy.DRY - || terminalPolicy == RiverTerminalPolicy.INHERIT) - && resolver.dryPermitted(sourceId)) { - return new RiverRoute(sourceId, RiverRouteState.DRY, edges, false, true); - } - } - return new RiverRoute(sourceId, RiverRouteState.SUPPRESSED, List.of(), false, false); - } - - private void accumulate( - RiverRoute route, - NodeResolver resolver, - Map accumulators - ) { - if (route.state() == RiverRouteState.SUPPRESSED) { - return; - } - for (int edgeIndex = 0; edgeIndex < route.edges().size(); edgeIndex++) { - RiverEdgeId edgeId = route.edges().get(edgeIndex); - ReachAccumulator accumulator = accumulators.get(edgeId); - if (accumulator == null) { - RiverNode first = resolver.resolve(edgeId.first()); - RiverNode second = resolver.resolve(edgeId.second()); - RiverNode from = compareRank(first, second) > 0 ? first : second; - RiverNode to = from == first ? second : first; - accumulator = new ReachAccumulator( - edgeId, - from, - to, - resolver.routingContext(from, to), - resolver.worm(from, to), - resolver.terrain - ); - accumulators.put(edgeId, accumulator); - } - boolean terminal = route.terminal() && edgeIndex == route.edges().size() - 1; - accumulator.add(route.state(), terminal); - } - } - - private RiverNodeId diagonalNeighbor(RiverNodeId id, long squareX, long squareZ) { - boolean ascending = (hash(squareX, squareZ, DIAGONAL_SALT) & 1L) == 0L; - RiverNodeId first = ascending - ? new RiverNodeId(squareX, squareZ) - : new RiverNodeId(squareX, squareZ + 1L); - RiverNodeId second = ascending - ? new RiverNodeId(squareX + 1L, squareZ + 1L) - : new RiverNodeId(squareX + 1L, squareZ); - if (id.equals(first)) { - return second; - } - return id.equals(second) ? first : null; - } - - private RiverPolyline createPolyline( - RiverNode from, - RiverNode to, - RiverWorm worm - ) { - int pointCount = worm.segments() + 1; - double[] rawX = new double[pointCount]; - double[] rawZ = new double[pointCount]; - double deltaX = to.x() - from.x(); - double deltaZ = to.z() - from.z(); - double length = StrictMath.hypot(deltaX, deltaZ); - rawX[0] = from.x(); - rawZ[0] = from.z(); - if (length <= 0D) { - return new RiverPolyline(rawX, rawZ); - } - double baseHeading = StrictMath.atan2(deltaZ, deltaX); - double stepLength = length / worm.segments(); - for (int point = 1; point < pointCount; point++) { - double x = rawX[point - 1]; - double z = rawZ[point - 1]; - double primary = perlin(x, z, worm.wavelength(), worm.seed() ^ WORM_PRIMARY_SALT); - double detail = perlin(x, z, worm.detailWavelength(), worm.seed() ^ WORM_DETAIL_SALT); - double heading = baseHeading + StrictMath.PI * ( - primary * worm.tortuosity() - + detail * worm.detailTortuosity() - ); - rawX[point] = x + StrictMath.cos(heading) * stepLength; - rawZ[point] = z + StrictMath.sin(heading) * stepLength; - } - double[] x = new double[pointCount]; - double[] z = new double[pointCount]; - double rawDeltaX = rawX[pointCount - 1] - from.x(); - double rawDeltaZ = rawZ[pointCount - 1] - from.z(); - double maximumDisplacement = 0D; - for (int point = 0; point < pointCount; point++) { - double t = (double) point / (pointCount - 1); - double tSquared = t * t; - double envelope = 16D * tSquared * (1D - t) * (1D - t); - double straightX = from.x() + deltaX * t; - double straightZ = from.z() + deltaZ * t; - double rawBridgeX = rawX[point] - (from.x() + rawDeltaX * t); - double rawBridgeZ = rawZ[point] - (from.z() + rawDeltaZ * t); - x[point] = straightX + rawBridgeX * envelope; - z[point] = straightZ + rawBridgeZ * envelope; - maximumDisplacement = StrictMath.max( - maximumDisplacement, - StrictMath.hypot(x[point] - straightX, z[point] - straightZ) - ); - } - double maximumOffset = StrictMath.min(worm.maxOffset(), length * 0.35D); - if (maximumDisplacement > maximumOffset && maximumDisplacement > 0D) { - double scale = maximumOffset / maximumDisplacement; - for (int point = 1; point < pointCount - 1; point++) { - double t = (double) point / (pointCount - 1); - double straightX = from.x() + deltaX * t; - double straightZ = from.z() + deltaZ * t; - x[point] = straightX + (x[point] - straightX) * scale; - z[point] = straightZ + (z[point] - straightZ) * scale; - } - } - x[0] = from.x(); - z[0] = from.z(); - x[pointCount - 1] = to.x(); - z[pointCount - 1] = to.z(); - return new RiverPolyline(x, z); - } - - private RiverWorm rootWormFor(RiverNodeId rootId) { - return selectWeighted(options.worms(), hash(rootId, WORM_FAMILY_SALT)); - } - - private RiverWorm childWormFor( - RiverWorm parent, - RiverNodeId parentId, - RiverNodeId childId, - int branchSlot - ) { - if (parent.children().isEmpty()) { - return parent; - } - double chance = StrictMath.min( - 1D, - parent.childChance() - + StrictMath.min(7, branchSlot) * parent.branchChildChance() - ); - RiverEdgeId edgeId = RiverEdgeId.of(childId, parentId); - if (!gate(hash(edgeId, WORM_CHILD_GATE_SALT), chance)) { - return parent; - } - return selectWeighted(parent.children(), hash(edgeId, WORM_CHILD_SELECTION_SALT)); - } - - private RiverWorm selectWeighted(List worms, long selectionHash) { - double totalWeight = 0D; - for (RiverWorm worm : worms) { - totalWeight += worm.weight(); - } - double selection = unit(selectionHash) * totalWeight; - double cumulative = 0D; - for (RiverWorm worm : worms) { - cumulative += worm.weight(); - if (selection < cumulative) { - return worm; - } - } - return worms.get(worms.size() - 1); - } - - private double perlin(double x, double z, double wavelength, long salt) { - double scaledX = x / wavelength; - double scaledZ = z / wavelength; - long minimumX = (long) StrictMath.floor(scaledX); - long minimumZ = (long) StrictMath.floor(scaledZ); - double fractionX = scaledX - minimumX; - double fractionZ = scaledZ - minimumZ; - double fadeX = perlinFade(fractionX); - double fadeZ = perlinFade(fractionZ); - double northwest = perlinGradient(minimumX, minimumZ, fractionX, fractionZ, salt); - double northeast = perlinGradient(minimumX + 1L, minimumZ, fractionX - 1D, fractionZ, salt); - double southwest = perlinGradient(minimumX, minimumZ + 1L, fractionX, fractionZ - 1D, salt); - double southeast = perlinGradient( - minimumX + 1L, - minimumZ + 1L, - fractionX - 1D, - fractionZ - 1D, - salt - ); - double north = northwest + (northeast - northwest) * fadeX; - double south = southwest + (southeast - southwest) * fadeX; - return StrictMath.max( - -1D, - StrictMath.min(1D, (north + (south - north) * fadeZ) * PERLIN_NORMALIZATION) - ); - } - - private double perlinGradient(long latticeX, long latticeZ, double x, double z, long salt) { - return switch ((int) (hash(latticeX, latticeZ, salt) & 7L)) { - case 0 -> x; - case 1 -> -x; - case 2 -> z; - case 3 -> -z; - case 4 -> (x + z) / PERLIN_NORMALIZATION; - case 5 -> (-x + z) / PERLIN_NORMALIZATION; - case 6 -> (x - z) / PERLIN_NORMALIZATION; - default -> (-x - z) / PERLIN_NORMALIZATION; - }; - } - - private double perlinFade(double value) { - double squared = value * value; - double cubed = squared * value; - return cubed * (value * (value * 6D - 15D) + 10D); - } - - private double bodyMultiplier( - ReachPosition position, - RiverWorm worm, - long primarySalt, - long detailSalt, - double variation - ) { - if (variation <= 0D) { - return 1D; - } - return StrictMath.max( - 0.125D, - 1D + bodyField(position, worm, primarySalt, detailSalt) * variation - ); - } - - private double roofScale(ReachPosition position, RiverWorm worm) { - if (worm.roofVariation() <= 0D) { - return 1D; - } - double normalized = bodyField( - position, - worm, - BODY_ROOF_PRIMARY_SALT, - BODY_ROOF_DETAIL_SALT - ) * 0.5D + 0.5D; - return StrictMath.max(0.125D, 1D - normalized * worm.roofVariation()); - } - - private double bodyField( - ReachPosition position, - RiverWorm worm, - long primarySalt, - long detailSalt - ) { - double primary = perlin( - position.x(), - position.z(), - worm.bodyWavelength(), - worm.seed() ^ primarySalt - ); - double detail = perlin( - position.x(), - position.z(), - worm.bodyDetailWavelength(), - worm.seed() ^ detailSalt - ); - double detailInfluence = worm.bodyDetailInfluence(); - return primary * (1D - detailInfluence) + detail * detailInfluence; - } - - private FlowTangent resolveFlowTangent(RiverNode node, RiverTerrainSampler terrain) { - double spacing = StrictMath.max(1D, options.cellSize() * 0.5D); - double left = terrain.flowNoise(node.x() - spacing, node.z()); - double right = terrain.flowNoise(node.x() + spacing, node.z()); - double top = terrain.flowNoise(node.x(), node.z() - spacing); - double bottom = terrain.flowNoise(node.x(), node.z() + spacing); - if (!Double.isFinite(left) || !Double.isFinite(right) - || !Double.isFinite(top) || !Double.isFinite(bottom)) { - return new FlowTangent(0D, 0D); - } - double tangentX = -(bottom - top); - double tangentZ = right - left; - double tangentLength = StrictMath.hypot(tangentX, tangentZ); - if (tangentLength <= 0.0000001D) { - return new FlowTangent(0D, 0D); - } - return new FlowTangent(tangentX / tangentLength, tangentZ / tangentLength); - } - - private double flowAlignmentCost(RiverNode from, RiverNode to, NodeResolver resolver) { - if (options.flowAlignmentWeight() <= 0D) { - return 0D; - } - FlowTangent tangent = resolver.flowTangent(from); - if (tangent.x() == 0D && tangent.z() == 0D) { - return 0D; - } - double deltaX = to.x() - from.x(); - double deltaZ = to.z() - from.z(); - double length = StrictMath.hypot(deltaX, deltaZ); - if (length <= 0.0000001D) { - return options.flowAlignmentWeight(); - } - double alignment = StrictMath.abs( - tangent.x() * deltaX / length + tangent.z() * deltaZ / length - ); - return options.flowAlignmentWeight() * (1D - StrictMath.min(1D, alignment)); - } - - private boolean intersects( - RiverReach reach, - long minimumX, - long minimumZ, - long maximumX, - long maximumZ - ) { - double radius = reach.width() * 0.5 + reach.bankWidth(); - RiverPolyline polyline = reach.polyline(); - for (int point = 0; point < polyline.size() - 1; point++) { - double segmentMinimumX = StrictMath.min(polyline.x(point), polyline.x(point + 1)) - radius; - double segmentMaximumX = StrictMath.max(polyline.x(point), polyline.x(point + 1)) + radius; - double segmentMinimumZ = StrictMath.min(polyline.z(point), polyline.z(point + 1)) - radius; - double segmentMaximumZ = StrictMath.max(polyline.z(point), polyline.z(point + 1)) + radius; - if (segmentMaximumX >= minimumX && segmentMinimumX < maximumX - && segmentMaximumZ >= minimumZ && segmentMinimumZ < maximumZ) { - return true; - } - } - return false; - } - - private boolean potentiallyIntersects( - RiverNode from, - RiverNode to, - long minimumX, - long minimumZ, - long maximumX, - long maximumZ - ) { - double length = StrictMath.hypot(to.x() - from.x(), to.z() - from.z()); - double maximumWormOffset = StrictMath.min(options.maximumWormOffset(), length * 0.35D); - double padding = options.maximumReachRadius() + maximumWormOffset; - double reachMinimumX = StrictMath.min(from.x(), to.x()) - padding; - double reachMaximumX = StrictMath.max(from.x(), to.x()) + padding; - double reachMinimumZ = StrictMath.min(from.z(), to.z()) - padding; - double reachMaximumZ = StrictMath.max(from.z(), to.z()) + padding; - return reachMaximumX >= minimumX && reachMinimumX < maximumX - && reachMaximumZ >= minimumZ && reachMinimumZ < maximumZ; - } - - private int geometryPaddingCells() { - double maximumEdgeAxisDelta = options.cellSize() * (1D + options.siteJitter()); - double maximumEdgeLength = StrictMath.sqrt(2D) * maximumEdgeAxisDelta; - double maximumWormOffset = StrictMath.min(options.maximumWormOffset(), maximumEdgeLength * 0.35D); - double displacement = options.maximumReachRadius() + maximumWormOffset; - return 1 + (int) StrictMath.ceil(displacement / options.cellSize()); - } - - private int compareRank(RiverNode first, RiverNode second) { - if (first.ocean() != second.ocean()) { - return first.ocean() ? -1 : 1; - } - int rankComparison = Double.compare(first.rank(), second.rank()); - if (rankComparison != 0) { - return rankComparison; - } - int hydraulicComparison = Double.compare(first.hydraulicHeight(), second.hydraulicHeight()); - return hydraulicComparison != 0 ? hydraulicComparison : first.id().compareTo(second.id()); - } - - private long hash(RiverNodeId id, long salt) { - return mix(options.seed() ^ id.stableId() ^ salt); - } - - private long hash(RiverEdgeId id, long salt) { - return mix(options.seed() ^ id.stableId() ^ salt); - } - - private long hash(long x, long z, long salt) { - return mix(options.seed() ^ salt ^ mix(x * 0x9E3779B97F4A7C15L) ^ Long.rotateLeft(mix(z), 27)); - } - - private static void addUnique(List values, RiverNodeId candidate) { - if (!values.contains(candidate)) { - values.add(candidate); - } - } - - private static boolean gate(long hash, double chance) { - if (chance <= 0.0) { - return false; - } - if (chance >= 1.0) { - return true; - } - return unit(hash) < chance; - } - - private static double centered(long hash) { - return unit(hash) * 2.0 - 1.0; - } - - private static double unit(long hash) { - return (hash >>> 11) * 0x1.0p-53; - } - - private static int clampToInt(long value) { - return (int) StrictMath.max(Integer.MIN_VALUE, StrictMath.min(Integer.MAX_VALUE, value)); - } - - private static double finiteOrZero(double value) { - return Double.isFinite(value) ? value : 0.0; - } - - private static double finiteNonNegative(double value) { - return Double.isFinite(value) && value > 0.0 ? value : 0.0; - } - - private static double effectiveChance(double baseChance, double multiplier) { - if (!Double.isFinite(multiplier) || multiplier <= 0.0) { - return 0.0; - } - return StrictMath.min(1.0, baseChance * multiplier); - } - - private static void requireWorldBounds(long minimumX, long minimumZ, long maximumX, long maximumZ) { - if (minimumX < Integer.MIN_VALUE || minimumZ < Integer.MIN_VALUE - || maximumX > Integer.MAX_VALUE || maximumZ > Integer.MAX_VALUE) { - throw new IllegalArgumentException("River tile exceeds integer world coordinates"); - } - } - - private final class NodeResolver { - private final RiverTerrainSampler terrain; - private final Map nodes; - private final Map> downstreamCandidates; - private final Map sourceGates; - private final Map> minimumSources; - private final Map reachFeasibilities; - private final Map continuationGates; - private final Map dryGates; - private final Map terminalPolicies; - private final Map routingContexts; - private final Map flowTangents; - private final Map branchSlots; - private final Map styleDistances; - private final Map styleParents; - private final Map styleWorms; - private final Map styleBranchSlots; - private final Map resolvedBranchParents; - private final Map resolvedStyleBranchParents; - - private NodeResolver(RiverTerrainSampler terrain) { - this.terrain = terrain; - nodes = new HashMap<>(); - downstreamCandidates = new HashMap<>(); - sourceGates = new HashMap<>(); - minimumSources = new HashMap<>(); - reachFeasibilities = new HashMap<>(); - continuationGates = new HashMap<>(); - dryGates = new HashMap<>(); - terminalPolicies = new HashMap<>(); - routingContexts = new HashMap<>(); - flowTangents = new HashMap<>(); - branchSlots = new HashMap<>(); - styleDistances = new HashMap<>(); - styleParents = new HashMap<>(); - styleWorms = new HashMap<>(); - styleBranchSlots = new HashMap<>(); - resolvedBranchParents = new HashMap<>(); - resolvedStyleBranchParents = new HashMap<>(); - } - - private RiverNode resolve(RiverNodeId id) { - return nodes.computeIfAbsent(id, key -> createNode(key, terrain)); - } - - private List downstreamCandidates(RiverNode node) { - return downstreamCandidates.computeIfAbsent( - node.id(), - ignored -> computeDownstreamCandidates(node, this)); - } - - private int branchSlot(RiverNode parent, RiverNode child) { - if (!resolvedBranchParents.containsKey(parent.id())) { - ArrayList upstream = new ArrayList<>(8); - for (RiverNodeId siblingId : neighbors(parent.id())) { - RiverNode sibling = resolve(siblingId); - if (sibling.riverAllowed() && compareRank(sibling, parent) > 0) { - upstream.add(sibling); - } - } - upstream.sort((first, second) -> { - long firstPriority = hash(RiverEdgeId.of(first.id(), parent.id()), BRANCH_SLOT_SALT); - long secondPriority = hash(RiverEdgeId.of(second.id(), parent.id()), BRANCH_SLOT_SALT); - int priorityComparison = Long.compareUnsigned(firstPriority, secondPriority); - return priorityComparison != 0 - ? priorityComparison - : first.id().compareTo(second.id()); - }); - for (int slot = 0; slot < upstream.size(); slot++) { - RiverNode sibling = upstream.get(slot); - branchSlots.put(RiverEdgeId.of(sibling.id(), parent.id()), slot); - } - resolvedBranchParents.put(parent.id(), true); - } - return branchSlots.getOrDefault(RiverEdgeId.of(child.id(), parent.id()), Integer.MAX_VALUE); - } - - private boolean sourcePermitted(RiverNodeId sourceId) { - return sourceGates.computeIfAbsent(sourceId, this::computeSourcePermitted); - } - - private boolean computeSourcePermitted(RiverNodeId sourceId) { - if (options.sourceChance() <= 0D) { - return false; - } - boolean minimumSelected = minimumSources(sourceId).contains(sourceId); - if (minimumSelected) { - return true; - } - long sourceHash = hash(sourceId, SOURCE_SALT); - double maximumMultiplier = terrain.maximumSourceChanceMultiplier(); - if (!minimumSelected - && Double.isFinite(maximumMultiplier) - && !gate(sourceHash, effectiveChance(options.sourceChance(), maximumMultiplier))) { - return false; - } - NodePosition position = nodePosition(sourceId); - RiverTerrainSourceSample sourceSample = terrain.sampleSource(position.blockX(), position.blockZ()); - double chance = effectiveChance( - options.sourceChance(), - sourceSample.chanceMultiplier() - ); - boolean selected = gate(sourceHash, chance); - boolean permitted = selected - && sourceSample.riverAllowed() - && !sourceSample.ocean(); - return permitted; - } - - private List minimumSources(RiverNodeId sourceId) { - if (options.minimumSourcesPerTile() <= 0 || options.sourceChance() <= 0D) { - return List.of(); - } - SourceTileId tileId = new SourceTileId( - Math.floorDiv(sourceId.cellX(), options.tileCells()), - Math.floorDiv(sourceId.cellZ(), options.tileCells()) - ); - return minimumSources.computeIfAbsent(tileId, this::computeMinimumSources); - } - - private List computeMinimumSources(SourceTileId tileId) { - long minimumCellX = tileId.tileX() * options.tileCells(); - long minimumCellZ = tileId.tileZ() * options.tileCells(); - int candidateCount = options.tileCells() * options.tileCells(); - int targetCount = Math.min(options.minimumSourcesPerTile(), candidateCount); - if (targetCount == candidateCount) { - ArrayList selected = new ArrayList<>(candidateCount); - for (long cellX = minimumCellX; cellX < minimumCellX + options.tileCells(); cellX++) { - for (long cellZ = minimumCellZ; cellZ < minimumCellZ + options.tileCells(); cellZ++) { - RiverNodeId candidateId = new RiverNodeId(cellX, cellZ); - if (sourceFloorEligible(candidateId)) { - selected.add(candidateId); - } - } - } - return List.copyOf(selected); - } - ArrayList candidates = new ArrayList<>(candidateCount); - for (long cellX = minimumCellX; cellX < minimumCellX + options.tileCells(); cellX++) { - for (long cellZ = minimumCellZ; cellZ < minimumCellZ + options.tileCells(); cellZ++) { - RiverNodeId candidateId = new RiverNodeId(cellX, cellZ); - candidates.add(new WeightedSource( - candidateId, - -drainageDistance(candidateId) - + unit(hash(candidateId, SOURCE_FLOOR_SALT)) * 0.25D - )); - } - } - candidates.sort(Comparator.comparingDouble(WeightedSource::priority) - .thenComparing(WeightedSource::id)); - ArrayList selected = new ArrayList<>(targetCount); - for (WeightedSource candidate : candidates) { - if (!sourceFloorEligible(candidate.id())) { - continue; - } - selected.add(candidate.id()); - if (selected.size() >= targetCount) { - break; - } - } - return List.copyOf(selected); - } - - private boolean sourceFloorEligible(RiverNodeId candidateId) { - NodePosition position = nodePosition(candidateId); - RiverTerrainSourceSample sourceSample = terrain.sampleSource(position.blockX(), position.blockZ()); - return sourceSample.riverAllowed() - && !sourceSample.ocean() - && Double.isFinite(sourceSample.chanceMultiplier()) - && sourceSample.chanceMultiplier() > 0D; - } - - private boolean reachFeasible(RiverRoutingContext context) { - return reachFeasibilities.computeIfAbsent( - context.edgeId(), - ignored -> terrain.allowsReach(context)); - } - - private boolean continuationPermitted(RiverRoutingContext context) { - return continuationGates.computeIfAbsent(context.edgeId(), ignored -> { - double chance = effectiveChance( - options.reachChance(), - terrain.reachChanceMultiplier(context.midpointX(), context.midpointZ()) - ); - return gate(hash(context.edgeId(), REACH_SALT), chance); - }); - } - - private boolean dryPermitted(RiverNodeId sourceId) { - return dryGates.computeIfAbsent( - sourceId, - ignored -> gate(hash(sourceId, DRY_SALT), options.dryChannelChance())); - } - - private RiverTerminalPolicy terminalPolicy(RiverNode terminal) { - return terminalPolicies.computeIfAbsent(terminal.id(), ignored -> { - int terminalX = clampToInt(StrictMath.round(terminal.x())); - int terminalZ = clampToInt(StrictMath.round(terminal.z())); - RiverTerminalPolicy sampled = terrain.terminalPolicy(terminalX, terminalZ); - return sampled == null ? RiverTerminalPolicy.INHERIT : sampled; - }); - } - - private RiverRoutingContext routingContext(RiverNode from, RiverNode to) { - RiverEdgeId edgeId = RiverEdgeId.of(from.id(), to.id()); - RiverWorm worm = worm(from, to); - return routingContexts.computeIfAbsent(edgeId, ignored -> RiverRoutingContext.lazy( - edgeId, - from, - to, - () -> createPolyline(from, to, worm))); - } - - private RiverWorm worm(RiverNode first, RiverNode second) { - RiverNode child = compareRank(first, second) > 0 ? first : second; - return styleWorm(child.id()); - } - - private RiverWorm styleWorm(RiverNodeId id) { - RiverWorm cached = styleWorms.get(id); - if (cached != null) { - return cached; - } - StyleParent parent = styleParent(id); - RiverWorm selected; - if (parent.id() == null) { - selected = rootWormFor(id); - } else { - RiverWorm parentWorm = styleWorm(parent.id()); - selected = childWormFor( - parentWorm, - parent.id(), - id, - styleBranchSlot(parent.id(), id) - ); - } - styleWorms.put(id, selected); - return selected; - } - - private StyleParent styleParent(RiverNodeId id) { - return styleParents.computeIfAbsent(id, nodeId -> { - double nodeDistance = styleDistance(nodeId); - RiverNodeId selected = null; - double selectedDistance = Double.POSITIVE_INFINITY; - for (RiverNodeId candidate : neighbors(nodeId)) { - double candidateDistance = styleDistance(candidate); - if (candidateDistance >= nodeDistance - 0.000000001D) { - continue; - } - if (candidateDistance < selectedDistance - || candidateDistance == selectedDistance - && (selected == null || candidate.compareTo(selected) < 0)) { - selected = candidate; - selectedDistance = candidateDistance; - } - } - return new StyleParent(selected); - }); - } - - private double styleDistance(RiverNodeId id) { - return styleDistances.computeIfAbsent(id, RiverNetwork.this::drainageDistance); - } - - private int styleBranchSlot(RiverNodeId parentId, RiverNodeId childId) { - if (!resolvedStyleBranchParents.containsKey(parentId)) { - ArrayList children = new ArrayList<>(8); - for (RiverNodeId candidate : neighbors(parentId)) { - StyleParent candidateParent = styleParent(candidate); - if (parentId.equals(candidateParent.id())) { - children.add(candidate); - } - } - children.sort((first, second) -> { - long firstPriority = hash(RiverEdgeId.of(first, parentId), BRANCH_SLOT_SALT); - long secondPriority = hash(RiverEdgeId.of(second, parentId), BRANCH_SLOT_SALT); - int priorityComparison = Long.compareUnsigned(firstPriority, secondPriority); - return priorityComparison != 0 ? priorityComparison : first.compareTo(second); - }); - for (int slot = 0; slot < children.size(); slot++) { - RiverNodeId child = children.get(slot); - styleBranchSlots.put(RiverEdgeId.of(child, parentId), slot); - } - resolvedStyleBranchParents.put(parentId, true); - } - return styleBranchSlots.getOrDefault(RiverEdgeId.of(childId, parentId), Integer.MAX_VALUE); - } - - private FlowTangent flowTangent(RiverNode node) { - return flowTangents.computeIfAbsent( - node.id(), - ignored -> resolveFlowTangent(node, terrain)); - } - } - - private record RankedCandidate(RiverNode node, double cost) { - } - - private record NodePosition(double x, double z, int blockX, int blockZ) { - } - - private record FlowTangent(double x, double z) { - } - - private record DrainageBasinId(long x, long z) { - } - - private record DrainageBasin(DrainageBasinId id, double distance) { - } - - private record StyleParent(RiverNodeId id) { - } - - private record SourceTileId(long tileX, long tileZ) { - } - - private record WeightedSource(RiverNodeId id, double priority) { - } - - private final class ReachAccumulator { - private final RiverEdgeId id; - private final RiverNode from; - private final RiverNode to; - private final RiverRoutingContext context; - private final RiverWorm worm; - private final RiverTerrainSampler terrain; - private int wetFlow; - private int dryFlow; - private int terminalWetFlow; - private int terminalDryFlow; - - private ReachAccumulator( - RiverEdgeId id, - RiverNode from, - RiverNode to, - RiverRoutingContext context, - RiverWorm worm, - RiverTerrainSampler terrain - ) { - this.id = id; - this.from = from; - this.to = to; - this.context = context; - this.worm = worm; - this.terrain = terrain; - } - - private void add(RiverRouteState state, boolean terminal) { - if (state == RiverRouteState.WET) { - wetFlow++; - if (terminal) { - terminalWetFlow++; - } - } else if (state == RiverRouteState.DRY) { - dryFlow++; - if (terminal) { - terminalDryFlow++; - } - } - } - - private RiverReach build() { - int flow = wetFlow + dryFlow; - int order = 1 + (31 - Integer.numberOfLeadingZeros(flow)); - RiverBodyProfile bodyProfile = bodyProfile(order, worm); - RiverRouteState state = wetFlow > 0 ? RiverRouteState.WET : RiverRouteState.DRY; - return new RiverReach( - id, - from, - to, - state, - flow, - order, - bodyProfile.maximumWidth(), - bodyProfile.maximumBankWidth(), - bodyProfile.maximumDepth(), - bodyProfile, - state == RiverRouteState.WET && to.ocean(), - state == RiverRouteState.WET - ? terminalWetFlow == wetFlow - : terminalDryFlow == dryFlow, - context.polyline() - ); - } - - private RiverBodyProfile bodyProfile(int order, RiverWorm worm) { - RiverPolyline polyline = context.polyline(); - double minimumWavelength = StrictMath.min(worm.bodyWavelength(), worm.bodyDetailWavelength()); - int resolvedSamples = 1 + (int) StrictMath.ceil(polyline.length() * 2D / minimumWavelength); - int sampleCount = StrictMath.max( - MINIMUM_BODY_PROFILE_SAMPLES, - StrictMath.min(MAXIMUM_BODY_PROFILE_SAMPLES, resolvedSamples) - ); - double[] positions = new double[sampleCount]; - double[] widths = new double[sampleCount]; - double[] bankWidths = new double[sampleCount]; - double[] depths = new double[sampleCount]; - double[] roofScales = new double[sampleCount]; - double widthOrderScale = 1D + options.orderWidthFactor() * (order - 1); - double depthOrderScale = 1D + options.orderDepthFactor() * (order - 1); - for (int index = 0; index < sampleCount; index++) { - double alongReach = (double) index / (sampleCount - 1); - ReachPosition position = positionAt(polyline, alongReach); - double baseWidth = positiveOrFallback( - terrain.channelWidth( - context, - position.x(), - position.z(), - options.channelWidth() - ), - options.channelWidth() - ); - positions[index] = alongReach; - widths[index] = StrictMath.min( - options.maxChannelWidth(), - StrictMath.max( - 1D, - baseWidth - * widthOrderScale - * worm.widthMultiplier() - * bodyMultiplier( - position, - worm, - BODY_WIDTH_PRIMARY_SALT, - BODY_WIDTH_DETAIL_SALT, - worm.widthVariation() - ) - + options.channelRadiusBonus() * 2D - ) - ); - double baseBankWidth = nonNegativeOrFallback( - terrain.bankWidth(context, position.x(), position.z(), options.bankWidth()), - options.bankWidth() - ); - bankWidths[index] = StrictMath.min( - options.maxBankWidth(), - baseBankWidth - * worm.bankMultiplier() - * bodyMultiplier( - position, - worm, - BODY_BANK_PRIMARY_SALT, - BODY_BANK_DETAIL_SALT, - worm.bankVariation() - ) - ); - double baseDepth = positiveOrFallback( - terrain.depth(context, position.x(), position.z(), options.depth()), - options.depth() - ); - depths[index] = StrictMath.min( - options.maxDepth(), - StrictMath.max( - 1D, - baseDepth - * depthOrderScale - * worm.depthMultiplier() - * bodyMultiplier( - position, - worm, - BODY_DEPTH_PRIMARY_SALT, - BODY_DEPTH_DETAIL_SALT, - worm.depthVariation() - ) - ) - ); - roofScales[index] = roofScale(position, worm); - } - return new RiverBodyProfile(positions, widths, bankWidths, depths, roofScales); - } - } - - private static ReachPosition positionAt(RiverPolyline polyline, double alongReach) { - double targetDistance = Math.max(0D, Math.min(1D, alongReach)) * polyline.length(); - for (int point = 0; point < polyline.size() - 1; point++) { - double segmentStart = polyline.cumulativeLength(point); - double segmentEnd = polyline.cumulativeLength(point + 1); - if (targetDistance > segmentEnd && point < polyline.size() - 2) { - continue; - } - double segmentLength = segmentEnd - segmentStart; - double interpolation = segmentLength <= 0D - ? 0D - : (targetDistance - segmentStart) / segmentLength; - return new ReachPosition( - polyline.x(point) + (polyline.x(point + 1) - polyline.x(point)) * interpolation, - polyline.z(point) + (polyline.z(point + 1) - polyline.z(point)) * interpolation - ); - } - int last = polyline.size() - 1; - return new ReachPosition(polyline.x(last), polyline.z(last)); - } - - private static double positiveOrFallback(double value, double fallback) { - return Double.isFinite(value) && value > 0.0 ? value : fallback; - } - - private static double nonNegativeOrFallback(double value, double fallback) { - return Double.isFinite(value) && value >= 0.0 ? value : fallback; - } - - private record ReachPosition(double x, double z) { - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java deleted file mode 100644 index f4b7520b3..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverNetworkOptions.java +++ /dev/null @@ -1,488 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -public record RiverNetworkOptions( - long seed, - int cellSize, - int tileCells, - double siteJitter, - int maxRouteReaches, - int minimumSourcesPerTile, - int downstreamCandidateLimit, - int routingBasinCells, - int routingDeviationScaleCells, - double routingDeviationStrengthCells, - double routingPlateauHeight, - double hydraulicBaseHeight, - boolean requireOcean, - double sourceChance, - double reachChance, - double dryChannelChance, - double terrainHeightWeight, - double routingNoiseWeight, - double flowAlignmentWeight, - double confluenceWeight, - double oceanAttraction, - double channelWidth, - double bankWidth, - double depth, - double channelRadiusBonus, - double maxChannelWidth, - double maxBankWidth, - double maxDepth, - double orderWidthFactor, - double orderDepthFactor, - double maximumReachRadius, - List worms -) { - public RiverNetworkOptions { - requireRange(cellSize, 8, 4096, "cellSize"); - requireRange(tileCells, 1, 64, "tileCells"); - requireRange(maxRouteReaches, 1, 256, "maxRouteReaches"); - requireRange(minimumSourcesPerTile, 0, tileCells * tileCells, "minimumSourcesPerTile"); - requireRange(downstreamCandidateLimit, 1, 8, "downstreamCandidateLimit"); - requireRange(routingBasinCells, 8, 256, "routingBasinCells"); - requireRange(routingDeviationScaleCells, 8, 256, "routingDeviationScaleCells"); - requireRange(routingDeviationStrengthCells, 0D, 32D, "routingDeviationStrengthCells"); - requirePositive(routingPlateauHeight, "routingPlateauHeight"); - requireFinite(hydraulicBaseHeight, "hydraulicBaseHeight"); - requireProbability(siteJitter, "siteJitter"); - requireProbability(sourceChance, "sourceChance"); - requireProbability(reachChance, "reachChance"); - requireProbability(dryChannelChance, "dryChannelChance"); - requireFiniteNonNegative(terrainHeightWeight, "terrainHeightWeight"); - requireFiniteNonNegative(routingNoiseWeight, "routingNoiseWeight"); - requireFiniteNonNegative(flowAlignmentWeight, "flowAlignmentWeight"); - requireFiniteNonNegative(confluenceWeight, "confluenceWeight"); - requireFiniteNonNegative(oceanAttraction, "oceanAttraction"); - requirePositive(channelWidth, "channelWidth"); - requireFiniteNonNegative(bankWidth, "bankWidth"); - requirePositive(depth, "depth"); - requireFiniteNonNegative(channelRadiusBonus, "channelRadiusBonus"); - requirePositive(maxChannelWidth, "maxChannelWidth"); - requireFiniteNonNegative(maxBankWidth, "maxBankWidth"); - requirePositive(maxDepth, "maxDepth"); - requireFiniteNonNegative(orderWidthFactor, "orderWidthFactor"); - requireFiniteNonNegative(orderDepthFactor, "orderDepthFactor"); - requireFiniteNonNegative(maximumReachRadius, "maximumReachRadius"); - if (worms == null || worms.isEmpty()) { - throw new IllegalArgumentException("worms must contain at least one profile"); - } - if (worms.size() > 16) { - throw new IllegalArgumentException("worms must contain at most 16 root profiles"); - } - Set ids = new HashSet(); - Set seeds = new HashSet(); - int wormCount = validateWormTree(worms, 1, ids, seeds); - if (wormCount > 128) { - throw new IllegalArgumentException("worm hierarchy must contain at most 128 profiles"); - } - worms = List.copyOf(worms); - RiverTopologyComplexity.requireSafe( - cellSize, - tileCells, - siteJitter, - maxRouteReaches, - maximumReachRadius, - maximumWormOffset(worms), - maximumWormSegments(worms) - ); - } - - public static Builder builder(long seed) { - return new Builder(seed); - } - - public double maximumWormOffset() { - return maximumWormOffset(worms); - } - - public int maximumWormSegments() { - return maximumWormSegments(worms); - } - - private static double maximumWormOffset(List worms) { - double maximum = 0D; - for (RiverWorm worm : worms) { - if (worm == null) { - throw new IllegalArgumentException("worms must not contain null profiles"); - } - maximum = StrictMath.max(maximum, worm.maxOffset()); - maximum = StrictMath.max(maximum, maximumWormOffset(worm.children())); - } - return maximum; - } - - private static int maximumWormSegments(List worms) { - int maximum = 1; - for (RiverWorm worm : worms) { - if (worm == null) { - throw new IllegalArgumentException("worms must not contain null profiles"); - } - maximum = StrictMath.max(maximum, worm.segments()); - maximum = StrictMath.max(maximum, maximumWormSegments(worm.children())); - } - return maximum; - } - - private static int validateWormTree( - List worms, - int depth, - Set ids, - Set seeds - ) { - if (depth > 4) { - throw new IllegalArgumentException("worm hierarchy must be at most 4 profiles deep"); - } - int count = 0; - for (RiverWorm worm : worms) { - if (worm == null) { - throw new IllegalArgumentException("worm hierarchy must not contain null profiles"); - } - if (!ids.add(worm.id())) { - throw new IllegalArgumentException("worm ids must be unique: " + worm.id()); - } - if (!seeds.add(worm.seed())) { - throw new IllegalArgumentException("worm seeds must be unique: " + worm.seed()); - } - count++; - if (!worm.children().isEmpty()) { - count += validateWormTree(worm.children(), depth + 1, ids, seeds); - } - } - return count; - } - - private static void requireRange(int value, int minimum, int maximum, String name) { - if (value < minimum || value > maximum) { - throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum); - } - } - - private static void requireRange(double value, double minimum, double maximum, String name) { - if (!Double.isFinite(value) || value < minimum || value > maximum) { - throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum); - } - } - - private static void requireProbability(double value, String name) { - if (!Double.isFinite(value) || value < 0.0 || value > 1.0) { - throw new IllegalArgumentException(name + " must be finite and between 0 and 1"); - } - } - - private static void requireFiniteNonNegative(double value, String name) { - if (!Double.isFinite(value) || value < 0.0) { - throw new IllegalArgumentException(name + " must be finite and non-negative"); - } - } - - private static void requirePositive(double value, String name) { - if (!Double.isFinite(value) || value <= 0.0) { - throw new IllegalArgumentException(name + " must be finite and positive"); - } - } - - private static void requireFinite(double value, String name) { - if (!Double.isFinite(value)) { - throw new IllegalArgumentException(name + " must be finite"); - } - } - - public static final class Builder { - private final long seed; - private int cellSize; - private int tileCells; - private double siteJitter; - private int maxRouteReaches; - private int minimumSourcesPerTile; - private int downstreamCandidateLimit; - private int routingBasinCells; - private int routingDeviationScaleCells; - private double routingDeviationStrengthCells; - private double routingPlateauHeight; - private double hydraulicBaseHeight; - private boolean requireOcean; - private double sourceChance; - private double reachChance; - private double dryChannelChance; - private double terrainHeightWeight; - private double routingNoiseWeight; - private double flowAlignmentWeight; - private double confluenceWeight; - private double oceanAttraction; - private double channelWidth; - private double bankWidth; - private double depth; - private double channelRadiusBonus; - private double maxChannelWidth; - private double maxBankWidth; - private double maxDepth; - private double orderWidthFactor; - private double orderDepthFactor; - private double maximumReachRadius; - private List worms; - - private Builder(long seed) { - this.seed = seed; - cellSize = 512; - tileCells = 4; - siteJitter = 0.35; - maxRouteReaches = 16; - minimumSourcesPerTile = 0; - downstreamCandidateLimit = 4; - routingBasinCells = 64; - routingDeviationScaleCells = 24; - routingDeviationStrengthCells = 0D; - routingPlateauHeight = 8.0; - hydraulicBaseHeight = 64D; - requireOcean = false; - sourceChance = 0.12; - reachChance = 0.98; - dryChannelChance = 0.35; - terrainHeightWeight = 1.0; - routingNoiseWeight = 24.0; - flowAlignmentWeight = 0D; - confluenceWeight = 0D; - oceanAttraction = 64.0; - channelWidth = 10.0; - bankWidth = 8.0; - depth = 4.0; - maxChannelWidth = 10D; - maxBankWidth = 8D; - maxDepth = 10D; - orderWidthFactor = 0.35; - orderDepthFactor = 0.2; - maximumReachRadius = Double.NaN; - worms = List.of(new RiverWorm( - "default", - 1L, - 1D, - 1024D, - 256D, - 0.5D, - 0.15D, - 40D, - 8, - 1D, - 1D, - 1D, - 512D, - 128D, - 0.3D, - 0D, - 0D, - 0D, - 0D, - 4, - 0.35D, - 1D, - 0D, - 0D, - List.of() - )); - } - - public Builder cellSize(int value) { - cellSize = value; - return this; - } - - public Builder tileCells(int value) { - tileCells = value; - return this; - } - - public Builder siteJitter(double value) { - siteJitter = value; - return this; - } - - public Builder maxRouteReaches(int value) { - maxRouteReaches = value; - return this; - } - - public Builder minimumSourcesPerTile(int value) { - minimumSourcesPerTile = value; - return this; - } - - public Builder downstreamCandidateLimit(int value) { - downstreamCandidateLimit = value; - return this; - } - - public Builder routingBasinCells(int value) { - routingBasinCells = value; - return this; - } - - public Builder routingDeviationScaleCells(int value) { - routingDeviationScaleCells = value; - return this; - } - - public Builder routingDeviationStrengthCells(double value) { - routingDeviationStrengthCells = value; - return this; - } - - public Builder routingPlateauHeight(double value) { - routingPlateauHeight = value; - return this; - } - - public Builder hydraulicBaseHeight(double value) { - hydraulicBaseHeight = value; - return this; - } - - public Builder requireOcean(boolean value) { - requireOcean = value; - return this; - } - - public Builder sourceChance(double value) { - sourceChance = value; - return this; - } - - public Builder reachChance(double value) { - reachChance = value; - return this; - } - - public Builder dryChannelChance(double value) { - dryChannelChance = value; - return this; - } - - public Builder terrainHeightWeight(double value) { - terrainHeightWeight = value; - return this; - } - - public Builder routingNoiseWeight(double value) { - routingNoiseWeight = value; - return this; - } - - public Builder flowAlignmentWeight(double value) { - flowAlignmentWeight = value; - return this; - } - - public Builder confluenceWeight(double value) { - confluenceWeight = value; - return this; - } - - public Builder oceanAttraction(double value) { - oceanAttraction = value; - return this; - } - - public Builder channelWidth(double value) { - channelWidth = value; - return this; - } - - public Builder bankWidth(double value) { - bankWidth = value; - return this; - } - - public Builder depth(double value) { - depth = value; - return this; - } - - public Builder channelRadiusBonus(double value) { - channelRadiusBonus = value; - return this; - } - - public Builder maxChannelWidth(double value) { - maxChannelWidth = value; - return this; - } - - public Builder maxBankWidth(double value) { - maxBankWidth = value; - return this; - } - - public Builder maxDepth(double value) { - maxDepth = value; - return this; - } - - public Builder orderWidthFactor(double value) { - orderWidthFactor = value; - return this; - } - - public Builder orderDepthFactor(double value) { - orderDepthFactor = value; - return this; - } - - public Builder maximumReachRadius(double value) { - maximumReachRadius = value; - return this; - } - - public Builder worms(List value) { - worms = value; - return this; - } - - public RiverNetworkOptions build() { - double resolvedMaximumReachRadius = Double.isFinite(maximumReachRadius) - ? maximumReachRadius - : defaultMaximumReachRadius(); - return new RiverNetworkOptions( - seed, - cellSize, - tileCells, - siteJitter, - maxRouteReaches, - minimumSourcesPerTile, - downstreamCandidateLimit, - routingBasinCells, - routingDeviationScaleCells, - routingDeviationStrengthCells, - routingPlateauHeight, - hydraulicBaseHeight, - requireOcean, - sourceChance, - reachChance, - dryChannelChance, - terrainHeightWeight, - routingNoiseWeight, - flowAlignmentWeight, - confluenceWeight, - oceanAttraction, - channelWidth, - bankWidth, - depth, - channelRadiusBonus, - maxChannelWidth, - maxBankWidth, - maxDepth, - orderWidthFactor, - orderDepthFactor, - resolvedMaximumReachRadius, - worms - ); - } - - private double defaultMaximumReachRadius() { - return maxChannelWidth * 0.5D + maxBankWidth; - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNode.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNode.java deleted file mode 100644 index 73b895b77..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverNode.java +++ /dev/null @@ -1,24 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.Objects; - -public record RiverNode( - RiverNodeId id, - double x, - double z, - double naturalHeight, - double hydraulicHeight, - double rank, - double routingScore, - boolean ocean, - boolean riverAllowed -) { - public RiverNode { - Objects.requireNonNull(id); - if (!Double.isFinite(x) || !Double.isFinite(z) || !Double.isFinite(naturalHeight) - || !Double.isFinite(hydraulicHeight) - || !Double.isFinite(rank) || !Double.isFinite(routingScore)) { - throw new IllegalArgumentException("River node coordinates, height, and rank must be finite"); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverNodeId.java b/core/src/main/java/art/arcane/iris/engine/river/RiverNodeId.java deleted file mode 100644 index 352502f58..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverNodeId.java +++ /dev/null @@ -1,13 +0,0 @@ -package art.arcane.iris.engine.river; - -public record RiverNodeId(long cellX, long cellZ) implements Comparable { - public long stableId() { - return RiverNetwork.mix(cellX * 0x9E3779B97F4A7C15L ^ Long.rotateLeft(cellZ * 0xC2B2AE3D27D4EB4FL, 31)); - } - - @Override - public int compareTo(RiverNodeId other) { - int xComparison = Long.compare(cellX, other.cellX); - return xComparison != 0 ? xComparison : Long.compare(cellZ, other.cellZ); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverPolyline.java b/core/src/main/java/art/arcane/iris/engine/river/RiverPolyline.java deleted file mode 100644 index 9970374ca..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverPolyline.java +++ /dev/null @@ -1,48 +0,0 @@ -package art.arcane.iris.engine.river; - -public final class RiverPolyline { - private final double[] x; - private final double[] z; - private final double[] cumulativeLength; - private final double length; - - public RiverPolyline(double[] x, double[] z) { - if (x.length != z.length || x.length < 2) { - throw new IllegalArgumentException("River polyline requires matching coordinate arrays and at least two points"); - } - this.x = x.clone(); - this.z = z.clone(); - cumulativeLength = new double[x.length]; - double measuredLength = 0.0; - for (int i = 0; i < x.length; i++) { - if (!Double.isFinite(x[i]) || !Double.isFinite(z[i])) { - throw new IllegalArgumentException("River polyline coordinates must be finite"); - } - if (i > 0) { - measuredLength += StrictMath.hypot(x[i] - x[i - 1], z[i] - z[i - 1]); - cumulativeLength[i] = measuredLength; - } - } - length = measuredLength; - } - - public int size() { - return x.length; - } - - public double x(int index) { - return x[index]; - } - - public double z(int index) { - return z[index]; - } - - public double cumulativeLength(int index) { - return cumulativeLength[index]; - } - - public double length() { - return length; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java b/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java deleted file mode 100644 index 383b224d9..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverReach.java +++ /dev/null @@ -1,59 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.Objects; - -public record RiverReach( - RiverEdgeId id, - RiverNode from, - RiverNode to, - RiverRouteState state, - int flow, - int order, - double width, - double bankWidth, - double depth, - RiverBodyProfile bodyProfile, - boolean mouth, - boolean terminal, - RiverPolyline polyline -) { - public RiverReach { - Objects.requireNonNull(id); - Objects.requireNonNull(from); - Objects.requireNonNull(to); - Objects.requireNonNull(state); - Objects.requireNonNull(bodyProfile); - Objects.requireNonNull(polyline); - if (state == RiverRouteState.SUPPRESSED) { - throw new IllegalArgumentException("Suppressed routes cannot produce reaches"); - } - if (flow < 1 || order < 1) { - throw new IllegalArgumentException("River reach flow and order must be positive"); - } - if (!Double.isFinite(width) || width <= 0.0 || !Double.isFinite(bankWidth) || bankWidth < 0.0 - || !Double.isFinite(depth) || depth <= 0.0) { - throw new IllegalArgumentException("River reach dimensions must be finite and valid"); - } - if (Double.compare(width, bodyProfile.maximumWidth()) != 0 - || Double.compare(bankWidth, bodyProfile.maximumBankWidth()) != 0 - || Double.compare(depth, bodyProfile.maximumDepth()) != 0) { - throw new IllegalArgumentException("River reach dimensions must equal their body-profile maxima"); - } - } - - public double widthAt(double alongReach) { - return bodyProfile.width(alongReach); - } - - public double bankWidthAt(double alongReach) { - return bodyProfile.bankWidth(alongReach); - } - - public double depthAt(double alongReach) { - return bodyProfile.depth(alongReach); - } - - public double roofScaleAt(double alongReach) { - return bodyProfile.roofScale(alongReach); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverRoute.java b/core/src/main/java/art/arcane/iris/engine/river/RiverRoute.java deleted file mode 100644 index 1d619c05e..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverRoute.java +++ /dev/null @@ -1,18 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.List; -import java.util.Objects; - -public record RiverRoute( - RiverNodeId source, - RiverRouteState state, - List edges, - boolean oceanConnected, - boolean terminal -) { - public RiverRoute { - Objects.requireNonNull(source); - Objects.requireNonNull(state); - edges = List.copyOf(edges); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverRouteState.java b/core/src/main/java/art/arcane/iris/engine/river/RiverRouteState.java deleted file mode 100644 index 00beebf4e..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverRouteState.java +++ /dev/null @@ -1,7 +0,0 @@ -package art.arcane.iris.engine.river; - -public enum RiverRouteState { - WET, - DRY, - SUPPRESSED -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverRoutingContext.java b/core/src/main/java/art/arcane/iris/engine/river/RiverRoutingContext.java deleted file mode 100644 index ef9e856c1..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverRoutingContext.java +++ /dev/null @@ -1,105 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.Objects; -import java.util.function.Supplier; - -public final class RiverRoutingContext { - private final RiverEdgeId edgeId; - private final RiverNode from; - private final RiverNode to; - private final Supplier polylineSupplier; - private volatile RiverPolyline polyline; - - public RiverRoutingContext(RiverEdgeId edgeId, RiverNode from, RiverNode to, RiverPolyline polyline) { - this(edgeId, from, to, () -> polyline, Objects.requireNonNull(polyline)); - } - - static RiverRoutingContext lazy( - RiverEdgeId edgeId, - RiverNode from, - RiverNode to, - Supplier polylineSupplier - ) { - return new RiverRoutingContext(edgeId, from, to, polylineSupplier, null); - } - - private RiverRoutingContext( - RiverEdgeId edgeId, - RiverNode from, - RiverNode to, - Supplier polylineSupplier, - RiverPolyline polyline - ) { - this.edgeId = Objects.requireNonNull(edgeId); - this.from = Objects.requireNonNull(from); - this.to = Objects.requireNonNull(to); - this.polylineSupplier = Objects.requireNonNull(polylineSupplier); - this.polyline = polyline; - } - - public RiverEdgeId edgeId() { - return edgeId; - } - - public RiverNode from() { - return from; - } - - public RiverNode to() { - return to; - } - - public RiverPolyline polyline() { - RiverPolyline resolved = polyline; - if (resolved != null) { - return resolved; - } - synchronized (this) { - if (polyline == null) { - polyline = Objects.requireNonNull(polylineSupplier.get()); - } - return polyline; - } - } - - public int midpointX() { - return (int) StrictMath.max( - Integer.MIN_VALUE, - StrictMath.min(Integer.MAX_VALUE, StrictMath.round((from.x() + to.x()) * 0.5)) - ); - } - - public int midpointZ() { - return (int) StrictMath.max( - Integer.MIN_VALUE, - StrictMath.min(Integer.MAX_VALUE, StrictMath.round((from.z() + to.z()) * 0.5)) - ); - } - - @Override - public boolean equals(Object candidate) { - if (this == candidate) { - return true; - } - if (!(candidate instanceof RiverRoutingContext context)) { - return false; - } - return edgeId.equals(context.edgeId) - && from.equals(context.from) - && to.equals(context.to) - && polyline().equals(context.polyline()); - } - - @Override - public int hashCode() { - return Objects.hash(edgeId, from, to, polyline()); - } - - @Override - public String toString() { - return "RiverRoutingContext[edgeId=" + edgeId - + ", from=" + from - + ", to=" + to - + ", polyline=" + polyline() + "]"; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverSample.java b/core/src/main/java/art/arcane/iris/engine/river/RiverSample.java deleted file mode 100644 index c527910da..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverSample.java +++ /dev/null @@ -1,37 +0,0 @@ -package art.arcane.iris.engine.river; - -public record RiverSample( - boolean present, - RiverRouteState state, - RiverSection section, - double distance, - double alongReach, - double carveWeight, - int flow, - int order, - double width, - double bankWidth, - double depth, - boolean terminal, - RiverEdgeId reachId -) { - private static final RiverSample NONE = new RiverSample( - false, - RiverRouteState.SUPPRESSED, - RiverSection.NONE, - Double.POSITIVE_INFINITY, - 0.0, - 0.0, - 0, - 0, - 0.0, - 0.0, - 0.0, - false, - null - ); - - public static RiverSample none() { - return NONE; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverSection.java b/core/src/main/java/art/arcane/iris/engine/river/RiverSection.java deleted file mode 100644 index 452774c60..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverSection.java +++ /dev/null @@ -1,10 +0,0 @@ -package art.arcane.iris.engine.river; - -public enum RiverSection { - NONE, - CHANNEL, - MOUTH, - BANK, - DRY_CHANNEL, - DRY_BANK -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTerminalPolicy.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTerminalPolicy.java deleted file mode 100644 index 5f0563d85..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTerminalPolicy.java +++ /dev/null @@ -1,8 +0,0 @@ -package art.arcane.iris.engine.river; - -public enum RiverTerminalPolicy { - INHERIT, - WET, - DRY, - SUPPRESS -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainNodeSample.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainNodeSample.java deleted file mode 100644 index 8fe2fbee6..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainNodeSample.java +++ /dev/null @@ -1,9 +0,0 @@ -package art.arcane.iris.engine.river; - -public record RiverTerrainNodeSample( - double naturalHeight, - boolean ocean, - boolean riverAllowed, - double routingCost -) { -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java deleted file mode 100644 index 1fc250bec..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSampler.java +++ /dev/null @@ -1,99 +0,0 @@ -package art.arcane.iris.engine.river; - -public interface RiverTerrainSampler { - double naturalHeight(int blockX, int blockZ); - - boolean isOcean(int blockX, int blockZ); - - default RiverTerrainNodeSample sampleNode(int blockX, int blockZ) { - return new RiverTerrainNodeSample( - naturalHeight(blockX, blockZ), - isOcean(blockX, blockZ), - allowsRiver(blockX, blockZ), - routingCost(blockX, blockZ) - ); - } - - default RiverTerrainSourceSample sampleSource(int blockX, int blockZ) { - return new RiverTerrainSourceSample( - sourceChanceMultiplier(blockX, blockZ), - allowsRiver(blockX, blockZ), - isOcean(blockX, blockZ) - ); - } - - default double routingCost(int blockX, int blockZ) { - return 0.0; - } - - default double sourceChanceMultiplier(int blockX, int blockZ) { - return 1.0; - } - - default double maximumSourceChanceMultiplier() { - return Double.POSITIVE_INFINITY; - } - - default double reachChanceMultiplier(int blockX, int blockZ) { - return 1.0; - } - - default boolean allowsRiver(int blockX, int blockZ) { - return true; - } - - default boolean allowsReach(RiverRoutingContext context) { - return true; - } - - default double reachRoutingCost(RiverRoutingContext context) { - return 0.0; - } - - default double flowNoise(double x, double z) { - return Double.NaN; - } - - default double channelWidth(RiverRoutingContext context, double fallback) { - return fallback; - } - - default double channelWidth( - RiverRoutingContext context, - double x, - double z, - double fallback - ) { - return channelWidth(context, fallback); - } - - default double bankWidth(RiverRoutingContext context, double fallback) { - return fallback; - } - - default double bankWidth( - RiverRoutingContext context, - double x, - double z, - double fallback - ) { - return bankWidth(context, fallback); - } - - default double depth(RiverRoutingContext context, double fallback) { - return fallback; - } - - default double depth( - RiverRoutingContext context, - double x, - double z, - double fallback - ) { - return depth(context, fallback); - } - - default RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { - return RiverTerminalPolicy.INHERIT; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSourceSample.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSourceSample.java deleted file mode 100644 index 279feb58b..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTerrainSourceSample.java +++ /dev/null @@ -1,8 +0,0 @@ -package art.arcane.iris.engine.river; - -public record RiverTerrainSourceSample( - double chanceMultiplier, - boolean riverAllowed, - boolean ocean -) { -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java deleted file mode 100644 index 8c9bbdd85..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTile.java +++ /dev/null @@ -1,717 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; - -public final class RiverTile { - private static final int BUCKET_SIZE = 64; - - private final int tileX; - private final int tileZ; - private final int minimumX; - private final int minimumZ; - private final int maximumX; - private final int maximumZ; - private final List reaches; - private final Map reachesById; - private final Map> spatialIndex; - - public RiverTile( - int tileX, - int tileZ, - int minimumX, - int minimumZ, - int maximumX, - int maximumZ, - List reaches - ) { - if (minimumX >= maximumX || minimumZ >= maximumZ) { - throw new IllegalArgumentException("River tile bounds must have positive area"); - } - this.tileX = tileX; - this.tileZ = tileZ; - this.minimumX = minimumX; - this.minimumZ = minimumZ; - this.maximumX = maximumX; - this.maximumZ = maximumZ; - this.reaches = List.copyOf(reaches); - reachesById = indexById(this.reaches); - spatialIndex = createSpatialIndex(this.reaches); - } - - public int tileX() { - return tileX; - } - - public int tileZ() { - return tileZ; - } - - public int minimumX() { - return minimumX; - } - - public int minimumZ() { - return minimumZ; - } - - public int maximumX() { - return maximumX; - } - - public int maximumZ() { - return maximumZ; - } - - public List reaches() { - return reaches; - } - - public RiverReach reach(RiverEdgeId id) { - return reachesById.get(Objects.requireNonNull(id)); - } - - public List candidateAnchors(double spacing, long salt) { - return candidateAnchors(minimumX, minimumZ, maximumX, maximumZ, spacing, salt); - } - - public List candidateAnchors( - double queryMinimumX, - double queryMinimumZ, - double queryMaximumX, - double queryMaximumZ, - double spacing, - long salt - ) { - if (!Double.isFinite(spacing) || spacing <= 0.0) { - throw new IllegalArgumentException("River anchor spacing must be finite and positive"); - } - if (!Double.isFinite(queryMinimumX) || !Double.isFinite(queryMinimumZ) - || !Double.isFinite(queryMaximumX) || !Double.isFinite(queryMaximumZ) - || queryMinimumX >= queryMaximumX || queryMinimumZ >= queryMaximumZ) { - throw new IllegalArgumentException("River anchor query bounds must be finite and have positive area"); - } - ArrayList anchors = new ArrayList<>(); - for (RiverReach reach : indexedReaches(queryMinimumX, queryMinimumZ, queryMaximumX, queryMaximumZ)) { - addAnchors( - reach, - spacing, - salt, - queryMinimumX, - queryMinimumZ, - queryMaximumX, - queryMaximumZ, - anchors - ); - } - return List.copyOf(anchors); - } - - public int sampleCandidateCount(double x, double z) { - return indexedReaches(x, z).size(); - } - - public RiverSample sample(double x, double z) { - return sampleExpanded(x, z, 0D); - } - - public RiverSample sampleExpanded(double x, double z, double additionalRadius) { - if (!Double.isFinite(additionalRadius) || additionalRadius < 0D) { - throw new IllegalArgumentException("Additional river sample radius must be finite and non-negative"); - } - RiverReach nearestReach = null; - double nearestDistanceSquared = Double.POSITIVE_INFINITY; - double nearestAlongReach = 0.0; - List candidates = additionalRadius == 0D - ? indexedReaches(x, z) - : indexedReaches( - x - additionalRadius, - z - additionalRadius, - x + additionalRadius, - z + additionalRadius - ); - for (RiverReach reach : candidates) { - ClosestPoint closest = closestCoveringPoint(reach, x, z, additionalRadius); - if (closest == null) { - continue; - } - if (closest.distanceSquared() < nearestDistanceSquared - || (closest.distanceSquared() == nearestDistanceSquared - && nearestReach != null - && reach.id().compareTo(nearestReach.id()) < 0)) { - nearestReach = reach; - nearestDistanceSquared = closest.distanceSquared(); - nearestAlongReach = closest.alongReach(); - } - } - if (nearestReach == null) { - return RiverSample.none(); - } - - return createSample(nearestReach, nearestDistanceSquared, nearestAlongReach); - } - - public RiverSample sampleFootprint( - double queryMinimumX, - double queryMinimumZ, - double queryMaximumX, - double queryMaximumZ - ) { - if (!Double.isFinite(queryMinimumX) || !Double.isFinite(queryMinimumZ) - || !Double.isFinite(queryMaximumX) || !Double.isFinite(queryMaximumZ) - || queryMinimumX > queryMaximumX || queryMinimumZ > queryMaximumZ) { - throw new IllegalArgumentException("River footprint bounds must be finite and ordered"); - } - RiverReach nearestReach = null; - double nearestDistanceSquared = Double.POSITIVE_INFINITY; - double nearestAlongReach = 0.0; - for (RiverReach reach : indexedReachesInclusive( - queryMinimumX, - queryMinimumZ, - queryMaximumX, - queryMaximumZ - )) { - ClosestPoint closest = closestCoveringPoint( - reach, - queryMinimumX, - queryMinimumZ, - queryMaximumX, - queryMaximumZ - ); - if (closest == null) { - continue; - } - if (closest.distanceSquared() < nearestDistanceSquared - || (closest.distanceSquared() == nearestDistanceSquared - && nearestReach != null - && reach.id().compareTo(nearestReach.id()) < 0)) { - nearestReach = reach; - nearestDistanceSquared = closest.distanceSquared(); - nearestAlongReach = closest.alongReach(); - } - } - if (nearestReach == null) { - return RiverSample.none(); - } - - return createSample(nearestReach, nearestDistanceSquared, nearestAlongReach); - } - - private static RiverSample createSample( - RiverReach nearestReach, - double nearestDistanceSquared, - double nearestAlongReach - ) { - - double distance = StrictMath.sqrt(nearestDistanceSquared); - double localWidth = nearestReach.widthAt(nearestAlongReach); - double localBankWidth = nearestReach.bankWidthAt(nearestAlongReach); - double localDepth = nearestReach.depthAt(nearestAlongReach); - double channelRadius = localWidth * 0.5; - RiverSection section = section(nearestReach, distance, channelRadius); - double carveWeight = carveWeight(distance, channelRadius, localBankWidth); - return new RiverSample( - true, - nearestReach.state(), - section, - distance, - nearestAlongReach, - carveWeight, - nearestReach.flow(), - nearestReach.order(), - localWidth, - localBankWidth, - localDepth, - nearestReach.terminal(), - nearestReach.id() - ); - } - - private static RiverSection section(RiverReach reach, double distance, double channelRadius) { - if (distance <= channelRadius) { - if (reach.state() == RiverRouteState.DRY) { - return RiverSection.DRY_CHANNEL; - } - return reach.mouth() ? RiverSection.MOUTH : RiverSection.CHANNEL; - } - return reach.state() == RiverRouteState.DRY ? RiverSection.DRY_BANK : RiverSection.BANK; - } - - private void addAnchors( - RiverReach reach, - double spacing, - long salt, - double queryMinimumX, - double queryMinimumZ, - double queryMaximumX, - double queryMaximumZ, - List anchors - ) { - double length = reach.polyline().length(); - double firstDistance = unit(RiverNetwork.mix(reach.id().stableId() ^ salt)) * spacing; - int index = 0; - for (double distance = firstDistance; distance < length; distance += spacing) { - Position position = positionAt(reach.polyline(), distance); - if (position.x() >= minimumX && position.x() < maximumX - && position.z() >= minimumZ && position.z() < maximumZ - && position.x() >= queryMinimumX && position.x() < queryMaximumX - && position.z() >= queryMinimumZ && position.z() < queryMaximumZ) { - long stableId = RiverNetwork.mix( - reach.id().stableId() ^ salt ^ (long) index * 0x9E3779B97F4A7C15L - ); - anchors.add(new RiverAnchor( - reach.id(), - index, - stableId, - spacing, - salt, - position.x(), - position.z(), - position.alongReach(), - reach.state(), - reach.flow(), - reach.order() - )); - } - index++; - } - } - - private static Position positionAt(RiverPolyline polyline, double targetDistance) { - double traversed = 0.0; - for (int point = 0; point < polyline.size() - 1; point++) { - double startX = polyline.x(point); - double startZ = polyline.z(point); - double deltaX = polyline.x(point + 1) - startX; - double deltaZ = polyline.z(point + 1) - startZ; - double segmentLength = StrictMath.hypot(deltaX, deltaZ); - if (targetDistance <= traversed + segmentLength || point == polyline.size() - 2) { - double t = segmentLength == 0.0 ? 0.0 : (targetDistance - traversed) / segmentLength; - t = StrictMath.max(0.0, StrictMath.min(1.0, t)); - double alongReach = polyline.length() == 0.0 ? 0.0 : targetDistance / polyline.length(); - return new Position(startX + deltaX * t, startZ + deltaZ * t, alongReach); - } - traversed += segmentLength; - } - return new Position( - polyline.x(polyline.size() - 1), - polyline.z(polyline.size() - 1), - 1.0 - ); - } - - private static double unit(long hash) { - return (hash >>> 11) * 0x1.0p-53; - } - - private static double carveWeight(double distance, double channelRadius, double bankWidth) { - if (distance <= channelRadius || bankWidth == 0.0) { - return 1.0; - } - double t = StrictMath.min(1.0, (distance - channelRadius) / bankWidth); - double smooth = t * t * (3.0 - 2.0 * t); - return 1.0 - smooth; - } - - private static ClosestPoint closestCoveringPoint( - RiverReach reach, - double x, - double z, - double additionalRadius - ) { - RiverPolyline polyline = reach.polyline(); - RiverBodyProfile bodyProfile = reach.bodyProfile(); - double polylineLength = polyline.length(); - if (polylineLength == 0D) { - double distanceSquared = squared(x - polyline.x(0)) + squared(z - polyline.z(0)); - double radius = reach.widthAt(0D) * 0.5D + reach.bankWidthAt(0D) + additionalRadius; - return distanceSquared <= radius * radius ? new ClosestPoint(distanceSquared, 0D) : null; - } - double nearest = Double.POSITIVE_INFINITY; - double nearestAlong = 0.0; - int pointLimit = polyline.size() - 1; - int profileLimit = bodyProfile.size() - 1; - for (int point = 0; point < pointLimit; point++) { - double segmentStartAlong = polyline.cumulativeLength(point) / polylineLength; - double segmentEndAlong = polyline.cumulativeLength(point + 1) / polylineLength; - double segmentAlongSpan = segmentEndAlong - segmentStartAlong; - if (segmentAlongSpan == 0D) { - continue; - } - double startX = polyline.x(point); - double startZ = polyline.z(point); - double deltaX = polyline.x(point + 1) - startX; - double deltaZ = polyline.z(point + 1) - startZ; - int firstProfileIndex = bodyProfile.intervalIndex(segmentStartAlong); - for (int profileIndex = firstProfileIndex; - profileIndex < profileLimit - && bodyProfile.position(profileIndex) <= segmentEndAlong; - profileIndex++) { - double profileStart = bodyProfile.position(profileIndex); - double profileEnd = bodyProfile.position(profileIndex + 1); - double overlapStart = StrictMath.max(segmentStartAlong, profileStart); - double overlapEnd = StrictMath.min(segmentEndAlong, profileEnd); - if (overlapStart > overlapEnd) { - continue; - } - double intervalStart = (overlapStart - segmentStartAlong) / segmentAlongSpan; - double intervalEnd = (overlapEnd - segmentStartAlong) / segmentAlongSpan; - double profileSpan = profileEnd - profileStart; - double profileWidth = bodyProfile.widthAtIndex(profileIndex); - double profileBankWidth = bodyProfile.bankWidthAtIndex(profileIndex); - double widthSlope = (bodyProfile.widthAtIndex(profileIndex + 1) - - profileWidth) / profileSpan; - double bankSlope = (bodyProfile.bankWidthAtIndex(profileIndex + 1) - - profileBankWidth) / profileSpan; - double radiusBase = (profileWidth - + widthSlope * (segmentStartAlong - profileStart)) * 0.5D - + profileBankWidth - + bankSlope * (segmentStartAlong - profileStart) - + additionalRadius; - double radiusSlope = (widthSlope * 0.5D + bankSlope) * segmentAlongSpan; - ClosestPoint candidate = coveringPoint( - intervalStart, - intervalEnd, - deltaX, - startX - x, - deltaZ, - startZ - z, - radiusSlope, - radiusBase, - segmentStartAlong, - segmentAlongSpan - ); - if (candidate != null && candidate.distanceSquared() < nearest) { - nearest = candidate.distanceSquared(); - nearestAlong = candidate.alongReach(); - } - } - } - return Double.isFinite(nearest) ? new ClosestPoint(nearest, nearestAlong) : null; - } - - private static ClosestPoint closestCoveringPoint( - RiverReach reach, - double minimumX, - double minimumZ, - double maximumX, - double maximumZ - ) { - RiverPolyline polyline = reach.polyline(); - RiverBodyProfile bodyProfile = reach.bodyProfile(); - double polylineLength = polyline.length(); - if (polylineLength == 0D) { - double distanceSquared = pointRectangleDistanceSquared( - polyline.x(0), - polyline.z(0), - minimumX, - minimumZ, - maximumX, - maximumZ - ); - double radius = reach.widthAt(0D) * 0.5D + reach.bankWidthAt(0D); - return distanceSquared <= radius * radius ? new ClosestPoint(distanceSquared, 0D) : null; - } - double nearest = Double.POSITIVE_INFINITY; - double nearestAlong = 0.0; - int pointLimit = polyline.size() - 1; - int profileLimit = bodyProfile.size() - 1; - for (int point = 0; point < pointLimit; point++) { - double segmentStartAlong = polyline.cumulativeLength(point) / polylineLength; - double segmentEndAlong = polyline.cumulativeLength(point + 1) / polylineLength; - double segmentAlongSpan = segmentEndAlong - segmentStartAlong; - if (segmentAlongSpan == 0D) { - continue; - } - double startX = polyline.x(point); - double startZ = polyline.z(point); - double deltaX = polyline.x(point + 1) - startX; - double deltaZ = polyline.z(point + 1) - startZ; - int firstProfileIndex = bodyProfile.intervalIndex(segmentStartAlong); - for (int profileIndex = firstProfileIndex; - profileIndex < profileLimit - && bodyProfile.position(profileIndex) <= segmentEndAlong; - profileIndex++) { - double profileStart = bodyProfile.position(profileIndex); - double profileEnd = bodyProfile.position(profileIndex + 1); - double overlapStart = StrictMath.max(segmentStartAlong, profileStart); - double overlapEnd = StrictMath.min(segmentEndAlong, profileEnd); - if (overlapStart > overlapEnd) { - continue; - } - double intervalStart = (overlapStart - segmentStartAlong) / segmentAlongSpan; - double intervalEnd = (overlapEnd - segmentStartAlong) / segmentAlongSpan; - double profileSpan = profileEnd - profileStart; - double profileWidth = bodyProfile.widthAtIndex(profileIndex); - double profileBankWidth = bodyProfile.bankWidthAtIndex(profileIndex); - double widthSlope = (bodyProfile.widthAtIndex(profileIndex + 1) - - profileWidth) / profileSpan; - double bankSlope = (bodyProfile.bankWidthAtIndex(profileIndex + 1) - - profileBankWidth) / profileSpan; - double radiusBase = (profileWidth - + widthSlope * (segmentStartAlong - profileStart)) * 0.5D - + profileBankWidth - + bankSlope * (segmentStartAlong - profileStart); - double radiusSlope = (widthSlope * 0.5D + bankSlope) * segmentAlongSpan; - double cursor = intervalStart; - do { - double next = intervalEnd; - next = nextCrossing(startX, deltaX, minimumX, cursor, next); - next = nextCrossing(startX, deltaX, maximumX, cursor, next); - next = nextCrossing(startZ, deltaZ, minimumZ, cursor, next); - next = nextCrossing(startZ, deltaZ, maximumZ, cursor, next); - double middle = (cursor + next) * 0.5D; - double middleX = startX + deltaX * middle; - double middleZ = startZ + deltaZ * middle; - double distanceSlopeX = middleX < minimumX || middleX > maximumX ? deltaX : 0D; - double distanceBaseX = middleX < minimumX - ? startX - minimumX - : middleX > maximumX ? startX - maximumX : 0D; - double distanceSlopeZ = middleZ < minimumZ || middleZ > maximumZ ? deltaZ : 0D; - double distanceBaseZ = middleZ < minimumZ - ? startZ - minimumZ - : middleZ > maximumZ ? startZ - maximumZ : 0D; - ClosestPoint candidate = coveringPoint( - cursor, - next, - distanceSlopeX, - distanceBaseX, - distanceSlopeZ, - distanceBaseZ, - radiusSlope, - radiusBase, - segmentStartAlong, - segmentAlongSpan - ); - if (candidate != null && candidate.distanceSquared() < nearest) { - nearest = candidate.distanceSquared(); - nearestAlong = candidate.alongReach(); - } - cursor = next; - } while (cursor < intervalEnd); - } - } - return Double.isFinite(nearest) ? new ClosestPoint(nearest, nearestAlong) : null; - } - - private static ClosestPoint coveringPoint( - double intervalStart, - double intervalEnd, - double distanceSlopeX, - double distanceBaseX, - double distanceSlopeZ, - double distanceBaseZ, - double radiusSlope, - double radiusBase, - double segmentStartAlong, - double segmentAlongSpan - ) { - double distanceQuadratic = squared(distanceSlopeX) + squared(distanceSlopeZ); - double distanceLinear = 2D * (distanceSlopeX * distanceBaseX + distanceSlopeZ * distanceBaseZ); - double distanceConstant = squared(distanceBaseX) + squared(distanceBaseZ); - double coverageQuadratic = distanceQuadratic - squared(radiusSlope); - double coverageLinear = distanceLinear - 2D * radiusSlope * radiusBase; - double coverageConstant = distanceConstant - squared(radiusBase); - double distancePosition = distanceQuadratic == 0D - ? intervalStart - : clamp(-distanceLinear / (2D * distanceQuadratic), intervalStart, intervalEnd); - if (quadraticValue(coverageQuadratic, coverageLinear, coverageConstant, distancePosition) <= 0D) { - return new ClosestPoint( - StrictMath.max(0D, quadraticValue( - distanceQuadratic, - distanceLinear, - distanceConstant, - distancePosition - )), - segmentStartAlong + segmentAlongSpan * distancePosition - ); - } - double coveragePosition = intervalStart; - double minimumCoverage = quadraticValue( - coverageQuadratic, - coverageLinear, - coverageConstant, - coveragePosition - ); - double endCoverage = quadraticValue(coverageQuadratic, coverageLinear, coverageConstant, intervalEnd); - if (endCoverage < minimumCoverage) { - minimumCoverage = endCoverage; - coveragePosition = intervalEnd; - } - if (coverageQuadratic > 0D) { - double vertex = clamp(-coverageLinear / (2D * coverageQuadratic), intervalStart, intervalEnd); - double vertexCoverage = quadraticValue(coverageQuadratic, coverageLinear, coverageConstant, vertex); - if (vertexCoverage < minimumCoverage) { - minimumCoverage = vertexCoverage; - coveragePosition = vertex; - } - } - if (minimumCoverage > 0D) { - return null; - } - double uncovered = distancePosition; - double covered = coveragePosition; - for (int iteration = 0; iteration < 40; iteration++) { - double middle = (uncovered + covered) * 0.5D; - if (quadraticValue(coverageQuadratic, coverageLinear, coverageConstant, middle) <= 0D) { - covered = middle; - } else { - uncovered = middle; - } - } - return new ClosestPoint( - StrictMath.max(0D, quadraticValue( - distanceQuadratic, - distanceLinear, - distanceConstant, - covered - )), - segmentStartAlong + segmentAlongSpan * covered - ); - } - - private static double nextCrossing( - double start, - double delta, - double boundary, - double cursor, - double currentNext - ) { - if (delta == 0D) { - return currentNext; - } - double crossing = (boundary - start) / delta; - return crossing > cursor && crossing < currentNext ? crossing : currentNext; - } - - private static double quadraticValue(double quadratic, double linear, double constant, double value) { - return (quadratic * value + linear) * value + constant; - } - - private static double clamp(double value, double minimum, double maximum) { - return StrictMath.max(minimum, StrictMath.min(maximum, value)); - } - - private static double pointRectangleDistanceSquared( - double x, - double z, - double minimumX, - double minimumZ, - double maximumX, - double maximumZ - ) { - double deltaX = x < minimumX ? minimumX - x : StrictMath.max(0.0, x - maximumX); - double deltaZ = z < minimumZ ? minimumZ - z : StrictMath.max(0.0, z - maximumZ); - return squared(deltaX) + squared(deltaZ); - } - - private static double squared(double value) { - return value * value; - } - - private static Map> createSpatialIndex(List reaches) { - HashMap> mutable = new HashMap<>(); - for (RiverReach reach : reaches) { - double radius = reach.width() * 0.5 + reach.bankWidth(); - RiverPolyline polyline = reach.polyline(); - for (int point = 0; point < polyline.size() - 1; point++) { - int minimumBucketX = bucket(StrictMath.min(polyline.x(point), polyline.x(point + 1)) - radius); - int maximumBucketX = bucket(StrictMath.max(polyline.x(point), polyline.x(point + 1)) + radius); - int minimumBucketZ = bucket(StrictMath.min(polyline.z(point), polyline.z(point + 1)) - radius); - int maximumBucketZ = bucket(StrictMath.max(polyline.z(point), polyline.z(point + 1)) + radius); - for (int bucketX = minimumBucketX; bucketX <= maximumBucketX; bucketX++) { - for (int bucketZ = minimumBucketZ; bucketZ <= maximumBucketZ; bucketZ++) { - mutable.computeIfAbsent(bucketKey(bucketX, bucketZ), ignored -> new LinkedHashSet<>()).add(reach); - } - } - } - } - HashMap> immutable = new HashMap<>(mutable.size()); - for (Map.Entry> entry : mutable.entrySet()) { - immutable.put(entry.getKey(), List.copyOf(entry.getValue())); - } - return Map.copyOf(immutable); - } - - private static Map indexById(List reaches) { - HashMap indexed = new HashMap<>(reaches.size()); - for (RiverReach reach : reaches) { - RiverReach previous = indexed.put(reach.id(), reach); - if (previous != null) { - throw new IllegalArgumentException("River tile cannot contain duplicate reach IDs"); - } - } - return Map.copyOf(indexed); - } - - private List indexedReaches(double x, double z) { - return spatialIndex.getOrDefault(bucketKey(bucket(x), bucket(z)), List.of()); - } - - private List indexedReaches( - double queryMinimumX, - double queryMinimumZ, - double queryMaximumX, - double queryMaximumZ - ) { - LinkedHashSet indexed = new LinkedHashSet<>(); - int minimumBucketX = bucket(queryMinimumX); - int maximumBucketX = bucket(StrictMath.nextDown(queryMaximumX)); - int minimumBucketZ = bucket(queryMinimumZ); - int maximumBucketZ = bucket(StrictMath.nextDown(queryMaximumZ)); - if (minimumBucketX == maximumBucketX && minimumBucketZ == maximumBucketZ) { - return spatialIndex.getOrDefault( - bucketKey(minimumBucketX, minimumBucketZ), - List.of() - ); - } - for (int bucketX = minimumBucketX; bucketX <= maximumBucketX; bucketX++) { - for (int bucketZ = minimumBucketZ; bucketZ <= maximumBucketZ; bucketZ++) { - indexed.addAll(spatialIndex.getOrDefault(bucketKey(bucketX, bucketZ), List.of())); - } - } - return List.copyOf(indexed); - } - - private List indexedReachesInclusive( - double queryMinimumX, - double queryMinimumZ, - double queryMaximumX, - double queryMaximumZ - ) { - int minimumBucketX = bucket(queryMinimumX); - int maximumBucketX = bucket(queryMaximumX); - int minimumBucketZ = bucket(queryMinimumZ); - int maximumBucketZ = bucket(queryMaximumZ); - if (minimumBucketX == maximumBucketX && minimumBucketZ == maximumBucketZ) { - return indexedReaches(queryMinimumX, queryMinimumZ); - } - LinkedHashSet indexed = new LinkedHashSet<>(); - for (int bucketX = minimumBucketX; bucketX <= maximumBucketX; bucketX++) { - for (int bucketZ = minimumBucketZ; bucketZ <= maximumBucketZ; bucketZ++) { - indexed.addAll(spatialIndex.getOrDefault(bucketKey(bucketX, bucketZ), List.of())); - } - } - return List.copyOf(indexed); - } - - private static int bucket(double coordinate) { - return (int) StrictMath.floor(coordinate / BUCKET_SIZE); - } - - private static long bucketKey(int bucketX, int bucketZ) { - return ((long) bucketX << 32) ^ (bucketZ & 0xFFFFFFFFL); - } - - private record Position(double x, double z, double alongReach) { - } - - private record ClosestPoint(double distanceSquared, double alongReach) { - } - -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTileCache.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTileCache.java deleted file mode 100644 index 2f89335ee..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTileCache.java +++ /dev/null @@ -1,200 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; - -public final class RiverTileCache implements AutoCloseable { - private final Object lock; - private final int maxCompletedEntries; - private final Map entries; - private final LinkedHashMap completedEntries; - private TileBuilder builder; - private boolean closed; - - public RiverTileCache(int maxCompletedEntries, TileBuilder builder) { - if (maxCompletedEntries < 1) { - throw new IllegalArgumentException("River tile cache capacity must be positive"); - } - this.maxCompletedEntries = maxCompletedEntries; - this.builder = Objects.requireNonNull(builder); - lock = new Object(); - entries = new HashMap<>(maxCompletedEntries); - completedEntries = new LinkedHashMap<>(maxCompletedEntries, 0.75f, true); - } - - public RiverTile get(int tileX, int tileZ) { - TileKey key = new TileKey(tileX, tileZ); - Entry entry; - TileBuilder activeBuilder; - boolean build; - synchronized (lock) { - requireOpen(); - entry = entries.get(key); - if (entry == null) { - entry = new Entry(); - entries.put(key, entry); - activeBuilder = builder; - build = true; - } else { - if (entry.completed) { - completedEntries.get(key); - } - activeBuilder = null; - build = false; - } - } - - if (build) { - build(key, entry, activeBuilder); - } - return await(entry.future, key); - } - - public int completedSize() { - synchronized (lock) { - return completedEntries.size(); - } - } - - public boolean isClosed() { - synchronized (lock) { - return closed; - } - } - - public void clear() { - List> invalidated; - synchronized (lock) { - requireOpen(); - invalidated = clearLocked(); - } - invalidate(invalidated, "River tile cache was cleared"); - } - - @Override - public void close() { - List> invalidated; - synchronized (lock) { - if (closed) { - return; - } - closed = true; - builder = null; - invalidated = clearLocked(); - } - invalidate(invalidated, "River tile cache was closed"); - } - - private void build(TileKey key, Entry entry, TileBuilder activeBuilder) { - try { - RiverTile tile = Objects.requireNonNull( - activeBuilder.build(key.tileX(), key.tileZ()), - "River tile builder returned null" - ); - if (tile.tileX() != key.tileX() || tile.tileZ() != key.tileZ()) { - throw new IllegalStateException( - "River tile builder returned " + tile.tileX() + "," + tile.tileZ() - + " for " + key.tileX() + "," + key.tileZ() - ); - } - publishCompleted(key, entry, tile); - } catch (Throwable failure) { - removeFailed(key, entry); - entry.future.completeExceptionally(failure); - } - } - - private void publishCompleted(TileKey key, Entry entry, RiverTile tile) { - synchronized (lock) { - if (closed || entries.get(key) != entry) { - entry.future.completeExceptionally(new IllegalStateException( - closed ? "River tile cache was closed" : "River tile cache entry was cleared" - )); - return; - } - entry.completed = true; - completedEntries.put(key, entry); - while (completedEntries.size() > maxCompletedEntries) { - Map.Entry eldest = completedEntries.entrySet().iterator().next(); - completedEntries.remove(eldest.getKey()); - entries.remove(eldest.getKey(), eldest.getValue()); - } - entry.future.complete(tile); - } - } - - private void removeFailed(TileKey key, Entry entry) { - synchronized (lock) { - entries.remove(key, entry); - completedEntries.remove(key, entry); - } - } - - private List> clearLocked() { - ArrayList> invalidated = new ArrayList<>(entries.size()); - for (Entry entry : entries.values()) { - if (!entry.future.isDone()) { - invalidated.add(entry.future); - } - } - entries.clear(); - completedEntries.clear(); - return invalidated; - } - - private void requireOpen() { - if (closed) { - throw new IllegalStateException("River tile cache is closed"); - } - } - - private static RiverTile await(CompletableFuture future, TileKey key) { - try { - return future.join(); - } catch (CompletionException failure) { - Throwable cause = failure.getCause(); - if (cause instanceof InterruptedException) { - Thread.currentThread().interrupt(); - } - if (cause instanceof RuntimeException runtimeException) { - throw runtimeException; - } - if (cause instanceof Error error) { - throw error; - } - throw new IllegalStateException( - "Failed to build river tile " + key.tileX() + "," + key.tileZ(), - cause - ); - } - } - - private static void invalidate(List> futures, String message) { - for (CompletableFuture future : futures) { - future.completeExceptionally(new IllegalStateException(message)); - } - } - - @FunctionalInterface - public interface TileBuilder { - RiverTile build(int tileX, int tileZ) throws Exception; - } - - private record TileKey(int tileX, int tileZ) { - } - - private static final class Entry { - private final CompletableFuture future; - private boolean completed; - - private Entry() { - future = new CompletableFuture<>(); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java b/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java deleted file mode 100644 index a6261ba6e..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverTopologyComplexity.java +++ /dev/null @@ -1,211 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.ArrayList; -import java.util.List; - -public final class RiverTopologyComplexity { - public static final long MAXIMUM_SOURCE_WINDOW_CELLS = 65_536L; - public static final long MAXIMUM_ROUTE_SCAN_STEPS = 65_536L; - public static final long MAXIMUM_BUCKET_WRITES_PER_REACH = 1_048_576L; - public static final long MAXIMUM_TUNNEL_SAMPLE_COLUMNS = 65_536L; - private static final int SPATIAL_BUCKET_SIZE = 64; - - private RiverTopologyComplexity() { - } - - public static Estimate estimate( - int cellSize, - int tileCells, - double siteJitter, - int maxRouteReaches, - double maximumReachRadius, - double maximumWormOffset, - int maximumWormSegments - ) { - double maximumEdgeAxisDelta = cellSize * (1D + siteJitter); - long geometryPaddingCells = 1L + ceilToLong( - (maximumReachRadius + maximumWormOffset) / cellSize - ); - long targetWindowAxis = saturatedAdd(tileCells, saturatedMultiply(2L, geometryPaddingCells)); - long sourceWindowAxis = saturatedAdd( - targetWindowAxis, - saturatedMultiply(2L, maxRouteReaches) - ); - long sourceWindowCells = saturatedMultiply(sourceWindowAxis, sourceWindowAxis); - long maximumRouteScanSteps = saturatedMultiply(sourceWindowCells, maxRouteReaches); - double maximumSegmentSpan = maximumEdgeAxisDelta - + maximumWormOffset * 2D - + maximumReachRadius * 2D; - long maximumSegmentBucketAxis = saturatedAdd( - ceilToLong(maximumSegmentSpan / SPATIAL_BUCKET_SIZE), - 1L - ); - long maximumSegmentBucketCount = saturatedMultiply( - maximumSegmentBucketAxis, - maximumSegmentBucketAxis - ); - long maximumBucketWritesPerReach = saturatedMultiply( - maximumSegmentBucketCount, - maximumWormSegments - ); - return new Estimate( - geometryPaddingCells, - sourceWindowAxis, - sourceWindowCells, - maximumRouteScanSteps, - maximumSegmentBucketAxis, - maximumBucketWritesPerReach - ); - } - - public static void requireSafe( - int cellSize, - int tileCells, - double siteJitter, - int maxRouteReaches, - double maximumReachRadius, - double maximumWormOffset, - int maximumWormSegments - ) { - Estimate estimate = estimate( - cellSize, - tileCells, - siteJitter, - maxRouteReaches, - maximumReachRadius, - maximumWormOffset, - maximumWormSegments - ); - List violations = estimate.violations(); - if (!violations.isEmpty()) { - throw new IllegalArgumentException(String.join(" ", violations)); - } - } - - public static int tunnelHalo( - double maximumChannelWidth, - double maximumTunnelWidthMultiplier, - double tunnelMouthBlend - ) { - if (!Double.isFinite(maximumChannelWidth) || maximumChannelWidth <= 0D - || !Double.isFinite(maximumTunnelWidthMultiplier) || maximumTunnelWidthMultiplier < 1D - || !Double.isFinite(tunnelMouthBlend) || tunnelMouthBlend < 0D) { - throw new IllegalArgumentException("River tunnel dimensions must be finite and valid"); - } - return Math.max( - 1, - (int) StrictMath.ceil( - maximumChannelWidth * 0.5D * maximumTunnelWidthMultiplier + tunnelMouthBlend - ) + 1 - ); - } - - public static long tunnelSampleColumns( - double maximumChannelWidth, - double maximumTunnelWidthMultiplier, - double tunnelMouthBlend - ) { - long axis = 16L + 2L * tunnelHalo( - maximumChannelWidth, - maximumTunnelWidthMultiplier, - tunnelMouthBlend - ); - return saturatedMultiply(axis, axis); - } - - public static String tunnelPlanViolation( - double maximumChannelWidth, - double maximumTunnelWidthMultiplier, - double tunnelMouthBlend - ) { - long columns = tunnelSampleColumns( - maximumChannelWidth, - maximumTunnelWidthMultiplier, - tunnelMouthBlend - ); - if (columns <= MAXIMUM_TUNNEL_SAMPLE_COLUMNS) { - return null; - } - return "River tunnel planning may sample " + columns - + " columns per generated chunk, above the safe limit of " + MAXIMUM_TUNNEL_SAMPLE_COLUMNS - + "; reduce maxChannelWidth, tunnelWidthMultiplier.max, or tunnelMouthBlend."; - } - - public static void requireSafeTunnelPlan( - double maximumChannelWidth, - double maximumTunnelWidthMultiplier, - double tunnelMouthBlend - ) { - String violation = tunnelPlanViolation( - maximumChannelWidth, - maximumTunnelWidthMultiplier, - tunnelMouthBlend - ); - if (violation != null) { - throw new IllegalArgumentException(violation); - } - } - - private static long ceilToLong(double value) { - if (!Double.isFinite(value) || value >= Long.MAX_VALUE) { - return Long.MAX_VALUE; - } - if (value <= 0D) { - return 0L; - } - return (long) StrictMath.ceil(value); - } - - private static long saturatedAdd(long first, long second) { - if (first > Long.MAX_VALUE - second) { - return Long.MAX_VALUE; - } - return first + second; - } - - private static long saturatedMultiply(long first, long second) { - if (first == 0L || second == 0L) { - return 0L; - } - if (first > Long.MAX_VALUE / second) { - return Long.MAX_VALUE; - } - return first * second; - } - - public record Estimate( - long geometryPaddingCells, - long sourceWindowAxis, - long sourceWindowCells, - long maximumRouteScanSteps, - long maximumSegmentBucketAxis, - long maximumBucketWritesPerReach - ) { - public boolean safe() { - return violations().isEmpty(); - } - - public List violations() { - ArrayList violations = new ArrayList<>(3); - if (sourceWindowCells > MAXIMUM_SOURCE_WINDOW_CELLS) { - violations.add("River topology source window requires " + sourceWindowCells - + " cells (" + sourceWindowAxis + " per axis), above the safe limit of " - + MAXIMUM_SOURCE_WINDOW_CELLS - + "; increase cellSize or reduce tileCells, maxRouteReaches, channel width, or bank width."); - } - if (maximumRouteScanSteps > MAXIMUM_ROUTE_SCAN_STEPS) { - violations.add("River topology route scan permits " + maximumRouteScanSteps - + " source-to-reach steps, above the safe limit of " + MAXIMUM_ROUTE_SCAN_STEPS - + "; reduce maxRouteReaches, tileCells, channel width, or bank width."); - } - if (maximumBucketWritesPerReach > MAXIMUM_BUCKET_WRITES_PER_REACH) { - violations.add("River topology spatial index may require " + maximumBucketWritesPerReach - + " bucket writes for one reach (" + maximumSegmentBucketAxis - + " buckets per segment axis), above the safe limit of " - + MAXIMUM_BUCKET_WRITES_PER_REACH - + "; reduce channel width, bank width, orderWidthFactor, worm maxOffset, or worm segments."); - } - return List.copyOf(violations); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java b/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java deleted file mode 100644 index 539d3b54b..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/RiverWorm.java +++ /dev/null @@ -1,81 +0,0 @@ -package art.arcane.iris.engine.river; - -import java.util.List; - -public record RiverWorm( - String id, - long seed, - double weight, - double wavelength, - double detailWavelength, - double tortuosity, - double detailTortuosity, - double maxOffset, - int segments, - double widthMultiplier, - double bankMultiplier, - double depthMultiplier, - double bodyWavelength, - double bodyDetailWavelength, - double bodyDetailInfluence, - double widthVariation, - double bankVariation, - double depthVariation, - double roofVariation, - int branchCap, - double branchDecay, - double confluenceMultiplier, - double childChance, - double branchChildChance, - List children -) { - public RiverWorm { - if (id == null || !id.matches("[a-z0-9][a-z0-9_-]{0,63}")) { - throw new IllegalArgumentException("id must use 1 to 64 lowercase letters, digits, underscores, or hyphens"); - } - requireRange(weight, 0.000001D, 1000000D, "weight"); - requireRange(wavelength, 8D, 16384D, "wavelength"); - requireRange(detailWavelength, 8D, 16384D, "detailWavelength"); - requireRange(tortuosity, 0D, 1D, "tortuosity"); - requireRange(detailTortuosity, 0D, 1D, "detailTortuosity"); - requireRange(maxOffset, 0D, 1024D, "maxOffset"); - if (segments < 1 || segments > 64) { - throw new IllegalArgumentException("segments must be between 1 and 64"); - } - requireRange(widthMultiplier, 0.125D, 8D, "widthMultiplier"); - requireRange(bankMultiplier, 0.125D, 8D, "bankMultiplier"); - requireRange(depthMultiplier, 0.125D, 8D, "depthMultiplier"); - requireRange(bodyWavelength, 8D, 16384D, "bodyWavelength"); - requireRange(bodyDetailWavelength, 8D, 16384D, "bodyDetailWavelength"); - requireRange(bodyDetailInfluence, 0D, 1D, "bodyDetailInfluence"); - requireRange(widthVariation, 0D, 0.875D, "widthVariation"); - requireRange(bankVariation, 0D, 0.875D, "bankVariation"); - requireRange(depthVariation, 0D, 0.875D, "depthVariation"); - requireRange(roofVariation, 0D, 0.875D, "roofVariation"); - if (branchCap < 1 || branchCap > 8) { - throw new IllegalArgumentException("branchCap must be between 1 and 8"); - } - requireRange(branchDecay, 0D, 1D, "branchDecay"); - requireRange(confluenceMultiplier, 0D, 8D, "confluenceMultiplier"); - requireRange(childChance, 0D, 1D, "childChance"); - requireRange(branchChildChance, 0D, 1D, "branchChildChance"); - if (children == null) { - throw new IllegalArgumentException("children must not be null"); - } - if (children.size() > 16) { - throw new IllegalArgumentException("children must contain at most 16 profiles"); - } - for (RiverWorm child : children) { - if (child == null) { - throw new IllegalArgumentException("children must not contain null profiles"); - } - } - children = List.copyOf(children); - } - - private static void requireRange(double value, double minimum, double maximum, String name) { - if (!Double.isFinite(value) || value < minimum || value > maximum) { - throw new IllegalArgumentException(name + " must be between " + minimum + " and " + maximum); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/CavePosition.java b/core/src/main/java/art/arcane/iris/engine/river/cave/CavePosition.java deleted file mode 100644 index 38693c462..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/CavePosition.java +++ /dev/null @@ -1,7 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -public record CavePosition(int x, int y, int z) { - public CavePosition offset(int dx, int dy, int dz) { - return new CavePosition(x + dx, y + dy, z + dz); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxel.java b/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxel.java deleted file mode 100644 index 6ea8abc31..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxel.java +++ /dev/null @@ -1,9 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -public enum CaveVoxel { - SOLID, - CAVE_AIR, - COMPATIBLE_FLUID, - LAVA, - INCOMPATIBLE_FLUID -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelView.java b/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelView.java deleted file mode 100644 index 990a05682..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/CaveVoxelView.java +++ /dev/null @@ -1,9 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -public interface CaveVoxelView { - boolean isInWorld(CavePosition position); - - CaveVoxel voxelAt(CavePosition position); - - boolean isOpenToSurface(CavePosition position); -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java deleted file mode 100644 index 430fdd352..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveAction.java +++ /dev/null @@ -1,8 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -public enum RiverCaveAction { - WET_SOURCE, - FALLING_FLUID, - DRY_AIR, - SEAL_GUARD -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java deleted file mode 100644 index 2db8771e3..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlanner.java +++ /dev/null @@ -1,1086 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.OptionalLong; -import java.util.Queue; -import java.util.Set; - -public final class RiverCaveContainmentPlanner { - private static final List DIRECTIONS = List.of( - new CavePosition(1, 0, 0), - new CavePosition(-1, 0, 0), - new CavePosition(0, 1, 0), - new CavePosition(0, -1, 0), - new CavePosition(0, 0, 1), - new CavePosition(0, 0, -1) - ); - private static final Comparator SOURCE_PRIORITY = Comparator - .comparingInt(RiverCaveSource::waterHeadY) - .reversed() - .thenComparingLong(RiverCaveSource::sourceId) - .thenComparingInt(source -> source.entry().x()) - .thenComparingInt(source -> source.entry().y()) - .thenComparingInt(source -> source.entry().z()) - .thenComparingInt(source -> source.target().x()) - .thenComparingInt(source -> source.target().y()) - .thenComparingInt(source -> source.target().z()) - .thenComparing(RiverCaveSource::mode); - - public RiverCavePlan plan( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings - ) { - Objects.requireNonNull(view); - Objects.requireNonNull(source); - Objects.requireNonNull(settings); - - RiverCaveRejection sourceRejection = validateSource(source); - if (sourceRejection != RiverCaveRejection.NONE) { - return rejected(source, sourceRejection); - } - - PathResult throat = buildThroat(view, source, settings); - if (throat.rejection() != RiverCaveRejection.NONE) { - return rejected(source, throat.rejection()); - } - - return switch (source.mode()) { - case CLOSED_COMPONENT -> planClosedComponent(view, source, settings, throat.positions()); - case GENERATED_GROTTO -> planGeneratedGrotto(view, source, settings, throat.positions()); - case GROTTO_OR_CLOSED_COMPONENT -> planGrottoOrClosedComponent( - view, - source, - settings, - throat.positions() - ); - case WATERFALL_POOL -> planWaterfallPool( - view, - source, - settings, - throat.positions() - ); - case DEEP_POOL -> planDeepPool(view, source, settings, throat.positions()); - }; - } - - public RiverCavePlanningResult planAll( - CaveVoxelView view, - Collection sources, - RiverCavePlannerSettings settings - ) { - Objects.requireNonNull(view); - Objects.requireNonNull(sources); - Objects.requireNonNull(settings); - - List orderedSources = new ArrayList<>(sources); - orderedSources.sort(SOURCE_PRIORITY); - List plans = new ArrayList<>(orderedSources.size()); - Map combinedActions = new LinkedHashMap<>(); - Map claimedBy = new HashMap<>(); - Map combinedPreconditions = new LinkedHashMap<>(); - - for (RiverCaveSource source : orderedSources) { - RiverCavePlan candidate = plan(view, source, settings); - if (!candidate.accepted()) { - plans.add(candidate); - continue; - } - OptionalLong winnerSourceId = findWinningSourceId( - candidate.actions().keySet(), - claimedBy - ); - if (winnerSourceId.isPresent()) { - plans.add(rejectedOverlap(source, winnerSourceId.getAsLong())); - } else { - plans.add(candidate); - combinedActions.putAll(candidate.actions()); - combinedPreconditions.putAll(candidate.baselinePreconditions()); - } - for (CavePosition position : candidate.actions().keySet()) { - claimedBy.putIfAbsent(position, source); - } - } - - return new RiverCavePlanningResult(plans, combinedActions, combinedPreconditions); - } - - private RiverCavePlan planGrottoOrClosedComponent( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - List throat - ) { - CaveVoxel targetVoxel = voxelAt(view, source.target()); - if (isFluidReachable(targetVoxel, settings)) { - return planClosedComponent(view, source, settings, throat); - } - if (targetVoxel == CaveVoxel.LAVA) { - return rejected(source, RiverCaveRejection.LAVA_CONTACT); - } - if (targetVoxel == CaveVoxel.INCOMPATIBLE_FLUID) { - return rejected(source, RiverCaveRejection.INCOMPATIBLE_FLUID); - } - return planGeneratedGrotto(view, source, settings, throat); - } - - private RiverCavePlan planWaterfallPool( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - List throat - ) { - if (!view.isOpenToSurface(source.target())) { - return planGrottoOrClosedComponent(view, source, settings, throat); - } - RiverCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat); - if (dryThroatRejection != RiverCaveRejection.NONE) { - return rejected(source, dryThroatRejection); - } - RiverCaveRejection shaftRejection = validateWaterfallShaft(view, source, settings, throat); - if (shaftRejection != RiverCaveRejection.NONE) { - return rejected(source, shaftRejection); - } - CaveVoxel targetVoxel = voxelAt(view, source.target()); - if (!isFluidReachable(targetVoxel, settings)) { - return rejected(source, rejectionForTarget(targetVoxel, settings)); - } - - Map actions = new HashMap<>(); - addThroatActions(actions, throat, source); - addSealGuards(view, source, actions); - return accepted(view, source, actions); - } - - private RiverCavePlan planClosedComponent( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - List throat - ) { - RiverCaveRejection dryThroatRejection = validateDryThroatContacts(view, source, throat); - if (dryThroatRejection != RiverCaveRejection.NONE) { - return rejected(source, dryThroatRejection); - } - RiverCaveRejection waterfallRejection = validateWaterfallShaft(view, source, settings, throat); - if (waterfallRejection != RiverCaveRejection.NONE) { - return rejected(source, waterfallRejection); - } - - CaveVoxel targetVoxel = voxelAt(view, source.target()); - if (!isFluidReachable(targetVoxel, settings)) { - return rejected(source, rejectionForTarget(targetVoxel, settings)); - } - - ComponentResult component = resolveClosedComponent(view, source, settings, throat); - if (component.rejection() != RiverCaveRejection.NONE) { - return rejected(source, component.rejection()); - } - - Map actions = new HashMap<>(); - addThroatActions(actions, throat, source); - for (CavePosition position : component.positions()) { - actions.put(position, RiverCaveAction.WET_SOURCE); - } - addSealGuards(view, source, actions); - return accepted(view, source, actions); - } - - private RiverCaveRejection validateDryThroatContacts( - CaveVoxelView view, - RiverCaveSource source, - List throat - ) { - Set throatPositions = Set.copyOf(throat); - for (CavePosition position : throat) { - if (position.y() <= source.waterHeadY()) { - continue; - } - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) { - continue; - } - if (!view.isInWorld(neighbor)) { - return RiverCaveRejection.WORLD_BOUNDARY; - } - CaveVoxel voxel = voxelAt(view, neighbor); - if (voxel == CaveVoxel.LAVA) { - return RiverCaveRejection.LAVA_CONTACT; - } - if (voxel == CaveVoxel.COMPATIBLE_FLUID) { - return RiverCaveRejection.EXISTING_FLUID; - } - if (voxel == CaveVoxel.INCOMPATIBLE_FLUID) { - return RiverCaveRejection.INCOMPATIBLE_FLUID; - } - } - } - return RiverCaveRejection.NONE; - } - - private RiverCaveRejection validateWaterfallShaft( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - List throat - ) { - if (source.mode() != RiverCaveMode.WATERFALL_POOL) { - return RiverCaveRejection.NONE; - } - - Set throatPositions = Set.copyOf(throat); - for (CavePosition position : throat) { - if (position.y() <= source.waterHeadY()) { - continue; - } - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (throatPositions.contains(neighbor) || isInletOpening(source, neighbor)) { - continue; - } - if (!view.isInWorld(neighbor)) { - return RiverCaveRejection.WORLD_BOUNDARY; - } - RiverCaveRejection boundsRejection = validateBounds(source, settings, neighbor); - if (boundsRejection != RiverCaveRejection.NONE) { - return boundsRejection; - } - CaveVoxel voxel = voxelAt(view, neighbor); - RiverCaveRejection hazard = rejectionForHazard(voxel, settings); - if (hazard != RiverCaveRejection.NONE) { - return hazard; - } - if (voxel != CaveVoxel.SOLID) { - return RiverCaveRejection.WATERFALL_SHAFT_OPEN; - } - } - } - return RiverCaveRejection.NONE; - } - - private RiverCavePlan planGeneratedGrotto( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - List throat - ) { - GrottoResult grotto = buildGrotto(source, settings); - if (grotto.rejection() != RiverCaveRejection.NONE) { - return rejected(source, grotto.rejection()); - } - Set chamber = grotto.positions(); - - Set carve = new HashSet<>(chamber.size() + throat.size()); - carve.addAll(chamber); - carve.addAll(throat); - RiverCaveRejection carveRejection = validateGeneratedCarve(view, source, settings, carve); - if (carveRejection != RiverCaveRejection.NONE) { - return rejected(source, carveRejection); - } - - BoundaryResult boundary = validateGeneratedBoundary(view, source, settings, carve); - if (boundary.rejection() != RiverCaveRejection.NONE) { - return rejected(source, boundary.rejection()); - } - - Map actions = new HashMap<>(); - addChamberActions(actions, chamber, source.waterHeadY()); - addThroatActions(actions, throat, source); - for (CavePosition position : boundary.sealGuards()) { - actions.put(position, RiverCaveAction.SEAL_GUARD); - } - return accepted(view, source, actions); - } - - private RiverCavePlan planDeepPool( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - List throat - ) { - GrottoResult grotto = buildGrotto(source, settings); - if (grotto.rejection() != RiverCaveRejection.NONE) { - return rejected(source, grotto.rejection()); - } - Set chamber = grotto.positions(); - Set carve = new HashSet<>(chamber.size() + throat.size()); - carve.addAll(chamber); - carve.addAll(throat); - - RiverCaveRejection carveRejection = validateDeepPoolCarve(view, source, settings, carve); - if (carveRejection != RiverCaveRejection.NONE) { - return rejected(source, carveRejection); - } - BoundaryResult boundary = validateDeepPoolBoundary(view, source, settings, carve); - if (boundary.rejection() != RiverCaveRejection.NONE) { - return rejected(source, boundary.rejection()); - } - - Map actions = new HashMap<>(); - addChamberActions(actions, chamber, source.waterHeadY()); - addThroatActions(actions, throat, source); - for (CavePosition position : boundary.sealGuards()) { - actions.put(position, RiverCaveAction.SEAL_GUARD); - } - return accepted(view, source, actions); - } - - private RiverCaveRejection validateDeepPoolCarve( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - Set carve - ) { - for (CavePosition position : carve) { - if (!view.isInWorld(position)) { - return RiverCaveRejection.WORLD_BOUNDARY; - } - RiverCaveRejection boundsRejection = validateBounds(source, settings, position); - if (boundsRejection != RiverCaveRejection.NONE) { - return boundsRejection; - } - CaveVoxel voxel = voxelAt(view, position); - RiverCaveRejection hazard = rejectionForHazard(voxel, settings); - if (hazard != RiverCaveRejection.NONE) { - return hazard; - } - if (voxel == CaveVoxel.SOLID) { - continue; - } - if (position.y() > source.waterHeadY() - && voxel == CaveVoxel.CAVE_AIR - && !view.isOpenToSurface(position)) { - continue; - } - return RiverCaveRejection.GROTTO_INTERSECTION; - } - return RiverCaveRejection.NONE; - } - - private BoundaryResult validateDeepPoolBoundary( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - Set carve - ) { - Set guards = new HashSet<>(); - for (CavePosition position : carve) { - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (carve.contains(neighbor)) { - continue; - } - if (!view.isInWorld(neighbor)) { - return BoundaryResult.rejected(RiverCaveRejection.WORLD_BOUNDARY); - } - RiverCaveRejection boundsRejection = validateBounds(source, settings, neighbor); - if (boundsRejection != RiverCaveRejection.NONE) { - return BoundaryResult.rejected(boundsRejection); - } - CaveVoxel voxel = voxelAt(view, neighbor); - RiverCaveRejection hazard = rejectionForHazard(voxel, settings); - if (hazard != RiverCaveRejection.NONE) { - return BoundaryResult.rejected(hazard); - } - if (voxel == CaveVoxel.SOLID) { - guards.add(neighbor); - continue; - } - if (neighbor.y() > source.waterHeadY() - && voxel == CaveVoxel.CAVE_AIR - && !view.isOpenToSurface(neighbor)) { - continue; - } - return BoundaryResult.rejected(RiverCaveRejection.GROTTO_SHELL_OPEN); - } - } - return BoundaryResult.accepted(guards); - } - - private ComponentResult resolveClosedComponent( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - List throat - ) { - Queue queue = new ArrayDeque<>(); - Set queued = new HashSet<>(); - Set resolved = new HashSet<>(); - - queue.add(source.target()); - queued.add(source.target()); - RiverCaveRejection seedRejection = addThroatContacts(view, source, settings, throat, queue, queued); - if (seedRejection != RiverCaveRejection.NONE) { - return ComponentResult.rejected(seedRejection); - } - - while (!queue.isEmpty()) { - CavePosition position = queue.remove(); - RiverCaveRejection positionRejection = validateReachablePosition(view, source, settings, position); - if (positionRejection != RiverCaveRejection.NONE) { - return ComponentResult.rejected(positionRejection); - } - if (!resolved.add(position)) { - continue; - } - if (resolved.size() > settings.maxFloodVolume()) { - return ComponentResult.rejected(RiverCaveRejection.VOLUME_LIMIT); - } - - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - RiverCaveRejection neighborRejection = inspectReachableNeighbor( - view, - source, - settings, - neighbor, - queue, - queued - ); - if (neighborRejection != RiverCaveRejection.NONE) { - return ComponentResult.rejected(neighborRejection); - } - } - } - - return ComponentResult.accepted(resolved); - } - - private RiverCaveRejection addThroatContacts( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - List throat, - Queue queue, - Set queued - ) { - for (CavePosition position : throat) { - if (position.y() > source.waterHeadY()) { - continue; - } - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - RiverCaveRejection rejection = inspectReachableNeighbor( - view, - source, - settings, - neighbor, - queue, - queued - ); - if (rejection != RiverCaveRejection.NONE) { - return rejection; - } - } - } - return RiverCaveRejection.NONE; - } - - private RiverCaveRejection inspectReachableNeighbor( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - CavePosition position, - Queue queue, - Set queued - ) { - if (position.y() > source.waterHeadY()) { - return inspectAboveHeadNeighbor(view, source, position); - } - if (!view.isInWorld(position)) { - return RiverCaveRejection.WORLD_BOUNDARY; - } - - CaveVoxel voxel = voxelAt(view, position); - RiverCaveRejection hazard = rejectionForHazard(voxel, settings); - if (hazard != RiverCaveRejection.NONE) { - return hazard; - } - if (!isFluidReachable(voxel, settings)) { - return RiverCaveRejection.NONE; - } - - RiverCaveRejection boundsRejection = validateBounds(source, settings, position); - if (boundsRejection != RiverCaveRejection.NONE) { - return boundsRejection; - } - if (queued.add(position)) { - queue.add(position); - } - return RiverCaveRejection.NONE; - } - - private RiverCaveRejection inspectAboveHeadNeighbor( - CaveVoxelView view, - RiverCaveSource source, - CavePosition position - ) { - if (isInletOpening(source, position) || !view.isInWorld(position)) { - return RiverCaveRejection.NONE; - } - CaveVoxel voxel = voxelAt(view, position); - return switch (voxel) { - case LAVA -> RiverCaveRejection.LAVA_CONTACT; - case COMPATIBLE_FLUID -> RiverCaveRejection.EXISTING_FLUID; - case INCOMPATIBLE_FLUID -> RiverCaveRejection.INCOMPATIBLE_FLUID; - default -> RiverCaveRejection.NONE; - }; - } - - private RiverCaveRejection validateReachablePosition( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - CavePosition position - ) { - if (!view.isInWorld(position)) { - return RiverCaveRejection.WORLD_BOUNDARY; - } - RiverCaveRejection boundsRejection = validateBounds(source, settings, position); - if (boundsRejection != RiverCaveRejection.NONE) { - return boundsRejection; - } - if (view.isOpenToSurface(position)) { - return RiverCaveRejection.OPEN_SURFACE; - } - CaveVoxel voxel = voxelAt(view, position); - RiverCaveRejection hazard = rejectionForHazard(voxel, settings); - if (hazard != RiverCaveRejection.NONE) { - return hazard; - } - return isFluidReachable(voxel, settings) - ? RiverCaveRejection.NONE - : RiverCaveRejection.NO_CAVE_TARGET; - } - - private PathResult buildThroat( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings - ) { - CavePosition entry = source.entry(); - CavePosition target = source.target(); - int deltaX = target.x() - entry.x(); - int deltaY = target.y() - entry.y(); - int deltaZ = target.z() - entry.z(); - int movesX = Math.abs(deltaX); - int movesY = Math.abs(deltaY); - int movesZ = Math.abs(deltaZ); - int length = movesX + movesY + movesZ; - if (length > settings.maxThroatLength()) { - return PathResult.rejected(RiverCaveRejection.THROAT_LIMIT); - } - - int stepX = Integer.signum(deltaX); - int stepY = Integer.signum(deltaY); - int stepZ = Integer.signum(deltaZ); - int usedX = 0; - int usedY = 0; - int usedZ = 0; - CavePosition current = entry; - List positions = new ArrayList<>(length + 1); - - while (true) { - RiverCaveRejection positionRejection = validateThroatPosition(view, source, settings, current); - if (positionRejection != RiverCaveRejection.NONE) { - return PathResult.rejected(positionRejection); - } - positions.add(current); - if (current.equals(target)) { - return expandThroat(view, source, settings, positions); - } - - int axis = selectNextAxis(source.sourceId(), movesX, movesY, movesZ, usedX, usedY, usedZ); - if (axis == 0) { - current = current.offset(stepX, 0, 0); - usedX++; - } else if (axis == 1) { - current = current.offset(0, stepY, 0); - usedY++; - } else { - current = current.offset(0, 0, stepZ); - usedZ++; - } - } - } - - private PathResult expandThroat( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - List centerline - ) { - int radius = settings.throatRadius(); - int extent = radius - 1; - int radiusSquared = radius * radius; - Set expanded = new LinkedHashSet<>(); - for (CavePosition center : centerline) { - for (int dx = -extent; dx <= extent; dx++) { - for (int dy = -extent; dy <= extent; dy++) { - for (int dz = -extent; dz <= extent; dz++) { - if ((dx * dx) + (dy * dy) + (dz * dz) >= radiusSquared) { - continue; - } - CavePosition position = center.offset(dx, dy, dz); - if (position.y() > source.entry().y()) { - continue; - } - RiverCaveRejection rejection = validateThroatPosition(view, source, settings, position); - if (rejection != RiverCaveRejection.NONE) { - return PathResult.rejected(rejection); - } - expanded.add(position); - if (expanded.size() > settings.maxFloodVolume()) { - return PathResult.rejected(RiverCaveRejection.VOLUME_LIMIT); - } - } - } - } - } - return PathResult.accepted(List.copyOf(expanded)); - } - - private int selectNextAxis( - long sourceId, - int movesX, - int movesY, - int movesZ, - int usedX, - int usedY, - int usedZ - ) { - double scoreX = nextAxisScore(movesX, usedX); - double scoreY = nextAxisScore(movesY, usedY); - double scoreZ = nextAxisScore(movesZ, usedZ); - double minimum = Math.min(scoreX, Math.min(scoreY, scoreZ)); - int tieOffset = Math.floorMod(sourceId, 3); - for (int offset = 0; offset < 3; offset++) { - int axis = (tieOffset + offset) % 3; - double score = axis == 0 ? scoreX : axis == 1 ? scoreY : scoreZ; - if (score == minimum) { - return axis; - } - } - throw new IllegalStateException("No remaining throat axis"); - } - - private double nextAxisScore(int moves, int used) { - if (used >= moves) { - return Double.POSITIVE_INFINITY; - } - return ((2D * used) + 1D) / moves; - } - - private RiverCaveRejection validateThroatPosition( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - CavePosition position - ) { - if (!view.isInWorld(position)) { - return RiverCaveRejection.WORLD_BOUNDARY; - } - RiverCaveRejection boundsRejection = validateBounds(source, settings, position); - if (boundsRejection != RiverCaveRejection.NONE) { - return boundsRejection; - } - return rejectionForHazard(voxelAt(view, position), settings); - } - - private GrottoResult buildGrotto(RiverCaveSource source, RiverCavePlannerSettings settings) { - int horizontalRadius = settings.grottoHorizontalRadius(); - int verticalRadius = settings.grottoVerticalRadius(); - Set candidates = new HashSet<>(); - - for (int dx = -horizontalRadius; dx <= horizontalRadius; dx++) { - for (int dy = -verticalRadius; dy <= verticalRadius; dy++) { - for (int dz = -horizontalRadius; dz <= horizontalRadius; dz++) { - if (settings.grottoShape().contains(source, settings, dx, dy, dz)) { - candidates.add(source.target().offset(dx, dy, dz)); - if (candidates.size() > settings.maxFloodVolume()) { - return GrottoResult.rejected(RiverCaveRejection.VOLUME_LIMIT); - } - } - } - } - } - candidates.add(source.target()); - for (int offset = 1; offset <= settings.dryHeadroom(); offset++) { - CavePosition headroom = new CavePosition( - source.target().x(), source.waterHeadY() + offset, source.target().z()); - if (Math.abs(headroom.y() - source.target().y()) > verticalRadius) { - return GrottoResult.rejected(RiverCaveRejection.DRY_HEADROOM_LIMIT); - } - candidates.add(headroom); - if (candidates.size() > settings.maxFloodVolume()) { - return GrottoResult.rejected(RiverCaveRejection.VOLUME_LIMIT); - } - } - return GrottoResult.accepted(connectedGrotto(source.target(), candidates)); - } - - private Set connectedGrotto(CavePosition target, Set candidates) { - Queue queue = new ArrayDeque<>(); - Set connected = new HashSet<>(); - queue.add(target); - connected.add(target); - while (!queue.isEmpty()) { - CavePosition position = queue.remove(); - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (candidates.contains(neighbor) && connected.add(neighbor)) { - queue.add(neighbor); - } - } - } - return connected; - } - - private RiverCaveRejection validateGeneratedCarve( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - Set carve - ) { - for (CavePosition position : carve) { - if (!view.isInWorld(position)) { - return RiverCaveRejection.WORLD_BOUNDARY; - } - RiverCaveRejection boundsRejection = validateBounds(source, settings, position); - if (boundsRejection != RiverCaveRejection.NONE) { - return boundsRejection; - } - CaveVoxel voxel = voxelAt(view, position); - if (isGeneratedInletCarve(view, source, settings, position, voxel)) { - continue; - } - RiverCaveRejection hazard = rejectionForHazard(voxel, settings); - if (hazard != RiverCaveRejection.NONE) { - return hazard; - } - if (voxel != CaveVoxel.SOLID) { - return RiverCaveRejection.GROTTO_INTERSECTION; - } - } - return RiverCaveRejection.NONE; - } - - private BoundaryResult validateGeneratedBoundary( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - Set carve - ) { - Set guards = new HashSet<>(); - for (CavePosition position : carve) { - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (carve.contains(neighbor)) { - continue; - } - if (isInletOpening(source, neighbor) - || isGeneratedInletOpening(view, source, settings, neighbor)) { - continue; - } - if (!view.isInWorld(neighbor)) { - return BoundaryResult.rejected(RiverCaveRejection.WORLD_BOUNDARY); - } - RiverCaveRejection boundsRejection = validateBounds(source, settings, neighbor); - if (boundsRejection != RiverCaveRejection.NONE) { - return BoundaryResult.rejected(boundsRejection); - } - CaveVoxel voxel = voxelAt(view, neighbor); - RiverCaveRejection hazard = rejectionForHazard(voxel, settings); - if (hazard != RiverCaveRejection.NONE) { - return BoundaryResult.rejected(hazard); - } - if (voxel != CaveVoxel.SOLID) { - return BoundaryResult.rejected(RiverCaveRejection.GROTTO_SHELL_OPEN); - } - guards.add(neighbor); - } - } - return BoundaryResult.accepted(guards); - } - - private void addChamberActions( - Map actions, - Collection positions, - int waterHeadY - ) { - for (CavePosition position : positions) { - RiverCaveAction action = position.y() <= waterHeadY - ? RiverCaveAction.WET_SOURCE - : RiverCaveAction.DRY_AIR; - actions.put(position, action); - } - } - - private void addThroatActions( - Map actions, - Collection throat, - RiverCaveSource source - ) { - for (CavePosition position : throat) { - RiverCaveAction action; - if (position.y() <= source.waterHeadY()) { - action = RiverCaveAction.WET_SOURCE; - } else if (source.mode() == RiverCaveMode.WATERFALL_POOL - || source.mode() == RiverCaveMode.GENERATED_GROTTO) { - action = RiverCaveAction.FALLING_FLUID; - } else { - action = RiverCaveAction.DRY_AIR; - } - actions.put(position, action); - } - } - - private void addSealGuards( - CaveVoxelView view, - RiverCaveSource source, - Map actions - ) { - Set guards = new HashSet<>(); - for (CavePosition position : List.copyOf(actions.keySet())) { - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (actions.containsKey(neighbor) - || isInletOpening(source, neighbor) - || !view.isInWorld(neighbor)) { - continue; - } - if (voxelAt(view, neighbor) == CaveVoxel.SOLID) { - guards.add(neighbor); - } - } - } - for (CavePosition guard : guards) { - actions.put(guard, RiverCaveAction.SEAL_GUARD); - } - } - - private boolean isInletOpening(RiverCaveSource source, CavePosition position) { - return position.equals(source.entry().offset(0, 1, 0)); - } - - private boolean isGeneratedInletCarve( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - CavePosition position, - CaveVoxel voxel - ) { - if (voxel != CaveVoxel.CAVE_AIR && voxel != CaveVoxel.COMPATIBLE_FLUID) { - return false; - } - int extent = Math.max(0, settings.throatRadius() - 1); - long deltaX = (long) position.x() - source.entry().x(); - long deltaZ = (long) position.z() - source.entry().z(); - return position.y() >= source.entry().y() - extent - && position.y() <= source.entry().y() - && deltaX * deltaX + deltaZ * deltaZ <= (long) extent * extent - && view.isOpenToSurface(position); - } - - private boolean isGeneratedInletOpening( - CaveVoxelView view, - RiverCaveSource source, - RiverCavePlannerSettings settings, - CavePosition position - ) { - int radius = Math.max(1, settings.throatRadius()); - long deltaX = (long) position.x() - source.entry().x(); - long deltaZ = (long) position.z() - source.entry().z(); - return position.y() >= source.entry().y() - && position.y() <= source.entry().y() + 1 - && deltaX * deltaX + deltaZ * deltaZ < (long) radius * radius - && view.isOpenToSurface(position); - } - - private RiverCaveRejection validateSource(RiverCaveSource source) { - if (source.entry().y() < source.waterHeadY()) { - return RiverCaveRejection.INVALID_SOURCE; - } - if (source.target().y() > source.waterHeadY()) { - return RiverCaveRejection.INVALID_SOURCE; - } - if (source.target().y() > source.entry().y()) { - return RiverCaveRejection.INVALID_SOURCE; - } - return RiverCaveRejection.NONE; - } - - private RiverCaveRejection validateBounds( - RiverCaveSource source, - RiverCavePlannerSettings settings, - CavePosition position - ) { - boolean closedComponent = source.mode() == RiverCaveMode.CLOSED_COMPONENT - || source.mode() == RiverCaveMode.WATERFALL_POOL; - int horizontalRadius = closedComponent - ? settings.maxClosedComponentHorizontalRadius() - : settings.maxHorizontalRadius(); - int maximumDepth = closedComponent - ? settings.maxClosedComponentDepth() - : settings.maxDepth(); - long deltaX = (long) position.x() - source.entry().x(); - long deltaZ = (long) position.z() - source.entry().z(); - long radiusSquared = (long) horizontalRadius * horizontalRadius; - if ((deltaX * deltaX) + (deltaZ * deltaZ) > radiusSquared) { - return RiverCaveRejection.RADIUS_LIMIT; - } - long depth = (long) source.entry().y() - position.y(); - int maximumGeneratedY = source.waterHeadY() + settings.dryHeadroom() + 1; - boolean allowedGeneratedHeadroom = !closedComponent - && position.y() <= maximumGeneratedY; - if ((depth < 0L && !allowedGeneratedHeadroom) || depth > maximumDepth) { - return RiverCaveRejection.DEPTH_LIMIT; - } - return RiverCaveRejection.NONE; - } - - private RiverCaveRejection rejectionForTarget( - CaveVoxel voxel, - RiverCavePlannerSettings settings - ) { - RiverCaveRejection hazard = rejectionForHazard(voxel, settings); - return hazard == RiverCaveRejection.NONE ? RiverCaveRejection.NO_CAVE_TARGET : hazard; - } - - private RiverCaveRejection rejectionForHazard( - CaveVoxel voxel, - RiverCavePlannerSettings settings - ) { - return switch (voxel) { - case LAVA -> RiverCaveRejection.LAVA_CONTACT; - case INCOMPATIBLE_FLUID -> settings.existingFluidPolicy() == RiverCaveFluidPolicy.REPLACE_CONTAINED - ? RiverCaveRejection.NONE - : RiverCaveRejection.INCOMPATIBLE_FLUID; - case COMPATIBLE_FLUID -> settings.existingFluidPolicy() == RiverCaveFluidPolicy.REJECT_EXISTING - ? RiverCaveRejection.EXISTING_FLUID - : RiverCaveRejection.NONE; - default -> RiverCaveRejection.NONE; - }; - } - - private boolean isFluidReachable(CaveVoxel voxel, RiverCavePlannerSettings settings) { - return voxel == CaveVoxel.CAVE_AIR - || (voxel == CaveVoxel.COMPATIBLE_FLUID - && settings.existingFluidPolicy() != RiverCaveFluidPolicy.REJECT_EXISTING) - || (voxel == CaveVoxel.INCOMPATIBLE_FLUID - && settings.existingFluidPolicy() == RiverCaveFluidPolicy.REPLACE_CONTAINED); - } - - private CaveVoxel voxelAt(CaveVoxelView view, CavePosition position) { - return Objects.requireNonNull(view.voxelAt(position)); - } - - private OptionalLong findWinningSourceId( - Set positions, - Map claimedBy - ) { - RiverCaveSource winner = null; - for (CavePosition position : positions) { - RiverCaveSource contender = claimedBy.get(position); - if (contender == null) { - continue; - } - if (winner == null || SOURCE_PRIORITY.compare(contender, winner) < 0) { - winner = contender; - } - } - return winner == null ? OptionalLong.empty() : OptionalLong.of(winner.sourceId()); - } - - private RiverCavePlan accepted( - CaveVoxelView view, - RiverCaveSource source, - Map actions - ) { - Map preconditions = new HashMap<>(actions.size()); - for (CavePosition position : actions.keySet()) { - preconditions.put( - position, - new CaveVoxelPrecondition(voxelAt(view, position), view.isOpenToSurface(position)) - ); - } - return new RiverCavePlan( - source, - RiverCaveRejection.NONE, - actions, - preconditions, - OptionalLong.empty() - ); - } - - private RiverCavePlan rejected(RiverCaveSource source, RiverCaveRejection rejection) { - return new RiverCavePlan( - source, - rejection, - Map.of(), - Map.of(), - OptionalLong.empty() - ); - } - - private RiverCavePlan rejectedOverlap(RiverCaveSource source, long winnerSourceId) { - return new RiverCavePlan( - source, - RiverCaveRejection.OVERLAPPING_SOURCE, - Map.of(), - Map.of(), - OptionalLong.of(winnerSourceId) - ); - } - - private record PathResult(List positions, RiverCaveRejection rejection) { - private static PathResult accepted(List positions) { - return new PathResult(List.copyOf(positions), RiverCaveRejection.NONE); - } - - private static PathResult rejected(RiverCaveRejection rejection) { - return new PathResult(List.of(), rejection); - } - } - - private record ComponentResult(Set positions, RiverCaveRejection rejection) { - private static ComponentResult accepted(Set positions) { - return new ComponentResult(Set.copyOf(positions), RiverCaveRejection.NONE); - } - - private static ComponentResult rejected(RiverCaveRejection rejection) { - return new ComponentResult(Set.of(), rejection); - } - } - - private record BoundaryResult(Set sealGuards, RiverCaveRejection rejection) { - private static BoundaryResult accepted(Set sealGuards) { - return new BoundaryResult(Set.copyOf(sealGuards), RiverCaveRejection.NONE); - } - - private static BoundaryResult rejected(RiverCaveRejection rejection) { - return new BoundaryResult(Set.of(), rejection); - } - } - - private record GrottoResult(Set positions, RiverCaveRejection rejection) { - private static GrottoResult accepted(Set positions) { - return new GrottoResult(Set.copyOf(positions), RiverCaveRejection.NONE); - } - - private static GrottoResult rejected(RiverCaveRejection rejection) { - return new GrottoResult(Set.of(), rejection); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidKind.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidKind.java deleted file mode 100644 index 34c93559b..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidKind.java +++ /dev/null @@ -1,6 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -public enum RiverCaveFluidKind { - RIVER, - DEEP_POOL -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidPolicy.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidPolicy.java deleted file mode 100644 index c84e9c3b7..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveFluidPolicy.java +++ /dev/null @@ -1,7 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -public enum RiverCaveFluidPolicy { - REJECT_EXISTING, - ALLOW_COMPATIBLE, - REPLACE_CONTAINED -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java deleted file mode 100644 index 5c1be7dc9..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCaveHydrology.java +++ /dev/null @@ -1,99 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -import art.arcane.volmlib.util.matter.MatterCavern; - -import java.util.Objects; -import java.util.Optional; - -public final class RiverCaveHydrology { - private static final byte LIQUID_FLUID = 1; - private static final byte LIQUID_FORCED_AIR = 3; - - private final RiverCaveAction action; - private final String floodedBiomeKey; - private final RiverCaveFluidKind fluidKind; - private final MatterCavern cavern; - - public RiverCaveHydrology( - RiverCaveAction action, - String floodedBiomeKey, - RiverCaveFluidKind fluidKind - ) { - this.action = Objects.requireNonNull(action); - this.floodedBiomeKey = floodedBiomeKey == null ? "" : floodedBiomeKey.trim(); - this.fluidKind = Objects.requireNonNull(fluidKind); - this.cavern = switch (action) { - case WET_SOURCE, FALLING_FLUID -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FLUID); - case DRY_AIR -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FORCED_AIR); - case SEAL_GUARD -> null; - }; - } - - public static RiverCaveHydrology of(RiverCaveAction action) { - return new RiverCaveHydrology(action, "", RiverCaveFluidKind.RIVER); - } - - public static RiverCaveHydrology of(RiverCaveAction action, RiverCaveFluidKind fluidKind) { - return new RiverCaveHydrology(action, "", fluidKind); - } - - public Optional floodedBiome() { - return floodedBiomeKey.isEmpty() ? Optional.empty() : Optional.of(floodedBiomeKey); - } - - public boolean carves() { - return action != RiverCaveAction.SEAL_GUARD; - } - - public boolean isWet() { - return action == RiverCaveAction.WET_SOURCE || action == RiverCaveAction.FALLING_FLUID; - } - - public boolean isFalling() { - return action == RiverCaveAction.FALLING_FLUID; - } - - public boolean protectsPlacement() { - return action == RiverCaveAction.SEAL_GUARD || isWet(); - } - - public MatterCavern asCavern() { - return cavern; - } - - public RiverCaveAction action() { - return action; - } - - public String floodedBiomeKey() { - return floodedBiomeKey; - } - - public RiverCaveFluidKind fluidKind() { - return fluidKind; - } - - @Override - public boolean equals(Object object) { - if (this == object) { - return true; - } - if (!(object instanceof RiverCaveHydrology hydrology)) { - return false; - } - return action == hydrology.action - && floodedBiomeKey.equals(hydrology.floodedBiomeKey) - && fluidKind == hydrology.fluidKind; - } - - @Override - public int hashCode() { - return (31 * ((31 * action.hashCode()) + floodedBiomeKey.hashCode())) + fluidKind.hashCode(); - } - - @Override - public String toString() { - return "RiverCaveHydrology[action=" + action + ", floodedBiomeKey=" + floodedBiomeKey - + ", fluidKind=" + fluidKind + "]"; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlan.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlan.java deleted file mode 100644 index 3c38fd0c1..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlan.java +++ /dev/null @@ -1,31 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -import java.util.Map; -import java.util.Objects; -import java.util.OptionalLong; - -public record RiverCavePlan( - RiverCaveSource source, - RiverCaveRejection rejection, - Map actions, - Map baselinePreconditions, - OptionalLong arbitrationWinnerSourceId -) { - public RiverCavePlan { - Objects.requireNonNull(source); - Objects.requireNonNull(rejection); - actions = Map.copyOf(Objects.requireNonNull(actions)); - baselinePreconditions = Map.copyOf(Objects.requireNonNull(baselinePreconditions)); - Objects.requireNonNull(arbitrationWinnerSourceId); - if (rejection != RiverCaveRejection.NONE && (!actions.isEmpty() || !baselinePreconditions.isEmpty())) { - throw new IllegalArgumentException("Rejected cave plans cannot contain mutations or preconditions"); - } - if (rejection != RiverCaveRejection.OVERLAPPING_SOURCE && arbitrationWinnerSourceId.isPresent()) { - throw new IllegalArgumentException("Only overlap rejections can name an arbitration winner"); - } - } - - public boolean accepted() { - return rejection == RiverCaveRejection.NONE; - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlanningResult.java b/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlanningResult.java deleted file mode 100644 index a1cd46868..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/cave/RiverCavePlanningResult.java +++ /dev/null @@ -1,17 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -import java.util.List; -import java.util.Map; -import java.util.Objects; - -public record RiverCavePlanningResult( - List plans, - Map actions, - Map baselinePreconditions -) { - public RiverCavePlanningResult { - plans = List.copyOf(Objects.requireNonNull(plans)); - actions = Map.copyOf(Objects.requireNonNull(actions)); - baselinePreconditions = Map.copyOf(Objects.requireNonNull(baselinePreconditions)); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettings.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettings.java deleted file mode 100644 index acf613ff1..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettings.java +++ /dev/null @@ -1,161 +0,0 @@ -package art.arcane.iris.engine.river.runtime; - -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisRiverBiomes; -import art.arcane.iris.engine.object.IrisRiverNetwork; -import art.arcane.iris.engine.object.IrisRiverOverride; -import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; -import art.arcane.iris.engine.object.IrisRiverTerminalMode; -import art.arcane.volmlib.util.collection.KList; - -import java.util.List; -import java.util.Objects; - -public record EffectiveRiverSettings( - boolean allowSources, - IrisRiverRoutingPolicy routingPolicy, - double routingCostMultiplier, - double widthMultiplier, - double bankWidthMultiplier, - double depthMultiplier, - double maxIncisionMultiplier, - double continuationChanceMultiplier, - double caveEntryMultiplier, - IrisRiverTerminalMode terminalMode, - boolean terminalModeOverridden, - List channelBiomes, - List bankBiomes, - List mouthBiomes, - List dryBiomes, - List floodedCaveBiomes -) { - public EffectiveRiverSettings { - Objects.requireNonNull(routingPolicy); - Objects.requireNonNull(terminalMode); - channelBiomes = List.copyOf(Objects.requireNonNull(channelBiomes)); - bankBiomes = List.copyOf(Objects.requireNonNull(bankBiomes)); - mouthBiomes = List.copyOf(Objects.requireNonNull(mouthBiomes)); - dryBiomes = List.copyOf(Objects.requireNonNull(dryBiomes)); - floodedCaveBiomes = List.copyOf(Objects.requireNonNull(floodedCaveBiomes)); - } - - public static EffectiveRiverSettings resolve( - IrisRiverNetwork network, - IrisRegion region, - IrisBiome naturalBiome - ) { - Objects.requireNonNull(network); - IrisRiverBiomes biomes = network.getBiomes() == null ? new IrisRiverBiomes() : network.getBiomes(); - Builder builder = new Builder( - network.getTerrain().getTerminalMode(), - copy(biomes.getChannel()), - copy(biomes.getBank()), - copy(biomes.getMouth()), - copy(biomes.getDry()), - copy(biomes.getFloodedCave()) - ); - if (region != null) { - builder.apply(region.getRiverOverride()); - } - if (naturalBiome != null) { - builder.apply(naturalBiome.getRiverOverride()); - } - return builder.build(); - } - - private static List copy(KList values) { - return values == null ? List.of() : List.copyOf(values); - } - - private static final class Builder { - private boolean allowSources = true; - private IrisRiverRoutingPolicy routingPolicy = IrisRiverRoutingPolicy.ALLOW; - private double routingCostMultiplier = 1D; - private double widthMultiplier = 1D; - private double bankWidthMultiplier = 1D; - private double depthMultiplier = 1D; - private double maxIncisionMultiplier = 1D; - private double continuationChanceMultiplier = 1D; - private double caveEntryMultiplier = 1D; - private IrisRiverTerminalMode terminalMode; - private boolean terminalModeOverridden; - private List channelBiomes; - private List bankBiomes; - private List mouthBiomes; - private List dryBiomes; - private List floodedCaveBiomes; - - private Builder( - IrisRiverTerminalMode terminalMode, - List channelBiomes, - List bankBiomes, - List mouthBiomes, - List dryBiomes, - List floodedCaveBiomes - ) { - this.terminalMode = terminalMode == null ? IrisRiverTerminalMode.DRY_CHANNEL : terminalMode; - this.channelBiomes = channelBiomes; - this.bankBiomes = bankBiomes; - this.mouthBiomes = mouthBiomes; - this.dryBiomes = dryBiomes; - this.floodedCaveBiomes = floodedCaveBiomes; - } - - private void apply(IrisRiverOverride override) { - if (override == null) { - return; - } - allowSources = override.getAllowSources() == null ? allowSources : override.getAllowSources(); - routingPolicy = override.getRoutingPolicy() == null ? routingPolicy : override.getRoutingPolicy(); - routingCostMultiplier = value(override.getRoutingCostMultiplier(), routingCostMultiplier); - widthMultiplier = value(override.getWidthMultiplier(), widthMultiplier); - bankWidthMultiplier = value(override.getBankWidthMultiplier(), bankWidthMultiplier); - depthMultiplier = value(override.getDepthMultiplier(), depthMultiplier); - maxIncisionMultiplier = value(override.getMaxIncisionMultiplier(), maxIncisionMultiplier); - continuationChanceMultiplier = value( - override.getContinuationChanceMultiplier(), - continuationChanceMultiplier - ); - caveEntryMultiplier = value(override.getCaveEntryMultiplier(), caveEntryMultiplier); - if (override.getTerminalMode() != null) { - terminalMode = override.getTerminalMode(); - terminalModeOverridden = true; - } - channelBiomes = replace(override.getChannelBiomes(), channelBiomes); - bankBiomes = replace(override.getBankBiomes(), bankBiomes); - mouthBiomes = replace(override.getMouthBiomes(), mouthBiomes); - dryBiomes = replace(override.getDryBiomes(), dryBiomes); - floodedCaveBiomes = replace(override.getFloodedCaveBiomes(), floodedCaveBiomes); - } - - private EffectiveRiverSettings build() { - return new EffectiveRiverSettings( - allowSources, - routingPolicy, - routingCostMultiplier, - widthMultiplier, - bankWidthMultiplier, - depthMultiplier, - maxIncisionMultiplier, - continuationChanceMultiplier, - caveEntryMultiplier, - terminalMode, - terminalModeOverridden, - channelBiomes, - bankBiomes, - mouthBiomes, - dryBiomes, - floodedCaveBiomes - ); - } - - private static double value(Double configured, double inherited) { - return configured == null || !Double.isFinite(configured) ? inherited : Math.max(0D, configured); - } - - private static List replace(KList configured, List inherited) { - return configured == null ? inherited : List.copyOf(configured); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java deleted file mode 100644 index 5c5a21cf5..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntime.java +++ /dev/null @@ -1,1586 +0,0 @@ -package art.arcane.iris.engine.river.runtime; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.object.IRare; -import art.arcane.iris.engine.object.InferredType; -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisGeneratorStyle; -import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisRiverNetwork; -import art.arcane.iris.engine.object.IrisRiverCaveMode; -import art.arcane.iris.engine.object.IrisRiverCaves; -import art.arcane.iris.engine.object.IrisRiverDeepPools; -import art.arcane.iris.engine.object.IrisRiverNoiseChance; -import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; -import art.arcane.iris.engine.object.IrisRiverTerminalMode; -import art.arcane.iris.engine.object.IrisRiverTerrain; -import art.arcane.iris.engine.object.IrisRiverTopology; -import art.arcane.iris.engine.object.IrisRiverWater; -import art.arcane.iris.engine.object.IrisRiverWaterMode; -import art.arcane.iris.engine.object.IrisRiverWorm; -import art.arcane.iris.engine.object.IrisStyledRange; -import art.arcane.iris.engine.object.NoiseStyle; -import art.arcane.iris.engine.river.RiverAnchor; -import art.arcane.iris.engine.river.RiverEdgeId; -import art.arcane.iris.engine.river.RiverNetworkOptions; -import art.arcane.iris.engine.river.RiverPolyline; -import art.arcane.iris.engine.river.RiverReach; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverRoutingContext; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.RiverTerrainNodeSample; -import art.arcane.iris.engine.river.RiverTerrainSourceSample; -import art.arcane.iris.engine.river.RiverTerrainSampler; -import art.arcane.iris.engine.river.RiverTerminalPolicy; -import art.arcane.iris.engine.river.RiverTile; -import art.arcane.iris.engine.river.RiverTileCache; -import art.arcane.iris.engine.river.RiverTopologyComplexity; -import art.arcane.iris.engine.river.RiverWorm; -import art.arcane.iris.util.project.interpolation.NoiseBounds; -import art.arcane.iris.util.project.noise.CNG; -import art.arcane.iris.util.project.stream.ProceduralStream; -import art.arcane.volmlib.util.collection.KList; -import art.arcane.volmlib.util.math.RNG; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicReferenceArray; - -public final class IrisRiverRuntime implements AutoCloseable { - static final int MAXIMUM_REACH_FEASIBILITY_SAMPLES = 65; - static final int FEASIBILITY_REJECT = 0; - static final int FEASIBILITY_ACCEPT = 1; - static final int FEASIBILITY_SAMPLE_BED = 2; - private static final long SOURCE_NOISE_SALT = 0x243F6A8885A308D3L; - private static final long CONTINUATION_NOISE_SALT = 0x13198A2E03707344L; - private static final long INCISION_NOISE_SALT = 0xA4093822299F31D0L; - private static final long INCISION_GATE_SALT = 0x082EFA98EC4E6C89L; - private static final long ROUTING_NOISE_SALT = 0x452821E638D01377L; - private static final long WIDTH_NOISE_SALT = 0xBE5466CF34E90C6CL; - private static final long BANK_NOISE_SALT = 0xC0AC29B7C97C50DDL; - private static final long DEPTH_NOISE_SALT = 0x3F84D5B5B5470917L; - private static final long BED_NOISE_SALT = 0xD1310BA698DFB5ACL; - private static final long BIOME_NOISE_SALT = 0x2FFD72DBD01ADFB7L; - private static final long CAVE_ENTRY_NOISE_SALT = 0xB8E1AFED6A267E96L; - private static final long CAVE_ENTRY_GATE_SALT = 0xBA7C9045F12C7F99L; - private static final long DEEP_POOL_REACH_NOISE_SALT = 0x7137449123EF65CDL; - private static final long DEEP_POOL_REACH_GATE_SALT = 0xE9B5DBA58189DBBCL; - private static final long TUNNEL_FLOOR_NOISE_SALT = 0x8CB92BA72F3D8DD7L; - private static final long TUNNEL_ROOF_NOISE_SALT = 0xDB4F0B9175AE2165L; - private static final long TUNNEL_WIDTH_NOISE_SALT = 0xC6EF372FE94F82BEL; - private static final long FLOODED_CAVE_BIOME_SALT = 0x24A19947B3916CF7L; - private static final long TERMINAL_CAVE_ANCHOR_SALT = 0x9E3779B97F4A7C15L; - private static final int TILE_CACHE_SIZE = 32; - private static final int TUNNEL_SAMPLE_CHUNK_CACHE_SIZE = 4_096; - private static final Object ABSENT_TUNNEL_SAMPLE = new Object(); - - private final long seed; - private final IrisRiverNetwork configuration; - private final IrisData data; - private final int riverFluidHeight; - private final int dimensionFluidHeight; - private final boolean boreMantleActive; - private final boolean caveHydrologyActive; - private final boolean blockingRoutingPossible; - private final boolean variableMaxIncisionPossible; - private final boolean biomeRiverOverridesPossible; - private final RiverHeightBoundsSampler naturalHeightBounds; - private final ProceduralStream naturalHeight; - private final ProceduralStream naturalSlope; - private final ProceduralStream naturalOcean; - private final ProceduralStream naturalBiome; - private final ProceduralStream region; - private final IrisRiverTerrain terrain; - private final IrisRiverWater water; - private final IrisRiverCaves caves; - private final CNG sourceNoise; - private final CNG continuationNoise; - private final CNG incisionNoise; - private final CNG routingNoise; - private final CNG widthNoise; - private final CNG bankNoise; - private final CNG depthNoise; - private final CNG bedNoise; - private final CNG biomeNoise; - private final CNG caveEntryNoise; - private final CNG deepPoolReachNoise; - private final CNG tunnelFloorNoise; - private final CNG tunnelRoofNoise; - private final CNG tunnelWidthNoise; - private final art.arcane.iris.engine.river.RiverNetwork network; - private final RuntimeTerrainSampler terrainSampler; - private final RiverTileCache tileCache; - private final Cache tunnelSampleCache; - private final ConcurrentHashMap settingsCache; - private final ConcurrentHashMap> biomePoolCache; - - public IrisRiverRuntime(IrisRiverRuntimeContext context) { - Objects.requireNonNull(context); - seed = context.seed(); - configuration = context.configuration(); - data = context.data(); - riverFluidHeight = context.riverFluidHeight(); - dimensionFluidHeight = context.dimensionFluidHeight(); - boreMantleActive = context.boreMantleActive(); - caveHydrologyActive = context.caveHydrologyActive(); - blockingRoutingPossible = context.blockingRoutingPossible(); - variableMaxIncisionPossible = context.variableMaxIncisionPossible(); - biomeRiverOverridesPossible = context.biomeRiverOverridesPossible(); - naturalHeightBounds = context.naturalHeightBounds(); - naturalHeight = context.naturalHeight(); - naturalSlope = context.naturalSlope(); - naturalOcean = context.naturalOcean(); - naturalBiome = context.naturalBiome(); - region = context.region(); - terrain = Objects.requireNonNull(configuration.getTerrain()); - RiverTopologyComplexity.requireSafeTunnelPlan( - terrain.getMaxChannelWidth(), - maximumTunnelWidthMultiplier(terrain), - terrain.getTunnelMouthBlend() - ); - water = Objects.requireNonNull(configuration.getWater()); - caves = configuration.getCaves() == null ? new IrisRiverCaves() : configuration.getCaves(); - IrisRiverTopology topology = Objects.requireNonNull(configuration.getTopology()); - sourceNoise = noise(topology.getSource(), SOURCE_NOISE_SALT); - continuationNoise = noise(topology.getContinuation(), CONTINUATION_NOISE_SALT); - incisionNoise = noise(terrain.getIncision(), INCISION_NOISE_SALT); - routingNoise = noise(topology.getRoutingStyle(), ROUTING_NOISE_SALT); - widthNoise = noise(terrain.getChannelWidth(), WIDTH_NOISE_SALT); - bankNoise = noise(terrain.getBankWidth(), BANK_NOISE_SALT); - depthNoise = noise(terrain.getDepth(), DEPTH_NOISE_SALT); - bedNoise = noise(terrain.getBedRoughnessStyle(), BED_NOISE_SALT); - biomeNoise = noise(configuration.getBiomes().getSelectionStyle(), BIOME_NOISE_SALT); - caveEntryNoise = noise(caves.getEntry(), CAVE_ENTRY_NOISE_SALT); - IrisRiverDeepPools deepPools = caves.getDeepPools(); - deepPoolReachNoise = noise( - deepPools == null ? null : deepPools.getReach(), - DEEP_POOL_REACH_NOISE_SALT - ); - tunnelFloorNoise = noise(terrain.getTunnelFloorStyle(), TUNNEL_FLOOR_NOISE_SALT); - tunnelRoofNoise = noise(terrain.getTunnelRoofStyle(), TUNNEL_ROOF_NOISE_SALT); - tunnelWidthNoise = noise(terrain.getTunnelWidthMultiplier(), TUNNEL_WIDTH_NOISE_SALT); - settingsCache = new ConcurrentHashMap<>(); - biomePoolCache = new ConcurrentHashMap<>(); - RiverNetworkOptions options = options(topology, terrain); - network = new art.arcane.iris.engine.river.RiverNetwork(options); - terrainSampler = new RuntimeTerrainSampler(topology); - tileCache = new RiverTileCache( - TILE_CACHE_SIZE, - (tileX, tileZ) -> network.buildTile(tileX, tileZ, terrainSampler) - ); - tunnelSampleCache = Caffeine.newBuilder() - .maximumSize(TUNNEL_SAMPLE_CHUNK_CACHE_SIZE) - .build(); - } - - public IrisRiverSurfaceSample sample(double x, double z) { - ResolvedRiverColumn column = resolveColumn(x, z); - if (column == null) { - return IrisRiverSurfaceSample.none(naturalHeight.get(x, z), dimensionFluidHeight); - } - if (column.subterranean()) { - return new IrisRiverSurfaceSample( - column.river(), - column.naturalHeight(), - column.naturalHeight(), - column.waterSurfaceY(), - true, - false - ); - } - double carveWeight = StrictMath.pow( - clamp01(column.river().carveWeight()), - Math.max(0.125D, terrain.getBankExponent()) - ); - if (column.river().terminal() && shouldTaperTerminal(column.reach())) { - carveWeight *= terminalWeight( - terrain.getTerminalTaper(), - column.reach().polyline().length(), - column.river().alongReach() - ); - } - double terrainHeight = incisedHeight( - column.naturalHeight(), - column.bedHeight(), - carveWeight, - column.maximumIncision() - ); - boolean wet = column.river().state() == RiverRouteState.WET; - boolean surfaceFluid = wet && Math.round(terrainHeight) < Math.round(column.waterSurfaceY()); - return new IrisRiverSurfaceSample( - column.river(), - column.naturalHeight(), - terrainHeight, - wet ? column.waterSurfaceY() : terrainHeight, - false, - surfaceFluid - ); - } - - public IrisRiverTunnelSample sampleTunnel(double x, double z) { - double mouthBlend = terrain.getTunnelMouthBlend(); - double maximumMultiplier = maximumTunnelWidthMultiplier(terrain); - double maximumExtraRadius = terrain.getMaxChannelWidth() * 0.5D * (maximumMultiplier - 1D) - + mouthBlend; - RiverTile tile = tileAt(x, z); - RiverSample river = tile.sampleExpanded(x, z, maximumExtraRadius); - if (!river.present() || river.state() != RiverRouteState.WET) { - return null; - } - double widthMultiplier = styled( - terrain.getTunnelWidthMultiplier(), - tunnelWidthNoise, - (int) StrictMath.round(x), - (int) StrictMath.round(z), - 1D - ); - double channelRadius = river.width() * 0.5D * widthMultiplier; - if (river.distance() > channelRadius + mouthBlend) { - return null; - } - ResolvedRiverColumn column = resolveColumn(x, z, tile, river); - if (!column.subterranean()) { - return null; - } - double mouthFactor = tunnelMouthFactor(column, mouthBlend); - channelRadius += mouthBlend * mouthFactor; - if (column.river().distance() > channelRadius) { - return null; - } - double normalizedDistance = channelRadius <= 0D - ? 1D - : clamp01(column.river().distance() / channelRadius); - double profile = StrictMath.sqrt(Math.max(0D, 1D - normalizedDistance * normalizedDistance)); - int waterHeadY = (int) Math.round(column.waterSurfaceY()); - double floorOffset = tunnelFloorNoise.fitDouble( - -terrain.getTunnelFloorVariation(), - terrain.getTunnelFloorVariation(), - x, - z - ); - int bedY = shapedTunnelBedY(waterHeadY, column.bedHeight(), profile, floorOffset); - double roofOffset = tunnelRoofNoise.fitDouble( - -terrain.getTunnelRoofVariation(), - terrain.getTunnelRoofVariation(), - x, - z - ); - int ceilingY = shapedTunnelCeilingY( - waterHeadY, - caves.getDryHeadroom() * column.reach().roofScaleAt(column.river().alongReach()) - + mouthBlend * mouthFactor, - profile, - roofOffset - ); - return new IrisRiverTunnelSample(column.river(), bedY, waterHeadY, ceilingY); - } - - public IrisRiverTunnelSample sampleTunnel(int x, int z) { - long chunkKey = ((long) (x >> 4) << 32) ^ ((z >> 4) & 0xFFFFFFFFL); - TunnelSampleChunk chunk = tunnelSampleCache.get(chunkKey, ignored -> new TunnelSampleChunk()); - return chunk.sample(this, x, z); - } - - static int shapedTunnelBedY( - int waterHeadY, - double baseBedY, - double profile, - double floorOffset - ) { - double baseDepth = Math.max(1D, waterHeadY - baseBedY); - double shapedDepth = Math.max(1D, (baseDepth + floorOffset) * clamp01(profile)); - return waterHeadY - Math.max(1, (int) StrictMath.ceil(shapedDepth)); - } - - static int shapedTunnelCeilingY( - int waterHeadY, - double dryHeadroom, - double profile, - double roofOffset - ) { - double shapedHeadroom = Math.max( - 0D, - (Math.max(0D, dryHeadroom) + roofOffset) * clamp01(profile) - ); - return waterHeadY + (int) StrictMath.ceil(shapedHeadroom); - } - - private ResolvedRiverColumn resolveColumn(double x, double z) { - return resolveColumn(x, z, 0D); - } - - private ResolvedRiverColumn resolveColumn(double x, double z, double additionalRadius) { - RiverTile tile = tileAt(x, z); - RiverSample river = tile.sampleExpanded(x, z, additionalRadius); - if (!river.present()) { - return null; - } - return resolveColumn(x, z, tile, river); - } - - private ResolvedRiverColumn resolveColumn(double x, double z, RiverTile tile, RiverSample river) { - double sampledNaturalHeight = naturalHeight.get(x, z); - RiverReach reach = tile.reach(river.reachId()); - IrisRegion sampledRegion = region.get(x, z); - IrisBiome sampledBiome = naturalBiome.get(x, z); - EffectiveRiverSettings settings = settingsFor(sampledRegion, sampledBiome); - double waterSurfaceY = river.state() == RiverRouteState.WET - ? waterSurface(reach, river.alongReach(), isNaturalOcean(sampledBiome)) - : sampledNaturalHeight; - double bedHeight = river.state() == RiverRouteState.WET - ? waterSurfaceY - river.depth() + bedRoughness(x, z) - : sampledNaturalHeight - river.depth() + bedRoughness(x, z); - double maximumIncision = Math.max(0D, terrain.getMaxIncision() * settings.maxIncisionMultiplier()); - double cappedSurface = incisedHeight( - sampledNaturalHeight, - bedHeight, - 1D, - maximumIncision - ); - return new ResolvedRiverColumn( - river, - reach, - sampledNaturalHeight, - waterSurfaceY, - bedHeight, - maximumIncision, - boreMantleActive - && river.state() == RiverRouteState.WET - && Math.round(cappedSurface) >= Math.round(waterSurfaceY) - ); - } - - private double tunnelMouthFactor(ResolvedRiverColumn column, double mouthBlend) { - if (mouthBlend <= 0D) { - return 0D; - } - double length = column.reach().polyline().length(); - if (length <= 0D) { - return 0D; - } - double offset = mouthBlend / length; - double alongReach = column.river().alongReach(); - return Math.max( - tunnelMouthFactor(column.reach(), alongReach, clamp01(alongReach - offset), mouthBlend), - tunnelMouthFactor(column.reach(), alongReach, clamp01(alongReach + offset), mouthBlend) - ); - } - - private double tunnelMouthFactor( - RiverReach reach, - double subterraneanAlong, - double candidateOpenAlong, - double mouthBlend - ) { - if (candidateOpenAlong == subterraneanAlong - || isCenterlineSubterranean(reach, candidateOpenAlong)) { - return 0D; - } - double openAlong = candidateOpenAlong; - double solidAlong = subterraneanAlong; - for (int iteration = 0; iteration < 5; iteration++) { - double midpoint = (openAlong + solidAlong) * 0.5D; - if (isCenterlineSubterranean(reach, midpoint)) { - solidAlong = midpoint; - } else { - openAlong = midpoint; - } - } - double distance = StrictMath.abs(solidAlong - subterraneanAlong) * reach.polyline().length(); - return clamp01(1D - distance / mouthBlend); - } - - private boolean isCenterlineSubterranean(RiverReach reach, double alongReach) { - CenterlinePosition center = centerlinePosition(reach, alongReach); - IrisRegion sampledRegion = region.get(center.x(), center.z()); - IrisBiome sampledBiome = naturalBiome.get(center.x(), center.z()); - double head = waterSurface(reach, alongReach, isNaturalOcean(sampledBiome)); - double centerNaturalHeight = naturalHeight.get(center.x(), center.z()); - double centerBedHeight = head - reach.depthAt(alongReach) + bedRoughness(center.x(), center.z()); - EffectiveRiverSettings settings = settingsFor(sampledRegion, sampledBiome); - double maximumIncision = Math.max(0D, terrain.getMaxIncision() * settings.maxIncisionMultiplier()); - double cappedSurface = incisedHeight(centerNaturalHeight, centerBedHeight, 1D, maximumIncision); - return Math.round(cappedSurface) >= Math.round(head); - } - - private static CenterlinePosition centerlinePosition(RiverReach reach, double alongReach) { - double targetDistance = clamp01(alongReach) * reach.polyline().length(); - for (int point = 0; point < reach.polyline().size() - 1; point++) { - double segmentStart = reach.polyline().cumulativeLength(point); - double segmentEnd = reach.polyline().cumulativeLength(point + 1); - if (targetDistance > segmentEnd && point < reach.polyline().size() - 2) { - continue; - } - double segmentLength = segmentEnd - segmentStart; - double t = segmentLength <= 0D ? 0D : (targetDistance - segmentStart) / segmentLength; - return new CenterlinePosition( - reach.polyline().x(point) - + (reach.polyline().x(point + 1) - reach.polyline().x(point)) * t, - reach.polyline().z(point) - + (reach.polyline().z(point + 1) - reach.polyline().z(point)) * t - ); - } - int last = reach.polyline().size() - 1; - return new CenterlinePosition(reach.polyline().x(last), reach.polyline().z(last)); - } - - public RiverTile tileAt(double x, double z) { - int blockX = clampToInt(StrictMath.floor(x)); - int blockZ = clampToInt(StrictMath.floor(z)); - return tileCache.get(network.tileXForBlock(blockX), network.tileZForBlock(blockZ)); - } - - public RiverSample sampleFootprint( - double minimumX, - double minimumZ, - double maximumX, - double maximumZ - ) { - if (!Double.isFinite(minimumX) || !Double.isFinite(minimumZ) - || !Double.isFinite(maximumX) || !Double.isFinite(maximumZ) - || minimumX > maximumX || minimumZ > maximumZ) { - throw new IllegalArgumentException("River footprint bounds must be finite and ordered"); - } - double centerX = minimumX * 0.5D + maximumX * 0.5D; - double centerZ = minimumZ * 0.5D + maximumZ * 0.5D; - return tileAt(centerX, centerZ).sampleFootprint(minimumX, minimumZ, maximumX, maximumZ); - } - - public boolean hasRiverFootprint( - int minimumX, - int minimumZ, - int maximumX, - int maximumZ - ) { - if (minimumX >= maximumX || minimumZ >= maximumZ) { - return false; - } - int minimumTileX = network.tileXForBlock(minimumX); - int minimumTileZ = network.tileZForBlock(minimumZ); - int maximumTileX = network.tileXForBlock(maximumX - 1); - int maximumTileZ = network.tileZForBlock(maximumZ - 1); - for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { - for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { - RiverSample sample = tileCache.get(tileX, tileZ).sampleFootprint( - minimumX, - minimumZ, - maximumX, - maximumZ - ); - if (sample.present()) { - return true; - } - } - } - return false; - } - - public List candidateAnchors( - int minimumX, - int minimumZ, - int maximumX, - int maximumZ, - double spacing, - long salt - ) { - if (minimumX >= maximumX || minimumZ >= maximumZ) { - return List.of(); - } - int minimumTileX = network.tileXForBlock(minimumX); - int minimumTileZ = network.tileZForBlock(minimumZ); - int maximumTileX = network.tileXForBlock(maximumX - 1); - int maximumTileZ = network.tileZForBlock(maximumZ - 1); - ArrayList anchors = new ArrayList<>(); - Set seen = new HashSet<>(); - for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) { - for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) { - RiverTile tile = tileCache.get(tileX, tileZ); - List candidates = tile.candidateAnchors( - minimumX, - minimumZ, - maximumX, - maximumZ, - spacing, - salt - ); - for (RiverAnchor anchor : candidates) { - if (seen.add(anchor.stableId())) { - anchors.add(anchor); - } - } - addTerminalCaveAnchors( - tile, - minimumX, - minimumZ, - maximumX, - maximumZ, - spacing, - salt, - anchors, - seen - ); - } - } - return List.copyOf(anchors); - } - - public EffectiveRiverSettings settingsAt(double x, double z) { - IrisRegion sampledRegion = region.get(x, z); - IrisBiome sampledBiome = biomeRiverOverridesPossible ? naturalBiome.get(x, z) : null; - return settingsFor(sampledRegion, sampledBiome); - } - - private EffectiveRiverSettings settingsFor(IrisRegion sampledRegion, IrisBiome sampledBiome) { - IdentitySettingsKey key = new IdentitySettingsKey(sampledRegion, sampledBiome); - return settingsCache.computeIfAbsent( - key, - ignored -> EffectiveRiverSettings.resolve(configuration, sampledRegion, sampledBiome) - ); - } - - public IrisRiverCaves caveSettings() { - return caves; - } - - public double maximumChannelWidth() { - return terrain.getMaxChannelWidth(); - } - - public double maximumTunnelWidthMultiplier() { - return maximumTunnelWidthMultiplier(terrain); - } - - public double tunnelMouthBlend() { - return terrain.getTunnelMouthBlend(); - } - - public int maximumTunnelHeadroom() { - return caves.getDryHeadroom() - + (int) StrictMath.ceil(terrain.getTunnelRoofVariation()) - + (int) StrictMath.ceil(terrain.getTunnelMouthBlend()); - } - - public boolean acceptsCaveAnchor(RiverAnchor anchor) { - Objects.requireNonNull(anchor); - IrisRiverCaves caves = caveSettings(); - if (anchor.state() != RiverRouteState.WET - || !caveHydrologyActive - || caves.getMode() == IrisRiverCaveMode.SEALED - || caves.getMaximumPerReach() <= 0) { - return false; - } - RiverReach reach = tileAt(anchor.x(), anchor.z()).reach(anchor.reachId()); - if (reach == null || reach.state() != RiverRouteState.WET) { - return false; - } - TerminalCaveAnchor terminal = terminalCaveAnchor(reach); - if (terminal != null) { - return anchor.stableId() == terminal.stableId() - && caves.getMode() != IrisRiverCaveMode.SEALED; - } - double firstDistance = unit(art.arcane.iris.engine.river.RiverNetwork.mix( - reach.id().stableId() ^ anchor.samplingSalt() - )) * anchor.samplingSpacing(); - double anchorDistance = firstDistance + anchor.index() * anchor.samplingSpacing(); - if (anchorDistance >= reach.polyline().length()) { - return false; - } - int accepted = 0; - for (int index = 0; index <= anchor.index(); index++) { - double distance = firstDistance + index * anchor.samplingSpacing(); - TerminalPosition position = positionAt(reach.polyline(), distance); - long stableId = art.arcane.iris.engine.river.RiverNetwork.mix( - reach.id().stableId() - ^ anchor.samplingSalt() - ^ (long) index * 0x9E3779B97F4A7C15L - ); - if (!caveEntryEligible(caves, stableId, position.x(), position.z())) { - continue; - } - if (!isCaveAnchorSourceable(position.x(), position.z())) { - continue; - } - if (index == anchor.index()) { - return stableId == anchor.stableId() && accepted < caves.getMaximumPerReach(); - } - accepted++; - if (accepted >= caves.getMaximumPerReach()) { - return false; - } - } - return false; - } - - public boolean acceptsDeepPoolAnchor(RiverAnchor anchor) { - Objects.requireNonNull(anchor); - IrisRiverDeepPools deepPools = caves.getDeepPools(); - if (deepPools == null - || !deepPools.isEnabled() - || deepPools.getMaximumPerReach() <= 0 - || anchor.state() != RiverRouteState.WET - || !caveHydrologyActive) { - return false; - } - RiverReach reach = tileAt(anchor.x(), anchor.z()).reach(anchor.reachId()); - if (reach == null || reach.state() != RiverRouteState.WET) { - return false; - } - if (!deepPoolReachEligible(deepPools, reach)) { - return false; - } - TerminalCaveAnchor terminal = terminalCaveAnchor(reach); - if (terminal != null && anchor.stableId() == terminal.stableId()) { - return false; - } - double firstDistance = unit(art.arcane.iris.engine.river.RiverNetwork.mix( - reach.id().stableId() ^ anchor.samplingSalt() - )) * anchor.samplingSpacing(); - double anchorDistance = firstDistance + anchor.index() * anchor.samplingSpacing(); - if (anchorDistance >= reach.polyline().length()) { - return false; - } - int accepted = 0; - for (int index = 0; index <= anchor.index(); index++) { - long stableId = art.arcane.iris.engine.river.RiverNetwork.mix( - reach.id().stableId() - ^ anchor.samplingSalt() - ^ (long) index * 0x9E3779B97F4A7C15L - ); - if (index == anchor.index()) { - return stableId == anchor.stableId() && accepted < deepPools.getMaximumPerReach(); - } - accepted++; - if (accepted >= deepPools.getMaximumPerReach()) { - return false; - } - } - return false; - } - - private boolean isCaveAnchorSourceable(double x, double z) { - IrisRiverSurfaceSample surface = sample(x, z); - if (surface.river().present() - && surface.river().state() == RiverRouteState.WET - && surface.river().section() == RiverSection.CHANNEL - && surface.surfaceFluid()) { - return true; - } - return sampleTunnel(x, z) != null; - } - - public boolean isTerminalCaveAnchor(RiverAnchor anchor) { - TerminalCaveAnchor terminal = terminalCaveAnchor(anchor); - return terminal != null && anchor.stableId() == terminal.stableId(); - } - - public String selectFloodedCaveBiome(RiverAnchor anchor) { - Objects.requireNonNull(anchor); - List biomes = settingsAt(anchor.x(), anchor.z()).floodedCaveBiomes(); - if (biomes.isEmpty()) { - return ""; - } - long hash = art.arcane.iris.engine.river.RiverNetwork.mix( - seed ^ anchor.stableId() ^ FLOODED_CAVE_BIOME_SALT); - int index = Math.floorMod(hash, biomes.size()); - String selected = biomes.get(index); - return selected == null ? "" : selected.trim(); - } - - public IrisBiome selectSurfaceBiome(IrisRiverSurfaceSample sample, double x, double z) { - RiverSample river = sample.river(); - if (!river.present() || sample.subterranean()) { - return null; - } - EffectiveRiverSettings settings = settingsAt(x, z); - PoolSelection selection = poolFor(river.section(), settings); - if (selection.keys().isEmpty()) { - return null; - } - BiomePoolKey key = new BiomePoolKey(selection.keys(), selection.type()); - List candidates = biomePoolCache.computeIfAbsent(key, this::loadBiomePool); - if (candidates.isEmpty()) { - return null; - } - double selector = clamp01(biomeNoise.fitDouble(0D, 1D, x, z)); - IrisBiome selected = IRare.pick(candidates, selector); - return selected == null ? null : selected.withInferredType(selection.type()); - } - - public int completedTileCount() { - return tileCache.completedSize(); - } - - boolean allowsReach(RiverRoutingContext context) { - return terrainSampler.allowsReach(Objects.requireNonNull(context)); - } - - RiverTerrainNodeSample sampleNode(int blockX, int blockZ) { - return terrainSampler.sampleNode(blockX, blockZ); - } - - RiverTerrainSourceSample sampleSource(int blockX, int blockZ) { - return terrainSampler.sampleSource(blockX, blockZ); - } - - @Override - public void close() { - tileCache.close(); - tunnelSampleCache.invalidateAll(); - settingsCache.clear(); - biomePoolCache.clear(); - } - - private RiverNetworkOptions options(IrisRiverTopology topology, IrisRiverTerrain riverTerrain) { - double dryChance = clamp01(riverTerrain.getDryContinuationChance()); - return RiverNetworkOptions.builder(seed) - .cellSize(topology.getCellSize()) - .tileCells(topology.getTileCells()) - .siteJitter(topology.getSiteJitter()) - .maxRouteReaches(topology.getMaxRouteReaches()) - .minimumSourcesPerTile(topology.getMinimumSourcesPerTile()) - .downstreamCandidateLimit(Math.max(1, Math.min(8, topology.getSinkSearchReaches() + 1))) - .routingBasinCells(topology.getRoutingBasinCells()) - .routingDeviationScaleCells(topology.getRoutingDeviationScaleCells()) - .routingDeviationStrengthCells(topology.getRoutingDeviationStrengthCells()) - .routingPlateauHeight(topology.getRoutingPlateauHeight()) - .hydraulicBaseHeight(riverFluidHeight) - .requireOcean(topology.isRequireOcean()) - .sourceChance(chance(topology.getSource())) - .reachChance(chance(topology.getContinuation())) - .dryChannelChance(dryChance) - .terrainHeightWeight(topology.getTerrainHeightWeight()) - .routingNoiseWeight(0D) - .flowAlignmentWeight(topology.getFlowAlignmentWeight()) - .confluenceWeight(topology.getConfluenceWeight()) - .oceanAttraction(topology.getOceanAttraction()) - .channelWidth(mid(riverTerrain.getChannelWidth(), 12D)) - .bankWidth(mid(riverTerrain.getBankWidth(), 8D)) - .depth(mid(riverTerrain.getDepth(), 4D)) - .channelRadiusBonus(riverTerrain.getChannelRadiusBonus()) - .maxChannelWidth(riverTerrain.getMaxChannelWidth()) - .maxBankWidth(riverTerrain.getMaxBankWidth()) - .maxDepth(riverTerrain.getMaxDepth()) - .orderWidthFactor(riverTerrain.getOrderWidthFactor()) - .orderDepthFactor(riverTerrain.getOrderDepthFactor()) - .maximumReachRadius(maximumReachRadius(topology, riverTerrain)) - .worms(worms(riverTerrain)) - .build(); - } - - private List worms(IrisRiverTerrain riverTerrain) { - if (riverTerrain.getWorms() == null || riverTerrain.getWorms().isEmpty()) { - throw new IllegalArgumentException("River terrain must configure at least one Perlin worm"); - } - ArrayList worms = new ArrayList<>(riverTerrain.getWorms().size()); - for (IrisRiverWorm configured : riverTerrain.getWorms()) { - if (configured == null) { - throw new IllegalArgumentException("River terrain worms must not contain null entries"); - } - worms.add(worm(configured)); - } - return List.copyOf(worms); - } - - private RiverWorm worm(IrisRiverWorm configured) { - if (configured.getChildren() == null) { - throw new IllegalArgumentException("River worm children must be an array"); - } - ArrayList children = new ArrayList<>(configured.getChildren().size()); - for (IrisRiverWorm child : configured.getChildren()) { - if (child == null) { - throw new IllegalArgumentException("River worm children must not contain null entries"); - } - children.add(worm(child)); - } - return new RiverWorm( - configured.getId(), - configured.getSeed(), - configured.getWeight(), - configured.getWavelength(), - configured.getDetailWavelength(), - configured.getTortuosity(), - configured.getDetailTortuosity(), - configured.getMaxOffset(), - configured.getSegments(), - configured.getWidthMultiplier(), - configured.getBankMultiplier(), - configured.getDepthMultiplier(), - configured.getBodyWavelength(), - configured.getBodyDetailWavelength(), - configured.getBodyDetailInfluence(), - configured.getWidthVariation(), - configured.getBankVariation(), - configured.getDepthVariation(), - configured.getRoofVariation(), - configured.getBranchCap(), - configured.getBranchDecay(), - configured.getConfluenceMultiplier(), - configured.getChildChance(), - configured.getBranchChildChance(), - List.copyOf(children) - ); - } - - private void addTerminalCaveAnchors( - RiverTile tile, - int minimumX, - int minimumZ, - int maximumX, - int maximumZ, - double spacing, - long salt, - List anchors, - Set seen - ) { - for (RiverReach reach : tile.reaches()) { - TerminalCaveAnchor terminal = terminalCaveAnchor(reach); - if (terminal == null - || terminal.x() < minimumX - || terminal.x() >= maximumX - || terminal.z() < minimumZ - || terminal.z() >= maximumZ - || !seen.add(terminal.stableId())) { - continue; - } - anchors.add(new RiverAnchor( - reach.id(), - 0, - terminal.stableId(), - spacing, - salt, - terminal.x(), - terminal.z(), - terminal.alongReach(), - reach.state(), - reach.flow(), - reach.order() - )); - } - } - - private TerminalCaveAnchor terminalCaveAnchor(RiverAnchor anchor) { - RiverReach reach = tileAt(anchor.x(), anchor.z()).reach(anchor.reachId()); - return reach == null ? null : terminalCaveAnchor(reach); - } - - private boolean caveEntryEligible( - IrisRiverCaves caves, - long stableId, - double x, - double z - ) { - EffectiveRiverSettings settings = settingsAt(x, z); - double chance = clamp01(effectiveChance( - caves.getEntry(), - caveEntryNoise, - (int) StrictMath.floor(x), - (int) StrictMath.floor(z) - ) * settings.caveEntryMultiplier()); - long hash = art.arcane.iris.engine.river.RiverNetwork.mix( - seed ^ stableId ^ CAVE_ENTRY_GATE_SALT - ); - return unit(hash) < chance; - } - - private boolean deepPoolReachEligible( - IrisRiverDeepPools deepPools, - RiverReach reach - ) { - CenterlinePosition center = centerlinePosition(reach, 0.5D); - EffectiveRiverSettings settings = settingsAt(center.x(), center.z()); - double chance = clamp01(effectiveChance( - deepPools.getReach(), - deepPoolReachNoise, - (int) StrictMath.floor(center.x()), - (int) StrictMath.floor(center.z()) - ) * settings.caveEntryMultiplier()); - long hash = art.arcane.iris.engine.river.RiverNetwork.mix( - seed ^ reach.id().stableId() ^ DEEP_POOL_REACH_GATE_SALT - ); - return unit(hash) < chance; - } - - private TerminalCaveAnchor terminalCaveAnchor(RiverReach reach) { - if (!caveHydrologyActive || !reach.terminal() || reach.state() != RiverRouteState.WET) { - return null; - } - RiverPolyline polyline = reach.polyline(); - double length = polyline.length(); - double taperDistance = Math.min(length, Math.max(0D, terrain.getTerminalTaper())); - double targetDistance = taperDistance == 0D ? length : length - (taperDistance * 0.5D); - TerminalPosition position = positionAt(polyline, targetDistance); - EffectiveRiverSettings settings = settingsAt(reach.to().x(), reach.to().z()); - if (settings.terminalMode() != IrisRiverTerminalMode.SINKHOLE_GROTTO) { - return null; - } - long stableId = art.arcane.iris.engine.river.RiverNetwork.mix( - reach.id().stableId() ^ TERMINAL_CAVE_ANCHOR_SALT - ); - return new TerminalCaveAnchor(stableId, position.x(), position.z(), position.alongReach()); - } - - private boolean shouldTaperTerminal(RiverReach reach) { - return settingsAt(reach.to().x(), reach.to().z()).terminalMode() - != IrisRiverTerminalMode.SINKHOLE_GROTTO; - } - - private TerminalPosition positionAt(RiverPolyline polyline, double targetDistance) { - double traversed = 0D; - for (int point = 0; point < polyline.size() - 1; point++) { - double startX = polyline.x(point); - double startZ = polyline.z(point); - double deltaX = polyline.x(point + 1) - startX; - double deltaZ = polyline.z(point + 1) - startZ; - double segmentLength = StrictMath.hypot(deltaX, deltaZ); - if (targetDistance <= traversed + segmentLength || point == polyline.size() - 2) { - double factor = segmentLength == 0D ? 0D : (targetDistance - traversed) / segmentLength; - factor = Math.max(0D, Math.min(1D, factor)); - double alongReach = polyline.length() == 0D ? 0D : targetDistance / polyline.length(); - return new TerminalPosition( - startX + (deltaX * factor), - startZ + (deltaZ * factor), - alongReach - ); - } - traversed += segmentLength; - } - return new TerminalPosition( - polyline.x(polyline.size() - 1), - polyline.z(polyline.size() - 1), - 1D - ); - } - - double waterSurface(RiverReach reach, double alongReach, boolean naturalOcean) { - if (naturalOcean) { - return dimensionFluidHeight; - } - if (reach == null || water.getMode() == IrisRiverWaterMode.FIXED) { - return riverFluidHeight; - } - return terracedWaterSurface( - reach.from().hydraulicHeight(), - reach.to().hydraulicHeight(), - reach.polyline().length(), - alongReach - ); - } - - double terracedWaterSurface( - double fromNaturalHeight, - double toNaturalHeight, - double reachLength, - double alongReach - ) { - int dropHeight = Math.max(1, water.getDropHeight()); - int fromHead = nodeWaterHead(fromNaturalHeight, dropHeight); - int toHead = nodeWaterHead(toNaturalHeight, dropHeight); - int headDelta = toHead - fromHead; - int availableDrops = StrictMath.abs(headDelta) / dropHeight; - if (availableDrops == 0) { - return fromHead; - } - double normalized = clamp01(alongReach); - double distance = normalized * Math.max(0D, reachLength); - double configuredPoolLength = Math.max(1D, water.getPoolLength()); - double requiredInteriorLength = configuredPoolLength * Math.max(0, availableDrops - 1); - double requiredTargetLength = configuredPoolLength * (availableDrops + 1D); - double dropSpacing; - double firstDrop; - if (reachLength >= requiredTargetLength) { - dropSpacing = configuredPoolLength; - firstDrop = (reachLength - requiredInteriorLength) * 0.5D; - } else { - dropSpacing = reachLength / (availableDrops + 1D); - firstDrop = dropSpacing; - } - int completedDrops; - if (normalized >= 1D || dropSpacing <= 0D) { - completedDrops = availableDrops; - } else if (distance < firstDrop) { - completedDrops = 0; - } else { - completedDrops = 1 + (int) StrictMath.floor((distance - firstDrop) / dropSpacing); - completedDrops = Math.min(availableDrops, completedDrops); - } - int direction = Integer.signum(headDelta); - return fromHead + direction * completedDrops * dropHeight; - } - - private double bedRoughness(double x, double z) { - return bedNoise.fitDouble( - -terrain.getBedRoughness(), - terrain.getBedRoughness(), - x, - z - ); - } - - private static double incisedHeight( - double naturalHeight, - double bedHeight, - double carveWeight, - double maximumIncision - ) { - double targetHeight = naturalHeight + (bedHeight - naturalHeight) * carveWeight; - double guardedTarget = Math.max(targetHeight, naturalHeight - maximumIncision); - return Math.min(naturalHeight, guardedTarget); - } - - private int nodeWaterHead(double naturalNodeHeight, int dropHeight) { - int availableRise = Math.max(0, water.getMaximumPoolRise()); - int maximumHead = riverFluidHeight + availableRise; - int naturalHead = (int) StrictMath.floor(naturalNodeHeight - 1D); - int clamped = Math.max(riverFluidHeight, Math.min(maximumHead, naturalHead)); - return riverFluidHeight + Math.floorDiv(clamped - riverFluidHeight, dropHeight) * dropHeight; - } - - private static boolean isNaturalOcean(IrisBiome biome) { - return biome != null && biome.getInferredType() == InferredType.SEA; - } - - static double terminalWeight(int terminalTaper, double reachLength, double alongReach) { - double taperFraction = Math.min( - 1D, - Math.max(0, terminalTaper) / Math.max(0.000001D, reachLength) - ); - double taperStart = 1D - taperFraction; - if (alongReach <= taperStart) { - return 1D; - } - return clamp01((1D - alongReach) / Math.max(0.000001D, taperFraction)); - } - - private PoolSelection poolFor(RiverSection section, EffectiveRiverSettings settings) { - return switch (section) { - case CHANNEL -> new PoolSelection(settings.channelBiomes(), InferredType.SEA); - case MOUTH -> new PoolSelection(settings.mouthBiomes(), InferredType.SEA); - case BANK -> new PoolSelection(settings.bankBiomes(), InferredType.SHORE); - case DRY_CHANNEL, DRY_BANK -> new PoolSelection(settings.dryBiomes(), InferredType.LAND); - case NONE -> new PoolSelection(List.of(), InferredType.LAND); - }; - } - - private List loadBiomePool(BiomePoolKey pool) { - KList loaded = data.getBiomeLoader().loadAll(new KList<>(pool.keys())); - ArrayList inferred = new ArrayList<>(loaded.size()); - for (IrisBiome biome : loaded) { - if (biome != null) { - inferred.add(biome.withInferredType(pool.type())); - } - } - return List.copyOf(inferred); - } - - private CNG noise(IrisRiverNoiseChance configured, long salt) { - IrisGeneratorStyle style = configured == null ? null : configured.getStyle(); - return noise(style, salt); - } - - private CNG noise(IrisStyledRange configured, long salt) { - IrisGeneratorStyle style = configured == null ? null : configured.getStyle(); - return noise(style, salt); - } - - private CNG noise(IrisGeneratorStyle configured, long salt) { - IrisGeneratorStyle style = configured == null ? new IrisGeneratorStyle(NoiseStyle.FLAT) : configured; - return style.createNoCache(new RNG(seed ^ salt), data); - } - - private double effectiveChance(IrisRiverNoiseChance configured, CNG noise, int x, int z) { - if (configured == null) { - return 1D; - } - double contribution = noise.fitDouble(-configured.getInfluence(), configured.getInfluence(), x, z); - return clamp01(configured.getChance() + contribution); - } - - private double chanceMultiplier(IrisRiverNoiseChance configured, CNG noise, int x, int z) { - double baseChance = chance(configured); - if (baseChance <= 0D) { - return 0D; - } - return effectiveChance(configured, noise, x, z) / baseChance; - } - - private static double chance(IrisRiverNoiseChance configured) { - return configured == null ? 1D : clamp01(configured.getChance()); - } - - private static double mid(IrisStyledRange range, double fallback) { - if (range == null || !Double.isFinite(range.getMin()) || !Double.isFinite(range.getMax())) { - return fallback; - } - return Math.max(0.000001D, (range.getMin() + range.getMax()) * 0.5D); - } - - private static double styled(IrisStyledRange range, CNG noise, int x, int z, double fallback) { - if (range == null || !Double.isFinite(range.getMin()) || !Double.isFinite(range.getMax())) { - return fallback; - } - double minimum = Math.min(range.getMin(), range.getMax()); - double maximum = Math.max(range.getMin(), range.getMax()); - if (minimum == maximum) { - return minimum; - } - return noise.fitDouble(minimum, maximum, x, z); - } - - private static double maximumReachRadius(IrisRiverTopology topology, IrisRiverTerrain riverTerrain) { - double surfaceRadius = riverTerrain.getMaxChannelWidth() * 0.5D - + riverTerrain.getMaxBankWidth(); - double tunnelRadius = riverTerrain.getMaxChannelWidth() * 0.5D - * maximumTunnelWidthMultiplier(riverTerrain) - + riverTerrain.getTunnelMouthBlend(); - return Math.max(surfaceRadius, tunnelRadius); - } - - private static double maximumTunnelWidthMultiplier(IrisRiverTerrain riverTerrain) { - IrisStyledRange configured = riverTerrain.getTunnelWidthMultiplier(); - if (configured == null - || !Double.isFinite(configured.getMin()) - || !Double.isFinite(configured.getMax())) { - return 1D; - } - return Math.max(1D, Math.max(configured.getMin(), configured.getMax())); - } - - private static double clamp01(double value) { - return Math.max(0D, Math.min(1D, value)); - } - - private static int clampToInt(double value) { - return (int) StrictMath.max(Integer.MIN_VALUE, StrictMath.min(Integer.MAX_VALUE, value)); - } - - private static double unit(long hash) { - return (hash >>> 11) * 0x1.0p-53; - } - - private static final class TunnelSampleChunk { - private final AtomicReferenceArray samples = new AtomicReferenceArray<>(256); - - private IrisRiverTunnelSample sample(IrisRiverRuntime runtime, int x, int z) { - int index = ((x & 15) << 4) | (z & 15); - Object cached = samples.get(index); - if (cached == null) { - IrisRiverTunnelSample computed = runtime.sampleTunnel((double) x, (double) z); - Object encoded = computed == null ? ABSENT_TUNNEL_SAMPLE : computed; - if (samples.compareAndSet(index, null, encoded)) { - cached = encoded; - } else { - cached = samples.get(index); - } - } - return cached == ABSENT_TUNNEL_SAMPLE ? null : (IrisRiverTunnelSample) cached; - } - } - - private final class RuntimeTerrainSampler implements RiverTerrainSampler { - private final IrisRiverTopology topology; - - private RuntimeTerrainSampler(IrisRiverTopology topology) { - this.topology = topology; - } - - @Override - public RiverTerrainNodeSample sampleNode(int blockX, int blockZ) { - boolean oceanIntent = Boolean.TRUE.equals(naturalOcean.get(blockX, blockZ)); - boolean naturalHeightRequired = topology.getTerrainHeightWeight() > 0D - || water.getMode() != IrisRiverWaterMode.FIXED - || oceanIntent; - double sampledNaturalHeight = naturalHeightRequired - ? naturalHeight.get(blockX, blockZ) - : dimensionFluidHeight; - boolean sampledOcean = oceanIntent && isSubmergedOutlet(sampledNaturalHeight); - IrisRegion sampledRegion = region.get(blockX, blockZ); - IrisBiome sampledBiome = biomeRiverOverridesPossible ? naturalBiome.get(blockX, blockZ) : null; - EffectiveRiverSettings settings = settingsFor(sampledRegion, sampledBiome); - return new RiverTerrainNodeSample( - sampledNaturalHeight, - sampledOcean, - settings.routingPolicy() != IrisRiverRoutingPolicy.BLOCK, - routingCost(blockX, blockZ, settings) - ); - } - - @Override - public RiverTerrainSourceSample sampleSource(int blockX, int blockZ) { - EffectiveRiverSettings settings = settingsAt(blockX, blockZ); - double chanceMultiplier = settings.allowSources() - ? chanceMultiplier(topology.getSource(), sourceNoise, blockX, blockZ) - : 0D; - return new RiverTerrainSourceSample( - chanceMultiplier, - settings.routingPolicy() != IrisRiverRoutingPolicy.BLOCK, - isSubmergedOceanIntent(blockX, blockZ) - ); - } - - @Override - public double naturalHeight(int blockX, int blockZ) { - return IrisRiverRuntime.this.naturalHeight.get(blockX, blockZ); - } - - @Override - public boolean isOcean(int blockX, int blockZ) { - return isSubmergedOceanIntent(blockX, blockZ); - } - - private boolean isSubmergedOceanIntent(int blockX, int blockZ) { - return Boolean.TRUE.equals(naturalOcean.get(blockX, blockZ)) - && isSubmergedOutlet(naturalHeight.get(blockX, blockZ)); - } - - private boolean isSubmergedOutlet(double sampledNaturalHeight) { - return Math.round(sampledNaturalHeight) < Math.round(dimensionFluidHeight); - } - - @Override - public double routingCost(int blockX, int blockZ) { - EffectiveRiverSettings settings = settingsAt(blockX, blockZ); - return routingCost(blockX, blockZ, settings); - } - - @Override - public double sourceChanceMultiplier(int blockX, int blockZ) { - EffectiveRiverSettings settings = settingsAt(blockX, blockZ); - if (!settings.allowSources()) { - return 0D; - } - return chanceMultiplier(topology.getSource(), sourceNoise, blockX, blockZ); - } - - @Override - public double maximumSourceChanceMultiplier() { - IrisRiverNoiseChance configured = topology.getSource(); - if (configured == null) { - return 1D; - } - double baseChance = chance(configured); - if (baseChance <= 0D) { - return 0D; - } - return clamp01(baseChance + StrictMath.abs(configured.getInfluence())) / baseChance; - } - - @Override - public double reachChanceMultiplier(int blockX, int blockZ) { - EffectiveRiverSettings settings = settingsAt(blockX, blockZ); - return chanceMultiplier(topology.getContinuation(), continuationNoise, blockX, blockZ) - * settings.continuationChanceMultiplier(); - } - - @Override - public boolean allowsRiver(int blockX, int blockZ) { - return settingsAt(blockX, blockZ).routingPolicy() != IrisRiverRoutingPolicy.BLOCK; - } - - @Override - public boolean allowsReach(RiverRoutingContext context) { - if (!incisionGate(context)) { - return false; - } - if (boreMantleActive) { - if (!blockingRoutingPossible) { - return true; - } - return RiverPolylineProbe.all( - context.polyline(), - MAXIMUM_REACH_FEASIBILITY_SAMPLES, - (x, z, alongReach) -> settingsAt(x, z).routingPolicy() - != IrisRiverRoutingPolicy.BLOCK - ); - } - double depth = depth(context, mid(terrain.getDepth(), 4D)); - return RiverPolylineProbe.all( - context.polyline(), - MAXIMUM_REACH_FEASIBILITY_SAMPLES, - (x, z, alongReach) -> allowsReachSample(context, depth, alongReach, x, z) - ); - } - - private boolean allowsReachSample( - RiverRoutingContext context, - double depth, - double alongReach, - int x, - int z - ) { - double maximumIncision = terrain.getMaxIncision(); - if (blockingRoutingPossible || variableMaxIncisionPossible) { - EffectiveRiverSettings settings = settingsAt(x, z); - if (settings.routingPolicy() == IrisRiverRoutingPolicy.BLOCK) { - return false; - } - maximumIncision *= settings.maxIncisionMultiplier(); - } - double head = configuration.getWater().getMode() == IrisRiverWaterMode.FIXED - ? riverFluidHeight - : terracedWaterSurface( - context.from().hydraulicHeight(), - context.to().hydraulicHeight(), - context.polyline().length(), - alongReach - ); - int rangeDecision = boundedFeasibilityRange( - naturalHeightBounds.sample(x, z), - head, - maximumIncision - ); - if (rangeDecision == FEASIBILITY_ACCEPT) { - return true; - } - if (rangeDecision == FEASIBILITY_REJECT) { - return false; - } - double sampledNaturalHeight = naturalHeight(x, z); - int boundedDecision = boundedFeasibility( - sampledNaturalHeight, - head, - maximumIncision, - depth, - terrain.getBedRoughness()); - if (boundedDecision == FEASIBILITY_ACCEPT) { - return true; - } - if (boundedDecision == FEASIBILITY_REJECT) { - return false; - } - maximumIncision = Math.max(0D, maximumIncision); - double bedHeight = head - depth + bedRoughness(x, z); - double finalHeight = incisedHeight(sampledNaturalHeight, bedHeight, 1D, maximumIncision); - return Math.round(finalHeight) < Math.round(head); - } - - @Override - public double reachRoutingCost(RiverRoutingContext context) { - return routingCost(context.midpointX(), context.midpointZ()); - } - - @Override - public double flowNoise(double x, double z) { - return routingNoise.fitDouble(-1D, 1D, x, z); - } - - @Override - public double channelWidth(RiverRoutingContext context, double fallback) { - EffectiveRiverSettings settings = settingsAt(context.midpointX(), context.midpointZ()); - return styled(terrain.getChannelWidth(), widthNoise, context.midpointX(), context.midpointZ(), fallback) - * settings.widthMultiplier(); - } - - @Override - public double channelWidth( - RiverRoutingContext context, - double x, - double z, - double fallback - ) { - EffectiveRiverSettings settings = settingsAt(x, z); - return styled( - terrain.getChannelWidth(), - widthNoise, - (int) StrictMath.round(x), - (int) StrictMath.round(z), - fallback - ) - * settings.widthMultiplier(); - } - - @Override - public double bankWidth(RiverRoutingContext context, double fallback) { - EffectiveRiverSettings settings = settingsAt(context.midpointX(), context.midpointZ()); - return styled(terrain.getBankWidth(), bankNoise, context.midpointX(), context.midpointZ(), fallback) - * settings.bankWidthMultiplier(); - } - - @Override - public double bankWidth(RiverRoutingContext context, double x, double z, double fallback) { - EffectiveRiverSettings settings = settingsAt(x, z); - return styled( - terrain.getBankWidth(), - bankNoise, - (int) StrictMath.round(x), - (int) StrictMath.round(z), - fallback - ) * settings.bankWidthMultiplier(); - } - - @Override - public double depth(RiverRoutingContext context, double fallback) { - EffectiveRiverSettings settings = settingsAt(context.midpointX(), context.midpointZ()); - double configuredDepth = styled( - terrain.getDepth(), - depthNoise, - context.midpointX(), - context.midpointZ(), - fallback - ) * settings.depthMultiplier(); - return Math.min( - terrain.getMaxDepth(), - Math.max(1D + terrain.getBedRoughness(), configuredDepth) - ); - } - - @Override - public double depth(RiverRoutingContext context, double x, double z, double fallback) { - EffectiveRiverSettings settings = settingsAt(x, z); - double configuredDepth = styled( - terrain.getDepth(), - depthNoise, - (int) StrictMath.round(x), - (int) StrictMath.round(z), - fallback - ) * settings.depthMultiplier(); - return Math.min( - terrain.getMaxDepth(), - Math.max(1D + terrain.getBedRoughness(), configuredDepth) - ); - } - - @Override - public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { - EffectiveRiverSettings settings = settingsAt(blockX, blockZ); - if (settings.terminalMode() == IrisRiverTerminalMode.SINKHOLE_GROTTO) { - return caveHydrologyActive ? RiverTerminalPolicy.WET : RiverTerminalPolicy.SUPPRESS; - } - if (!topology.isRequireOcean() && !settings.terminalModeOverridden()) { - return RiverTerminalPolicy.WET; - } - return switch (settings.terminalMode()) { - case SINKHOLE_GROTTO -> caveHydrologyActive - ? RiverTerminalPolicy.WET - : RiverTerminalPolicy.SUPPRESS; - case DRY_CHANNEL -> RiverTerminalPolicy.DRY; - case SUPPRESS -> RiverTerminalPolicy.SUPPRESS; - }; - } - - private double routingCost(int blockX, int blockZ, EffectiveRiverSettings settings) { - double noiseCost = routingNoise.fitDouble(0D, topology.getRoutingNoiseWeight(), blockX, blockZ); - double slopeWeight = topology.getTerrainSlopeWeight(); - double slopeCost = slopeWeight <= 0D - ? 0D - : Math.max(0D, naturalSlope.get(blockX, blockZ)) * slopeWeight; - double avoidance = settings.routingPolicy() == IrisRiverRoutingPolicy.AVOID - ? topology.getRoutingNoiseWeight() + topology.getOceanAttraction() + 64D - : 0D; - return (noiseCost + slopeCost + avoidance) * settings.routingCostMultiplier(); - } - - private boolean incisionGate(RiverRoutingContext context) { - IrisRiverNoiseChance configured = terrain.getIncision(); - double chance = effectiveChance( - configured, - incisionNoise, - context.midpointX(), - context.midpointZ() - ); - long hash = art.arcane.iris.engine.river.RiverNetwork.mix( - seed ^ context.edgeId().stableId() ^ INCISION_GATE_SALT - ); - return unit(hash) < chance; - } - - } - - static int boundedFeasibility( - double naturalHeight, - double head, - double maximumIncision, - double depth, - double maximumBedRoughness - ) { - long roundedHead = Math.round(head); - if (Math.round(naturalHeight) < roundedHead) { - return FEASIBILITY_ACCEPT; - } - double clampedIncision = Math.max(0D, maximumIncision); - if (Math.round(naturalHeight - clampedIncision) >= roundedHead) { - return FEASIBILITY_REJECT; - } - double maximumBedHeight = head - depth + Math.abs(maximumBedRoughness); - return Math.round(maximumBedHeight) < roundedHead - ? FEASIBILITY_ACCEPT - : FEASIBILITY_SAMPLE_BED; - } - - static int boundedFeasibilityRange( - NoiseBounds bounds, - double head, - double maximumIncision - ) { - double minimum = Math.min(bounds.min(), bounds.max()); - double maximum = Math.max(bounds.min(), bounds.max()); - long roundedHead = Math.round(head); - if (Math.round(maximum) < roundedHead) { - return FEASIBILITY_ACCEPT; - } - if (Math.round(minimum - Math.max(0D, maximumIncision)) >= roundedHead) { - return FEASIBILITY_REJECT; - } - return FEASIBILITY_SAMPLE_BED; - } - - private record ResolvedRiverColumn( - RiverSample river, - RiverReach reach, - double naturalHeight, - double waterSurfaceY, - double bedHeight, - double maximumIncision, - boolean subterranean - ) { - } - - private record CenterlinePosition(double x, double z) { - } - - private record PoolSelection(List keys, InferredType type) { - } - - private record TerminalPosition(double x, double z, double alongReach) { - } - - private record TerminalCaveAnchor(long stableId, double x, double z, double alongReach) { - } - - private record BiomePoolKey(List keys, InferredType type) { - private BiomePoolKey { - keys = List.copyOf(keys); - Objects.requireNonNull(type); - } - } - - private static final class IdentitySettingsKey { - private final IrisRegion region; - private final IrisBiome biome; - private final int hash; - - private IdentitySettingsKey(IrisRegion region, IrisBiome biome) { - this.region = region; - this.biome = biome; - hash = 31 * System.identityHashCode(region) + System.identityHashCode(biome); - } - - @Override - public boolean equals(Object other) { - return other instanceof IdentitySettingsKey key - && key.region == region - && key.biome == biome; - } - - @Override - public int hashCode() { - return hash; - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java deleted file mode 100644 index 27a1e69a5..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeContext.java +++ /dev/null @@ -1,39 +0,0 @@ -package art.arcane.iris.engine.river.runtime; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisRiverNetwork; -import art.arcane.iris.util.project.stream.ProceduralStream; - -import java.util.Objects; - -public record IrisRiverRuntimeContext( - long seed, - IrisRiverNetwork configuration, - IrisData data, - int riverFluidHeight, - int dimensionFluidHeight, - boolean boreMantleActive, - boolean caveHydrologyActive, - boolean blockingRoutingPossible, - boolean variableMaxIncisionPossible, - boolean biomeRiverOverridesPossible, - RiverHeightBoundsSampler naturalHeightBounds, - ProceduralStream naturalHeight, - ProceduralStream naturalSlope, - ProceduralStream naturalOcean, - ProceduralStream naturalBiome, - ProceduralStream region -) { - public IrisRiverRuntimeContext { - Objects.requireNonNull(configuration); - Objects.requireNonNull(data); - Objects.requireNonNull(naturalHeightBounds); - Objects.requireNonNull(naturalHeight); - Objects.requireNonNull(naturalSlope); - Objects.requireNonNull(naturalOcean); - Objects.requireNonNull(naturalBiome); - Objects.requireNonNull(region); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverSurfaceSample.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverSurfaceSample.java deleted file mode 100644 index 3c14e5a9f..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverSurfaceSample.java +++ /dev/null @@ -1,33 +0,0 @@ -package art.arcane.iris.engine.river.runtime; - -import art.arcane.iris.engine.river.RiverSample; - -import java.util.Objects; - -public record IrisRiverSurfaceSample( - RiverSample river, - double naturalHeight, - double terrainHeight, - double waterSurfaceY, - boolean subterranean, - boolean surfaceFluid -) { - public IrisRiverSurfaceSample { - Objects.requireNonNull(river); - if (!Double.isFinite(naturalHeight) || !Double.isFinite(terrainHeight) - || !Double.isFinite(waterSurfaceY)) { - throw new IllegalArgumentException("River surface values must be finite"); - } - } - - public static IrisRiverSurfaceSample none(double naturalHeight, double naturalWaterSurfaceY) { - return new IrisRiverSurfaceSample( - RiverSample.none(), - naturalHeight, - naturalHeight, - naturalWaterSurfaceY, - false, - Math.round(naturalHeight) < Math.round(naturalWaterSurfaceY) - ); - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverTunnelSample.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverTunnelSample.java deleted file mode 100644 index 9019cc944..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/IrisRiverTunnelSample.java +++ /dev/null @@ -1,22 +0,0 @@ -package art.arcane.iris.engine.river.runtime; - -import art.arcane.iris.engine.river.RiverSample; - -import java.util.Objects; - -public record IrisRiverTunnelSample( - RiverSample river, - int bedY, - int waterHeadY, - int ceilingY -) { - public IrisRiverTunnelSample { - Objects.requireNonNull(river); - if (!river.present()) { - throw new IllegalArgumentException("A river tunnel sample requires a present river"); - } - if (bedY >= waterHeadY || ceilingY < waterHeadY) { - throw new IllegalArgumentException("River tunnel vertical bounds are invalid"); - } - } -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverHeightBoundsSampler.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverHeightBoundsSampler.java deleted file mode 100644 index 803a91f70..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverHeightBoundsSampler.java +++ /dev/null @@ -1,8 +0,0 @@ -package art.arcane.iris.engine.river.runtime; - -import art.arcane.iris.util.project.interpolation.NoiseBounds; - -@FunctionalInterface -public interface RiverHeightBoundsSampler { - NoiseBounds sample(int blockX, int blockZ); -} diff --git a/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverPolylineProbe.java b/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverPolylineProbe.java deleted file mode 100644 index 028e14f7d..000000000 --- a/core/src/main/java/art/arcane/iris/engine/river/runtime/RiverPolylineProbe.java +++ /dev/null @@ -1,56 +0,0 @@ -package art.arcane.iris.engine.river.runtime; - -import art.arcane.iris.engine.river.RiverPolyline; - -import java.util.Objects; - -final class RiverPolylineProbe { - private RiverPolylineProbe() { - } - - static boolean all(RiverPolyline polyline, int maximumSamples, CellPredicate predicate) { - Objects.requireNonNull(polyline); - Objects.requireNonNull(predicate); - if (maximumSamples < 2) { - throw new IllegalArgumentException("River polyline probing requires at least two samples"); - } - double totalLength = polyline.length(); - int desiredSamples = totalLength >= Integer.MAX_VALUE - ? Integer.MAX_VALUE - : (int) StrictMath.ceil(totalLength) + 1; - int sampleCount = StrictMath.max(2, StrictMath.min(maximumSamples, desiredSamples)); - int segment = 0; - for (int sample = 0; sample < sampleCount; sample++) { - double alongReach = (double) sample / (sampleCount - 1); - double targetDistance = totalLength * alongReach; - while (segment < polyline.size() - 2 - && targetDistance > polyline.cumulativeLength(segment + 1)) { - segment++; - } - double segmentStart = polyline.cumulativeLength(segment); - double segmentEnd = polyline.cumulativeLength(segment + 1); - double segmentLength = segmentEnd - segmentStart; - double factor = segmentLength <= 0D ? 0D : (targetDistance - segmentStart) / segmentLength; - double x = polyline.x(segment) - + (polyline.x(segment + 1) - polyline.x(segment)) * factor; - double z = polyline.z(segment) - + (polyline.z(segment + 1) - polyline.z(segment)) * factor; - if (!predicate.test(clampRound(x), clampRound(z), alongReach)) { - return false; - } - } - return true; - } - - private static int clampRound(double value) { - return (int) StrictMath.max( - Integer.MIN_VALUE, - StrictMath.min(Integer.MAX_VALUE, StrictMath.round(value)) - ); - } - - @FunctionalInterface - interface CellPredicate { - boolean test(int blockX, int blockZ, double alongReach); - } -} diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/HydrologyTypeHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/HydrologyTypeHandler.java new file mode 100644 index 000000000..40bafd240 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/HydrologyTypeHandler.java @@ -0,0 +1,53 @@ +package art.arcane.iris.util.common.director.specialhandlers; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.util.common.director.DirectorParameterHandler; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; + +import java.util.List; + +public final class HydrologyTypeHandler implements DirectorParameterHandler { + @Override + public KList getPossibilities() { + Engine activeEngine = engine(); + if (activeEngine == null) { + return new KList<>(HydrologyFeatureQuery.suggestions(List.of())); + } + IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime(); + return new KList<>(runtime == null + ? HydrologyFeatureQuery.suggestions(List.of()) + : runtime.featureQueryKeys()); + } + + @Override + public String toString(String value) { + return value == null ? "" : value; + } + + @Override + public String parse(String input, boolean force) throws DirectorParsingException { + String normalized = input == null ? "" : input.trim(); + if (normalized.isEmpty()) { + throw new DirectorParsingException("Hydrology feature type must not be blank."); + } + for (String possibility : getPossibilities(normalized)) { + if (possibility.equalsIgnoreCase(normalized)) { + return possibility; + } + } + return normalized; + } + + @Override + public boolean supports(Class type) { + return type == String.class; + } + + @Override + public String getRandomDefault() { + return "surface"; + } +} diff --git a/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandler.java b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandler.java new file mode 100644 index 000000000..bae1c4628 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandler.java @@ -0,0 +1,24 @@ +package art.arcane.iris.util.common.director.specialhandlers; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.volmlib.util.collection.KList; + +public class ReachableBiomeHandler extends RegistrantHandler { + public ReachableBiomeHandler() { + super(IrisBiome.class, true); + } + + @Override + public KList getPossibilities() { + Engine activeEngine = engine(); + return activeEngine == null + ? super.getPossibilities() + : activeEngine.getAllBiomes(); + } + + @Override + public String getRandomDefault() { + return "biome"; + } +} diff --git a/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java b/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java index 66dd864ee..3f90ca276 100644 --- a/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java +++ b/core/src/main/java/art/arcane/iris/util/project/context/ChunkContext.java @@ -3,6 +3,7 @@ package art.arcane.iris.util.project.context; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.EngineMetrics; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.FloatingIslandBoundarySampler; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; @@ -61,6 +62,10 @@ public class ChunkContext { PrefillPlan resolvedPlan = prefillPlan == null ? PrefillPlan.NO_CAVE : prefillPlan; boolean capturePrefillMetric = metrics != null; long totalStartNanos = capturePrefillMetric ? System.nanoTime() : 0L; + IrisHydrologyRuntime hydrologyRuntime = complex.getHydrologyRuntime(); + if (hydrologyRuntime != null && (resolvedPlan.height || resolvedPlan.biome)) { + hydrologyRuntime.prepareChunkColumns(x, z); + } List fillTasks = new ArrayList<>(6); if (resolvedPlan.height) { fillTasks.add(() -> height.fillRounded(roundedHeight)); diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java b/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java index 187f38498..9d6b72e15 100644 --- a/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java +++ b/core/src/main/java/art/arcane/iris/util/project/matter/IrisMatterSupport.java @@ -25,7 +25,8 @@ import art.arcane.iris.util.project.matter.slices.EntityMatter; import art.arcane.iris.util.project.matter.slices.IdentifierMatter; import art.arcane.iris.util.project.matter.slices.NativeStructureOwnershipMatter; import art.arcane.iris.util.project.matter.slices.PlatformBlockMatter; -import art.arcane.iris.util.project.matter.slices.RiverCaveHydrologyMatter; +import art.arcane.iris.util.project.matter.slices.HydrologyCaveMatter; +import art.arcane.iris.util.project.matter.slices.PreObjectMatter; import art.arcane.iris.util.project.matter.slices.SpawnerMatter; import art.arcane.iris.util.project.matter.slices.TileMatter; import art.arcane.iris.util.project.matter.slices.TreeBlockMaterialMatter; @@ -65,7 +66,8 @@ public final class IrisMatterSupport { IrisMatter.registerSliceType(new IdentifierMatter()); IrisMatter.registerSliceType(new NativeStructureOwnershipMatter()); IrisMatter.registerSliceType(new PlatformBlockMatter()); - IrisMatter.registerSliceType(new RiverCaveHydrologyMatter()); + IrisMatter.registerSliceType(new HydrologyCaveMatter()); + IrisMatter.registerSliceType(new PreObjectMatter()); IrisMatter.registerSliceType(new SpawnerMatter()); IrisMatter.registerSliceType(new TileMatter()); IrisMatter.registerSliceType(new TreeBlockMaterialMatter()); diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/PreObjectMatterCell.java b/core/src/main/java/art/arcane/iris/util/project/matter/PreObjectMatterCell.java new file mode 100644 index 000000000..2972c7413 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/project/matter/PreObjectMatterCell.java @@ -0,0 +1,88 @@ +package art.arcane.iris.util.project.matter; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.matter.MatterCavern; + +public record PreObjectMatterCell( + boolean blockCaptured, + PlatformBlockState block, + boolean stringCaptured, + String string, + boolean cavernCaptured, + MatterCavern cavern +) { + public PreObjectMatterCell { + if (!blockCaptured && block != null) { + throw new IllegalArgumentException("An uncaptured block cannot have a value"); + } + if (!stringCaptured && string != null) { + throw new IllegalArgumentException("An uncaptured string cannot have a value"); + } + if (!cavernCaptured && cavern != null) { + throw new IllegalArgumentException("An uncaptured cavern cannot have a value"); + } + if (!blockCaptured && !stringCaptured && !cavernCaptured) { + throw new IllegalArgumentException("A pre-object cell must capture at least one value"); + } + } + + public static PreObjectMatterCell block(PlatformBlockState value) { + return new PreObjectMatterCell(true, value, false, null, false, null); + } + + public static PreObjectMatterCell string(String value) { + return new PreObjectMatterCell(false, null, true, value, false, null); + } + + public static PreObjectMatterCell cavern(MatterCavern value) { + return new PreObjectMatterCell(false, null, false, null, true, value); + } + + public PreObjectMatterCell captureBlock(PlatformBlockState value) { + if (blockCaptured) { + return this; + } + return new PreObjectMatterCell(true, value, stringCaptured, string, cavernCaptured, cavern); + } + + public PreObjectMatterCell captureString(String value) { + if (stringCaptured) { + return this; + } + return new PreObjectMatterCell(blockCaptured, block, true, value, cavernCaptured, cavern); + } + + public PreObjectMatterCell captureCavern(MatterCavern value) { + if (cavernCaptured) { + return this; + } + return new PreObjectMatterCell(blockCaptured, block, stringCaptured, string, true, value); + } + + public boolean captures(Class type) { + if (type == PlatformBlockState.class) { + return blockCaptured; + } + if (type == String.class) { + return stringCaptured; + } + if (type == MatterCavern.class) { + return cavernCaptured; + } + return false; + } + + @SuppressWarnings("unchecked") + public T original(Class type) { + if (!captures(type)) { + throw new IllegalArgumentException("Unsupported or uncaptured pre-object type " + type.getCanonicalName()); + } + if (type == PlatformBlockState.class) { + return (T) block; + } + if (type == String.class) { + return (T) string; + } + return (T) cavern; + } +} diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/slices/HydrologyCaveMatter.java b/core/src/main/java/art/arcane/iris/util/project/matter/slices/HydrologyCaveMatter.java new file mode 100644 index 000000000..238c03791 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/project/matter/slices/HydrologyCaveMatter.java @@ -0,0 +1,59 @@ +package art.arcane.iris.util.project.matter.slices; + +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.volmlib.util.data.palette.Palette; +import art.arcane.volmlib.util.matter.Sliced; +import art.arcane.volmlib.util.matter.slices.RawMatter; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +@Sliced +public final class HydrologyCaveMatter extends RawMatter { + public HydrologyCaveMatter() { + this(1, 1, 1); + } + + public HydrologyCaveMatter(int width, int height, int depth) { + super(width, height, depth, HydrologyCaveCell.class); + } + + @Override + public Palette getGlobalPalette() { + return null; + } + + @Override + public void writeNode(HydrologyCaveCell hydrology, DataOutputStream output) throws IOException { + output.writeByte(actionCode(hydrology.action())); + output.writeUTF(hydrology.fluidProfileKey()); + output.writeUTF(hydrology.floodedBiomeKey()); + } + + @Override + public HydrologyCaveCell readNode(DataInputStream input) throws IOException { + HydrologyCaveAction action = actionFromCode(input.readUnsignedByte()); + return new HydrologyCaveCell(action, input.readUTF(), input.readUTF()); + } + + private int actionCode(HydrologyCaveAction action) { + return switch (action) { + case WET_SOURCE -> 1; + case FALLING_FLUID -> 2; + case DRY_AIR -> 3; + case SEAL_GUARD -> 4; + }; + } + + private HydrologyCaveAction actionFromCode(int code) throws IOException { + return switch (code) { + case 1 -> HydrologyCaveAction.WET_SOURCE; + case 2 -> HydrologyCaveAction.FALLING_FLUID; + case 3 -> HydrologyCaveAction.DRY_AIR; + case 4 -> HydrologyCaveAction.SEAL_GUARD; + default -> throw new IOException("Unknown hydrology cave action code " + code); + }; + } +} diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/slices/PreObjectMatter.java b/core/src/main/java/art/arcane/iris/util/project/matter/slices/PreObjectMatter.java new file mode 100644 index 000000000..4b25e18c2 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/project/matter/slices/PreObjectMatter.java @@ -0,0 +1,134 @@ +package art.arcane.iris.util.project.matter.slices; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.common.data.B; +import art.arcane.iris.util.project.matter.PreObjectMatterCell; +import art.arcane.volmlib.util.data.palette.Palette; +import art.arcane.volmlib.util.hunk.storage.MappedHunk; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterReader; +import art.arcane.volmlib.util.matter.MatterSlice; +import art.arcane.volmlib.util.matter.MatterWriter; +import art.arcane.volmlib.util.matter.Sliced; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +@Sliced +public final class PreObjectMatter extends MappedHunk + implements MatterSlice { + private static final int BLOCK_CAPTURED = 1; + private static final int BLOCK_PRESENT = 1 << 1; + private static final int STRING_CAPTURED = 1 << 2; + private static final int STRING_PRESENT = 1 << 3; + private static final int CAVERN_CAPTURED = 1 << 4; + private static final int CAVERN_PRESENT = 1 << 5; + + public PreObjectMatter() { + this(1, 1, 1); + } + + public PreObjectMatter(int width, int height, int depth) { + super(width, height, depth); + } + + @Override + public Class getType() { + return PreObjectMatterCell.class; + } + + @Override + public Palette getGlobalPalette() { + return null; + } + + @Override + public MatterWriter writeInto(Class mediumType) { + return null; + } + + @Override + public MatterReader readFrom(Class mediumType) { + return null; + } + + @Override + public void writeNode(PreObjectMatterCell cell, DataOutputStream output) throws IOException { + int flags = flags(cell); + output.writeByte(flags); + if ((flags & BLOCK_PRESENT) != 0) { + output.writeUTF(cell.block().key()); + } + if ((flags & STRING_PRESENT) != 0) { + output.writeUTF(cell.string()); + } + if ((flags & CAVERN_PRESENT) != 0) { + output.writeBoolean(cell.cavern().isCavern()); + output.writeUTF(cell.cavern().getCustomBiome()); + output.writeByte(cell.cavern().getLiquid()); + } + } + + @Override + public PreObjectMatterCell readNode(DataInputStream input) throws IOException { + int flags = input.readUnsignedByte(); + validateFlags(flags); + PlatformBlockState block = (flags & BLOCK_PRESENT) == 0 ? null : B.getState(input.readUTF()); + String string = (flags & STRING_PRESENT) == 0 ? null : input.readUTF(); + MatterCavern cavern = (flags & CAVERN_PRESENT) == 0 + ? null + : new MatterCavern(input.readBoolean(), input.readUTF(), input.readByte()); + return new PreObjectMatterCell( + (flags & BLOCK_CAPTURED) != 0, + block, + (flags & STRING_CAPTURED) != 0, + string, + (flags & CAVERN_CAPTURED) != 0, + cavern + ); + } + + private int flags(PreObjectMatterCell cell) { + int flags = 0; + if (cell.blockCaptured()) { + flags |= BLOCK_CAPTURED; + if (cell.block() != null) { + flags |= BLOCK_PRESENT; + } + } + if (cell.stringCaptured()) { + flags |= STRING_CAPTURED; + if (cell.string() != null) { + flags |= STRING_PRESENT; + } + } + if (cell.cavernCaptured()) { + flags |= CAVERN_CAPTURED; + if (cell.cavern() != null) { + flags |= CAVERN_PRESENT; + } + } + return flags; + } + + private void validateFlags(int flags) throws IOException { + int knownFlags = BLOCK_CAPTURED | BLOCK_PRESENT | STRING_CAPTURED | STRING_PRESENT + | CAVERN_CAPTURED | CAVERN_PRESENT; + if ((flags & ~knownFlags) != 0) { + throw new IOException("Unknown pre-object matter flags " + flags); + } + if ((flags & (BLOCK_CAPTURED | STRING_CAPTURED | CAVERN_CAPTURED)) == 0) { + throw new IOException("Pre-object matter cell captures no values"); + } + if ((flags & BLOCK_PRESENT) != 0 && (flags & BLOCK_CAPTURED) == 0) { + throw new IOException("Pre-object block value is not captured"); + } + if ((flags & STRING_PRESENT) != 0 && (flags & STRING_CAPTURED) == 0) { + throw new IOException("Pre-object string value is not captured"); + } + if ((flags & CAVERN_PRESENT) != 0 && (flags & CAVERN_CAPTURED) == 0) { + throw new IOException("Pre-object cavern value is not captured"); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java b/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java deleted file mode 100644 index d18492c5b..000000000 --- a/core/src/main/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatter.java +++ /dev/null @@ -1,76 +0,0 @@ -package art.arcane.iris.util.project.matter.slices; - -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; -import art.arcane.volmlib.util.data.palette.Palette; -import art.arcane.volmlib.util.matter.Sliced; -import art.arcane.volmlib.util.matter.slices.RawMatter; - -import java.io.DataInputStream; -import java.io.DataOutputStream; -import java.io.IOException; - -@Sliced -public final class RiverCaveHydrologyMatter extends RawMatter { - public RiverCaveHydrologyMatter() { - this(1, 1, 1); - } - - public RiverCaveHydrologyMatter(int width, int height, int depth) { - super(width, height, depth, RiverCaveHydrology.class); - } - - @Override - public Palette getGlobalPalette() { - return null; - } - - @Override - public void writeNode(RiverCaveHydrology hydrology, DataOutputStream output) throws IOException { - output.writeByte(actionCode(hydrology.action())); - output.writeByte(fluidKindCode(hydrology.fluidKind())); - output.writeUTF(hydrology.floodedBiomeKey()); - } - - @Override - public RiverCaveHydrology readNode(DataInputStream input) throws IOException { - RiverCaveAction action = actionFromCode(input.readUnsignedByte()); - RiverCaveFluidKind fluidKind = fluidKindFromCode(input.readUnsignedByte()); - return new RiverCaveHydrology(action, input.readUTF(), fluidKind); - } - - private int actionCode(RiverCaveAction action) { - return switch (action) { - case WET_SOURCE -> 1; - case FALLING_FLUID -> 2; - case DRY_AIR -> 3; - case SEAL_GUARD -> 4; - }; - } - - private RiverCaveAction actionFromCode(int code) throws IOException { - return switch (code) { - case 1 -> RiverCaveAction.WET_SOURCE; - case 2 -> RiverCaveAction.FALLING_FLUID; - case 3 -> RiverCaveAction.DRY_AIR; - case 4 -> RiverCaveAction.SEAL_GUARD; - default -> throw new IOException("Unknown river cave hydrology action code " + code); - }; - } - - private int fluidKindCode(RiverCaveFluidKind fluidKind) { - return switch (fluidKind) { - case RIVER -> 1; - case DEEP_POOL -> 2; - }; - } - - private RiverCaveFluidKind fluidKindFromCode(int code) throws IOException { - return switch (code) { - case 1 -> RiverCaveFluidKind.RIVER; - case 2 -> RiverCaveFluidKind.DEEP_POOL; - default -> throw new IOException("Unknown river cave fluid-kind code " + code); - }; - } -} diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStream.java b/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStream.java index d0c9567f5..f0d652c7e 100644 --- a/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStream.java +++ b/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStream.java @@ -72,9 +72,18 @@ import art.arcane.iris.util.project.stream.utility.SynchronizedStream; import java.util.ArrayList; import java.util.List; import java.util.function.Function; +import java.util.function.Supplier; @SuppressWarnings("ALL") public interface ProceduralStream extends ProceduralLayer, Interpolated { + static V bypass2DCaches(Supplier operation) { + return ProceduralStreamCacheBypass.supply(operation); + } + + static boolean is2DCacheBypassActive() { + return ProceduralStreamCacheBypass.isActive(); + } + static ProceduralStream ofDouble(Function2 f) { try { return of(f, Interpolated.DOUBLE); diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStreamCacheBypass.java b/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStreamCacheBypass.java new file mode 100644 index 000000000..3fad8ab78 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/util/project/stream/ProceduralStreamCacheBypass.java @@ -0,0 +1,28 @@ +package art.arcane.iris.util.project.stream; + +import java.util.function.Supplier; + +final class ProceduralStreamCacheBypass { + private static final ThreadLocal DEPTH = new ThreadLocal<>(); + + private ProceduralStreamCacheBypass() { + } + + static boolean isActive() { + return DEPTH.get() != null; + } + + static T supply(Supplier operation) { + Integer previousDepth = DEPTH.get(); + DEPTH.set(previousDepth == null ? 1 : previousDepth + 1); + try { + return operation.get(); + } finally { + if (previousDepth == null) { + DEPTH.remove(); + } else { + DEPTH.set(previousDepth); + } + } + } +} diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedDoubleStream2D.java b/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedDoubleStream2D.java index bd7971a00..633ed3d7f 100644 --- a/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedDoubleStream2D.java +++ b/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedDoubleStream2D.java @@ -34,6 +34,9 @@ public class CachedDoubleStream2D extends BasicStream implements Procedu @Override public Double get(double x, double z) { + if (ProceduralStream.is2DCacheBypassActive()) { + return stream.getDouble((int) x, (int) z); + } return cache.get((int) x, (int) z); } @@ -44,6 +47,9 @@ public class CachedDoubleStream2D extends BasicStream implements Procedu @Override public double getDouble(double x, double z) { + if (ProceduralStream.is2DCacheBypassActive()) { + return stream.getDouble((int) x, (int) z); + } return cache.get((int) x, (int) z); } diff --git a/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream2D.java b/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream2D.java index 4c074919e..c54eb9a27 100644 --- a/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream2D.java +++ b/core/src/main/java/art/arcane/iris/util/project/stream/utility/CachedStream2D.java @@ -55,6 +55,9 @@ public class CachedStream2D extends BasicStream implements ProceduralStrea @Override public T get(double x, double z) { + if (ProceduralStream.is2DCacheBypassActive()) { + return stream.get((int) x, (int) z); + } return cache.get((int) x, (int) z); } diff --git a/core/src/main/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 297203907..c1d184202 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Vision", "iris.desktop.vision.view": "Ansicht:", "iris.desktop.vision.grid": "Raster", + "iris.desktop.vision.entities": "Entitäten", "iris.desktop.vision.follow": "Folgen", "iris.desktop.vision.refreshing": "Wird aktualisiert", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zoom-Reset", "iris.desktop.vision.grid_enabled": "Raster aktiviert", "iris.desktop.vision.grid_disabled": "Raster deaktiviert", + "iris.desktop.vision.entities_enabled": "Entitäten aktiviert", + "iris.desktop.vision.entities_disabled": "Entitäten deaktiviert", "iris.desktop.vision.following": "Folge {player}", "iris.desktop.vision.no_player": "Kein Spieler in der Welt", "iris.desktop.vision.follow_disabled": "Folgen deaktiviert", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index 5b98d090d..acf750762 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Vision", "iris.desktop.vision.view": "Vista:", "iris.desktop.vision.grid": "Cuadrícula", + "iris.desktop.vision.entities": "Entidades", "iris.desktop.vision.follow": "Seguir", "iris.desktop.vision.refreshing": "Actualizando", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zoom restablecido", "iris.desktop.vision.grid_enabled": "Cuadrícula activada", "iris.desktop.vision.grid_disabled": "Cuadrícula desactivada", + "iris.desktop.vision.entities_enabled": "Entidades activadas", + "iris.desktop.vision.entities_disabled": "Entidades desactivadas", "iris.desktop.vision.following": "Siguiendo a {player}", "iris.desktop.vision.no_player": "No hay jugadores en el mundo", "iris.desktop.vision.follow_disabled": "Seguimiento desactivado", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index 9f3f0c947..f793ecf05 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Näkö", "iris.desktop.vision.view": "Näytä:", "iris.desktop.vision.grid": "Ruudukko", + "iris.desktop.vision.entities": "Entiteetit", "iris.desktop.vision.follow": "Seuraa", "iris.desktop.vision.refreshing": "Virkistävä", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Lähennä nollaus", "iris.desktop.vision.grid_enabled": "Verkko käytössä", "iris.desktop.vision.grid_disabled": "Verkko pois käytöstä", + "iris.desktop.vision.entities_enabled": "Entiteetit käytössä", + "iris.desktop.vision.entities_disabled": "Entiteetit pois käytöstä", "iris.desktop.vision.following": "Seurataan pelaajaa {player}", "iris.desktop.vision.no_player": "Ei pelaajaa maailmassa", "iris.desktop.vision.follow_disabled": "Seuraa pois käytöstä", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 6bbaa1be8..30136d9b9 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Vision", "iris.desktop.vision.view": "Vue :", "iris.desktop.vision.grid": "Grille", + "iris.desktop.vision.entities": "Entités", "iris.desktop.vision.follow": "Suivre", "iris.desktop.vision.refreshing": "Actualisation", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zoom réinitialisé", "iris.desktop.vision.grid_enabled": "Grille activée", "iris.desktop.vision.grid_disabled": "Grille désactivée", + "iris.desktop.vision.entities_enabled": "Entités activées", + "iris.desktop.vision.entities_disabled": "Entités désactivées", "iris.desktop.vision.following": "Suivi de {player}", "iris.desktop.vision.no_player": "Aucun joueur dans le monde", "iris.desktop.vision.follow_disabled": "Suivi désactivé", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index 0c4bc61a4..6a91a0f60 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris חזון", "iris.desktop.vision.view": "תגית:", "iris.desktop.vision.grid": "גרי", + "iris.desktop.vision.entities": "ישויות", "iris.desktop.vision.follow": "עקבו אחרי עקוב", "iris.desktop.vision.refreshing": "מרעננים", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "ZOOE", "iris.desktop.vision.grid_enabled": "רשת אפשר", "iris.desktop.vision.grid_disabled": "סגור", + "iris.desktop.vision.entities_enabled": "ישויות מופעלות", + "iris.desktop.vision.entities_disabled": "ישויות מושבתות", "iris.desktop.vision.following": "עוקב אחר {player}", "iris.desktop.vision.no_player": "אין שחקן בעולם", "iris.desktop.vision.follow_disabled": "עקבו אחרי מוגבלויות", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 80e9730b4..99b902141 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Vision", "iris.desktop.vision.view": "Vedi:", "iris.desktop.vision.grid": "Griglia", + "iris.desktop.vision.entities": "Entità", "iris.desktop.vision.follow": "Segui", "iris.desktop.vision.refreshing": "Aggiornamento", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Ripristino dello zoom", "iris.desktop.vision.grid_enabled": "Griglia abilitata", "iris.desktop.vision.grid_disabled": "Griglia disattivata", + "iris.desktop.vision.entities_enabled": "Entità abilitate", + "iris.desktop.vision.entities_disabled": "Entità disabilitate", "iris.desktop.vision.following": "Stai seguendo {player}", "iris.desktop.vision.no_player": "Nessun giocatore nel mondo", "iris.desktop.vision.follow_disabled": "Segui disattivato", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index 10e7d9d22..4d9d3856d 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris ビジョン", "iris.desktop.vision.view": "表示:", "iris.desktop.vision.grid": "グリッド", + "iris.desktop.vision.entities": "エンティティ", "iris.desktop.vision.follow": "フォロー", "iris.desktop.vision.refreshing": "更新中", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "ズームリセット", "iris.desktop.vision.grid_enabled": "グリッド有効", "iris.desktop.vision.grid_disabled": "グリッド無効", + "iris.desktop.vision.entities_enabled": "エンティティ有効", + "iris.desktop.vision.entities_disabled": "エンティティ無効", "iris.desktop.vision.following": "{player} を追跡中", "iris.desktop.vision.no_player": "ワールドにプレイヤーがいません", "iris.desktop.vision.follow_disabled": "追跡無効", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index 086f3c05e..693a15fb5 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Vision", "iris.desktop.vision.view": "보기:", "iris.desktop.vision.grid": "격자", + "iris.desktop.vision.entities": "엔티티", "iris.desktop.vision.follow": "추적", "iris.desktop.vision.refreshing": "새로 고치는 중", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zoom 리셋", "iris.desktop.vision.grid_enabled": "격자 활성화", "iris.desktop.vision.grid_disabled": "격자 비활성화", + "iris.desktop.vision.entities_enabled": "엔티티 활성화", + "iris.desktop.vision.entities_disabled": "엔티티 비활성화", "iris.desktop.vision.following": "{player} 추적 중", "iris.desktop.vision.no_player": "월드에 플레이어가 없음", "iris.desktop.vision.follow_disabled": "추적 비활성화", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index 4ed1ba76f..cb808e5cf 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Regėjimas", "iris.desktop.vision.view": "Rodymas:", "iris.desktop.vision.grid": "Tinklelis", + "iris.desktop.vision.entities": "Esybės", "iris.desktop.vision.follow": "Sekti", "iris.desktop.vision.refreshing": "Atnaujinimas", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Mastelis iš naujo", "iris.desktop.vision.grid_enabled": "Tinklelis įjungtas", "iris.desktop.vision.grid_disabled": "Tinklelis išjungtas", + "iris.desktop.vision.entities_enabled": "Esybės įjungtos", + "iris.desktop.vision.entities_disabled": "Esybės išjungtos", "iris.desktop.vision.following": "Sekamas {player}", "iris.desktop.vision.no_player": "Jokio žaidėjo pasaulyje", "iris.desktop.vision.follow_disabled": "Sekti išjungtą", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index b889f0919..24ccf2568 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Gezicht", "iris.desktop.vision.view": "Beeld:", "iris.desktop.vision.grid": "Raster", + "iris.desktop.vision.entities": "Entiteiten", "iris.desktop.vision.follow": "Volgen", "iris.desktop.vision.refreshing": "Verversen", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Reset zoomen", "iris.desktop.vision.grid_enabled": "Raster ingeschakeld", "iris.desktop.vision.grid_disabled": "Raster uitgeschakeld", + "iris.desktop.vision.entities_enabled": "Entiteiten ingeschakeld", + "iris.desktop.vision.entities_disabled": "Entiteiten uitgeschakeld", "iris.desktop.vision.following": "Volgt {player}", "iris.desktop.vision.no_player": "Geen speler in de wereld", "iris.desktop.vision.follow_disabled": "Volgen uitgeschakeld", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index 75ddd814a..dd19ddfb4 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Wizja", "iris.desktop.vision.view": "Widok:", "iris.desktop.vision.grid": "Sieć", + "iris.desktop.vision.entities": "Byty", "iris.desktop.vision.follow": "Śledź", "iris.desktop.vision.refreshing": "Odświeżające", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Zmień powiększenie", "iris.desktop.vision.grid_enabled": "Siatka włączona", "iris.desktop.vision.grid_disabled": "Sieć wyłączona", + "iris.desktop.vision.entities_enabled": "Byty włączone", + "iris.desktop.vision.entities_disabled": "Byty wyłączone", "iris.desktop.vision.following": "Śledzenie gracza {player}", "iris.desktop.vision.no_player": "Brak gracza na świecie", "iris.desktop.vision.follow_disabled": "Śledź wyłączone", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index 4cc9466c6..3b53d61ae 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Visão", "iris.desktop.vision.view": "Ver:", "iris.desktop.vision.grid": "Grade", + "iris.desktop.vision.entities": "Entidades", "iris.desktop.vision.follow": "Seguir", "iris.desktop.vision.refreshing": "Atualizando", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Ampliação repor", "iris.desktop.vision.grid_enabled": "Grade habilitada", "iris.desktop.vision.grid_disabled": "Grade desabilitada", + "iris.desktop.vision.entities_enabled": "Entidades ativadas", + "iris.desktop.vision.entities_disabled": "Entidades desativadas", "iris.desktop.vision.following": "A seguir {player}", "iris.desktop.vision.no_player": "Nenhum jogador no mundo", "iris.desktop.vision.follow_disabled": "Seguir desabilitado", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index 8e8932d74..8c7e606a9 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Видение", "iris.desktop.vision.view": "Посмотреть:", "iris.desktop.vision.grid": "сетка", + "iris.desktop.vision.entities": "Сущности", "iris.desktop.vision.follow": "Следить", "iris.desktop.vision.refreshing": "освежающий", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Сброс Zoom", "iris.desktop.vision.grid_enabled": "Сетка включена", "iris.desktop.vision.grid_disabled": "Сетка отключена", + "iris.desktop.vision.entities_enabled": "Сущности включены", + "iris.desktop.vision.entities_disabled": "Сущности отключены", "iris.desktop.vision.following": "Отслеживается {player}", "iris.desktop.vision.no_player": "Нет игрока в мире", "iris.desktop.vision.follow_disabled": "Следить за инвалидами", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index debb5802a..2cf7e6b35 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Vizyon", "iris.desktop.vision.view": "Görüntüleme:", "iris.desktop.vision.grid": "Izgara", + "iris.desktop.vision.entities": "Varlıklar", "iris.desktop.vision.follow": "Takip", "iris.desktop.vision.refreshing": "Yenileme", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "yakınlaştırma reset sıfırlama", "iris.desktop.vision.grid_enabled": "Grid etkinleştirdi", "iris.desktop.vision.grid_disabled": "Izgara", + "iris.desktop.vision.entities_enabled": "Varlıklar etkin", + "iris.desktop.vision.entities_disabled": "Varlıklar devre dışı", "iris.desktop.vision.following": "{player} takip ediliyor", "iris.desktop.vision.no_player": "Hiçbir oyuncu dünyada", "iris.desktop.vision.follow_disabled": "Engellileri Takip Et", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 73a65069e..57ac04c79 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris Tầm nhìn", "iris.desktop.vision.view": "Xem:", "iris.desktop.vision.grid": "Lưới", + "iris.desktop.vision.entities": "Thực thể", "iris.desktop.vision.follow": "Theo", "iris.desktop.vision.refreshing": "Cập nhật", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "Đặt lại phóng đại", "iris.desktop.vision.grid_enabled": "Lưới đã bật", "iris.desktop.vision.grid_disabled": "Tắt Lưới", + "iris.desktop.vision.entities_enabled": "Đã bật thực thể", + "iris.desktop.vision.entities_disabled": "Đã tắt thực thể", "iris.desktop.vision.following": "Đang theo dõi {player}", "iris.desktop.vision.no_player": "Không có người chơi trên thế giới", "iris.desktop.vision.follow_disabled": "Tắt theo", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index 983026bab..463e61b8d 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris 愿景", "iris.desktop.vision.view": "视图 :", "iris.desktop.vision.grid": "网格", + "iris.desktop.vision.entities": "实体", "iris.desktop.vision.follow": "跟着", "iris.desktop.vision.refreshing": "刷新", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "缩放重置", "iris.desktop.vision.grid_enabled": "已启用网格", "iris.desktop.vision.grid_disabled": "已禁用网格", + "iris.desktop.vision.entities_enabled": "实体已启用", + "iris.desktop.vision.entities_disabled": "实体已禁用", "iris.desktop.vision.following": "跟踪 {player}", "iris.desktop.vision.no_player": "世界上没有玩家", "iris.desktop.vision.follow_disabled": "跟随已禁用", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index 7f0e835ed..8f86bf22b 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -1358,12 +1358,15 @@ "iris.desktop.vision.title": "Iris 願景", "iris.desktop.vision.view": "檢視 :", "iris.desktop.vision.grid": "網格", + "iris.desktop.vision.entities": "實體", "iris.desktop.vision.follow": "跟著", "iris.desktop.vision.refreshing": "重新整理", "iris.desktop.vision.fps": "{fps} FPS", "iris.desktop.vision.zoom_reset": "縮放重置", "iris.desktop.vision.grid_enabled": "已啟用網格", "iris.desktop.vision.grid_disabled": "已禁用網格", + "iris.desktop.vision.entities_enabled": "實體已啟用", + "iris.desktop.vision.entities_disabled": "實體已停用", "iris.desktop.vision.following": "跟蹤 {player}", "iris.desktop.vision.no_player": "世界上沒有玩家", "iris.desktop.vision.follow_disabled": "跟隨已禁用", diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java index a7ae182ac..9608d0eba 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/BukkitWorldConfigurationTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.lifecycle; +import art.arcane.iris.engine.IrisEngineMantle; import art.arcane.iris.core.WorldSlotKey; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.Rule; @@ -223,8 +224,10 @@ public class BukkitWorldConfigurationTest { public void auditFailsClosedForMantleDataWithoutAPackSnapshot() throws Exception { File configuration = temporaryFolder.newFile("audit-mantle.yml"); Path levelRoot = temporaryFolder.newFolder("audit-mantle-level-root").toPath(); - Files.createDirectories(levelRoot.resolve("dimensions/iris/mantled/mantle")); - Files.writeString(levelRoot.resolve("dimensions/iris/mantled/mantle/0.0.ttp"), "mantle"); + Path mantleRoot = levelRoot.resolve("dimensions/iris/mantled") + .resolve(IrisEngineMantle.STORAGE_FOLDER_NAME); + Files.createDirectories(mantleRoot); + Files.writeString(mantleRoot.resolve("0.0.ttp"), "mantle"); YamlConfiguration yaml = new YamlConfiguration(); yaml.set("worlds.world_iris_mantled.generator", "Iris:overworld"); yaml.save(configuration); diff --git a/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java b/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java index 6cc517f79..94d783959 100644 --- a/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java +++ b/core/src/test/java/art/arcane/iris/core/lifecycle/HuskWorldQuarantineTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.core.lifecycle; +import art.arcane.iris.engine.IrisEngineMantle; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; @@ -83,7 +84,7 @@ public class HuskWorldQuarantineTest { public void aWorldWithMantleDataIsLeftForTheFailClosedGuard() throws IOException { Path levelRoot = level("mantle"); Path world = dimensionRoot(levelRoot, "irisworld"); - write(world.resolve("mantle/pv.0.ttp.lz4b")); + write(world.resolve(IrisEngineMantle.STORAGE_FOLDER_NAME).resolve("pv.0.ttp.lz4b")); assertQuarantinesNothing(levelRoot, world); } diff --git a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java index a96c6ac92..477334d61 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/DefaultPackBootstrapProvisionerTest.java @@ -458,7 +458,7 @@ public class DefaultPackBootstrapProvisionerTest { } @Test - public void betaPacksUpdateIndependentlyAndRecompileOneAggregateDatapack() throws Exception { + public void releasePacksUpdateIndependentlyAndRecompileOneAggregateDatapack() throws Exception { byte[] overworld = packArchive("overworld", "overworld_first"); byte[] underworldFirst = underworldArchive("underworld_first"); AtomicInteger requests = new AtomicInteger(); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java index e34d6ca2e..7bfab5029 100644 --- a/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java +++ b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java @@ -105,9 +105,9 @@ public class PackDownloaderTest { } @Test - public void resolvesDefaultOverworldBetaRelease() { + public void resolvesDefaultOverworldStableRelease() { assertEquals( - "https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip", + "https://github.com/IrisDimensions/overworld/releases/download/4002/overworld.zip", PackDownloader.builtInPackUrl("overworld") ); assertTrue(PackDownloader.isDefaultOverworld("overworld")); @@ -116,9 +116,9 @@ public class PackDownloaderTest { } @Test - public void resolvesUnderworldBetaRelease() { + public void resolvesUnderworldStableRelease() { assertEquals( - "https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip", + "https://github.com/IrisDimensions/underworld/releases/download/1005/underworld.zip", PackDownloader.builtInPackUrl("underworld") ); assertTrue(PackDownloader.isBuiltInPack("underworld")); @@ -659,8 +659,8 @@ public class PackDownloaderTest { Files.writeString( extracted.toPath().resolve("dimensions/overworld.json"), "{\"name\":\"Overworld\",\"regions\":[\"local\"],\"logicalHeight\":256," - + "\"dimensionHeight\":{\"min\":-64,\"max\":320},\"rivers\":{\"enabled\":true," - + "\"terrain\":{},\"water\":{\"mode\":\"SEA_LEVEL\"}}}", + + "\"dimensionHeight\":{\"min\":-64,\"max\":320},\"hydrology\":{\"rivers\":{" + + "\"enabled\":true,\"routing\":{\"tileSize\":255}}}}", StandardCharsets.UTF_8 ); diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java new file mode 100644 index 000000000..d8a161af4 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java @@ -0,0 +1,891 @@ +package art.arcane.iris.core.pack; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PackHydrologyValidatorTest { + @Rule + public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void acceptsCanonicalHydrologyAndRecursivePolicyClosure() throws Exception { + File pack = pack(canonicalDimension()); + write(pack, "regions/region.json", """ + { + "landBiomes": ["biome"], + "seaBiomes": [], + "shoreBiomes": [], + "caveBiomes": [], + "riverPolicy": { + "placement": "NATURAL", + "profiles": ["default"], + "surfaceBiomes": ["river_surface"] + } + } + """); + write(pack, "biomes/biome.json", """ + { + "name": "Biome", + "derivative": "minecraft:plains", + "riverPolicy": { + "routing": "PREFER", + "outletAdmission": null, + "shoreBiomes": ["river_shore"] + } + } + """); + write(pack, "biomes/river_surface.json", """ + { + "name": "Surface", + "derivative": "minecraft:river", + "riverPolicy": { + "profiles": ["default"], + "dryBiomes": ["river_dry"] + } + } + """); + write(pack, "biomes/river_shore.json", + "{\"name\":\"Shore\",\"derivative\":\"minecraft:river\"}"); + write(pack, "biomes/river_dry.json", + "{\"name\":\"Dry\",\"derivative\":\"minecraft:plains\"}"); + + PackHydrologyValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().isEmpty()); + assertTrue(result.warnings().toString(), result.warnings().isEmpty()); + } + + @Test + public void rejectsRoutingGeometryHydraulicComplexityAndCapabilityFailures() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -64, "max": 128}, + "useMantle": false, + "carvingEnabled": false, + "disabledComponents": ["CARVED", "RIVER_HYDROLOGY"], + "hydrology": { + "rivers": { + "enabled": true, + "routing": { + "tileSize": 8192, + "sampleSpacing": 8, + "refinementSpacing": 3, + "branching": { + "minimumSurfaceCourseLength": 32769, + "minimumUndergroundCourseLength": 32769 + }, + "maximumRouteLength": 32768, + "oceanOutlets": false, + "inlandOutlets": [] + }, + "geometry": { + "meanders": { + "primaryWavelength": 7, + "detailWavelength": 129, + "maximumTurnDegrees": 151 + }, + "surface": { + "bedRoundness": 7, + "bedRoughness": -0.1, + "wallRoughness": 1.1, + "roughnessWavelength": 2 + }, + "drops": { + "cascadeRunPerBlock": 17, + "cascadeExponent": 0.2, + "maximumCascadeStep": 5, + "flowWidthRatio": 0.2, + "maximumFlowDepth": 17, + "basinWidthRatio": 4.1, + "maximumBasinDepth": 33 + } + }, + "surface": { + "sources": {"density": 64, "minimumElevation": 128, "minimumPerTile": 64, + "minimumSpacing": 8193}, + "channel": { + "width": {"min": 20, "max": 2}, + "depth": {"min": 2, "max": 5}, + "surfaceInset": {"min": 0, "max": 65}, + "maximumIncision": 65, + "shoreWidth": 3, + "terrainBlendWidth": {"min": 3, "max": 65} + }, + "hydraulics": { + "targetPoolLength": {"min": 8, "max": 32}, + "riffleDrop": 5, + "maximumGradualDrop": 3, + "maximumGradualLength": 2, + "waterfallMinimumDrop": 8 + }, + "ridgeTunnels": {"maximumLength": 4097, "headroom": 4}, + "mouths": {"levelingDistance": 4, "maximumOceanApron": 8} + }, + "underground": { + "sources": {"density": 64, "minimumPerTile": 64}, + "fluidLevel": {"min": -64, "max": 127}, + "channelWidth": {"min": 4, "max": 18}, + "depth": {"min": 2, "max": 5}, + "headroom": {"min": 6, "max": 14} + }, + "profiles": [ + { + "id": "default", + "fluidPalette": {"palette": [{"block": "minecraft:water"}]} + } + ] + } + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "sampleSpacing must be divisible by refinementSpacing"); + assertContains(result.errors(), "primaryWavelength must be at least 8"); + assertContains(result.errors(), "detailWavelength must be at most 128"); + assertContains(result.errors(), "detailWavelength must not exceed primaryWavelength"); + assertContains(result.errors(), "maximumTurnDegrees must be at most 150"); + assertContains(result.errors(), "bedRoundness must be at most 6"); + assertContains(result.errors(), "bedRoughness must be at least 0"); + assertContains(result.errors(), "wallRoughness must be at most 1"); + assertContains(result.errors(), "roughnessWavelength must be at least 3"); + assertContains(result.errors(), "minimumSurfaceCourseLength must be at most 32768"); + assertContains(result.errors(), "minimumUndergroundCourseLength must be at most 32768"); + assertContains(result.errors(), "cascadeRunPerBlock must be at most 16"); + assertContains(result.errors(), "cascadeExponent must be at least 0.25"); + assertContains(result.errors(), "maximumCascadeStep must be at most 4"); + assertContains(result.errors(), "flowWidthRatio must be at least 0.25"); + assertContains(result.errors(), "maximumFlowDepth must be at most 16"); + assertContains(result.errors(), "basinWidthRatio must be at most 4"); + assertContains(result.errors(), "maximumBasinDepth must be at most 32"); + assertContains(result.errors(), "minimumSpacing must be at most 8192"); + assertContains(result.errors(), "must enable oceanOutlets or select at least one"); + assertContains(result.errors(), "shoreWidth must be at most 2"); + assertContains(result.errors(), "terrainBlendWidth.min must be at least 4"); + assertContains(result.errors(), "terrainBlendWidth.max must be at most 64"); + assertContains(result.errors(), "width.min must not exceed max"); + assertContains(result.errors(), "surfaceInset.min must be at least 1"); + assertContains(result.errors(), "surfaceInset.max must be at most 64"); + assertContains(result.errors(), "maximumIncision must be at most 64"); + assertContains(result.errors(), "riffleDrop must not exceed maximumGradualDrop"); + assertContains(result.errors(), "waterfallMinimumDrop must equal maximumGradualDrop plus one"); + assertContains(result.errors(), "maximumGradualLength must be at least maximumGradualDrop"); + assertContains(result.errors(), "maximumLength must be at most 4096"); + assertContains(result.errors(), "coarse lattice nodes"); + assertContains(result.errors(), "route samples per tile"); + assertContains(result.errors(), "requires useMantle to be true"); + assertContains(result.errors(), "requires carvingEnabled to be true"); + assertContains(result.errors(), "requires CARVED to remain enabled"); + assertContains(result.errors(), "requires RIVER_HYDROLOGY to remain enabled"); + assertContains(result.errors(), "minimumElevation must be below dimensionHeight.max"); + assertContains(result.errors(), "fluidLevel.min and depth.max"); + assertContains(result.errors(), "fluidLevel.max and headroom.max"); + } + + @Test + public void rejectsInvalidProfilesPalettesAndPolicyProfileReferences() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "hydrology": { + "rivers": { + "enabled": true, + "profiles": [ + { + "id": "water", + "fluidPalette": {"palette": [{"block": "minecraft:water"}]} + }, + { + "id": "water", + "fluidPalette": {"palette": []} + }, + {"id": "Bad Profile"}, + { + "id": "stone", + "fluidPalette": {"palette": [{"block": "minecraft:stone"}]} + } + ] + } + }, + "riverPolicy": { + "placement": "REQUIRED_HEADWATER", + "routing": "ALLOW", + "outletAdmission": true, + "profiles": ["missing"] + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "id must be unique inside hydrology.rivers.profiles"); + assertContains(result.errors(), "fluidPalette.palette must contain at least one fluid block"); + assertContains(result.errors(), "id must use 1 to 64 lowercase"); + assertContains(result.errors(), "fluidPalette must be configured"); + assertContains(result.errors(), "may contain only fluid blocks"); + assertContains(result.errors(), "references unknown river profile 'missing'"); + } + + @Test + public void validatesAllCurrentPolicyFieldsAndBiomeExistence() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "hydrology": { + "rivers": { + "enabled": true, + "profiles": [ + { + "id": "default", + "fluidPalette": {"palette": [{"block": "minecraft:water"}]} + } + ] + } + } + } + """); + write(pack, "regions/region.json", """ + { + "landBiomes": ["biome"], + "riverPolicy": { + "placement": "SOURCE", + "routing": "CHEAP", + "outletAdmission": "yes", + "profiles": "default", + "surfaceBiomes": ["missing"], + "mouthBiomes": "river", + "shoreBiomes": ["biome", "biome"], + "dryBiomes": [17], + "floodedCaveBiomes": ["../escape"], + "widthMultiplier": 0, + "depthMultiplier": 17, + "incisionMultiplier": -1, + "routingMultiplier": -1 + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), ".placement must be one of"); + assertContains(result.errors(), ".routing must be one of"); + assertContains(result.errors(), ".outletAdmission must be a boolean or null"); + assertContains(result.errors(), ".profiles must be an array or null"); + assertContains(result.errors(), "surfaceBiomes[0] references missing biome 'missing'"); + assertContains(result.errors(), ".mouthBiomes must be an array or null"); + assertContains(result.errors(), "shoreBiomes[1] duplicates biome 'biome'"); + assertContains(result.errors(), "dryBiomes[0] must name a biome resource"); + assertContains(result.errors(), "floodedCaveBiomes[0] must name a biome resource"); + assertContains(result.errors(), "widthMultiplier must be at least"); + assertContains(result.errors(), "depthMultiplier must be at most"); + assertContains(result.errors(), "incisionMultiplier must be at least"); + assertContains(result.errors(), "routingMultiplier must be at least"); + } + + @Test + public void validatesProfileReferencesReachedOnlyThroughAnotherPolicy() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "hydrology": { + "rivers": { + "enabled": true, + "profiles": [ + { + "id": "default", + "fluidPalette": {"palette": [{"block": "minecraft:water"}]} + } + ] + } + } + } + """); + write(pack, "biomes/biome.json", """ + { + "name": "Root", + "derivative": "minecraft:plains", + "riverPolicy": {"surfaceBiomes": ["indirect"]} + } + """); + write(pack, "biomes/indirect.json", """ + { + "name": "Indirect", + "derivative": "minecraft:river", + "riverPolicy": {"profiles": ["not_in_dimension"]} + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains( + result.errors(), + "Biome 'indirect'.riverPolicy.profiles[0] references unknown river profile 'not_in_dimension'" + ); + } + + @Test + public void validatesProfileReferencesReachedOnlyThroughBiomeImageMapTargets() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "imageMaps": [ + {"key": "biome", "map": "policy-map", "application": "BIOME"} + ], + "hydrology": { + "rivers": { + "enabled": true, + "profiles": [ + { + "id": "default", + "fluidPalette": {"palette": [{"block": "minecraft:water"}]} + } + ] + } + } + } + """); + write(pack, "image-maps/policy-map.json", """ + { + "colors": {"#123456": "iris:mapped_color"}, + "fallbackTarget": "mapped_fallback" + } + """); + write(pack, "biomes/mapped_color.json", """ + { + "name": "Mapped Color", + "derivative": "minecraft:river", + "riverPolicy": {"profiles": ["missing_color_profile"]} + } + """); + write(pack, "biomes/mapped_fallback.json", """ + { + "name": "Mapped Fallback", + "derivative": "minecraft:river", + "riverPolicy": {"profiles": ["missing_fallback_profile"]} + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains( + result.errors(), + "Biome 'mapped_color'.riverPolicy.profiles[0] references unknown river profile 'missing_color_profile'" + ); + assertContains( + result.errors(), + "Biome 'mapped_fallback'.riverPolicy.profiles[0] references unknown river profile 'missing_fallback_profile'" + ); + } + + @Test + public void rejectsUnsafeDeepFluidConfiguration() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -64, "max": 128}, + "hydrology": { + "deepFluids": [ + { + "id": "deep_lava", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "height": {"min": 40, "max": -40}, + "density": 1, + "spacing": 16, + "horizontalRadius": 20, + "verticalRadius": 3, + "channelWidth": 8, + "depth": 4, + "headroom": 4, + "containedPools": "yes", + "shortChannels": 1 + }, + { + "id": "deep_lava", + "fluidPalette": {"palette": []}, + "height": {"min": -63, "max": 127} + } + ] + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "height.min must not exceed max"); + assertContains(result.errors(), "spacing must be at least"); + assertContains(result.errors(), "depth and headroom must fit inside verticalRadius"); + assertContains(result.errors(), "containedPools must be a boolean"); + assertContains(result.errors(), "shortChannels must be a boolean"); + assertContains(result.errors(), "id must be unique inside hydrology.deepFluids"); + assertContains(result.errors(), "fluidPalette.palette must contain at least one fluid block"); + assertContains(result.errors(), "height.min and lower footprint envelope must remain above dimensionHeight.min"); + assertContains(result.errors(), "height.max and headroom must remain below dimensionHeight.max"); + } + + @Test + public void rejectsDeepPoolWhoseVerticalRadiusCrossesTheDimensionFloor() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -64, "max": 128}, + "hydrology": { + "deepFluids": [ + { + "id": "deep_lava", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "height": {"min": -60, "max": -40}, + "density": 1, + "spacing": 64, + "horizontalRadius": 8, + "verticalRadius": 8, + "channelWidth": 4, + "depth": 2, + "headroom": 4, + "containedPools": true, + "shortChannels": false + } + ] + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains( + result.errors(), + "height.min and lower footprint envelope must remain above dimensionHeight.min" + ); + } + + @Test + public void rejectsFluidIdsSharedByRiverAndDeepProfiles() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "rivers": { + "enabled": true, + "profiles": [ + { + "id": "shared", + "fluidPalette": {"palette": [{"block": "minecraft:water"}]} + } + ] + }, + "deepFluids": [ + { + "id": "shared", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "height": {"min": -192, "max": 32}, + "density": 0.1, + "spacing": 1024, + "horizontalRadius": 18, + "verticalRadius": 8, + "channelWidth": 4, + "depth": 2, + "headroom": 4, + "containedPools": true, + "shortChannels": true + } + ] + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "must not duplicate a hydrology.rivers profile id"); + } + + @Test + public void rejectsDeepOnlyIdSharedByTheImplicitDefaultRiverProfile() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "deepFluids": [ + { + "id": "default", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "density": 0.5 + } + ] + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "must not duplicate a hydrology.rivers profile id"); + } + + @Test + public void omittedDeepGeometryUsesRuntimeDefaults() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "deepFluids": [ + { + "id": "deep_lava", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "density": 0.5 + } + ] + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().isEmpty()); + } + + @Test + public void omittedGrottoGeometryUsesRuntimeDefaults() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "rivers": { + "enabled": true, + "grottos": { + "coastal": {"verticalRadius": 6}, + "inland": {"verticalRadius": 6} + } + } + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().isEmpty()); + } + + @Test + public void validatesSurfaceSinkholeConfigurationAsACanonicalInlandOutlet() throws Exception { + File missingOutlet = pack(""" + { + "regions": ["region"], + "hydrology": { + "rivers": { + "grottos": { + "inland": { + "enabled": false, + "connectSurfaceRivers": true + } + } + } + } + } + """); + File invalidType = pack(""" + { + "regions": ["region"], + "hydrology": { + "rivers": { + "grottos": { + "inland": { + "enabled": true, + "connectSurfaceRivers": "yes" + } + } + } + } + } + """); + + PackHydrologyValidator.Validation missingOutletResult = validate(missingOutlet); + PackHydrologyValidator.Validation invalidTypeResult = validate(invalidType); + + assertContains(missingOutletResult.errors(), "connectSurfaceRivers requires the inland grotto to be enabled"); + assertContains(missingOutletResult.errors(), "requires routing.inlandOutlets to select SINKHOLE_GROTTO"); + assertContains(invalidTypeResult.errors(), "connectSurfaceRivers must be a boolean"); + } + + @Test + public void shapeDisabledDeepEntryDoesNotRequireMantleCapabilities() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "useMantle": false, + "carvingEnabled": false, + "disabledComponents": ["CARVED", "RIVER_HYDROLOGY"], + "hydrology": { + "deepFluids": [ + { + "id": "deep_lava", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "density": 1, + "containedPools": false, + "shortChannels": false + } + ] + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().isEmpty()); + } + + @Test + public void rejectsEveryReservedHydrologyFeatureKeywordAsADeepFluidId() throws Exception { + for (String id : List.of( + "surface", + "waterfall", + "sinkhole", + "underground", + "grotto", + "coastal_grotto", + "coastal-grotto", + "inland_grotto", + "inland-grotto", + "mouth", + "ridge_tunnel", + "ridge-tunnel", + "deep" + )) { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "deepFluids": [ + { + "id": "%s", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "height": {"min": -192, "max": 32}, + "density": 0.1, + "spacing": 1024, + "horizontalRadius": 18, + "verticalRadius": 8, + "channelWidth": 4, + "depth": 2, + "headroom": 4, + "containedPools": true, + "shortChannels": true + } + ] + } + } + """.formatted(id)); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "id must not use a reserved hydrology feature keyword"); + } + } + + @Test + public void packValidatorRegistersCanonicalHydrologyValidator() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "logicalHeight": 256, + "hydrology": { + "rivers": { + "enabled": true, + "routing": { + "tileSize": 2048, + "sampleSpacing": 63, + "refinementSpacing": 8 + }, + "profiles": [ + { + "id": "default", + "fluidPalette": {"palette": [{"block": "minecraft:water"}]} + } + ] + } + } + } + """); + + PackValidationResult result = PackValidator.validateForPackaging(pack); + + assertFalse(result.getBlockingErrors().toString(), result.isLoadable()); + assertContains(result.getBlockingErrors(), "tileSize must be divisible by sampleSpacing"); + } + + private PackHydrologyValidator.Validation validate(File pack) { + File[] dimensions = new File(pack, "dimensions").listFiles( + file -> file.isFile() && file.getName().endsWith(".json")); + return PackHydrologyValidator.validate(pack, dimensions); + } + + private File pack(String dimensionJson) throws Exception { + File pack = temporaryFolder.newFolder("pack-" + System.nanoTime()); + write(pack, "dimensions/main.json", dimensionJson); + write(pack, "regions/region.json", """ + { + "landBiomes": ["biome"], + "seaBiomes": [], + "shoreBiomes": [], + "caveBiomes": [] + } + """); + write(pack, "biomes/biome.json", + "{\"name\":\"Biome\",\"derivative\":\"minecraft:plains\"}"); + return pack; + } + + private String canonicalDimension() { + return """ + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "rivers": { + "enabled": true, + "routing": { + "tileSize": 2048, + "sampleSpacing": 64, + "refinementSpacing": 8, + "maximumRouteLength": 8192, + "oceanOutlets": true, + "inlandOutlets": ["SINKHOLE_GROTTO"] + }, + "surface": { + "enabled": true, + "sources": { + "density": 0.5, + "minimumElevation": 88, + "minimumPerTile": 1 + }, + "channel": { + "width": {"min": 4, "max": 32, "style": {"style": "STATIC"}}, + "depth": {"min": 2, "max": 5}, + "maximumIncision": 12, + "shoreWidth": 1.5, + "terrainBlendWidth": {"min": 4, "max": 10} + }, + "hydraulics": { + "targetPoolLength": {"min": 48, "max": 160}, + "riffleDrop": 1, + "maximumGradualDrop": 3, + "maximumGradualLength": 10, + "waterfallMinimumDrop": 4 + }, + "ridgeTunnels": { + "enabled": true, + "maximumLength": 192, + "headroom": 10 + }, + "mouths": { + "levelingDistance": 64, + "maximumOceanApron": 2 + } + }, + "underground": { + "enabled": true, + "sources": {"density": 0.5, "minimumPerTile": 1}, + "fluidLevel": {"min": -48, "max": 50}, + "channelWidth": {"min": 4, "max": 18}, + "depth": {"min": 2, "max": 5}, + "headroom": {"min": 6, "max": 14}, + "connectToExistingCaves": true + }, + "grottos": { + "coastal": { + "enabled": true, + "poolLevel": "SEA_LEVEL", + "horizontalRadius": 12, + "verticalRadius": 7, + "headroom": 4, + "maximumVolume": 8192 + }, + "inland": { + "enabled": true, + "connectSurfaceRivers": true, + "horizontalRadius": 10, + "verticalRadius": 6, + "headroom": 4, + "maximumVolume": 8192 + } + }, + "profiles": [ + { + "id": "default", + "fluidPalette": { + "palette": [{"block": "minecraft:water", "weight": 1}] + } + } + ] + }, + "deepFluids": [ + { + "id": "deep_lava", + "fluidPalette": { + "palette": [{"block": "minecraft:lava"}] + }, + "height": {"min": -192, "max": 32}, + "density": 0.5, + "spacing": 384, + "horizontalRadius": 18, + "verticalRadius": 8, + "channelWidth": 4, + "depth": 2, + "headroom": 4, + "containedPools": true, + "shortChannels": true + } + ] + }, + "riverPolicy": { + "placement": "NATURAL", + "routing": "ALLOW", + "outletAdmission": true, + "profiles": ["default"], + "surfaceBiomes": ["river_surface"], + "mouthBiomes": ["river_surface"], + "shoreBiomes": ["river_shore"], + "dryBiomes": ["river_dry"], + "floodedCaveBiomes": ["river_dry"], + "widthMultiplier": 1, + "depthMultiplier": 1, + "incisionMultiplier": 1, + "routingMultiplier": 1 + } + } + """; + } + + private void assertContains(List messages, String fragment) { + assertTrue(messages.toString(), contains(messages, fragment)); + } + + private boolean contains(List messages, String fragment) { + for (String message : messages) { + if (message.contains(fragment)) { + return true; + } + } + return false; + } + + private void write(File root, String relative, String content) throws Exception { + Path target = new File(root, relative).toPath(); + Files.createDirectories(target.getParent()); + Files.writeString(target, content, StandardCharsets.UTF_8); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java deleted file mode 100644 index 4b26d4988..000000000 --- a/core/src/test/java/art/arcane/iris/core/pack/PackRiverValidatorTest.java +++ /dev/null @@ -1,1122 +0,0 @@ -package art.arcane.iris.core.pack; - -import org.junit.Rule; -import org.junit.Test; -import org.junit.rules.TemporaryFolder; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -public class PackRiverValidatorTest { - @Rule - public TemporaryFolder temporaryFolder = new TemporaryFolder(); - - @Test - public void acceptsEnabledNetworkWithFallbackBiomePools() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "logicalHeight": 256, - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "biomes": { - "channel": [], - "bank": [], - "mouth": [], - "dry": [], - "floodedCave": [] - } - } - } - """); - - PackValidationResult result = PackValidator.validate(pack); - - assertTrue(result.getBlockingErrors().toString(), result.isLoadable()); - } - - @Test - public void acceptsRequiredValidWormProfiles() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": { - "worms": [ - { - "id": "floodplain_trunk", - "seed": 17, - "weight": 2.5, - "wavelength": 1536, - "detailWavelength": 192, - "tortuosity": 0.65, - "detailTortuosity": 0.2, - "maxOffset": 420, - "segments": 56, - "widthMultiplier": 1.4, - "bankMultiplier": 1.2, - "depthMultiplier": 0.8, - "bodyWavelength": 1400, - "bodyDetailWavelength": 320, - "widthVariation": 0.65, - "bankVariation": 0.75, - "depthVariation": 0.45, - "roofVariation": 0.55, - "branchCap": 3, - "branchDecay": 0.25, - "confluenceMultiplier": 1.5, - "childChance": 0.2, - "branchChildChance": 0.6, - "children": [ - { - "id": "floodplain_tributary", - "seed": 29, - "weight": 1, - "wavelength": 640, - "detailWavelength": 128, - "tortuosity": 0.8, - "detailTortuosity": 0.3, - "maxOffset": 300, - "segments": 48, - "widthMultiplier": 0.7, - "bankMultiplier": 0.9, - "depthMultiplier": 1.3, - "bodyWavelength": 180, - "bodyDetailWavelength": 48, - "widthVariation": 0.8, - "bankVariation": 0.8, - "depthVariation": 0.65, - "roofVariation": 0.75, - "branchCap": 2, - "branchDecay": 0.1, - "confluenceMultiplier": 0.75, - "childChance": 0, - "branchChildChance": 0, - "children": [] - } - ] - } - ] - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertTrue(result.errors().toString(), result.errors().isEmpty()); - } - - @Test - public void rejectsInvalidWormHierarchyIdentifiersRangesAndChildren() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": { - "worms": [ - { - "id": "trunk", - "seed": 17, - "bodyWavelength": 7, - "bodyDetailWavelength": 16385, - "bodyDetailInfluence": 1.1, - "widthVariation": -0.1, - "bankVariation": 0.876, - "depthVariation": -0.1, - "roofVariation": 0.876, - "branchCap": 0, - "branchDecay": 2, - "confluenceMultiplier": 9, - "childChance": -0.1, - "branchChildChance": 1.1, - "children": "tributary" - }, - {"id": "trunk", "seed": 29}, - {"id": "Bad ID", "seed": 31} - ] - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "rivers.terrain.worms[0].bodyWavelength must be at least 8"); - assertContains(result.errors(), "rivers.terrain.worms[0].bodyDetailWavelength must be at most 16384"); - assertContains(result.errors(), "rivers.terrain.worms[0].bodyDetailInfluence must be at most 1"); - assertContains(result.errors(), "rivers.terrain.worms[0].widthVariation must be at least 0"); - assertContains(result.errors(), "rivers.terrain.worms[0].bankVariation must be at most 0.875"); - assertContains(result.errors(), "rivers.terrain.worms[0].depthVariation must be at least 0"); - assertContains(result.errors(), "rivers.terrain.worms[0].roofVariation must be at most 0.875"); - assertContains(result.errors(), "rivers.terrain.worms[0].branchCap must be at least 1"); - assertContains(result.errors(), "rivers.terrain.worms[0].branchDecay must be at most 1"); - assertContains(result.errors(), "rivers.terrain.worms[0].confluenceMultiplier must be at most 8"); - assertContains(result.errors(), "rivers.terrain.worms[0].childChance must be at least 0"); - assertContains(result.errors(), "rivers.terrain.worms[0].branchChildChance must be at most 1"); - assertContains(result.errors(), "rivers.terrain.worms[0].children must be an array"); - assertContains(result.errors(), "rivers.terrain.worms[1].id must be unique inside the worm hierarchy"); - assertContains(result.errors(), - "rivers.terrain.worms[2].id must use 1 to 64 lowercase letters, digits, underscores, or hyphens"); - } - - @Test - public void rejectsWormHierarchyDepthAndProfileLimits() throws Exception { - File excessiveDepth = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": { - "worms": [ - { - "id": "level_1", - "seed": 1, - "children": [ - { - "id": "level_2", - "seed": 2, - "children": [ - { - "id": "level_3", - "seed": 3, - "children": [ - { - "id": "level_4", - "seed": 4, - "children": [ - {"id": "level_5", "seed": 5} - ] - } - ] - } - ] - } - ] - } - ] - } - } - } - """); - File excessiveRoots = packWithWorms(wormHierarchy(17, 0)); - File excessiveProfiles = packWithWorms(wormHierarchy(16, 8)); - - PackRiverValidator.Validation depthResult = validate(excessiveDepth); - PackRiverValidator.Validation rootResult = validate(excessiveRoots); - PackRiverValidator.Validation profileResult = validate(excessiveProfiles); - - assertContains(depthResult.errors(), "children exceeds the maximum hierarchy depth of 4"); - assertContains(rootResult.errors(), "rivers.terrain.worms must contain at most 16 root profiles"); - assertContains(profileResult.errors(), "rivers.terrain.worms hierarchy must contain at most 128 profiles"); - } - - @Test - public void rejectsMissingAndEmptyWormLists() throws Exception { - File missing = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {} - } - } - """); - File empty = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": []} - } - } - """); - - PackRiverValidator.Validation missingResult = validate(missing); - PackRiverValidator.Validation emptyResult = validate(empty); - - assertContains(missingResult.errors(), - "rivers.terrain.worms must be an array with at least one Perlin-worm profile"); - assertContains(emptyResult.errors(), - "rivers.terrain.worms must contain at least one Perlin-worm profile"); - } - - @Test - public void rejectsNullEnabledNetworkSections() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "topology": null, - "terrain": null, - "water": null, - "biomes": null, - "caves": null - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "rivers.topology must be an object."); - assertContains(result.errors(), "rivers.terrain must be an object."); - assertContains(result.errors(), "rivers.water must be an object."); - assertContains(result.errors(), "rivers.biomes must be an object."); - assertContains(result.errors(), "rivers.caves must be an object."); - } - - @Test - public void rejectsInvalidFiniteRangesAndMalformedStyles() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "topology": { - "siteJitter": 1e400, - "tileCells": 1, - "minimumSourcesPerTile": 2, - "sinkSearchReaches": 8, - "routingBasinCells": 7, - "routingDeviationScaleCells": 7, - "routingDeviationStrengthCells": 33, - "routingPlateauHeight": 0, - "flowAlignmentWeight": 1025, - "confluenceWeight": -1, - "routingStyle": {"zoom": 0} - }, - "terrain": { - "worms": [{"id": "river"}], - "channelWidth": {"min": 40, "max": 12}, - "depth": {}, - "tunnelWidthMultiplier": {"min": 0.5, "max": 9}, - "tunnelMouthBlend": 17, - "tunnelFloorVariation": 9, - "tunnelRoofVariation": 17, - "tunnelFloorStyle": {"zoom": 0}, - "tunnelRoofStyle": {"zoom": 0} - }, - "caves": { - "parentBiomeInheritance": 2 - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "rivers.topology.siteJitter must be a number."); - assertContains(result.errors(), "rivers.topology.minimumSourcesPerTile must not exceed tileCells squared."); - assertContains(result.errors(), "rivers.topology.sinkSearchReaches must be at most 7"); - assertContains(result.errors(), "rivers.topology.routingBasinCells must be at least 8"); - assertContains(result.errors(), "rivers.topology.routingDeviationScaleCells must be at least 8"); - assertContains(result.errors(), "rivers.topology.routingDeviationStrengthCells must be at most 32"); - assertContains(result.errors(), "rivers.topology.routingPlateauHeight must be at least 1"); - assertContains(result.errors(), "rivers.topology.flowAlignmentWeight must be at most 1024"); - assertContains(result.errors(), "rivers.topology.confluenceWeight must be at least 0"); - assertContains(result.errors(), "rivers.topology.routingStyle.zoom must be at least"); - assertContains(result.errors(), "rivers.terrain.channelWidth.min must not exceed"); - assertContains(result.errors(), "rivers.terrain.depth must set min and max explicitly."); - assertContains(result.errors(), "rivers.terrain.tunnelWidthMultiplier.min must be at least 1"); - assertContains(result.errors(), "rivers.terrain.tunnelWidthMultiplier.max must be at most 8"); - assertContains(result.errors(), "rivers.terrain.tunnelMouthBlend must be at most 16"); - assertContains(result.errors(), "rivers.terrain.tunnelFloorVariation must be at most 8"); - assertContains(result.errors(), "rivers.terrain.tunnelRoofVariation must be at most 16"); - assertContains(result.errors(), "rivers.terrain.tunnelFloorStyle.zoom must be at least"); - assertContains(result.errors(), "rivers.terrain.tunnelRoofStyle.zoom must be at least"); - assertContains(result.errors(), "rivers.caves.parentBiomeInheritance must be at most 1"); - } - - @Test - public void rejectsTerraceDropsAbovePermittedRise() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "water": { - "mode": "TERRACED", - "maximumPoolRise": 2, - "dropHeight": 3 - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "rivers.water.dropHeight must not exceed maximumPoolRise"); - } - - @Test - public void acceptsIndependentCaveLavaRiverHeightAndPalette() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "dimensionHeight": {"min": -64, "max": 320}, - "fluidHeight": 63, - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "water": { - "mode": "FIXED", - "fluidHeight": -48, - "fluidPalette": { - "palette": [{"block": "minecraft:lava"}] - } - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertTrue(result.errors().toString(), result.errors().isEmpty()); - } - - @Test - public void acceptsSparseBlobbyDeepLavaPoolsWithIndependentHeight() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "dimensionHeight": {"min": -256, "max": 512}, - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "caves": { - "deepPools": { - "enabled": true, - "reach": { - "chance": 0.08, - "influence": 0.04, - "style": {"style": "IRIS", "zoom": 4096} - }, - "minimumSpacing": 768, - "maximumPerReach": 1, - "minimumFluidY": -224, - "maximumFluidY": -108, - "searchRadius": 20, - "searchAttempts": 12, - "horizontalRadius": 24, - "verticalRadius": 10, - "dryHeadroom": 5, - "shapeStyle": {"style": "IRIS", "zoom": 12}, - "shapeVariation": 0.6, - "warpStyle": {"style": "IRIS", "zoom": 24}, - "warpStrength": 8, - "maximumVolume": 65536, - "fluidPalette": { - "palette": [{"block": "minecraft:lava"}] - } - } - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertTrue(result.errors().toString(), result.errors().isEmpty()); - } - - @Test - public void rejectsUnsafeDeepPoolEnvelopeShapeAndPalette() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "dimensionHeight": {"min": -64, "max": 320}, - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "caves": { - "deepPools": { - "enabled": true, - "reach": {"chance": 0}, - "minimumFluidY": -90, - "maximumFluidY": -120, - "searchRadius": 120, - "horizontalRadius": 20, - "verticalRadius": 8, - "dryHeadroom": 8, - "maximumVolume": 64, - "fluidPalette": {"palette": []} - } - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "deepPools.minimumFluidY must not exceed maximumFluidY"); - assertContains(result.errors(), "deepPools.dryHeadroom must be smaller than verticalRadius"); - assertContains(result.errors(), "deepPools.searchRadius plus horizontalRadius must not exceed 128"); - assertContains(result.errors(), "deepPools.maximumVolume must be at least"); - assertContains(result.errors(), "deepPools.fluidPalette.palette must contain at least one fluid block"); - assertContains(result.errors(), "deepPools fluid range and chamber envelope must remain inside dimensionHeight"); - assertContains(result.warnings(), "deepPools is enabled but its reach gate cannot accept any pools"); - } - - @Test - public void rejectsRetiredWaterModeInvalidPaletteAndOutOfBoundsHeight() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "dimensionHeight": {"min": -64, "max": 320}, - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "water": { - "mode": "SEA_LEVEL", - "fluidHeight": -80, - "fluidPalette": {"palette": []} - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "rivers.water.mode must be one of"); - assertContains(result.errors(), "rivers.water.fluidHeight must remain inside dimensionHeight"); - assertContains(result.errors(), "rivers.water.fluidPalette.palette must contain at least one fluid block"); - } - - @Test - public void rejectsPathologicalCombinedTopologyComplexity() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "topology": { - "cellSize": 64, - "tileCells": 64, - "siteJitter": 0.49, - "maxRouteReaches": 256 - }, - "terrain": { - "channelWidth": {"min": 2048, "max": 2048}, - "bankWidth": {"min": 2048, "max": 2048}, - "orderWidthFactor": 8, - "worms": [{"id": "river", "maxOffset": 1024, "segments": 64}] - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "exceeds the safe derived complexity budget"); - assertContains(result.errors(), "source window requires"); - assertContains(result.errors(), "increase cellSize or reduce tileCells"); - } - - @Test - public void rejectsRiverGeometryCapsOutsideSupportedRanges() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": { - "worms": [{"id": "river"}], - "maxChannelWidth": 0, - "maxBankWidth": -1, - "maxDepth": 513 - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "rivers.terrain.maxChannelWidth"); - assertContains(result.errors(), "rivers.terrain.maxBankWidth"); - assertContains(result.errors(), "rivers.terrain.maxDepth"); - } - - @Test - public void rejectsTunnelFootprintsAboveDerivedHydrologyBudget() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": { - "worms": [{"id": "river"}], - "maxChannelWidth": 2048, - "tunnelMouthBlend": 16 - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "exceeds the safe derived hydrology budget"); - assertContains(result.errors(), "columns per generated chunk"); - } - - @Test - public void rejectsMissingBiomeReferencesAndWarnsAboutInferredRoles() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "biomes": { - "channel": ["biome", "missing"], - "bank": ["biome"] - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "references missing biome 'missing'"); - assertContains(result.warnings(), "inferred river role SEA"); - assertContains(result.warnings(), "inferred river role SHORE"); - } - - @Test - public void netherRiverBiomesKeepNetherDerivativesWithoutOverworldRoleWarnings() throws Exception { - File pack = pack(""" - { - "environment": "NETHER", - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "biomes": { - "channel": ["biome"], - "bank": ["biome"] - } - } - } - """); - write(pack, "biomes/biome.json", """ - { - "name": "Nether River", - "derivative": "minecraft:basalt_deltas", - "vanillaDerivative": "minecraft:basalt_deltas" - } - """); - write(pack, "regions/region.json", """ - { - "landBiomes": ["biome"], - "riverOverride": { - "channelBiomes": ["biome"], - "bankBiomes": ["biome"] - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertFalse(result.errors().toString(), contains(result.errors(), "biome")); - assertFalse(result.warnings().toString(), contains(result.warnings(), "inferred river role")); - } - - @Test - public void validatesRegionAndBiomeOverridesWhenRiversAreEnabled() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]} - } - } - """); - write(pack, "regions/region.json", """ - { - "landBiomes": ["biome"], - "riverOverride": { - "routingCostMultiplier": -1, - "channelBiomes": ["missing"] - } - } - """); - write(pack, "biomes/biome.json", """ - { - "name": "Biome", - "riverOverride": { - "allowSources": "yes", - "bankBiomes": [] - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "Region 'region' riverOverride.routingCostMultiplier must be at least"); - assertContains(result.errors(), "Region 'region' riverOverride.channelBiomes[0] references missing biome"); - assertContains(result.errors(), "Biome 'biome' riverOverride.allowSources must be a boolean"); - } - - @Test - public void rejectsZeroChannelAndDepthMultipliersInsteadOfRestoringFallbackGeometry() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]} - } - } - """); - write(pack, "regions/region.json", """ - { - "landBiomes": ["biome"], - "riverOverride": { - "widthMultiplier": 0, - "depthMultiplier": 0 - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "riverOverride.widthMultiplier must be at least 1.0E-4"); - assertContains(result.errors(), "riverOverride.depthMultiplier must be at least 1.0E-4"); - } - - @Test - public void rejectsMathematicallyImpossibleGeneratedGrotto() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "caves": { - "mode": "GENERATE_GROTTO", - "throatRadius": 4, - "grottoHorizontalRadius": 4, - "grottoVerticalRadius": 4, - "grottoWarpStrength": 3, - "dryHeadroom": 9, - "maxFloodRadius": 6, - "maxFloodDepth": 6, - "maxFloodVolume": 64 - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "throatRadius must be smaller than both grotto radii"); - assertContains(result.errors(), "dryHeadroom must fit inside the generated grotto height"); - assertContains(result.errors(), "maxFloodRadius must be at least 8"); - assertContains(result.errors(), "maxFloodDepth must be at least 8"); - assertContains(result.errors(), "maxFloodVolume must be at least"); - } - - @Test - public void warnsWhenActiveCaveEntryGateCannotProduceConnections() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "caves": { - "mode": "FLOOD_CLOSED_COMPONENT", - "maximumPerReach": 0, - "maxBoreDepth": 64, - "maxFloodDepth": 32 - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.warnings(), "entry gate cannot accept any connections"); - assertContains(result.warnings(), "maxBoreDepth exceeds maxFloodDepth"); - } - - @Test - public void rejectsSinkholeTerminalWithoutCaveHydrology() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": { - "worms": [{"id": "river"}], - "terminalMode": "SINKHOLE_GROTTO" - }, - "caves": {"mode": "SEALED"} - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "SINKHOLE_GROTTO requires a non-SEALED caves.mode"); - } - - @Test - public void rejectsSinkholeTerminalWhenPerReachCapDisablesItsForcedAnchor() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": { - "worms": [{"id": "river"}], - "terminalMode": "SINKHOLE_GROTTO" - }, - "caves": { - "mode": "GENERATE_GROTTO", - "maximumPerReach": 0 - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "SINKHOLE_GROTTO requires caves.maximumPerReach above zero"); - } - - @Test - public void rejectsSinkholeTerminalWhenCarvingIsDisabled() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "carvingEnabled": false, - "rivers": { - "enabled": true, - "terrain": { - "worms": [{"id": "river"}], - "terminalMode": "SINKHOLE_GROTTO" - }, - "caves": {"mode": "GENERATE_GROTTO"} - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "SINKHOLE_GROTTO requires carvingEnabled to be true"); - } - - @Test - public void forcedSinkholeValidatesGrottoEnvelopeInClosedComponentModeOnce() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": { - "worms": [{"id": "river"}], - "terminalMode": "SINKHOLE_GROTTO" - }, - "caves": { - "mode": "FLOOD_CLOSED_COMPONENT", - "fallback": "SEALED", - "grottoHorizontalRadius": 12, - "grottoWarpStrength": 2, - "maxFloodRadius": 12 - } - } - } - """); - - PackRiverValidator.Validation result = validate(pack); - - String fragment = "rivers.caves.maxFloodRadius must be at least 15"; - assertContains(result.errors(), fragment); - assertTrue(result.errors().toString(), count(result.errors(), fragment) == 1); - } - - @Test - public void rejectsReachableRegionSinkholeAgainstInactiveDimensionHydrology() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "caves": {"mode": "SEALED"} - } - } - """); - write(pack, "regions/region.json", """ - { - "landBiomes": ["biome"], - "riverOverride": {"terminalMode": "SINKHOLE_GROTTO"} - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "Region 'region' riverOverride.terminalMode SINKHOLE_GROTTO requires a non-SEALED caves.mode"); - } - - @Test - public void rejectsReachableChildBiomeSinkholeAgainstDisabledCarving() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "carvingEnabled": false, - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "caves": {"mode": "GENERATE_GROTTO"} - } - } - """); - write(pack, "biomes/biome.json", """ - { - "name": "Biome", - "derivative": "minecraft:plains", - "children": ["child"] - } - """); - write(pack, "biomes/child.json", """ - { - "name": "Child", - "derivative": "minecraft:forest", - "riverOverride": {"terminalMode": "SINKHOLE_GROTTO"} - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "Biome 'child' riverOverride.terminalMode SINKHOLE_GROTTO requires carvingEnabled to be true"); - } - - @Test - public void rejectsTransitiveFinalTerrainExpressionDependency() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "topology": { - "routingStyle": {"expression": "route"} - } - } - } - """); - write(pack, "expressions/route.json", """ - { - "variables": [ - { - "name": "nested", - "styleValue": {"expression": "terrain"} - } - ], - "expression": "nested" - } - """); - write(pack, "expressions/terrain.json", """ - { - "functions": [ - { - "name": "height", - "engineStreamValue": "HEIGHT" - } - ], - "expression": "height(x,z)" - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "engineStreamValue 'HEIGHT'"); - assertContains(result.errors(), "would recurse during river generation"); - } - - @Test - public void rejectsFinalTerrainDependencyInsideExpressionEntrySnippet() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "topology": { - "routingStyle": {"expression": "route"} - } - } - } - """); - write(pack, "expressions/route.json", """ - { - "variables": ["snippet/expression-load/final-height"], - "expression": "height" - } - """); - write(pack, "snippet/expression-load/final-height.json", """ - { - "name": "height", - "engineStreamValue": "HEIGHT_OR_FLUID" - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "engineStreamValue 'HEIGHT_OR_FLUID'"); - } - - @Test - public void rejectsCyclicStyleSnippetDependenciesWithoutRecursing() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "topology": { - "routingStyle": "snippet/style/first" - } - } - } - """); - write(pack, "snippet/style/first.json", "{\"fracture\":\"snippet/style/second\"}"); - write(pack, "snippet/style/second.json", "{\"fracture\":\"snippet/style/first\"}"); - - PackRiverValidator.Validation result = validate(pack); - - assertContains(result.errors(), "cyclic river-noise style snippet dependency"); - } - - @Test - public void acceptsNaturalHeightExpressionDependency() throws Exception { - File pack = pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": [{"id": "river"}]}, - "topology": { - "routingStyle": {"expression": "route"} - } - } - } - """); - write(pack, "expressions/route.json", """ - { - "variables": [ - { - "name": "height", - "engineStreamValue": "NATURAL_HEIGHT" - } - ], - "expression": "height" - } - """); - - PackRiverValidator.Validation result = validate(pack); - - assertFalse(result.errors().toString(), contains(result.errors(), "engineStreamValue")); - } - - private PackRiverValidator.Validation validate(File pack) { - File[] dimensions = new File(pack, "dimensions").listFiles( - file -> file.isFile() && file.getName().endsWith(".json")); - return PackRiverValidator.validate(pack, dimensions); - } - - private File pack(String dimensionJson) throws Exception { - File pack = temporaryFolder.newFolder("pack-" + System.nanoTime()); - write(pack, "dimensions/main.json", dimensionJson); - write(pack, "regions/region.json", "{\"landBiomes\":[\"biome\"]}"); - write(pack, "biomes/biome.json", - "{\"name\":\"Biome\",\"derivative\":\"minecraft:plains\"}"); - return pack; - } - - private File packWithWorms(String worms) throws Exception { - return pack(""" - { - "regions": ["region"], - "rivers": { - "enabled": true, - "terrain": {"worms": %s} - } - } - """.formatted(worms)); - } - - private String wormHierarchy(int rootCount, int childrenPerRoot) { - StringBuilder hierarchy = new StringBuilder("["); - int seed = 1; - for (int rootIndex = 0; rootIndex < rootCount; rootIndex++) { - if (rootIndex > 0) { - hierarchy.append(','); - } - hierarchy.append("{\"id\":\"root_") - .append(rootIndex) - .append("\",\"seed\":") - .append(seed++); - if (childrenPerRoot > 0) { - hierarchy.append(",\"children\":["); - for (int childIndex = 0; childIndex < childrenPerRoot; childIndex++) { - if (childIndex > 0) { - hierarchy.append(','); - } - hierarchy.append("{\"id\":\"child_") - .append(rootIndex) - .append('_') - .append(childIndex) - .append("\",\"seed\":") - .append(seed++) - .append('}'); - } - hierarchy.append(']'); - } - hierarchy.append('}'); - } - return hierarchy.append(']').toString(); - } - - private void assertContains(List messages, String fragment) { - assertTrue(messages.toString(), contains(messages, fragment)); - } - - private boolean contains(List messages, String fragment) { - for (String message : messages) { - if (message.contains(fragment)) { - return true; - } - } - return false; - } - - private int count(List messages, String fragment) { - int matches = 0; - for (String message : messages) { - if (message.contains(fragment)) { - matches++; - } - } - return matches; - } - - private void write(File root, String relative, String content) throws Exception { - Path target = new File(root, relative).toPath(); - Files.createDirectories(target.getParent()); - Files.writeString(target, content, StandardCharsets.UTF_8); - } -} diff --git a/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenMantleCleanupContractTest.java b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenMantleCleanupContractTest.java new file mode 100644 index 000000000..0276a65a6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pregenerator/methods/PregenMantleCleanupContractTest.java @@ -0,0 +1,74 @@ +package art.arcane.iris.core.pregenerator.methods; + +import org.junit.Test; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PregenMantleCleanupContractTest { + @Test + public void normalEngineCleanupRetriesCandidatesCoveredByTheNewestRealChunk() throws IOException { + String source = read("art/arcane/iris/engine/framework/Engine.java"); + String cleanup = method(source, "default void cleanupMantleChunk(int x, int z)"); + + assertTrue(cleanup.contains("cleanupChunksCoveredBy(")); + assertTrue(cleanup.contains("EngineMantle.ChunkCleanupCallback.NONE")); + assertFalse(cleanup.contains(".cleanupChunk(x, z)")); + } + + @Test + public void asyncPregenReportsOnlyCleanupCallbacksFromCoveredTargets() throws IOException { + String source = read("art/arcane/iris/core/pregenerator/methods/AsyncPregenMethod.java"); + String completion = method(source, "private void completeChunk("); + String cleanup = method(source, "private void cleanupMantleChunksCoveredBy("); + + assertTrue(completion.contains("cleanupMantleChunksCoveredBy(x, z, listener)")); + assertFalse(completion.contains("listener.onChunkCleaned")); + assertTrue(cleanup.contains("cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned)")); + assertFalse(cleanup.contains("forceCleanupChunk")); + } + + private static String read(String relativePath) throws IOException { + Path current; + try { + current = Path.of(PregenMantleCleanupContractTest.class.getProtectionDomain() + .getCodeSource() + .getLocation() + .toURI()); + } catch (URISyntaxException e) { + throw new IOException("Unable to resolve the core source root", e); + } + while (current != null && !Files.isDirectory(current.resolve("src/main/java"))) { + current = current.getParent(); + } + if (current == null) { + throw new IOException("Unable to locate the core source root"); + } + return Files.readString(current.resolve("src/main/java").resolve(relativePath)); + } + + private static String method(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("Missing source contract signature: " + signature, start >= 0); + int openBrace = source.indexOf('{', start); + assertTrue("Missing source contract method body: " + signature, openBrace >= 0); + int depth = 0; + for (int index = openBrace; index < source.length(); index++) { + char current = source.charAt(index); + if (current == '{') { + depth++; + } else if (current == '}') { + depth--; + if (depth == 0) { + return source.substring(start, index + 1); + } + } + } + throw new IllegalArgumentException("Unclosed source contract method: " + signature); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java index 4619e1632..dd91d5e84 100644 --- a/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java +++ b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java @@ -6,8 +6,8 @@ import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBlockData; import art.arcane.iris.engine.object.IrisExpression; -import art.arcane.iris.engine.object.IrisRiverNetwork; -import art.arcane.iris.engine.object.IrisRiverOverride; +import art.arcane.iris.engine.object.IrisHydrology; +import art.arcane.iris.engine.object.IrisRiverPolicy; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.json.JSONArray; @@ -53,91 +53,134 @@ public class IrisRiverSchemaTest { } @Test - public void riverNetworkSchemaExposesNestedNoiseLimitsModesAndBiomePools() { - JSONObject schema = new SchemaBuilder(IrisRiverNetwork.class, schemaData()).construct(); + public void hydrologySchemaExposesTypedPhysicalHierarchy() { + JSONObject schema = new SchemaBuilder(IrisHydrology.class, schemaData()).construct(); JSONObject definitions = schema.getJSONObject("definitions"); JSONObject properties = schema.getJSONObject("properties"); - JSONObject topology = referencedProperties(definitions, properties.getJSONObject("topology")); - JSONObject source = referencedProperties(definitions, topology.getJSONObject("source")); - JSONObject terrainDefinition = referencedDefinition(definitions, properties.getJSONObject("terrain")); - JSONObject terrain = terrainDefinition.getJSONObject("properties"); - JSONObject worms = terrain.getJSONObject("worms"); - JSONObject worm = referencedProperties(definitions, worms.getJSONObject("items")); - JSONObject water = referencedProperties(definitions, properties.getJSONObject("water")); - JSONObject biomes = referencedProperties(definitions, properties.getJSONObject("biomes")); - JSONObject caves = referencedProperties(definitions, properties.getJSONObject("caves")); - JSONObject deepPools = referencedProperties(definitions, caves.getJSONObject("deepPools")); + JSONObject rivers = referencedProperties(definitions, properties.getJSONObject("rivers")); + JSONObject routing = referencedProperties(definitions, rivers.getJSONObject("routing")); + JSONObject branching = referencedProperties(definitions, routing.getJSONObject("branching")); + JSONObject geometry = referencedProperties(definitions, rivers.getJSONObject("geometry")); + JSONObject drops = referencedProperties(definitions, geometry.getJSONObject("drops")); + JSONObject surface = referencedProperties(definitions, rivers.getJSONObject("surface")); + JSONObject sources = referencedProperties(definitions, surface.getJSONObject("sources")); + JSONObject channel = referencedProperties(definitions, surface.getJSONObject("channel")); + JSONObject hydraulics = referencedProperties(definitions, surface.getJSONObject("hydraulics")); + JSONObject ridgeTunnels = referencedProperties(definitions, surface.getJSONObject("ridgeTunnels")); + JSONObject mouths = referencedProperties(definitions, surface.getJSONObject("mouths")); + JSONObject underground = referencedProperties(definitions, rivers.getJSONObject("underground")); + JSONObject undergroundSources = referencedProperties(definitions, underground.getJSONObject("sources")); + JSONObject grottos = referencedProperties(definitions, rivers.getJSONObject("grottos")); + JSONObject coastal = referencedProperties(definitions, grottos.getJSONObject("coastal")); + JSONObject inland = referencedProperties(definitions, grottos.getJSONObject("inland")); + JSONObject profiles = rivers.getJSONObject("profiles"); + JSONObject profile = referencedProperties(definitions, profiles.getJSONObject("items")); + JSONObject deepFluids = properties.getJSONObject("deepFluids"); + JSONObject deepFluid = referencedProperties(definitions, deepFluids.getJSONObject("items")); - assertEquals("boolean", properties.getJSONObject("enabled").getString("type")); - assertEquals(64, topology.getJSONObject("cellSize").getInt("minimum")); - assertEquals(4096, topology.getJSONObject("cellSize").getInt("maximum")); - assertEquals(7, topology.getJSONObject("sinkSearchReaches").getInt("maximum")); - assertEquals(8, topology.getJSONObject("routingBasinCells").getInt("minimum")); - assertEquals(256, topology.getJSONObject("routingBasinCells").getInt("maximum")); - assertEquals(0D, source.getJSONObject("chance").getDouble("minimum"), 0D); - assertEquals(1D, source.getJSONObject("chance").getDouble("maximum"), 0D); - assertEquals(1D, terrain.getJSONObject("maxChannelWidth").getDouble("minimum"), 0D); - assertEquals(2048D, terrain.getJSONObject("maxChannelWidth").getDouble("maximum"), 0D); - assertEquals(0D, terrain.getJSONObject("maxBankWidth").getDouble("minimum"), 0D); - assertEquals(512D, terrain.getJSONObject("maxDepth").getDouble("maximum"), 0D); - assertTrue(arrayContains(terrainDefinition.getJSONArray("required"), "worms")); - assertEquals("array", worms.getString("type")); - assertEquals(1, worms.getInt("minItems")); - assertEquals(0.000001D, worm.getJSONObject("weight").getDouble("minimum"), 0D); - assertEquals(16384D, worm.getJSONObject("wavelength").getDouble("maximum"), 0D); - assertEquals(1D, worm.getJSONObject("tortuosity").getDouble("maximum"), 0D); - assertEquals(1024D, worm.getJSONObject("maxOffset").getDouble("maximum"), 0D); - assertEquals(64, worm.getJSONObject("segments").getInt("maximum")); - assertEquals(0.125D, worm.getJSONObject("widthMultiplier").getDouble("minimum"), 0D); - assertEquals(8D, worm.getJSONObject("bankMultiplier").getDouble("maximum"), 0D); - assertEquals(8D, worm.getJSONObject("depthMultiplier").getDouble("maximum"), 0D); - assertEquals(8D, worm.getJSONObject("bodyWavelength").getDouble("minimum"), 0D); - assertEquals(16384D, worm.getJSONObject("bodyDetailWavelength").getDouble("maximum"), 0D); - assertEquals(1D, worm.getJSONObject("bodyDetailInfluence").getDouble("maximum"), 0D); - assertEquals(0.875D, worm.getJSONObject("widthVariation").getDouble("maximum"), 0D); - assertEquals(0.875D, worm.getJSONObject("bankVariation").getDouble("maximum"), 0D); - assertEquals(0.875D, worm.getJSONObject("depthVariation").getDouble("maximum"), 0D); - assertEquals(0.875D, worm.getJSONObject("roofVariation").getDouble("maximum"), 0D); - assertEquals(8, worm.getJSONObject("branchCap").getInt("maximum")); - assertEquals(1D, worm.getJSONObject("branchDecay").getDouble("maximum"), 0D); - assertEquals(8D, worm.getJSONObject("confluenceMultiplier").getDouble("maximum"), 0D); - assertEquals(1D, worm.getJSONObject("childChance").getDouble("maximum"), 0D); - assertEquals(1D, worm.getJSONObject("branchChildChance").getDouble("maximum"), 0D); - assertEquals("array", worm.getJSONObject("children").getString("type")); - assertEquals(List.of("FIXED", "TERRACED"), enumValues(definitions, water.getJSONObject("mode"))); - assertEquals(-2048, water.getJSONObject("fluidHeight").getInt("minimum")); - assertEquals(2048, water.getJSONObject("fluidHeight").getInt("maximum")); - assertTrue(water.has("fluidPalette")); - assertEquals(64D, terrain.getJSONObject("channelRadiusBonus").getDouble("maximum"), 0D); - assertEquals("array", biomes.getJSONObject("channel").getString("type")); - assertEquals("#/definitions/erzbiomes", - biomes.getJSONObject("channel").getJSONObject("items").getString("$ref")); - assertTrue(properties.has("terrain")); - assertTrue(properties.has("caves")); - assertEquals("boolean", deepPools.getJSONObject("enabled").getString("type")); - assertEquals(-2048, deepPools.getJSONObject("minimumFluidY").getInt("minimum")); - assertEquals(2048, deepPools.getJSONObject("maximumFluidY").getInt("maximum")); - assertEquals(128, deepPools.getJSONObject("horizontalRadius").getInt("maximum")); - assertEquals(64, deepPools.getJSONObject("verticalRadius").getInt("maximum")); - assertEquals(0.75D, deepPools.getJSONObject("shapeVariation").getDouble("maximum"), 0D); - assertEquals(64D, deepPools.getJSONObject("warpStrength").getDouble("maximum"), 0D); - assertTrue(deepPools.has("reach")); - assertTrue(deepPools.has("shapeStyle")); - assertTrue(deepPools.has("warpStyle")); - assertTrue(deepPools.has("fluidPalette")); + assertEquals(8192, routing.getJSONObject("tileSize").getInt("maximum")); + assertEquals(64, routing.getJSONObject("refinementSpacing").getInt("maximum")); + assertEquals(32768, routing.getJSONObject("maximumRouteLength").getInt("maximum")); + assertEquals(32768, branching.getJSONObject("minimumSurfaceCourseLength").getInt("maximum")); + assertEquals(32768, branching.getJSONObject("minimumUndergroundCourseLength").getInt("maximum")); + JSONObject meanders = referencedProperties(definitions, geometry.getJSONObject("meanders")); + assertEquals(10D, meanders.getJSONObject("maximumTurnDegrees").getDouble("minimum"), 0D); + assertEquals(List.of("SINKHOLE_GROTTO"), + enumValues(definitions, routing.getJSONObject("inlandOutlets").getJSONObject("items"))); + assertEquals(64D, sources.getJSONObject("density").getDouble("maximum"), 0D); + assertEquals(64D, undergroundSources.getJSONObject("density").getDouble("maximum"), 0D); + assertEquals(8192, sources.getJSONObject("minimumSpacing").getInt("maximum")); + assertEquals(8192, undergroundSources.getJSONObject("minimumSpacing").getInt("maximum")); + assertEquals(16, drops.getJSONObject("cascadeRunPerBlock").getInt("maximum")); + assertEquals(6D, drops.getJSONObject("cascadeExponent").getDouble("maximum"), 0D); + assertEquals(4, drops.getJSONObject("maximumCascadeStep").getInt("maximum")); + assertEquals(1D, drops.getJSONObject("flowWidthRatio").getDouble("maximum"), 0D); + assertEquals(16, drops.getJSONObject("maximumFlowDepth").getInt("maximum")); + assertEquals(4D, drops.getJSONObject("basinWidthRatio").getDouble("maximum"), 0D); + assertEquals(32, drops.getJSONObject("maximumBasinDepth").getInt("maximum")); + assertSnippetBackedObjectReference(channel.getJSONObject("width")); + assertSnippetBackedObjectReference(channel.getJSONObject("depth")); + assertSnippetBackedObjectReference(channel.getJSONObject("surfaceInset")); + assertSnippetBackedObjectReference(channel.getJSONObject("terrainBlendWidth")); + assertEquals(64, channel.getJSONObject("maximumIncision").getInt("maximum")); + assertEquals(1D, channel.getJSONObject("shoreWidth").getDouble("minimum"), 0D); + assertEquals(2D, channel.getJSONObject("shoreWidth").getDouble("maximum"), 0D); + assertEquals(0, hydraulics.getJSONObject("riffleDrop").getInt("minimum")); + assertEquals(16, hydraulics.getJSONObject("riffleDrop").getInt("maximum")); + assertEquals(0, hydraulics.getJSONObject("maximumGradualDrop").getInt("minimum")); + assertEquals(1024, hydraulics.getJSONObject("maximumGradualLength").getInt("maximum")); + assertEquals(1, hydraulics.getJSONObject("waterfallMinimumDrop").getInt("minimum")); + assertEquals(128, hydraulics.getJSONObject("waterfallMinimumDrop").getInt("maximum")); + assertEquals(4096, ridgeTunnels.getJSONObject("maximumLength").getInt("maximum")); + assertEquals(0, mouths.getJSONObject("levelingDistance").getInt("minimum")); + assertEquals(64, mouths.getJSONObject("maximumOceanApron").getInt("maximum")); + assertSnippetBackedObjectReference(underground.getJSONObject("fluidLevel")); + assertSnippetBackedObjectReference(underground.getJSONObject("channelWidth")); + assertSnippetBackedObjectReference(underground.getJSONObject("headroom")); + + assertGrottoBounds(coastal); + assertGrottoBounds(inland); + assertEquals("boolean", inland.getJSONObject("connectSurfaceRivers").getString("type")); + assertEquals("array", profiles.getString("type")); + assertEquals(1, profiles.getInt("minItems")); + assertTrue(profile.has("id")); + assertTrue(profile.has("fluidPalette")); + + assertEquals("array", deepFluids.getString("type")); + assertTrue(deepFluid.has("id")); + assertTrue(deepFluid.has("fluidPalette")); + assertTrue(deepFluid.has("height")); + assertEquals(64D, deepFluid.getJSONObject("density").getDouble("maximum"), 0D); + assertEquals(8192, deepFluid.getJSONObject("spacing").getInt("maximum")); + assertEquals(16, deepFluid.getJSONObject("spacing").getInt("minimum")); + assertEquals(128, deepFluid.getJSONObject("horizontalRadius").getInt("maximum")); + assertEquals(2, deepFluid.getJSONObject("horizontalRadius").getInt("minimum")); + assertEquals(64, deepFluid.getJSONObject("verticalRadius").getInt("maximum")); + assertEquals(2, deepFluid.getJSONObject("verticalRadius").getInt("minimum")); + assertEquals(32, deepFluid.getJSONObject("channelWidth").getInt("maximum")); + assertEquals(32, deepFluid.getJSONObject("depth").getInt("maximum")); + assertEquals(63, deepFluid.getJSONObject("headroom").getInt("maximum")); } @Test - public void overrideSchemaKeepsEveryFieldOptionalAndTyped() { - JSONObject schema = new SchemaBuilder(IrisRiverOverride.class, schemaData()).construct(); + public void riverPolicySchemaKeepsEveryOverlayOptionalAndTyped() { + JSONObject schema = new SchemaBuilder(IrisRiverPolicy.class, schemaData()).construct(); + JSONObject definitions = schema.getJSONObject("definitions"); JSONObject properties = schema.getJSONObject("properties"); assertTrue(!schema.has("required") || schema.getJSONArray("required").length() == 0); - assertEquals("boolean", properties.getJSONObject("allowSources").getString("type")); - assertEquals("number", properties.getJSONObject("routingCostMultiplier").getString("type")); - assertEquals("array", properties.getJSONObject("channelBiomes").getString("type")); + assertEquals(List.of( + "DISABLED", + "TRANSIT_ONLY", + "NATURAL", + "PREFERRED_HEADWATER", + "REQUIRED_HEADWATER" + ), + enumValues(definitions, properties.getJSONObject("placement"))); + assertEquals(List.of("BLOCK", "AVOID", "ALLOW", "PREFER"), + enumValues(definitions, properties.getJSONObject("routing"))); + assertEquals("boolean", properties.getJSONObject("outletAdmission").getString("type")); + assertEquals("array", properties.getJSONObject("profiles").getString("type")); + assertEquals("array", properties.getJSONObject("surfaceBiomes").getString("type")); + assertEquals("array", properties.getJSONObject("mouthBiomes").getString("type")); + assertEquals("array", properties.getJSONObject("shoreBiomes").getString("type")); + assertEquals("array", properties.getJSONObject("dryBiomes").getString("type")); + assertEquals("array", properties.getJSONObject("floodedCaveBiomes").getString("type")); + assertEquals(16D, properties.getJSONObject("widthMultiplier").getDouble("maximum"), 0D); + assertEquals(0D, properties.getJSONObject("incisionMultiplier").getDouble("minimum"), 0D); + assertEquals(64D, properties.getJSONObject("routingMultiplier").getDouble("maximum"), 0D); assertEquals("#/definitions/erzbiomes", - properties.getJSONObject("floodedCaveBiomes").getJSONObject("items").getString("$ref")); + properties.getJSONObject("shoreBiomes").getJSONObject("items").getString("$ref")); + } + + private static void assertGrottoBounds(JSONObject grotto) { + assertEquals(1, grotto.getJSONObject("horizontalRadius").getInt("minimum")); + assertEquals(1, grotto.getJSONObject("verticalRadius").getInt("minimum")); + assertEquals(128, grotto.getJSONObject("horizontalRadius").getInt("maximum")); + assertEquals(64, grotto.getJSONObject("verticalRadius").getInt("maximum")); + assertEquals(63, grotto.getJSONObject("headroom").getInt("maximum")); + assertEquals(1, grotto.getJSONObject("maximumVolume").getInt("minimum")); + assertEquals(1048576, grotto.getJSONObject("maximumVolume").getInt("maximum")); } @SuppressWarnings("unchecked") @@ -152,7 +195,7 @@ public class IrisRiverSchemaTest { when(data.getBlockLoader()).thenReturn(blockLoader); when(data.getLoaders()).thenReturn(loaders); when(data.getPossibleSnippets(anyString())).thenReturn(new KList<>()); - when(biomeLoader.getPossibleKeys()).thenReturn(new String[]{"river/channel"}); + when(biomeLoader.getPossibleKeys()).thenReturn(new String[]{"river/surface"}); when(biomeLoader.getFolderName()).thenReturn("biomes"); when(biomeLoader.getResourceTypeName()).thenReturn("Biome"); when(blockLoader.getPossibleKeys()).thenReturn(new String[0]); @@ -163,29 +206,33 @@ public class IrisRiverSchemaTest { } private static JSONObject referencedProperties(JSONObject definitions, JSONObject reference) { - return referencedDefinition(definitions, reference).getJSONObject("properties"); - } - - private static JSONObject referencedDefinition(JSONObject definitions, JSONObject reference) { String key = reference.getString("$ref").substring("#/definitions/".length()); - return definitions.getJSONObject(key); + return definitions.getJSONObject(key).getJSONObject("properties"); } - private static boolean arrayContains(JSONArray values, String expected) { - for (int index = 0; index < values.length(); index++) { - if (expected.equals(values.getString(index))) { - return true; + private static void assertSnippetBackedObjectReference(JSONObject property) { + JSONArray variants = property.getJSONArray("anyOf"); + boolean hasObjectReference = false; + boolean hasSnippetReference = false; + for (int index = 0; index < variants.length(); index++) { + JSONObject variant = variants.getJSONObject(index); + if ("object".equals(variant.getString("type")) && variant.has("$ref")) { + hasObjectReference = true; + } + if ("string".equals(variant.getString("type")) && variant.has("$ref")) { + hasSnippetReference = true; } } - return false; + assertTrue(hasObjectReference); + assertTrue(hasSnippetReference); } private static List enumValues(JSONObject definitions, JSONObject reference) { String key = reference.getString("$ref").substring("#/definitions/".length()); - JSONArray values = definitions.getJSONObject(key).getJSONArray("oneOf"); + JSONArray values = definitions.getJSONObject(key).getJSONArray("enum"); List names = new ArrayList<>(values.length()); for (int index = 0; index < values.length(); index++) { - names.add(values.getJSONObject(index).getString("const")); + names.add(values.getString(index)); } return names; } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexGeneratorOrderingTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexGeneratorOrderingTest.java new file mode 100644 index 000000000..bc16f376f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexGeneratorOrderingTest.java @@ -0,0 +1,148 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.object.IrisGenerator; +import art.arcane.iris.engine.object.IrisInterpolator; +import art.arcane.iris.util.project.interpolation.InterpolationMethod; +import org.junit.Test; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.StringJoiner; + +import static org.junit.Assert.assertEquals; + +public class IrisComplexGeneratorOrderingTest { + private static final int[][] PERMUTATIONS = { + {0, 1, 2, 3}, + {3, 2, 1, 0}, + {1, 3, 0, 2}, + {2, 0, 3, 1} + }; + + @Test + public void frozenGroupsUseStableOrderAcrossInsertionPermutations() { + IrisInterpolator bilinearWide = interpolator(InterpolationMethod.BILINEAR, 16D); + IrisInterpolator starcast = interpolator(InterpolationMethod.STARCAST_3, 8D); + IrisInterpolator bilinearNarrow = interpolator(InterpolationMethod.BILINEAR, 4D); + List generators = List.of( + generator("zeta", starcast, 1L, 0D), + generator("beta", bilinearWide, 2L, 0D), + generator("alpha", bilinearWide, 3L, 0D), + generator("delta", bilinearNarrow, 4L, 0D) + ); + + String expected = "BILINEAR@4.0[delta]|BILINEAR@16.0[alpha,beta]|STARCAST_3@8.0[zeta]"; + for (int[] permutation : PERMUTATIONS) { + IrisComplex.GeneratorGroup[] groups = IrisComplex.freezeGeneratorGroups( + groupByInterpolator(generators, permutation) + ); + assertEquals(expected, signature(groups)); + } + } + + @Test + public void frozenGeneratorOrderProducesBitIdenticalOutputAcrossInsertionPermutations() { + IrisInterpolator interpolator = interpolator(InterpolationMethod.BILINEAR_STARCAST_6, 7D); + List generators = List.of( + generator("alpha", interpolator, 1L, 1.0E16D), + generator("beta", interpolator, 2L, -1.0E16D), + generator("gamma", interpolator, 3L, 1D), + generator("omega", interpolator, 4L, 0D) + ); + long expectedBits = Double.doubleToRawLongBits(0.25D); + + for (int[] permutation : PERMUTATIONS) { + IrisComplex.GeneratorGroup[] groups = IrisComplex.freezeGeneratorGroups( + groupByInterpolator(generators, permutation) + ); + double output = 0D; + for (IrisComplex.GeneratorGroup group : groups) { + output += IrisComplex.averageGeneratorHeights( + group.generators(), + 0D, + 1D, + 0D, + 0D, + 0L + ); + } + assertEquals(expectedBits, Double.doubleToRawLongBits(output)); + } + } + + @Test + public void emptyGeneratorCollectionFreezesAndAccumulatesAsEmpty() { + Map> generators = Map.of(); + + assertEquals(0, IrisComplex.freezeGeneratorGroups(generators).length); + assertEquals(0D, IrisComplex.averageGeneratorHeights( + new IrisGenerator[0], + -1D, + 1D, + 0D, + 0D, + 0L + ), 0D); + } + + private IrisInterpolator interpolator(InterpolationMethod function, double horizontalScale) { + return new IrisInterpolator() + .setFunction(function) + .setHorizontalScale(horizontalScale); + } + + private FixedGenerator generator( + String loadKey, + IrisInterpolator interpolator, + long seed, + double height + ) { + FixedGenerator generator = new FixedGenerator(height); + generator.setLoadKey(loadKey); + generator.setInterpolator(interpolator); + generator.setSeed(seed); + return generator; + } + + private Map> groupByInterpolator( + List generators, + int[] permutation + ) { + Map> groups = new LinkedHashMap<>(); + for (int index : permutation) { + IrisGenerator generator = generators.get(index); + groups.computeIfAbsent(generator.getInterpolator(), ignored -> new LinkedHashSet<>()).add(generator); + } + return groups; + } + + private String signature(IrisComplex.GeneratorGroup[] groups) { + StringJoiner groupSignature = new StringJoiner("|"); + for (IrisComplex.GeneratorGroup group : groups) { + StringJoiner generatorKeys = new StringJoiner(","); + for (IrisGenerator generator : group.generators()) { + generatorKeys.add(generator.getLoadKey()); + } + groupSignature.add(group.interpolator().getFunction().name() + + "@" + group.interpolator().getHorizontalScale() + + "[" + generatorKeys + "]"); + } + return groupSignature.toString(); + } + + private static final class FixedGenerator extends IrisGenerator { + private final double height; + + private FixedGenerator(double height) { + this.height = height; + } + + @Override + public double getHeight(double x, double z, long seed) { + return height; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java index a97aeda8b..46f6cd2de 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexGridBoundsCacheTest.java @@ -61,6 +61,51 @@ public class IrisComplexGridBoundsCacheTest { assertEquals(1, interpolator.getInvocations()); } + @Test + public void horizontalGridAxisMatchesLegacyBilerpBitForBit() throws Exception { + IrisComplex complex = createComplex(); + Method gridSampleBounds = gridSampleBoundsMethod(); + CoordinateInterpolator interpolator = new CoordinateInterpolator(); + double x = 67D; + double z = -32D; + + NoiseBounds actual = invokeGridSampleBounds(complex, gridSampleBounds, interpolator, x, z); + NoiseBounds expected = legacyGridSampleBounds(x, z); + + assertBoundsBitsEqual(expected, actual); + assertEquals(2, interpolator.getInvocations()); + } + + @Test + public void verticalGridAxisMatchesLegacyBilerpBitForBitAtNegativeCoordinates() throws Exception { + IrisComplex complex = createComplex(); + Method gridSampleBounds = gridSampleBoundsMethod(); + CoordinateInterpolator interpolator = new CoordinateInterpolator(); + double x = -32D; + double z = -29D; + + NoiseBounds actual = invokeGridSampleBounds(complex, gridSampleBounds, interpolator, x, z); + NoiseBounds expected = legacyGridSampleBounds(x, z); + + assertBoundsBitsEqual(expected, actual); + assertEquals(2, interpolator.getInvocations()); + } + + @Test + public void interiorGridSampleMatchesLegacyBilerpBitForBit() throws Exception { + IrisComplex complex = createComplex(); + Method gridSampleBounds = gridSampleBoundsMethod(); + CoordinateInterpolator interpolator = new CoordinateInterpolator(); + double x = -29D; + double z = 67D; + + NoiseBounds actual = invokeGridSampleBounds(complex, gridSampleBounds, interpolator, x, z); + NoiseBounds expected = legacyGridSampleBounds(x, z); + + assertBoundsBitsEqual(expected, actual); + assertEquals(4, interpolator.getInvocations()); + } + private IrisComplex createComplex() throws Exception { IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS); @@ -133,6 +178,50 @@ public class IrisComplexGridBoundsCacheTest { return (NoiseBounds) method.invoke(complex, null, interpolator, 0, new IrisGenerator[0], x, z); } + private NoiseBounds legacyGridSampleBounds(double x, double z) { + int grid = 4; + int xi = (int) Math.floor(x); + int zi = (int) Math.floor(z); + int mask = grid - 1; + int gx = xi & ~mask; + int gz = zi & ~mask; + double fx = (x - gx) / grid; + double fz = (z - gz) / grid; + NoiseBounds b00 = packedCoordinateBounds(gx, gz); + NoiseBounds b10 = packedCoordinateBounds(gx + grid, gz); + NoiseBounds b01 = packedCoordinateBounds(gx, gz + grid); + NoiseBounds b11 = packedCoordinateBounds(gx + grid, gz + grid); + return new NoiseBounds( + legacyBiLerp(b00.min(), b10.min(), b01.min(), b11.min(), fx, fz), + legacyBiLerp(b00.max(), b10.max(), b01.max(), b11.max(), fx, fz) + ); + } + + private NoiseBounds packedCoordinateBounds(int x, int z) { + return new NoiseBounds( + (float) CoordinateInterpolator.low(x, z), + (float) CoordinateInterpolator.high(x, z) + ); + } + + private double legacyBiLerp( + double v00, + double v10, + double v01, + double v11, + double fx, + double fz + ) { + double a = v00 + ((v10 - v00) * fx); + double b = v01 + ((v11 - v01) * fx); + return a + ((b - a) * fz); + } + + private void assertBoundsBitsEqual(NoiseBounds expected, NoiseBounds actual) { + assertEquals(Double.doubleToRawLongBits(expected.min()), Double.doubleToRawLongBits(actual.min())); + assertEquals(Double.doubleToRawLongBits(expected.max()), Double.doubleToRawLongBits(actual.max())); + } + private float unpackLow(long packed) { return Float.intBitsToFloat((int) (packed >>> 32)); } @@ -159,4 +248,26 @@ public class IrisComplexGridBoundsCacheTest { return invocations.get(); } } + + private static final class CoordinateInterpolator extends IrisInterpolator { + private final AtomicInteger invocations = new AtomicInteger(); + + @Override + public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) { + invocations.incrementAndGet(); + return new NoiseBounds(low(x, z), high(x, z)); + } + + private static double low(double x, double z) { + return x * 0.125D - z * 0.0625D - 7.75D; + } + + private static double high(double x, double z) { + return x * -0.03125D + z * 0.1875D + 12.5D; + } + + private int getInvocations() { + return invocations.get(); + } + } } diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexInferredBiomeStreamCacheTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexInferredBiomeStreamCacheTest.java new file mode 100644 index 000000000..a8fc613c7 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexInferredBiomeStreamCacheTest.java @@ -0,0 +1,139 @@ +package art.arcane.iris.engine; + +import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class IrisComplexInferredBiomeStreamCacheTest { + @Test + public void compilesOncePerRegionIdentityAndPreservesSampling() { + IrisRegion first = new IrisRegion(); + IrisRegion second = new IrisRegion(); + AtomicInteger compilations = new AtomicInteger(); + IdentityHashMap> expected = new IdentityHashMap<>(); + List compilationOrder = new ArrayList<>(); + + Map>> streams = + IrisComplex.compileInferredBiomeStreams( + List.of(first, first, second), + (region, inferredType) -> { + compilations.incrementAndGet(); + compilationOrder.add(inferredType); + IrisBiome biome = new IrisBiome().setInferredType(inferredType); + expected.computeIfAbsent(region, ignored -> new EnumMap<>(InferredType.class)) + .put(inferredType, biome); + return constant(biome); + } + ); + + assertEquals(8, compilations.get()); + assertEquals(List.of( + InferredType.LAND, + InferredType.CAVE, + InferredType.SEA, + InferredType.SHORE, + InferredType.LAND, + InferredType.CAVE, + InferredType.SEA, + InferredType.SHORE + ), compilationOrder); + assertEquals(2, streams.size()); + assertNotSame(streams.get(first), streams.get(second)); + for (InferredType inferredType : InferredType.values()) { + ProceduralStream firstStream = IrisComplex.preparedInferredBiomeStream( + streams, first, inferredType); + assertSame(firstStream, IrisComplex.preparedInferredBiomeStream(streams, first, inferredType)); + assertSame(expected.get(first).get(inferredType), firstStream.get(13D, 17D)); + assertSame( + expected.get(second).get(inferredType), + IrisComplex.preparedInferredBiomeStream(streams, second, inferredType).get(13D, 17D) + ); + } + assertThrows(UnsupportedOperationException.class, streams::clear); + assertThrows( + UnsupportedOperationException.class, + () -> streams.get(first).put(InferredType.LAND, constant(new IrisBiome())) + ); + } + + @Test + public void rejectsOnlyUnpreparedRegionIdentities() { + IrisRegion prepared = new IrisRegion(); + IrisRegion unprepared = new IrisRegion(); + unprepared.setLoadKey("missing"); + Map>> streams = + IrisComplex.compileInferredBiomeStreams( + List.of(prepared), + (region, inferredType) -> constant(new IrisBiome().setInferredType(inferredType)) + ); + + IllegalStateException error = assertThrows( + IllegalStateException.class, + () -> IrisComplex.preparedInferredBiomeStream(streams, unprepared, InferredType.LAND) + ); + + assertTrue(error.getMessage().contains("missing")); + } + + @Test + public void immutableCacheSupportsConcurrentReads() throws Exception { + IrisRegion region = new IrisRegion(); + IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND); + Map>> streams = + IrisComplex.compileInferredBiomeStreams( + List.of(region), + (preparedRegion, inferredType) -> constant( + inferredType == InferredType.LAND + ? biome + : new IrisBiome().setInferredType(inferredType) + ) + ); + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + List> reads = new ArrayList<>(); + for (int task = 0; task < 32; task++) { + reads.add(executor.submit(() -> { + IrisBiome sampled = null; + for (int read = 0; read < 1_000; read++) { + sampled = IrisComplex.preparedInferredBiomeStream( + streams, region, InferredType.LAND).get(read, -read); + } + return sampled; + })); + } + for (Future read : reads) { + assertSame(biome, read.get(5, TimeUnit.SECONDS)); + } + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + private static ProceduralStream constant(IrisBiome biome) { + return ProceduralStream.of( + (x, z) -> biome, + Interpolated.of(value -> 0D, value -> biome) + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java b/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java index 677157c2f..f2d6bc775 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java @@ -1,138 +1,114 @@ package art.arcane.iris.engine; -import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; import art.arcane.iris.engine.object.InferredType; +import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisRiverOverride; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.iris.util.project.stream.interpolation.Interpolated; import org.junit.Test; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertSame; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertSame; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; public class IrisComplexSurfaceBiomeTest { @Test - public void focusBiomeWithoutOverrideIgnoresOverridesOutsideFocus() { - IrisBiome focusBiome = new IrisBiome(); - IrisBiome unreachableBiome = new IrisBiome().setRiverOverride(new IrisRiverOverride()); + public void gradingFootprintKeepsItsExactNaturalParentBiome() { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 1L, + HydrologyFeatureType.SURFACE_POOL, + 2L, + 3L, + 4, + 70, + 5, + 1, + 0, + false + ); + HydrologyColumnLayer grading = new HydrologyColumnLayer( + feature, + 72, + 74, + 74, + false, + false, + true, + false, + false, + false, + true, + false, + false, + "water", + "river", + "mouth", + "shore", + "dry", + "flooded" + ); + HydrologyColumnSample sample = new HydrologyColumnSample( + 4, + 5, + 76, + 63, + false, + "exact_parent", + List.of(grading) + ); - assertFalse(IrisComplex.biomeRiverOverridesPossible(focusBiome, List.of(unreachableBiome))); - } - - @Test - public void focusBiomeOverrideAlwaysEnablesBiomeSampling() { - IrisBiome focusBiome = new IrisBiome().setRiverOverride(new IrisRiverOverride()); - - assertTrue(IrisComplex.biomeRiverOverridesPossible(focusBiome, List.of())); - } - - @Test - public void nonFocusBiomeSamplingTracksReachableOverrides() { - assertFalse(IrisComplex.biomeRiverOverridesPossible(null, List.of(new IrisBiome()))); - assertTrue(IrisComplex.biomeRiverOverridesPossible( - null, - List.of(new IrisBiome().setRiverOverride(new IrisRiverOverride())) - )); - } - - @Test - public void maxIncisionCapabilityIgnoresIdentityOverrides() { - assertFalse(IrisComplex.changesMaxIncision(null)); - assertFalse(IrisComplex.changesMaxIncision(new IrisRiverOverride())); - assertFalse(IrisComplex.changesMaxIncision( - new IrisRiverOverride().setMaxIncisionMultiplier(1D))); - assertTrue(IrisComplex.changesMaxIncision( - new IrisRiverOverride().setMaxIncisionMultiplier(0.75D))); + assertEquals("exact_parent", IrisComplex.hydrologySurfaceBiomeKey(sample)); } @Test public void naturalOceanMaskTracksContinentalIntent() { - assertTrue(IrisComplex.createNaturalOceanStream( + assertEquals(InferredType.SEA, IrisComplex.resolveNaturalInferredType( constantType(InferredType.SEA), - null - ).get(0D, 0D)); - assertFalse(IrisComplex.createNaturalOceanStream( + null, + 0D, + 0D + )); + assertEquals(InferredType.LAND, IrisComplex.resolveNaturalInferredType( constantType(InferredType.LAND), - null - ).get(0D, 0D)); + null, + 0D, + 0D + )); } @Test - public void naturalOceanMaskDoesNotSampleNaturalHeight() { - AtomicInteger heightSamples = new AtomicInteger(); - ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { - heightSamples.incrementAndGet(); - return 62D; - }); - - Boolean ocean = IrisComplex.createNaturalOceanStream( + public void focusNaturalOceanMaskUsesTheFocusedSurfaceType() { + assertEquals(InferredType.SEA, IrisComplex.resolveNaturalInferredType( constantType(InferredType.LAND), - null - ).get(8D, -3D); - - assertFalse(ocean); - assertEquals(0, heightSamples.get()); + new IrisBiome().setInferredType(InferredType.SEA), + 0D, + 0D + )); + assertEquals(InferredType.LAND, IrisComplex.resolveNaturalInferredType( + constantType(InferredType.SEA), + new IrisBiome().setInferredType(InferredType.LAND), + 0D, + 0D + )); + assertEquals(InferredType.SHORE, IrisComplex.resolveNaturalInferredType( + constantType(InferredType.SEA), + new IrisBiome().setInferredType(InferredType.SHORE), + 0D, + 0D + )); } @Test - public void focusNaturalOceanMaskIgnoresHeightForEverySurfaceType() { - AtomicInteger heightSamples = new AtomicInteger(); - ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { - heightSamples.incrementAndGet(); - return -1_000D; - }); - - assertTrue(IrisComplex.createNaturalOceanStream( - constantType(InferredType.LAND), - new IrisBiome().setInferredType(InferredType.SEA) - ).get(0D, 0D)); - assertFalse(IrisComplex.createNaturalOceanStream( - constantType(InferredType.SEA), - new IrisBiome().setInferredType(InferredType.LAND) - ).get(0D, 0D)); - assertFalse(IrisComplex.createNaturalOceanStream( - constantType(InferredType.SEA), - new IrisBiome().setInferredType(InferredType.SHORE) - ).get(0D, 0D)); - assertEquals(0, heightSamples.get()); - } - - @Test - public void fixedOceanMaskUsesContinentalIntentWithoutSamplingHeight() { - AtomicInteger heightSamples = new AtomicInteger(); - ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { - heightSamples.incrementAndGet(); - return -1_000D; - }); - - Boolean ocean = IrisComplex.createNaturalOceanStream( - constantType(InferredType.SEA), - null - ).get(8D, -3D); - - assertTrue(ocean); - assertEquals(0, heightSamples.get()); - } - - @Test - public void emptyRiverPoolsKeepWetChannelsAquaticAndDryChannelsLand() { - assertEquals(InferredType.SEA, IrisComplex.directRiverFallback(sample(RiverRouteState.WET, RiverSection.CHANNEL))); - assertEquals(InferredType.SEA, IrisComplex.directRiverFallback(sample(RiverRouteState.WET, RiverSection.MOUTH))); - assertEquals(InferredType.LAND, IrisComplex.directRiverFallback(sample(RiverRouteState.DRY, RiverSection.DRY_BANK))); - assertNull(IrisComplex.directRiverFallback(sample(RiverRouteState.WET, RiverSection.BANK))); + public void naturalSlopeMatchesTheRangeThreeStreamFormula() { + assertEquals(5D, IrisComplex.calculateNaturalSlope(10D, 13D, 14D), 0D); } @Test @@ -153,10 +129,12 @@ public class IrisComplexSurfaceBiomeTest { 70D, constant(base), constant(sea), - constant(base)); + constant(base) + ); assertSame(sea, resolved); } + @Test public void shorelineHeightSelectsShoreBiome() { IrisBiome base = mock(IrisBiome.class); @@ -174,7 +152,8 @@ public class IrisComplexSurfaceBiomeTest { 63D, constant(base), constant(base), - constant(shore)); + constant(shore) + ); assertSame(shore, resolved); } @@ -197,7 +176,8 @@ public class IrisComplexSurfaceBiomeTest { 63D, constant(land), constant(aquatic), - constant(aquatic)); + constant(aquatic) + ); assertSame(land, resolved); } @@ -215,8 +195,4 @@ public class IrisComplexSurfaceBiomeTest { Interpolated.of(value -> 0D, value -> type) ); } - - private static RiverSample sample(RiverRouteState state, RiverSection section) { - return new RiverSample(true, state, section, 0D, 0.5D, 1D, 1, 1, 10D, 5D, 3D, false, null); - } } diff --git a/core/src/test/java/art/arcane/iris/engine/actuator/IrisDecorantActuatorRiverTest.java b/core/src/test/java/art/arcane/iris/engine/actuator/IrisDecorantActuatorRiverTest.java index 38872700c..7e8042b02 100644 --- a/core/src/test/java/art/arcane/iris/engine/actuator/IrisDecorantActuatorRiverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisDecorantActuatorRiverTest.java @@ -1,40 +1,251 @@ package art.arcane.iris.engine.actuator; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; 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.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; public class IrisDecorantActuatorRiverTest { @Test - public void dryRiverSurfaceDoesNotRunShorelineDecoration() { - IrisRiverSurfaceSample dry = new IrisRiverSurfaceSample( - river(RiverRouteState.DRY, RiverSection.DRY_CHANNEL), - 70D, - 65D, - 65D, + public void acceptedSurfaceRoleControlsShorelineDecoration() { + HydrologyColumnSample shore = surfaceRole(false, true, true); + HydrologyColumnSample channel = surfaceRole(true, false, false); + HydrologyColumnSample grading = surfaceRole(false, false, true); + HydrologyColumnSample underground = sample(HydrologyFeatureType.UNDERGROUND_POOL, true, true); + HydrologyColumnSample overlap = new HydrologyColumnSample( + 0, + 0, + 70, + 63, false, + "parent", + List.of( + layer(1L, true, false, false, HydrologyFeatureType.SURFACE_POOL, true, true), + layer(2L, false, true, true, HydrologyFeatureType.SURFACE_POOL, false, false) + ) + ); + + assertEquals( + IrisDecorantActuator.ShorelineDecorationMode.ACCEPTED, + IrisDecorantActuator.shorelineDecorationMode(shore, 62, 65) + ); + assertEquals( + IrisDecorantActuator.ShorelineDecorationMode.NONE, + IrisDecorantActuator.shorelineDecorationMode(channel, 65, 65) + ); + assertEquals( + IrisDecorantActuator.ShorelineDecorationMode.NONE, + IrisDecorantActuator.shorelineDecorationMode(grading, 65, 65) + ); + assertEquals( + IrisDecorantActuator.ShorelineDecorationMode.NONE, + IrisDecorantActuator.shorelineDecorationMode(overlap, 65, 65) + ); + assertEquals( + IrisDecorantActuator.ShorelineDecorationMode.LEGACY, + IrisDecorantActuator.shorelineDecorationMode(underground, 65, 65) + ); + assertEquals( + IrisDecorantActuator.ShorelineDecorationMode.LEGACY, + IrisDecorantActuator.shorelineDecorationMode(null, 65, 65) + ); + assertEquals( + IrisDecorantActuator.ShorelineDecorationMode.NONE, + IrisDecorantActuator.shorelineDecorationMode(null, 64, 65) + ); + } + + @Test + public void onlyAcceptedFallingWaterfallThroatsRejectSurfaceDecoration() { + assertTrue(IrisDecorantActuator.isFallingWaterfallThroat( + sample(HydrologyFeatureType.WATERFALL, true, true, true, true) + )); + assertTrue(IrisDecorantActuator.isFallingWaterfallThroat( + sample(HydrologyFeatureType.SINKHOLE, true, true, true, true) + )); + assertFalse(IrisDecorantActuator.isFallingWaterfallThroat( + sample(HydrologyFeatureType.WATERFALL, false, false, false, false) + )); + assertFalse(IrisDecorantActuator.isFallingWaterfallThroat( + sample(HydrologyFeatureType.UNDERGROUND_DROP, true, true) + )); + assertFalse(IrisDecorantActuator.isFallingWaterfallThroat(null)); + } + + @Test + public void seaDecoratorsRequireConnectedWaterBelowTheConfiguredSurface() { + Hunk output = Hunk.newArrayHunk(3, 4, 1); + PlatformBlockState water = mock(PlatformBlockState.class); + PlatformBlockState adjacentWater = mock(PlatformBlockState.class); + doReturn(true).when(water).isWater(); + doReturn(true).when(adjacentWater).isWater(); + output.set(1, 1, 0, water); + + assertFalse(IrisDecorantActuator.hasConnectedSurfaceWater(output, 1, 0, 0, 1)); + assertFalse(IrisDecorantActuator.hasConnectedSurfaceWater(output, 1, 0, 0, 2)); + + output.set(0, 1, 0, adjacentWater); + assertTrue(IrisDecorantActuator.hasConnectedSurfaceWater(output, 1, 0, 0, 2)); + } + + @Test + public void seaDecoratorStacksRequireConnectedWaterAtEveryPlacedLevel() { + Hunk output = Hunk.newArrayHunk(3, 4, 1); + PlatformBlockState lowerWater = waterState(); + PlatformBlockState upperWater = waterState(); + PlatformBlockState adjacentWater = waterState(); + output.set(1, 1, 0, lowerWater); + output.set(1, 2, 0, upperWater); + output.set(0, 1, 0, adjacentWater); + + assertFalse(IrisDecorantActuator.hasConnectedWaterColumn(output, 1, 0, 1, 2)); + + output.set(0, 2, 0, waterState()); + assertTrue(IrisDecorantActuator.hasConnectedWaterColumn(output, 1, 0, 1, 2)); + } + + @Test + public void dryAquaticSeaSurfacePlacementRestoresTheOriginalBlock() { + Hunk output = Hunk.newArrayHunk(3, 3, 1); + PlatformBlockState air = state("minecraft:air"); + PlatformBlockState seagrass = state("minecraft:seagrass"); + output.set(1, 1, 0, air); + + output.set(1, 1, 0, seagrass); + IrisDecorantActuator.restoreUnsupportedAquaticPlacement(output, 1, 1, 0, air); + + assertSame(air, output.get(1, 1, 0)); + } + + private static HydrologyColumnSample sample( + HydrologyFeatureType type, + boolean connectedFluid, + boolean fluidOwned + ) { + return sample( + type, + connectedFluid, + fluidOwned, + true, + type == HydrologyFeatureType.WATERFALL || type == HydrologyFeatureType.SINKHOLE + ); + } + + private static HydrologyColumnSample sample( + HydrologyFeatureType type, + boolean connectedFluid, + boolean fluidOwned, + boolean channel, + boolean fallingFluid + ) { + HydrologyColumnLayer layer = layer( + 1L, + channel, + false, + !channel, + type, + connectedFluid, + fluidOwned, + fallingFluid + ); + return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer)); + } + + private static HydrologyColumnSample surfaceRole( + boolean channel, + boolean shore, + boolean grading + ) { + HydrologyColumnLayer layer = layer( + 1L, + channel, + shore, + grading, + HydrologyFeatureType.SURFACE_POOL, + channel, + channel + ); + return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer)); + } + + private static HydrologyColumnLayer layer( + long id, + boolean channel, + boolean shore, + boolean grading, + HydrologyFeatureType type, + boolean connectedFluid, + boolean fluidOwned + ) { + return layer(id, channel, shore, grading, type, connectedFluid, fluidOwned, false); + } + + private static HydrologyColumnLayer layer( + long id, + boolean channel, + boolean shore, + boolean grading, + HydrologyFeatureType type, + boolean connectedFluid, + boolean fluidOwned, + boolean fallingFluid + ) { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + id, + type, + 2L, + 3L, + 0, + 65, + 0, + 1, + 0, false ); - IrisRiverSurfaceSample wet = new IrisRiverSurfaceSample( - river(RiverRouteState.WET, RiverSection.CHANNEL), - 70D, - 65D, - 65D, + return new HydrologyColumnLayer( + feature, + 63, + 65, + type.isUnderground() ? 72 : 65, + channel, + shore, + grading, + connectedFluid, + fallingFluid, + fallingFluid, + true, + fluidOwned, false, - true + "water", + "river", + "mouth", + "shore", + "dry", + "cave" ); - - assertFalse(IrisDecorantActuator.shouldDecorateShoreline(dry, 65)); - assertTrue(IrisDecorantActuator.shouldDecorateShoreline(wet, 65)); - assertTrue(IrisDecorantActuator.shouldDecorateShoreline(IrisRiverSurfaceSample.none(65D, 65D), 65)); } - private static RiverSample river(RiverRouteState state, RiverSection section) { - return new RiverSample(true, state, section, 0D, 0.5D, 1D, 1, 1, 10D, 5D, 3D, false, null); + private static PlatformBlockState waterState() { + PlatformBlockState water = state("minecraft:water"); + doReturn(true).when(water).isWater(); + return water; + } + + private static PlatformBlockState state(String key) { + PlatformBlockState state = mock(PlatformBlockState.class); + doReturn(key).when(state).key(); + return state; } } diff --git a/core/src/test/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuatorHydrologyTest.java b/core/src/test/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuatorHydrologyTest.java new file mode 100644 index 000000000..6d433b7fa --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisTerrainNormalActuatorHydrologyTest.java @@ -0,0 +1,115 @@ +package art.arcane.iris.engine.actuator; + +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.object.IrisDecorationStep; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.collection.KList; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisTerrainNormalActuatorHydrologyTest { + @Test + public void hydrologyOwnedFluidBypassesBiomeSeaLayers() { + PlatformBlockState water = mock(PlatformBlockState.class); + PlatformBlockState ice = mock(PlatformBlockState.class); + KList seaLayers = new KList<>(); + seaLayers.add(ice); + + assertSame(water, HydrologyFluidLayerSelector.select(seaLayers, 0, water, true)); + assertSame(ice, HydrologyFluidLayerSelector.select(seaLayers, 0, water, false)); + assertSame(water, HydrologyFluidLayerSelector.select(seaLayers, 1, water, false)); + } + + @Test + public void frozenTerrainPublishesSourceWaterBeforeStandardTopLayerFreezing() { + PlatformBlockState water = mock(PlatformBlockState.class); + PlatformBlockState ice = mock(PlatformBlockState.class); + when(water.key()).thenReturn("minecraft:water"); + when(water.isWater()).thenReturn(true); + when(ice.key()).thenReturn("minecraft:ice"); + KList frozenSeaLayers = new KList<>(); + frozenSeaLayers.add(ice); + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 11L, + HydrologyFeatureType.SURFACE_POOL, + 12L, + 13L, + 4, + 70, + 6, + 1, + 0, + true + ); + HydrologyColumnLayer acceptedLayer = new HydrologyColumnLayer( + feature, + 67, + 70, + 70, + true, + false, + false, + true, + false, + false, + true, + true, + false, + "default", + "frozen_river", + "frozen_mouth", + "frozen_shore", + "frozen_dry", + "frozen_cave" + ); + HydrologyColumnSample acceptedColumn = new HydrologyColumnSample( + 4, + 6, + 76, + 63, + false, + "frozen_parent", + List.of(acceptedLayer) + ); + boolean hydrologyOwned = acceptedColumn.primarySurfaceFluidLayer().isPresent(); + ArrayList stages = new ArrayList<>(); + + PlatformBlockState state = HydrologyFluidLayerSelector.select( + frozenSeaLayers, + 0, + water, + hydrologyOwned + ); + stages.add("hydrology"); + assertTrue(hydrologyOwned); + assertSame(water, state); + assertEquals("minecraft:water", state.key()); + assertTrue(state.isWater()); + assertFalse(acceptedLayer.fallingFluid()); + + for (IrisDecorationStep step : IrisDecorationStep.values()) { + if (step == IrisDecorationStep.TOP_LAYER_MODIFICATION) { + assertSame(water, state); + state = ice; + stages.add(step.getSerializedName()); + } + } + + assertEquals(List.of("hydrology", "top_layer_modification"), stages); + assertSame(ice, state); + assertEquals("minecraft:ice", state.key()); + assertFalse(state.isWater()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java index 897360900..bb2d2d438 100644 --- a/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java +++ b/core/src/test/java/art/arcane/iris/engine/decorator/IrisDecoratorCaveContextTest.java @@ -16,6 +16,7 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyDouble; @@ -74,4 +75,179 @@ public class IrisDecoratorCaveContextTest { assertTrue(IrisSurfaceDecorator.isUnderwater(null, 10, 63)); assertFalse(IrisSurfaceDecorator.skipsFluid(null)); } + + @Test + @SuppressWarnings("unchecked") + public void underwaterSurfaceDecorationRequiresActualFluidAboveTheTerrain() { + Hunk output = mock(Hunk.class); + PlatformBlockState dry = mock(PlatformBlockState.class); + PlatformBlockState fluid = mock(PlatformBlockState.class); + doReturn(128).when(output).getHeight(); + doReturn(dry).when(output).get(0, 11, 0); + doReturn(false).when(dry).isFluid(); + + assertFalse(IrisSurfaceDecorator.hasFluidAbove(output, 0, 10, 0)); + + doReturn(fluid).when(output).get(0, 11, 0); + doReturn(true).when(fluid).isFluid(); + + assertTrue(IrisSurfaceDecorator.hasFluidAbove(output, 0, 10, 0)); + } + + @Test + public void aquaticPlacementClassificationCoversWaterPlantsCoralAndWaterloggedStates() { + assertTrue(IrisSurfaceDecorator.isAquaticPlacement(state("minecraft:seagrass"))); + assertTrue(IrisSurfaceDecorator.isAquaticPlacement(state("minecraft:tall_seagrass[half=lower]"))); + assertTrue(IrisSurfaceDecorator.isAquaticPlacement(state("minecraft:kelp"))); + assertTrue(IrisSurfaceDecorator.isAquaticPlacement(state("minecraft:kelp_plant"))); + assertTrue(IrisSurfaceDecorator.isAquaticPlacement(state("minecraft:sea_pickle[pickles=2]"))); + assertTrue(IrisSurfaceDecorator.isAquaticPlacement(state("minecraft:fire_coral_fan"))); + + PlatformBlockState waterlogged = state("minecraft:oak_fence[waterlogged=true]"); + doReturn(true).when(waterlogged).isWaterLogged(); + assertTrue(IrisSurfaceDecorator.isAquaticPlacement(waterlogged)); + + assertFalse(IrisSurfaceDecorator.isAquaticPlacement(state("minecraft:stone"))); + assertFalse(IrisSurfaceDecorator.isAquaticPlacement(state("minecraft:dead_fire_coral_fan"))); + } + + @Test + public void compatibleWaterMustBeHorizontallyConnected() { + Hunk output = Hunk.newArrayHunk(3, 3, 1); + PlatformBlockState centerWater = waterState(); + PlatformBlockState neighborWater = waterState(); + PlatformBlockState lava = fluidState(); + output.set(1, 1, 0, centerWater); + + assertFalse(IrisSurfaceDecorator.hasConnectedWater(output, 1, 1, 0)); + + output.set(0, 1, 0, lava); + assertFalse(IrisSurfaceDecorator.hasConnectedWater(output, 1, 1, 0)); + + output.set(0, 1, 0, neighborWater); + assertTrue(IrisSurfaceDecorator.hasConnectedWater(output, 1, 1, 0)); + } + + @Test + public void aquaticPlacementAtFluidHeightBoundaryRestoresDryBank() { + IrisData data = mock(IrisData.class); + PlatformBlockState air = state("minecraft:air"); + PlatformBlockState seagrass = state("minecraft:seagrass"); + IrisDecorator decorator = aquaticDecorator(data, seagrass, false, 1); + Hunk output = Hunk.newArrayHunk(3, 4, 1); + output.set(1, 1, 0, air); + + IrisSurfaceDecorator.AquaticPlacementSnapshot snapshot = IrisSurfaceDecorator.captureAquaticPlacement( + decorator, data, output, 1, 0, 0, 4); + output.set(1, 1, 0, seagrass); + snapshot.restoreIfUnsupported(output, 1, 0); + + assertSame(air, output.get(1, 1, 0)); + } + + @Test + public void legitimateUnderwaterAquaticPlacementIsPreserved() { + IrisData data = mock(IrisData.class); + PlatformBlockState sourceWater = waterState(); + PlatformBlockState adjacentWater = waterState(); + PlatformBlockState kelp = state("minecraft:kelp_plant"); + IrisDecorator decorator = aquaticDecorator(data, kelp, true, 1); + Hunk output = Hunk.newArrayHunk(3, 4, 1); + output.set(1, 1, 0, sourceWater); + output.set(0, 1, 0, adjacentWater); + + IrisSurfaceDecorator.AquaticPlacementSnapshot snapshot = IrisSurfaceDecorator.captureAquaticPlacement( + decorator, data, output, 1, 0, 0, 4); + output.set(1, 1, 0, kelp); + snapshot.restoreIfUnsupported(output, 1, 0); + + assertSame(kelp, output.get(1, 1, 0)); + } + + @Test + public void unsupportedUpperStackRestoresTheWholeAquaticPlacement() { + IrisData data = mock(IrisData.class); + PlatformBlockState lowerWater = waterState(); + PlatformBlockState upperWater = waterState(); + PlatformBlockState adjacentWater = waterState(); + PlatformBlockState kelp = state("minecraft:kelp_plant"); + IrisDecorator decorator = aquaticDecorator(data, kelp, true, 2); + Hunk output = Hunk.newArrayHunk(3, 4, 1); + output.set(1, 1, 0, lowerWater); + output.set(1, 2, 0, upperWater); + output.set(0, 1, 0, adjacentWater); + + IrisSurfaceDecorator.AquaticPlacementSnapshot snapshot = IrisSurfaceDecorator.captureAquaticPlacement( + decorator, data, output, 1, 0, 0, 4); + output.set(1, 1, 0, kelp); + output.set(1, 2, 0, kelp); + snapshot.restoreIfUnsupported(output, 1, 0); + + assertSame(lowerWater, output.get(1, 1, 0)); + assertSame(upperWater, output.get(1, 2, 0)); + } + + @Test + public void dryWaterloggedAndTwoBlockAquaticPlacementsAreRestoredAtomically() { + IrisData data = mock(IrisData.class); + PlatformBlockState lowerAir = state("minecraft:air"); + PlatformBlockState upperAir = state("minecraft:air"); + PlatformBlockState lower = state("minecraft:tall_seagrass[half=lower]"); + PlatformBlockState upper = state("minecraft:tall_seagrass[half=upper]"); + IrisDecorator decorator = aquaticDecorator(data, lower, false, 1); + Hunk output = Hunk.newArrayHunk(3, 4, 1); + output.set(1, 1, 0, lowerAir); + output.set(1, 2, 0, upperAir); + + IrisSurfaceDecorator.AquaticPlacementSnapshot snapshot = IrisSurfaceDecorator.captureAquaticPlacement( + decorator, data, output, 1, 0, 0, 4); + output.set(1, 1, 0, lower); + output.set(1, 2, 0, upper); + snapshot.restoreIfUnsupported(output, 1, 0); + + assertSame(lowerAir, output.get(1, 1, 0)); + assertSame(upperAir, output.get(1, 2, 0)); + + PlatformBlockState dryWaterlogged = state("minecraft:mangrove_roots[waterlogged=true]"); + doReturn(true).when(dryWaterlogged).isWaterLogged(); + IrisDecorator waterloggedDecorator = aquaticDecorator(data, dryWaterlogged, false, 1); + IrisSurfaceDecorator.AquaticPlacementSnapshot waterloggedSnapshot = IrisSurfaceDecorator.captureAquaticPlacement( + waterloggedDecorator, data, output, 1, 0, 0, 4); + output.set(1, 1, 0, dryWaterlogged); + waterloggedSnapshot.restoreIfUnsupported(output, 1, 0); + + assertSame(lowerAir, output.get(1, 1, 0)); + } + + private IrisDecorator aquaticDecorator( + IrisData data, + PlatformBlockState aquatic, + boolean stacking, + int stackMaximum + ) { + IrisDecorator decorator = mock(IrisDecorator.class); + doReturn(new PlatformBlockState[]{aquatic}).when(decorator).getBlockDataArray(data); + doReturn(new PlatformBlockState[0]).when(decorator).getBlockDataTopsArray(data); + doReturn(stacking).when(decorator).isStacking(); + doReturn(stackMaximum).when(decorator).getStackMax(); + return decorator; + } + + private PlatformBlockState state(String key) { + PlatformBlockState state = mock(PlatformBlockState.class); + doReturn(key).when(state).key(); + return state; + } + + private PlatformBlockState waterState() { + PlatformBlockState water = fluidState(); + doReturn(true).when(water).isWater(); + return water; + } + + private PlatformBlockState fluidState() { + PlatformBlockState fluid = state("minecraft:lava"); + doReturn(true).when(fluid).isFluid(); + return fluid; + } } diff --git a/core/src/test/java/art/arcane/iris/engine/decorator/IrisShoreLineDecoratorTest.java b/core/src/test/java/art/arcane/iris/engine/decorator/IrisShoreLineDecoratorTest.java index 101b6eee8..14ae48177 100644 --- a/core/src/test/java/art/arcane/iris/engine/decorator/IrisShoreLineDecoratorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/decorator/IrisShoreLineDecoratorTest.java @@ -75,19 +75,101 @@ public class IrisShoreLineDecoratorTest { } @Test - public void forcePlaceStillRejectsMissingSurface() { - Fixture fixture = createFixture(true); + public void acceptedShoreUsesPublishedGeometry() { + Fixture fixture = createFixture(false, false); + PlatformBlockState support = sturdyState(); PlatformBlockState targetAir = airState(); - Hunk output = output(airState(), targetAir); + when(fixture.decorant.canPlaceOnto(support)).thenReturn(true); + Hunk output = output(support, targetAir); fixture.shoreline.decorate(0, 0, 0, 1, -1, 0, 1, -1, output, fixture.biome, FLUID_HEIGHT, output.getHeight()); assertSame(targetAir, output.get(0, FLUID_HEIGHT + 1, 0)); + + fixture.shoreline.decorateAcceptedShore( + 0, + 0, + 0, + 0, + output, + fixture.biome, + FLUID_HEIGHT, + output.getHeight() + ); + + assertSame(fixture.decorant, output.get(0, FLUID_HEIGHT + 1, 0)); + } + + @Test + public void forcePlaceStillRejectsMissingSurface() { + Fixture fixture = createFixture(true); + PlatformBlockState targetAir = airState(); + Hunk output = output(airState(), targetAir); + + fixture.shoreline.decorateAcceptedShore( + 0, + 0, + 0, + 0, + output, + fixture.biome, + FLUID_HEIGHT, + output.getHeight() + ); + + assertSame(targetAir, output.get(0, FLUID_HEIGHT + 1, 0)); + } + + @Test + public void unsupportedWaterloggedStackRestoresEveryOriginalBlock() { + Fixture fixture = createFixture(false, false); + PlatformBlockState support = sturdyState(); + PlatformBlockState lowerOriginal = airState(); + PlatformBlockState upperOriginal = airState(); + PlatformBlockState waterloggedTop = mock(PlatformBlockState.class); + when(fixture.decorant.key()).thenReturn("minecraft:grass"); + when(fixture.decorant.canPlaceOnto(support)).thenReturn(true); + when(waterloggedTop.isWaterLogged()).thenReturn(true); + when(fixture.decorator.isStacking()).thenReturn(true); + when(fixture.decorator.isScaleStack()).thenReturn(false); + when(fixture.decorator.getStackMax()).thenReturn(2); + when(fixture.decorator.getHeight(any(), anyDouble(), anyDouble(), eq(fixture.data))).thenReturn(2); + when(fixture.decorator.getTopThreshold()).thenReturn(1D); + when(fixture.decorator.getBlockDataArray(fixture.data)).thenReturn(new PlatformBlockState[0]); + when(fixture.decorator.getBlockDataTopsArray(fixture.data)) + .thenReturn(new PlatformBlockState[]{waterloggedTop}); + when(fixture.decorator.getBlockDataForTop( + eq(fixture.biome), any(), anyDouble(), anyDouble(), anyDouble(), eq(fixture.data))) + .thenReturn(waterloggedTop); + + Hunk output = Hunk.newArrayHunk(1, FLUID_HEIGHT + 3, 1); + output.set(0, FLUID_HEIGHT, 0, support); + output.set(0, FLUID_HEIGHT + 1, 0, lowerOriginal); + output.set(0, FLUID_HEIGHT + 2, 0, upperOriginal); + + fixture.shoreline.decorateAcceptedShore( + 0, + 0, + 0, + 0, + output, + fixture.biome, + FLUID_HEIGHT, + output.getHeight() + ); + + assertSame(lowerOriginal, output.get(0, FLUID_HEIGHT + 1, 0)); + assertSame(upperOriginal, output.get(0, FLUID_HEIGHT + 2, 0)); } @SuppressWarnings("unchecked") private Fixture createFixture(boolean forcePlace) { + return createFixture(forcePlace, true); + } + + @SuppressWarnings("unchecked") + private Fixture createFixture(boolean forcePlace, boolean naturalShore) { Engine engine = mock(Engine.class); SeedManager seedManager = mock(SeedManager.class); IrisDimension dimension = mock(IrisDimension.class); @@ -109,7 +191,9 @@ public class IrisShoreLineDecoratorTest { when(complex.getFluidHeight()).thenReturn((double) FLUID_HEIGHT); when(complex.getHeightStream()).thenReturn(heightStream); when(complex.getRiverWaterSurfaceStream()).thenReturn(fluidStream); - when(heightStream.get(anyDouble(), anyDouble())).thenReturn((double) FLUID_HEIGHT - 1); + when(heightStream.get(anyDouble(), anyDouble())).thenReturn( + naturalShore ? (double) FLUID_HEIGHT - 1 : (double) FLUID_HEIGHT + ); when(fluidStream.get(anyDouble(), anyDouble())).thenReturn((double) FLUID_HEIGHT); when(engine.getData()).thenReturn(data); when(biome.getDecoratorBucket(IrisDecorationPart.SHORE_LINE)) diff --git a/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java index 628817ada..91446c2cd 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/HintedLocatorTest.java @@ -7,6 +7,7 @@ import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisObjectPlacement; import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.util.project.stream.ProceduralStream; @@ -91,6 +92,7 @@ public class HintedLocatorTest { when(engine.isClosed()).thenReturn(false); when(engine.acquireGenerationLease(any(String.class))).thenReturn(GenerationSessionLease.noop()); when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>()); } private static IrisBiome biome(String key) { @@ -211,6 +213,57 @@ public class HintedLocatorTest { assertFalse(plan.isPossible()); } + @Test + public void biomePlanFindsPolicyOnlyReachableBiome() throws Exception { + IrisRegion regionA = region("reg_a"); + regionA.setLandBiomes(keys("plains")); + + IrisBiome plains = biome("plains"); + IrisBiome riverChild = biome("river_child"); + stubBiome(plains); + stubBiome(riverChild); + stubRegions(regionA); + + when(dimension.getReachableBiomes(engine)).thenReturn(new KList<>(riverChild)); + when(complex.getRegionStream()).thenReturn(constantStream(regionA)); + when(engine.getSurfaceBiome(anyInt(), anyInt())).thenAnswer(invocation -> { + int x = invocation.getArgument(0); + return x >= 1024 ? riverChild : plains; + }); + + HintedLocator.SearchPlan plan = HintedLocator.biomePlan(engine, "river_child"); + assertTrue(plan.isPossible()); + assertNull(plan.getCoarse()); + + Locator locator = Locator.surfaceBiome("river_child"); + Position2 result = locator.find(engine, new Position2(0, 0), 60_000, (Integer count) -> { + }).get(); + + assertNotNull(result); + assertTrue(((result.getX() << 4) + 8) >= 1024); + } + + @Test + public void surfaceBiomeLocatorFindsNarrowAcceptedBiomeAwayFromChunkCenter() { + IrisBiome riverShore = biome("river_shore"); + IrisBiome plains = biome("plains"); + IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class); + when(engine.getSurfaceBiome(anyInt(), anyInt())).thenReturn(plains); + when(complex.getHydrologyRuntime()).thenReturn(hydrology); + when(hydrology.hasAcceptedSurfaceBiomeInChunk("river_shore", 0, 0)).thenReturn(true); + + assertTrue(Locator.chunkContainsSurfaceBiome( + engine, + new Position2(0, 0), + riverShore.getLoadKey() + )); + assertFalse(Locator.chunkContainsSurfaceBiome( + engine, + new Position2(1, 0), + riverShore.getLoadKey() + )); + } + @Test public void biomePlanFallsBackToCaveStreamForCaveOnlyBiome() { IrisRegion regionA = region("reg_a"); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java index 013b440af..337532b56 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructureCaveAnchorResolverTest.java @@ -3,8 +3,8 @@ package art.arcane.iris.engine.framework; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructurePlacement; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; @@ -181,18 +181,18 @@ public class StructureCaveAnchorResolverTest { } @Test - public void wetAndSealHydrologyCannotBecomeStructureAnchors() { + public void plannedHydrologyCannotBecomeStructureAnchors() { MatterCavern fluid = new MatterCavern(true, "", (byte) 1); MatterCavern forcedAir = new MatterCavern(true, "", (byte) 3); assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( - true, fluid, RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), 20, 8)); + true, fluid, HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), 20, 8)); assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( - true, fluid, RiverCaveHydrology.of(RiverCaveAction.FALLING_FLUID), 20, 8)); + true, fluid, HydrologyCaveCell.of(HydrologyCaveAction.FALLING_FLUID), 20, 8)); assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( - true, null, RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD), 20, 8)); - assertTrue(StructureCaveAnchorResolver.acceptsAnchorFluid( - false, forcedAir, RiverCaveHydrology.of(RiverCaveAction.DRY_AIR), 0, 8)); + true, null, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD), 20, 8)); + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( + false, forcedAir, HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR), 0, 8)); } private static Engine engineWithFloorAnchor(MatterCavern anchorCavern, int surfaceHeight) { diff --git a/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java index bac4ac05c..15a5d7e54 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java @@ -2,52 +2,123 @@ package art.arcane.iris.engine.framework.render; import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyCandidateKind; +import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate; +import art.arcane.iris.engine.hydrology.HydrologyDiagnosticRenderSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyRenderSample; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; import art.arcane.iris.util.project.stream.ProceduralStream; import org.junit.Test; import java.awt.Color; import java.awt.image.BufferedImage; import java.util.EnumMap; +import java.util.List; import java.util.Map; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class IrisRendererRiverTest { @Test - public void everyRiverSectionHasItsDiagnosticColor() { - Map expected = new EnumMap<>(RiverSection.class); - expected.put(RiverSection.CHANNEL, new Color(48, 112, 190).getRGB()); - expected.put(RiverSection.MOUTH, new Color(54, 164, 205).getRGB()); - expected.put(RiverSection.BANK, new Color(92, 146, 78).getRGB()); - expected.put(RiverSection.DRY_CHANNEL, new Color(171, 128, 68).getRGB()); - expected.put(RiverSection.DRY_BANK, new Color(132, 105, 62).getRGB()); - expected.put(RiverSection.NONE, new Color(28, 31, 38).getRGB()); + public void everyAcceptedHydrologyFeatureHasItsDiagnosticColor() { + Map expected = new EnumMap<>(HydrologyFeatureType.class); + expected.put(HydrologyFeatureType.SURFACE_POOL, new Color(48, 112, 190).getRGB()); + expected.put(HydrologyFeatureType.RIFFLE, new Color(96, 166, 214).getRGB()); + expected.put(HydrologyFeatureType.CASCADE, new Color(135, 204, 232).getRGB()); + expected.put(HydrologyFeatureType.WATERFALL, new Color(190, 236, 255).getRGB()); + expected.put(HydrologyFeatureType.SINKHOLE, new Color(176, 146, 232).getRGB()); + expected.put(HydrologyFeatureType.RIDGE_BORE, new Color(120, 99, 174).getRGB()); + expected.put(HydrologyFeatureType.UNDERGROUND_POOL, new Color(102, 86, 196).getRGB()); + expected.put(HydrologyFeatureType.UNDERGROUND_DROP, new Color(102, 86, 196).getRGB()); + expected.put(HydrologyFeatureType.COASTAL_GROTTO, new Color(51, 174, 174).getRGB()); + expected.put(HydrologyFeatureType.INLAND_GROTTO, new Color(116, 76, 150).getRGB()); + expected.put(HydrologyFeatureType.MOUTH, new Color(54, 164, 205).getRGB()); + expected.put(HydrologyFeatureType.DEEP_POOL, new Color(194, 50, 22).getRGB()); + expected.put(HydrologyFeatureType.DEEP_CHANNEL, new Color(194, 50, 22).getRGB()); - assertEquals(RiverSection.values().length, expected.size()); - for (RiverSection section : RiverSection.values()) { - assertEquals(section.name(), expected.get(section).intValue(), IrisRenderer.riverColor(section)); + assertEquals(HydrologyFeatureType.values().length, expected.size()); + for (HydrologyFeatureType type : HydrologyFeatureType.values()) { + assertEquals(type.name(), expected.get(type).intValue(), IrisRenderer.hydrologyFeatureColor(type)); } } @Test - public void riverRenderTypeSamplesOneWorldFootprintPerRenderedPixel() { + public void headwatersAndWaterfallsRemainDistinct() { + HydrologyRenderSample headwater = sample(HydrologyFeatureType.SURFACE_POOL, true); + HydrologyRenderSample waterfall = sample(HydrologyFeatureType.WATERFALL, false); + + assertEquals(IrisRenderer.headwaterColor(), IrisRenderer.hydrologyColor(headwater)); + assertEquals(IrisRenderer.waterfallColor(), IrisRenderer.hydrologyColor(waterfall)); + assertNotEquals(IrisRenderer.headwaterColor(), IrisRenderer.waterfallColor()); + } + + @Test + public void acceptedHeadwaterArrowsFollowOppositePlanVectors() { + BufferedImage east = renderHeadwater(1, 0); + BufferedImage west = renderHeadwater(-1, 0); + int direction = IrisRenderer.headwaterDirectionColor(); + + assertEquals(direction, east.getRGB(4, 2)); + assertNotEquals(direction, west.getRGB(4, 2)); + assertEquals(direction, west.getRGB(2, 2)); + assertNotEquals(direction, east.getRGB(2, 2)); + } + + @Test + public void projectedCandidatesUseASeparateDiagnosticPalette() { + assertNotEquals( + IrisRenderer.hydrologyFeatureColor(HydrologyFeatureType.SURFACE_POOL), + IrisRenderer.hydrologyDiagnosticColor(HydrologyCandidateKind.SOURCE) + ); + assertNotEquals( + IrisRenderer.hydrologyFeatureColor(HydrologyFeatureType.MOUTH), + IrisRenderer.hydrologyDiagnosticColor(HydrologyCandidateKind.OUTLET) + ); + assertNotEquals( + IrisRenderer.hydrologyFeatureColor(HydrologyFeatureType.DEEP_POOL), + IrisRenderer.hydrologyDiagnosticColor(HydrologyCandidateKind.DEEP_FLUID) + ); + } + + @Test + public void acceptedWaterfallTakesPrecedenceOverAnOverlappingPool() { + HydrologyFeatureRef pool = feature(1L, HydrologyFeatureType.SURFACE_POOL, false); + HydrologyFeatureRef waterfall = feature(2L, HydrologyFeatureType.WATERFALL, false); + HydrologyRenderSample sample = new HydrologyRenderSample(0, 0, List.of(pool, waterfall)); + + assertEquals(IrisRenderer.waterfallColor(), IrisRenderer.hydrologyColor(sample)); + } + + @Test + public void acceptedSinkholeTakesPrecedenceOverItsReceivingGrotto() { + HydrologyFeatureRef grotto = feature(1L, HydrologyFeatureType.INLAND_GROTTO, false); + HydrologyFeatureRef sinkhole = feature(2L, HydrologyFeatureType.SINKHOLE, false); + HydrologyRenderSample sample = new HydrologyRenderSample(0, 0, List.of(grotto, sinkhole)); + + assertEquals(IrisRenderer.hydrologyFeatureColor(HydrologyFeatureType.SINKHOLE), + IrisRenderer.hydrologyColor(sample)); + } + + @Test + public void riverRenderTypeSamplesOneAcceptedWorldFootprintPerRenderedPixel() { Engine engine = mock(Engine.class); IrisComplex complex = mock(IrisComplex.class); - IrisRiverRuntime runtime = mock(IrisRiverRuntime.class); - RiverSample river = mock(RiverSample.class); - + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); when(engine.getComplex()).thenReturn(complex); - when(complex.getRiverRuntime()).thenReturn(runtime); - when(runtime.sampleFootprint(12D, -7D, 20D, 1D)).thenReturn(river); - when(river.present()).thenReturn(true); - when(river.section()).thenReturn(RiverSection.CHANNEL); + when(complex.getHydrologyRuntime()).thenReturn(runtime); + when(runtime.sampleRenderFootprint(12D, -7D, 20D, 1D)) + .thenReturn(sample(HydrologyFeatureType.SURFACE_POOL, false)); BufferedImage image = new IrisRenderer(engine).renderStudio( 12D, @@ -59,26 +130,61 @@ public class IrisRendererRiverTest { ); assertEquals(new Color(48, 112, 190).getRGB(), image.getRGB(0, 0)); - verify(runtime).sampleFootprint(12D, -7D, 20D, 1D); + verify(runtime).sampleRenderFootprint(12D, -7D, 20D, 1D); } @Test - public void biomeAtlasCompositesRiverChannelsOverTheFastBaseBiome() { + public void riverVisionShowsAProjectedCandidateWithoutTreatingItAsAccepted() { Engine engine = mock(Engine.class); IrisComplex complex = mock(IrisComplex.class); - IrisRiverRuntime runtime = mock(IrisRiverRuntime.class); - RiverSample river = mock(RiverSample.class); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + HydrologyDiagnosticCandidate candidate = new HydrologyDiagnosticCandidate( + 44L, + HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, + new HydrologyPoint(0, 80, 0), + HydrologyCandidateRejection.NO_LEGAL_OUTLET + ); + when(engine.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(runtime); + when(runtime.sampleRenderFootprint(0D, 0D, 4D, 4D)) + .thenReturn(new HydrologyRenderSample(0, 0, List.of())); + when(runtime.sampleDiagnosticFootprint(0D, 0D, 4D, 4D)) + .thenReturn(new HydrologyDiagnosticRenderSample(0, 0, List.of(candidate))); + + BufferedImage image = new IrisRenderer(engine).renderStudio( + 0D, + 0D, + 4D, + 1, + RenderType.RIVER, + () -> false + ); + + assertEquals( + IrisRenderer.hydrologyDiagnosticColor(HydrologyCandidateKind.SOURCE), + image.getRGB(0, 0) + ); + assertNotEquals(IrisRenderer.headwaterDirectionColor(), image.getRGB(0, 0)); + verify(runtime).sampleRenderFootprint(0D, 0D, 4D, 4D); + verify(runtime).sampleDiagnosticFootprint(0D, 0D, 4D, 4D); + } + + @Test + public void biomeAtlasCompositesTheSameAcceptedFootprint() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); IrisBiome biome = mock(IrisBiome.class); @SuppressWarnings("unchecked") ProceduralStream base = mock(ProceduralStream.class); when(engine.getComplex()).thenReturn(complex); when(complex.getBaseBiomeStream()).thenReturn(base); - when(complex.getRiverRuntime()).thenReturn(runtime); + when(complex.getHydrologyRuntime()).thenReturn(runtime); when(base.get(0D, 0D)).thenReturn(biome); when(biome.getColor(engine, RenderType.BIOME)).thenReturn(Color.GREEN); - when(runtime.sampleFootprint(0D, 0D, 4D, 4D)).thenReturn(river); - when(river.present()).thenReturn(true); - when(river.section()).thenReturn(RiverSection.CHANNEL); + when(runtime.sampleRenderFootprint(0D, 0D, 4D, 4D)) + .thenReturn(sample(HydrologyFeatureType.WATERFALL, false)); BufferedImage image = new IrisRenderer(engine).renderStudio( 0D, @@ -89,8 +195,60 @@ public class IrisRendererRiverTest { () -> false ); - assertEquals(new Color(48, 112, 190).getRGB(), image.getRGB(0, 0)); + assertNotEquals(Color.GREEN.getRGB(), image.getRGB(0, 0)); verify(base).get(0D, 0D); - verify(runtime).sampleFootprint(0D, 0D, 4D, 4D); + verify(runtime).sampleRenderFootprint(0D, 0D, 4D, 4D); + verify(runtime, never()).sampleDiagnosticFootprint(anyDouble(), anyDouble(), anyDouble(), anyDouble()); + } + + private static HydrologyRenderSample sample(HydrologyFeatureType type, boolean source) { + return new HydrologyRenderSample(0, 0, List.of(feature(1L, type, source))); + } + + private static BufferedImage renderHeadwater(int flowX, int flowZ) { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + HydrologyFeatureRef headwater = new HydrologyFeatureRef( + 71L, + HydrologyFeatureType.SURFACE_POOL, + 10L, + 20L, + 3, + 64, + 3, + flowX, + flowZ, + true + ); + when(engine.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(runtime); + when(runtime.sampleRenderFootprint(anyDouble(), anyDouble(), anyDouble(), anyDouble())) + .thenAnswer(invocation -> { + double minimumX = invocation.getArgument(0, Double.class); + double minimumZ = invocation.getArgument(1, Double.class); + double maximumX = invocation.getArgument(2, Double.class); + double maximumZ = invocation.getArgument(3, Double.class); + boolean contains = minimumX <= headwater.x() && maximumX > headwater.x() + && minimumZ <= headwater.z() && maximumZ > headwater.z(); + return new HydrologyRenderSample( + (int) StrictMath.floor(minimumX), + (int) StrictMath.floor(minimumZ), + contains ? List.of(headwater) : List.of() + ); + }); + + return new IrisRenderer(engine).renderStudio( + 0D, + 0D, + 7D, + 7, + RenderType.RIVER, + () -> false + ); + } + + private static HydrologyFeatureRef feature(long id, HydrologyFeatureType type, boolean source) { + return new HydrologyFeatureRef(id, type, 10L, 20L, 0, 64, 0, 1, 0, source); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java new file mode 100644 index 000000000..d13988da2 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCaveCourseFilterTest.java @@ -0,0 +1,1434 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCandidate; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlannerSettings; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class HydrologyCaveCourseFilterTest { + private static final int DEFAULT_GROTTO_MAXIMUM_VOLUME = 8192; + private static final int[][] NEIGHBORS = { + {1, 0, 0}, {-1, 0, 0}, + {0, 1, 0}, {0, -1, 0}, + {0, 0, 1}, {0, 0, -1} + }; + + @Test + public void rejectedRidgeBoreIsAbsentFromCoursesFootprintRendererAndLocator() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 22L, + 11L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(8, 20, 8), + new HydrologyPoint(16, 20, 8) + ) + ); + RiverCourse course = course(ridge); + HydrologyColumnLayer layer = layer(ridge, 8, 8); + RiverFootprint footprint = footprint(terrain, layer); + CaveVoxelView lavaView = view(CaveVoxel.LAVA, false); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter(lavaView, true).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + diagnostics + ); + HydrologyTile tile = new HydrologyTile( + new HydrologyTileKey(0, 0), + 1L, + 1L, + 32, + filtered.nodes(), + filtered.edges(), + filtered.outlets(), + filtered.courses(), + filtered.cavePlans(), + diagnostics, + RiverFootprint.empty() + ); + + assertTrue(tile.courses().isEmpty()); + assertTrue(tile.footprint().isEmpty()); + assertTrue(tile.features().isEmpty()); + assertTrue(tile.renderAt(8, 8).features().isEmpty()); + assertTrue(tile.nearestFeature(HydrologyFeatureType.RIDGE_BORE, 8, 8, 16).isEmpty()); + assertEquals(1, tile.diagnosticCandidates().size()); + assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, + tile.diagnosticCandidates().getFirst().rejection()); + } + + @Test + public void generatedSurfaceConnectedCaveAroundRidgeBoreIsSealed() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 42L, + 41L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(8, 20, 8), + new HydrologyPoint(16, 20, 8) + ) + ); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter( + view(CaveVoxel.CAVE_AIR, true), + true + ).filter( + List.of(), + List.of(), + List.of(), + List.of(course(ridge)), + footprint(terrain, layer(ridge, 8, 8)).columns().values(), + diagnostics + ); + + assertEquals(List.of(course(ridge)), filtered.courses()); + assertEquals(1, filtered.cavePlans().size()); + assertTrue(filtered.cavePlans().getFirst().actions().containsValue( + HydrologyCaveAction.SEAL_GUARD + )); + assertTrue(filtered.cavePlans().getFirst().baselinePreconditions().values().stream() + .anyMatch(CaveVoxelPrecondition::openToSurface)); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void exactCandidateGeometryIsReusedAndBoundedAcrossSettlementPasses() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 420L, + 410L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(8, 20, 8), + new HydrologyPoint(16, 20, 8) + ) + ); + RiverCourse course = course(ridge); + List columns = List.copyOf( + footprint(terrain, layer(ridge, 8, 8)).columns().values() + ); + AtomicInteger iterations = new AtomicInteger(); + Iterable countedColumns = () -> { + iterations.incrementAndGet(); + return columns.iterator(); + }; + Map cache = new LinkedHashMap<>(); + HydrologyCaveCourseFilter filter = new HydrologyCaveCourseFilter( + selectiveSurfaceView(Set.of(), new AtomicInteger()), + new HydrologyCaveCourseFilter.Options( + true, + DEFAULT_GROTTO_MAXIMUM_VOLUME, + DEFAULT_GROTTO_MAXIMUM_VOLUME + ), + cache + ); + + HydrologyCaveCourseFilter.Result first = filter.filter( + List.of(), + List.of(), + List.of(), + List.of(course), + countedColumns, + new ArrayList<>() + ); + HydrologyCaveCourseFilter.Result repeated = filter.filter( + List.of(), + List.of(), + List.of(), + List.of(course), + countedColumns, + new ArrayList<>() + ); + + assertEquals(first, repeated); + assertEquals(2, iterations.get()); + assertEquals(1, cache.size()); + + Map.Entry cached = + cache.entrySet().iterator().next(); + long weight = (long) cached.getValue().actions().size() + + cached.getValue().intentionalOpenings().size(); + assertTrue(weight > 1L); + HydrologyCaveCourseFilter.CandidateKey secondKey = new HydrologyCaveCourseFilter.CandidateKey( + cached.getKey().courseId() + 1L, + cached.getKey().courseType(), + cached.getKey().profileKey(), + cached.getKey().segments(), + cached.getKey().options() + ); + HydrologyCaveCourseFilter.CandidateCache entryBounded = + new HydrologyCaveCourseFilter.CandidateCache(1, weight * 2L); + entryBounded.put(cached.getKey(), cached.getValue()); + entryBounded.put(secondKey, cached.getValue()); + assertEquals(1, entryBounded.size()); + assertEquals(weight, entryBounded.retainedPositions()); + assertTrue(entryBounded.containsKey(secondKey)); + + HydrologyCaveCourseFilter.CandidateCache positionBounded = + new HydrologyCaveCourseFilter.CandidateCache(2, weight - 1L); + positionBounded.put(cached.getKey(), cached.getValue()); + assertTrue(positionBounded.isEmpty()); + assertEquals(0L, positionBounded.retainedPositions()); + } + + @Test + public void ridgeBoreAboveFinalTerrainRejectsTheWholeCourse() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 52L, + 51L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(8, 20, 8), + new HydrologyPoint(16, 20, 8) + ) + ); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter( + view(CaveVoxel.CAVE_AIR, true, true), + true + ).filter( + List.of(), + List.of(), + List.of(), + List.of(course(ridge)), + footprint(terrain, layer(ridge, 8, 8)).columns().values(), + diagnostics + ); + + assertTrue(filtered.courses().isEmpty()); + assertEquals(1, diagnostics.size()); + } + + @Test + public void surfaceRidgeExposurePreflightChecksTheFullActionBoundaryBeforeVoxelExpansion() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 152L, + 151L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(16, 20, 8) + ) + ); + CavePosition exposedBoundary = new CavePosition(8, 25, 8); + AtomicInteger voxelLoads = new AtomicInteger(); + CaveVoxelView exposedView = selectiveSurfaceView(Set.of(exposedBoundary), voxelLoads); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter(exposedView, true).filter( + List.of(), + List.of(), + List.of(), + List.of(course(ridge)), + footprint(terrain, layer(ridge, 8, 8)).columns().values(), + diagnostics + ); + + assertTrue(filtered.courses().isEmpty()); + assertTrue(filtered.cavePlans().isEmpty()); + assertEquals(0, voxelLoads.get()); + assertEquals(1, diagnostics.size()); + assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, diagnostics.getFirst().rejection()); + } + + @Test + public void ridgeSurfaceOpeningExemptsItsCompleteSixNeighborShell() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 162L, + 161L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(8, 20, 8) + ) + ); + RiverCourse course = course(ridge); + RiverFootprint footprint = footprint(terrain, layer(ridge, 0, 8)); + CaveVoxelView exposedView = view(CaveVoxel.SOLID, false, true); + ArrayList diagnostics = new ArrayList<>(); + + Set preflightRejections = defaultCourseFilter(exposedView, true) + .preflightRejectedCourseIds(List.of(course), footprint.columns().values(), diagnostics); + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter(exposedView, true).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + diagnostics + ); + + assertTrue(preflightRejections.isEmpty()); + assertEquals(List.of(course), filtered.courses()); + assertEquals(1, filtered.cavePlans().size()); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void undergroundDropInteriorIsNotExemptedByItsSurfaceOpenings() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment surfaceLip = new HydraulicSegment( + 172L, + 171L, + HydrologyFeatureType.SURFACE_POOL, + 20, + 20, + 2, + 2, + false, + false, + List.of(new HydrologyPoint(0, 20, 0)) + ); + HydraulicSegment drop = new HydraulicSegment( + 173L, + 171L, + HydrologyFeatureType.UNDERGROUND_DROP, + 20, + 18, + 2, + 2, + true, + true, + List.of( + new HydrologyPoint(0, 20, 0), + new HydrologyPoint(6, 19, 0), + new HydrologyPoint(12, 18, 0) + ) + ); + HydraulicSegment surfacePool = new HydraulicSegment( + 174L, + 171L, + HydrologyFeatureType.SURFACE_POOL, + 18, + 18, + 2, + 2, + false, + false, + List.of(new HydrologyPoint(12, 18, 0)) + ); + RiverCourse course = new RiverCourse( + 171L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(surfaceLip, drop, surfacePool) + ); + HydrologyColumnLayer dropLayer = caveLayer(drop, 1731L, 6, 0, 16, 18, 22, true); + RiverFootprint footprint = footprint(terrain, dropLayer); + CavePosition exposedBoundary = new CavePosition(6, 23, 0); + ArrayList diagnostics = new ArrayList<>(); + + Set preflightRejections = defaultCourseFilter( + selectiveSurfaceView(Set.of(exposedBoundary), new AtomicInteger()), + true + ).preflightRejectedCourseIds(List.of(course), footprint.columns().values(), diagnostics); + + assertEquals(Set.of(course.id()), preflightRejections); + assertEquals(1, diagnostics.size()); + assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, diagnostics.getFirst().rejection()); + } + + @Test + public void undergroundExposurePreflightSkipsVoxelExpansion() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment tunnel = new HydraulicSegment( + 182L, + 181L, + HydrologyFeatureType.UNDERGROUND_POOL, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(8, 20, 8) + ) + ); + RiverCourse course = undergroundCourse(tunnel); + RiverFootprint footprint = footprint(terrain, layer(tunnel, 8, 8)); + AtomicInteger voxelLoads = new AtomicInteger(); + CaveVoxelView exposedView = selectiveSurfaceView(Set.of(new CavePosition(8, 20, 8)), voxelLoads); + ArrayList preflightDiagnostics = new ArrayList<>(); + + Set preflightRejections = defaultCourseFilter(exposedView, true) + .preflightRejectedCourseIds( + List.of(course), + footprint.columns().values(), + preflightDiagnostics + ); + ArrayList diagnostics = new ArrayList<>(); + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter(exposedView, true).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + diagnostics + ); + + assertEquals(Set.of(course.id()), preflightRejections); + assertEquals(1, preflightDiagnostics.size()); + assertTrue(filtered.courses().isEmpty()); + assertTrue(filtered.cavePlans().isEmpty()); + assertEquals(0, voxelLoads.get()); + assertEquals(1, diagnostics.size()); + assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, diagnostics.getFirst().rejection()); + } + + @Test + public void deepFluidExposurePreflightSkipsVoxelExpansion() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment pool = new HydraulicSegment( + 188L, + 187L, + HydrologyFeatureType.DEEP_POOL, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(8, 20, 8) + ) + ); + RiverCourse course = new RiverCourse( + pool.courseId(), + RiverCourseType.DEEP_FLUID, + OptionalLong.empty(), + OptionalLong.empty(), + "water", + 1, + List.of(), + List.of(pool) + ); + RiverFootprint footprint = footprint(terrain, layer(pool, 8, 8)); + AtomicInteger voxelLoads = new AtomicInteger(); + CaveVoxelView exposedView = selectiveSurfaceView(Set.of(new CavePosition(8, 20, 8)), voxelLoads); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter(exposedView, true).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + diagnostics + ); + + assertTrue(filtered.courses().isEmpty()); + assertTrue(filtered.cavePlans().isEmpty()); + assertEquals(0, voxelLoads.get()); + assertEquals(1, diagnostics.size()); + assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, diagnostics.getFirst().rejection()); + } + + @Test + public void compactSurfaceExposureMatchesLiteralVoxelShells() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 192L, + 191L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 0), + new HydrologyPoint(12, 20, 0) + ) + ); + RiverCourse course = course(ridge); + Random random = new Random(0x485944524f4c4f47L); + int exposedCases = 0; + int containedCases = 0; + + for (int iteration = 0; iteration < 128; iteration++) { + LinkedHashMap columns = new LinkedHashMap<>(); + HashSet actions = new HashSet<>(); + HashSet openings = new HashSet<>(); + addRandomColumn(terrain, ridge, iteration, 0, 0, random, columns, actions, openings); + addRandomColumn(terrain, ridge, iteration, 6, 0, random, columns, actions, openings); + int randomColumns = 1 + random.nextInt(10); + for (int index = 0; index < randomColumns; index++) { + int x = random.nextInt(15) - 2; + int z = random.nextInt(7) - 3; + addRandomColumn(terrain, ridge, iteration, x, z, random, columns, actions, openings); + } + HashSet exposedPositions = new HashSet<>(); + if (!openings.isEmpty()) { + exposedPositions.add(openings.iterator().next()); + } + if ((iteration & 1) != 0) { + List shell = literalShell(actions, openings); + exposedPositions.add(shell.get(random.nextInt(shell.size()))); + } + CaveVoxelView view = selectiveSurfaceView(exposedPositions, new AtomicInteger()); + boolean expectedExposed = literalExposure(actions, openings, view); + Set actualRejections = defaultCourseFilter(view, true).preflightRejectedCourseIds( + List.of(course), + new RiverFootprint(columns).columns().values(), + new ArrayList<>() + ); + + assertEquals(expectedExposed, actualRejections.contains(course.id())); + if (expectedExposed) { + exposedCases++; + } else { + containedCases++; + } + } + + assertEquals(64, exposedCases); + assertEquals(64, containedCases); + } + + @Test + public void pureSurfaceCourseBypassesCaveContainmentWithoutChangingItsPlan() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment surface = new HydraulicSegment( + 62L, + 61L, + HydrologyFeatureType.SURFACE_POOL, + 76, + 76, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(8, 76, 8)) + ); + RiverCourse course = course(surface); + RiverFootprint footprint = footprint(terrain, surfaceLayer(surface, 8, 8)); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter( + view(CaveVoxel.LAVA, true), + true + ).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + diagnostics + ); + + assertEquals(List.of(course), filtered.courses()); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void acceptedRidgeBoreRetainsItsExactPlanOnTheImmutableTile() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 82L, + 81L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(8, 20, 8), + new HydrologyPoint(16, 20, 8) + ) + ); + RiverFootprint footprint = footprint(terrain, layer(ridge, 8, 8)); + ArrayList diagnostics = new ArrayList<>(); + DrainageNode source = new DrainageNode(1L, 0, 8, terrain, 1D, 2L); + RiverOutlet outlet = new RiverOutlet( + 2L, + HydrologyFeatureType.MOUTH, + source.id(), + new HydrologyPoint(0, 63, 8), + new HydrologyPoint(1, 63, 8), + 63, + true + ); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter( + view(CaveVoxel.SOLID, false), + true + ).filter( + List.of(source), + List.of(), + List.of(outlet), + List.of(course(ridge)), + footprint.columns().values(), + diagnostics + ); + HydrologyTile tile = new HydrologyTile( + new HydrologyTileKey(0, 0), + 1L, + 1L, + 32, + filtered.nodes(), + filtered.edges(), + filtered.outlets(), + filtered.courses(), + filtered.cavePlans(), + diagnostics, + footprint + ); + + assertEquals(1, tile.courses().size()); + assertEquals(1, tile.cavePlans().size()); + assertTrue(tile.cavePlan(ridge.courseId()).orElseThrow().accepted()); + assertEquals(1, tile.renderAt(8, 8).features().size()); + } + + @Test + public void coastalGrottoMaximumVolumeBoundsOnlyTheCoastalPlan() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment grotto = grotto(121L, 122L, HydrologyFeatureType.COASTAL_GROTTO); + RiverCourse course = course(grotto); + RiverFootprint footprint = footprint(terrain, layer(grotto, 8, 8)); + CaveVoxelView solidView = view(CaveVoxel.SOLID, false); + ArrayList acceptedDiagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result accepted = limitedCourseFilter(solidView, 6, 1).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + acceptedDiagnostics + ); + ArrayList rejectedDiagnostics = new ArrayList<>(); + HydrologyCaveCourseFilter.Result rejected = limitedCourseFilter(solidView, 5, 6).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + rejectedDiagnostics + ); + + assertEquals(List.of(course), accepted.courses()); + assertEquals(1, accepted.cavePlans().size()); + assertTrue(accepted.cavePlans().getFirst().accepted()); + assertTrue(acceptedDiagnostics.isEmpty()); + assertTrue(rejected.courses().isEmpty()); + assertTrue(rejected.cavePlans().isEmpty()); + assertEquals(1, rejectedDiagnostics.size()); + assertEquals(HydrologyCandidateRejection.VOLUME_LIMIT, rejectedDiagnostics.getFirst().rejection()); + } + + @Test + public void oversizedGrottoPrecedesSurfaceExposure() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment grotto = grotto(123L, 124L, HydrologyFeatureType.COASTAL_GROTTO); + RiverCourse course = course(grotto); + RiverFootprint footprint = footprint( + terrain, + layer(grotto, 8, 8), + layer(grotto, 12, 8) + ); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = limitedCourseFilter( + view(CaveVoxel.SOLID, false, true), + 11, + DEFAULT_GROTTO_MAXIMUM_VOLUME + ).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + diagnostics + ); + + assertTrue(filtered.courses().isEmpty()); + assertEquals(1, diagnostics.size()); + assertEquals(HydrologyCandidateRejection.VOLUME_LIMIT, diagnostics.getFirst().rejection()); + } + + @Test + public void inlandGrottoMaximumVolumeBoundsOnlyTheInlandPlan() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment grotto = grotto(131L, 132L, HydrologyFeatureType.INLAND_GROTTO); + RiverCourse course = undergroundCourse(grotto); + RiverFootprint footprint = footprint(terrain, layer(grotto, 8, 8)); + CaveVoxelView solidView = view(CaveVoxel.SOLID, false); + ArrayList acceptedDiagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result accepted = limitedCourseFilter(solidView, 1, 6).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + acceptedDiagnostics + ); + ArrayList rejectedDiagnostics = new ArrayList<>(); + HydrologyCaveCourseFilter.Result rejected = limitedCourseFilter(solidView, 6, 5).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + rejectedDiagnostics + ); + + assertEquals(List.of(course), accepted.courses()); + assertEquals(1, accepted.cavePlans().size()); + assertTrue(accepted.cavePlans().getFirst().accepted()); + assertTrue(acceptedDiagnostics.isEmpty()); + assertTrue(rejected.courses().isEmpty()); + assertTrue(rejected.cavePlans().isEmpty()); + assertEquals(1, rejectedDiagnostics.size()); + assertEquals(HydrologyCandidateRejection.VOLUME_LIMIT, rejectedDiagnostics.getFirst().rejection()); + } + + @Test + public void grottoCapDoesNotLimitTheRestOfItsTransactionalCourse() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment tunnel = new HydraulicSegment( + 141L, + 140L, + HydrologyFeatureType.UNDERGROUND_POOL, + 20, + 20, + 2, + 2, + false, + false, + List.of(new HydrologyPoint(0, 20, 8), new HydrologyPoint(1, 20, 8)) + ); + HydraulicSegment grotto = grotto(142L, 140L, HydrologyFeatureType.INLAND_GROTTO); + RiverCourse course = new RiverCourse( + 140L, + RiverCourseType.UNDERGROUND, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(tunnel, grotto) + ); + RiverFootprint footprint = footprint( + terrain, + layer(tunnel, 0, 8), + layer(tunnel, 1, 8), + layer(grotto, 8, 8) + ); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = limitedCourseFilter( + view(CaveVoxel.SOLID, false), + 1, + 6 + ).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + diagnostics + ); + + assertEquals(List.of(course), filtered.courses()); + assertEquals(1, filtered.cavePlans().size()); + assertTrue(filtered.cavePlans().getFirst().actions().size() > 6); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void oversizedTransactionalCourseRejectsBeforeVoxelMaterialization() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment tunnel = new HydraulicSegment( + 151L, + 150L, + HydrologyFeatureType.UNDERGROUND_POOL, + 20, + 20, + 2, + 2, + false, + false, + List.of(new HydrologyPoint(8, 20, 8), new HydrologyPoint(9, 20, 8)) + ); + RiverCourse course = undergroundCourse(tunnel); + HydrologyColumnLayer oversized = caveLayer( + tunnel, + 152L, + 8, + 8, + 0, + 20, + HydrologyCavePlannerSettings.MAXIMUM_PLANNED_MUTATIONS + 1, + false + ); + RiverFootprint footprint = footprint(terrain, oversized); + AtomicInteger voxelLoads = new AtomicInteger(); + CaveVoxelView view = new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + voxelLoads.incrementAndGet(); + return true; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + voxelLoads.incrementAndGet(); + return CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + voxelLoads.incrementAndGet(); + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + voxelLoads.incrementAndGet(); + return false; + } + }; + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter(view, false).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + diagnostics + ); + + assertTrue(filtered.courses().isEmpty()); + assertTrue(filtered.cavePlans().isEmpty()); + assertEquals(1, diagnostics.size()); + assertEquals(HydrologyCandidateRejection.VOLUME_LIMIT, diagnostics.getFirst().rejection()); + assertEquals(0, voxelLoads.get()); + } + + @Test + public void selfCarvedUndergroundCourseDoesNotMaterializeObservedCavesOrBoundaryGuards() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment tunnel = new HydraulicSegment( + 161L, + 160L, + HydrologyFeatureType.UNDERGROUND_POOL, + 20, + 20, + 2, + 2, + false, + false, + List.of(new HydrologyPoint(8, 20, 8), new HydrologyPoint(9, 20, 8)) + ); + RiverCourse course = undergroundCourse(tunnel); + RiverFootprint footprint = footprint(terrain, layer(tunnel, 8, 8), layer(tunnel, 9, 8)); + AtomicInteger observedLoads = new AtomicInteger(); + CaveVoxelView observedView = new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + observedLoads.incrementAndGet(); + return true; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + observedLoads.incrementAndGet(); + return CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + observedLoads.incrementAndGet(); + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + observedLoads.incrementAndGet(); + return false; + } + }; + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter(observedView, false).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint.columns().values(), + diagnostics + ); + + assertEquals(List.of(course), filtered.courses()); + assertEquals(1, filtered.cavePlans().size()); + assertTrue(filtered.cavePlans().getFirst().accepted()); + assertTrue(filtered.cavePlans().getFirst().actions().values().stream() + .noneMatch((HydrologyCaveAction action) -> action == HydrologyCaveAction.SEAL_GUARD)); + assertTrue(filtered.cavePlans().getFirst().baselinePreconditions().values().stream() + .allMatch((CaveVoxelPrecondition precondition) -> precondition.voxel() == CaveVoxel.UNCONDITIONAL)); + assertTrue(diagnostics.isEmpty()); + assertEquals(0, observedLoads.get()); + } + + @Test + public void ridgeBoreCanUseTheAdjacentUndergroundSegmentsSurfaceOpening() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 102L, + 101L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 8), + new HydrologyPoint(8, 20, 8) + ) + ); + HydraulicSegment drop = new HydraulicSegment( + 103L, + 101L, + HydrologyFeatureType.UNDERGROUND_DROP, + 20, + 18, + 2, + 2, + true, + true, + List.of( + new HydrologyPoint(8, 20, 8), + new HydrologyPoint(9, 18, 8) + ) + ); + HydraulicSegment surface = new HydraulicSegment( + 104L, + 101L, + HydrologyFeatureType.SURFACE_POOL, + 18, + 18, + 2, + 2, + false, + false, + List.of(new HydrologyPoint(9, 18, 8)) + ); + RiverCourse course = new RiverCourse( + 101L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(ridge, drop, surface) + ); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter( + view(CaveVoxel.CAVE_AIR, true), + true + ).filter( + List.of(), + List.of(), + List.of(), + List.of(course), + footprint(terrain, layer(ridge, 8, 8)).columns().values(), + diagnostics + ); + + assertEquals(List.of(course), filtered.courses()); + assertEquals(1, filtered.cavePlans().size()); + assertTrue(filtered.cavePlans().getFirst().accepted()); + assertTrue(diagnostics.isEmpty()); + } + + @Test + public void remoteCrossChunkHazardRejectsEveryCellOfTheCourse() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 92L, + 91L, + HydrologyFeatureType.RIDGE_BORE, + 20, + 20, + 2, + 2, + false, + false, + List.of( + new HydrologyPoint(8, 20, 8), + new HydrologyPoint(24, 20, 8) + ) + ); + HydrologyColumnLayer local = layer(ridge, 15, 8); + HydrologyColumnLayer remote = layer(ridge, 16, 8); + RiverFootprint footprint = footprint(terrain, local, remote); + CavePosition hazard = new CavePosition(16, 19, 8); + CaveVoxelView view = selectiveView(Map.of(hazard, CaveVoxel.INCOMPATIBLE_FLUID)); + ArrayList diagnostics = new ArrayList<>(); + + HydrologyCaveCourseFilter.Result filtered = defaultCourseFilter(view, true).filter( + List.of(), + List.of(), + List.of(), + List.of(course(ridge)), + footprint.columns().values(), + diagnostics + ); + + assertTrue(filtered.courses().isEmpty()); + assertTrue(filtered.cavePlans().isEmpty()); + assertEquals(1, diagnostics.size()); + } + + private RiverCourse course(HydraulicSegment segment) { + return new RiverCourse( + segment.courseId(), + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(segment) + ); + } + + private RiverCourse undergroundCourse(HydraulicSegment segment) { + return new RiverCourse( + segment.courseId(), + RiverCourseType.UNDERGROUND, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(segment) + ); + } + + private HydraulicSegment grotto(long segmentId, long courseId, HydrologyFeatureType type) { + return new HydraulicSegment( + segmentId, + courseId, + type, + 20, + 20, + 2, + 2, + false, + false, + List.of(new HydrologyPoint(8, 20, 8)) + ); + } + + private HydrologyCaveCourseFilter defaultCourseFilter(CaveVoxelView view, boolean connectToExistingCaves) { + return new HydrologyCaveCourseFilter( + view, + new HydrologyCaveCourseFilter.Options( + connectToExistingCaves, + DEFAULT_GROTTO_MAXIMUM_VOLUME, + DEFAULT_GROTTO_MAXIMUM_VOLUME + ) + ); + } + + private HydrologyCaveCourseFilter limitedCourseFilter( + CaveVoxelView view, + int coastalGrottoMaximumVolume, + int inlandGrottoMaximumVolume + ) { + return new HydrologyCaveCourseFilter( + view, + new HydrologyCaveCourseFilter.Options( + false, + coastalGrottoMaximumVolume, + inlandGrottoMaximumVolume + ) + ); + } + + private HydrologyColumnLayer layer(HydraulicSegment segment, int x, int z) { + return new HydrologyColumnLayer( + feature(segment, x, z), + 18, + 20, + 24, + true, + false, + false, + true, + false, + false, + true, + true, + false, + "water", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + } + + private HydrologyColumnLayer caveLayer( + HydraulicSegment segment, + long featureId, + int x, + int z, + int bedY, + int fluidHeadY, + int ceilingY, + boolean fallingFluid + ) { + return new HydrologyColumnLayer( + feature(segment, featureId, x, z), + bedY, + fluidHeadY, + ceilingY, + true, + false, + false, + true, + fallingFluid, + fallingFluid, + true, + true, + false, + "water", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + } + + private HydrologyColumnLayer surfaceLayer(HydraulicSegment segment, int x, int z) { + return new HydrologyColumnLayer( + feature(segment, x, z), + 74, + 76, + 76, + true, + false, + false, + true, + false, + false, + true, + true, + false, + "water", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + } + + private HydrologyFeatureRef feature(HydraulicSegment segment, int x, int z) { + return feature(segment, 101L, x, z); + } + + private HydrologyFeatureRef feature(HydraulicSegment segment, long featureId, int x, int z) { + return new HydrologyFeatureRef( + featureId, + segment.type(), + segment.courseId(), + segment.id(), + x, + segment.upstreamHeadY(), + z, + 1, + 0, + false + ); + } + + private RiverFootprint footprint(HydrologyTerrainSample terrain, HydrologyColumnLayer... layers) { + Map columns = new LinkedHashMap<>(); + for (HydrologyColumnLayer layer : layers) { + HydrologyColumnSample sample = new HydrologyColumnSample( + layer.feature().x(), + layer.feature().z(), + terrain.naturalHeight(), + 63, + false, + terrain.parentBiomeKey(), + List.of(layer) + ); + columns.put(RiverFootprint.pack(sample.x(), sample.z()), sample); + } + return new RiverFootprint(columns); + } + + private void addRandomColumn( + HydrologyTerrainSample terrain, + HydraulicSegment ridge, + int iteration, + int x, + int z, + Random random, + Map columns, + Set actions, + Set openings + ) { + long columnKey = RiverFootprint.pack(x, z); + if (columns.containsKey(columnKey)) { + return; + } + int bedY = 14 + random.nextInt(6); + int ceilingY = 20 + random.nextInt(6); + long featureId = 10_000L + (long) iteration * 1_000L + (long) (x + 16) * 32L + z + 16L; + HydrologyColumnLayer layer = caveLayer( + ridge, + featureId, + x, + z, + bedY, + 20, + ceilingY, + false + ); + columns.put(columnKey, new HydrologyColumnSample( + x, + z, + terrain.naturalHeight(), + 63, + false, + terrain.parentBiomeKey(), + List.of(layer) + )); + for (int y = bedY + 1; y <= ceilingY; y++) { + CavePosition position = new CavePosition(x, y, z); + actions.add(position); + if (matchesRidgeOpening(x, z)) { + addOpeningNeighborhood(position, openings); + } + } + } + + private boolean matchesRidgeOpening(int x, int z) { + double progress = Math.max(0D, Math.min(1D, x / 2D)); + double deltaX = x - 2D * progress; + return deltaX * deltaX + (double) z * z <= 4D; + } + + private void addOpeningNeighborhood(CavePosition position, Set openings) { + openings.add(position); + for (int[] offset : NEIGHBORS) { + openings.add(position.offset(offset[0], offset[1], offset[2])); + } + } + + private List literalShell(Set actions, Set openings) { + HashSet shell = new HashSet<>(); + for (CavePosition position : actions) { + if (!openings.contains(position)) { + shell.add(position); + } + for (int[] offset : NEIGHBORS) { + CavePosition neighbor = position.offset(offset[0], offset[1], offset[2]); + if (!actions.contains(neighbor) && !openings.contains(neighbor)) { + shell.add(neighbor); + } + } + } + return List.copyOf(shell); + } + + private boolean literalExposure( + Set actions, + Set openings, + CaveVoxelView view + ) { + for (CavePosition position : actions) { + if (!openings.contains(position) + && view.isInWorld(position) + && view.isAboveTerrainSurface(position)) { + return true; + } + for (int[] offset : NEIGHBORS) { + CavePosition neighbor = position.offset(offset[0], offset[1], offset[2]); + if (actions.contains(neighbor) + || openings.contains(neighbor) + || !view.isInWorld(neighbor)) { + continue; + } + if (view.isAboveTerrainSurface(neighbor)) { + return true; + } + } + } + return false; + } + + private CaveVoxelView view(CaveVoxel voxel, boolean openToSurface) { + return view(voxel, openToSurface, false); + } + + private CaveVoxelView view(CaveVoxel voxel, boolean openToSurface, boolean aboveTerrainSurface) { + return new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > 0 && position.y() < 128; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return voxel; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return openToSurface; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return aboveTerrainSurface; + } + }; + } + + private CaveVoxelView selectiveView(Map voxels) { + return new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > 0 && position.y() < 128; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return voxels.getOrDefault(position, CaveVoxel.SOLID); + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return false; + } + }; + } + + private CaveVoxelView selectiveSurfaceView( + Set exposedPositions, + AtomicInteger voxelLoads + ) { + return new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > 0 && position.y() < 128; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + voxelLoads.incrementAndGet(); + return CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return exposedPositions.contains(position); + } + }; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileCaveAdmissionTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileCaveAdmissionTest.java new file mode 100644 index 000000000..21422bd9c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileCaveAdmissionTest.java @@ -0,0 +1,283 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveMode; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveRejection; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveSource; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyCrossTileCaveAdmissionTest { + @Test + public void compatibleLowerRankClaimRetainsTheCurrentClaim() { + CavePosition shared = position(4, 20, 7); + HydrologyCrossTileCaveAdmission.Claim current = claim( + 11L, + 30, + "water", + Map.of(shared, HydrologyCaveAction.WET_SOURCE) + ); + HydrologyCrossTileCaveAdmission.Claim blocker = claim( + 12L, + 10, + "water", + Map.of(shared, HydrologyCaveAction.WET_SOURCE) + ); + + HydrologyCrossTileCaveAdmission.Result result = HydrologyCrossTileCaveAdmission.admit( + 4, + List.of(current), + List.of(ranked(0, 0, 1, blocker)) + ); + + assertTrue(result.rejections().isEmpty()); + } + + @Test + public void actionConflictYieldsToTheLowerOwnerColorRank() { + CavePosition shared = position(8, 24, 9); + HydrologyCrossTileCaveAdmission.Claim current = claim( + 21L, + 40, + "water", + Map.of(shared, HydrologyCaveAction.WET_SOURCE) + ); + HydrologyCrossTileCaveAdmission.Claim blocker = claim( + 22L, + 20, + "water", + Map.of(shared, HydrologyCaveAction.FALLING_FLUID) + ); + + HydrologyCrossTileCaveAdmission.Result result = HydrologyCrossTileCaveAdmission.admit( + 4, + List.of(current), + List.of(ranked(0, 1, 1, blocker)) + ); + + assertEquals(Map.of(21L, 22L), result.loserCourseIdsToWinnerSourceIds()); + } + + @Test + public void profileConflictYieldsToTheLowerOwnerColorRank() { + CavePosition shared = position(12, 18, 13); + HydrologyCrossTileCaveAdmission.Claim current = claim( + 31L, + 40, + "water", + Map.of(shared, HydrologyCaveAction.WET_SOURCE) + ); + HydrologyCrossTileCaveAdmission.Claim blocker = claim( + 32L, + 20, + "lava", + Map.of(shared, HydrologyCaveAction.WET_SOURCE) + ); + + HydrologyCrossTileCaveAdmission.Result result = HydrologyCrossTileCaveAdmission.admit( + 5, + List.of(current), + List.of(ranked(1, 0, 2, blocker)) + ); + + assertEquals(Map.of(31L, 32L), result.loserCourseIdsToWinnerSourceIds()); + } + + @Test + public void ownerColorRankPrecedesCaveSourceHeadPriority() { + CavePosition shared = position(16, 28, 17); + HydrologyCrossTileCaveAdmission.Claim higherHeadCurrent = claim( + 41L, + 90, + "water", + Map.of(shared, HydrologyCaveAction.WET_SOURCE) + ); + HydrologyCrossTileCaveAdmission.Claim lowerHeadBlocker = claim( + 42L, + 10, + "water", + Map.of(shared, HydrologyCaveAction.DRY_AIR) + ); + + HydrologyCrossTileCaveAdmission.Result result = HydrologyCrossTileCaveAdmission.admit( + 8, + List.of(higherHeadCurrent), + List.of(ranked(-1, -1, 7, lowerHeadBlocker)) + ); + + assertEquals(Map.of(41L, 42L), result.loserCourseIdsToWinnerSourceIds()); + } + + @Test + public void sourcePriorityBreaksTiesWithinOneBlockerRank() { + CavePosition firstOverlap = position(20, 32, 21); + CavePosition secondOverlap = position(24, 32, 25); + HydrologyCrossTileCaveAdmission.Claim current = claim( + 51L, + 30, + "water", + Map.of( + firstOverlap, HydrologyCaveAction.DRY_AIR, + secondOverlap, HydrologyCaveAction.DRY_AIR + ) + ); + HydrologyCrossTileCaveAdmission.Claim higherHead = claim( + 52L, + 40, + "water", + Map.of(firstOverlap, HydrologyCaveAction.WET_SOURCE) + ); + HydrologyCrossTileCaveAdmission.Claim lowerHead = claim( + 53L, + 20, + "water", + Map.of(secondOverlap, HydrologyCaveAction.WET_SOURCE) + ); + + HydrologyCrossTileCaveAdmission.Result result = HydrologyCrossTileCaveAdmission.admit( + 6, + List.of(current), + List.of( + ranked(1, 1, 2, lowerHead), + ranked(1, 1, 2, higherHead) + ) + ); + + assertEquals(Map.of(51L, 52L), result.loserCourseIdsToWinnerSourceIds()); + } + + @Test + public void negativeOwnersAndInputOrderProduceTheSameDeterministicResult() { + CavePosition first = position(-257, 22, -513); + CavePosition second = position(-300, 22, -520); + HydrologyCrossTileCaveAdmission.Claim currentOne = claim( + 61L, + 30, + "water", + Map.of(first, HydrologyCaveAction.WET_SOURCE) + ); + HydrologyCrossTileCaveAdmission.Claim currentTwo = claim( + 62L, + 20, + "water", + Map.of(second, HydrologyCaveAction.WET_SOURCE) + ); + HydrologyCrossTileCaveAdmission.RankedClaim firstBlocker = ranked( + -2, + -3, + 1, + claim(63L, 10, "water", Map.of(first, HydrologyCaveAction.DRY_AIR)) + ); + HydrologyCrossTileCaveAdmission.RankedClaim secondBlocker = ranked( + -1, + -2, + 2, + claim(64L, 10, "water", Map.of(second, HydrologyCaveAction.DRY_AIR)) + ); + ArrayList reverseClaims = new ArrayList<>(List.of( + currentOne, + currentTwo + )); + Collections.reverse(reverseClaims); + ArrayList reverseBlockers = new ArrayList<>(List.of( + firstBlocker, + secondBlocker + )); + Collections.reverse(reverseBlockers); + + HydrologyCrossTileCaveAdmission.Result forward = HydrologyCrossTileCaveAdmission.admit( + 5, + List.of(currentOne, currentTwo), + List.of(firstBlocker, secondBlocker) + ); + HydrologyCrossTileCaveAdmission.Result reverse = HydrologyCrossTileCaveAdmission.admit( + 5, + reverseClaims, + reverseBlockers + ); + + assertEquals(Map.of(61L, 63L, 62L, 64L), forward.loserCourseIdsToWinnerSourceIds()); + assertEquals(forward, reverse); + } + + @Test + public void sameOrHigherRankClaimsCannotBeUsedAsBlockers() { + HydrologyCrossTileCaveAdmission.Claim current = claim( + 71L, + 30, + "water", + Map.of(position(1, 20, 1), HydrologyCaveAction.WET_SOURCE) + ); + HydrologyCrossTileCaveAdmission.RankedClaim blocker = ranked( + 1, + 1, + 3, + claim(72L, 20, "water", Map.of(position(1, 20, 1), HydrologyCaveAction.DRY_AIR)) + ); + + assertThrows( + IllegalArgumentException.class, + () -> HydrologyCrossTileCaveAdmission.admit(3, List.of(current), List.of(blocker)) + ); + } + + private HydrologyCrossTileCaveAdmission.RankedClaim ranked( + int tileX, + int tileZ, + int rank, + HydrologyCrossTileCaveAdmission.Claim claim + ) { + return new HydrologyCrossTileCaveAdmission.RankedClaim( + new HydrologyTileKey(tileX, tileZ), + rank, + claim + ); + } + + private HydrologyCrossTileCaveAdmission.Claim claim( + long sourceId, + int waterHeadY, + String profileKey, + Map actions + ) { + CavePosition entry = position((int) sourceId, waterHeadY + 4, 0); + CavePosition target = position((int) sourceId, waterHeadY, 0); + HydrologyCaveSource source = new HydrologyCaveSource( + sourceId, + entry, + target, + waterHeadY, + HydrologyCaveMode.CLOSED_COMPONENT + ); + LinkedHashMap preconditions = new LinkedHashMap<>(); + for (CavePosition position : actions.keySet()) { + preconditions.put(position, new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + } + HydrologyCavePlan plan = new HydrologyCavePlan( + source, + HydrologyCaveRejection.NONE, + actions, + preconditions, + OptionalLong.empty() + ); + return new HydrologyCrossTileCaveAdmission.Claim(profileKey, plan); + } + + private CavePosition position(int x, int y, int z) { + return new CavePosition(x, y, z); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmissionTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmissionTest.java new file mode 100644 index 000000000..48bb8fd8e --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyCrossTileSurfaceAdmissionTest.java @@ -0,0 +1,139 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class HydrologyCrossTileSurfaceAdmissionTest { + @Test + public void rejectsClusteredIndependentMouths() { + HydrologyCrossTileSurfaceAdmission.Claim current = claim( + 10L, + 100L, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(100, 63, 0)), + true + ); + HydrologyCrossTileSurfaceAdmission.Claim blocker = claim( + 20L, + 200L, + List.of(new HydrologyPoint(0, 70, 48), new HydrologyPoint(112, 63, 24)), + true + ); + + HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit( + List.of(current), + List.of(new HydrologyCrossTileSurfaceAdmission.RankedClaim( + new HydrologyTileKey(0, 0), + 0, + blocker + )), + 128 + ); + + assertEquals(1, result.rejections().size()); + assertEquals(current.courseId(), result.rejections().getFirst().loser().courseId()); + } + + @Test + public void rejectsCrossingUnrelatedCourses() { + HydrologyCrossTileSurfaceAdmission.Claim current = claim( + 10L, + 100L, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(100, 65, 100)), + false + ); + HydrologyCrossTileSurfaceAdmission.Claim blocker = claim( + 20L, + 200L, + List.of(new HydrologyPoint(0, 70, 100), new HydrologyPoint(100, 65, 0)), + false + ); + + HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit( + List.of(current), + List.of(new HydrologyCrossTileSurfaceAdmission.RankedClaim( + new HydrologyTileKey(0, 0), + 0, + blocker + )), + 64 + ); + + assertEquals(1, result.rejections().size()); + } + + @Test + public void rejectsNearbySourcesWhenTheirCoursesImmediatelyDiverge() { + HydrologyCrossTileSurfaceAdmission.Claim current = claim( + 10L, + 100L, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(512, 63, 0)), + true + ); + HydrologyCrossTileSurfaceAdmission.Claim blocker = claim( + 20L, + 200L, + List.of(new HydrologyPoint(0, 70, 128), new HydrologyPoint(-512, 63, 128)), + true + ); + + HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit( + List.of(current), + List.of(new HydrologyCrossTileSurfaceAdmission.RankedClaim( + new HydrologyTileKey(0, 0), + 0, + blocker + )), + 192 + ); + + assertEquals(1, result.rejections().size()); + } + + @Test + public void keepsSeparatedNetworks() { + HydrologyCrossTileSurfaceAdmission.Claim current = claim( + 10L, + 100L, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(100, 63, 0)), + true + ); + HydrologyCrossTileSurfaceAdmission.Claim blocker = claim( + 20L, + 200L, + List.of(new HydrologyPoint(0, 70, 256), new HydrologyPoint(100, 63, 256)), + true + ); + + HydrologyCrossTileSurfaceAdmission.Result result = HydrologyCrossTileSurfaceAdmission.admit( + List.of(current), + List.of(new HydrologyCrossTileSurfaceAdmission.RankedClaim( + new HydrologyTileKey(0, 0), + 0, + blocker + )), + 128 + ); + + assertTrue(result.rejections().isEmpty()); + } + + private HydrologyCrossTileSurfaceAdmission.Claim claim( + long courseId, + long outletId, + List centerline, + boolean reachesOutlet + ) { + return new HydrologyCrossTileSurfaceAdmission.Claim( + courseId, + outletId, + centerline.getLast(), + reachesOutlet, + 4, + centerline + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java new file mode 100644 index 000000000..6dcb4d207 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java @@ -0,0 +1,2226 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.OptionalLong; +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.assertTrue; + +public class HydrologyFootprintCompilerTest { + @Test + public void surfaceChannelNeverRaisesTerrainOrPublishesUnsupportedFluid() { + HydraulicSegment segment = new HydraulicSegment( + 9L, + 8L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 10, + 4, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand( + z < 0 ? 54 : 82, + 0D, + "parent" + ); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + terrain, + request -> request.minimum() + ).compile(List.of(course(8L, RiverCourseType.SURFACE, segment))); + + HydrologyColumnSample lowCenter = footprint.sample(12, -1).orElse(null); + assertTrue(lowCenter == null || lowCenter.primarySurfaceFluidLayer().isEmpty()); + for (HydrologyColumnSample sample : footprint.columns().values()) { + assertTrue(sample.terrainHeight() <= sample.naturalHeight()); + sample.primarySurfaceFluidLayer().ifPresent((HydrologyColumnLayer layer) -> + assertTrue(layer.fluidHeadY() < sample.naturalHeight())); + } + } + + @Test + public void sparseCenterlinePublishesAContinuousOwnedFluidChannel() { + HydraulicSegment segment = new HydraulicSegment( + 11L, + 10L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0)) + ); + RiverCourse course = new RiverCourse( + 10L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(segment) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(75, 1D, "parent"); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + terrain, + HydrologyGeometrySampler.deterministic(1L, terrain) + ).compile(List.of(course)); + + for (int x = 0; x <= 8; x++) { + HydrologyColumnLayer layer = footprint.sample(x, 0) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElseThrow(); + assertTrue(layer.channel()); + assertTrue(layer.connectedFluid()); + assertTrue(layer.fluidOwned()); + } + HydrologyColumnLayer firstShared = footprint.sample(1, 0) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElseThrow(); + HydrologyColumnLayer secondShared = footprint.sample(2, 0) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElseThrow(); + assertSame(firstShared.feature(), secondShared.feature()); + } + + @Test + public void sweptSurfaceChannelDoesNotRoundItsCascadeLipOverLowerTerrain() { + HydraulicSegment approach = new HydraulicSegment( + 21L, + 20L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 12, + 5, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0)) + ); + HydraulicSegment cascade = new HydraulicSegment( + 22L, + 20L, + HydrologyFeatureType.WATERFALL, + 70, + 60, + 4, + 5, + true, + true, + List.of( + new HydrologyPoint(8, 70, 0), + new HydrologyPoint(9, 60, 0), + new HydrologyPoint(10, 60, 0) + ) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand( + x > 8 ? 64 : 82, + 0D, + "parent" + ); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + terrain, + request -> request.minimum() + ).compile(List.of(new RiverCourse( + 20L, + RiverCourseType.SURFACE, + OptionalLong.of(21L), + OptionalLong.of(22L), + "water", + 1, + List.of(), + List.of(approach, cascade) + ))); + + assertTrue(footprint.sample(8, 0).flatMap(HydrologyColumnSample::primarySurfaceFluidLayer).isPresent()); + HydrologyColumnLayer receivingLayer = footprint.sample(9, 0) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElseThrow(); + assertTrue(receivingLayer.fluidHeadY() <= 64); + } + + @Test + public void sweptSurfaceChannelHasABroadThalwegAndContinuousOrganicBanks() { + HydraulicSegment segment = new HydraulicSegment( + 13L, + 12L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 12, + 5, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(96, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent"); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + terrain, + request -> request.minimum() + ).compile(List.of(course(12L, RiverCourseType.SURFACE, segment))); + + ArrayList crossSection = new ArrayList<>(); + for (int z = 0; z <= 8; z++) { + HydrologyColumnLayer layer = footprint.sample(72, z) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElse(null); + if (layer == null) { + break; + } + crossSection.add(layer.bedY()); + } + assertTrue(crossSection.size() >= 7); + assertTrue(crossSection.getFirst() >= 65 && crossSection.getFirst() <= 66); + int thalwegMinimum = crossSection.subList(0, 4).stream().mapToInt(Integer::intValue).min().orElseThrow(); + int thalwegMaximum = crossSection.subList(0, 4).stream().mapToInt(Integer::intValue).max().orElseThrow(); + assertTrue(thalwegMaximum - thalwegMinimum <= 1); + for (int index = 1; index < crossSection.size(); index++) { + assertTrue(crossSection.get(index) >= crossSection.get(index - 1)); + assertTrue( + "crossSection=" + crossSection, + crossSection.get(index) - crossSection.get(index - 1) <= 3 + ); + } + + for (int x = 60; x < 84; x++) { + for (int z = -5; z < 5; z++) { + HydrologyColumnLayer northWest = surfaceFluidLayer(footprint, x, z); + HydrologyColumnLayer northEast = surfaceFluidLayer(footprint, x + 1, z); + HydrologyColumnLayer southWest = surfaceFluidLayer(footprint, x, z + 1); + HydrologyColumnLayer southEast = surfaceFluidLayer(footprint, x + 1, z + 1); + if (northWest == null || northEast == null || southWest == null || southEast == null) { + continue; + } + boolean checker = northWest.bedY() == southEast.bedY() + && northEast.bedY() == southWest.bedY() + && northWest.bedY() != northEast.bedY(); + assertFalse(checker); + } + } + } + + @Test + public void surfaceChannelRejectsTerrainAtItsFluidHead() { + HydraulicSegment segment = new HydraulicSegment( + 15L, + 14L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(70, 0D, "parent"); + RiverFootprint footprint = compiler(terrain).compile(List.of(course(14L, RiverCourseType.SURFACE, segment))); + + for (int x = 0; x <= 24; x++) { + assertTrue(footprint.sample(x, 0) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .isEmpty()); + } + } + + @Test + public void deepPoolIsOneConnectedAsymmetricMultiLobedBowl() { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "lava", + true, + 1D, + 512, + -64, + 32, + 20, + 20, + 8, + 8, + 0, + 0, + 4, + 2, + 5, + 65536, + 1, + true, + false + ); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + base.surface(), + base.hydraulics(), + base.underground(), + base.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + List.of(deepFluid) + ); + HydraulicSegment segment = new HydraulicSegment( + 15L, + 14L, + HydrologyFeatureType.DEEP_POOL, + -20, + -20, + 40, + 8, + false, + false, + List.of(new HydrologyPoint(0, -20, 0)) + ); + RiverCourse course = new RiverCourse( + 14L, + RiverCourseType.DEEP_FLUID, + OptionalLong.empty(), + OptionalLong.empty(), + "lava", + 1, + List.of(), + List.of(segment) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "parent"); + RiverFootprint footprint = new HydrologyFootprintCompiler(settings, terrain, request -> request.minimum()) + .compile(List.of(course)); + + HashSet wetColumns = new HashSet<>(); + int shallowestBed = Integer.MIN_VALUE; + for (HydrologyColumnSample sample : footprint.columns().values()) { + HydrologyColumnLayer layer = sample.layers().getFirst(); + if (!layer.channel()) { + continue; + } + wetColumns.add(RiverFootprint.pack(sample.x(), sample.z())); + shallowestBed = Math.max(shallowestBed, layer.bedY()); + } + assertTrue(wetColumns.size() > 100); + assertEquals(wetColumns.size(), connectedColumnCount(wetColumns)); + assertTrue(layerAt(footprint, 0, 0).bedY() < shallowestBed); + + boolean rotationalMismatch = false; + for (long packed : wetColumns) { + int x = RiverFootprint.unpackX(packed); + int z = RiverFootprint.unpackZ(packed); + if (wetColumns.contains(RiverFootprint.pack(-z, x)) != wetColumns.contains(packed)) { + rotationalMismatch = true; + break; + } + } + assertTrue(rotationalMismatch); + } + + @Test + public void overlappingRasterPointsReuseExactClassificationAndPreserveCanonicalAnchors() { + HydraulicSegment segment = new HydraulicSegment( + 111L, + 110L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(16, 70, 0)) + ); + RiverCourse course = course(110L, RiverCourseType.SURFACE, segment); + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent"); + CountingNaturalSampler naturalSampler = new CountingNaturalSampler( + terrain, + HydrologyRoutingTerrainSampler.NaturalClassification.LAND + ); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + new HydrologyFootprintCompiler.Sampling( + (int x, int z) -> terrain, + request -> request.minimum(), + naturalSampler + ) + ).compile(List.of(course)); + + assertEquals(footprint.size(), naturalSampler.classificationCalls()); + ArrayList keys = new ArrayList<>(footprint.columns().keySet()); + ArrayList sortedKeys = new ArrayList<>(keys); + sortedKeys.sort(Long::compareTo); + assertEquals(sortedKeys, keys); + HydrologyFeatureRef channelFeature = footprint.sample(1, 0) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElseThrow() + .feature(); + assertEquals(70, channelFeature.y()); + assertEquals(111L, channelFeature.segmentId()); + assertTrue(footprint.sample(channelFeature.x(), channelFeature.z()).isPresent()); + } + + @Test + public void styledBlendAndHeadroomSampleOnlySelectedCenterlines() { + HydraulicSegment surfaceSegment = new HydraulicSegment( + 21L, + 20L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0)) + ); + HydraulicSegment undergroundSegment = new HydraulicSegment( + 31L, + 30L, + HydrologyFeatureType.UNDERGROUND_POOL, + 50, + 50, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(20, 50, 0)) + ); + RiverCourse surface = course(20L, RiverCourseType.SURFACE, surfaceSegment); + RiverCourse underground = course(30L, RiverCourseType.UNDERGROUND, undergroundSegment); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 1D, "parent"); + AtomicInteger geometrySamples = new AtomicInteger(); + HydrologyGeometrySampler geometry = request -> { + geometrySamples.incrementAndGet(); + return request.x() < 10 ? request.maximum() : request.minimum(); + }; + + RiverFootprint first = new HydrologyFootprintCompiler(settings, terrain, geometry) + .compile(List.of(surface, underground)); + RiverFootprint repeated = new HydrologyFootprintCompiler(settings, terrain, geometry) + .compile(List.of(surface, underground)); + + assertEquals(first, repeated); + assertEquals(4, geometrySamples.get()); + int surfaceRadius = surfaceSegment.width() / 2; + int outerX = surfaceRadius + + (int) StrictMath.ceil(settings.surface().shoreWidth()) + + settings.surface().maximumTerrainBlendWidth(); + HydrologyColumnLayer grading = first.sample(outerX, 0).orElseThrow().layers().getFirst(); + assertTrue(grading.grading()); + HydrologyColumnLayer channel = first.sample(20, 0).orElseThrow().layers().getFirst(); + assertEquals(settings.underground().minimumHeadroom(), channel.ceilingY() - channel.fluidHeadY()); + } + + @Test + public void surfaceChannelUsesSlopeFreeBasesWithIdenticalFootprint() { + HydraulicSegment segment = new HydraulicSegment( + 41L, + 40L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0)) + ); + RiverCourse course = course(40L, RiverCourseType.SURFACE, segment); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyTerrainSample detailed = HydrologyTerrainSample.openLand(80, 20D, "parent"); + AtomicInteger baselineCalls = new AtomicInteger(); + HydrologyTerrainSampler baselineSampler = (int x, int z) -> { + baselineCalls.incrementAndGet(); + return detailed; + }; + RiverFootprint baseline = new HydrologyFootprintCompiler( + settings, + baselineSampler, + request -> request.minimum() + ).compile(List.of(course)); + AtomicInteger detailedCalls = new AtomicInteger(); + HydrologyTerrainSampler detailedSampler = (int x, int z) -> { + detailedCalls.incrementAndGet(); + return detailed; + }; + CountingNaturalSampler naturalSampler = new CountingNaturalSampler( + detailed.withSlope(0D), + HydrologyRoutingTerrainSampler.NaturalClassification.LAND + ); + + RiverFootprint optimized = new HydrologyFootprintCompiler( + settings, + new HydrologyFootprintCompiler.Sampling( + detailedSampler, + request -> request.minimum(), + naturalSampler + ) + ).compile(List.of(course)); + + assertEquals(baseline, optimized); + assertTrue(baselineCalls.get() > 221); + assertTrue(baselineCalls.get() <= detailedCalls.get() + naturalSampler.basisCalls()); + assertEquals(baselineCalls.get(), naturalSampler.classificationCalls()); + assertTrue(detailedCalls.get() > 0); + assertTrue(naturalSampler.basisCalls() > 0); + } + + @Test + public void surfaceChannelRoundsFromItsDeepCenterIntoParentTerrain() { + HydraulicSegment segment = new HydraulicSegment( + 43L, + 42L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 12, + 10, + false, + false, + List.of(new HydrologyPoint(0, 70, 0)) + ); + RiverCourse course = course(42L, RiverCourseType.SURFACE, segment); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 1D, "parent"); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + terrain, + request -> request.minimum() + ).compile(List.of(course)); + + HydrologyColumnLayer center = layerAt(footprint, 0, 0); + HydrologyColumnLayer wetEdge = layerAt(footprint, 4, 0); + HydrologyColumnLayer shore = layerAt(footprint, 7, 0); + HydrologyColumnLayer outerGrade = layerAt(footprint, 12, 0); + + assertTrue(center.bedY() >= 60 && center.bedY() <= 61); + assertTrue(center.bedY() < wetEdge.bedY()); + assertTrue(wetEdge.bedY() < shore.bedY()); + assertTrue(shore.bedY() < outerGrade.bedY()); + assertEquals(76, outerGrade.bedY()); + assertTrue(center.channel()); + assertTrue(wetEdge.channel()); + assertTrue(shore.shore() || shore.grading()); + assertTrue(footprint.columns().values().stream() + .flatMap((HydrologyColumnSample sample) -> sample.layers().stream()) + .anyMatch(HydrologyColumnLayer::shore)); + assertTrue(outerGrade.grading()); + } + + @Test + public void surfacePoolOutlineIsNotRotationallySymmetric() { + HydraulicSegment segment = new HydraulicSegment( + 47L, + 46L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 12, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0)) + ); + RiverCourse course = course(46L, RiverCourseType.SURFACE, segment); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(84, 1D, "parent"); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + terrain, + request -> request.minimum() + ).compile(List.of(course)); + boolean rotationalMismatch = false; + for (HydrologyColumnSample column : footprint.columns().values()) { + boolean channel = column.primarySurfaceFluidLayer() + .map(HydrologyColumnLayer::channel) + .orElse(false); + if (!channel) { + continue; + } + boolean rotatedChannel = footprint.sample(-column.z(), column.x()) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .map(HydrologyColumnLayer::channel) + .orElse(false); + if (!rotatedChannel) { + rotationalMismatch = true; + break; + } + } + + assertTrue(rotationalMismatch); + } + + @Test + public void waterfallGradingUsesOnlySlopeFreeBasesWithIdenticalFootprint() { + HydraulicSegment segment = new HydraulicSegment( + 51L, + 50L, + HydrologyFeatureType.WATERFALL, + 80, + 68, + 4, + 2, + true, + true, + List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(1, 68, 0)) + ); + RiverCourse course = course(50L, RiverCourseType.SURFACE, segment); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyTerrainSample detailed = HydrologyTerrainSample.openLand(90, 50D, "parent"); + AtomicInteger baselineCalls = new AtomicInteger(); + HydrologyTerrainSampler baselineSampler = (int x, int z) -> { + baselineCalls.incrementAndGet(); + return detailed; + }; + RiverFootprint baseline = new HydrologyFootprintCompiler( + settings, + baselineSampler, + request -> request.minimum() + ).compile(List.of(course)); + AtomicInteger detailedCalls = new AtomicInteger(); + HydrologyTerrainSampler detailedSampler = (int x, int z) -> { + detailedCalls.incrementAndGet(); + return detailed; + }; + CountingNaturalSampler naturalSampler = new CountingNaturalSampler( + detailed.withSlope(0D), + HydrologyRoutingTerrainSampler.NaturalClassification.LAND + ); + + RiverFootprint optimized = new HydrologyFootprintCompiler( + settings, + new HydrologyFootprintCompiler.Sampling( + detailedSampler, + request -> request.minimum(), + naturalSampler + ) + ).compile(List.of(course)); + + assertEquals(baseline, optimized); + assertTrue(baselineCalls.get() > 0); + assertTrue(baselineCalls.get() < 1024); + assertEquals(0, detailedCalls.get()); + assertEquals(baselineCalls.get(), naturalSampler.basisCalls()); + assertEquals(baselineCalls.get(), naturalSampler.classificationCalls()); + + int fallingColumns = 0; + int minimumX = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (HydrologyColumnSample column : baseline.columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (!layer.fallingFluid()) { + continue; + } + fallingColumns++; + minimumX = Math.min(minimumX, column.x()); + maximumX = Math.max(maximumX, column.x()); + minimumZ = Math.min(minimumZ, column.z()); + maximumZ = Math.max(maximumZ, column.z()); + } + } + assertTrue(fallingColumns > 0); + assertTrue(fallingColumns < 13); + assertTrue(maximumZ - minimumZ > maximumX - minimumX); + } + + @Test + public void waterfallCurtainNeverOwnsTheVerticalTerrainCut() { + HydraulicSegment approach = new HydraulicSegment( + 55L, + 54L, + HydrologyFeatureType.SURFACE_POOL, + 80, + 80, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(8, 80, 0)) + ); + HydraulicSegment waterfall = new HydraulicSegment( + 56L, + 54L, + HydrologyFeatureType.WATERFALL, + 80, + 68, + 8, + 3, + true, + true, + List.of(new HydrologyPoint(8, 80, 0), new HydrologyPoint(9, 68, 0)) + ); + HydraulicSegment receiver = new HydraulicSegment( + 57L, + 54L, + HydrologyFeatureType.SURFACE_POOL, + 68, + 68, + 8, + 3, + false, + true, + List.of(new HydrologyPoint(9, 68, 0), new HydrologyPoint(20, 68, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand( + x <= 8 ? 90 : 76, + 0D, + "parent" + ); + RiverCourse course = new RiverCourse( + 54L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(approach, waterfall, receiver) + ); + HydrologyFootprintCompiler compiler = compiler(terrain); + RiverFootprint footprint = compiler.compile(List.of(course)); + + HydrologyColumnSample lip = footprint.sample(8, 0).orElseThrow(); + HydrologyColumnLayer curtain = lip.layers().stream() + .filter((HydrologyColumnLayer layer) -> layer.feature().segmentId() == waterfall.id()) + .filter(HydrologyColumnLayer::fallingFluid) + .findFirst() + .orElseThrow(); + assertFalse(curtain.terrainOwned()); + assertTrue(curtain.fluidOwned()); + assertFalse(footprint.columns().values().stream() + .flatMap((HydrologyColumnSample sample) -> sample.layers().stream()) + .anyMatch((HydrologyColumnLayer layer) -> + layer.feature().segmentId() == waterfall.id() + && layer.fallingFluid() + && layer.terrainOwned())); + assertTrue(footprint.sample(9, 0).orElseThrow().layers().stream() + .anyMatch((HydrologyColumnLayer layer) -> layer.receivingPool() && layer.terrainOwned())); + assertTrue(compiler.surfaceBanksContained(course)); + } + + @Test + public void undergroundPassageIsContinuousArchedAndVerticallyVaried() { + HydraulicSegment segment = new HydraulicSegment( + 61L, + 60L, + HydrologyFeatureType.UNDERGROUND_POOL, + 48, + 48, + 12, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 48, 0), + new HydrologyPoint(12, 48, 5), + new HydrologyPoint(24, 48, 0) + ) + ); + RiverCourse course = course(60L, RiverCourseType.UNDERGROUND, segment); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(84, 1D, "parent"); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + terrain, + request -> request.minimum() + Math.floorMod(request.x(), request.maximum() - request.minimum() + 1) + ).compile(List.of(course)); + + for (int x = 0; x <= 24; x++) { + int z = x <= 12 + ? (int) StrictMath.round(x * 5D / 12D) + : (int) StrictMath.round((24 - x) * 5D / 12D); + HydrologyColumnLayer layer = layerAt(footprint, x, z); + assertTrue(layer.channel()); + assertTrue(layer.connectedFluid()); + } + + HashSet bedLevels = new HashSet<>(); + HashSet ceilingLevels = new HashSet<>(); + int centerBed = layerAt(footprint, 12, 5).bedY(); + int shallowestBed = Integer.MIN_VALUE; + int lowestCeiling = Integer.MAX_VALUE; + for (HydrologyColumnSample column : footprint.columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (!layer.channel()) { + continue; + } + bedLevels.add(layer.bedY()); + ceilingLevels.add(layer.ceilingY()); + shallowestBed = Math.max(shallowestBed, layer.bedY()); + lowestCeiling = Math.min(lowestCeiling, layer.ceilingY()); + } + } + + assertTrue(bedLevels.size() >= 3); + assertTrue(ceilingLevels.size() >= 3); + assertTrue(centerBed < shallowestBed); + assertTrue(lowestCeiling >= 49); + } + + @Test + public void compilerReusesSingleCourseFootprintsAndCombinesThemExactly() { + RiverCourse surface = course( + 71L, + RiverCourseType.SURFACE, + new HydraulicSegment( + 72L, + 71L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0)) + ) + ); + RiverCourse underground = course( + 81L, + RiverCourseType.UNDERGROUND, + new HydraulicSegment( + 82L, + 81L, + HydrologyFeatureType.UNDERGROUND_POOL, + 40, + 40, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(32, 40, 0)) + ) + ); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent"); + AtomicInteger terrainCalls = new AtomicInteger(); + AtomicInteger geometryCalls = new AtomicInteger(); + HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler( + settings, + (int x, int z) -> { + terrainCalls.incrementAndGet(); + return terrain; + }, + request -> { + geometryCalls.incrementAndGet(); + return request.minimum(); + } + ); + + RiverFootprint surfaceFootprint = compiler.compile(List.of(surface)); + int terrainAfterSurface = terrainCalls.get(); + int geometryAfterSurface = geometryCalls.get(); + RiverFootprint repeatedSurface = compiler.compile(List.of(surface)); + + assertSame(surfaceFootprint, repeatedSurface); + assertEquals(terrainAfterSurface, terrainCalls.get()); + assertEquals(geometryAfterSurface, geometryCalls.get()); + + RiverFootprint combined = compiler.compile(List.of(surface, underground)); + int terrainAfterCombined = terrainCalls.get(); + int geometryAfterCombined = geometryCalls.get(); + RiverFootprint repeatedCombined = compiler.compile(List.of(surface, underground)); + RiverFootprint freshCombined = new HydrologyFootprintCompiler( + settings, + (int x, int z) -> terrain, + request -> request.minimum() + ).compile(List.of(surface, underground)); + + assertEquals(freshCombined, combined); + assertEquals(combined, repeatedCombined); + assertTrue(terrainAfterCombined > terrainAfterSurface); + assertTrue(geometryAfterCombined > geometryAfterSurface); + assertEquals(terrainAfterCombined, terrainCalls.get()); + assertEquals(geometryAfterCombined, geometryCalls.get()); + } + + @Test + public void overlappingCourseMergePreservesCanonicalLayerOrder() { + HydraulicSegment pool = new HydraulicSegment( + 211L, + 210L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0)) + ); + HydraulicSegment waterfall = new HydraulicSegment( + 221L, + 220L, + HydrologyFeatureType.WATERFALL, + 74, + 68, + 4, + 2, + true, + true, + List.of(new HydrologyPoint(0, 74, 0)) + ); + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent"); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + (int x, int z) -> terrain, + request -> request.minimum() + ).compile(List.of( + course(210L, RiverCourseType.SURFACE, pool), + course(220L, RiverCourseType.SURFACE, waterfall) + )); + + List layers = footprint.sample(0, 0).orElseThrow().layers(); + assertEquals(2, layers.size()); + assertEquals(HydrologyFeatureType.WATERFALL, layers.get(0).feature().type()); + assertEquals(HydrologyFeatureType.SURFACE_POOL, layers.get(1).feature().type()); + } + + @Test + public void oneCoursePublishesOnlyOneNonFallingSurfaceHeadPerColumn() { + HydraulicSegment upper = new HydraulicSegment( + 231L, + 230L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(-2, 70, 0), new HydrologyPoint(0, 70, 0)) + ); + HydraulicSegment lower = new HydraulicSegment( + 232L, + 230L, + HydrologyFeatureType.SURFACE_POOL, + 68, + 68, + 4, + 2, + false, + true, + List.of(new HydrologyPoint(0, 68, 0), new HydrologyPoint(2, 68, 0)) + ); + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent"); + RiverFootprint footprint = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + (int x, int z) -> terrain, + request -> request.minimum() + ).compile(List.of(new RiverCourse( + 230L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(upper, lower) + ))); + + HashSet heads = new HashSet<>(); + for (HydrologyColumnLayer layer : footprint.sample(0, 0).orElseThrow().layers()) { + if (layer.feature().type().isSurface() + && layer.channel() + && layer.connectedFluid() + && layer.fluidOwned() + && !layer.fallingFluid()) { + heads.add(layer.fluidHeadY()); + } + } + assertEquals(1, heads.size()); + } + + @Test + public void exactOceanClassificationSkipsUnpublishableTerrainSamples() { + HydraulicSegment segment = new HydraulicSegment( + 61L, + 60L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0)) + ); + RiverCourse course = course(60L, RiverCourseType.SURFACE, segment); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyTerrainSample ocean = HydrologyTerrainSample.ocean(62, "ocean"); + AtomicInteger baselineCalls = new AtomicInteger(); + HydrologyTerrainSampler baselineSampler = (int x, int z) -> { + baselineCalls.incrementAndGet(); + return ocean; + }; + RiverFootprint baseline = new HydrologyFootprintCompiler( + settings, + baselineSampler, + request -> request.minimum() + ).compile(List.of(course)); + AtomicInteger detailedCalls = new AtomicInteger(); + HydrologyTerrainSampler detailedSampler = (int x, int z) -> { + detailedCalls.incrementAndGet(); + return ocean; + }; + CountingNaturalSampler naturalSampler = new CountingNaturalSampler( + ocean, + HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + ); + + RiverFootprint optimized = new HydrologyFootprintCompiler( + settings, + new HydrologyFootprintCompiler.Sampling( + detailedSampler, + request -> request.minimum(), + naturalSampler + ) + ).compile(List.of(course)); + + assertEquals(baseline, optimized); + assertTrue(optimized.isEmpty()); + assertTrue(baselineCalls.get() > 221); + assertEquals(baselineCalls.get(), naturalSampler.classificationCalls()); + assertEquals(0, naturalSampler.basisCalls()); + assertEquals(0, detailedCalls.get()); + } + + @Test + public void naturallySubmergedLandColumnsRejectOwnedSurfaceWritesAtAndBelowSeaLevel() { + HydraulicSegment segment = new HydraulicSegment( + 71L, + 70L, + HydrologyFeatureType.SURFACE_POOL, + 63, + 63, + 6, + 2, + false, + false, + List.of(new HydrologyPoint(0, 63, 0)) + ); + RiverCourse course = course(70L, RiverCourseType.SURFACE, segment); + + for (int naturalHeight : List.of(62, 63)) { + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand( + naturalHeight, + 0D, + "shore" + ); + RiverFootprint footprint = compiler(terrain).compile(List.of(course)); + + assertTrue(footprint.isEmpty()); + } + + HydrologyTerrainSampler raisedTerrain = (int x, int z) -> HydrologyTerrainSample.openLand( + 64, + 0D, + "shore" + ); + RiverFootprint raised = compiler(raisedTerrain).compile(List.of(course)); + + assertTrue(raised.sample(0, 0).flatMap(HydrologyColumnSample::primarySurfaceFluidLayer).isPresent()); + } + + @Test + public void naturallySubmergedLandAtAMouthPublishesOnlyANonOwningApron() { + HydraulicSegment mouth = new HydraulicSegment( + 81L, + 80L, + HydrologyFeatureType.MOUTH, + 63, + 63, + 6, + 2, + false, + false, + List.of(new HydrologyPoint(0, 63, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(63, 0D, "shore"); + RiverFootprint footprint = compiler(terrain).compile(List.of(course(80L, RiverCourseType.SURFACE, mouth))); + + HydrologyColumnSample sample = footprint.sample(0, 0).orElseThrow(); + assertEquals(63, sample.terrainHeight()); + assertTrue(sample.layers().stream().allMatch(HydrologyColumnLayer::oceanApron)); + assertTrue(sample.layers().stream().noneMatch(HydrologyColumnLayer::terrainOwned)); + assertTrue(sample.layers().stream().noneMatch(HydrologyColumnLayer::fluidOwned)); + } + + @Test + public void validationRetriesDeferTheOnlyFullFootprintMaterialization() { + HydraulicSegment surface = new HydraulicSegment( + 302L, + 301L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0)) + ); + HydraulicSegment ridge = new HydraulicSegment( + 303L, + 301L, + HydrologyFeatureType.RIDGE_BORE, + 66, + 66, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(8, 66, 0), new HydrologyPoint(12, 66, 0)) + ); + RiverCourse course = new RiverCourse( + 301L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(surface, ridge) + ); + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 1D, "parent"); + HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + (int x, int z) -> terrain, + request -> request.minimum() + ); + + HydrologyFootprintCompiler.ValidationRaster first = compiler.compileValidation(List.of(course)); + HydrologyFootprintCompiler.ValidationRaster retry = compiler.compileValidation(List.of(course)); + + assertEquals(0, compiler.fullMaterializationCount()); + assertEquals(first.columns(), retry.columns()); + assertTrue(first.columns().stream().flatMap( + (HydrologyColumnSample sample) -> sample.layers().stream() + ).allMatch((HydrologyColumnLayer layer) -> layer.feature().type().isUnderground() + || layer.feature().type().isDeepFluid() + || layer.oceanApron())); + + RiverFootprint footprint = compiler.compile(List.of(course)); + + assertEquals(1, compiler.fullMaterializationCount()); + for (HydrologyColumnSample sample : footprint.columns().values()) { + assertEquals( + "planned surface at " + sample.x() + "," + sample.z(), + sample.terrainHeight(), + first.plannedSurface().resolve(sample.x(), sample.z(), sample.naturalHeight()) + ); + } + } + + @Test + public void surfaceRidgeBorePreservesASolidTerrainFollowingRoof() { + HydraulicSegment ridge = new HydraulicSegment( + 311L, + 310L, + HydrologyFeatureType.RIDGE_BORE, + 70, + 70, + 6, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand( + x < 4 ? 90 : 77, + 0D, + "parent" + ); + RiverFootprint footprint = compiler(terrain).compile(List.of(course( + 310L, + RiverCourseType.SURFACE, + ridge + ))); + + HydrologyColumnLayer deepRoof = layerAt(footprint, 2, 0); + HydrologyColumnLayer taperedRoof = layerAt(footprint, 6, 0); + assertEquals(80, deepRoof.ceilingY()); + assertEquals(76, taperedRoof.ceilingY()); + for (HydrologyColumnSample sample : footprint.columns().values()) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().type() == HydrologyFeatureType.RIDGE_BORE) { + assertTrue(layer.ceilingY() < sample.naturalHeight()); + } + } + } + } + + @Test + public void surfaceUndergroundPoolPreservesASolidTerrainFollowingRoof() { + HydraulicSegment pool = new HydraulicSegment( + 316L, + 315L, + HydrologyFeatureType.UNDERGROUND_POOL, + 70, + 70, + 6, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand( + x < 4 ? 90 : 77, + 0D, + "parent" + ); + RiverFootprint footprint = compiler(terrain).compile(List.of(course( + 315L, + RiverCourseType.SURFACE, + pool + ))); + + HydrologyColumnLayer deepRoof = layerAt(footprint, 2, 0); + HydrologyColumnLayer taperedRoof = layerAt(footprint, 6, 0); + assertTrue(deepRoof.ceilingY() < footprint.sample(2, 0).orElseThrow().naturalHeight()); + assertEquals(76, taperedRoof.ceilingY()); + for (HydrologyColumnSample sample : footprint.columns().values()) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().type() == HydrologyFeatureType.UNDERGROUND_POOL) { + assertTrue(layer.ceilingY() < sample.naturalHeight()); + } + } + } + } + + @Test + public void surfaceBankGradingTapersAnIntersectingBoreInsteadOfOpeningItsSide() { + HydraulicSegment surface = new HydraulicSegment( + 321L, + 320L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 6, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(8, 70, 0)) + ); + HydraulicSegment ridge = new HydraulicSegment( + 322L, + 320L, + HydrologyFeatureType.RIDGE_BORE, + 74, + 74, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 74, 8), new HydrologyPoint(8, 74, 8)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "parent"); + RiverFootprint footprint = compiler(terrain).compile(List.of(new RiverCourse( + 320L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(surface, ridge) + ))); + + HydrologyColumnSample gradedBank = footprint.sample(4, 8).orElseThrow(); + assertTrue(gradedBank.layers().stream().noneMatch( + (HydrologyColumnLayer layer) -> layer.feature().type() == HydrologyFeatureType.RIDGE_BORE + )); + assertTrue(gradedBank.terrainHeight() < gradedBank.naturalHeight()); + } + + @Test + public void compactValidationMatchesFullRasterAcrossOverlapsOceanAndSurfaceOnlyCourses() { + RiverCourse mixed = new RiverCourse( + 401L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of( + new HydraulicSegment( + 402L, + 401L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 6, + 2, + false, + false, + List.of(new HydrologyPoint(-8, 70, 0), new HydrologyPoint(8, 70, 0)) + ), + new HydraulicSegment( + 403L, + 401L, + HydrologyFeatureType.RIDGE_BORE, + 60, + 60, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(8, 60, 0), new HydrologyPoint(14, 60, 0)) + ), + new HydraulicSegment( + 404L, + 401L, + HydrologyFeatureType.MOUTH, + 63, + 63, + 6, + 2, + false, + false, + List.of(new HydrologyPoint(14, 63, 0), new HydrologyPoint(20, 63, 0)) + ) + ) + ); + RiverCourse surfaceOnly = course( + 411L, + RiverCourseType.SURFACE, + new HydraulicSegment( + 412L, + 411L, + HydrologyFeatureType.SURFACE_POOL, + 74, + 74, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 74, -4), new HydrologyPoint(12, 74, -4)) + ) + ); + RiverCourse underground = new RiverCourse( + 421L, + RiverCourseType.UNDERGROUND, + OptionalLong.of(3L), + OptionalLong.of(4L), + "water", + 1, + List.of(), + List.of( + new HydraulicSegment( + 422L, + 421L, + HydrologyFeatureType.UNDERGROUND_POOL, + 45, + 45, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(4, 45, 2), new HydrologyPoint(16, 45, 2)) + ), + new HydraulicSegment( + 423L, + 421L, + HydrologyFeatureType.MOUTH, + 63, + 63, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(16, 63, 2), new HydrologyPoint(20, 63, 2)) + ) + ) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> x >= 17 + ? HydrologyTerrainSample.ocean(63, "ocean") + : HydrologyTerrainSample.openLand(80 + Math.floorMod(z, 3), Math.abs(z), "parent"); + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + List courses = List.of(mixed, surfaceOnly, underground); + HydrologyFootprintCompiler compactCompiler = new HydrologyFootprintCompiler( + settings, + terrain, + request -> request.minimum() + ); + HydrologyFootprintCompiler.ValidationRaster validation = compactCompiler.compileValidation(courses); + RiverFootprint full = new HydrologyFootprintCompiler( + settings, + terrain, + request -> request.minimum() + ).compile(courses); + + ArrayList expectedValidationColumns = new ArrayList<>(); + for (HydrologyColumnSample sample : full.columns().values()) { + ArrayList layers = new ArrayList<>(); + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().type().isUnderground() + || layer.feature().type().isDeepFluid() + || layer.oceanApron()) { + layers.add(layer); + } + } + if (!layers.isEmpty()) { + expectedValidationColumns.add(new HydrologyColumnSample( + sample.x(), + sample.z(), + sample.naturalHeight(), + sample.seaLevel(), + sample.ocean(), + sample.parentBiomeKey(), + layers + )); + } + assertEquals( + "planned surface at " + sample.x() + "," + sample.z(), + sample.terrainHeight(), + validation.plannedSurface().resolve( + sample.x(), + sample.z(), + terrain.sample(sample.x(), sample.z()).naturalHeight() + ) + ); + } + assertEquals(expectedValidationColumns.size(), validation.columns().size()); + for (int index = 0; index < expectedValidationColumns.size(); index++) { + assertEquals( + "validation column " + index, + expectedValidationColumns.get(index), + validation.columns().get(index) + ); + } + assertEquals(91, validation.plannedSurface().resolve(80, 80, 91)); + assertEquals(92, validation.plannedSurface().resolve(80, 80, 92)); + } + + @Test + public void adjacentSurfaceSegmentsMatchOneContinuousSurfaceSweep() { + HydraulicSegment continuous = new HydraulicSegment( + 501L, + 500L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of( + new HydrologyPoint(0, 70, 0), + new HydrologyPoint(32, 70, 0), + new HydrologyPoint(64, 70, 0) + ) + ); + HydraulicSegment first = new HydraulicSegment( + 502L, + 500L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(32, 70, 0)) + ); + HydraulicSegment second = new HydraulicSegment( + 503L, + 500L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(32, 70, 0), new HydrologyPoint(64, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent"); + RiverFootprint continuousFootprint = compiler(terrain).compile(List.of(new RiverCourse( + 500L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(continuous) + ))); + RiverFootprint adjacentFootprint = compiler(terrain).compile(List.of(new RiverCourse( + 500L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(first, second) + ))); + + assertEquals(continuousFootprint.columns().keySet(), adjacentFootprint.columns().keySet()); + for (long packed : continuousFootprint.columns().keySet()) { + HydrologyColumnLayer continuousLayer = continuousFootprint.columns().get(packed) + .primarySurfaceLayer() + .orElse(null); + HydrologyColumnLayer adjacentLayer = adjacentFootprint.columns().get(packed) + .primarySurfaceLayer() + .orElse(null); + assertEquals(continuousLayer == null, adjacentLayer == null); + if (continuousLayer == null) { + continue; + } + assertEquals(continuousLayer.channel(), adjacentLayer.channel()); + assertEquals(continuousLayer.shore(), adjacentLayer.shore()); + assertEquals(continuousLayer.grading(), adjacentLayer.grading()); + assertEquals(continuousLayer.fluidHeadY(), adjacentLayer.fluidHeadY()); + } + } + + @Test + public void continuousSurfaceBendHasNoJoinLayerOrDiagonalSpur() { + HydraulicSegment first = new HydraulicSegment( + 511L, + 510L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(32, 70, 0)) + ); + HydraulicSegment second = new HydraulicSegment( + 512L, + 510L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(32, 70, 0), new HydrologyPoint(32, 70, 32)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent"); + RiverFootprint footprint = compiler(terrain).compile(List.of(new RiverCourse( + 510L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(first, second) + ))); + + HydrologyColumnSample join = footprint.sample(32, 0).orElseThrow(); + long joinedChannels = join.layers().stream() + .filter((HydrologyColumnLayer layer) -> layer.feature().type().isSurface() && layer.channel()) + .count(); + assertEquals(1L, joinedChannels); + assertTrue(join.primarySurfaceFluidLayer().isPresent()); + assertTrue(footprint.sample(39, -7).isEmpty()); + } + + @Test + public void surfaceHeadwaterTapersUpToChannelWidthWithoutARadialBasin() { + HydraulicSegment segment = new HydraulicSegment( + 521L, + 520L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(64, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent"); + RiverCourse course = course(520L, RiverCourseType.SURFACE, segment); + HydrologyFootprintCompiler compiler = compiler(terrain); + RiverFootprint footprint = compiler.compile(List.of(course)); + + assertTrue(surfaceFluidLayer(footprint, -4, 0) == null); + assertTrue(wetHalfWidth(footprint, 0) < wetHalfWidth(footprint, 32)); + assertTrue(wetHalfWidth(footprint, 32) <= wetHalfWidth(footprint, 56)); + HydrologyColumnLayer source = surfaceFluidLayer(footprint, 0, 0); + HydrologyColumnLayer cruise = surfaceFluidLayer(footprint, 56, 0); + assertTrue(source != null); + assertTrue(cruise != null); + int sourceDepth = source.fluidHeadY() - source.bedY(); + int cruiseDepth = cruise.fluidHeadY() - cruise.bedY(); + assertTrue(Math.abs(sourceDepth - cruiseDepth) <= 1); + assertTrue(compiler.surfaceHeadwaterRampSupported(course)); + } + + @Test + public void shortSurfaceHeadwaterStillTapersBeforeAnUndergroundTransition() { + HydraulicSegment surface = new HydraulicSegment( + 525L, + 524L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(12, 70, 0)) + ); + HydraulicSegment ridge = new HydraulicSegment( + 526L, + 524L, + HydrologyFeatureType.RIDGE_BORE, + 69, + 69, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(12, 69, 0), new HydrologyPoint(24, 69, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent"); + RiverCourse course = new RiverCourse( + 524L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(surface, ridge) + ); + HydrologyFootprintCompiler compiler = compiler(terrain); + RiverFootprint footprint = compiler.compile(List.of(course)); + + assertTrue(wetHalfWidth(footprint, 0) < wetHalfWidth(footprint, 8)); + assertFalse(compiler.surfaceHeadwaterRampSupported(course)); + } + + @Test + public void continuousSurfaceBoreHasNoSecondHeadwaterOrRoundedSeams() { + HydraulicSegment entry = new HydraulicSegment( + 528L, + 527L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(32, 70, 0)) + ); + HydraulicSegment bore = new HydraulicSegment( + 529L, + 527L, + HydrologyFeatureType.RIDGE_BORE, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(32, 70, 0), new HydrologyPoint(64, 70, 0)) + ); + HydraulicSegment exit = new HydraulicSegment( + 530L, + 527L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(64, 70, 0), new HydrologyPoint(96, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent"); + RiverCourse course = new RiverCourse( + 527L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(entry, bore, exit) + ); + RiverFootprint footprint = compiler(terrain).compile(List.of(course)); + + for (int x = 32; x <= 64; x++) { + HydrologyColumnSample sample = footprint.sample(x, 0).orElseThrow(); + assertTrue(sample.layers().stream().anyMatch((HydrologyColumnLayer layer) -> + layer.feature().segmentId() == bore.id() + && layer.channel() + && layer.connectedFluid() + && layer.fluidOwned())); + } + assertTrue(footprint.sample(31, 0).orElseThrow().layers().stream().noneMatch( + (HydrologyColumnLayer layer) -> layer.feature().segmentId() == bore.id() + )); + assertTrue(footprint.sample(65, 0).orElseThrow().layers().stream().noneMatch( + (HydrologyColumnLayer layer) -> layer.feature().segmentId() == bore.id() + )); + HydrologyColumnLayer reopened = surfaceFluidLayer(footprint, 64, 0); + assertTrue(reopened != null); + assertFalse(reopened.feature().source()); + assertEquals(wetHalfWidth(footprint, 65), wetHalfWidth(footprint, 80)); + } + + @Test + public void incompleteSurfaceBoreCenterlineRejectsTheWholeCourse() { + HydraulicSegment entry = new HydraulicSegment( + 532L, + 531L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(16, 70, 0)) + ); + HydraulicSegment bore = new HydraulicSegment( + 533L, + 531L, + HydrologyFeatureType.RIDGE_BORE, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(16, 70, 0), new HydrologyPoint(48, 70, 0)) + ); + HydraulicSegment exit = new HydraulicSegment( + 534L, + 531L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(48, 70, 0), new HydrologyPoint(64, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand( + x == 32 ? 70 : 82, + 0D, + "parent" + ); + RiverCourse course = new RiverCourse( + 531L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(entry, bore, exit) + ); + + assertFalse(compiler(terrain).surfaceBanksContained(course)); + } + + @Test + public void descendingSurfaceBanksRemainAboveEveryAdjacentFluidHead() { + HydraulicSegment cascade = new HydraulicSegment( + 528L, + 527L, + HydrologyFeatureType.CASCADE, + 74, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 74, 0), new HydrologyPoint(24, 70, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent"); + RiverFootprint footprint = compiler(terrain).compile(List.of(course(527L, RiverCourseType.SURFACE, cascade))); + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + for (HydrologyColumnSample sample : footprint.columns().values()) { + HydrologyColumnLayer fluid = sample.primarySurfaceFluidLayer().orElse(null); + if (fluid == null) { + continue; + } + for (int[] offset : offsets) { + HydrologyColumnSample neighbor = footprint.sample( + sample.x() + offset[0], + sample.z() + offset[1] + ).orElse(null); + assertTrue(neighbor != null); + if (neighbor.primarySurfaceFluidLayer().isPresent()) { + continue; + } + assertTrue(neighbor.terrainHeight() > fluid.fluidHeadY()); + } + } + } + + @Test + public void surfaceContainmentClipsAnUnsupportedWetEdgeWithoutRaisingItsNaturalBank() { + HydraulicSegment channel = new HydraulicSegment( + 529L, + 528L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0)) + ); + RiverCourse course = course(528L, RiverCourseType.SURFACE, channel); + HydrologyTerrainSampler highTerrain = (int x, int z) -> + HydrologyTerrainSample.openLand(82, 0D, "parent"); + HydrologyFootprintCompiler highCompiler = compiler(highTerrain); + RiverFootprint highFootprint = highCompiler.compile(List.of(course)); + int bankZ = 0; + while (surfaceFluidLayer(highFootprint, 12, bankZ) != null) { + bankZ++; + } + int lowBankZ = bankZ; + HydrologyTerrainSampler lowBankTerrain = (int x, int z) -> HydrologyTerrainSample.openLand( + x == 12 && z == lowBankZ ? 70 : 82, + 0D, + "parent" + ); + HydrologyFootprintCompiler lowBankCompiler = compiler(lowBankTerrain); + RiverFootprint lowBankFootprint = lowBankCompiler.compile(List.of(course)); + HydrologyColumnSample lowBank = lowBankFootprint.sample(12, lowBankZ).orElseThrow(); + + assertTrue(highCompiler.surfaceBanksContained(course)); + assertEquals(70, lowBank.naturalHeight()); + assertTrue(lowBank.terrainHeight() <= lowBank.naturalHeight()); + assertTrue(surfaceFluidLayer(lowBankFootprint, 12, lowBankZ - 1) == null); + assertTrue(surfaceFluidLayer(lowBankFootprint, 12, lowBankZ - 2) != null); + assertTrue(lowBankCompiler.surfaceBanksContained(course)); + } + + @Test + public void surfaceIncisionContainmentUsesFinalTerrainHeight() { + HydraulicSegment channel = new HydraulicSegment( + 529L, + 528L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(24, 70, 0)) + ); + RiverCourse course = course(528L, RiverCourseType.SURFACE, channel); + HydrologyFootprintCompiler supported = compiler((int x, int z) -> + HydrologyTerrainSample.openLand(76, 0D, "parent")); + HydrologyTerrainSample routingBasis = HydrologyTerrainSample.openLand(76, 0D, "parent"); + HydrologyFootprintCompiler excessive = new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + new HydrologyFootprintCompiler.Sampling( + (int x, int z) -> HydrologyTerrainSample.openLand(79, 0D, "parent"), + request -> request.minimum(), + new CountingNaturalSampler( + routingBasis, + HydrologyRoutingTerrainSampler.NaturalClassification.LAND + ) + ) + ); + + assertTrue(supported.surfaceIncisionContained(course)); + RiverFootprint excessiveFootprint = excessive.compile(List.of(course)); + assertFalse( + "center=" + excessiveFootprint.sample(12, 0), + excessive.surfaceIncisionContained(course) + ); + assertFalse(excessive.surfaceIncisionContained(course, excessiveFootprint)); + } + + @Test + public void surfaceContainmentRejectsAnUnpublishedCascadeCenterline() { + HydraulicSegment cascade = new HydraulicSegment( + 530L, + 529L, + HydrologyFeatureType.CASCADE, + 74, + 70, + 8, + 3, + false, + true, + List.of( + new HydrologyPoint(0, 74, 0), + new HydrologyPoint(1, 73, 0), + new HydrologyPoint(2, 72, 0), + new HydrologyPoint(3, 71, 0), + new HydrologyPoint(4, 70, 0) + ) + ); + RiverCourse course = course(529L, RiverCourseType.SURFACE, cascade); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.ocean(82, "ocean"); + HydrologyFootprintCompiler compiler = compiler(terrain); + + assertTrue(compiler.compile(List.of(course)).isEmpty()); + assertFalse(compiler.surfaceBanksContained(course)); + } + + @Test + public void surfaceContainmentAllowsTerrainFreeFallingFluidCenterline() { + HydraulicSegment waterfall = new HydraulicSegment( + 531L, + 530L, + HydrologyFeatureType.WATERFALL, + 80, + 68, + 8, + 3, + true, + true, + List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(1, 68, 0)) + ); + HydraulicSegment receiver = new HydraulicSegment( + 532L, + 530L, + HydrologyFeatureType.SURFACE_POOL, + 68, + 68, + 8, + 3, + false, + true, + List.of(new HydrologyPoint(1, 68, 0), new HydrologyPoint(20, 68, 0)) + ); + RiverCourse course = new RiverCourse( + 530L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(waterfall, receiver) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand( + x <= 0 ? 90 : 76, + 0D, + "parent" + ); + HydrologyFootprintCompiler compiler = compiler(terrain); + RiverFootprint footprint = compiler.compile(List.of(course)); + HydrologyColumnLayer throat = footprint.sample(0, 0) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElseThrow(); + + assertTrue(throat.fallingFluid()); + assertTrue(throat.fluidOwned()); + assertFalse(throat.terrainOwned()); + assertTrue(compiler.surfaceBanksContained(course)); + } + + @Test + public void oceanMouthWidensGraduallyIntoItsTerminalGrade() { + HydraulicSegment channel = new HydraulicSegment( + 531L, + 530L, + HydrologyFeatureType.SURFACE_POOL, + 63, + 63, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 63, 0), new HydrologyPoint(32, 63, 0)) + ); + HydraulicSegment mouth = new HydraulicSegment( + 532L, + 530L, + HydrologyFeatureType.MOUTH, + 63, + 63, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(32, 63, 0), new HydrologyPoint(64, 63, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> x >= 64 + ? HydrologyTerrainSample.ocean(48, "ocean") + : HydrologyTerrainSample.openLand(76, 0D, "parent"); + RiverCourse course = new RiverCourse( + 530L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(channel, mouth) + ); + HydrologyFootprintCompiler compiler = compiler(terrain); + RiverFootprint footprint = compiler.compile(List.of(course)); + + assertTrue(compiler.surfaceBanksContained(course)); + int upstreamWidth = wetHalfWidth(footprint, 32); + int transitionWidth = wetHalfWidth(footprint, 52); + int terminalWidth = wetHalfWidth(footprint, 62); + assertTrue(transitionWidth >= upstreamWidth); + assertTrue(terminalWidth >= transitionWidth); + assertTrue(terminalWidth > upstreamWidth); + assertTrue(surfaceFluidLayer(footprint, 63, 0) != null); + HydrologyColumnSample firstOcean = footprint.sample(64, 0).orElseThrow(); + assertEquals(48, firstOcean.naturalHeight()); + assertEquals(48, firstOcean.terrainHeight()); + assertFalse(firstOcean.layers().stream().anyMatch(HydrologyColumnLayer::terrainOwned)); + assertFalse(firstOcean.layers().stream().anyMatch(HydrologyColumnLayer::fluidOwned)); + } + + @Test + public void surfaceSinkholeContinuesThroughOneBorePortal() { + HydraulicSegment channel = new HydraulicSegment( + 541L, + 540L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 8, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(48, 70, 0)) + ); + HydraulicSegment sinkhole = new HydraulicSegment( + 542L, + 540L, + HydrologyFeatureType.SINKHOLE, + 70, + 60, + 4, + 2, + false, + true, + List.of(new HydrologyPoint(48, 70, 0), new HydrologyPoint(52, 60, 0)) + ); + HydraulicSegment grotto = new HydraulicSegment( + 543L, + 540L, + HydrologyFeatureType.INLAND_GROTTO, + 60, + 60, + 8, + 4, + false, + true, + List.of(new HydrologyPoint(52, 60, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent"); + RiverFootprint footprint = compiler(terrain).compile(List.of(new RiverCourse( + 540L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(channel, sinkhole, grotto) + ))); + + HydrologyColumnLayer entry = footprint.sample(48, 0).orElseThrow().layers().stream() + .filter((HydrologyColumnLayer layer) -> layer.feature().segmentId() == sinkhole.id()) + .findFirst() + .orElseThrow(); + HydrologyColumnLayer receiver = footprint.sample(52, 0).orElseThrow().layers().stream() + .filter((HydrologyColumnLayer layer) -> layer.feature().segmentId() == sinkhole.id()) + .findFirst() + .orElseThrow(); + assertTrue(wetHalfWidth(footprint, 46) >= wetHalfWidth(footprint, 24)); + assertTrue(entry.channel()); + assertTrue(entry.connectedFluid()); + assertFalse(entry.receivingPool()); + assertTrue(receiver.receivingPool()); + } + + @Test + public void midCourseReceivingPoolWidensWithoutAHalfEdgeSpur() { + HydraulicSegment approach = new HydraulicSegment( + 551L, + 550L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(64, 70, 0)) + ); + HydraulicSegment receiver = new HydraulicSegment( + 552L, + 550L, + HydrologyFeatureType.CASCADE, + 70, + 69, + 4, + 2, + false, + true, + List.of(new HydrologyPoint(64, 70, 0), new HydrologyPoint(80, 69, 0)) + ); + HydraulicSegment outflow = new HydraulicSegment( + 553L, + 550L, + HydrologyFeatureType.SURFACE_POOL, + 69, + 69, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(80, 69, 0), new HydrologyPoint(160, 69, 0)) + ); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(82, 0D, "parent"); + RiverFootprint footprint = compiler(terrain).compile(List.of(new RiverCourse( + 550L, + RiverCourseType.SURFACE, + OptionalLong.of(1L), + OptionalLong.of(2L), + "water", + 1, + List.of(), + List.of(approach, receiver, outflow) + ))); + + int previousWidth = wetHalfWidth(footprint, 40); + for (int x = 44; x <= 120; x += 4) { + int width = wetHalfWidth(footprint, x); + assertTrue(Math.abs(width - previousWidth) <= 1); + previousWidth = width; + } + } + + private RiverCourse course(long id, RiverCourseType type, HydraulicSegment segment) { + return new RiverCourse( + id, + type, + OptionalLong.of(id + 1L), + OptionalLong.of(id + 2L), + "water", + 1, + List.of(), + List.of(segment) + ); + } + + private HydrologyColumnLayer layerAt(RiverFootprint footprint, int x, int z) { + return footprint.sample(x, z).orElseThrow().layers().getFirst(); + } + + private HydrologyColumnLayer surfaceFluidLayer(RiverFootprint footprint, int x, int z) { + return footprint.sample(x, z) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElse(null); + } + + private HydrologyFootprintCompiler compiler(HydrologyTerrainSampler terrain) { + return new HydrologyFootprintCompiler( + HydrologyPlannerSettings.defaults(), + terrain, + request -> request.minimum() + ); + } + + private int wetHalfWidth(RiverFootprint footprint, int x) { + int halfWidth = 0; + for (int z = 0; z <= 32; z++) { + if (surfaceFluidLayer(footprint, x, z) == null) { + break; + } + halfWidth = z; + } + return halfWidth; + } + + private int connectedColumnCount(HashSet columns) { + if (columns.isEmpty()) { + return 0; + } + HashSet visited = new HashSet<>(); + ArrayDeque pending = new ArrayDeque<>(); + pending.add(columns.iterator().next()); + while (!pending.isEmpty()) { + long packed = pending.removeFirst(); + if (!visited.add(packed)) { + continue; + } + int x = RiverFootprint.unpackX(packed); + int z = RiverFootprint.unpackZ(packed); + long[] neighbors = { + RiverFootprint.pack(x - 1, z), + RiverFootprint.pack(x + 1, z), + RiverFootprint.pack(x, z - 1), + RiverFootprint.pack(x, z + 1) + }; + for (long neighbor : neighbors) { + if (columns.contains(neighbor) && !visited.contains(neighbor)) { + pending.addLast(neighbor); + } + } + } + return visited.size(); + } + + private static final class CountingNaturalSampler implements HydrologyNaturalTerrainSampler { + private final HydrologyTerrainSample basis; + private final NaturalClassification classification; + private final AtomicInteger basisCalls; + private final AtomicInteger classificationCalls; + + private CountingNaturalSampler(HydrologyTerrainSample basis, NaturalClassification classification) { + this.basis = basis; + this.classification = classification; + this.basisCalls = new AtomicInteger(); + this.classificationCalls = new AtomicInteger(); + } + + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + classificationCalls.incrementAndGet(); + return classification; + } + + @Override + public HydrologyTerrainSample sampleBasis(int blockX, int blockZ) { + basisCalls.incrementAndGet(); + return basis; + } + + private int basisCalls() { + return basisCalls.get(); + } + + private int classificationCalls() { + return classificationCalls.get(); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java new file mode 100644 index 000000000..e5dbed055 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java @@ -0,0 +1,718 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveMode; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveRejection; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveSource; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyModelTest { + @Test + public void footprintAndColumnCollectionsAreImmutableCopies() { + HydrologyFeatureRef feature = feature(HydrologyFeatureType.SURFACE_POOL, 10L, 70); + ArrayList mutableLayers = new ArrayList<>(); + mutableLayers.add(layer(feature, 67, 70, 70, true, false, false)); + HydrologyColumnSample column = new HydrologyColumnSample(4, 9, 74, 63, false, "parent", mutableLayers); + HashMap mutableColumns = new HashMap<>(); + mutableColumns.put(RiverFootprint.pack(4, 9), column); + RiverFootprint footprint = new RiverFootprint(mutableColumns); + + mutableLayers.clear(); + mutableColumns.clear(); + + assertEquals(1, column.layers().size()); + assertEquals(1, footprint.size()); + assertThrows(UnsupportedOperationException.class, () -> column.layers().add(layer( + feature, + 67, + 70, + 70, + true, + false, + false + ))); + assertThrows(UnsupportedOperationException.class, () -> footprint.columns().clear()); + } + + @Test + public void positiveHeadTransitionsCanShareOneReceivingPoolAcrossAComplex() { + List centerline = List.of( + new HydrologyPoint(0, 80, 0), + new HydrologyPoint(1, 74, 0) + ); + + HydraulicSegment graded = new HydraulicSegment( + 1L, + 2L, + HydrologyFeatureType.WATERFALL, + 80, + 74, + 4, + 2, + false, + true, + centerline + ); + assertEquals(6, graded.drop()); + assertFalse(graded.fallingFluid()); + HydraulicSegment intermediate = new HydraulicSegment( + 1L, + 2L, + HydrologyFeatureType.WATERFALL, + 80, + 74, + 4, + 2, + false, + false, + centerline + ); + assertFalse(intermediate.receivingPool()); + HydraulicSegment accepted = new HydraulicSegment( + 1L, + 2L, + HydrologyFeatureType.WATERFALL, + 80, + 74, + 4, + 2, + true, + true, + centerline + ); + assertEquals(6, accepted.drop()); + } + + @Test + public void surfaceInlandGrottoRequiresOneExplicitSinkholeLinkInTheSameCourse() { + HydraulicSegment lip = new HydraulicSegment( + 1L, + 9L, + HydrologyFeatureType.SURFACE_POOL, + 80, + 80, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 80, 0)) + ); + HydraulicSegment sinkhole = new HydraulicSegment( + 2L, + 9L, + HydrologyFeatureType.SINKHOLE, + 80, + 74, + 4, + 2, + true, + true, + List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(1, 74, 0)) + ); + HydraulicSegment grotto = new HydraulicSegment( + 3L, + 9L, + HydrologyFeatureType.INLAND_GROTTO, + 74, + 74, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(1, 74, 0)) + ); + + RiverCourse accepted = new RiverCourse( + 9L, + RiverCourseType.SURFACE, + OptionalLong.of(4L), + OptionalLong.of(5L), + "water", + 1, + List.of(), + List.of(lip, sinkhole, grotto) + ); + + assertTrue(accepted.surfaceSinkholeContinuation()); + HydraulicSegment containedLip = new HydraulicSegment( + 4L, + 9L, + HydrologyFeatureType.RIDGE_BORE, + 80, + 80, + 4, + 2, + false, + false, + List.of(new HydrologyPoint(0, 80, 0)) + ); + RiverCourse containedApproach = new RiverCourse( + 9L, + RiverCourseType.SURFACE, + OptionalLong.of(4L), + OptionalLong.of(5L), + "water", + 1, + List.of(), + List.of(containedLip, sinkhole, grotto) + ); + assertTrue(containedApproach.surfaceSinkholeContinuation()); + assertThrows(IllegalArgumentException.class, () -> new RiverCourse( + 9L, + RiverCourseType.SURFACE, + OptionalLong.of(4L), + OptionalLong.of(5L), + "water", + 1, + List.of(), + List.of(lip, grotto) + )); + } + + @Test + public void oceanColumnsRejectOwnedOrElevatedWrites() { + HydrologyFeatureRef feature = feature(HydrologyFeatureType.MOUTH, 7L, 63); + HydrologyColumnLayer apron = layer(feature, 63, 63, 63, true, false, true); + HydrologyColumnSample accepted = new HydrologyColumnSample( + 4, + 9, + 50, + 63, + true, + "ocean_parent", + List.of(apron) + ); + assertTrue(accepted.layers().getFirst().oceanApron()); + + HydrologyColumnLayer owned = layer(feature, 61, 63, 63, true, true, false); + assertThrows(IllegalArgumentException.class, () -> new HydrologyColumnSample( + 4, + 9, + 50, + 63, + true, + "ocean_parent", + List.of(owned) + )); + } + + @Test + public void submergedNonOceanColumnsRejectSurfaceOwnershipButAllowSubterrainHydrology() { + HydrologyColumnLayer surface = layer( + feature(HydrologyFeatureType.SURFACE_POOL, 8L, 63), + 60, + 63, + 63, + true, + true, + false + ); + assertThrows(IllegalArgumentException.class, () -> new HydrologyColumnSample( + 4, + 9, + 63, + 63, + false, + "shore_parent", + List.of(surface) + )); + + HydrologyColumnSample raised = new HydrologyColumnSample( + 4, + 9, + 64, + 63, + false, + "shore_parent", + List.of(surface) + ); + assertTrue(raised.primarySurfaceFluidLayer().isPresent()); + + HydrologyColumnLayer underground = layer( + feature(HydrologyFeatureType.UNDERGROUND_POOL, 9L, 40), + 36, + 40, + 45, + true, + true, + false + ); + HydrologyColumnSample subterrain = new HydrologyColumnSample( + 4, + 9, + 50, + 63, + false, + "shore_parent", + List.of(underground) + ); + assertTrue(subterrain.hasFeature(HydrologyFeatureType.UNDERGROUND_POOL)); + } + + @Test + public void outerGradingUsesTheExactParentBiome() { + HydrologyFeatureRef feature = feature(HydrologyFeatureType.SURFACE_POOL, 3L, 70); + HydrologyColumnLayer grading = layer(feature, 72, 72, 72, false, false, false); + grading = new HydrologyColumnLayer( + grading.feature(), + grading.bedY(), + grading.fluidHeadY(), + grading.ceilingY(), + false, + false, + true, + false, + false, + false, + true, + false, + false, + "alpha", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + HydrologyColumnSample sample = new HydrologyColumnSample(0, 0, 74, 63, false, "exact_parent", List.of(grading)); + + assertTrue(grading.biomeKey() == null); + assertEquals("exact_parent", sample.parentBiomeKey()); + assertFalse(sample.hasConnectedFluid()); + } + + @Test + public void overlappingDrySurfaceGradesKeepTheHighestContainedBank() { + HydrologyColumnLayer lowerCascadeGrade = surfaceLayer( + feature(HydrologyFeatureType.CASCADE, 30L, 70), + 70, + 70, + false, + false, + true, + true, + false + ); + HydrologyColumnLayer higherPoolGrade = surfaceLayer( + feature(HydrologyFeatureType.SURFACE_POOL, 31L, 72), + 72, + 72, + false, + false, + true, + true, + false + ); + HydrologyColumnSample sample = new HydrologyColumnSample( + 0, + 0, + 75, + 63, + false, + "parent", + List.of(lowerCascadeGrade, higherPoolGrade) + ); + + assertEquals(72, sample.terrainHeight()); + } + + @Test + public void acceptedSurfaceArbitrationPrefersChannelThenHydraulicType() { + HydrologyColumnLayer grading = surfaceLayer( + feature(HydrologyFeatureType.WATERFALL, 4L, 78), + 73, + 78, + false, + false, + true, + true, + false + ); + HydrologyColumnLayer pool = surfaceLayer( + feature(HydrologyFeatureType.SURFACE_POOL, 5L, 74), + 70, + 74, + true, + false, + false, + true, + true + ); + HydrologyColumnLayer waterfall = surfaceLayer( + feature(HydrologyFeatureType.WATERFALL, 6L, 72), + 66, + 72, + true, + false, + false, + true, + true + ); + HydrologyColumnSample sample = new HydrologyColumnSample( + 4, + 9, + 80, + 63, + false, + "parent", + List.of(grading, pool, waterfall) + ); + + assertEquals(waterfall, sample.primarySurfaceLayer().orElseThrow()); + assertEquals(waterfall, sample.primarySurfaceFluidLayer().orElseThrow()); + assertEquals(66, sample.terrainHeight()); + } + + @Test + public void oceanApronDoesNotOwnSurfaceArbitration() { + HydrologyFeatureRef feature = feature(HydrologyFeatureType.MOUTH, 8L, 63); + HydrologyColumnLayer apron = layer(feature, 63, 63, 63, true, false, true); + HydrologyColumnSample sample = new HydrologyColumnSample( + 4, + 9, + 52, + 63, + true, + "ocean_parent", + List.of(apron) + ); + + assertTrue(sample.primarySurfaceLayer().isEmpty()); + assertTrue(sample.primarySurfaceFluidLayer().isEmpty()); + assertEquals(52, sample.terrainHeight()); + } + + @Test + public void rendererClassificationIsExactlyTheAcceptedColumnClassification() { + HydrologyColumnLayer pool = surfaceLayer( + feature(HydrologyFeatureType.SURFACE_POOL, 10L, 70), + 67, + 70, + true, + false, + false, + true, + true + ); + HydrologyColumnLayer waterfall = surfaceLayer( + feature(HydrologyFeatureType.WATERFALL, 11L, 76), + 64, + 76, + true, + false, + false, + true, + true + ); + HydrologyColumnSample sample = new HydrologyColumnSample( + 4, + 9, + 80, + 63, + false, + "parent", + List.of(pool, waterfall) + ); + + HydrologyRenderSample render = sample.renderSample(); + assertEquals(2, render.features().size()); + assertEquals(HydrologyFeatureType.WATERFALL, render.primaryFeature().orElseThrow().type()); + assertTrue(render.hasFeature(HydrologyFeatureType.SURFACE_POOL)); + assertTrue(render.hasFeature(HydrologyFeatureType.WATERFALL)); + } + + @Test + public void locatorIndexKeepsOnePublishedRepresentativePerSegment() { + HydrologyFeatureRef gradingFeature = new HydrologyFeatureRef( + 10L, HydrologyFeatureType.WATERFALL, 1L, 100L, 0, 74, 0, 1, 0, false + ); + HydrologyFeatureRef fallingFeature = new HydrologyFeatureRef( + 11L, HydrologyFeatureType.WATERFALL, 1L, 100L, 1, 74, 0, 1, 0, false + ); + HydrologyFeatureRef poolFeature = new HydrologyFeatureRef( + 12L, HydrologyFeatureType.SURFACE_POOL, 1L, 101L, 2, 70, 0, 1, 0, false + ); + HydrologyColumnLayer grading = surfaceLayer( + gradingFeature, 74, 74, false, false, true, true, false + ); + HydrologyColumnLayer falling = new HydrologyColumnLayer( + fallingFeature, + 68, + 74, + 74, + true, + false, + false, + true, + true, + true, + true, + true, + false, + "alpha", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + HydrologyColumnLayer pool = surfaceLayer( + poolFeature, 67, 70, true, false, false, true, true + ); + Map columns = Map.of( + RiverFootprint.pack(0, 0), new HydrologyColumnSample(0, 0, 80, 63, false, "parent", List.of(grading)), + RiverFootprint.pack(1, 0), new HydrologyColumnSample(1, 0, 80, 63, false, "parent", List.of(falling)), + RiverFootprint.pack(2, 0), new HydrologyColumnSample(2, 0, 80, 63, false, "parent", List.of(pool)) + ); + RiverFootprint footprint = new RiverFootprint(columns); + + List features = HydrologyTile.collectFeatures(footprint); + + assertEquals(2, features.size()); + assertTrue(features.contains(fallingFeature)); + assertTrue(features.contains(poolFeature)); + for (HydrologyFeatureRef feature : features) { + assertTrue(footprint.sample(feature.x(), feature.z()).orElseThrow().layers().stream() + .anyMatch(layer -> layer.feature().id() == feature.id())); + } + } + + @Test + public void featureQuerySuggestionsIncludeConfiguredDeepFluidsWithoutHardcodedProfiles() { + List suggestions = HydrologyFeatureQuery.suggestions( + List.of("acid", "deep_lava", "acid", "surface", "ridge-tunnel") + ); + + assertEquals(List.of( + "surface", + "waterfall", + "sinkhole", + "underground", + "grotto", + "coastal_grotto", + "inland_grotto", + "mouth", + "ridge_tunnel", + "deep", + "acid", + "deep_lava" + ), suggestions); + } + + @Test + public void featureQueryReservesEveryBuiltInKeywordAndEquivalentHyphenSpelling() { + List builtInKeywords = List.of( + "surface", + "waterfall", + "sinkhole", + "underground", + "grotto", + "coastal_grotto", + "inland_grotto", + "mouth", + "ridge_tunnel", + "deep" + ); + + assertEquals(builtInKeywords, HydrologyFeatureQuery.suggestions(List.of())); + for (String keyword : builtInKeywords) { + assertTrue(keyword, HydrologyFeatureQuery.isReservedKeyword(keyword)); + assertTrue(keyword, HydrologyFeatureQuery.isReservedKeyword(keyword.replace('_', '-'))); + } + assertFalse(HydrologyFeatureQuery.isReservedKeyword("acid")); + assertFalse(HydrologyFeatureQuery.isReservedKeyword(null)); + assertEquals(Set.of(HydrologyFeatureType.SINKHOLE), HydrologyFeatureQuery.parse("sinkhole").types()); + } + + @Test + public void compactLocatorIndexFindsThePublishedEndOfALongSegment() { + HydraulicSegment segment = new HydraulicSegment( + 200L, + 201L, + HydrologyFeatureType.DEEP_CHANNEL, + 20, + 20, + 4, + 2, + false, + false, + List.of( + new HydrologyPoint(0, 20, 0), + new HydrologyPoint(500, 20, 0), + new HydrologyPoint(1000, 20, 0) + ) + ); + RiverCourse course = new RiverCourse( + 201L, + RiverCourseType.DEEP_FLUID, + OptionalLong.empty(), + OptionalLong.empty(), + "acid", + 1, + List.of(), + List.of(segment) + ); + HydrologyFeatureRef endpoint = new HydrologyFeatureRef( + 202L, + HydrologyFeatureType.DEEP_CHANNEL, + course.id(), + segment.id(), + 1000, + 20, + 0, + 1, + 0, + false + ); + HydrologyColumnLayer layer = new HydrologyColumnLayer( + endpoint, + 18, + 20, + 24, + true, + false, + false, + true, + false, + false, + true, + true, + false, + "acid", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + HydrologyColumnSample column = new HydrologyColumnSample( + 1000, 0, 80, 63, false, "parent", List.of(layer) + ); + HashMap actions = new HashMap<>(); + HashMap preconditions = new HashMap<>(); + for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) { + CavePosition position = new CavePosition(column.x(), y, column.z()); + actions.put( + position, + y > layer.fluidHeadY() ? HydrologyCaveAction.DRY_AIR : HydrologyCaveAction.WET_SOURCE + ); + preconditions.put(position, new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + } + HydrologyCavePlan plan = new HydrologyCavePlan( + new HydrologyCaveSource( + course.id(), + new CavePosition(column.x(), layer.ceilingY(), column.z()), + new CavePosition(column.x(), layer.fluidHeadY(), column.z()), + layer.fluidHeadY(), + HydrologyCaveMode.GENERATED_GROTTO + ), + HydrologyCaveRejection.NONE, + actions, + preconditions, + OptionalLong.empty() + ); + HydrologyTile tile = new HydrologyTile( + new HydrologyTileKey(0, 0), + 1L, + 2L, + 2048, + List.of(), + List.of(), + List.of(), + List.of(course), + List.of(plan), + List.of(), + new RiverFootprint(Map.of(RiverFootprint.pack(1000, 0), column)) + ); + + assertEquals(endpoint, tile.nearestFeature( + Set.of(HydrologyFeatureType.DEEP_CHANNEL), "acid", 1000, 0, 10).orElseThrow()); + assertTrue(tile.nearestFeature( + Set.of(HydrologyFeatureType.DEEP_CHANNEL), "deep_lava", 1000, 0, 10).isEmpty()); + } + + private HydrologyFeatureRef feature(HydrologyFeatureType type, long id, int y) { + return new HydrologyFeatureRef(id, type, 1L, 2L, 4, y, 9, 1, 0, false); + } + + private HydrologyColumnLayer layer( + HydrologyFeatureRef feature, + int bed, + int head, + int ceiling, + boolean channel, + boolean owned, + boolean apron + ) { + return new HydrologyColumnLayer( + feature, + bed, + head, + ceiling, + channel, + false, + false, + channel, + false, + false, + owned, + owned, + apron, + "alpha", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + } + + private HydrologyColumnLayer surfaceLayer( + HydrologyFeatureRef feature, + int bed, + int head, + boolean channel, + boolean shore, + boolean grading, + boolean terrainOwned, + boolean fluidOwned + ) { + return new HydrologyColumnLayer( + feature, + bed, + head, + head, + channel, + shore, + grading, + fluidOwned, + false, + false, + terrainOwned, + fluidOwned, + false, + "alpha", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefinerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefinerTest.java new file mode 100644 index 000000000..a779ec36a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyOceanBoundaryRefinerTest.java @@ -0,0 +1,127 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +public class HydrologyOceanBoundaryRefinerTest { + @Test + public void firstOceanUsesOneFullSampleAtTheImmediatelyPreviousLandPoint() { + List probes = new ArrayList<>(); + List detailSamples = new ArrayList<>(); + List crossing = crossing(0, 4, -7); + HydrologyTerrainSample land = HydrologyTerrainSample.openLand(91, 5D, "parent"); + HydrologyRoutingTerrainSampler routingSampler = routingSampler( + probes, + 3, + HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN + ); + HydrologyTerrainSampler terrainSampler = (int x, int z) -> { + detailSamples.add(new HydrologyPoint(x, 0, z)); + return land; + }; + + HydrologyOceanBoundaryRefiner.Result result = HydrologyOceanBoundaryRefiner.refine( + crossing, + terrainSampler, + routingSampler + ); + + assertEquals(List.of(point(1, -7), point(2, -7), point(3, -7)), probes); + assertEquals(List.of(point(2, -7)), detailSamples); + assertEquals(new HydrologyPoint(2, 90, -7), result.landwardPoint()); + assertEquals(new HydrologyPoint(3, 90, -7), result.oceanPoint()); + assertEquals(land, result.landwardTerrain()); + } + + @Test + public void immediateOceanResamplesTheStartingLandPoint() { + List detailSamples = new ArrayList<>(); + HydrologyTerrainSample land = HydrologyTerrainSample.openLand(88, 1D, "parent"); + HydrologyTerrainSampler terrainSampler = (int x, int z) -> { + detailSamples.add(new HydrologyPoint(x, 0, z)); + return land; + }; + + HydrologyOceanBoundaryRefiner.Result result = HydrologyOceanBoundaryRefiner.refine( + crossing(-2, 1, 9), + terrainSampler, + routingSampler(new ArrayList<>(), -1, HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN) + ); + + assertEquals(List.of(point(-2, 9)), detailSamples); + assertEquals(new HydrologyPoint(-2, 90, 9), result.landwardPoint()); + assertEquals(new HydrologyPoint(-1, 90, 9), result.oceanPoint()); + } + + @Test + public void unavailableClassificationAbortsWithoutFullTerrainSampling() { + List detailSamples = new ArrayList<>(); + HydrologyTerrainSampler terrainSampler = (int x, int z) -> { + detailSamples.add(new HydrologyPoint(x, 0, z)); + return HydrologyTerrainSample.openLand(90, 0D, "parent"); + }; + + HydrologyOceanBoundaryRefiner.Result result = HydrologyOceanBoundaryRefiner.refine( + crossing(0, 3, 0), + terrainSampler, + routingSampler(new ArrayList<>(), 2, HydrologyRoutingTerrainSampler.NaturalClassification.UNAVAILABLE) + ); + + assertNull(result); + assertEquals(List.of(), detailSamples); + } + + @Test + public void noOceanDoesNotRequestFullTerrain() { + List detailSamples = new ArrayList<>(); + HydrologyTerrainSampler terrainSampler = (int x, int z) -> { + detailSamples.add(new HydrologyPoint(x, 0, z)); + return HydrologyTerrainSample.openLand(90, 0D, "parent"); + }; + + HydrologyOceanBoundaryRefiner.Result result = HydrologyOceanBoundaryRefiner.refine( + crossing(-3, 0, 2), + terrainSampler, + routingSampler(new ArrayList<>(), Integer.MAX_VALUE, HydrologyRoutingTerrainSampler.NaturalClassification.LAND) + ); + + assertNull(result); + assertEquals(List.of(), detailSamples); + } + + private HydrologyRoutingTerrainSampler routingSampler( + List probes, + int classifiedX, + HydrologyRoutingTerrainSampler.NaturalClassification classification + ) { + return new HydrologyRoutingTerrainSampler() { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + probes.add(new HydrologyPoint(blockX, 0, blockZ)); + return blockX == classifiedX ? classification : NaturalClassification.LAND; + } + }; + } + + private List crossing(int minimumX, int maximumX, int z) { + ArrayList crossing = new ArrayList<>(); + for (int x = minimumX; x <= maximumX; x++) { + crossing.add(new HydrologyPoint(x, 90, z)); + } + return List.copyOf(crossing); + } + + private HydrologyPoint point(int x, int z) { + return new HydrologyPoint(x, 0, z); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java new file mode 100644 index 000000000..3e82e9714 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java @@ -0,0 +1,544 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveConflictPolicy; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest { + private static final long SEED = 77L; + private static final List ADJACENT_PAIRS = List.of( + new TilePair(new HydrologyTileKey(0, -2), new HydrologyTileKey(0, -1)), + new TilePair(new HydrologyTileKey(0, 0), new HydrologyTileKey(0, 1)) + ); + + @Test + public void seed77AdjacentTilesRejectIncompatibleCaveOverlapsRegardlessOfRequestOrder() { + List forwardOrder = List.of( + ADJACENT_PAIRS.get(0).first(), + ADJACENT_PAIRS.get(0).second(), + ADJACENT_PAIRS.get(1).first(), + ADJACENT_PAIRS.get(1).second() + ); + ArrayList reverseOrder = new ArrayList<>(forwardOrder); + Collections.reverse(reverseOrder); + + Map forward = plans(planner(), forwardOrder); + Map reverse = plans(planner(), reverseOrder); + + assertEquals(forward, reverse); + for (TilePair pair : ADJACENT_PAIRS) { + assertNoIncompatibleCaveOverlaps( + pair, + forward.get(pair.first()), + forward.get(pair.second()) + ); + } + } + + @Test + public void clusteredRequiredSourcesResolveToStableNonOverlappingNetworksRegardlessOfRequestOrder() { + TilePair pair = ADJACENT_PAIRS.getFirst(); + List forwardOrder = List.of(pair.first(), pair.second()); + ArrayList reverseOrder = new ArrayList<>(forwardOrder); + Collections.reverse(reverseOrder); + + Map forward = plans(requiredFallbackPlanner(), forwardOrder); + Map reverse = plans(requiredFallbackPlanner(), reverseOrder); + + assertEquals(forward, reverse); + List firstSources = sourcePoints(forward.get(pair.first()), RiverCourseType.SURFACE); + List secondSources = sourcePoints(forward.get(pair.second()), RiverCourseType.SURFACE); + assertEquals(forward.get(pair.first()).diagnosticCandidates().toString(), 1, firstSources.size()); + assertEquals(forward.get(pair.second()).diagnosticCandidates().toString(), 1, secondSources.size()); + assertPointNear(new HydrologyPoint(32, 121, -272), firstSources.getFirst(), 8); + assertPointNear(new HydrologyPoint(16, 126, -192), secondSources.getFirst(), 8); + assertNoIncompatibleCaveOverlaps(pair, forward.get(pair.first()), forward.get(pair.second())); + } + + @Test + public void colorRankBoundsTheCompleteOwnerDependencyGeometry() { + HydrologyPlanner planner = planner(); + + assertEquals(1, planner.maximumCrossTileDependencyOwners(new HydrologyTileKey(0, 0))); + assertEquals(49, planner.maximumCrossTileDependencyOwners(new HydrologyTileKey(-1, -1))); + } + + @Test + public void acceptedCavePlansMatchTheirMaterializedSurfaceAfterCrossTileAdmission() { + HydrologyTerrainSampler terrain = terrain(); + HydrologyPlanner planner = new HydrologyPlanner( + SEED, + settings(), + terrain, + -4096, + (HydrologyCaveVoxelViewFactory.PlannedSurface surface) -> + new PlannedSurfaceCaveView(terrain, surface) + ); + + for (TilePair pair : ADJACENT_PAIRS) { + assertMaterializedPreconditions(planner.plan(pair.first()), terrain); + assertMaterializedPreconditions(planner.plan(pair.second()), terrain); + } + } + + private HydrologyPlanner planner() { + return new HydrologyPlanner(SEED, settings(), terrain()); + } + + private HydrologyPlanner requiredFallbackPlanner() { + HydrologyTerrainSampler terrain = requiredFallbackTerrain(); + return new HydrologyPlanner( + SEED, + requiredFallbackSettings(), + terrain, + -4096, + (HydrologyCaveVoxelViewFactory.PlannedSurface surface) -> + new PlannedSurfaceCaveView(terrain, surface) + ); + } + + private void assertMaterializedPreconditions(HydrologyTile tile, HydrologyTerrainSampler terrain) { + HydrologyCaveVoxelViewFactory.PlannedSurface surface = (int x, int z, int naturalHeight) -> + tile.footprint().sample(x, z) + .map(HydrologyColumnSample::terrainHeight) + .orElse(naturalHeight); + CaveVoxelView view = new PlannedSurfaceCaveView(terrain, surface); + for (HydrologyCavePlan plan : tile.cavePlans()) { + for (Map.Entry entry + : plan.baselinePreconditions().entrySet()) { + CavePosition position = entry.getKey(); + CaveVoxelPrecondition expected = entry.getValue(); + assertEquals(expected.voxel(), view.voxelAt(position)); + assertEquals(expected.openToSurface(), view.isOpenToSurface(position)); + } + } + } + + private Map plans( + HydrologyPlanner planner, + List requestOrder + ) { + LinkedHashMap plans = new LinkedHashMap<>(); + for (HydrologyTileKey key : requestOrder) { + plans.put(key, planner.plan(key)); + } + return Map.copyOf(plans); + } + + private void assertNoIncompatibleCaveOverlaps( + TilePair pair, + HydrologyTile first, + HydrologyTile second + ) { + Map profileKeys = profileKeys(first, second); + int comparedPlanPairs = 0; + for (HydrologyCavePlan firstPlan : first.cavePlans()) { + if (!firstPlan.accepted()) { + continue; + } + for (HydrologyCavePlan secondPlan : second.cavePlans()) { + if (!secondPlan.accepted() + || firstPlan.source().sourceId() == secondPlan.source().sourceId()) { + continue; + } + comparedPlanPairs++; + long firstCourseId = firstPlan.source().sourceId(); + long secondCourseId = secondPlan.source().sourceId(); + assertFalse( + "Incompatible seed 77 cave overlap between " + pair.first() + " course " + + firstCourseId + " and " + pair.second() + " course " + secondCourseId, + HydrologyCaveConflictPolicy.hasIncompatibleOverlap( + requiredProfileKey(profileKeys, firstCourseId), + firstPlan.actions(), + requiredProfileKey(profileKeys, secondCourseId), + secondPlan.actions() + ) + ); + } + } + assertTrue("Seed 77 tile pair must retain accepted cave plans " + pair, comparedPlanPairs > 0); + } + + private Map profileKeys(HydrologyTile first, HydrologyTile second) { + LinkedHashMap profileKeys = new LinkedHashMap<>(); + addProfileKeys(profileKeys, first); + addProfileKeys(profileKeys, second); + return Map.copyOf(profileKeys); + } + + private void addProfileKeys(Map profileKeys, HydrologyTile tile) { + for (RiverCourse course : tile.courses()) { + String existing = profileKeys.putIfAbsent(course.id(), course.profileKey()); + if (existing != null) { + assertEquals(existing, course.profileKey()); + } + } + } + + private String requiredProfileKey(Map profileKeys, long courseId) { + String profileKey = profileKeys.get(courseId); + assertTrue("Missing profile for cave course " + courseId, profileKey != null); + return profileKey; + } + + private List sourcePoints(HydrologyTile tile, RiverCourseType type) { + ArrayList points = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() != type) { + continue; + } + DrainageNode source = tile.node(course.sourceNodeId().orElseThrow()).orElseThrow(); + points.add(source.naturalPoint()); + } + return List.copyOf(points); + } + + private void assertPointNear(HydrologyPoint expected, HydrologyPoint actual, int radius) { + assertTrue(expected + " vs " + actual, StrictMath.abs(expected.y() - actual.y()) <= 2); + assertTrue(expected + " vs " + actual, actual.distanceSquared2D(expected) <= (long) radius * radius); + } + + private HydrologyPlannerSettings settings() { + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( + true, + 4D, + 80, + 1, + 6, + 32 + ); + HydrologyPlannerSettings.Source undergroundSources = new HydrologyPlannerSettings.Source( + true, + 4D, + Integer.MIN_VALUE, + 1, + 4, + 48 + ); + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "deep_lava", + true, + 3D, + 48, + 18, + 42, + 3, + 5, + 2, + 3, + 8, + 24, + 4, + 2, + 3, + 4096, + 4, + true, + true + ); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing(256, 16, 4, 1_089, 256, + new HydrologyPlannerSettings.Branching(16, 8), 0.5D, 12D, 0.5D, 0.2D), + new HydrologyPlannerSettings.Surface( + true, + surfaceSources, + 4, + 20, + 2, + 5, + 4, + 8, + 12, + 1.5D, + 4, + 10, + true, + 96, + 10 + ), + new HydrologyPlannerSettings.Hydraulics(12, 40, 1, 3, 8, 4), + new HydrologyPlannerSettings.Underground( + true, + undergroundSources, + 68, + 82, + 4, + 14, + 2, + 5, + 6, + 12, + true + ), + new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(true, 4, 3, 3, 4096), + new HydrologyPlannerSettings.Grotto(true, 4, 3, 3, 4096), + true, + 12, + 48, + 2, + 8 + ), + stableGeometry(), + List.of(deepFluid) + ); + } + + private HydrologyPlannerSettings.Geometry stableGeometry() { + HydrologyPlannerSettings.ChannelShape stableChannel = + new HydrologyPlannerSettings.ChannelShape(2D, 0D, 0D, 11); + return new HydrologyPlannerSettings.Geometry( + new HydrologyPlannerSettings.Meanders(224, 72, 0D, 0D, 0D, 0, 75D), + stableChannel, + stableChannel, + stableChannel, + HydrologyPlannerSettings.Geometry.defaults().drops() + ); + } + + private HydrologyPlannerSettings requiredFallbackSettings() { + HydrologyPlannerSettings base = settings(); + HydrologyPlannerSettings.Surface baseSurface = base.surface(); + HydrologyPlannerSettings.Underground baseUnderground = base.underground(); + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( + true, + 0D, + 80, + 1, + 1, + 32 + ); + HydrologyPlannerSettings.Source disabledSources = new HydrologyPlannerSettings.Source( + false, + 0D, + Integer.MIN_VALUE, + 0, + 0, + 0 + ); + return new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + new HydrologyPlannerSettings.Surface( + true, + surfaceSources, + baseSurface.minimumWidth(), + baseSurface.maximumWidth(), + baseSurface.minimumDepth(), + baseSurface.maximumDepth(), + baseSurface.minimumSurfaceInset(), + baseSurface.maximumSurfaceInset(), + 64, + baseSurface.shoreWidth(), + baseSurface.minimumTerrainBlendWidth(), + baseSurface.maximumTerrainBlendWidth(), + baseSurface.ridgeTunnelsEnabled(), + baseSurface.maximumRidgeTunnelLength(), + baseSurface.ridgeTunnelHeadroom() + ), + base.hydraulics(), + new HydrologyPlannerSettings.Underground( + false, + disabledSources, + baseUnderground.minimumFluidY(), + baseUnderground.maximumFluidY(), + baseUnderground.minimumWidth(), + baseUnderground.maximumWidth(), + baseUnderground.minimumDepth(), + baseUnderground.maximumDepth(), + baseUnderground.minimumHeadroom(), + baseUnderground.maximumHeadroom(), + baseUnderground.connectToExistingCaves() + ), + new HydrologyPlannerSettings.Outlets( + base.outlets().oceanEnabled(), + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + base.outlets().inlandGrotto(), + base.outlets().surfaceSinkholesEnabled(), + base.outlets().coastalCliffMinimumHeight(), + base.outlets().mouthLevelingDistance(), + base.outlets().maximumOceanApron(), + base.outlets().maximumPerTile() + ), + base.geometry(), + base.deepFluids() + ); + } + + private HydrologyTerrainSampler terrain() { + return (int x, int z) -> { + if (x >= 224) { + return oceanTerrain(); + } + double wave = StrictMath.sin((z + SEED % 37L) / 19D) * 3D; + double ridge = x >= 96 && x <= 128 ? 34D : 0D; + int height = 126 - Math.floorDiv(x, 13) + (int) StrictMath.round(wave + ridge); + double slope = ridge > 0D ? 18D : 1D + StrictMath.abs(wave) * 0.25D; + boolean surfaceSource = x >= 0 && x <= 48; + boolean undergroundSource = x >= 0 && x <= 64; + return new HydrologyTerrainSample( + height, + slope, + false, + true, + 70, + 74 + Math.floorMod(x + z, 7), + true, + true, + surfaceSource, + surfaceSource, + undergroundSource, + undergroundSource, + 0D, + surfaceSource ? 1D : 0D, + undergroundSource ? 1D : 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water") + ); + }; + } + + private HydrologyTerrainSampler requiredFallbackTerrain() { + HydrologyTerrainSampler base = terrain(); + return (int x, int z) -> { + HydrologyTerrainSample sample = base.sample(x, z); + boolean winner = x == 32 && z == -272; + boolean initialLoser = x == 16 && z == -224; + boolean fallback = x == 16 && z == -192; + boolean required = winner || initialLoser || fallback; + double weight = initialLoser ? 64D : required ? 1D : 0D; + return new HydrologyTerrainSample( + sample.naturalHeight(), + sample.slope(), + sample.ocean(), + sample.caveAvailable(), + sample.caveFloorY(), + sample.caveFluidY(), + sample.transitAllowed(), + sample.outletAllowed(), + required, + required, + false, + false, + sample.routingCost(), + weight, + 0D, + sample.widthMultiplier(), + sample.depthMultiplier(), + sample.incisionMultiplier(), + sample.routingMultiplier(), + sample.parentBiomeKey(), + sample.surfaceBiomeKey(), + sample.mouthBiomeKey(), + sample.shoreBiomeKey(), + sample.dryBiomeKey(), + sample.floodedCaveBiomeKey(), + sample.preferredProfileKeys() + ); + }; + } + + private HydrologyTerrainSample oceanTerrain() { + return new HydrologyTerrainSample( + 54, + 0D, + true, + false, + 30, + 32, + false, + false, + false, + false, + false, + false, + 0D, + 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + "ocean_parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water") + ); + } + + private record TilePair(HydrologyTileKey first, HydrologyTileKey second) { + } + + private static final class PlannedSurfaceCaveView implements CaveVoxelView { + private final HydrologyTerrainSampler terrain; + private final HydrologyCaveVoxelViewFactory.PlannedSurface surface; + + private PlannedSurfaceCaveView( + HydrologyTerrainSampler terrain, + HydrologyCaveVoxelViewFactory.PlannedSurface surface + ) { + this.terrain = terrain; + this.surface = surface; + } + + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > -4096 && position.y() < 4095; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return position.y() <= surfaceHeight(position.x(), position.z()) + ? CaveVoxel.SOLID + : CaveVoxel.CAVE_AIR; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return position.y() > surfaceHeight(position.x(), position.z()); + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return isOpenToSurface(position); + } + + @Override + public boolean hasAboveTerrainSurface(int x, int z, int minimumY, int maximumY) { + return maximumY > surfaceHeight(x, z); + } + + private int surfaceHeight(int x, int z) { + HydrologyTerrainSample sample = terrain.sample(x, z); + return surface.resolve(x, z, sample.naturalHeight()); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java new file mode 100644 index 000000000..fe50c6e1d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java @@ -0,0 +1,1369 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +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 HydrologyPlannerGlobalInvariantTest { + private static final int TILE_SIZE = 128; + private static final CaveVoxelView SOLID_CAVE_VIEW = new SolidCaveVoxelView(); + + @Test + public void deepOnlyPlanningSkipsTheRoutedDrainageLattice() { + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "deep_water", + true, + 1D, + TILE_SIZE, + -32, + -32, + 1, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 1, + 64, + 1, + true, + false + ); + AtomicInteger sampleCount = new AtomicInteger(); + HydrologyTerrainSampler sampler = (int x, int z) -> { + sampleCount.incrementAndGet(); + return terrain(120, false, false, false, false); + }; + HydrologyPlanner planner = new HydrologyPlanner( + 7341L, + settings(disabledSource(), disabledSource(), false, false, List.of(deepFluid)), + sampler, + SOLID_CAVE_VIEW + ); + + HydrologyTile tile = planner.plan(new HydrologyTileKey(0, 0)); + + assertTrue(tile.nodes().isEmpty()); + assertTrue(tile.edges().isEmpty()); + assertTrue(tile.outlets().isEmpty()); + assertEquals(1, courses(tile, RiverCourseType.DEEP_FLUID).size()); + assertTrue(sampleCount.get() < 50); + } + + @Test + public void deepSitesUseOneWorldLatticeAcrossAxesCornersNegativesAndRequestOrder() { + int spacing = 192; + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "deep_lava", + true, + 64D, + spacing, + -96, + -80, + 1, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 1, + 64, + 64, + true, + false + ); + HydrologyPlannerSettings settings = settings( + disabledSource(), + disabledSource(), + false, + false, + List.of(deepFluid) + ); + HydrologyPlanner planner = new HydrologyPlanner( + 99177L, + settings, + (int x, int z) -> terrain(120, false, false, false, false), + SOLID_CAVE_VIEW + ); + List keys = tileKeys(3); + + Map forward = plans(planner, keys); + ArrayList reversedKeys = new ArrayList<>(keys); + Collections.reverse(reversedKeys); + Map reverse = plans(planner, reversedKeys); + List sites = deepSites(forward); + + assertEquals(forward, reverse); + assertTrue(sites.size() > 4); + assertQuadrants(sites); + assertMinimumSpacing(sites, spacing); + assertEquals(sites.size(), new HashSet<>(sites).size()); + for (Map.Entry entry : forward.entrySet()) { + for (RiverCourse course : courses(entry.getValue(), RiverCourseType.DEEP_FLUID)) { + HydrologyPoint site = course.segments().getFirst().start(); + assertTrue(entry.getKey().contains(site.x(), site.z(), TILE_SIZE)); + } + } + } + + @Test + public void surfaceSourceSpacingIsGlobalAcrossAxesCornersNegativesAndRequestOrder() { + assertGlobalSourceSpacing(true); + } + + @Test + public void undergroundSourceSpacingIsGlobalAcrossAxesCornersNegativesAndRequestOrder() { + assertGlobalSourceSpacing(false); + } + + @Test + public void acceptedPlansRemainDeterministicAcrossSeeds() { + HydrologyPlannerSettings.Source surface = new HydrologyPlannerSettings.Source( + true, + 1D, + 80, + 0, + 1, + 32 + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int height = 180 - Math.floorDiv(Math.abs(x - 64), 8) - Math.floorDiv(Math.abs(z - 64), 8); + return surfaceTerrain(height, true, false, 1D); + }; + long[] seeds = {11L, 29L, 47L, 83L, 101L}; + for (long seed : seeds) { + HydrologyTile first = new HydrologyPlanner( + seed, + settings(surface, disabledSource(), true, false, List.of()), + sampler, + SOLID_CAVE_VIEW + ).plan(new HydrologyTileKey(0, 0)); + HydrologyTile second = new HydrologyPlanner( + seed, + settings(surface, disabledSource(), true, false, List.of()), + sampler, + SOLID_CAVE_VIEW + ).plan(new HydrologyTileKey(0, 0)); + + assertEquals("seed " + seed, acceptedPlanFingerprint(first), acceptedPlanFingerprint(second)); + } + } + + @Test + public void lazyAdmissionMatchesEagerSelectionAcrossSeededCandidateSets() { + long[] seeds = {5L, 17L, 31L, 73L, 127L}; + for (long seed : seeds) { + Random random = new Random(seed); + for (int scenario = 0; scenario < 32; scenario++) { + int candidateCount = 8 + random.nextInt(57); + int target = random.nextInt(candidateCount + 1); + int guaranteed = random.nextInt(target + 1); + boolean[] globallyAdmitted = new boolean[candidateCount]; + for (int candidateIndex = 0; candidateIndex < candidateCount; candidateIndex++) { + globallyAdmitted[candidateIndex] = random.nextBoolean(); + } + + HydrologyPlanner.SourceAdmissionSelection lazy = HydrologyPlanner.selectSourceAdmissions( + candidateCount, + target, + guaranteed, + (int candidateIndex) -> globallyAdmitted[candidateIndex] + ); + + assertEquals( + "seed " + seed + ", scenario " + scenario, + eagerSourceSelection(globallyAdmitted, target, guaranteed), + lazy.selectedCandidateIndices() + ); + } + } + } + + @Test + public void lazyAdmissionStopsGlobalEvaluationWhenQuotaIsFilled() { + AtomicInteger evaluations = new AtomicInteger(); + + HydrologyPlanner.SourceAdmissionSelection selection = HydrologyPlanner.selectSourceAdmissions( + 256, + 1, + 0, + (int candidateIndex) -> { + evaluations.incrementAndGet(); + return true; + } + ); + + assertEquals(List.of(0), selection.selectedCandidateIndices()); + assertEquals(1, evaluations.get()); + assertTrue(selection.selected(0)); + assertFalse(selection.selected(1)); + assertFalse(selection.rejectedBySpacing(1)); + } + + @Test + public void filledQuotaDoesNotBuildNeighborSourceRoutingContexts() { + HydrologyPlannerSettings.Source surface = new HydrologyPlannerSettings.Source( + true, + 1D, + 80, + 0, + 1, + 32 + ); + AtomicInteger samplesOutsideOwnerGrid = new AtomicInteger(); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x < -64 || x > 192 || z < -64 || z > 192) { + samplesOutsideOwnerGrid.incrementAndGet(); + } + if (x >= 112) { + return oceanTerrain(); + } + int height = 180 - Math.floorDiv(Math.abs(x - 64), 8) - Math.floorDiv(Math.abs(z - 64), 8); + double weight = x == 64 && z == 64 ? 64D : 1D; + return surfaceTerrain(height, true, false, weight); + }; + HydrologyTile tile = new HydrologyPlanner( + 4051L, + settings(surface, disabledSource(), true, false, List.of()), + sampler, + SOLID_CAVE_VIEW + ).plan(new HydrologyTileKey(0, 0)); + + List sources = sourcePoints(tile, RiverCourseType.SURFACE); + assertEquals(1, sources.size()); + assertPointNear(new HydrologyPoint(64, 180, 64), sources.getFirst(), 8); + assertEquals(0, samplesOutsideOwnerGrid.get()); + } + + @Test + public void blockedHigherPriorityNeighborCannotSuppressAViableNaturalSource() { + HydrologyPlannerSettings.Source surface = new HydrologyPlannerSettings.Source( + true, + 1D, + 80, + 0, + 1, + 32 + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + boolean source = z == 0 && (x == -16 || x == 0); + int height = x == -16 ? 160 : 120 - Math.floorDiv(Math.max(0, x), 8); + return surfaceTerrain(height, source, false, x == -16 ? 2D : 1D); + }; + HydrologyPlanner planner = new HydrologyPlanner( + 5812L, + settings(surface, disabledSource(), true, false, List.of()), + sampler, + SOLID_CAVE_VIEW + ); + + HydrologyTile tile = planner.plan(new HydrologyTileKey(0, 0)); + List sources = sourcePoints(tile, RiverCourseType.SURFACE); + + assertEquals(1, sources.size()); + assertPointNear(new HydrologyPoint(0, 120, 0), sources.getFirst(), 8); + } + + @Test + public void requiredQuotaOutranksAHigherWeightViableNaturalNeighbor() { + HydrologyPlannerSettings.Source surface = new HydrologyPlannerSettings.Source( + true, + 1D, + 80, + 1, + 1, + 32 + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + if (z == 0 && x == 0) { + return surfaceTerrain(120, true, true, 1D); + } + if (z == 0 && x == 16) { + return surfaceTerrain(120, true, false, 64D); + } + return surfaceTerrain(120 - Math.floorDiv(Math.max(0, x), 8), false, false, 0D); + }; + HydrologyTile tile = new HydrologyPlanner( + 5813L, + settings(surface, disabledSource(), true, false, List.of()), + sampler, + SOLID_CAVE_VIEW + ).plan(new HydrologyTileKey(0, 0)); + + List sources = sourcePoints(tile, RiverCourseType.SURFACE); + assertEquals(1, sources.size()); + assertPointNear(new HydrologyPoint(0, 120, 0), sources.getFirst(), 8); + } + + @Test + public void requiredMinimumPerTileDoesNotCreateAClusteredShortBranch() { + HydrologyPlannerSettings.Source surface = new HydrologyPlannerSettings.Source( + true, + 0D, + 80, + 2, + 2, + 32 + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + boolean source = z == 0 && (x == 0 || x == 16); + return surfaceTerrain(120 - Math.floorDiv(Math.max(0, x), 8), source, x == 0 && z == 0, 1D); + }; + HydrologyTile tile = new HydrologyPlanner( + 5814L, + settings(surface, disabledSource(), true, false, List.of()), + sampler, + SOLID_CAVE_VIEW + ).plan(new HydrologyTileKey(0, 0)); + + List sources = sourcePoints(tile, RiverCourseType.SURFACE); + assertEquals(1, sources.size()); + assertPointNear(new HydrologyPoint(0, 120, 0), sources.getFirst(), 8); + } + + @Test + public void minimumPerTileDoesNotCreateNaturalSourcesWithoutRequiredPolicy() { + HydrologyPlannerSettings.Source surface = new HydrologyPlannerSettings.Source( + true, + 0D, + 80, + 2, + 2, + 32 + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + boolean source = z == 0 && (x == 0 || x == 16); + return surfaceTerrain(120 - Math.floorDiv(Math.max(0, x), 8), source, false, 1D); + }; + HydrologyTile tile = new HydrologyPlanner( + 5814L, + settings(surface, disabledSource(), true, false, List.of()), + sampler, + SOLID_CAVE_VIEW + ).plan(new HydrologyTileKey(0, 0)); + + assertTrue(sourcePoints(tile, RiverCourseType.SURFACE).isEmpty()); + } + + @Test + public void crossTileAdmissionKeepsOneNetworkAcrossClusteredRequiredSources() { + int spacing = 192; + HydrologyPlannerSettings.Source surface = new HydrologyPlannerSettings.Source( + true, + 0D, + 80, + 0, + 1, + spacing + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + int localX = Math.floorMod(x, TILE_SIZE); + int localZ = Math.floorMod(z, TILE_SIZE); + if (localX >= 112) { + return oceanTerrain(); + } + boolean source = localX == 0 && localZ == 0; + return surfaceTerrain(120 - Math.floorDiv(localX, 8), source, true, 1D); + }; + HydrologyPlanner planner = new HydrologyPlanner( + 5815L, + settings(surface, disabledSource(), true, false, List.of()), + sampler, + SOLID_CAVE_VIEW + ); + HydrologyTile negative = planner.plan(new HydrologyTileKey(-1, 0)); + HydrologyTile positive = planner.plan(new HydrologyTileKey(0, 0)); + ArrayList sources = new ArrayList<>(); + sources.addAll(sourcePoints(negative, RiverCourseType.SURFACE)); + sources.addAll(sourcePoints(positive, RiverCourseType.SURFACE)); + + assertEquals(1, sources.size()); + } + + @Test + public void refinedRoutesRejectAOneBlockOceanBarrierBetweenSamples() { + HydrologyPlannerSettings.Source surface = new HydrologyPlannerSettings.Source( + true, + 1D, + 80, + 1, + 1, + 16 + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x == 2 || x >= 112) { + return oceanTerrain(); + } + return terrain(120 - Math.floorDiv(x, 8), false, x == 0 && z == 0, true, false); + }; + HydrologyTile tile = new HydrologyPlanner( + 617L, + settings(surface, disabledSource(), true, false, List.of()), + sampler, + SOLID_CAVE_VIEW + ).plan(new HydrologyTileKey(0, 0)); + + assertTrue(courses(tile, RiverCourseType.SURFACE).isEmpty()); + assertTrue(tile.diagnosticCandidates().stream().anyMatch( + (HydrologyDiagnosticCandidate candidate) -> candidate.rejection() + == HydrologyCandidateRejection.NO_DRAINAGE_PATH + )); + } + + @Test + public void gradualCascadesCompileGradedWettedRunsAndReceivingCells() { + HydrologyPlannerSettings.Source surface = new HydrologyPlannerSettings.Source( + true, + 1D, + 80, + 1, + 1, + 16 + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int terrace = Math.max(0, Math.floorDiv(x, 32)); + int height = 120 - terrace * 2; + return surfaceTerrainWithTransit(height, x == 0 && z == 0, true, 1D, z == 0); + }; + HydrologyTile tile = new HydrologyPlanner( + 1441L, + gradualSettings(surface, disabledSource(), true, false), + sampler, + SOLID_CAVE_VIEW + ).plan(new HydrologyTileKey(0, 0)); + + int cascadeCount = 0; + for (RiverCourse course : courses(tile, RiverCourseType.SURFACE)) { + for (HydraulicSegment segment : course.segments()) { + if (segment.type() != HydrologyFeatureType.CASCADE) { + continue; + } + cascadeCount++; + assertTrue(segment.drop() > 0); + assertFalse(segment.fallingFluid()); + assertTrue(segment.width() >= 1); + assertTrue(hasChannelLayer(tile, segment.start(), segment.id())); + assertTrue( + segment + " column=" + tile.columnAt(segment.end().x(), segment.end().z()), + hasTransitionLayer(tile, segment.end(), segment.id(), false) + ); + assertGradedCenterline(segment, 2); + } + } + assertTrue( + courses(tile, RiverCourseType.SURFACE).stream() + .map((RiverCourse course) -> course.segments().stream() + .map((HydraulicSegment segment) -> segment.type() + ":" + segment.drop()) + .toList()) + .toList() + .toString(), + cascadeCount >= 1 + ); + } + + @Test + public void undergroundGradualDropsCompileGradedChannelsWithBedsAndReceivingCells() { + HydrologyPlannerSettings.Source underground = new HydrologyPlannerSettings.Source( + true, + 1D, + Integer.MIN_VALUE, + 1, + 1, + 16 + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int terrace = Math.max(0, Math.floorDiv(x, 32)); + int fluidY = 84 - terrace * 2; + return undergroundTerrainWithTransit(120, fluidY, x == 0 && z == 0, true, 1D, z == 0); + }; + HydrologyTile tile = new HydrologyPlanner( + 1442L, + gradualSettings(disabledSource(), underground, false, true), + sampler, + SOLID_CAVE_VIEW + ).plan(new HydrologyTileKey(0, 0)); + + assertDistributedTransition(tile, RiverCourseType.UNDERGROUND, HydrologyFeatureType.UNDERGROUND_DROP); + } + + private void assertDistributedTransition( + HydrologyTile tile, + RiverCourseType courseType, + HydrologyFeatureType featureType + ) { + + boolean gradedDrop = false; + for (RiverCourse course : courses(tile, courseType)) { + for (HydraulicSegment segment : course.segments()) { + if (segment.type() != featureType || segment.drop() <= 0) { + continue; + } + gradedDrop = true; + assertFalse(segment.fallingFluid()); + assertTrue(hasChannelLayer(tile, segment.start(), segment.id())); + assertTrue(hasTransitionLayer(tile, segment.end(), segment.id(), false)); + assertGradedCenterline(segment, 2); + } + } + assertTrue(gradedDrop); + } + + private void assertGradedCenterline(HydraulicSegment segment, int maximumStepDrop) { + int previousHead = segment.upstreamHeadY(); + for (HydrologyPoint point : segment.centerline()) { + assertTrue(point.y() <= previousHead); + assertTrue(previousHead - point.y() <= maximumStepDrop); + previousHead = point.y(); + } + assertEquals(segment.downstreamHeadY(), previousHead); + } + + private void assertGlobalSourceSpacing(boolean surfaceMode) { + int spacing = 192; + HydrologyPlannerSettings.Source active = new HydrologyPlannerSettings.Source( + true, + 64D, + 80, + 0, + 64, + spacing + ); + HydrologyPlannerSettings plannerSettings = settings( + surfaceMode ? active : disabledSource(), + surfaceMode ? disabledSource() : active, + surfaceMode, + !surfaceMode, + List.of() + ); + HydrologyTerrainSampler sampler = (int x, int z) -> { + int localX = Math.floorMod(x, TILE_SIZE); + int localZ = Math.floorMod(z, TILE_SIZE); + if (localX >= 112) { + return oceanTerrain(); + } + boolean source = localX == 0 && localZ == 0; + int height = 120 - Math.floorDiv(localX, 8); + return terrain(height, false, surfaceMode && source, false, !surfaceMode && source); + }; + HydrologyPlanner planner = new HydrologyPlanner(47119L, plannerSettings, sampler, SOLID_CAVE_VIEW); + List keys = tileKeys(3); + + Map forward = plans(planner, keys); + ArrayList reversedKeys = new ArrayList<>(keys); + Collections.reverse(reversedKeys); + Map reverse = plans(planner, reversedKeys); + RiverCourseType type = surfaceMode ? RiverCourseType.SURFACE : RiverCourseType.UNDERGROUND; + ArrayList sources = new ArrayList<>(); + for (HydrologyTile tile : forward.values()) { + sources.addAll(sourcePoints(tile, type)); + } + assertEquals(forward, reverse); + if (surfaceMode) { + assertTrue(sources.toString(), sources.size() >= 2); + assertTrue(sources.stream().anyMatch((HydrologyPoint point) -> point.x() < 0)); + assertTrue(sources.stream().anyMatch((HydrologyPoint point) -> point.x() >= 0)); + assertTrue(sources.stream().anyMatch((HydrologyPoint point) -> point.z() < 0)); + ArrayList positiveKeys = new ArrayList<>(); + for (HydrologyTileKey key : keys) { + positiveKeys.add(new HydrologyTileKey(key.tileX(), key.tileZ() + 8)); + } + HydrologyPlanner positivePlanner = new HydrologyPlanner( + 47119L, + plannerSettings, + sampler, + SOLID_CAVE_VIEW + ); + ArrayList positiveSources = new ArrayList<>(); + for (HydrologyTile tile : plans(positivePlanner, positiveKeys).values()) { + positiveSources.addAll(sourcePoints(tile, type)); + } + assertTrue(positiveSources.toString(), positiveSources.stream().anyMatch( + (HydrologyPoint point) -> point.z() >= 0 + )); + assertMinimumSpacing(positiveSources, spacing); + } else { + assertTrue(sources.size() > 4); + assertQuadrants(sources); + } + assertMinimumSpacing(sources, spacing); + assertEquals(sources.size(), new HashSet<>(sources).size()); + } + + private HydrologyPlannerSettings settings( + HydrologyPlannerSettings.Source surfaceSources, + HydrologyPlannerSettings.Source undergroundSources, + boolean surfaceEnabled, + boolean undergroundEnabled, + List deepFluids + ) { + return settingsWithRefinement( + surfaceSources, + undergroundSources, + surfaceEnabled, + undergroundEnabled, + deepFluids, + 4 + ); + } + + private HydrologyPlannerSettings settingsWithRefinement( + HydrologyPlannerSettings.Source surfaceSources, + HydrologyPlannerSettings.Source undergroundSources, + boolean surfaceEnabled, + boolean undergroundEnabled, + List deepFluids, + int refinementSpacing + ) { + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing( + TILE_SIZE, + 16, + refinementSpacing, + 512, + 256, + new HydrologyPlannerSettings.Branching(16, 8), + 0.5D, + 12D, + 0.5D, + 0.1D + ), + new HydrologyPlannerSettings.Surface( + surfaceEnabled, + surfaceSources, + 2, + 4, + 1, + 2, + 4, + 8, + 128, + 1D, + 1, + 2, + false, + 0, + 0 + ), + new HydrologyPlannerSettings.Hydraulics(8, 16, 1, 3, 8, 4), + new HydrologyPlannerSettings.Underground( + undergroundEnabled, + undergroundSources, + 64, + 84, + 2, + 4, + 1, + 2, + 2, + 4, + false + ), + new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), + new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), + false, + 32, + 16, + 1, + 8 + ), + HydrologyPlannerSettings.Geometry.defaults(), + deepFluids + ); + } + + private HydrologyPlannerSettings gradualSettings( + HydrologyPlannerSettings.Source surfaceSources, + HydrologyPlannerSettings.Source undergroundSources, + boolean surfaceEnabled, + boolean undergroundEnabled + ) { + HydrologyPlannerSettings base = settingsWithRefinement( + surfaceSources, + undergroundSources, + surfaceEnabled, + undergroundEnabled, + List.of(), + 8 + ); + HydrologyPlannerSettings.Surface surface = base.surface(); + HydrologyPlannerSettings.Underground underground = base.underground(); + return new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + new HydrologyPlannerSettings.Surface( + surface.enabled(), + surface.sources(), + surface.minimumWidth(), + surface.minimumWidth(), + surface.minimumDepth(), + surface.maximumDepth(), + surface.minimumSurfaceInset(), + surface.maximumSurfaceInset(), + surface.maximumIncision(), + surface.shoreWidth(), + surface.minimumTerrainBlendWidth(), + surface.maximumTerrainBlendWidth(), + surface.ridgeTunnelsEnabled(), + surface.maximumRidgeTunnelLength(), + surface.ridgeTunnelHeadroom() + ), + base.hydraulics(), + new HydrologyPlannerSettings.Underground( + underground.enabled(), + underground.sources(), + underground.minimumFluidY(), + underground.maximumFluidY(), + underground.minimumWidth(), + underground.minimumWidth(), + underground.minimumDepth(), + underground.maximumDepth(), + underground.minimumHeadroom(), + underground.maximumHeadroom(), + underground.connectToExistingCaves() + ), + base.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + base.deepFluids() + ); + } + + private HydrologyPlannerSettings.Source disabledSource() { + return new HydrologyPlannerSettings.Source(false, 0D, Integer.MIN_VALUE, 0, 0, 0); + } + + private Map plans( + HydrologyPlanner planner, + List keys + ) { + HashMap plans = new HashMap<>(); + for (HydrologyTileKey key : keys) { + plans.put(key, planner.plan(key)); + } + return Map.copyOf(plans); + } + + private List tileKeys(int radius) { + ArrayList keys = new ArrayList<>(); + for (int tileZ = -radius; tileZ <= radius; tileZ++) { + for (int tileX = -radius; tileX <= radius; tileX++) { + keys.add(new HydrologyTileKey(tileX, tileZ)); + } + } + return List.copyOf(keys); + } + + private void assertPointNear(HydrologyPoint expected, HydrologyPoint actual, int radius) { + assertTrue(StrictMath.abs(expected.y() - actual.y()) <= 2); + assertTrue(actual.distanceSquared2D(expected) <= (long) radius * radius); + } + + private List deepSites(Map plans) { + ArrayList sites = new ArrayList<>(); + for (HydrologyTile tile : plans.values()) { + for (RiverCourse course : courses(tile, RiverCourseType.DEEP_FLUID)) { + sites.add(course.segments().getFirst().start()); + } + } + return List.copyOf(sites); + } + + private List sourcePoints(HydrologyTile tile, RiverCourseType type) { + ArrayList points = new ArrayList<>(); + for (RiverCourse course : courses(tile, type)) { + long sourceNodeId = course.sourceNodeId().orElseThrow(); + DrainageNode source = tile.node(sourceNodeId).orElseThrow(); + points.add(new HydrologyPoint(source.x(), source.terrain().naturalHeight(), source.z())); + } + return List.copyOf(points); + } + + private List courses(HydrologyTile tile, RiverCourseType type) { + ArrayList selected = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == type) { + selected.add(course); + } + } + return List.copyOf(selected); + } + + private long acceptedPlanFingerprint(HydrologyTile tile) { + AcceptedPlanFingerprint fingerprint = new AcceptedPlanFingerprint(); + fingerprint.add(tile); + return fingerprint.value(); + } + + private List eagerSourceSelection(boolean[] globallyAdmitted, int target, int guaranteed) { + ArrayList selected = new ArrayList<>(Math.min(globallyAdmitted.length, target)); + boolean[] selectedCandidates = new boolean[globallyAdmitted.length]; + for (int candidateIndex = 0; candidateIndex < globallyAdmitted.length; candidateIndex++) { + if (!globallyAdmitted[candidateIndex] || selected.size() >= target) { + continue; + } + selected.add(candidateIndex); + selectedCandidates[candidateIndex] = true; + } + for (int candidateIndex = 0; + candidateIndex < globallyAdmitted.length && selected.size() < guaranteed; + candidateIndex++) { + if (selectedCandidates[candidateIndex]) { + continue; + } + selected.add(candidateIndex); + selectedCandidates[candidateIndex] = true; + } + return List.copyOf(selected); + } + + private void assertMinimumSpacing(List points, int spacing) { + long minimumSquared = (long) spacing * spacing; + for (int firstIndex = 0; firstIndex < points.size(); firstIndex++) { + for (int secondIndex = firstIndex + 1; secondIndex < points.size(); secondIndex++) { + HydrologyPoint first = points.get(firstIndex); + HydrologyPoint second = points.get(secondIndex); + assertTrue( + "first=" + first + " second=" + second + " spacing=" + spacing, + first.distanceSquared2D(second) >= minimumSquared + ); + } + } + } + + private void assertQuadrants(List points) { + assertTrue(points.stream().anyMatch((HydrologyPoint point) -> point.x() < 0)); + assertTrue(points.stream().anyMatch((HydrologyPoint point) -> point.x() >= 0)); + assertTrue(points.stream().anyMatch((HydrologyPoint point) -> point.z() < 0)); + assertTrue(points.stream().anyMatch((HydrologyPoint point) -> point.z() >= 0)); + } + + private boolean hasTransitionLayer( + HydrologyTile tile, + HydrologyPoint point, + long segmentId, + boolean falling + ) { + HydrologyColumnSample column = tile.columnAt(point.x(), point.z()).orElseThrow(); + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().segmentId() == segmentId + && (falling ? layer.fallingFluid() : layer.receivingPool())) { + return true; + } + } + return false; + } + + private boolean hasChannelLayer(HydrologyTile tile, HydrologyPoint point, long segmentId) { + HydrologyColumnSample column = tile.columnAt(point.x(), point.z()).orElseThrow(); + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().segmentId() == segmentId && layer.channel()) { + return true; + } + } + return false; + } + + private HydrologyTerrainSample terrain( + int height, + boolean ocean, + boolean surfaceSource, + boolean required, + boolean undergroundSource + ) { + return new HydrologyTerrainSample( + height, + ocean ? 0D : 1D, + ocean, + !ocean, + 68, + 74, + !ocean, + !ocean, + surfaceSource, + surfaceSource && required, + undergroundSource, + undergroundSource && required, + 0D, + surfaceSource ? 1D : 0D, + undergroundSource ? 1D : 0D, + 1D, + 1D, + 1D, + 1D, + ocean ? "ocean" : "land", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default") + ); + } + + private HydrologyTerrainSample oceanTerrain() { + return terrain(54, true, false, false, false); + } + + private HydrologyTerrainSample surfaceTerrain( + int height, + boolean source, + boolean required, + double weight + ) { + return surfaceTerrainWithTransit(height, source, required, weight, true); + } + + private HydrologyTerrainSample surfaceTerrainWithTransit( + int height, + boolean source, + boolean required, + double weight, + boolean transit + ) { + return new HydrologyTerrainSample( + height, + 1D, + false, + true, + 68, + 74, + transit, + transit, + source, + source && required, + false, + false, + 0D, + source ? weight : 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + "land", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default") + ); + } + + private HydrologyTerrainSample undergroundTerrain( + int height, + int fluidY, + boolean source, + boolean required, + double weight + ) { + return undergroundTerrainWithTransit(height, fluidY, source, required, weight, true); + } + + private HydrologyTerrainSample undergroundTerrainWithTransit( + int height, + int fluidY, + boolean source, + boolean required, + double weight, + boolean transit + ) { + return new HydrologyTerrainSample( + height, + 1D, + false, + true, + fluidY - 4, + fluidY, + transit, + transit, + false, + false, + source, + source && required, + 0D, + 0D, + source ? weight : 0D, + 1D, + 1D, + 1D, + 1D, + "land", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default") + ); + } + + private static final class SolidCaveVoxelView implements CaveVoxelView { + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > -2048 && position.y() < 2048; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return false; + } + } + + private static final class AcceptedPlanFingerprint { + private static final long FNV_OFFSET_BASIS = 0xcbf29ce484222325L; + private static final long FNV_PRIME = 0x100000001b3L; + private static final Comparator LAYER_ORDER = Comparator + .comparingLong((HydrologyColumnLayer layer) -> layer.feature().courseId()) + .thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().segmentId()) + .thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().id()) + .thenComparingInt(HydrologyColumnLayer::bedY) + .thenComparingInt(HydrologyColumnLayer::fluidHeadY) + .thenComparingInt(HydrologyColumnLayer::ceilingY); + private static final Comparator> CAVE_POSITION_ORDER = Comparator + .comparingInt((Map.Entry entry) -> entry.getKey().x()) + .thenComparingInt((Map.Entry entry) -> entry.getKey().y()) + .thenComparingInt((Map.Entry entry) -> entry.getKey().z()); + + private long hash = FNV_OFFSET_BASIS; + + private long value() { + return hash; + } + + private void add(HydrologyTile tile) { + addString("nodes"); + addInt(tile.nodes().size()); + for (DrainageNode node : tile.nodes()) { + add(node); + } + addString("edges"); + addInt(tile.edges().size()); + for (DrainageEdge edge : tile.edges()) { + add(edge); + } + addString("outlets"); + addInt(tile.outlets().size()); + for (RiverOutlet outlet : tile.outlets()) { + add(outlet); + } + addString("courses"); + addInt(tile.courses().size()); + for (RiverCourse course : tile.courses()) { + add(course); + } + addString("cavePlans"); + addInt(tile.cavePlans().size()); + for (HydrologyCavePlan plan : tile.cavePlans()) { + add(plan); + } + addString("footprint"); + ArrayList> columns = new ArrayList<>( + tile.footprint().columns().entrySet() + ); + columns.sort(Comparator.comparingLong((Map.Entry entry) -> entry.getKey())); + addInt(columns.size()); + for (Map.Entry entry : columns) { + addLong(entry.getKey()); + add(entry.getValue()); + } + } + + private void add(DrainageNode node) { + addLong(node.id()); + addInt(node.x()); + addInt(node.z()); + add(node.terrain()); + addDouble(node.potential()); + addLong(node.outletId()); + } + + private void add(HydrologyTerrainSample terrain) { + addInt(terrain.naturalHeight()); + addDouble(terrain.slope()); + addBoolean(terrain.ocean()); + addBoolean(terrain.caveAvailable()); + addInt(terrain.caveFloorY()); + addInt(terrain.caveFluidY()); + addBoolean(terrain.transitAllowed()); + addBoolean(terrain.outletAllowed()); + addBoolean(terrain.surfaceSourceAllowed()); + addBoolean(terrain.surfaceSourceRequired()); + addBoolean(terrain.undergroundSourceAllowed()); + addBoolean(terrain.undergroundSourceRequired()); + addDouble(terrain.routingCost()); + addDouble(terrain.surfaceSourceWeight()); + addDouble(terrain.undergroundSourceWeight()); + addDouble(terrain.widthMultiplier()); + addDouble(terrain.depthMultiplier()); + addDouble(terrain.incisionMultiplier()); + addDouble(terrain.routingMultiplier()); + addString(terrain.parentBiomeKey()); + addString(terrain.surfaceBiomeKey()); + addString(terrain.mouthBiomeKey()); + addString(terrain.shoreBiomeKey()); + addString(terrain.dryBiomeKey()); + addString(terrain.floodedCaveBiomeKey()); + addInt(terrain.preferredProfileKeys().size()); + for (String profile : terrain.preferredProfileKeys()) { + addString(profile); + } + } + + private void add(DrainageEdge edge) { + addLong(edge.id()); + addLong(edge.upstreamNodeId()); + addLong(edge.downstreamNodeId()); + addLong(edge.outletId()); + addDouble(edge.cost()); + addInt(edge.contributingSurfaceSources()); + addInt(edge.contributingUndergroundSources()); + addPoints(edge.centerline()); + } + + private void add(RiverOutlet outlet) { + addLong(outlet.id()); + addEnum(outlet.type()); + addLong(outlet.drainageNodeId()); + add(outlet.landwardPoint()); + add(outlet.connectionPoint()); + addInt(outlet.seaLevel()); + addBoolean(outlet.directOcean()); + } + + private void add(RiverCourse course) { + addLong(course.id()); + addEnum(course.type()); + addBoolean(course.sourceNodeId().isPresent()); + if (course.sourceNodeId().isPresent()) { + addLong(course.sourceNodeId().getAsLong()); + } + addBoolean(course.outletId().isPresent()); + if (course.outletId().isPresent()) { + addLong(course.outletId().getAsLong()); + } + addString(course.profileKey()); + addInt(course.discharge()); + addInt(course.drainageEdges().size()); + for (DrainageEdge edge : course.drainageEdges()) { + add(edge); + } + addInt(course.segments().size()); + for (HydraulicSegment segment : course.segments()) { + add(segment); + } + } + + private void add(HydraulicSegment segment) { + addLong(segment.id()); + addLong(segment.courseId()); + addEnum(segment.type()); + addInt(segment.upstreamHeadY()); + addInt(segment.downstreamHeadY()); + addInt(segment.width()); + addInt(segment.depth()); + addBoolean(segment.fallingFluid()); + addBoolean(segment.receivingPool()); + addPoints(segment.centerline()); + } + + private void add(HydrologyCavePlan plan) { + addLong(plan.source().sourceId()); + add(plan.source().entry()); + add(plan.source().target()); + addInt(plan.source().waterHeadY()); + addEnum(plan.source().mode()); + addEnum(plan.rejection()); + ArrayList> actions = new ArrayList<>( + plan.actions().entrySet() + ); + actions.sort(AcceptedPlanFingerprint::compareCavePositions); + addInt(actions.size()); + for (Map.Entry entry : actions) { + add(entry.getKey()); + addEnum(entry.getValue()); + } + ArrayList> preconditions = new ArrayList<>( + plan.baselinePreconditions().entrySet() + ); + preconditions.sort(AcceptedPlanFingerprint::compareCavePositions); + addInt(preconditions.size()); + for (Map.Entry entry : preconditions) { + add(entry.getKey()); + addEnum(entry.getValue().voxel()); + addBoolean(entry.getValue().openToSurface()); + } + addBoolean(plan.arbitrationWinnerSourceId().isPresent()); + if (plan.arbitrationWinnerSourceId().isPresent()) { + addLong(plan.arbitrationWinnerSourceId().getAsLong()); + } + } + + private void add(HydrologyColumnSample column) { + addInt(column.x()); + addInt(column.z()); + addInt(column.naturalHeight()); + addInt(column.seaLevel()); + addBoolean(column.ocean()); + addString(column.parentBiomeKey()); + ArrayList layers = new ArrayList<>(column.layers()); + layers.sort(LAYER_ORDER); + addInt(layers.size()); + for (HydrologyColumnLayer layer : layers) { + add(layer); + } + } + + private void add(HydrologyColumnLayer layer) { + add(layer.feature()); + addInt(layer.bedY()); + addInt(layer.fluidHeadY()); + addInt(layer.ceilingY()); + addBoolean(layer.channel()); + addBoolean(layer.shore()); + addBoolean(layer.grading()); + addBoolean(layer.connectedFluid()); + addBoolean(layer.fallingFluid()); + addBoolean(layer.receivingPool()); + addBoolean(layer.terrainOwned()); + addBoolean(layer.fluidOwned()); + addBoolean(layer.oceanApron()); + addString(layer.profileKey()); + addString(layer.surfaceBiomeKey()); + addString(layer.mouthBiomeKey()); + addString(layer.shoreBiomeKey()); + addString(layer.dryBiomeKey()); + addString(layer.floodedCaveBiomeKey()); + } + + private void add(HydrologyFeatureRef feature) { + addLong(feature.id()); + addEnum(feature.type()); + addLong(feature.courseId()); + addLong(feature.segmentId()); + addInt(feature.x()); + addInt(feature.y()); + addInt(feature.z()); + addInt(feature.flowDeltaX()); + addInt(feature.flowDeltaZ()); + addBoolean(feature.source()); + } + + private void addPoints(List points) { + addInt(points.size()); + for (HydrologyPoint point : points) { + add(point); + } + } + + private void add(HydrologyPoint point) { + addInt(point.x()); + addInt(point.y()); + addInt(point.z()); + } + + private void add(CavePosition position) { + addInt(position.x()); + addInt(position.y()); + addInt(position.z()); + } + + private void addEnum(Enum value) { + addString(value.name()); + } + + private void addString(String value) { + addInt(value.length()); + for (int index = 0; index < value.length(); index++) { + addInt(value.charAt(index)); + } + } + + private void addDouble(double value) { + addLong(Double.doubleToLongBits(value)); + } + + private void addLong(long value) { + for (int shift = 0; shift < Long.SIZE; shift += Byte.SIZE) { + addByte((int) (value >>> shift)); + } + } + + private void addInt(int value) { + for (int shift = 0; shift < Integer.SIZE; shift += Byte.SIZE) { + addByte(value >>> shift); + } + } + + private void addBoolean(boolean value) { + addByte(value ? 1 : 0); + } + + private void addByte(int value) { + hash ^= value & 0xffL; + hash *= FNV_PRIME; + } + + private static int compareCavePositions( + Map.Entry first, + Map.Entry second + ) { + return CAVE_POSITION_ORDER.compare(first, second); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerRoutingSamplerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerRoutingSamplerTest.java new file mode 100644 index 000000000..de95e06a8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerRoutingSamplerTest.java @@ -0,0 +1,120 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class HydrologyPlannerRoutingSamplerTest { + @Test + public void coarseLatticeUsesOnlyTheRoutingBatch() { + AtomicInteger batchCalls = new AtomicInteger(); + AtomicInteger detailCalls = new AtomicInteger(); + HydrologyTerrainSampler detailSampler = (int x, int z) -> { + detailCalls.incrementAndGet(); + return HydrologyTerrainSample.ocean(62, "ocean"); + }; + HydrologyRoutingTerrainSampler routingSampler = new HydrologyRoutingTerrainSampler() { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + batchCalls.incrementAndGet(); + int width = request.width(); + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[width * width]; + Arrays.fill(samples, HydrologyTerrainSample.ocean(62, "ocean")); + return samples; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + return NaturalClassification.OCEAN; + } + }; + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyPlanner planner = new HydrologyPlanner( + 17L, + settings, + detailSampler, + routingSampler, + HydrologyGeometrySampler.deterministic(17L, detailSampler), + -4096, + footprint -> new HydrologyTerrainCaveVoxelView(detailSampler, settings.seaLevel(), -4096, 4096) + ); + + planner.plan(new HydrologyTileKey(-1, 1)); + + assertEquals(1, batchCalls.get()); + assertEquals(0, detailCalls.get()); + } + + @Test + public void malformedRoutingBatchIsRejected() { + HydrologyTerrainSampler detailSampler = (int x, int z) -> HydrologyTerrainSample.ocean(62, "ocean"); + HydrologyRoutingTerrainSampler routingSampler = new HydrologyRoutingTerrainSampler() { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + int width = request.width(); + return new HydrologyTerrainSample[width * width - 1]; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + return NaturalClassification.OCEAN; + } + }; + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyPlanner planner = new HydrologyPlanner( + 19L, + settings, + detailSampler, + routingSampler, + HydrologyGeometrySampler.deterministic(19L, detailSampler), + -4096, + footprint -> new HydrologyTerrainCaveVoxelView(detailSampler, settings.seaLevel(), -4096, 4096) + ); + + assertThrows(IllegalStateException.class, () -> planner.plan(new HydrologyTileKey(0, 0))); + } + + @Test + public void oceanClassificationBypassesNaturalBasisSampling() { + AtomicInteger basisCalls = new AtomicInteger(); + HydrologyTerrainSampler detailSampler = (int x, int z) -> HydrologyTerrainSample.openLand(96, 0D, "land"); + HydrologyNaturalTerrainSampler routingSampler = new HydrologyNaturalTerrainSampler() { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + int width = request.width(); + HydrologyTerrainSample[] samples = new HydrologyTerrainSample[width * width]; + Arrays.fill(samples, HydrologyTerrainSample.openLand(96, 0D, "land")); + return samples; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + return NaturalClassification.OCEAN; + } + + @Override + public HydrologyTerrainSample sampleBasis(int blockX, int blockZ) { + basisCalls.incrementAndGet(); + throw new IllegalStateException("Ocean basis must not be sampled"); + } + }; + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + HydrologyPlanner planner = new HydrologyPlanner( + 23L, + settings, + detailSampler, + routingSampler, + HydrologyGeometrySampler.deterministic(23L, detailSampler), + -4096, + footprint -> new HydrologyTerrainCaveVoxelView(detailSampler, settings.seaLevel(), -4096, 4096) + ); + + planner.plan(new HydrologyTileKey(0, 0)); + + assertEquals(0, basisCalls.get()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java new file mode 100644 index 000000000..03770ebf8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java @@ -0,0 +1,250 @@ +package art.arcane.iris.engine.hydrology; + +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.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyPlannerSettingsTest { + @Test + public void branchingUsesIndependentSurfaceAndUndergroundCourseFloors() { + HydrologyPlannerSettings.Branching branching = new HydrologyPlannerSettings.Branching(384, 192); + + assertEquals(384, branching.minimumCourseLength(true)); + assertEquals(192, branching.minimumCourseLength(false)); + } + + @Test + public void crossTileAdmissionRejectsPublicationEnvelopesBeyondFourColorsPerAxis() { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + assertEquals(0.5D, base.surface().sources().density(), 0D); + assertEquals(0.25D, base.underground().sources().density(), 0D); + HydrologyPlannerSettings.DeepFluid overlongChannel = new HydrologyPlannerSettings.DeepFluid( + "deep", + true, + 1D, + 64, + -64, + -32, + 1, + 1, + 1, + 1, + 1, + 3072, + 1, + 1, + 1, + 64, + 1, + false, + true + ); + + assertEquals(2, base.crossTileColorPeriod()); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + base.surface(), + base.hydraulics(), + base.underground(), + base.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + List.of(overlongChannel) + )); + } + + @Test + public void canonicalBoundaryValuesAreAccepted() { + HydrologyPlannerSettings.Routing routing = new HydrologyPlannerSettings.Routing( + 512, + 512, + 64, + 4, + 256, + new HydrologyPlannerSettings.Branching(0, 0), + 0D, + 0D, + 0D, + 0D + ); + HydrologyPlannerSettings.Source sources = new HydrologyPlannerSettings.Source(true, 1D, 0, 0, 1, 0); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + true, + sources, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1D, + 0, + 0, + false, + 0, + 0 + ); + HydrologyPlannerSettings.Hydraulics hydraulics = new HydrologyPlannerSettings.Hydraulics( + 8, + 8, + 0, + 0, + 1, + 1 + ); + HydrologyPlannerSettings.Grotto grotto = new HydrologyPlannerSettings.Grotto(true, 1, 1, 1, 1); + HydrologyPlannerSettings.Outlets outlets = new HydrologyPlannerSettings.Outlets( + true, + grotto, + grotto, + true, + 0, + 0, + 64, + 1 + ); + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "deep", + true, + 1D, + 8192, + -64, + -32, + 1, + 1, + 1, + 1, + 1, + 2730, + 1, + 1, + 1, + 64, + 1, + false, + true + ); + + assertEquals(256, routing.maximumRouteLength()); + assertEquals(0, surface.maximumIncision()); + assertEquals(1, hydraulics.waterfallMinimumDrop()); + assertEquals(0, outlets.mouthLevelingDistance()); + assertEquals(1, grotto.maximumVolume()); + assertEquals(2730, deepFluid.maximumChannelLength()); + } + + @Test + public void inconsistentHydraulicThresholdsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Hydraulics( + 8, + 16, + 0, + 2, + 2, + 2 + )); + } + + @Test + public void valuesOutsideCanonicalBoundsAreRejected() { + HydrologyPlannerSettings.Grotto accepted = new HydrologyPlannerSettings.Grotto(true, 1, 1, 1, 1); + assertEquals(1, accepted.maximumVolume()); + assertThrows(IllegalArgumentException.class, + () -> new HydrologyPlannerSettings.Grotto(true, 1, 1, 1, 0)); + HydrologyPlannerSettings.Grotto disabled = new HydrologyPlannerSettings.Grotto(false, 1, 1, 1, 1); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Outlets( + true, + accepted, + disabled, + true, + 0, + 0, + 1, + 1 + )); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Outlets( + true, + accepted, + accepted, + true, + 0, + 0, + 65, + 1 + )); + } + + @Test + public void dropGeometryNarrowsFlowAndExpandsItsReceivingBasin() { + HydrologyPlannerSettings.Drops drops = new HydrologyPlannerSettings.Drops( + 2, + 1.4D, + 2, + 0.45D, + 2, + 1.8D, + 8 + ); + + assertEquals(5, drops.flowWidth(10)); + assertEquals(1, drops.stepLimit(HydrologyFeatureType.CASCADE)); + assertEquals(2, drops.stepLimit(HydrologyFeatureType.UNDERGROUND_DROP)); + assertEquals(2, drops.flowDepth(6)); + assertEquals(9, drops.basinWidth(5)); + assertEquals(5, drops.basinDepth(2, 8)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Drops( + 2, + 1.4D, + 2, + 0.45D, + 4, + 1.8D, + 3 + )); + } + + @Test + public void deepSiteAdmissionUsesItsOwnHeadAndEnvelope() { + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "deep", + true, + 1D, + 1024, + 16, + 96, + 4, + 4, + 3, + 3, + 0, + 0, + 2, + 2, + 4, + 4096, + 1, + true, + false + ); + HydrologyTerrainSample terrainWithoutUndergroundRiverCave = HydrologyTerrainSample.openLand(100, 1D, "parent"); + + assertFalse(HydrologyPlanner.deepSiteFits(terrainWithoutUndergroundRiverCave, deepFluid, 3, 3, 0)); + assertTrue(HydrologyPlanner.deepSiteFits(terrainWithoutUndergroundRiverCave, deepFluid, 3, 3, -64)); + assertTrue(HydrologyPlanner.deepSiteFits(terrainWithoutUndergroundRiverCave, deepFluid, 4, 3, 0)); + assertTrue(HydrologyPlanner.deepSiteFits(terrainWithoutUndergroundRiverCave, deepFluid, 80, 3, 0)); + assertFalse(HydrologyPlanner.deepSiteFits(terrainWithoutUndergroundRiverCave, deepFluid, 96, 3, 0)); + assertFalse(HydrologyPlanner.deepSiteFits( + HydrologyTerrainSample.ocean(50, "ocean"), + deepFluid, + 40, + 3, + 0 + )); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java new file mode 100644 index 000000000..af0a20923 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java @@ -0,0 +1,2623 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Random; +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.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyPlannerTest { + private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + + @Test + public void biomeIncisionMultiplierCannotExceedTheConfiguredSurfaceMaximum() { + assertEquals(6, HydrologyPlanner.permittedSurfaceIncision(6, 2D)); + assertEquals(3, HydrologyPlanner.permittedSurfaceIncision(6, 0.5D)); + } + + @Test + public void outletFirstPlanIsAcyclicAndHydraulicallyNonRising() { + HydrologyPlanner planner = planner(77L, standardSettings(4D, 0D, true, false, List.of()), rollingCoast(112)); + + HydrologyTile tile = planner.plan(TILE); + + assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty()); + assertTrue(tile.outlets().stream().allMatch(RiverOutlet::directOcean)); + assertTrue(tile.acyclic()); + assertFalse(surfaceCourses(tile).isEmpty()); + for (DrainageEdge edge : tile.edges()) { + DrainageNode upstream = tile.node(edge.upstreamNodeId()).orElseThrow(); + DrainageNode downstream = tile.node(edge.downstreamNodeId()).orElseThrow(); + assertTrue(downstream.potential() < upstream.potential()); + } + for (RiverCourse course : tile.courses()) { + assertTrue(course.hydraulicallyNonRising()); + for (HydraulicSegment segment : course.segments()) { + if (segment.drop() > 0) { + assertTrue(segment.type().isDrop()); + assertEquals(segment.type() == HydrologyFeatureType.WATERFALL, segment.fallingFluid()); + assertTrue(segment.receivingPool() || hasDownstreamReceivingDrop(tile, segment)); + } + } + } + } + + @Test + public void acceptedSurfaceCourseUsesOneDeterministicContinuousOrganicCurve() { + HydrologyPlannerSettings settings = organicShapeSettings(); + HydrologyTerrainSampler terrain = organicShapeTerrain(); + HydrologyTile first = new HydrologyPlanner(642L, settings, terrain).plan(TILE); + HydrologyTile replay = new HydrologyPlanner(642L, settings, terrain).plan(TILE); + + assertEquals(first.courses(), replay.courses()); + List courses = surfaceCourses(first); + assertTrue(first.diagnosticCandidates().toString(), courses.size() <= 4); + RiverCourse mainCourse = courses.getFirst(); + for (RiverCourse course : courses) { + if (course.drainageEdges().size() > mainCourse.drainageEdges().size()) { + mainCourse = course; + } + } + ArrayList centerline = new ArrayList<>(); + for (HydraulicSegment segment : mainCourse.segments()) { + for (HydrologyPoint point : segment.centerline()) { + if (centerline.isEmpty() || !centerline.getLast().equals(point)) { + centerline.add(point); + } + } + } + assertTrue(centerline.size() >= 64); + double pathLength = 0D; + for (int index = 1; index < centerline.size(); index++) { + HydrologyPoint previous = centerline.get(index - 1); + HydrologyPoint point = centerline.get(index); + pathLength += StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); + } + HydrologyPoint start = centerline.getFirst(); + HydrologyPoint end = centerline.getLast(); + double directLength = StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); + assertTrue("sinuosity=" + pathLength / directLength, pathLength / directLength <= 1.3D); + assertTrue( + "deviation=" + maximumChordDeviationRatio(centerline), + maximumChordDeviationRatio(centerline) <= 0.19D + ); + double maximumStickLength = maximumQuantizedStickLength(centerline); + assertTrue( + "maximumStickLength=" + maximumStickLength, + maximumStickLength <= settings.routing().sampleSpacing() * 0.75D + ); + } + + @Test + public void fallbackSourceTargetCannotExceedAvailableOutlets() { + assertEquals(1, HydrologyPlanner.effectiveSourceTarget(true, 8, 1)); + assertEquals(0, HydrologyPlanner.effectiveSourceTarget(true, 8, 0)); + assertEquals(8, HydrologyPlanner.effectiveSourceTarget(false, 8, 1)); + assertThrows( + IllegalArgumentException.class, + () -> HydrologyPlanner.effectiveSourceTarget(true, -1, 1) + ); + } + + @Test + public void zeroMouthLevelingDistanceKeepsAcceptedCoursesHydraulicallyNonRising() { + HydrologyPlannerSettings base = standardSettings(4D, 0D, true, false, List.of()); + HydrologyPlannerSettings.Outlets configured = base.outlets(); + HydrologyPlannerSettings settings = withOutlets(base, new HydrologyPlannerSettings.Outlets( + configured.oceanEnabled(), + configured.coastalGrotto(), + configured.inlandGrotto(), + configured.surfaceSinkholesEnabled(), + configured.coastalCliffMinimumHeight(), + 0, + configured.maximumOceanApron(), + configured.maximumPerTile() + )); + + HydrologyTile tile = planner(78L, settings, rollingCoast(112)).plan(TILE); + + assertFalse(tile.diagnosticCandidates().toString(), surfaceCourses(tile).isEmpty()); + assertTrue(surfaceCourses(tile).stream().allMatch(RiverCourse::hydraulicallyNonRising)); + } + + @Test + public void requiredSourceQuotaOnlyAppliesWhenAProvenOutletExists() { + HydrologyPlannerSettings base = standardSettings(0D, 0D, true, false, List.of()); + HydrologyPlannerSettings settings = withSurfaceSources(base, new HydrologyPlannerSettings.Source( + true, + 0D, + 80, + 0, + 0, + 24 + )); + HydrologyTerrainSampler connected = (int x, int z) -> terrain( + 110 - Math.floorDiv(x, 16), + 1D, + x >= 112, + true, + x >= 0 && x <= 16, + true, + false, + false + ); + HydrologyTerrainSampler disconnected = (int x, int z) -> terrain( + 110, + 0D, + false, + true, + x >= 0 && x <= 16, + true, + false, + false + ); + + HydrologyTile connectedTile = new HydrologyPlanner(81L, settings, connected).plan(TILE); + HydrologyTile disconnectedTile = new HydrologyPlanner(81L, settings, disconnected).plan(TILE); + + assertEquals(0, settings.surface().sources().maximumPerTile()); + assertTrue(settings.publicationRadius() > 0); + assertFalse(surfaceCourses(connectedTile).isEmpty()); + assertTrue(surfaceCourses(disconnectedTile).isEmpty()); + assertTrue(disconnectedTile.outlets().isEmpty()); + } + + @Test + public void rejectedRequiredCourseBackfillsTheNextRankedRequiredCandidate() { + HydrologyPlannerSettings base = standardSettings(0D, 0D, true, false, List.of()); + HydrologyPlannerSettings settings = withUndergroundSources(base, new HydrologyPlannerSettings.Source( + true, + 0D, + Integer.MIN_VALUE, + 0, + 0, + 32 + )); + HydrologyTerrainSampler terrain = (int x, int z) -> terrain( + 120 - Math.floorDiv(x, 16) - (z >= 64 ? 4 : 0), + 1D, + x >= 112, + true, + false, + false, + x == 0 && (z == 0 || z == 96), + x == 0 && (z == 0 || z == 96) + ); + HydrologyPlanner acceptedPlanner = new HydrologyPlanner(8101L, settings, terrain, solidCaveView()); + HydrologyTile accepted = acceptedPlanner.plan(TILE); + RiverCourse initiallySelected = courses(accepted, RiverCourseType.UNDERGROUND).getFirst(); + DrainageNode initialSource = accepted.node(initiallySelected.sourceNodeId().orElseThrow()).orElseThrow(); + CavePosition hazard = accepted.cavePlan(initiallySelected.id()).orElseThrow().actions().entrySet().stream() + .filter((Map.Entry entry) -> + entry.getKey().x() == initialSource.x() + && entry.getKey().z() == initialSource.z() + && entry.getValue() == HydrologyCaveAction.WET_SOURCE) + .map(Map.Entry::getKey) + .findFirst() + .orElseThrow(); + + HydrologyPlanner backfillPlanner = new HydrologyPlanner( + 8101L, + settings, + terrain, + selectiveCaveView(Map.of(hazard, CaveVoxel.LAVA)) + ); + HydrologyTile backfilled = backfillPlanner.plan(TILE); + HydrologyTile repeated = backfillPlanner.plan(TILE); + + assertEquals(backfilled, repeated); + assertEquals(1, courses(backfilled, RiverCourseType.UNDERGROUND).size()); + RiverCourse replacement = courses(backfilled, RiverCourseType.UNDERGROUND).getFirst(); + DrainageNode replacementSource = backfilled.node(replacement.sourceNodeId().orElseThrow()).orElseThrow(); + assertTrue(initialSource.z() < replacementSource.z()); + assertFalse(replacement.sourceNodeId().equals(initiallySelected.sourceNodeId())); + assertTrue(backfilled.diagnosticCandidates().stream().anyMatch( + (HydrologyDiagnosticCandidate candidate) -> + candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT + && candidate.point().x() == initialSource.x() + && candidate.point().z() == initialSource.z() + )); + } + + @Test + public void requiredBackfillRestoresTheRequestedAcceptedCount() { + HydrologyPlannerSettings base = standardSettings(0D, 0D, true, false, List.of()); + HydrologyPlannerSettings settings = withUndergroundSources(base, new HydrologyPlannerSettings.Source( + true, + 2D, + Integer.MIN_VALUE, + 1, + 2, + 32 + )); + HydrologyTerrainSampler terrain = (int x, int z) -> terrain( + 120 - Math.floorDiv(x, 16) - Math.floorDiv(Math.max(0, z), 48), + 1D, + x >= 112, + true, + false, + false, + x == 0 && (z == 0 || z == 48 || z == 96), + x == 0 && (z == 0 || z == 48 || z == 96) + ); + HydrologyTile accepted = new HydrologyPlanner(8102L, settings, terrain, solidCaveView()).plan(TILE); + List initialCourses = courses(accepted, RiverCourseType.UNDERGROUND); + assertEquals(2, initialCourses.size()); + RiverCourse rejectedCourse = initialCourses.getFirst(); + CavePosition hazard = accepted.cavePlan(rejectedCourse.id()).orElseThrow().actions().entrySet().stream() + .filter((Map.Entry entry) -> + entry.getValue() == HydrologyCaveAction.WET_SOURCE) + .map(Map.Entry::getKey) + .findFirst() + .orElseThrow(); + Set initialSourceIds = new HashSet<>(); + for (RiverCourse course : initialCourses) { + initialSourceIds.add(course.sourceNodeId().orElseThrow()); + } + + HydrologyTile filtered = new HydrologyPlanner( + 8102L, + settings, + terrain, + selectiveCaveView(Map.of(hazard, CaveVoxel.LAVA)) + ).plan(TILE); + + List survivingCourses = courses(filtered, RiverCourseType.UNDERGROUND); + assertEquals(2, survivingCourses.size()); + assertTrue(survivingCourses.stream().anyMatch( + (RiverCourse course) -> initialSourceIds.contains(course.sourceNodeId().orElseThrow()) + )); + assertTrue(survivingCourses.stream().anyMatch( + (RiverCourse course) -> !initialSourceIds.contains(course.sourceNodeId().orElseThrow()) + )); + } + + @Test + public void rejectedOptionalCourseBackfillsTheRequestedAcceptedCount() { + HydrologyPlannerSettings base = standardSettings(0D, 0D, true, false, List.of()); + HydrologyPlannerSettings settings = withUndergroundSources(base, new HydrologyPlannerSettings.Source( + true, + 2D, + Integer.MIN_VALUE, + 0, + 2, + 0 + )); + HydrologyTerrainSampler terrain = (int x, int z) -> terrain( + 120 - Math.floorDiv(x, 16) - (z >= 64 ? 4 : 0), + 1D, + x >= 112, + true, + false, + false, + x == 0 && (z == 0 || z == 48 || z == 96), + false + ); + HydrologyTile baseline = new HydrologyPlanner(8102L, settings, terrain, solidCaveView()).plan(TILE); + List baselineCourses = courses(baseline, RiverCourseType.UNDERGROUND); + assertEquals(2, baselineCourses.size()); + RiverCourse rejectedCourse = baselineCourses.getFirst(); + DrainageNode rejectedSource = baseline.node(rejectedCourse.sourceNodeId().orElseThrow()).orElseThrow(); + Set initialSourceIds = new HashSet<>(); + for (RiverCourse course : baselineCourses) { + initialSourceIds.add(course.sourceNodeId().orElseThrow()); + } + CavePosition hazard = baseline.cavePlan(rejectedCourse.id()).orElseThrow().actions().entrySet().stream() + .filter((Map.Entry entry) -> + entry.getKey().x() == rejectedSource.x() + && entry.getKey().z() == rejectedSource.z() + && entry.getValue() == HydrologyCaveAction.WET_SOURCE) + .map(Map.Entry::getKey) + .findFirst() + .orElseThrow(); + + HydrologyTile rejected = new HydrologyPlanner( + 8102L, + settings, + terrain, + selectiveCaveView(Map.of(hazard, CaveVoxel.LAVA)) + ).plan(TILE); + List acceptedCourses = courses(rejected, RiverCourseType.UNDERGROUND); + assertEquals(2, acceptedCourses.size()); + assertTrue(acceptedCourses.stream().anyMatch( + (RiverCourse course) -> !initialSourceIds.contains(course.sourceNodeId().orElseThrow()) + )); + assertTrue(acceptedCourses.stream().flatMap((RiverCourse course) -> course.drainageEdges().stream()).allMatch( + (DrainageEdge edge) -> edge.contributingUndergroundSources() >= 1 + )); + } + + @Test + public void refinedRoutesRejectUnsampledOceanBarriersBetweenCoarseNodes() { + HydrologyPlannerSettings settings = standardSettings(4D, 0D, true, false, List.of()); + HydrologyTerrainSampler terrain = (int x, int z) -> { + if (x >= 4 && x <= 12 || x >= 112) { + return oceanTerrain(); + } + return terrain( + 118 - Math.floorDiv(x, 12), + 1D, + false, + true, + x == 0, + x == 0, + false, + false + ); + }; + + HydrologyTile tile = new HydrologyPlanner(82L, settings, terrain).plan(TILE); + + assertTrue(surfaceCourses(tile).isEmpty()); + assertTrue(tile.diagnosticCandidates().toString(), tile.diagnosticCandidates().stream().anyMatch( + candidate -> candidate.rejection() == HydrologyCandidateRejection.NO_DRAINAGE_PATH + )); + } + + @Test + public void rejectedCandidatesRemainIsolatedFromAcceptedGenerationRenderingAndLocators() { + HydrologyPlannerSettings settings = standardSettings(0D, 0D, false, false, List.of()); + HydrologyTerrainSampler terrain = (int x, int z) -> terrain( + 110, + 0D, + false, + true, + x >= 0 && x <= 16, + true, + false, + false + ); + HydrologyTile tile = new HydrologyPlanner(181L, settings, terrain).plan(TILE); + + assertTrue(tile.courses().isEmpty()); + assertTrue(tile.footprint().isEmpty()); + assertTrue(tile.features().isEmpty()); + assertFalse(tile.diagnosticCandidates().isEmpty()); + for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + assertEquals(HydrologyCandidateKind.SOURCE, candidate.kind()); + assertEquals(HydrologyFeatureType.SURFACE_POOL, candidate.projectedType()); + assertEquals(HydrologyCandidateRejection.NO_LEGAL_OUTLET, candidate.rejection()); + assertTrue(tile.columnAt(candidate.point().x(), candidate.point().z()).isEmpty()); + assertFalse(tile.renderAt(candidate.point().x(), candidate.point().z()).present()); + assertTrue(tile.nearestFeature( + candidate.projectedType(), + candidate.point().x(), + candidate.point().z(), + 0 + ).isEmpty()); + HydrologyDiagnosticRenderSample diagnostic = tile.diagnosticRenderAt( + candidate.point().x(), + candidate.point().z(), + 0 + ); + assertTrue(diagnostic.present()); + assertTrue(diagnostic.candidates().contains(candidate)); + } + } + + @Test + public void rollingTerrainProducesPooledStepsAndCliffsProduceWaterfalls() { + HydrologyPlannerSettings settings = standardSettings(3D, 0D, true, false, List.of()); + HydrologyTerrainSampler rollingTerrain = rollingCoast(112); + HydrologyTile rolling = new HydrologyPlanner(19L, settings, rollingTerrain).plan(TILE); + HydrologyTerrainSampler cliffTerrain = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int height = x < 64 ? 122 : 78; + return terrain(height, x >= 56 && x <= 64 ? 24D : 1D, false, true, x <= 16, true, false, false); + }; + HydrologyTile cliff = new HydrologyPlanner(19L, settings, cliffTerrain).plan(TILE); + + assertTrue( + "diagnostics=" + rolling.diagnosticCandidates() + " segments=" + rolling.courses().stream() + .flatMap((RiverCourse course) -> course.segments().stream()) + .map((HydraulicSegment segment) -> segment.type() + ":" + segment.drop()) + .toList(), + hasAny(rolling, HydrologyFeatureType.RIFFLE, HydrologyFeatureType.CASCADE) + ); + for (HydraulicSegment segment : segments(rolling, HydrologyFeatureType.CASCADE)) { + assertTrue(segment.drop() > 0); + assertFalse(segment.fallingFluid()); + assertTrue(segment.receivingPool() || hasDownstreamReceivingDrop(rolling, segment)); + assertTrue(segment.centerline().size() >= segment.drop() + 1); + int previousHead = segment.upstreamHeadY(); + for (HydrologyPoint point : segment.centerline()) { + assertTrue(point.y() <= previousHead); + assertTrue(previousHead - point.y() <= 1); + assertTrue(rollingTerrain.sample(point.x() - 1, point.z()).naturalHeight() > point.y()); + assertTrue(rollingTerrain.sample(point.x() + 1, point.z()).naturalHeight() > point.y()); + assertTrue(rollingTerrain.sample(point.x(), point.z() - 1).naturalHeight() > point.y()); + assertTrue(rollingTerrain.sample(point.x(), point.z() + 1).naturalHeight() > point.y()); + previousHead = point.y(); + } + assertEquals(segment.downstreamHeadY(), previousHead); + } + assertTrue( + "features=" + cliff.features().stream().map(HydrologyFeatureRef::type).toList() + + " diagnostics=" + cliff.diagnosticCandidates().stream() + .map((HydrologyDiagnosticCandidate candidate) -> + candidate.projectedType() + ":" + candidate.rejection()) + .toList(), + hasAny(cliff, HydrologyFeatureType.WATERFALL) + ); + for (HydraulicSegment segment : segments(cliff, HydrologyFeatureType.WATERFALL)) { + assertTrue(segment.drop() > 0); + assertTrue(segment.fallingFluid()); + assertEquals(2, segment.centerline().size()); + assertTrue(segment.width() >= 1); + } + boolean sawBlendedWaterfall = false; + for (RiverCourse course : cliff.courses()) { + for (int index = 1; index < course.segments().size() - 1; index++) { + HydraulicSegment waterfall = course.segments().get(index); + if (waterfall.type() != HydrologyFeatureType.WATERFALL) { + continue; + } + HydraulicSegment approach = course.segments().get(index - 1); + HydraulicSegment receiver = course.segments().get(index + 1); + assertTrue(approach.type().isSurface()); + assertTrue(receiver.type().isSurface()); + assertEquals(approach.end(), waterfall.start()); + assertEquals(waterfall.end(), receiver.start()); + sawBlendedWaterfall = true; + } + } + assertTrue(sawBlendedWaterfall); + } + + @Test + public void adjacentSurfaceDropsCollapseIntoSeparatedTransitionComplexes() { + HydrologyPlannerSettings settings = standardSettings(1D, 0D, false, true, List.of()); + HydrologyTerrainSampler terrain = (int x, int z) -> { + boolean source = x == 0 && z == 0; + boolean outlet = x == 64 && z == 64; + int distance = x + z; + int height = tieredTransitionHeight(distance); + return new HydrologyTerrainSample( + height, + 1D, + false, + true, + 72, + 74, + true, + outlet, + source, + source, + false, + false, + 0D, + source ? 1D : 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha") + ); + }; + HydrologyTile tile = new HydrologyPlanner(812L, settings, terrain, solidCaveView()).plan(TILE); + + assertFalse("diagnostics=" + tile.diagnosticCandidates(), surfaceCourses(tile).isEmpty()); + boolean aggregateTransition = false; + for (RiverCourse course : surfaceCourses(tile)) { + double recovery = Double.POSITIVE_INFINITY; + boolean transitionActive = false; + for (HydraulicSegment segment : course.segments()) { + boolean surfaceDrop = segment.type().isSurface() && segment.type().isDrop(); + if (surfaceDrop) { + if (!transitionActive) { + assertTrue( + "segments=" + course.segments(), + !Double.isFinite(recovery) + || recovery >= settings.hydraulics().minimumTargetPoolLength() + ); + } + recovery = 0D; + transitionActive = !segment.receivingPool(); + if (segment.type() == HydrologyFeatureType.WATERFALL + && segment.drop() >= settings.hydraulics().waterfallMinimumDrop()) { + aggregateTransition = true; + } + continue; + } + if (segment.type() == HydrologyFeatureType.UNDERGROUND_DROP + && segment.drop() >= settings.hydraulics().waterfallMinimumDrop()) { + aggregateTransition = true; + } + if (transitionActive && segment.receivingPool()) { + transitionActive = false; + } else if (!transitionActive && Double.isFinite(recovery)) { + recovery += segmentLength(segment); + } + } + } + assertTrue("courses=" + surfaceCourses(tile), aggregateTransition); + } + + @Test + public void anUnbridgeableTerrainCrevasseRejectsTheSurfaceCourse() { + HydrologyTerrainSampler terrain = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int height = x == 47 + ? 82 + : 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D); + return terrain(height, 1D, false, true, x >= 0 && x <= 24, true, true, false); + }; + HydrologyTile tile = new HydrologyPlanner( + 19L, + standardSettings(3D, 0D, true, false, List.of()), + terrain + ).plan(TILE); + + assertTrue("courses=" + surfaceCourses(tile), surfaceCourses(tile).isEmpty()); + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (!layer.feature().type().isSurface() || !layer.channel() || !layer.fluidOwned()) { + continue; + } + assertTrue( + column.x() + "," + column.z() + " " + layer.feature().type() + + " head=" + layer.fluidHeadY() + " natural=" + column.naturalHeight(), + layer.fluidHeadY() <= column.naturalHeight() + ); + } + } + } + + @Test + public void aSurfaceCourseCanBoreThroughARidgeAndReopen() { + HydrologyTerrainSampler ridgeTerrain = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int base = 108 - Math.floorDiv(x, 8); + int height = x >= 48 && x <= 72 ? base + 48 : base; + return terrain(height, x >= 48 && x <= 72 ? 18D : 1D, false, true, x <= 16, true, false, false); + }; + HydrologyPlannerSettings settings = standardSettings(2D, 0D, true, false, List.of()); + HydrologyTile tile = new HydrologyPlanner( + 992L, + settings, + ridgeTerrain + ).plan(TILE); + + boolean reopened = false; + for (RiverCourse course : surfaceCourses(tile)) { + int firstBore = firstSegment( + course, + HydrologyFeatureType.RIDGE_BORE, + HydrologyFeatureType.UNDERGROUND_DROP + ); + if (firstBore < 0) { + continue; + } + HydraulicSegment bore = course.segments().get(firstBore); + assertTrue(bore.width() >= settings.surface().minimumWidth()); + HydrologyColumnLayer grotto = layerForSegment( + tile.columnAt(bore.start().x(), bore.start().z()).orElseThrow(), + bore.id() + ); + assertTrue(grotto.ceilingY() > grotto.fluidHeadY()); + for (int index = firstBore + 1; index < course.segments().size(); index++) { + HydraulicSegment previous = course.segments().get(index - 1); + HydraulicSegment current = course.segments().get(index); + assertEquals(previous.centerline().getLast(), current.centerline().getFirst()); + if (current.type().isSurface()) { + HydrologyColumnLayer reopenedLayer = layerForSegment( + tile.columnAt(current.start().x(), current.start().z()).orElseThrow(), + current.id() + ); + assertFalse(reopenedLayer.feature().source()); + reopened = true; + break; + } + } + if (reopened) { + break; + } + } + assertTrue( + "diagnostics=" + tile.diagnosticCandidates() + " courses=" + surfaceCourses(tile).stream() + .map((RiverCourse course) -> course.segments().stream() + .map(HydraulicSegment::type) + .toList()) + .toList(), + reopened + ); + } + + @Test + public void ridgeCoursesRequireConfiguredPublishedSurfaceExposure() { + HydrologyPlannerSettings base = standardSettings(2D, 0D, true, false, List.of()); + HydrologyPlannerSettings.Routing routing = base.routing(); + HydrologyPlannerSettings settings = new HydrologyPlannerSettings( + base.seaLevel(), + new HydrologyPlannerSettings.Routing( + routing.tileSize(), + routing.sampleSpacing(), + routing.refinementSpacing(), + routing.maximumRouteNodes(), + routing.maximumRouteLength(), + new HydrologyPlannerSettings.Branching(96, 0), + routing.valleyPreference(), + routing.uphillPenalty(), + routing.slopePenalty(), + routing.confluenceAttraction() + ), + base.surface(), + base.hydraulics(), + base.underground(), + base.outlets(), + base.geometry(), + base.deepFluids() + ); + HydrologyTerrainSampler repeatedRidges = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int baseHeight = 108 - Math.floorDiv(x, 8); + boolean ridge = x >= 48 && x <= 72 && Math.abs(z) < 24; + int height = ridge ? baseHeight + 48 : baseHeight; + return terrain(height, ridge ? 18D : 1D, false, true, x <= 16, true, false, false); + }; + + HydrologyTile tile = new HydrologyPlanner(993L, settings, repeatedRidges).plan(TILE); + + assertFalse("courses=" + surfaceCourses(tile), surfaceCourses(tile).isEmpty()); + for (RiverCourse course : surfaceCourses(tile)) { + assertTrue("segments=" + course.segments(), course.segments().getFirst().type().isSurface()); + double exposedLength = 0D; + double exposedRunLength = 0D; + double longestExposedRunLength = 0D; + for (HydraulicSegment segment : course.segments()) { + if (segment.type().isSurface() && segment.type() != HydrologyFeatureType.MOUTH) { + double length = segmentLength(segment); + exposedLength += length; + exposedRunLength += length; + longestExposedRunLength = Math.max(longestExposedRunLength, exposedRunLength); + } else { + exposedRunLength = 0D; + } + } + assertTrue("segments=" + course.segments(), exposedLength >= 96D); + assertTrue( + "segments=" + course.segments(), + longestExposedRunLength >= Math.min(96, settings.routing().sampleSpacing() * 2) + ); + } + } + + @Test + public void undergroundSourcesUseAnIndependentBudgetAndConfiguredHeight() { + HydrologyPlannerSettings settings = standardSettings(0D, 3D, true, false, List.of()); + HydrologyTerrainSampler undergroundCoast = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D); + return terrain(height, 1D, false, true, false, false, x >= 0 && x <= 24, true); + }; + HydrologyTile tile = new HydrologyPlanner(331L, settings, undergroundCoast).plan(TILE); + + assertTrue(surfaceCourses(tile).isEmpty()); + List underground = courses(tile, RiverCourseType.UNDERGROUND); + assertFalse(underground.isEmpty()); + for (RiverCourse course : underground) { + int head = course.segments().getFirst().upstreamHeadY(); + DrainageNode source = tile.node(course.sourceNodeId().orElseThrow()).orElseThrow(); + assertEquals(source.terrain().caveFluidY(), head); + assertEquals(74, head); + assertTrue(head >= settings.underground().minimumFluidY()); + assertTrue(head <= settings.underground().maximumFluidY()); + } + } + + @Test + public void undergroundSourcesHonorStyledFluidLevelAtEachAcceptedSource() { + HydrologyPlannerSettings settings = standardSettings(0D, 4D, true, false, List.of()); + HydrologyTerrainSampler styledCoast = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int height = 118 - Math.floorDiv(x, 12); + int fluidY = 68 + Math.floorMod(x + z, 15); + return undergroundTerrain(height, fluidY, x >= 0 && x <= 24); + }; + HydrologyTile tile = new HydrologyPlanner(331L, settings, styledCoast).plan(TILE); + HashSet acceptedHeads = new HashSet<>(); + + for (RiverCourse course : courses(tile, RiverCourseType.UNDERGROUND)) { + DrainageNode source = tile.node(course.sourceNodeId().orElseThrow()).orElseThrow(); + int expected = Math.max( + settings.underground().minimumFluidY(), + Math.min(settings.underground().maximumFluidY(), source.terrain().caveFluidY()) + ); + int actual = course.segments().getFirst().upstreamHeadY(); + assertEquals(expected, actual); + acceptedHeads.add(actual); + } + assertTrue(acceptedHeads.size() > 1); + } + + @Test + public void undergroundCoastalOutletRaisesStyledHeadToSeaLevelWithinConfiguredRange() { + HydrologyPlannerSettings settings = withUndergroundFluidRange( + standardSettings(0D, 4D, true, false, List.of()), + 40, + 63 + ); + HydrologyTerrainSampler coast = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + return undergroundTerrain(118 - Math.floorDiv(x, 12), 48, x >= 0 && x <= 24); + }; + HydrologyGeometrySampler geometry = request -> request.field() + == HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL + ? 48 + : request.minimum(); + + HydrologyTile tile = new HydrologyPlanner( + 332L, + settings, + coast, + geometry, + -4096, + footprint -> solidCaveView() + ).plan(TILE); + + List underground = courses(tile, RiverCourseType.UNDERGROUND); + assertFalse(underground.isEmpty()); + for (RiverCourse course : underground) { + RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); + assertTrue(outlet.directOcean()); + assertEquals(settings.seaLevel(), course.segments().getFirst().upstreamHeadY()); + assertTrue(course.segments().getFirst().upstreamHeadY() >= settings.underground().minimumFluidY()); + assertTrue(course.segments().getFirst().upstreamHeadY() <= settings.underground().maximumFluidY()); + assertTrue(course.hydraulicallyNonRising()); + } + } + + @Test + public void undergroundCoastalOutletRejectsWhenSeaLevelExceedsConfiguredMaximum() { + HydrologyPlannerSettings settings = withUndergroundFluidRange( + standardSettings(0D, 4D, true, false, List.of()), + 40, + 62 + ); + HydrologyTerrainSampler coast = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + return undergroundTerrain(118 - Math.floorDiv(x, 12), 48, x >= 0 && x <= 24); + }; + + HydrologyTile tile = new HydrologyPlanner(333L, settings, coast, solidCaveView()).plan(TILE); + + assertTrue(courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); + assertTrue(tile.diagnosticCandidates().stream().anyMatch( + (HydrologyDiagnosticCandidate candidate) -> + candidate.rejection() == HydrologyCandidateRejection.OUTLET_LEVEL + )); + } + + @Test + public void undergroundUsesInlandGrottoWhenCoastIsAboveItsFluidRange() { + HydrologyPlannerSettings base = withUndergroundFluidRange( + standardSettings(0D, 4D, true, true, List.of()), + 40, + 62 + ); + HydrologyPlannerSettings.Outlets configured = base.outlets(); + HydrologyPlannerSettings settings = withOutlets(base, new HydrologyPlannerSettings.Outlets( + configured.oceanEnabled(), + configured.coastalGrotto(), + configured.inlandGrotto(), + configured.surfaceSinkholesEnabled(), + configured.coastalCliffMinimumHeight(), + configured.mouthLevelingDistance(), + configured.maximumOceanApron(), + 1 + )); + HydrologyTerrainSampler coast = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + return undergroundTerrain(118 - Math.floorDiv(x, 12), 48, x >= 0 && x <= 24); + }; + + HydrologyTile tile = new HydrologyPlanner( + 333L, + settings, + coast, + HydrologyGeometrySampler.deterministic(333L, coast), + -4096, + footprint -> solidCaveView() + ).plan(TILE); + + assertFalse(tile.diagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); + for (RiverCourse course : courses(tile, RiverCourseType.UNDERGROUND)) { + RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); + assertEquals(HydrologyFeatureType.INLAND_GROTTO, outlet.type()); + assertFalse(outlet.directOcean()); + assertTrue(course.hydraulicallyNonRising()); + } + } + + @Test + public void infeasibleUndergroundCourseSkipsWidthDependentRadialSampling() { + HydrologyPlannerSettings base = standardSettings(0D, 1D, true, false, List.of()); + HydrologyPlannerSettings narrow = withUndergroundWidth(base, 4); + HydrologyPlannerSettings wide = withUndergroundWidth(base, 12); + HydrologyTerrainSampler terrain = (int x, int z) -> x >= 112 + ? oceanTerrain() + : undergroundTerrain(72, 68, x == 0 && z == 0); + AtomicInteger narrowSamples = new AtomicInteger(); + AtomicInteger wideSamples = new AtomicInteger(); + + HydrologyTile narrowTile = plannerWithRoutingSampler( + 3331L, + narrow, + terrain, + narrowSamples + ).plan(TILE); + HydrologyTile wideTile = plannerWithRoutingSampler( + 3331L, + wide, + terrain, + wideSamples + ).plan(TILE); + + assertTrue(courses(narrowTile, RiverCourseType.UNDERGROUND).isEmpty()); + assertTrue(courses(wideTile, RiverCourseType.UNDERGROUND).isEmpty()); + assertTrue(narrowTile.diagnosticCandidates().stream().anyMatch( + (HydrologyDiagnosticCandidate candidate) -> + candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT + )); + assertTrue(wideTile.diagnosticCandidates().stream().anyMatch( + (HydrologyDiagnosticCandidate candidate) -> + candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT + )); + assertEquals(narrowSamples.get(), wideSamples.get()); + } + + @Test + public void undergroundInlandOutletKeepsStyledHeadAboveOutlet() { + HydrologyPlannerSettings settings = standardSettings(0D, 1D, false, true, List.of()); + HydrologyGeometrySampler geometry = request -> { + if (request.field() != HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL) { + return request.minimum(); + } + return request.x() < 32 && request.z() < 32 ? 79 : 71; + }; + + HydrologyTile tile = new HydrologyPlanner( + 334L, + settings, + inlandTerrain(false, true), + geometry, + -4096, + footprint -> solidCaveView() + ).plan(TILE); + + assertFalse(tile.diagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); + RiverCourse course = courses(tile, RiverCourseType.UNDERGROUND).getFirst(); + RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); + assertFalse( + "outlet=" + outlet.type() + " source=" + course.sourceNodeId() + + " diagnostics=" + tile.diagnosticCandidates(), + outlet.directOcean() + ); + assertEquals(71, outlet.connectionPoint().y()); + assertEquals(79, course.segments().getFirst().upstreamHeadY()); + assertEquals(71, course.segments().getLast().downstreamHeadY()); + assertTrue(course.hydraulicallyNonRising()); + } + + @Test + public void inlandOutletSelectionUsesDeterministicDisplacedAnchors() { + HydrologyPlannerSettings settings = standardSettings(0D, 0D, false, true, List.of()); + AtomicInteger firstOffLatticeSamples = new AtomicInteger(); + AtomicInteger secondOffLatticeSamples = new AtomicInteger(); + + HydrologyTile first = plannerWithRoutingSampler( + 335L, + settings, + inlandTerrain(false, false), + firstOffLatticeSamples + ).plan(TILE); + HydrologyTile second = plannerWithRoutingSampler( + 335L, + settings, + inlandTerrain(false, false), + secondOffLatticeSamples + ).plan(TILE); + + assertEquals(first.outlets(), second.outlets()); + assertEquals(firstOffLatticeSamples.get(), secondOffLatticeSamples.get()); + assertTrue(firstOffLatticeSamples.get() > 0); + } + + @Test + public void undergroundHeadSolverMatchesExhaustiveBoundedSequences() { + Random random = new Random(0x485944524f4c4f47L); + for (int iteration = 0; iteration < 400; iteration++) { + int length = 1 + random.nextInt(5); + int[] preferred = new int[length]; + int[] minimum = new int[length]; + int[] maximum = new int[length]; + int[] solved = new int[length]; + for (int index = 0; index < length; index++) { + minimum[index] = random.nextInt(7) - 2; + maximum[index] = minimum[index] + random.nextInt(5); + preferred[index] = random.nextInt(11) - 3; + } + int outlet = random.nextInt(11) - 3; + + boolean expected = bruteForceUndergroundHeads(minimum, maximum, outlet, 0, Integer.MAX_VALUE); + boolean actual = HydrologyPlanner.solveUndergroundHeads( + preferred, + minimum, + maximum, + outlet, + solved + ); + + assertEquals(expected, actual); + if (!actual) { + continue; + } + assertEquals(outlet, solved[length - 1]); + for (int index = 0; index < length; index++) { + assertTrue(solved[index] >= minimum[index]); + assertTrue(solved[index] <= maximum[index]); + if (index > 0) { + assertTrue(solved[index - 1] >= solved[index]); + } + } + } + } + + @Test + public void deepFluidPlacementIsIndependentAndHonorsItsOwnRangeAndShapeSwitches() { + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "deep_lava", + true, + 4D, + 32, + -180, + -120, + 3, + 4, + 2, + 3, + 8, + 24, + 3, + 2, + 3, + 8192, + 3, + false, + true + ); + HydrologyPlannerSettings settings = standardSettings(0D, 0D, false, false, List.of(deepFluid)); + HydrologyTerrainSampler terrain = (int x, int z) -> terrain(90, 0D, false, true, false, false, false, false); + + HydrologyTile tile = new HydrologyPlanner(441L, settings, terrain).plan(TILE); + + assertTrue(surfaceCourses(tile).isEmpty()); + assertTrue(courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); + List deepCourses = courses(tile, RiverCourseType.DEEP_FLUID); + assertFalse(deepCourses.isEmpty()); + for (RiverCourse course : deepCourses) { + assertEquals("deep_lava", course.profileKey()); + assertTrue(course.segments().stream().noneMatch( + (HydraulicSegment segment) -> segment.type() == HydrologyFeatureType.DEEP_POOL)); + assertTrue(course.segments().stream().allMatch( + (HydraulicSegment segment) -> segment.type() == HydrologyFeatureType.DEEP_CHANNEL)); + assertTrue(course.segments().stream().allMatch( + (HydraulicSegment segment) -> hasNonCollinearInterior(segment.centerline()))); + int head = course.segments().getFirst().upstreamHeadY(); + assertTrue(head >= -180 && head <= -120); + } + } + + @Test + public void routingHaloFindsAnOceanOutsideTheOwnedTile() { + HydrologyTerrainSampler neighboringCoast = (int x, int z) -> { + if (x >= 144) { + return oceanTerrain(); + } + return terrain(118 - Math.floorDiv(x, 8), 1D, false, true, x <= 16, true, false, false); + }; + HydrologyPlanner planner = new HydrologyPlanner( + 882L, + standardSettings(2D, 0D, true, true, List.of()), + neighboringCoast + ); + HydrologyTile tile = planner.plan(TILE); + + assertFalse( + "courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(), + tile.outlets().isEmpty() + ); + assertTrue(tile.outlets().stream().allMatch(RiverOutlet::directOcean)); + assertTrue(tile.outlets().stream().anyMatch((RiverOutlet outlet) -> outlet.connectionPoint().x() >= 144)); + HydrologyColumnSample neighboringColumn = null; + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + if (!TILE.contains(column.x(), column.z(), tile.tileSize()) && !column.ocean()) { + neighboringColumn = column; + break; + } + } + assertNotNull(neighboringColumn); + HydrologyColumnSample published = new HydrologyTileCache(planner, 8) + .columnAt(neighboringColumn.x(), neighboringColumn.z()) + .orElseThrow(); + assertTrue(published.layers().containsAll(neighboringColumn.layers())); + } + + @Test + public void firstOceanClipPublishesNoRiverOwnedWritesBeyondTheCoast() { + HydrologyTile tile = new HydrologyPlanner( + 52L, + standardSettings(3D, 0D, true, false, List.of()), + rollingCoast(112) + ).plan(TILE); + + boolean sawApron = false; + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + if (!column.ocean()) { + continue; + } + sawApron = true; + for (HydrologyColumnLayer layer : column.layers()) { + assertFalse(layer.terrainOwned()); + assertFalse(layer.fluidOwned()); + assertFalse(layer.grading()); + assertTrue(layer.fluidHeadY() <= column.seaLevel()); + } + } + assertTrue(sawApron); + } + + @Test + public void footprintPersistsResolvedProfileBiomeRolesAndParentGrading() { + HydrologyTile tile = new HydrologyPlanner( + 712L, + standardSettings(2D, 0D, true, false, List.of()), + rollingCoast(112) + ).plan(TILE); + + assertFalse("courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(), tile.footprint().isEmpty()); + boolean sawGrading = false; + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + assertEquals(column.ocean() ? "ocean_parent" : "parent", column.parentBiomeKey()); + for (HydrologyColumnLayer layer : column.layers()) { + assertTrue(layer.profileKey().equals("alpha") || layer.profileKey().equals("beta")); + assertEquals("surface", layer.surfaceBiomeKey()); + assertEquals("mouth", layer.mouthBiomeKey()); + assertEquals("shore", layer.shoreBiomeKey()); + assertEquals("dry", layer.dryBiomeKey()); + assertEquals("flooded", layer.floodedCaveBiomeKey()); + if (layer.grading() && !layer.shore() && !layer.channel()) { + sawGrading = true; + assertEquals("parent", column.parentBiomeKey()); + assertTrue(layer.biomeKey() == null); + } + } + } + assertTrue(sawGrading); + } + + @Test + public void fractionalShoreContentAndPhysicalParentGradingRemainIndependent() { + HydrologyPlannerSettings settings = standardSettings(3D, 0D, true, false, List.of()); + HydrologyTile tile = new HydrologyPlanner(501L, settings, rollingCoast(112)).plan(TILE); + boolean sawFractionalShore = false; + boolean sawChangedParentGrading = false; + double maximumShoreDistance = 0D; + double maximumGradingDistance = 0D; + int maximumChannelRadius = 0; + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.oceanApron() || !layer.feature().type().isSurface()) { + continue; + } + HydraulicSegment segment = segment(tile, layer.feature().segmentId()); + int channelRadius = Math.max(1, segment.width() / 2); + maximumChannelRadius = Math.max(maximumChannelRadius, channelRadius); + double distance = distanceToCenterline( + new HydrologyPoint(column.x(), layer.fluidHeadY(), column.z()), + segment.centerline() + ); + if (layer.shore()) { + maximumShoreDistance = Math.max(maximumShoreDistance, distance - channelRadius); + sawFractionalShore |= distance > channelRadius + 1.25D; + assertEquals("shore", layer.biomeKey()); + } else if (layer.grading() && !layer.channel()) { + maximumGradingDistance = Math.max(maximumGradingDistance, distance - channelRadius); + sawChangedParentGrading |= layer.bedY() != column.naturalHeight(); + assertTrue(layer.biomeKey() == null); + assertEquals("parent", column.parentBiomeKey()); + } + } + } + + assertTrue( + "fractional=" + sawFractionalShore + + " grading=" + sawChangedParentGrading + + " shore=" + maximumShoreDistance + + " gradingDistance=" + maximumGradingDistance + + " radius=" + maximumChannelRadius + + " courses=" + surfaceCourses(tile).size() + + " edges=" + tile.edges().size(), + sawFractionalShore + ); + assertTrue(sawChangedParentGrading); + assertTrue(maximumGradingDistance > 0D); + assertTrue(maximumChannelRadius > 0); + } + + @Test + public void surfaceCompilationPublishesOneTrunkAndNoBranchesPerOutlet() { + HydrologyPlannerSettings base = standardSettings(6D, 0D, true, false, List.of()); + HydrologyPlannerSettings.Surface deepeningSurface = new HydrologyPlannerSettings.Surface( + base.surface().enabled(), + base.surface().sources(), + base.surface().minimumWidth(), + base.surface().maximumWidth(), + base.surface().minimumDepth(), + 8, + base.surface().minimumSurfaceInset(), + base.surface().maximumSurfaceInset(), + 128, + base.surface().shoreWidth(), + base.surface().minimumTerrainBlendWidth(), + base.surface().maximumTerrainBlendWidth(), + base.surface().ridgeTunnelsEnabled(), + base.surface().maximumRidgeTunnelLength(), + base.surface().ridgeTunnelHeadroom() + ); + HydrologyPlannerSettings shaped = new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + deepeningSurface, + base.hydraulics(), + base.underground(), + base.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + base.deepFluids() + ); + HydrologyPlannerSettings settings = withOutlets(shaped, new HydrologyPlannerSettings.Outlets( + true, + base.outlets().coastalGrotto(), + base.outlets().inlandGrotto(), + base.outlets().surfaceSinkholesEnabled(), + base.outlets().coastalCliffMinimumHeight(), + base.outlets().mouthLevelingDistance(), + base.outlets().maximumOceanApron(), + 1 + )); + HydrologyTile tile = new HydrologyPlanner(642L, settings, optionalRollingCoast(112)).plan(TILE); + + List courses = surfaceCourses(tile); + assertFalse(courses.isEmpty()); + HashSet outletIds = new HashSet<>(); + for (RiverCourse course : courses) { + outletIds.add(course.outletId().orElseThrow()); + } + assertEquals(1, outletIds.size()); + assertEquals(1, courses.size()); + HydrologyFeatureType terminal = courses.getFirst().segments().getLast().type(); + assertTrue(terminal == HydrologyFeatureType.MOUTH + || terminal == HydrologyFeatureType.COASTAL_GROTTO + || terminal == HydrologyFeatureType.INLAND_GROTTO); + } + + @Test + public void everyAcceptedDropCompilesAContinuousBedAndReceivingBasin() { + HydrologyTerrainSampler cliffTerrain = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + int height = x < 64 ? 124 : 78; + return terrain(height, x >= 56 && x <= 64 ? 24D : 1D, false, true, x <= 16, true, false, false); + }; + HydrologyPlannerSettings settings = standardSettings(3D, 0D, true, false, List.of()); + HydrologyTile tile = new HydrologyPlanner( + 221L, + settings, + cliffTerrain + ).plan(TILE); + List drops = new ArrayList<>(); + for (RiverCourse course : surfaceCourses(tile)) { + for (HydraulicSegment segment : course.segments()) { + if (segment.drop() > 0) { + drops.add(segment); + } + } + } + + assertFalse(drops.isEmpty()); + for (HydraulicSegment drop : drops) { + boolean bed = false; + boolean falling = false; + boolean receiving = false; + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().segmentId() != drop.id()) { + continue; + } + if (!layer.channel()) { + continue; + } + falling |= layer.fallingFluid(); + assertTrue(layer.bedY() < layer.fluidHeadY()); + bed = true; + if (layer.receivingPool()) { + receiving = true; + assertEquals(drop.downstreamHeadY(), layer.fluidHeadY()); + } + } + } + assertTrue(bed); + assertEquals(drop.fallingFluid(), falling); + if (drop.type() == HydrologyFeatureType.WATERFALL) { + assertTrue(drop.fallingFluid()); + } + if (!receiving) { + HydrologyColumnSample endColumn = tile.columnAt(drop.end().x(), drop.end().z()).orElseThrow(); + receiving = endColumn.layers().stream().anyMatch((HydrologyColumnLayer layer) -> + layer.channel() && layer.fluidHeadY() == drop.downstreamHeadY()); + } + assertTrue("drop=" + drop, receiving); + if (!drop.fallingFluid()) { + int previousHead = drop.upstreamHeadY(); + for (HydrologyPoint point : drop.centerline()) { + assertTrue(point.y() <= previousHead); + assertTrue( + drop.type() + " drop=" + drop.drop() + " points=" + drop.centerline().size() + + " step=" + (previousHead - point.y()), + previousHead - point.y() <= settings.geometry().drops().stepLimit(drop.type()) + ); + previousHead = point.y(); + } + } + } + } + + @Test + public void coastalGrottoIsASeaLevelEllipsoidWithADirectOceanConnection() { + HydrologyPlannerSettings base = standardSettings(2D, 0D, true, true, List.of()); + HydrologyPlannerSettings settings = withOutlets(base, new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(true, 4, 3, 3, 4096), + base.outlets().inlandGrotto(), + base.outlets().surfaceSinkholesEnabled(), + 12, + base.outlets().mouthLevelingDistance(), + base.outlets().maximumOceanApron(), + base.outlets().maximumPerTile() + )); + HydrologyTerrainSampler cliffCoast = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + return terrain(92, 2D, false, true, x <= 16, true, false, false); + }; + HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast).plan(TILE); + + assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty()); + for (RiverOutlet outlet : tile.outlets()) { + assertEquals("outlet=" + outlet + " diagnostics=" + tile.diagnosticCandidates(), + HydrologyFeatureType.COASTAL_GROTTO, outlet.type()); + assertTrue(outlet.directOcean()); + assertEquals(settings.seaLevel(), outlet.connectionPoint().y()); + assertFalse(cliffCoast.sample(outlet.landwardPoint().x(), outlet.landwardPoint().z()).ocean()); + assertTrue(cliffCoast.sample(outlet.connectionPoint().x(), outlet.connectionPoint().z()).ocean()); + assertEquals(1L, outlet.landwardPoint().distanceSquared2D(outlet.connectionPoint())); + } + RiverCourse course = surfaceCourses(tile).stream() + .filter((RiverCourse candidate) -> candidate.segments().getLast().type() + == HydrologyFeatureType.COASTAL_GROTTO) + .findFirst() + .orElseThrow(); + HydraulicSegment grotto = course.segments().getLast(); + assertEquals(HydrologyFeatureType.COASTAL_GROTTO, grotto.type()); + assertEquals(settings.seaLevel(), grotto.upstreamHeadY()); + HydrologyColumnLayer center = layerForSegment( + tile.columnAt(grotto.start().x(), grotto.start().z()).orElseThrow(), + grotto.id() + ); + assertTrue(center.bedY() < settings.seaLevel()); + assertTrue(center.bedY() >= settings.seaLevel() + - settings.outlets().coastalGrotto().verticalRadius() * 2); + assertEquals(settings.seaLevel() + settings.outlets().coastalGrotto().headroom(), center.ceilingY()); + double maximumOwnedRadius = settings.outlets().coastalGrotto().horizontalRadius() + 0.25D; + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().segmentId() == grotto.id() && layer.terrainOwned()) { + assertTrue(StrictMath.hypot( + column.x() - grotto.start().x(), + column.z() - grotto.start().z() + ) <= maximumOwnedRadius); + } + } + } + } + + @Test + public void configuredSurfaceRiverContinuesThroughOneContainedSinkholeCourse() { + HydrologyPlannerSettings settings = standardSettings(0D, 0D, false, true, List.of()); + HydrologyTerrainSampler terrain = inlandTerrain(true, false); + HydrologyPlanner planner = new HydrologyPlanner(7012L, settings, terrain, solidCaveView()); + + HydrologyTile first = planner.plan(TILE); + planner.plan(new HydrologyTileKey(1, 0)); + HydrologyTile repeated = planner.plan(TILE); + + assertEquals(first, repeated); + assertEquals(first.diagnosticCandidates().toString(), 1, surfaceCourses(first).size()); + RiverCourse course = surfaceCourses(first).getFirst(); + assertTrue(course.surfaceSinkholeContinuation()); + RiverOutlet outlet = first.outlet(course.outletId().orElseThrow()).orElseThrow(); + assertEquals(HydrologyFeatureType.INLAND_GROTTO, outlet.type()); + assertFalse(outlet.directOcean()); + assertEquals(1L, outlet.landwardPoint().distanceSquared2D(outlet.connectionPoint())); + + HydraulicSegment sinkhole = course.segments().get(course.segments().size() - 2); + HydraulicSegment grotto = course.segments().getLast(); + HydraulicSegment approach = course.segments().get(course.segments().size() - 3); + assertEquals(HydrologyFeatureType.SINKHOLE, sinkhole.type()); + assertEquals(HydrologyFeatureType.INLAND_GROTTO, grotto.type()); + assertFalse(approach.type() == HydrologyFeatureType.SURFACE_POOL + && approach.centerline().size() == 1); + assertEquals(approach.end(), sinkhole.start()); + assertEquals(outlet.landwardPoint().x(), sinkhole.start().x()); + assertEquals(outlet.landwardPoint().z(), sinkhole.start().z()); + assertTrue(Math.abs(approach.width() - sinkhole.width()) <= 1); + assertEquals(course.id(), sinkhole.courseId()); + assertEquals(course.id(), grotto.courseId()); + assertTrue(sinkhole.drop() > 0); + assertFalse(sinkhole.fallingFluid()); + assertTrue(sinkhole.receivingPool()); + assertEquals(sinkhole.downstreamHeadY(), grotto.upstreamHeadY()); + + HydrologyCavePlan cavePlan = first.cavePlan(course.id()).orElseThrow(); + assertTrue(cavePlan.accepted()); + assertEquals(course.id(), cavePlan.source().sourceId()); + Set actions = new HashSet<>(cavePlan.actions().values()); + assertFalse(actions.contains(HydrologyCaveAction.FALLING_FLUID)); + assertTrue(actions.contains(HydrologyCaveAction.WET_SOURCE)); + assertTrue(actions.contains(HydrologyCaveAction.DRY_AIR)); + + HydrologyRenderSample render = first.renderAt(sinkhole.start().x(), sinkhole.start().z()); + assertTrue(render.hasFeature(HydrologyFeatureType.SINKHOLE)); + assertEquals(HydrologyFeatureType.SINKHOLE, render.primaryFeature().orElseThrow().type()); + HydrologyFeatureRef located = first.nearestFeature( + HydrologyFeatureType.SINKHOLE, + sinkhole.start().x(), + sinkhole.start().z(), + 0 + ).orElseThrow(); + assertEquals(course.id(), located.courseId()); + assertEquals(sinkhole.id(), located.segmentId()); + } + + @Test + public void disabledSurfaceSinkholesRejectSurfaceSourcesButKeepUndergroundSourcesEligible() { + HydrologyPlannerSettings base = standardSettings(0D, 1D, false, true, List.of()); + HydrologyPlannerSettings.Outlets configured = base.outlets(); + HydrologyPlannerSettings settings = withOutlets(base, new HydrologyPlannerSettings.Outlets( + configured.oceanEnabled(), + configured.coastalGrotto(), + configured.inlandGrotto(), + false, + configured.coastalCliffMinimumHeight(), + configured.mouthLevelingDistance(), + configured.maximumOceanApron(), + configured.maximumPerTile() + )); + + HydrologyTile tile = new HydrologyPlanner( + 7013L, + settings, + inlandTerrain(true, true), + solidCaveView() + ).plan(TILE); + + assertTrue(surfaceCourses(tile).isEmpty()); + assertFalse(courses(tile, RiverCourseType.UNDERGROUND).isEmpty()); + assertTrue(tile.diagnosticCandidates().stream().anyMatch( + (HydrologyDiagnosticCandidate candidate) -> candidate.projectedType() == HydrologyFeatureType.SURFACE_POOL + && candidate.rejection() == HydrologyCandidateRejection.NO_LEGAL_OUTLET + )); + assertTrue(courses(tile, RiverCourseType.UNDERGROUND).stream().allMatch( + (RiverCourse course) -> course.segments().getLast().type() == HydrologyFeatureType.INLAND_GROTTO + )); + } + + @Test + public void directOceanOutletsTakePriorityOverConfiguredSurfaceSinkholes() { + HydrologyPlannerSettings settings = standardSettings(2D, 0D, true, true, List.of()); + + HydrologyTile tile = new HydrologyPlanner(7014L, settings, rollingCoast(112)).plan(TILE); + + assertFalse(surfaceCourses(tile).isEmpty()); + assertTrue(tile.outlets().stream().allMatch(RiverOutlet::directOcean)); + assertFalse(hasAny(tile, HydrologyFeatureType.SINKHOLE, HydrologyFeatureType.INLAND_GROTTO)); + } + + @Test + public void rejectedOceanCourseFallsBackToOneContainedSurfaceSinkhole() { + HydrologyPlannerSettings base = standardSettings(1D, 0D, true, true, List.of()); + 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(), + surface.minimumSurfaceInset(), + surface.maximumSurfaceInset(), + surface.maximumIncision(), + surface.shoreWidth(), + surface.minimumTerrainBlendWidth(), + surface.maximumTerrainBlendWidth(), + surface.ridgeTunnelsEnabled(), + 32, + surface.ridgeTunnelHeadroom() + ), + base.hydraulics(), + base.underground(), + base.outlets(), + base.geometry(), + base.deepFluids() + ); + HydrologyTerrainSampler terrain = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + boolean source = x == 0 && z == 0; + int height = 120 + Math.floorDiv(Math.abs(x) + Math.abs(z - 96), 16); + if (x >= 32 && x <= 96) { + height += 64; + } + return terrain(height, 1D, false, true, source, source, false, false); + }; + + HydrologyTile tile = new HydrologyPlanner(70142L, settings, terrain, solidCaveView()).plan(TILE); + + assertEquals(tile.diagnosticCandidates().toString(), 1, surfaceCourses(tile).size()); + RiverCourse course = surfaceCourses(tile).getFirst(); + RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); + assertFalse(outlet.directOcean()); + assertTrue(course.surfaceSinkholeContinuation()); + assertTrue(course.segments().stream().noneMatch(HydraulicSegment::fallingFluid)); + assertTrue(tile.diagnosticCandidates().stream().anyMatch( + (HydrologyDiagnosticCandidate candidate) -> + candidate.rejection() == HydrologyCandidateRejection.SURFACE_RIDGE_TUNNEL_LIMIT + )); + } + + @Test + public void inlandOutletsServeOnlyComponentsWithoutAValidOceanOutlet() { + HydrologyPlannerSettings settings = standardSettings(2D, 0D, true, true, List.of()); + HydrologyTerrainSampler terrain = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + if (x == 48) { + return blockedTerrain(); + } + boolean source = (x == 0 || x == 80) && z == 0; + return terrain(120 - Math.floorDiv(x, 16), 1D, false, true, source, source, false, false); + }; + + HydrologyTile tile = new HydrologyPlanner(70140L, settings, terrain, solidCaveView()).plan(TILE); + + assertTrue( + tile.diagnosticCandidates().toString(), + tile.outlets().stream().anyMatch((RiverOutlet outlet) -> !outlet.directOcean()) + ); + RiverCourse course = surfaceCourses(tile).stream() + .filter((RiverCourse candidate) -> tile.outlet(candidate.outletId().orElseThrow()) + .map((RiverOutlet candidateOutlet) -> !candidateOutlet.directOcean()) + .orElse(false)) + .findFirst() + .orElseThrow(); + RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); + assertFalse(outlet.directOcean()); + assertTrue(hasAny(tile, HydrologyFeatureType.SINKHOLE, HydrologyFeatureType.INLAND_GROTTO)); + } + + @Test + public void sourceAdmissionRepresentsAConfiguredInlandDrainageComponent() { + HydrologyPlannerSettings settings = standardSettings(1D, 0D, true, true, List.of()); + HydrologyTerrainSampler terrain = (int x, int z) -> { + if (x >= 112) { + return oceanTerrain(); + } + if (x == 48) { + return blockedTerrain(); + } + boolean source = (x == 0 || x == 80) && z == 0; + int height = x < 48 ? 90 : 130 - Math.floorDiv(x, 16); + return terrain(height, 1D, false, true, source, false, false, false); + }; + + HydrologyTile tile = new HydrologyPlanner(70141L, settings, terrain, solidCaveView()).plan(TILE); + + assertEquals(1, surfaceCourses(tile).size()); + RiverCourse course = surfaceCourses(tile).getFirst(); + RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); + assertFalse( + "outlet=" + outlet.type() + " source=" + course.sourceNodeId() + + " diagnostics=" + tile.diagnosticCandidates(), + outlet.directOcean() + ); + assertTrue(hasAny(tile, HydrologyFeatureType.SINKHOLE, HydrologyFeatureType.INLAND_GROTTO)); + } + + @Test + public void oneSinkholeContainmentHazardRejectsTheConflictingCandidateTransaction() { + HydrologyPlannerSettings settings = standardSettings(0D, 0D, false, true, List.of()); + HydrologyTerrainSampler terrain = inlandTerrain(true, false); + HydrologyTile accepted = new HydrologyPlanner(7015L, settings, terrain, solidCaveView()).plan(TILE); + RiverCourse acceptedCourse = surfaceCourses(accepted).getFirst(); + HydrologyCavePlan acceptedPlan = accepted.cavePlan(acceptedCourse.id()).orElseThrow(); + CavePosition hazard = acceptedPlan.actions().entrySet().stream() + .filter((Map.Entry entry) -> + entry.getValue() == HydrologyCaveAction.WET_SOURCE) + .map(Map.Entry::getKey) + .findFirst() + .orElseThrow(); + + HydrologyTile rejected = new HydrologyPlanner( + 7015L, + settings, + terrain, + selectiveCaveView(Map.of(hazard, CaveVoxel.INCOMPATIBLE_FLUID)) + ).plan(TILE); + + assertTrue(surfaceCourses(rejected).stream().noneMatch(acceptedCourse::equals)); + assertTrue(rejected.cavePlans().stream().noneMatch( + (HydrologyCavePlan plan) -> plan.actions().containsKey(hazard) + )); + assertTrue(rejected.diagnosticCandidates().stream().anyMatch( + (HydrologyDiagnosticCandidate candidate) -> + candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT + )); + } + + @Test + public void receivingSinkholePoolSealsGeneratedSurfaceConnectedCarving() { + HydrologyPlannerSettings settings = standardSettings(0D, 0D, false, true, List.of()); + HydrologyTerrainSampler terrain = inlandTerrain(true, false); + HydrologyTile accepted = new HydrologyPlanner(7016L, settings, terrain, solidCaveView()).plan(TILE); + RiverCourse course = surfaceCourses(accepted).getFirst(); + HydraulicSegment sinkhole = course.segments().get(course.segments().size() - 2); + HydraulicSegment grotto = course.segments().getLast(); + CavePosition surfaceLip = new CavePosition( + sinkhole.start().x(), + sinkhole.upstreamHeadY(), + sinkhole.start().z() + ); + CavePosition exposedPool = new CavePosition( + grotto.start().x(), + grotto.upstreamHeadY(), + grotto.start().z() + ); + assertEquals(HydrologyCaveAction.WET_SOURCE, + accepted.cavePlan(course.id()).orElseThrow().actions().get(exposedPool)); + + HydrologyTile intentionalOpening = new HydrologyPlanner( + 7016L, + settings, + terrain, + caveView(Map.of(), Set.of(surfaceLip)) + ).plan(TILE); + assertEquals(1, surfaceCourses(intentionalOpening).size()); + + HydrologyTile sealed = new HydrologyPlanner( + 7016L, + settings, + terrain, + caveView(Map.of(), Set.of(exposedPool)) + ).plan(TILE); + + assertEquals(1, surfaceCourses(sealed).size()); + RiverCourse sealedCourse = surfaceCourses(sealed).getFirst(); + assertTrue(sealed.cavePlan(sealedCourse.id()).orElseThrow() + .baselinePreconditions().get(exposedPool).openToSurface()); + } + + @Test + public void containedDeepPoolsUseIndependentHeightAndBoundedEllipsoidFootprints() { + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "deep_lava", + true, + 4D, + 32, + -180, + -120, + 8, + 8, + 3, + 3, + 8, + 24, + 3, + 2, + 5, + 2048, + 3, + true, + true + ); + HydrologyPlannerSettings settings = standardSettings(0D, 0D, false, false, List.of(deepFluid)); + HydrologyTerrainSampler highCaves = (int x, int z) -> deepTerrain(70); + HydrologyTerrainSampler lowCaves = (int x, int z) -> deepTerrain(-40); + HydrologyTile high = new HydrologyPlanner(1991L, settings, highCaves).plan(TILE); + HydrologyTile low = new HydrologyPlanner(1991L, settings, lowCaves).plan(TILE); + + assertEquals(high.courses(), low.courses()); + List courses = courses(high, RiverCourseType.DEEP_FLUID); + assertFalse(courses.isEmpty()); + for (RiverCourse course : courses) { + HydraulicSegment pool = course.segments().getFirst(); + assertEquals(HydrologyFeatureType.DEEP_POOL, pool.type()); + assertTrue(course.segments().stream().anyMatch( + (HydraulicSegment segment) -> segment.type() == HydrologyFeatureType.DEEP_CHANNEL)); + assertTrue(pool.upstreamHeadY() >= deepFluid.minimumY()); + assertTrue(pool.upstreamHeadY() <= deepFluid.maximumY()); + int radius = pool.width() / 2; + HydrologyColumnLayer center = layerForSegment( + high.columnAt(pool.start().x(), pool.start().z()).orElseThrow(), + pool.id() + ); + HydrologyColumnLayer edge = null; + int direction = pool.start().x() + radius < settings.routing().tileSize() ? 1 : -1; + for (int distance = radius; distance >= 1 && edge == null; distance--) { + HydrologyColumnSample sample = high.columnAt( + pool.start().x() + direction * distance, + pool.start().z() + ).orElse(null); + if (sample != null) { + edge = layerForSegmentOrNull(sample, pool.id()); + } + } + assertNotNull(edge); + assertTrue(center.bedY() < edge.bedY()); + assertTrue(center.ceilingY() > edge.ceilingY()); + assertEquals("deep_lava", center.profileKey()); + assertEquals("flooded", center.biomeKey()); + assertTrue(hasRotationalAsymmetry(high, pool, radius)); + long poolVolume = 0L; + long courseVolume = 0L; + for (HydrologyColumnSample column : high.footprint().columns().values()) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().segmentId() == pool.id()) { + poolVolume += layer.ceilingY() - layer.bedY() + 1L; + } + if (layer.feature().courseId() == course.id()) { + courseVolume += layer.ceilingY() - layer.bedY() + 1L; + } + } + } + assertTrue(poolVolume <= deepFluid.maximumVolume()); + assertTrue(courseVolume <= deepFluid.maximumVolume()); + } + } + + @Test + public void identicalInputsProduceEqualPlansAcrossRequestOrder() { + HydrologyPlanner planner = new HydrologyPlanner( + 99121L, + standardSettings(3D, 2D, true, false, List.of()), + rollingCoast(112) + ); + + HydrologyTile firstZero = planner.plan(new HydrologyTileKey(0, 0)); + HydrologyTile firstOne = planner.plan(new HydrologyTileKey(1, 0)); + HydrologyTile secondOne = planner.plan(new HydrologyTileKey(1, 0)); + HydrologyTile secondZero = planner.plan(new HydrologyTileKey(0, 0)); + + assertEquals(firstZero, secondZero); + assertEquals(firstOne, secondOne); + } + + private HydrologyPlanner planner( + long seed, + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler + ) { + return new HydrologyPlanner(seed, settings, sampler); + } + + private boolean containsEdge(RiverCourse course, long edgeId) { + for (DrainageEdge edge : course.drainageEdges()) { + if (edge.id() == edgeId) { + return true; + } + } + return false; + } + + private boolean containsSurfaceEdge(HydrologyTile tile, long edgeId) { + for (RiverCourse course : surfaceCourses(tile)) { + if (containsEdge(course, edgeId)) { + return true; + } + } + return false; + } + + private double segmentLength(HydraulicSegment segment) { + double length = 0D; + for (int pointIndex = 1; pointIndex < segment.centerline().size(); pointIndex++) { + HydrologyPoint previous = segment.centerline().get(pointIndex - 1); + HydrologyPoint current = segment.centerline().get(pointIndex); + length += StrictMath.hypot(current.x() - previous.x(), current.z() - previous.z()); + } + return length; + } + + private int tieredTransitionHeight(int distance) { + if (distance < 32) { + return 132; + } + if (distance < 48) { + return 124; + } + if (distance < 64) { + return 120; + } + return distance < 80 ? 106 : 98; + } + + private HydrologyPlannerSettings standardSettings( + double surfaceDensity, + double undergroundDensity, + boolean oceanEnabled, + boolean inlandEnabled, + List deepFluids + ) { + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( + true, + surfaceDensity, + 80, + 0, + 6, + 24 + ); + HydrologyPlannerSettings.Source undergroundSources = new HydrologyPlannerSettings.Source( + true, + undergroundDensity, + Integer.MIN_VALUE, + 0, + 4, + 32 + ); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing(128, 16, 4, 512, 256, + new HydrologyPlannerSettings.Branching(0, 0), 0.5D, 12D, 0.5D, 0.1D), + new HydrologyPlannerSettings.Surface( + surfaceDensity > 0D || surfaceSources.maximumPerTile() > 0, + surfaceSources, + 4, + 18, + 2, + 4, + 4, + 8, + 10, + 1.5D, + 4, + 8, + true, + 64, + 8 + ), + new HydrologyPlannerSettings.Hydraulics(12, 28, 1, 3, 8, 4), + new HydrologyPlannerSettings.Underground( + undergroundDensity > 0D, + undergroundSources, + 68, + 82, + 4, + 12, + 2, + 4, + 5, + 9, + true + ), + new HydrologyPlannerSettings.Outlets( + oceanEnabled, + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + new HydrologyPlannerSettings.Grotto(inlandEnabled, 4, 3, 3, 4096), + inlandEnabled, + 12, + 32, + 2, + 4 + ), + stableGeometry(), + deepFluids + ); + } + + private HydrologyPlannerSettings.Geometry stableGeometry() { + HydrologyPlannerSettings.ChannelShape stableChannel = + new HydrologyPlannerSettings.ChannelShape(2D, 0D, 0D, 11); + return new HydrologyPlannerSettings.Geometry( + new HydrologyPlannerSettings.Meanders(224, 72, 0D, 0D, 0D, 0, 75D), + stableChannel, + stableChannel, + stableChannel, + HydrologyPlannerSettings.Geometry.defaults().drops() + ); + } + + private HydrologyPlannerSettings organicShapeSettings() { + HydrologyPlannerSettings base = standardSettings(8D, 0D, true, false, List.of()); + HydrologyPlannerSettings.Surface surface = base.surface(); + HydrologyPlannerSettings.Outlets outlets = base.outlets(); + return new HydrologyPlannerSettings( + base.seaLevel(), + new HydrologyPlannerSettings.Routing( + 512, + 64, + 4, + 1024, + 2048, + new HydrologyPlannerSettings.Branching(64, 32), + 1.5D, + 24D, + 2D, + 0.2D + ), + new HydrologyPlannerSettings.Surface( + true, + new HydrologyPlannerSettings.Source(true, 8D, 80, 0, 8, 128), + surface.minimumWidth(), + surface.maximumWidth(), + surface.minimumDepth(), + surface.maximumDepth(), + surface.minimumSurfaceInset(), + surface.maximumSurfaceInset(), + 96, + surface.shoreWidth(), + surface.minimumTerrainBlendWidth(), + surface.maximumTerrainBlendWidth(), + surface.ridgeTunnelsEnabled(), + 192, + surface.ridgeTunnelHeadroom() + ), + new HydrologyPlannerSettings.Hydraulics(12, 28, 1, 4, 12, 5), + base.underground(), + new HydrologyPlannerSettings.Outlets( + true, + outlets.coastalGrotto(), + outlets.inlandGrotto(), + outlets.surfaceSinkholesEnabled(), + outlets.coastalCliffMinimumHeight(), + 48, + outlets.maximumOceanApron(), + 1 + ), + HydrologyPlannerSettings.Geometry.defaults(), + List.of() + ); + } + + private HydrologyTerrainSampler organicShapeTerrain() { + return (int x, int z) -> { + if (x >= 448) { + return oceanTerrain(); + } + int height = 110 + - Math.floorDiv(x, 10) + + Math.floorDiv(StrictMath.abs(z - 256), 32); + boolean source = x >= 0 && x < 320; + return terrain(height, 1D, false, false, source, false, false, false); + }; + } + + private HydrologyTerrainSampler rollingCoast(int coastX) { + return (int x, int z) -> { + if (x >= coastX) { + return oceanTerrain(); + } + int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D); + return terrain(height, 1D, false, true, x >= 0 && x <= 24, true, true, false); + }; + } + + private HydrologyTerrainSampler optionalRollingCoast(int coastX) { + return (int x, int z) -> { + if (x >= coastX) { + return oceanTerrain(); + } + int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D); + return terrain(height, 1D, false, true, x >= 0 && x <= 24, false, true, false); + }; + } + + private HydrologyTerrainSampler inlandTerrain(boolean surfaceSource, boolean undergroundSource) { + return (int x, int z) -> { + boolean source = x == 0 && z == 0; + boolean outlet = x == 64 && z == 64; + int height = 120 - Math.floorDiv(x + z, 16); + return new HydrologyTerrainSample( + height, + 1D, + false, + true, + 72, + 74, + true, + outlet, + surfaceSource && source, + surfaceSource && source, + undergroundSource && source, + undergroundSource && source, + 0D, + surfaceSource && source ? 1D : 0D, + undergroundSource && source ? 1D : 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha") + ); + }; + } + + private CaveVoxelView solidCaveView() { + return caveView(Map.of(), Set.of()); + } + + private CaveVoxelView selectiveCaveView(Map voxels) { + return caveView(voxels, Set.of()); + } + + private CaveVoxelView caveView(Map voxels, Set surfaceOpenings) { + return new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > -4096 && position.y() < 4096; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return voxels.getOrDefault(position, CaveVoxel.SOLID); + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return surfaceOpenings.contains(position); + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return false; + } + }; + } + + private HydrologyTerrainSample oceanTerrain() { + return new HydrologyTerrainSample( + 54, + 0D, + true, + false, + 30, + 32, + false, + false, + false, + false, + false, + false, + 0D, + 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + "ocean_parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha", "beta") + ); + } + + private HydrologyTerrainSample blockedTerrain() { + return new HydrologyTerrainSample( + 100, + 0D, + false, + false, + 72, + 74, + false, + false, + false, + false, + false, + false, + 0D, + 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha") + ); + } + + private HydrologyTerrainSample terrain( + int height, + double slope, + boolean ocean, + boolean cave, + boolean surfaceSource, + boolean requiredSurface, + boolean undergroundSource, + boolean requiredUnderground + ) { + return new HydrologyTerrainSample( + height, + slope, + ocean, + cave, + 72, + 74, + !ocean, + !ocean, + surfaceSource, + requiredSurface, + undergroundSource, + requiredUnderground, + 0D, + surfaceSource ? 1D : 0D, + undergroundSource ? 1D : 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("beta", "alpha") + ); + } + + private HydrologyTerrainSample deepTerrain(int caveFloorY) { + return new HydrologyTerrainSample( + 90, + 0D, + false, + true, + caveFloorY, + 74, + true, + false, + false, + false, + false, + false, + 0D, + 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha") + ); + } + + private HydrologyTerrainSample undergroundTerrain(int height, int caveFluidY, boolean source) { + return new HydrologyTerrainSample( + height, + 1D, + false, + true, + caveFluidY - 4, + caveFluidY, + true, + true, + false, + false, + source, + source, + 0D, + 0D, + source ? 1D : 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha", "beta") + ); + } + + private HydrologyPlannerSettings withSurfaceSources( + HydrologyPlannerSettings settings, + HydrologyPlannerSettings.Source sources + ) { + HydrologyPlannerSettings.Surface surface = settings.surface(); + return new HydrologyPlannerSettings( + settings.seaLevel(), + settings.routing(), + new HydrologyPlannerSettings.Surface( + true, + sources, + surface.minimumWidth(), + surface.maximumWidth(), + surface.minimumDepth(), + surface.maximumDepth(), + surface.minimumSurfaceInset(), + surface.maximumSurfaceInset(), + surface.maximumIncision(), + surface.shoreWidth(), + surface.minimumTerrainBlendWidth(), + surface.maximumTerrainBlendWidth(), + surface.ridgeTunnelsEnabled(), + surface.maximumRidgeTunnelLength(), + surface.ridgeTunnelHeadroom() + ), + settings.hydraulics(), + settings.underground(), + settings.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + settings.deepFluids() + ); + } + + private HydrologyPlannerSettings withUndergroundSources( + HydrologyPlannerSettings settings, + HydrologyPlannerSettings.Source sources + ) { + HydrologyPlannerSettings.Underground underground = settings.underground(); + return new HydrologyPlannerSettings( + settings.seaLevel(), + settings.routing(), + settings.surface(), + settings.hydraulics(), + new HydrologyPlannerSettings.Underground( + true, + sources, + underground.minimumFluidY(), + underground.maximumFluidY(), + underground.minimumWidth(), + underground.maximumWidth(), + underground.minimumDepth(), + underground.maximumDepth(), + underground.minimumHeadroom(), + underground.maximumHeadroom(), + underground.connectToExistingCaves() + ), + settings.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + settings.deepFluids() + ); + } + + private HydrologyPlannerSettings withUndergroundFluidRange( + HydrologyPlannerSettings settings, + int minimumFluidY, + int maximumFluidY + ) { + HydrologyPlannerSettings.Underground underground = settings.underground(); + return new HydrologyPlannerSettings( + settings.seaLevel(), + settings.routing(), + settings.surface(), + settings.hydraulics(), + new HydrologyPlannerSettings.Underground( + underground.enabled(), + underground.sources(), + minimumFluidY, + maximumFluidY, + underground.minimumWidth(), + underground.maximumWidth(), + underground.minimumDepth(), + underground.maximumDepth(), + underground.minimumHeadroom(), + underground.maximumHeadroom(), + underground.connectToExistingCaves() + ), + settings.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + settings.deepFluids() + ); + } + + private HydrologyPlannerSettings withUndergroundWidth( + HydrologyPlannerSettings settings, + int width + ) { + HydrologyPlannerSettings.Underground underground = settings.underground(); + return new HydrologyPlannerSettings( + settings.seaLevel(), + settings.routing(), + settings.surface(), + settings.hydraulics(), + new HydrologyPlannerSettings.Underground( + underground.enabled(), + underground.sources(), + underground.minimumFluidY(), + underground.maximumFluidY(), + width, + width, + underground.minimumDepth(), + underground.maximumDepth(), + underground.minimumHeadroom(), + underground.maximumHeadroom(), + underground.connectToExistingCaves() + ), + settings.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + settings.deepFluids() + ); + } + + private HydrologyPlanner plannerWithRoutingSampler( + long seed, + HydrologyPlannerSettings settings, + HydrologyTerrainSampler terrain, + AtomicInteger samples + ) { + HydrologyTerrainSampler countingTerrain = (int x, int z) -> { + samples.incrementAndGet(); + return terrain.sample(x, z); + }; + HydrologyRoutingTerrainSampler routingTerrain = new HydrologyRoutingTerrainSampler() { + @Override + public HydrologyTerrainSample[] sampleGrid(GridRequest request) { + HydrologyTerrainSample[] grid = new HydrologyTerrainSample[Math.multiplyExact( + request.width(), + request.width() + )]; + for (int gridZ = 0; gridZ < request.width(); gridZ++) { + int z = request.minimumZ() + gridZ * request.spacing(); + for (int gridX = 0; gridX < request.width(); gridX++) { + int x = request.minimumX() + gridX * request.spacing(); + grid[gridZ * request.width() + gridX] = terrain.sample(x, z); + } + } + return grid; + } + + @Override + public NaturalClassification classifyNatural(int blockX, int blockZ) { + HydrologyTerrainSample sample = terrain.sample(blockX, blockZ); + return sample.ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND; + } + }; + return new HydrologyPlanner( + seed, + settings, + countingTerrain, + routingTerrain, + request -> request.minimum(), + -4096, + footprint -> solidCaveView() + ); + } + + private HydrologyPlannerSettings withOutlets( + HydrologyPlannerSettings settings, + HydrologyPlannerSettings.Outlets outlets + ) { + return new HydrologyPlannerSettings( + settings.seaLevel(), + settings.routing(), + settings.surface(), + settings.hydraulics(), + settings.underground(), + outlets, + HydrologyPlannerSettings.Geometry.defaults(), + settings.deepFluids() + ); + } + + private HydrologyColumnLayer layerForSegment(HydrologyColumnSample column, long segmentId) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().segmentId() == segmentId) { + return layer; + } + } + throw new AssertionError("Missing footprint layer for segment " + segmentId + "."); + } + + private HydrologyColumnLayer layerForSegmentOrNull(HydrologyColumnSample column, long segmentId) { + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().segmentId() == segmentId) { + return layer; + } + } + return null; + } + + private boolean hasNonCollinearInterior(List points) { + if (points.size() < 3) { + return false; + } + HydrologyPoint start = points.getFirst(); + HydrologyPoint end = points.getLast(); + long deltaX = end.x() - start.x(); + long deltaZ = end.z() - start.z(); + for (int index = 1; index < points.size() - 1; index++) { + HydrologyPoint point = points.get(index); + long localX = point.x() - start.x(); + long localZ = point.z() - start.z(); + if (deltaX * localZ - deltaZ * localX != 0L) { + return true; + } + } + return false; + } + + private double maximumChordDeviationRatio(List points) { + if (points.size() < 3) { + return 0D; + } + HydrologyPoint start = points.getFirst(); + HydrologyPoint end = points.getLast(); + double chordX = end.x() - start.x(); + double chordZ = end.z() - start.z(); + double chordLength = StrictMath.hypot(chordX, chordZ); + if (chordLength == 0D) { + return 0D; + } + double maximumDeviation = 0D; + for (int index = 1; index < points.size() - 1; index++) { + HydrologyPoint point = points.get(index); + double localX = point.x() - start.x(); + double localZ = point.z() - start.z(); + double deviation = StrictMath.abs(chordX * localZ - chordZ * localX) / chordLength; + maximumDeviation = Math.max(maximumDeviation, deviation); + } + return maximumDeviation / chordLength; + } + + private double maximumQuantizedStickLength(List points) { + int previousDirection = 0; + double currentLength = 0D; + double maximumLength = 0D; + for (int pointIndex = 1; pointIndex < points.size(); pointIndex++) { + HydrologyPoint previous = points.get(pointIndex - 1); + HydrologyPoint point = points.get(pointIndex); + int direction = quantizedStickDirection(point.x() - previous.x(), point.z() - previous.z()); + double length = StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); + if (direction == 0) { + previousDirection = 0; + currentLength = 0D; + continue; + } + currentLength = direction == previousDirection ? currentLength + length : length; + previousDirection = direction; + maximumLength = Math.max(maximumLength, currentLength); + } + return maximumLength; + } + + private int quantizedStickDirection(int deltaX, int deltaZ) { + if (deltaX == 0 && deltaZ != 0) { + return deltaZ > 0 ? 1 : 2; + } + if (deltaZ == 0 && deltaX != 0) { + return deltaX > 0 ? 3 : 4; + } + if (Math.abs(deltaX) != Math.abs(deltaZ) || deltaX == 0) { + return 0; + } + if (deltaX > 0) { + return deltaZ > 0 ? 5 : 6; + } + return deltaZ > 0 ? 7 : 8; + } + + private boolean hasRotationalAsymmetry( + HydrologyTile tile, + HydraulicSegment segment, + int radius + ) { + for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) { + for (int deltaX = -radius; deltaX <= radius; deltaX++) { + boolean present = segmentPresent(tile, segment, deltaX, deltaZ); + boolean rotated = segmentPresent(tile, segment, -deltaZ, deltaX); + if (present != rotated) { + return true; + } + } + } + return false; + } + + private boolean segmentPresent( + HydrologyTile tile, + HydraulicSegment segment, + int deltaX, + int deltaZ + ) { + HydrologyColumnSample column = tile.columnAt( + segment.start().x() + deltaX, + segment.start().z() + deltaZ + ).orElse(null); + return column != null && layerForSegmentOrNull(column, segment.id()) != null; + } + + private boolean hasDownstreamReceivingDrop(HydrologyTile tile, HydraulicSegment expected) { + for (RiverCourse course : tile.courses()) { + if (course.id() != expected.courseId()) { + continue; + } + boolean downstream = false; + for (HydraulicSegment segment : course.segments()) { + if (downstream && segment.drop() > 0 && segment.receivingPool()) { + return true; + } + downstream |= segment.id() == expected.id(); + } + } + return false; + } + + private HydraulicSegment segment(HydrologyTile tile, long segmentId) { + for (RiverCourse course : tile.courses()) { + for (HydraulicSegment segment : course.segments()) { + if (segment.id() == segmentId) { + return segment; + } + } + } + throw new AssertionError("Missing hydraulic segment " + segmentId + "."); + } + + private double distanceToCenterline(HydrologyPoint point, List centerline) { + double minimum = Double.POSITIVE_INFINITY; + for (HydrologyPoint center : centerline) { + minimum = Math.min(minimum, StrictMath.hypot(point.x() - center.x(), point.z() - center.z())); + } + return minimum; + } + + private List surfaceCourses(HydrologyTile tile) { + return courses(tile, RiverCourseType.SURFACE); + } + + private List courses(HydrologyTile tile, RiverCourseType type) { + ArrayList selected = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == type) { + selected.add(course); + } + } + return List.copyOf(selected); + } + + private boolean hasAny(HydrologyTile tile, HydrologyFeatureType... types) { + Set expected = new HashSet<>(List.of(types)); + for (RiverCourse course : tile.courses()) { + for (HydraulicSegment segment : course.segments()) { + if (expected.contains(segment.type())) { + return true; + } + } + } + return false; + } + + private List segments(HydrologyTile tile, HydrologyFeatureType type) { + ArrayList selected = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + for (HydraulicSegment segment : course.segments()) { + if (segment.type() == type) { + selected.add(segment); + } + } + } + return List.copyOf(selected); + } + + private int firstSegment(RiverCourse course, HydrologyFeatureType... types) { + Set expected = new HashSet<>(List.of(types)); + for (int index = 0; index < course.segments().size(); index++) { + if (expected.contains(course.segments().get(index).type())) { + return index; + } + } + return -1; + } + + private boolean bruteForceUndergroundHeads( + int[] minimum, + int[] maximum, + int outlet, + int index, + int upstreamHead + ) { + if (index == minimum.length - 1) { + return outlet >= minimum[index] + && outlet <= maximum[index] + && outlet <= upstreamHead; + } + int upper = Math.min(maximum[index], upstreamHead); + for (int head = minimum[index]; head <= upper; head++) { + if (bruteForceUndergroundHeads(minimum, maximum, outlet, index + 1, head)) { + return true; + } + } + return false; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java new file mode 100644 index 000000000..adeb5844d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java @@ -0,0 +1,410 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class HydrologyStyledGeometryTest { + private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + + @Test + public void nonFlatGeometryIsResolvedIntoAcceptedPlansDeterministically() { + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "deep_water", + true, + 2D, + 32, + 20, + 40, + 3, + 3, + 2, + 2, + 8, + 16, + 3, + 2, + 3, + 8192, + 2, + false, + true + ); + HydrologyPlannerSettings surfaceSettings = settings(2D, 0D, List.of(deepFluid)); + HydrologyPlannerSettings undergroundSettings = settings(0D, 2D, List.of()); + HydrologyTerrainSampler terrain = this::terrain; + HashSet sampledFields = new HashSet<>(); + HydrologyGeometrySampler geometry = request -> { + sampledFields.add(request.field()); + return styledValue(request); + }; + HydrologyPlanner surfacePlanner = planner(7711L, surfaceSettings, terrain, geometry); + HydrologyPlanner undergroundPlanner = planner(7711L, undergroundSettings, terrain, geometry); + + HydrologyTile first = surfacePlanner.plan(TILE); + HydrologyTile repeated = surfacePlanner.plan(TILE); + HydrologyTile undergroundFirst = undergroundPlanner.plan(TILE); + HydrologyTile undergroundRepeated = undergroundPlanner.plan(TILE); + + assertEquals(first, repeated); + assertEquals(undergroundFirst, undergroundRepeated); + assertEquals(Set.of(HydrologyGeometrySampler.Field.values()), sampledFields); + + RiverCourse surface = courses(first, RiverCourseType.SURFACE).getFirst(); + DrainageNode surfaceSource = first.node(surface.sourceNodeId().orElseThrow()).orElseThrow(); + HydraulicSegment surfaceStart = surface.segments().getFirst(); + int surfaceDischarge = Math.max(1, surface.drainageEdges().getFirst().contributingSurfaceSources()); + int maximumSurfaceWidth = surfaceSettings.surface().maximumWidth() + + (surfaceStart.receivingPool() ? 2 : 0); + int maximumSurfaceDepth = surfaceSettings.surface().maximumDepth() + + (surfaceStart.receivingPool() ? 1 : 0); + assertTrue(surfaceStart.width() >= surfaceSettings.surface().minimumWidth()); + assertTrue(surfaceStart.width() <= maximumSurfaceWidth); + assertTrue(surfaceStart.depth() >= surfaceSettings.surface().minimumDepth()); + assertTrue(surfaceStart.depth() <= maximumSurfaceDepth); + assertTrue( + surfaceStart.upstreamHeadY() <= + terrain(surfaceSource.naturalPoint().x(), surfaceSource.naturalPoint().z()).naturalHeight() + - surfaceSettings.surface().maximumSurfaceInset() + ); + int styledSurfaceWidth = scaledDimension( + styledValue(request( + HydrologyGeometrySampler.Field.SURFACE_WIDTH, + surface.profileKey(), + surfaceSource.naturalPoint(), + surfaceSettings.surface().minimumWidth(), + surfaceSettings.surface().maximumWidth() + )), + surfaceSettings.surface().maximumWidth(), + surfaceDischarge + ); + assertTrue(surfaceStart.width() >= styledSurfaceWidth + || surfaceStart.fallingFluid() + || surfaceStart.receivingPool()); + + RiverCourse underground = courses(undergroundFirst, RiverCourseType.UNDERGROUND).getFirst(); + DrainageNode undergroundSource = undergroundFirst.node(underground.sourceNodeId().orElseThrow()).orElseThrow(); + HydraulicSegment undergroundStart = underground.segments().getFirst(); + assertEquals(undergroundSettings.underground().maximumFluidY(), undergroundStart.upstreamHeadY()); + int expectedUndergroundWidth = undergroundStart.type().isDrop() + ? undergroundSettings.geometry().drops().flowWidth(undergroundSettings.underground().maximumWidth()) + : undergroundSettings.underground().maximumWidth(); + int expectedUndergroundDepth = undergroundStart.type().isDrop() + ? undergroundSettings.geometry().drops().flowDepth(undergroundSettings.underground().maximumDepth()) + : undergroundSettings.underground().maximumDepth(); + assertEquals(expectedUndergroundWidth, undergroundStart.width()); + assertEquals(expectedUndergroundDepth, undergroundStart.depth()); + assertEquals( + styledValue(request( + HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL, + underground.profileKey(), + undergroundSource.naturalPoint(), + undergroundSettings.underground().minimumFluidY(), + undergroundSettings.underground().maximumFluidY() + )), + undergroundStart.upstreamHeadY() + ); + + List deepCourses = courses(first, RiverCourseType.DEEP_FLUID); + assertFalse(deepCourses.isEmpty()); + for (RiverCourse course : deepCourses) { + HydraulicSegment segment = course.segments().getFirst(); + assertEquals( + styledValue(request( + HydrologyGeometrySampler.Field.DEEP_FLUID_HEIGHT, + course.profileKey(), + segment.start(), + deepFluid.minimumY(), + deepFluid.maximumY() + )), + segment.upstreamHeadY() + ); + } + } + + @Test + public void poolStylingCannotManufactureUnsupportedTransitions() { + HydrologyPlannerSettings settings = settings(1D, 0D, List.of()); + HydrologyTerrainSampler terrain = this::terrain; + HydrologyGeometrySampler shortPools = request -> request.field() + == HydrologyGeometrySampler.Field.TARGET_POOL_LENGTH + ? request.minimum() + : styledValue(request); + HydrologyGeometrySampler longPools = request -> request.field() + == HydrologyGeometrySampler.Field.TARGET_POOL_LENGTH + ? request.maximum() + : styledValue(request); + + HydrologyTile shortFirst = planner(8811L, settings, terrain, shortPools).plan(TILE); + HydrologyTile shortRepeated = planner(8811L, settings, terrain, shortPools).plan(TILE); + HydrologyTile longFirst = planner(8811L, settings, terrain, longPools).plan(TILE); + HydrologyTile longRepeated = planner(8811L, settings, terrain, longPools).plan(TILE); + + assertEquals(shortFirst, shortRepeated); + assertEquals(longFirst, longRepeated); + List shortHeads = heads(courses(shortFirst, RiverCourseType.SURFACE).getFirst()); + List longHeads = heads(courses(longFirst, RiverCourseType.SURFACE).getFirst()); + assertEquals(shortHeads.getFirst(), longHeads.getFirst()); + assertEquals(shortHeads.getLast(), longHeads.getLast()); + assertNonIncreasing(shortHeads); + assertNonIncreasing(longHeads); + assertSurfaceDropsTerrainBacked(shortFirst, terrain); + assertSurfaceDropsTerrainBacked(longFirst, terrain); + } + + private HydrologyPlanner planner( + long seed, + HydrologyPlannerSettings settings, + HydrologyTerrainSampler terrain, + HydrologyGeometrySampler geometry + ) { + return new HydrologyPlanner(seed, settings, terrain, geometry, -4096, footprint -> solidCaveView()); + } + + private CaveVoxelView solidCaveView() { + return new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > -4096 && position.y() < 4096; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return false; + } + }; + } + + private HydrologyPlannerSettings settings( + double surfaceDensity, + double undergroundDensity, + List deepFluids + ) { + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( + true, + surfaceDensity, + 80, + 0, + 4, + 24 + ); + HydrologyPlannerSettings.Source undergroundSources = new HydrologyPlannerSettings.Source( + true, + undergroundDensity, + Integer.MIN_VALUE, + 0, + 4, + 32 + ); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing(128, 16, 4, 512, 256, + new HydrologyPlannerSettings.Branching(16, 8), 0.5D, 12D, 0.5D, 0.1D), + new HydrologyPlannerSettings.Surface( + surfaceDensity > 0D, + surfaceSources, + 4, + 18, + 2, + 4, + 4, + 8, + 10, + 1.5D, + 4, + 8, + true, + 96, + 8 + ), + new HydrologyPlannerSettings.Hydraulics(12, 28, 1, 3, 8, 4), + new HydrologyPlannerSettings.Underground( + undergroundDensity > 0D, + undergroundSources, + 68, + 82, + 4, + 12, + 2, + 4, + 5, + 9, + true + ), + new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + false, + 12, + 32, + 2, + 4 + ), + HydrologyPlannerSettings.Geometry.defaults(), + deepFluids + ); + } + + private HydrologyTerrainSample terrain(int x, int z) { + if (x >= 112) { + return new HydrologyTerrainSample( + 54, + 0D, + true, + false, + 30, + 32, + false, + false, + false, + false, + false, + false, + 0D, + 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + "ocean", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water") + ); + } + int height = 118 - Math.floorDiv(x, 12) + (int) StrictMath.round(StrictMath.sin(z / 18D) * 2D); + boolean source = x >= 0 && x <= 24; + return new HydrologyTerrainSample( + height, + 1D, + false, + true, + 70, + 74, + true, + true, + source, + source, + source, + source, + 0D, + source ? 1D : 0D, + source ? 1D : 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water") + ); + } + + private int styledValue(HydrologyGeometrySampler.Request request) { + return request.x() < 56 ? request.maximum() : request.minimum(); + } + + private HydrologyGeometrySampler.Request request( + HydrologyGeometrySampler.Field field, + String profileKey, + HydrologyPoint point, + int minimum, + int maximum + ) { + return new HydrologyGeometrySampler.Request(field, profileKey, point.x(), point.z(), 0L, minimum, maximum); + } + + private int scaledDimension(int styledBase, int maximum, int discharge) { + double flowScale = Math.min(1D, StrictMath.log(discharge + 1D) / StrictMath.log(9D)); + return Math.min(maximum, styledBase + (int) StrictMath.round((maximum - styledBase) * flowScale)); + } + + private List courses(HydrologyTile tile, RiverCourseType type) { + ArrayList selected = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == type) { + selected.add(course); + } + } + return List.copyOf(selected); + } + + private List heads(RiverCourse course) { + ArrayList heads = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + if (segment.type() == HydrologyFeatureType.MOUTH) { + continue; + } + heads.add(segment.upstreamHeadY()); + heads.add(segment.downstreamHeadY()); + } + return List.copyOf(heads); + } + + private void assertNonIncreasing(List heads) { + for (int index = 1; index < heads.size(); index++) { + assertTrue(heads.get(index) <= heads.get(index - 1)); + } + } + + private void assertSurfaceDropsTerrainBacked(HydrologyTile tile, HydrologyTerrainSampler terrain) { + for (RiverCourse course : courses(tile, RiverCourseType.SURFACE)) { + RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); + for (int segmentIndex = 0; segmentIndex < course.segments().size(); segmentIndex++) { + HydraulicSegment segment = course.segments().get(segmentIndex); + if (!segment.type().isDrop() || !segment.type().isSurface()) { + continue; + } + HydrologyPoint start = segment.start(); + HydrologyPoint end = segment.end(); + int downstreamNaturalHeight = outlet.directOcean() && terminalMouthDrop(course, segmentIndex) + ? terrain.sample(outlet.connectionPoint().x(), outlet.connectionPoint().z()).naturalHeight() + : terrain.sample(end.x(), end.z()).naturalHeight(); + int naturalDrop = terrain.sample(start.x(), start.z()).naturalHeight() - downstreamNaturalHeight; + assertTrue( + "type=" + segment.type() + " start=" + start + " end=" + end + + " naturalDrop=" + naturalDrop + " solvedDrop=" + segment.drop(), + naturalDrop >= segment.drop() + ); + } + } + } + + private boolean terminalMouthDrop(RiverCourse course, int segmentIndex) { + for (int index = segmentIndex + 1; index < course.segments().size(); index++) { + if (course.segments().get(index).type() != HydrologyFeatureType.MOUTH) { + return false; + } + } + return true; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainCaveVoxelViewTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainCaveVoxelViewTest.java new file mode 100644 index 000000000..537e2423c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainCaveVoxelViewTest.java @@ -0,0 +1,66 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import org.junit.Test; + +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 HydrologyTerrainCaveVoxelViewTest { + @Test + public void reusesOneTerrainSampleAcrossVoxelQueriesInTheSameColumn() { + AtomicInteger samples = new AtomicInteger(); + HydrologyTerrainSampler sampler = (int x, int z) -> { + samples.incrementAndGet(); + return HydrologyTerrainSample.openLand(64, 0D, "land"); + }; + HydrologyTerrainCaveVoxelView view = new HydrologyTerrainCaveVoxelView( + sampler, + 63, + -64, + 320 + ); + + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(5, 60, 9))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(5, 70, 9))); + assertFalse(view.isAboveTerrainSurface(new CavePosition(5, 64, 9))); + assertTrue(view.isOpenToSurface(new CavePosition(5, 65, 9))); + assertFalse(view.hasAboveTerrainSurface(5, 9, 50, 64)); + assertTrue(view.hasAboveTerrainSurface(5, 9, 50, 65)); + assertEquals(1, samples.get()); + + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(6, 60, 9))); + assertEquals(2, samples.get()); + } + + @Test + public void sharedPlanningCacheReusesTerrainSamplesAcrossCaveViews() { + AtomicInteger samples = new AtomicInteger(); + HydrologyTerrainSampler sampler = (int x, int z) -> { + samples.incrementAndGet(); + return HydrologyTerrainSample.openLand(64, 0D, "land"); + }; + Long2ObjectOpenHashMap shared = new Long2ObjectOpenHashMap<>(); + HydrologyTerrainCaveVoxelView first = new HydrologyTerrainCaveVoxelView( + sampler, + 63, + -64, + 320 + ).withSampleCache(shared); + HydrologyTerrainCaveVoxelView second = new HydrologyTerrainCaveVoxelView( + sampler, + 63, + -64, + 320 + ).withSampleCache(shared); + + assertTrue(first.isAboveTerrainSurface(new CavePosition(5, 65, 9))); + assertEquals(CaveVoxel.SOLID, second.voxelAt(new CavePosition(5, 60, 9))); + assertEquals(1, samples.get()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java new file mode 100644 index 000000000..27a2e4f9b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java @@ -0,0 +1,82 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; + +public final class HydrologyTerrainSampleTest { + @Test + public void reslopingReusesAnAlreadyNormalizedProfileList() { + HydrologyTerrainSample sample = sample(List.of("deep", "water")); + + HydrologyTerrainSample resloped = sample.withSlope(2D); + + assertEquals(2D, resloped.slope(), 0D); + assertSame(sample.preferredProfileKeys(), resloped.preferredProfileKeys()); + } + + @Test + public void profileNormalizationStillTrimsSortsAndDeduplicates() { + HydrologyTerrainSample sample = sample(Arrays.asList(" water ", "", null, "deep", "water")); + + assertEquals(List.of("deep", "water"), sample.preferredProfileKeys()); + } + + @Test + public void normalizedMutableProfilesAreDefensivelyCopied() { + ArrayList profiles = new ArrayList<>(List.of("deep", "water")); + HydrologyTerrainSample sample = sample(profiles); + + profiles.clear(); + + assertEquals(List.of("deep", "water"), sample.preferredProfileKeys()); + assertThrows(UnsupportedOperationException.class, () -> sample.preferredProfileKeys().add("other")); + } + + @Test + public void emptyProfilesStillUseDefaultAndInvalidSlopeStillFails() { + assertEquals(List.of("default"), sample(List.of()).preferredProfileKeys()); + assertThrows(IllegalArgumentException.class, () -> sample(-1D, List.of("water"))); + } + + private static HydrologyTerrainSample sample(List profiles) { + return sample(1D, profiles); + } + + private static HydrologyTerrainSample sample(double slope, List profiles) { + return new HydrologyTerrainSample( + 80, + slope, + false, + true, + 40, + 50, + true, + true, + true, + false, + true, + false, + 1D, + 1D, + 1D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + profiles + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java new file mode 100644 index 000000000..0ea448dd9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -0,0 +1,339 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.Callable; +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class HydrologyTileCacheTest { + @Test + public void chunkPreparationUsesHeightFillColumnOrder() { + HydrologyPlanner planner = mock(HydrologyPlanner.class); + HydrologyTile tile = mock(HydrologyTile.class); + ArrayList visitedColumns = new ArrayList<>(); + when(planner.settings()).thenReturn(emptySettings()); + when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile); + doAnswer(invocation -> { + int blockX = invocation.getArgument(0); + int blockZ = invocation.getArgument(1); + visitedColumns.add(RiverFootprint.pack(blockX, blockZ)); + return Optional.empty(); + }).when(tile).columnAt(anyInt(), anyInt()); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4); + + cache.prepareChunkColumns(32, 48); + + assertEquals(256, visitedColumns.size()); + int index = 0; + for (int localZ = 0; localZ < 16; localZ++) { + for (int localX = 0; localX < 16; localX++) { + assertEquals( + RiverFootprint.pack(32 + localX, 48 + localZ), + visitedColumns.get(index).longValue() + ); + index++; + } + } + verify(planner, times(1)).plan(new HydrologyTileKey(0, 0)); + } + + @Test + public void concurrentSameKeyRequestsPlanOnlyOnce() throws Exception { + AtomicInteger planningStarts = new AtomicInteger(); + HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(90, 0D, "parent"); + HydrologyPlanner planner = new HydrologyPlanner( + 41L, + emptySettings(), + sampler, + -4096, + footprint -> { + planningStarts.incrementAndGet(); + return new HydrologyTerrainCaveVoxelView(sampler, 63, -4096, 4096); + } + ); + HydrologyTileCache cache = new HydrologyTileCache(planner, 4); + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + ArrayList> tasks = new ArrayList<>(); + for (int index = 0; index < 32; index++) { + tasks.add(() -> cache.get(new HydrologyTileKey(0, 0))); + } + List> futures = executor.invokeAll(tasks); + HydrologyTile expected = futures.getFirst().get(10L, TimeUnit.SECONDS); + for (Future future : futures) { + assertSame(expected, future.get(10L, TimeUnit.SECONDS)); + } + assertEquals(1, planningStarts.get()); + assertEquals(1, cache.size()); + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(10L, TimeUnit.SECONDS)); + } + } + + @Test + public void completedTilesAreEvictedAtTheConfiguredBound() { + HydrologyPlanner planner = new HydrologyPlanner( + 73L, + emptySettings(), + (int x, int z) -> HydrologyTerrainSample.openLand(90, 0D, "parent") + ); + HydrologyTileCache cache = new HydrologyTileCache(planner, 2); + + for (int tileX = 0; tileX < 8; tileX++) { + cache.get(new HydrologyTileKey(tileX, 0)); + assertTrue(cache.size() <= 2); + } + + assertEquals(2, cache.maximumEntries()); + assertEquals(2, cache.size()); + } + + @Test + public void compositePublicationIsIndependentOfTileAndColumnQueryOrder() { + HydrologyPlanner planner = new HydrologyPlanner(811L, featureSettings(), this::featureTerrain); + HydrologyTile origin = planner.plan(new HydrologyTileKey(0, 0)); + HydrologyColumnSample neighboringColumn = null; + HydrologyColumnSample ownerColumn = null; + for (HydrologyColumnSample column : origin.footprint().columns().values()) { + if (new HydrologyTileKey(0, 0).contains(column.x(), column.z(), origin.tileSize())) { + ownerColumn = column; + } else if (!column.ocean()) { + neighboringColumn = column; + } + if (ownerColumn != null && neighboringColumn != null) { + break; + } + } + assertTrue(origin.footprint().columns().values().stream().anyMatch( + (HydrologyColumnSample column) -> !new HydrologyTileKey(0, 0) + .contains(column.x(), column.z(), origin.tileSize()))); + assertTrue(ownerColumn != null && neighboringColumn != null); + HydrologyTileKey neighboringKey = HydrologyTileKey.fromBlock( + neighboringColumn.x(), + neighboringColumn.z(), + origin.tileSize() + ); + assertFalse(planner.plan(neighboringKey) + .columnAt(neighboringColumn.x(), neighboringColumn.z()) + .isPresent()); + + HydrologyTileCache firstOrder = new HydrologyTileCache(planner, 8); + HydrologyColumnSample firstNeighbor = firstOrder + .columnAt(neighboringColumn.x(), neighboringColumn.z()) + .orElseThrow(); + HydrologyColumnSample firstOwner = firstOrder.columnAt(ownerColumn.x(), ownerColumn.z()).orElseThrow(); + HydrologyTileCache reverseOrder = new HydrologyTileCache(planner, 8); + HydrologyColumnSample secondOwner = reverseOrder.columnAt(ownerColumn.x(), ownerColumn.z()).orElseThrow(); + HydrologyColumnSample secondNeighbor = reverseOrder + .columnAt(neighboringColumn.x(), neighboringColumn.z()) + .orElseThrow(); + + assertEquals(firstNeighbor, secondNeighbor); + assertEquals(firstOwner, secondOwner); + assertEquals(firstNeighbor.renderSample(), reverseOrder.renderAt(neighboringColumn.x(), neighboringColumn.z())); + assertTrue(firstNeighbor.layers().containsAll(neighboringColumn.layers())); + } + + @Test + public void realPlannedFeatureIdsAndTypesReachTheRendererAtExactCoordinates() { + HydrologyPlanner planner = new HydrologyPlanner(811L, featureSettings(), this::featureTerrain); + HydrologyTileCache cache = new HydrologyTileCache(planner, 8); + HydrologyTile planned = cache.get(new HydrologyTileKey(0, 0)); + HydrologyColumnSample plannedColumn = planned.footprint().columns().values().stream() + .filter(HydrologyColumnSample::present) + .findFirst() + .orElseThrow(); + HydrologyColumnSample accepted = cache.columnAt(plannedColumn.x(), plannedColumn.z()).orElseThrow(); + HydrologyRenderSample rendered = cache.renderAt(plannedColumn.x(), plannedColumn.z()); + Map acceptedFeatures = new HashMap<>(); + Map renderedFeatures = new HashMap<>(); + for (HydrologyColumnLayer layer : accepted.layers()) { + acceptedFeatures.put(layer.feature().id(), layer.feature().type()); + } + for (HydrologyFeatureRef feature : rendered.features()) { + renderedFeatures.put(feature.id(), feature.type()); + } + + assertFalse(acceptedFeatures.isEmpty()); + assertEquals(plannedColumn.x(), rendered.x()); + assertEquals(plannedColumn.z(), rendered.z()); + assertEquals(acceptedFeatures, renderedFeatures); + for (HydrologyColumnLayer layer : plannedColumn.layers()) { + assertEquals(layer.feature().type(), renderedFeatures.get(layer.feature().id())); + } + } + + @Test + public void concurrentDifferentTileAndCompositeQueriesRemainDeterministic() throws Exception { + HydrologyPlanner planner = new HydrologyPlanner(1211L, featureSettings(), this::featureTerrain); + HydrologyTileCache cache = new HydrologyTileCache(planner, 16); + List keys = List.of( + new HydrologyTileKey(-1, 0), + new HydrologyTileKey(0, 0), + new HydrologyTileKey(1, 0) + ); + ExecutorService executor = Executors.newFixedThreadPool(12); + try { + ArrayList> tileTasks = new ArrayList<>(); + for (int index = 0; index < 36; index++) { + HydrologyTileKey key = keys.get(index % keys.size()); + tileTasks.add(() -> cache.get(key)); + } + List> futures = executor.invokeAll(tileTasks); + Map firstByKey = new HashMap<>(); + for (int index = 0; index < futures.size(); index++) { + HydrologyTileKey key = keys.get(index % keys.size()); + HydrologyTile tile = futures.get(index).get(10L, TimeUnit.SECONDS); + HydrologyTile existing = firstByKey.putIfAbsent(key, tile); + if (existing != null) { + assertSame(existing, tile); + } + assertEquals(planner.plan(key), tile); + } + HydrologyColumnSample outside = planner.plan(new HydrologyTileKey(0, 0)).footprint().columns().values() + .stream() + .filter((HydrologyColumnSample column) -> column.x() >= 64 && !column.ocean()) + .findFirst() + .orElseThrow(); + HydrologyColumnSample expected = cache.columnAt(outside.x(), outside.z()).orElseThrow(); + ArrayList> columnTasks = new ArrayList<>(); + for (int index = 0; index < 24; index++) { + columnTasks.add(() -> cache.columnAt(outside.x(), outside.z()).orElseThrow()); + } + for (Future future : executor.invokeAll(columnTasks)) { + assertEquals(expected, future.get(10L, TimeUnit.SECONDS)); + assertEquals(expected.renderSample(), future.get(10L, TimeUnit.SECONDS).renderSample()); + } + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(10L, TimeUnit.SECONDS)); + } + } + + private HydrologyPlannerSettings emptySettings() { + HydrologyPlannerSettings.Source disabled = new HydrologyPlannerSettings.Source( + false, + 0D, + Integer.MIN_VALUE, + 0, + 0, + 0 + ); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing(64, 16, 4, 128, 64, + new HydrologyPlannerSettings.Branching(0, 0), 0D, 0D, 0D, 0D), + new HydrologyPlannerSettings.Surface(false, disabled, 2, 4, 1, 2, 2, 4, 4, 1D, 2, 4, false, 0, 0), + new HydrologyPlannerSettings.Hydraulics(8, 16, 1, 2, 8, 3), + new HydrologyPlannerSettings.Underground(false, disabled, -32, 32, 2, 4, 1, 2, 3, 4, false), + new HydrologyPlannerSettings.Outlets( + false, + new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), + new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), + false, + 8, + 16, + 1, + 2 + ), + HydrologyPlannerSettings.Geometry.defaults(), + List.of() + ); + } + + private HydrologyPlannerSettings featureSettings() { + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( + true, + 1D, + 80, + 0, + 1, + 16 + ); + HydrologyPlannerSettings.Source disabled = new HydrologyPlannerSettings.Source( + false, + 0D, + Integer.MIN_VALUE, + 0, + 0, + 0 + ); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing(64, 16, 4, 128, 96, + new HydrologyPlannerSettings.Branching(16, 8), 0.5D, 12D, 0.5D, 0.1D), + new HydrologyPlannerSettings.Surface(true, surfaceSources, 4, 8, 2, 3, 4, 8, 20, 1.5D, 2, 4, true, 96, 8), + new HydrologyPlannerSettings.Hydraulics(8, 16, 1, 2, 8, 3), + new HydrologyPlannerSettings.Underground(false, disabled, -32, 32, 2, 4, 1, 2, 3, 4, false), + new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), + new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), + false, + 8, + 16, + 1, + 2 + ), + HydrologyPlannerSettings.Geometry.defaults(), + List.of() + ); + } + + private HydrologyTerrainSample featureTerrain(int x, int z) { + if (x >= 80) { + return HydrologyTerrainSample.ocean(54, "ocean_parent"); + } + boolean source = x >= 0 && x <= 16; + return new HydrologyTerrainSample( + 104 - Math.floorDiv(x, 8), + 1D, + false, + false, + 40, + 42, + true, + true, + source, + source, + false, + false, + 0D, + source ? 1D : 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default") + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/cave/CavePositionIndexTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/CavePositionIndexTest.java new file mode 100644 index 000000000..7d63e98f5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/CavePositionIndexTest.java @@ -0,0 +1,40 @@ +package art.arcane.iris.engine.hydrology.cave; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class CavePositionIndexTest { + @Test + public void preservesDistinctFullIntegerCoordinatesAcrossGrowth() { + CavePositionIndex index = new CavePositionIndex(1); + for (int value = -128; value <= 128; value++) { + assertTrue(index.add(value, value * 31, value * -17)); + } + assertTrue(index.add(Integer.MIN_VALUE, Integer.MAX_VALUE, 0)); + assertTrue(index.add(Integer.MAX_VALUE, Integer.MIN_VALUE, -1)); + + for (int value = -128; value <= 128; value++) { + assertTrue(index.contains(value, value * 31, value * -17)); + assertFalse(index.add(value, value * 31, value * -17)); + } + assertTrue(index.contains(Integer.MIN_VALUE, Integer.MAX_VALUE, 0)); + assertTrue(index.contains(Integer.MAX_VALUE, Integer.MIN_VALUE, -1)); + assertFalse(index.contains(Integer.MIN_VALUE, Integer.MAX_VALUE, 1)); + } + + @Test + public void copiedIndexMatchesOnlyExactTriples() { + CavePositionIndex index = CavePositionIndex.copyOf(List.of( + new CavePosition(1, 2, 3), + new CavePosition(3, 2, 1) + )); + + assertTrue(index.contains(1, 2, 3)); + assertTrue(index.contains(3, 2, 1)); + assertFalse(index.contains(1, 3, 2)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlannerTest.java new file mode 100644 index 000000000..ff3f8e895 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveContainmentPlannerTest.java @@ -0,0 +1,1324 @@ +package art.arcane.iris.engine.hydrology.cave; + +import art.arcane.iris.engine.hydrology.HydrologyCaveVoxelViewFactory; +import art.arcane.iris.engine.hydrology.HydrologyObservedPlannedSurface; +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +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.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyCaveContainmentPlannerTest { + private static final List DIRECTIONS = List.of( + new CavePosition(1, 0, 0), + new CavePosition(-1, 0, 0), + new CavePosition(0, 1, 0), + new CavePosition(0, -1, 0), + new CavePosition(0, 0, 1), + new CavePosition(0, 0, -1) + ); + + @Test + public void cavePositionHashSeparatesAxisExtrusions() { + assertNotEquals(new CavePosition(0, 31, 0).hashCode(), new CavePosition(1, 0, 0).hashCode()); + assertNotEquals(new CavePosition(0, 1, 0).hashCode(), new CavePosition(0, 0, 31).hashCode()); + } + + private final HydrologyCaveContainmentPlanner planner = new HydrologyCaveContainmentPlanner(); + + @Test + public void closedComponentProducesConnectedThroatPoolAndGuards() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(1, 6, 0)); + HydrologyCaveSource source = source(14L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); + + HydrologyCavePlan plan = planner.plan(view, source, settings()); + + assertTrue(plan.accepted()); + assertEquals(HydrologyCaveAction.DRY_AIR, plan.actions().get(position(0, 12, 0))); + assertEquals(HydrologyCaveAction.DRY_AIR, plan.actions().get(position(0, 11, 0))); + assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); + assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(1, 6, 0))); + assertTrue(plan.actions().containsValue(HydrologyCaveAction.SEAL_GUARD)); + assertMutationConnected(plan, source.entry(), position(1, 6, 0)); + assertEquals(plan.actions().keySet(), plan.baselinePreconditions().keySet()); + assertEquals(CaveVoxel.CAVE_AIR, plan.baselinePreconditions().get(position(1, 6, 0)).voxel()); + assertEquals(CaveVoxel.SOLID, plan.baselinePreconditions().get(position(0, 12, 0)).voxel()); + } + + @Test + public void compatibleFluidFollowsExplicitPolicy() { + TestVoxelView view = new TestVoxelView(); + CavePosition target = position(0, 7, 0); + view.set(CaveVoxel.COMPATIBLE_FLUID, target); + HydrologyCaveSource source = source(1L, 10, HydrologyCaveMode.CLOSED_COMPONENT, target); + + HydrologyCavePlan allowed = planner.plan(view, source, settings(HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE)); + HydrologyCavePlan rejected = planner.plan(view, source, settings(HydrologyCaveFluidPolicy.REJECT_EXISTING)); + + assertTrue(allowed.accepted()); + assertEquals(HydrologyCaveAction.WET_SOURCE, allowed.actions().get(target)); + assertEquals(HydrologyCaveRejection.EXISTING_FLUID, rejected.rejection()); + assertTrue(rejected.actions().isEmpty()); + } + + @Test + public void replacementPolicyIsDistinctAndStillRejectsLava() { + CavePosition target = position(0, 7, 0); + TestVoxelView compatibleView = new TestVoxelView(); + compatibleView.set(CaveVoxel.COMPATIBLE_FLUID, target); + TestVoxelView incompatibleView = new TestVoxelView(); + incompatibleView.set(CaveVoxel.INCOMPATIBLE_FLUID, target); + TestVoxelView lavaView = new TestVoxelView(); + lavaView.set(CaveVoxel.LAVA, target); + HydrologyCaveSource source = source(101L, 10, HydrologyCaveMode.CLOSED_COMPONENT, target); + + HydrologyCavePlan rejectedCompatible = planner.plan( + compatibleView, + source, + settings(HydrologyCaveFluidPolicy.REJECT_EXISTING) + ); + HydrologyCavePlan allowedCompatible = planner.plan( + compatibleView, + source, + settings(HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE) + ); + HydrologyCavePlan rejectedIncompatible = planner.plan( + incompatibleView, + source, + settings(HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE) + ); + HydrologyCavePlan replacedIncompatible = planner.plan( + incompatibleView, + source, + settings(HydrologyCaveFluidPolicy.REPLACE_CONTAINED) + ); + HydrologyCavePlan rejectedLava = planner.plan( + lavaView, + source, + settings(HydrologyCaveFluidPolicy.REPLACE_CONTAINED) + ); + + assertEquals(HydrologyCaveRejection.EXISTING_FLUID, rejectedCompatible.rejection()); + assertTrue(allowedCompatible.accepted()); + assertEquals(HydrologyCaveRejection.INCOMPATIBLE_FLUID, rejectedIncompatible.rejection()); + assertTrue(replacedIncompatible.accepted()); + assertEquals(HydrologyCaveAction.WET_SOURCE, replacedIncompatible.actions().get(target)); + assertEquals(HydrologyCaveRejection.LAVA_CONTACT, rejectedLava.rejection()); + } + + @Test + public void throatRadiusExpandsTheConnectedBoreFootprint() { + TestVoxelView thinView = new TestVoxelView(); + TestVoxelView thickView = new TestVoxelView(); + CavePosition target = position(0, 7, 0); + thinView.set(CaveVoxel.CAVE_AIR, target); + thickView.set(CaveVoxel.CAVE_AIR, target); + HydrologyCaveSource source = source(102L, 10, HydrologyCaveMode.CLOSED_COMPONENT, target); + HydrologyCavePlannerSettings thinSettings = detailedSettings( + 1, + 0, + HydrologyCaveGrottoShape.ELLIPSOID, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + HydrologyCavePlannerSettings thickSettings = detailedSettings( + 3, + 0, + HydrologyCaveGrottoShape.ELLIPSOID, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + HydrologyCavePlan thin = planner.plan(thinView, source, thinSettings); + HydrologyCavePlan thick = planner.plan(thickView, source, thickSettings); + + assertTrue(thin.accepted()); + assertTrue(thick.accepted()); + assertFalse(thin.actions().containsKey(position(2, 10, 0))); + assertEquals(HydrologyCaveAction.WET_SOURCE, thick.actions().get(position(2, 10, 0))); + assertTrue(mutationCount(thick) > mutationCount(thin)); + } + + @Test + public void generatedGrottoRetainsConfiguredDryHeadroom() { + TestVoxelView view = new TestVoxelView(); + HydrologyCaveSource source = source(103L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)); + HydrologyCavePlannerSettings acceptedSettings = detailedSettings( + 1, + 2, + HydrologyCaveGrottoShape.ELLIPSOID, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + HydrologyCavePlannerSettings rejectedSettings = detailedSettings( + 1, + 5, + HydrologyCaveGrottoShape.ELLIPSOID, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + HydrologyCavePlan accepted = planner.plan(view, source, acceptedSettings); + HydrologyCavePlan rejected = planner.plan(view, source, rejectedSettings); + + assertTrue(accepted.accepted()); + assertEquals(HydrologyCaveAction.FALLING_FLUID, accepted.actions().get(position(0, 11, 0))); + assertEquals(HydrologyCaveAction.FALLING_FLUID, accepted.actions().get(position(0, 12, 0))); + assertEquals(HydrologyCaveRejection.DRY_HEADROOM_LIMIT, rejected.rejection()); + } + + @Test + public void configuredGrottoPredicateChangesFootprintDeterministically() { + TestVoxelView view = new TestVoxelView(); + HydrologyCaveSource source = source(104L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)); + HydrologyCavePlannerSettings ellipsoid = detailedSettings( + 1, + 0, + HydrologyCaveGrottoShape.ELLIPSOID, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + HydrologyCaveGrottoShape compactShape = (candidate, settings, dx, dy, dz) -> { + double horizontal = settings.grottoHorizontalRadius(); + double vertical = settings.grottoVerticalRadius(); + double normalized = (dx * dx / (horizontal * horizontal)) + + (dy * dy / (vertical * vertical)) + + (dz * dz / (horizontal * horizontal)); + return normalized <= 0.5D; + }; + HydrologyCavePlannerSettings compact = detailedSettings( + 1, + 0, + compactShape, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + HydrologyCavePlan full = planner.plan(view, source, ellipsoid); + HydrologyCavePlan firstCompact = planner.plan(view, source, compact); + HydrologyCavePlan secondCompact = planner.plan(view, source, compact); + + assertTrue(full.accepted()); + assertTrue(firstCompact.accepted()); + assertEquals(firstCompact, secondCompact); + assertTrue(mutationCount(full) > mutationCount(firstCompact)); + } + + @Test + public void radiusLimitRejectsWholeComponentWithoutClipping() { + TestVoxelView view = new TestVoxelView(); + for (int x = 0; x <= 3; x++) { + view.set(CaveVoxel.CAVE_AIR, position(x, 7, 0)); + } + HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings( + 2, + 20, + 64, + 32, + 1, + 1, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + HydrologyCavePlan plan = planner.plan( + view, + source(2L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), + settings + ); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.RADIUS_LIMIT); + } + + @Test + public void depthLimitRejectsWholeComponentWithoutClipping() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 8, 0), position(0, 7, 0)); + HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings( + 8, + 4, + 64, + 32, + 1, + 1, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + HydrologyCavePlan plan = planner.plan( + view, + source(3L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 8, 0)), + settings + ); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.DEPTH_LIMIT); + } + + @Test + public void volumeLimitRejectsWholeComponentWithoutClipping() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); + HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings( + 8, + 20, + 2, + 32, + 1, + 1, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + + HydrologyCavePlan plan = planner.plan( + view, + source(4L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), + settings + ); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.VOLUME_LIMIT); + } + + @Test + public void worldBoundaryRejectsWholeComponent() { + TestVoxelView view = new TestVoxelView(-16, 0, 0, 32, -16, 16); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + + HydrologyCavePlan plan = planner.plan( + view, + source(5L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), + settings() + ); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.WORLD_BOUNDARY); + } + + @Test + public void openSurfaceRejectsWholeComponent() { + TestVoxelView view = new TestVoxelView(); + CavePosition target = position(0, 7, 0); + view.set(CaveVoxel.CAVE_AIR, target); + view.openToSurface(target); + + HydrologyCavePlan plan = planner.plan( + view, + source(6L, 10, HydrologyCaveMode.CLOSED_COMPONENT, target), + settings() + ); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.OPEN_SURFACE); + } + + @Test + public void lavaAndIncompatibleFluidContactsRejectWholeComponent() { + TestVoxelView lavaView = new TestVoxelView(); + lavaView.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + lavaView.set(CaveVoxel.LAVA, position(1, 7, 0)); + TestVoxelView fluidView = new TestVoxelView(); + fluidView.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + fluidView.set(CaveVoxel.INCOMPATIBLE_FLUID, position(1, 7, 0)); + HydrologyCaveSource source = source(7L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); + + HydrologyCavePlan lavaPlan = planner.plan(lavaView, source, settings()); + HydrologyCavePlan fluidPlan = planner.plan(fluidView, source, settings()); + + assertRejectedWithoutPublication(lavaPlan, HydrologyCaveRejection.LAVA_CONTACT); + assertRejectedWithoutPublication(fluidPlan, HydrologyCaveRejection.INCOMPATIBLE_FLUID); + } + + @Test + public void fallingHazardAbovePoolRejectsWholeComponent() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + view.set(CaveVoxel.LAVA, position(1, 11, 0)); + + HydrologyCavePlan plan = planner.plan( + view, + source(15L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), + settings() + ); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.LAVA_CONTACT); + } + + @Test + public void generatedGrottoHasBoundedWetDryAndGuardActions() { + TestVoxelView view = new TestVoxelView(); + HydrologyCaveSource source = source(8L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)); + + HydrologyCavePlan plan = planner.plan(view, source, settings()); + + assertTrue(plan.accepted()); + assertEquals(HydrologyCaveAction.FALLING_FLUID, plan.actions().get(position(0, 12, 0))); + assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(0, 8, 0))); + assertTrue(plan.actions().containsValue(HydrologyCaveAction.SEAL_GUARD)); + assertEquals(plan.actions().keySet(), plan.baselinePreconditions().keySet()); + assertFalse(plan.actions().containsKey(position(0, 13, 0))); + assertMutationConnected(plan, source.entry(), source.target()); + } + + @Test + public void generatedGrottoAcceptsItsCompleteSurfaceMouth() { + TestVoxelView view = new TestVoxelView(); + CavePosition[] wetMouth = { + position(-1, 12, 0), + position(1, 12, 0), + position(0, 12, -1), + position(0, 12, 1) + }; + CavePosition[] dryMouth = { + position(-1, 13, 0), + position(1, 13, 0), + position(0, 13, -1), + position(0, 13, 1) + }; + view.set(CaveVoxel.COMPATIBLE_FLUID, wetMouth); + view.set(CaveVoxel.CAVE_AIR, dryMouth); + for (CavePosition position : wetMouth) { + view.openToSurface(position); + } + for (CavePosition position : dryMouth) { + view.openToSurface(position); + } + HydrologyCaveSource source = source(108L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)); + + HydrologyCavePlan plan = planner.plan(view, source, detailedSettings( + 2, + 2, + HydrologyCaveGrottoShape.ELLIPSOID, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + )); + + assertTrue(plan.rejection().toString(), plan.accepted()); + for (CavePosition position : wetMouth) { + assertEquals(HydrologyCaveAction.FALLING_FLUID, plan.actions().get(position)); + } + } + + @Test + public void generatedGrottoRejectsAnOpenShell() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(3, 8, 0)); + + HydrologyCavePlan plan = planner.plan( + view, + source(9L, 10, HydrologyCaveMode.GENERATED_GROTTO, position(0, 8, 0)), + settings() + ); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.GROTTO_SHELL_OPEN); + } + + @Test + public void deepPoolOpensOnlyAboveItsContainedFluidHead() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 11, 0), position(0, 13, 0)); + HydrologyCaveSource source = new HydrologyCaveSource( + 109L, + position(0, 10, 0), + position(0, 8, 0), + 10, + HydrologyCaveMode.DEEP_POOL + ); + + HydrologyCavePlan plan = planner.plan(view, source, detailedSettings( + 1, + 2, + HydrologyCaveGrottoShape.ELLIPSOID, + HydrologyCaveFluidPolicy.REJECT_EXISTING + )); + + assertTrue(plan.rejection().toString(), plan.accepted()); + assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); + assertEquals(HydrologyCaveAction.DRY_AIR, plan.actions().get(position(0, 11, 0))); + assertFalse(plan.actions().containsKey(position(0, 13, 0))); + } + + @Test + public void deepPoolRejectsCaveLeakAtOrBelowItsFluidHead() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(5, 8, 0)); + HydrologyCaveSource source = new HydrologyCaveSource( + 110L, + position(0, 10, 0), + position(0, 8, 0), + 10, + HydrologyCaveMode.DEEP_POOL + ); + + HydrologyCavePlan plan = planner.plan(view, source, detailedSettings( + 1, + 2, + HydrologyCaveGrottoShape.ELLIPSOID, + HydrologyCaveFluidPolicy.REJECT_EXISTING + )); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.GROTTO_SHELL_OPEN); + } + + @Test + public void combinedModeUsesClosedCaveOrGeneratedGrottoFromBaseline() { + TestVoxelView caveView = new TestVoxelView(); + CavePosition target = position(0, 8, 0); + caveView.set(CaveVoxel.CAVE_AIR, target); + TestVoxelView solidView = new TestVoxelView(); + HydrologyCaveSource source = source(10L, 10, HydrologyCaveMode.GROTTO_OR_CLOSED_COMPONENT, target); + + HydrologyCavePlan cavePlan = planner.plan(caveView, source, settings()); + HydrologyCavePlan grottoPlan = planner.plan(solidView, source, settings()); + + assertTrue(cavePlan.accepted()); + assertTrue(grottoPlan.accepted()); + assertEquals(CaveVoxel.CAVE_AIR, cavePlan.baselinePreconditions().get(target).voxel()); + assertEquals(CaveVoxel.SOLID, grottoPlan.baselinePreconditions().get(target).voxel()); + } + + @Test + public void waterfallKeepsColumnDistinctFromSourcePoolAndDryAir() { + TestVoxelView view = new TestVoxelView(); + HydrologyCaveSource source = source(11L, 10, HydrologyCaveMode.WATERFALL_POOL, position(0, 8, 0)); + + HydrologyCavePlan plan = planner.plan(view, source, settings()); + + assertTrue(plan.accepted()); + assertEquals(HydrologyCaveAction.FALLING_FLUID, plan.actions().get(position(0, 12, 0))); + assertEquals(HydrologyCaveAction.FALLING_FLUID, plan.actions().get(position(0, 11, 0))); + assertEquals(HydrologyCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); + assertFalse(plan.actions().containsValue(HydrologyCaveAction.DRY_AIR)); + } + + @Test + public void waterfallRejectsAnUnsealedFallingColumn() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 8, 0), position(1, 11, 0)); + HydrologyCaveSource source = source(13L, 10, HydrologyCaveMode.WATERFALL_POOL, position(0, 8, 0)); + + HydrologyCavePlan plan = planner.plan(view, source, settings()); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.WATERFALL_SHAFT_OPEN); + } + + @Test + public void overlapArbitrationIsOrderIndependentAndNamesWinner() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); + HydrologyCaveSource lowerHead = new HydrologyCaveSource( + 1L, + position(2, 12, 0), + position(2, 7, 0), + 10, + HydrologyCaveMode.CLOSED_COMPONENT + ); + HydrologyCaveSource higherHead = new HydrologyCaveSource( + 20L, + position(0, 12, 0), + position(0, 7, 0), + 11, + HydrologyCaveMode.CLOSED_COMPONENT + ); + + HydrologyCavePlanningResult forward = planner.planAll(view, List.of(lowerHead, higherHead), settings()); + HydrologyCavePlanningResult reverse = planner.planAll(view, List.of(higherHead, lowerHead), settings()); + + assertEquals(forward, reverse); + assertEquals(higherHead, forward.plans().get(0).source()); + assertTrue(forward.plans().get(0).accepted()); + assertEquals(HydrologyCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); + assertEquals(20L, forward.plans().get(1).arbitrationWinnerSourceId().getAsLong()); + assertEquals(forward.actions().keySet(), forward.baselinePreconditions().keySet()); + } + + @Test + public void equalHeadOverlapUsesLowestStableSourceId() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); + HydrologyCaveSource largerId = new HydrologyCaveSource( + 20L, + position(0, 12, 0), + position(0, 7, 0), + 10, + HydrologyCaveMode.CLOSED_COMPONENT + ); + HydrologyCaveSource smallerId = new HydrologyCaveSource( + 1L, + position(2, 12, 0), + position(2, 7, 0), + 10, + HydrologyCaveMode.CLOSED_COMPONENT + ); + + HydrologyCavePlanningResult result = planner.planAll(view, List.of(largerId, smallerId), settings()); + + assertEquals(smallerId, result.plans().get(0).source()); + assertTrue(result.plans().get(0).accepted()); + assertEquals(1L, result.plans().get(1).arbitrationWinnerSourceId().getAsLong()); + } + + @Test + public void arbitrationUsesDirectHigherPriorityPlansEvenWhenTheyAreRejected() { + TestVoxelView view = new TestVoxelView(); + view.set( + CaveVoxel.CAVE_AIR, + position(0, 7, 0), + position(2, 7, 0), + position(4, 7, 0) + ); + HydrologyCaveSource lowest = new HydrologyCaveSource( + 30L, + position(0, 12, 0), + position(0, 7, 0), + 10, + HydrologyCaveMode.CLOSED_COMPONENT + ); + HydrologyCaveSource middle = new HydrologyCaveSource( + 20L, + position(2, 12, 0), + position(2, 7, 0), + 11, + HydrologyCaveMode.CLOSED_COMPONENT + ); + HydrologyCaveSource highest = new HydrologyCaveSource( + 10L, + position(4, 12, 0), + position(4, 7, 0), + 12, + HydrologyCaveMode.CLOSED_COMPONENT + ); + + HydrologyCavePlanningResult forward = planner.planAll( + view, + List.of(lowest, middle, highest), + settings() + ); + HydrologyCavePlanningResult reverse = planner.planAll( + view, + List.of(highest, middle, lowest), + settings() + ); + + assertEquals(forward, reverse); + assertTrue(forward.plans().get(0).accepted()); + assertEquals(HydrologyCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); + assertEquals(10L, forward.plans().get(1).arbitrationWinnerSourceId().getAsLong()); + assertEquals(HydrologyCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(2).rejection()); + assertEquals(20L, forward.plans().get(2).arbitrationWinnerSourceId().getAsLong()); + assertFalse(forward.actions().containsKey(position(0, 7, 0))); + assertTrue(forward.actions().containsKey(position(4, 7, 0))); + } + + @Test + public void planOutputsAreImmutable() { + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); + HydrologyCaveSource source = source(12L, 10, HydrologyCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); + HydrologyCavePlanningResult result = planner.planAll(view, List.of(source), settings()); + HydrologyCavePlan plan = result.plans().get(0); + + assertThrows( + UnsupportedOperationException.class, + () -> plan.actions().put(position(9, 9, 9), HydrologyCaveAction.WET_SOURCE) + ); + assertThrows( + UnsupportedOperationException.class, + () -> plan.baselinePreconditions().put( + position(9, 9, 9), + new CaveVoxelPrecondition(CaveVoxel.SOLID, false) + ) + ); + assertThrows(UnsupportedOperationException.class, () -> result.plans().add(plan)); + assertThrows( + UnsupportedOperationException.class, + () -> result.actions().put(position(9, 9, 9), HydrologyCaveAction.WET_SOURCE) + ); + } + + @Test + public void plannedVolumesRejectHazardsSurfaceExposureAndOverflowWithoutActions() { + CavePosition center = position(0, 10, 0); + HydrologyCaveCandidate acceptedCandidate = candidate( + 201L, + 10, + "water", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ); + TestVoxelView lava = new TestVoxelView(); + lava.set(CaveVoxel.LAVA, center); + TestVoxelView incompatible = new TestVoxelView(); + incompatible.set(CaveVoxel.INCOMPATIBLE_FLUID, center); + TestVoxelView exposed = new TestVoxelView(); + exposed.set(CaveVoxel.CAVE_AIR, center); + exposed.openToSurface(center); + exposed.aboveTerrain(center); + HydrologyCavePlannerSettings oneVoxel = new HydrologyCavePlannerSettings( + 12, + 20, + 1, + 32, + 2, + 2, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + HydrologyCaveCandidate overflow = candidate( + 202L, + 10, + "water", + Map.of( + center, + HydrologyCaveAction.WET_SOURCE, + position(0, 9, 0), + HydrologyCaveAction.WET_SOURCE + ), + oneVoxel + ); + + assertRejectedWithoutPublication( + planner.validate(lava, acceptedCandidate), + HydrologyCaveRejection.LAVA_CONTACT + ); + assertRejectedWithoutPublication( + planner.validate(incompatible, acceptedCandidate), + HydrologyCaveRejection.INCOMPATIBLE_FLUID + ); + assertRejectedWithoutPublication( + planner.validate(exposed, acceptedCandidate), + HydrologyCaveRejection.OPEN_SURFACE + ); + assertRejectedWithoutPublication( + planner.validate(new TestVoxelView(), overflow), + HydrologyCaveRejection.VOLUME_LIMIT + ); + } + + @Test + public void validationCacheReusesStableUniqueObservationsAndInvalidatesChangedVoxels() { + CavePosition center = position(0, 10, 0); + HydrologyCaveCandidate candidate = candidate( + 203L, + 10, + "water", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ); + TestVoxelView view = new TestVoxelView(); + HydrologyCaveContainmentPlanner.ValidationCache cache = + new HydrologyCaveContainmentPlanner.ValidationCache(); + + HydrologyCavePlan initial = planner.validateAll(view, List.of(candidate), cache).plans().getFirst(); + HydrologyCavePlan cached = planner.validateAll(view, List.of(candidate), cache).plans().getFirst(); + + assertTrue(initial.accepted()); + assertEquals(initial, cached); + assertEquals(1L, cache.hits()); + assertEquals(1L, cache.misses()); + + view.set(CaveVoxel.LAVA, center); + HydrologyCavePlan changed = planner.validateAll(view, List.of(candidate), cache).plans().getFirst(); + + assertEquals(HydrologyCaveRejection.LAVA_CONTACT, changed.rejection()); + assertEquals(1L, cache.hits()); + assertEquals(2L, cache.misses()); + } + + @Test + public void oversizedPlannedVolumeRejectsBeforeLoadingVoxels() { + AtomicInteger voxelLoads = new AtomicInteger(); + CaveVoxelView view = new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + voxelLoads.incrementAndGet(); + return true; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + voxelLoads.incrementAndGet(); + return CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + voxelLoads.incrementAndGet(); + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + voxelLoads.incrementAndGet(); + return false; + } + }; + HydrologyCavePlannerSettings oneVoxel = new HydrologyCavePlannerSettings( + 12, + 20, + 1, + 32, + 2, + 2, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ); + HydrologyCaveCandidate candidate = candidate( + 205L, + 10, + "water", + Map.of( + position(0, 10, 0), HydrologyCaveAction.WET_SOURCE, + position(0, 9, 0), HydrologyCaveAction.WET_SOURCE + ), + oneVoxel + ); + + HydrologyCavePlan plan = planner.validate(view, candidate); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.VOLUME_LIMIT); + assertEquals(0, voxelLoads.get()); + } + + @Test + public void plannedSurfaceCacheTracksObservedColumnsWithoutRetainingVoxelObservations() { + CavePosition center = position(0, 10, 0); + HydrologyCaveCandidate candidate = candidate( + 204L, + 10, + "water", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ); + HydrologyCaveContainmentPlanner.ValidationCache cache = + new HydrologyCaveContainmentPlanner.ValidationCache(); + HydrologyObservedPlannedSurface initialSurface = observedSurface(32); + HydrologyObservedPlannedSurface matchingSurface = observedSurface(32); + HydrologyObservedPlannedSurface changedSurface = observedSurface(9); + + HydrologyCavePlan initial = planner.validateAll( + plannedSurfaceView(initialSurface), + List.of(candidate), + cache, + initialSurface + ).plans().getFirst(); + HydrologyCavePlan cached = planner.validateAll( + plannedSurfaceView(matchingSurface), + List.of(candidate), + cache, + matchingSurface + ).plans().getFirst(); + + assertTrue(initial.accepted()); + assertEquals(initial, cached); + assertEquals(1L, cache.hits()); + assertEquals(1L, cache.misses()); + + HydrologyCavePlan changed = planner.validateAll( + plannedSurfaceView(changedSurface), + List.of(candidate), + cache, + changedSurface + ).plans().getFirst(); + + assertEquals(HydrologyCaveRejection.OPEN_SURFACE, changed.rejection()); + assertEquals(1L, cache.hits()); + assertEquals(2L, cache.misses()); + } + + @Test + public void validationCacheEvictsItsPreviousWorkingSetWithoutResettingStatistics() { + CavePosition center = position(0, 10, 0); + HydrologyCaveCandidate first = candidate( + 206L, + 10, + "water", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ); + HydrologyCaveCandidate second = candidate( + 207L, + 10, + "water", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ); + HydrologyCaveContainmentPlanner.ValidationCache cache = + new HydrologyCaveContainmentPlanner.ValidationCache(1, Long.MAX_VALUE); + TestVoxelView view = new TestVoxelView(); + + planner.validateAll(view, List.of(first), cache); + planner.validateAll(view, List.of(first), cache); + planner.validateAll(view, List.of(second), cache); + planner.validateAll(view, List.of(first), cache); + + assertEquals(1L, cache.hits()); + assertEquals(3L, cache.misses()); + } + + @Test + public void geometricallyExposedPlannedVolumeRejectsBeforeVoxelLoading() { + CavePosition center = position(0, 10, 0); + AtomicInteger voxelLoads = new AtomicInteger(); + CaveVoxelView view = new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return true; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + voxelLoads.incrementAndGet(); + return CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return true; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return position.equals(center); + } + }; + + HydrologyCavePlan plan = planner.validate( + view, + candidate( + 2021L, + 10, + "water", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ) + ); + + assertRejectedWithoutPublication(plan, HydrologyCaveRejection.OPEN_SURFACE); + assertEquals(0, voxelLoads.get()); + } + + @Test + public void plannedWetVolumeSealsAClosedCaveContact() { + CavePosition center = position(0, 10, 0); + CavePosition closedCaveContact = position(1, 10, 0); + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, closedCaveContact); + HydrologyCavePlan plan = planner.validate( + view, + candidate( + 203L, + 10, + "water", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ) + ); + + assertTrue(plan.rejection().toString(), plan.accepted()); + assertEquals(HydrologyCaveAction.SEAL_GUARD, plan.actions().get(closedCaveContact)); + assertEquals(CaveVoxel.CAVE_AIR, plan.baselinePreconditions().get(closedCaveContact).voxel()); + } + + @Test + public void plannedWetVolumeSealsGeneratedSurfaceConnectedCaveBelowTerrain() { + CavePosition center = position(0, 10, 0); + CavePosition generatedOpening = position(1, 10, 0); + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, generatedOpening); + view.openToSurface(generatedOpening); + + HydrologyCavePlan plan = planner.validate( + view, + candidate( + 2031L, + 10, + "water", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ) + ); + + assertTrue(plan.rejection().toString(), plan.accepted()); + assertEquals(HydrologyCaveAction.SEAL_GUARD, plan.actions().get(generatedOpening)); + assertTrue(plan.baselinePreconditions().get(generatedOpening).openToSurface()); + } + + @Test + public void intentionalOpeningIsValidatedAndCapturedTransactionally() { + CavePosition center = position(0, 10, 0); + CavePosition opening = position(1, 10, 0); + HydrologyCaveSource source = new HydrologyCaveSource( + 2032L, + center, + center, + 10, + HydrologyCaveMode.GENERATED_GROTTO + ); + HydrologyCaveCandidate candidate = new HydrologyCaveCandidate( + source, + "water", + settings(), + true, + Map.of(center, HydrologyCaveAction.WET_SOURCE), + Set.of(opening) + ); + TestVoxelView acceptedView = new TestVoxelView(); + acceptedView.set(CaveVoxel.CAVE_AIR, opening); + acceptedView.openToSurface(opening); + acceptedView.aboveTerrain(opening); + + HydrologyCavePlan accepted = planner.validate(acceptedView, candidate); + assertTrue(accepted.rejection().toString(), accepted.accepted()); + assertEquals( + new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, true), + accepted.baselinePreconditions().get(opening) + ); + + TestVoxelView hazardousView = new TestVoxelView(); + hazardousView.set(CaveVoxel.LAVA, opening); + assertRejectedWithoutPublication( + planner.validate(hazardousView, candidate), + HydrologyCaveRejection.LAVA_CONTACT + ); + } + + @Test + public void unconditionalGeneratedOpeningRetainsIntentionalSurfaceExposureWithoutGuards() { + CavePosition center = position(0, 10, 0); + CavePosition opening = position(1, 10, 0); + HydrologyCaveCandidate candidate = new HydrologyCaveCandidate( + new HydrologyCaveSource( + 2033L, + center, + center, + 10, + HydrologyCaveMode.GENERATED_GROTTO + ), + "water", + settings(), + false, + Map.of(center, HydrologyCaveAction.WET_SOURCE), + Set.of(opening) + ); + CaveVoxelView generatedView = new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return true; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return CaveVoxel.UNCONDITIONAL; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return false; + } + }; + + HydrologyCavePlan plan = planner.validate(generatedView, candidate); + + assertTrue(plan.rejection().toString(), plan.accepted()); + assertEquals( + new CaveVoxelPrecondition(CaveVoxel.UNCONDITIONAL, true), + plan.baselinePreconditions().get(opening) + ); + assertTrue(plan.actions().values().stream() + .noneMatch((HydrologyCaveAction action) -> action == HydrologyCaveAction.SEAL_GUARD)); + } + + @Test + public void plannedDryHeadroomRetainsConfiguredClosedCaveConnection() { + CavePosition dryHeadroom = position(0, 11, 0); + CavePosition closedCaveContact = position(1, 11, 0); + TestVoxelView view = new TestVoxelView(); + view.set(CaveVoxel.CAVE_AIR, closedCaveContact); + HydrologyCavePlan plan = planner.validate( + view, + candidate( + 204L, + 10, + "water", + Map.of(dryHeadroom, HydrologyCaveAction.DRY_AIR), + detailedSettings( + 2, + 2, + HydrologyCaveGrottoShape.ELLIPSOID, + HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE + ) + ) + ); + + assertTrue(plan.rejection().toString(), plan.accepted()); + assertFalse(plan.actions().containsKey(closedCaveContact)); + assertEquals( + new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false), + plan.baselinePreconditions().get(closedCaveContact) + ); + } + + @Test + public void plannedVolumeOverlapArbitrationIsDeterministicAndTransactional() { + CavePosition center = position(0, 10, 0); + HydrologyCaveCandidate lower = candidate( + 220L, + 10, + "water", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ); + HydrologyCaveCandidate higher = candidate( + 210L, + 11, + "lava", + Map.of(center, HydrologyCaveAction.WET_SOURCE), + settings() + ); + TestVoxelView view = new TestVoxelView(); + + HydrologyCavePlanningResult forward = planner.validateAll(view, List.of(lower, higher)); + HydrologyCavePlanningResult reverse = planner.validateAll(view, List.of(higher, lower)); + List plansOnlyForward = planner.validateAllPlans(view, List.of(lower, higher)); + List plansOnlyReverse = planner.validateAllPlans(view, List.of(higher, lower)); + + assertEquals(forward, reverse); + assertEquals(forward.plans(), plansOnlyForward); + assertEquals(forward.plans(), plansOnlyReverse); + assertTrue(forward.plans().get(0).accepted()); + assertEquals(HydrologyCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); + assertEquals(210L, forward.plans().get(1).arbitrationWinnerSourceId().getAsLong()); + assertEquals(HydrologyCaveAction.WET_SOURCE, forward.actions().get(center)); + } + + @Test + public void plannedGuardAndMutationOverlapArbitrationIsDeterministic() { + CavePosition firstPosition = position(0, 10, 0); + CavePosition secondPosition = position(1, 10, 0); + HydrologyCaveCandidate first = candidate( + 230L, + 12, + "water", + Map.of(firstPosition, HydrologyCaveAction.WET_SOURCE), + settings() + ); + HydrologyCaveCandidate second = candidate( + 240L, + 11, + "water", + Map.of(secondPosition, HydrologyCaveAction.WET_SOURCE), + settings() + ); + TestVoxelView view = new TestVoxelView(); + + HydrologyCavePlanningResult forward = planner.validateAll(view, List.of(first, second)); + HydrologyCavePlanningResult reverse = planner.validateAll(view, List.of(second, first)); + List plansOnlyForward = planner.validateAllPlans(view, List.of(first, second)); + List plansOnlyReverse = planner.validateAllPlans(view, List.of(second, first)); + + assertEquals(forward, reverse); + assertEquals(forward.plans(), plansOnlyForward); + assertEquals(forward.plans(), plansOnlyReverse); + assertTrue(forward.plans().get(0).accepted()); + assertEquals(HydrologyCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); + assertEquals(230L, forward.plans().get(1).arbitrationWinnerSourceId().orElseThrow()); + assertEquals(HydrologyCaveAction.SEAL_GUARD, forward.actions().get(secondPosition)); + } + + private void assertMutationConnected(HydrologyCavePlan plan, CavePosition start, CavePosition expected) { + Set mutations = new HashSet<>(); + for (Map.Entry entry : plan.actions().entrySet()) { + if (entry.getValue() != HydrologyCaveAction.SEAL_GUARD) { + mutations.add(entry.getKey()); + } + } + Queue queue = new ArrayDeque<>(); + Set visited = new HashSet<>(); + queue.add(start); + visited.add(start); + while (!queue.isEmpty()) { + CavePosition position = queue.remove(); + for (CavePosition direction : DIRECTIONS) { + CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); + if (mutations.contains(neighbor) && visited.add(neighbor)) { + queue.add(neighbor); + } + } + } + assertTrue(visited.contains(expected)); + assertEquals(mutations, visited); + } + + private int mutationCount(HydrologyCavePlan plan) { + int count = 0; + for (HydrologyCaveAction action : plan.actions().values()) { + if (action != HydrologyCaveAction.SEAL_GUARD) { + count++; + } + } + return count; + } + + private void assertRejectedWithoutPublication(HydrologyCavePlan plan, HydrologyCaveRejection rejection) { + assertFalse(plan.accepted()); + assertEquals(rejection, plan.rejection()); + assertTrue(plan.actions().isEmpty()); + assertTrue(plan.baselinePreconditions().isEmpty()); + } + + private HydrologyCaveSource source(long sourceId, int waterHeadY, HydrologyCaveMode mode, CavePosition target) { + return new HydrologyCaveSource(sourceId, position(0, 12, 0), target, waterHeadY, mode); + } + + private HydrologyCaveCandidate candidate( + long sourceId, + int waterHeadY, + String profileKey, + Map actions, + HydrologyCavePlannerSettings settings + ) { + CavePosition sourcePosition = position(0, waterHeadY, 0); + return new HydrologyCaveCandidate( + new HydrologyCaveSource( + sourceId, + sourcePosition, + sourcePosition, + waterHeadY, + HydrologyCaveMode.GENERATED_GROTTO + ), + profileKey, + settings, + true, + actions, + Set.of() + ); + } + + private HydrologyCavePlannerSettings settings() { + return settings(HydrologyCaveFluidPolicy.ALLOW_COMPATIBLE); + } + + private HydrologyCavePlannerSettings settings(HydrologyCaveFluidPolicy policy) { + return new HydrologyCavePlannerSettings(12, 20, 64, 32, 2, 2, policy); + } + + private HydrologyCavePlannerSettings detailedSettings( + int throatRadius, + int dryHeadroom, + HydrologyCaveGrottoShape shape, + HydrologyCaveFluidPolicy policy + ) { + return new HydrologyCavePlannerSettings( + 12, + 20, + 2048, + 32, + throatRadius, + 4, + 4, + dryHeadroom, + policy, + shape + ); + } + + private CavePosition position(int x, int y, int z) { + return new CavePosition(x, y, z); + } + + private HydrologyObservedPlannedSurface observedSurface(int resolvedHeight) { + HydrologyCaveVoxelViewFactory.PlannedSurface surface = + (int x, int z, int naturalHeight) -> resolvedHeight; + return new HydrologyObservedPlannedSurface(surface); + } + + private CaveVoxelView plannedSurfaceView(HydrologyObservedPlannedSurface surface) { + return new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return true; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return isAboveTerrainSurface(position); + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return position.y() > surface.resolve(position.x(), position.z(), 32); + } + }; + } + + private static final class TestVoxelView implements CaveVoxelView { + private final int minX; + private final int maxX; + private final int minY; + private final int maxY; + private final int minZ; + private final int maxZ; + private final Map voxels = new HashMap<>(); + private final Set surfaceOpenings = new HashSet<>(); + private final Set aboveTerrain = new HashSet<>(); + + private TestVoxelView() { + this(-32, 32, 0, 32, -32, 32); + } + + private TestVoxelView(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) { + this.minX = minX; + this.maxX = maxX; + this.minY = minY; + this.maxY = maxY; + this.minZ = minZ; + this.maxZ = maxZ; + } + + @Override + public boolean isInWorld(CavePosition position) { + return position.x() >= minX + && position.x() <= maxX + && position.y() >= minY + && position.y() <= maxY + && position.z() >= minZ + && position.z() <= maxZ; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return voxels.getOrDefault(position, CaveVoxel.SOLID); + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return surfaceOpenings.contains(position); + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return aboveTerrain.contains(position); + } + + private void set(CaveVoxel voxel, CavePosition... positions) { + for (CavePosition position : positions) { + voxels.put(position, voxel); + } + } + + private void openToSurface(CavePosition position) { + surfaceOpenings.add(position); + } + + private void aboveTerrain(CavePosition position) { + aboveTerrain.add(position); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveIterationOrderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveIterationOrderTest.java new file mode 100644 index 000000000..e334913e0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveIterationOrderTest.java @@ -0,0 +1,150 @@ +package art.arcane.iris.engine.hydrology.cave; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.OptionalLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class HydrologyCaveIterationOrderTest { + @Test + public void candidatePreservesActionAndOpeningOrder() { + CavePosition first = new CavePosition(3, 20, 7); + CavePosition second = new CavePosition(-4, 11, 2); + LinkedHashMap actions = new LinkedHashMap<>(); + actions.put(first, HydrologyCaveAction.DRY_AIR); + actions.put(second, HydrologyCaveAction.WET_SOURCE); + LinkedHashSet openings = new LinkedHashSet<>(List.of(second, first)); + + HydrologyCaveCandidate candidate = new HydrologyCaveCandidate( + source(), + "water", + settings(), + false, + actions, + openings + ); + + assertEquals(List.of(first, second), new ArrayList<>(candidate.actions().keySet())); + assertEquals(List.of(second, first), new ArrayList<>(candidate.intentionalOpenings())); + actions.clear(); + openings.clear(); + assertEquals(List.of(first, second), new ArrayList<>(candidate.actions().keySet())); + assertEquals(List.of(second, first), new ArrayList<>(candidate.intentionalOpenings())); + assertThrows( + UnsupportedOperationException.class, + () -> candidate.actions().put(first, HydrologyCaveAction.WET_SOURCE) + ); + assertThrows(UnsupportedOperationException.class, () -> candidate.intentionalOpenings().add(second)); + } + + @Test + public void acceptedPlanAndPlanningResultPreserveMapOrder() { + CavePosition first = new CavePosition(3, 20, 7); + CavePosition second = new CavePosition(-4, 11, 2); + LinkedHashMap actions = new LinkedHashMap<>(); + actions.put(first, HydrologyCaveAction.DRY_AIR); + actions.put(second, HydrologyCaveAction.WET_SOURCE); + LinkedHashMap preconditions = new LinkedHashMap<>(); + preconditions.put(second, new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + preconditions.put(first, new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false)); + HydrologyCavePlan plan = new HydrologyCavePlan( + source(), + HydrologyCaveRejection.NONE, + actions, + preconditions, + OptionalLong.empty() + ); + HydrologyCavePlanningResult result = new HydrologyCavePlanningResult( + List.of(plan), + actions, + preconditions + ); + + assertEquals(List.of(first, second), new ArrayList<>(plan.actions().keySet())); + assertEquals(List.of(second, first), new ArrayList<>(plan.baselinePreconditions().keySet())); + assertEquals(List.of(first, second), new ArrayList<>(result.actions().keySet())); + assertEquals(List.of(second, first), new ArrayList<>(result.baselinePreconditions().keySet())); + actions.clear(); + preconditions.clear(); + assertEquals(List.of(first, second), new ArrayList<>(plan.actions().keySet())); + assertEquals(List.of(second, first), new ArrayList<>(plan.baselinePreconditions().keySet())); + assertEquals(List.of(first, second), new ArrayList<>(result.actions().keySet())); + assertEquals(List.of(second, first), new ArrayList<>(result.baselinePreconditions().keySet())); + assertThrows(UnsupportedOperationException.class, () -> plan.actions().remove(first)); + assertThrows(UnsupportedOperationException.class, () -> result.baselinePreconditions().remove(second)); + } + + @Test + public void packedPlanViewsImplementTheReadOnlyMapContract() { + CavePosition dry = new CavePosition(3, 20, 7); + CavePosition wet = new CavePosition(-4, 11, 2); + CavePosition guardOnly = new CavePosition(9, 4, -6); + CaveVoxelPrecondition dryBaseline = new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false); + CaveVoxelPrecondition wetBaseline = new CaveVoxelPrecondition(CaveVoxel.SOLID, true); + CaveVoxelPrecondition guardBaseline = new CaveVoxelPrecondition(CaveVoxel.LAVA, false); + Map actions = new LinkedHashMap<>(); + actions.put(dry, HydrologyCaveAction.DRY_AIR); + actions.put(wet, HydrologyCaveAction.WET_SOURCE); + Map preconditions = new LinkedHashMap<>(); + preconditions.put(guardOnly, guardBaseline); + preconditions.put(wet, wetBaseline); + preconditions.put(dry, dryBaseline); + HydrologyCavePlan plan = new HydrologyCavePlan( + source(), + HydrologyCaveRejection.NONE, + actions, + preconditions, + OptionalLong.empty() + ); + + assertEquals(HydrologyCaveAction.DRY_AIR, plan.actions().get(dry)); + assertNull(plan.actions().get(guardOnly)); + assertFalse(plan.actions().containsKey(guardOnly)); + assertTrue(plan.baselinePreconditions().containsKey(guardOnly)); + assertEquals(guardBaseline, plan.baselinePreconditions().get(guardOnly)); + assertEquals(actions, plan.actions()); + assertEquals(preconditions, plan.baselinePreconditions()); + assertEquals( + List.of(HydrologyCaveAction.DRY_AIR, HydrologyCaveAction.WET_SOURCE), + new ArrayList<>(plan.actions().values()) + ); + assertEquals( + List.of(guardBaseline, wetBaseline, dryBaseline), + new ArrayList<>(plan.baselinePreconditions().values()) + ); + assertThrows(UnsupportedOperationException.class, () -> plan.actions().clear()); + assertThrows(UnsupportedOperationException.class, () -> plan.baselinePreconditions().keySet().clear()); + } + + private HydrologyCaveSource source() { + CavePosition source = new CavePosition(3, 20, 7); + return new HydrologyCaveSource(17L, source, source, 20, HydrologyCaveMode.GENERATED_GROTTO); + } + + private HydrologyCavePlannerSettings settings() { + return new HydrologyCavePlannerSettings( + 8, + 16, + 64, + 16, + 1, + 2, + 2, + 4, + HydrologyCaveFluidPolicy.REJECT_EXISTING, + HydrologyCaveGrottoShape.ELLIPSOID, + 8, + 16 + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlanTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlanTest.java new file mode 100644 index 000000000..62cadee19 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlanTest.java @@ -0,0 +1,96 @@ +package art.arcane.iris.engine.hydrology.cave; + +import org.junit.Test; + +import java.util.Map; +import java.util.OptionalLong; +import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyCavePlanTest { + @Test + public void everyActionRequiresABaselinePrecondition() { + CavePosition actionPosition = new CavePosition(1, 20, 3); + + assertThrows(IllegalArgumentException.class, () -> new HydrologyCavePlan( + source(), + HydrologyCaveRejection.NONE, + Map.of(actionPosition, HydrologyCaveAction.WET_SOURCE), + Map.of(), + OptionalLong.empty() + )); + } + + @Test + public void extraDryBoundaryPreconditionsAreAllowed() { + CavePosition actionPosition = new CavePosition(1, 20, 3); + CavePosition dryBoundaryPosition = new CavePosition(2, 20, 3); + CaveVoxelPrecondition solid = new CaveVoxelPrecondition(CaveVoxel.SOLID, false); + CaveVoxelPrecondition dryBoundary = new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false); + HydrologyCavePlan plan = new HydrologyCavePlan( + source(), + HydrologyCaveRejection.NONE, + Map.of(actionPosition, HydrologyCaveAction.WET_SOURCE), + Map.of(actionPosition, solid, dryBoundaryPosition, dryBoundary), + OptionalLong.empty() + ); + + assertEquals(1, plan.actions().size()); + assertEquals(2, plan.baselinePreconditions().size()); + } + + @Test + public void boundedQueriesVisitOnlyExactPositionsAcrossNegativeChunkBoundaries() { + CavePosition inside = new CavePosition(-16, 20, 31); + CavePosition outsideX = new CavePosition(-17, 21, 31); + CavePosition outsideZ = new CavePosition(-16, 22, 32); + CavePosition preconditionOnly = new CavePosition(-15, 23, 30); + CaveVoxelPrecondition solid = new CaveVoxelPrecondition(CaveVoxel.SOLID, false); + LinkedHashMap actions = new LinkedHashMap<>(); + actions.put(outsideX, HydrologyCaveAction.DRY_AIR); + actions.put(inside, HydrologyCaveAction.WET_SOURCE); + actions.put(outsideZ, HydrologyCaveAction.SEAL_GUARD); + LinkedHashMap preconditions = new LinkedHashMap<>(); + preconditions.put(outsideX, solid); + preconditions.put(inside, solid); + preconditions.put(outsideZ, solid); + preconditions.put(preconditionOnly, solid); + HydrologyCavePlan plan = new HydrologyCavePlan( + source(), + HydrologyCaveRejection.NONE, + actions, + preconditions, + OptionalLong.empty() + ); + + assertTrue(plan.intersectsActions(-16, 30, 0, 32)); + assertFalse(plan.intersectsActions(-15, 30, 0, 31)); + ArrayList visitedActions = new ArrayList<>(); + plan.forEachActionIn(-16, 30, 0, 32, + (CavePosition position, HydrologyCaveAction action) -> visitedActions.add(position)); + assertEquals(List.of(inside), visitedActions); + ArrayList visitedPreconditions = new ArrayList<>(); + assertTrue(plan.allPreconditionsIn(-16, 30, 0, 32, + (CavePosition position, CaveVoxelPrecondition expected) -> { + visitedPreconditions.add(position); + return true; + })); + assertEquals(List.of(inside, preconditionOnly), visitedPreconditions); + } + + private HydrologyCaveSource source() { + return new HydrologyCaveSource( + 1L, + new CavePosition(1, 20, 3), + new CavePosition(2, 20, 3), + 20, + HydrologyCaveMode.CLOSED_COMPONENT + ); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorageTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorageTest.java similarity index 60% rename from core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorageTest.java rename to core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorageTest.java index 217d0fb4a..7bc26050b 100644 --- a/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveHydrologyStorageTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorageTest.java @@ -1,4 +1,4 @@ -package art.arcane.iris.engine.river.cave; +package art.arcane.iris.engine.hydrology.cave; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; @@ -12,23 +12,23 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -public class RiverCaveHydrologyStorageTest { +public class HydrologyCaveStorageTest { @Test @SuppressWarnings("unchecked") public void presentReadDoesNotMaterializeASectionOrSlice() { MantleChunk chunk = mock(MantleChunk.class); Matter matter = mock(Matter.class); - MatterSlice slice = mock(MatterSlice.class); - RiverCaveHydrology expected = RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE); + MatterSlice slice = mock(MatterSlice.class); + HydrologyCaveCell expected = HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE); when(chunk.exists(2)).thenReturn(true); when(chunk.get(2)).thenReturn(matter); - when(matter.hasSlice(RiverCaveHydrology.class)).thenReturn(true); - when(matter.getSlice(RiverCaveHydrology.class)).thenReturn(slice); + when(matter.hasSlice(HydrologyCaveCell.class)).thenReturn(true); + when(matter.getSlice(HydrologyCaveCell.class)).thenReturn(slice); when(slice.get(3, 1, 5)).thenReturn(expected); - assertSame(expected, RiverCaveHydrologyStorage.getIfPresent(chunk, 3, 33, 5)); + assertSame(expected, HydrologyCaveStorage.getIfPresent(chunk, 3, 33, 5)); verify(chunk, never()).getOrCreate(2); - verify(matter, never()).slice(RiverCaveHydrology.class); + verify(matter, never()).slice(HydrologyCaveCell.class); } @Test @@ -39,8 +39,8 @@ public class RiverCaveHydrologyStorageTest { when(chunk.exists(2)).thenReturn(true); when(chunk.get(2)).thenReturn(matter); - assertNull(RiverCaveHydrologyStorage.getIfPresent(chunk, 3, 33, 5)); - verify(matter, never()).slice(RiverCaveHydrology.class); - verify(matter, never()).getSlice(RiverCaveHydrology.class); + assertNull(HydrologyCaveStorage.getIfPresent(chunk, 3, 33, 5)); + verify(matter, never()).slice(HydrologyCaveCell.class); + verify(matter, never()).getSlice(HydrologyCaveCell.class); } } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java new file mode 100644 index 000000000..307f79930 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java @@ -0,0 +1,136 @@ +package art.arcane.iris.engine.hydrology.policy; + +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverPlacementMode; +import art.arcane.iris.engine.object.IrisRiverPolicy; +import art.arcane.iris.engine.object.IrisRiverRoutingMode; +import art.arcane.volmlib.util.collection.KList; +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.assertThrows; +import static org.junit.Assert.assertTrue; + +public class RiverPolicyResolverTest { + @Test + public void suppliesCanonicalDefaultsWithoutConfiguredPolicies() { + EffectiveRiverPolicy policy = RiverPolicyResolver.resolve( + (IrisRiverPolicy) null, + null, + null + ); + + assertEquals(IrisRiverPlacementMode.NATURAL, policy.placement()); + assertEquals(IrisRiverRoutingMode.ALLOW, policy.routing()); + assertTrue(policy.outletAdmission()); + assertTrue(policy.profiles().isEmpty()); + assertEquals(1D, policy.widthMultiplier(), 0D); + assertEquals(1D, policy.depthMultiplier(), 0D); + assertEquals(1D, policy.incisionMultiplier(), 0D); + assertEquals(1D, policy.routingMultiplier(), 0D); + assertTrue(policy.allowsSources()); + assertTrue(policy.allowsTransit()); + assertTrue(policy.allowsRouting()); + assertFalse(policy.prefersHeadwaters()); + assertFalse(policy.requiresHeadwaters()); + } + + @Test + public void overlaysDimensionThenRegionThenBiomeIncludingExplicitEmptyLists() { + IrisRiverPolicy dimension = new IrisRiverPolicy() + .setPlacement(IrisRiverPlacementMode.NATURAL) + .setRouting(IrisRiverRoutingMode.AVOID) + .setOutletAdmission(false) + .setProfiles(new KList<>("water")) + .setSurfaceBiomes(new KList<>("river/dimension")) + .setMouthBiomes(new KList<>("river/mouth")) + .setWidthMultiplier(1.5D) + .setDepthMultiplier(1.25D); + IrisRiverPolicy region = new IrisRiverPolicy() + .setRouting(IrisRiverRoutingMode.PREFER) + .setProfiles(new KList<>()) + .setSurfaceBiomes(new KList<>("river/region")) + .setShoreBiomes(new KList<>("river/shore")) + .setWidthMultiplier(0.8D) + .setRoutingMultiplier(2D); + IrisRiverPolicy biome = new IrisRiverPolicy() + .setPlacement(IrisRiverPlacementMode.REQUIRED_HEADWATER) + .setRouting(IrisRiverRoutingMode.BLOCK) + .setOutletAdmission(true) + .setSurfaceBiomes(new KList<>()) + .setDryBiomes(new KList<>("river/dry")) + .setFloodedCaveBiomes(new KList<>("river/flooded")) + .setDepthMultiplier(0.75D) + .setIncisionMultiplier(0.5D); + + EffectiveRiverPolicy policy = RiverPolicyResolver.resolve(dimension, region, biome); + + assertEquals(IrisRiverPlacementMode.REQUIRED_HEADWATER, policy.placement()); + assertEquals(IrisRiverRoutingMode.BLOCK, policy.routing()); + assertTrue(policy.outletAdmission()); + assertTrue(policy.profiles().isEmpty()); + assertTrue(policy.surfaceBiomes().isEmpty()); + assertEquals(List.of("river/mouth"), policy.mouthBiomes()); + assertEquals(List.of("river/shore"), policy.shoreBiomes()); + assertEquals(List.of("river/dry"), policy.dryBiomes()); + assertEquals(List.of("river/flooded"), policy.floodedCaveBiomes()); + assertEquals(0.8D, policy.widthMultiplier(), 0D); + assertEquals(0.75D, policy.depthMultiplier(), 0D); + assertEquals(0.5D, policy.incisionMultiplier(), 0D); + assertEquals(2D, policy.routingMultiplier(), 0D); + assertTrue(policy.allowsSources()); + assertTrue(policy.allowsTransit()); + assertFalse(policy.allowsRouting()); + assertTrue(policy.prefersHeadwaters()); + assertTrue(policy.requiresHeadwaters()); + } + + @Test + public void resolvesOwnerPoliciesAndSnapshotsMutableLists() { + KList profiles = new KList<>("water"); + IrisDimension dimension = new IrisDimension().setRiverPolicy(new IrisRiverPolicy() + .setProfiles(profiles) + .setPlacement(IrisRiverPlacementMode.PREFERRED_HEADWATER)); + IrisRegion region = new IrisRegion().setRiverPolicy(new IrisRiverPolicy() + .setRouting(IrisRiverRoutingMode.PREFER)); + IrisBiome biome = new IrisBiome().setRiverPolicy(new IrisRiverPolicy() + .setMouthBiomes(new KList<>("river/mouth"))); + + EffectiveRiverPolicy policy = RiverPolicyResolver.resolve(dimension, region, biome); + profiles.add("lava"); + + assertEquals(List.of("water"), policy.profiles()); + assertEquals(List.of("river/mouth"), policy.mouthBiomes()); + assertEquals(IrisRiverPlacementMode.PREFERRED_HEADWATER, policy.placement()); + assertEquals(IrisRiverRoutingMode.PREFER, policy.routing()); + assertThrows(UnsupportedOperationException.class, () -> policy.profiles().add("other")); + } + + @Test + public void placementModesExposeDistinctAdmissionSemantics() { + EffectiveRiverPolicy disabled = resolvePlacement(IrisRiverPlacementMode.DISABLED); + EffectiveRiverPolicy transitOnly = resolvePlacement(IrisRiverPlacementMode.TRANSIT_ONLY); + EffectiveRiverPolicy natural = resolvePlacement(IrisRiverPlacementMode.NATURAL); + EffectiveRiverPolicy preferred = resolvePlacement(IrisRiverPlacementMode.PREFERRED_HEADWATER); + EffectiveRiverPolicy required = resolvePlacement(IrisRiverPlacementMode.REQUIRED_HEADWATER); + + assertFalse(disabled.allowsSources()); + assertFalse(disabled.allowsTransit()); + assertFalse(transitOnly.allowsSources()); + assertTrue(transitOnly.allowsTransit()); + assertTrue(natural.allowsSources()); + assertFalse(natural.prefersHeadwaters()); + assertTrue(preferred.prefersHeadwaters()); + assertFalse(preferred.requiresHeadwaters()); + assertTrue(required.requiresHeadwaters()); + } + + private EffectiveRiverPolicy resolvePlacement(IrisRiverPlacementMode placement) { + return RiverPolicyResolver.resolve(new IrisRiverPolicy().setPlacement(placement), null, null); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchBoundsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchBoundsTest.java new file mode 100644 index 000000000..97dca351a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchBoundsTest.java @@ -0,0 +1,29 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class HydrologyFeatureSearchBoundsTest { + @Test + public void preservesRequestedDistanceWhenPublicationHaloFits() { + int accepted = HydrologyFeatureSearchBounds.maximumDistance( + 0, 0, 1024, 256, 128, 1_089); + + assertEquals(1024, accepted); + } + + @Test + public void reducesDistanceToTheLargestSafeTileWindow() { + int requested = 8192; + int accepted = HydrologyFeatureSearchBounds.maximumDistance( + 255, -129, requested, 256, 2_730, 1_089); + + assertTrue(accepted < requested); + assertTrue(HydrologyFeatureSearchBounds.tileCount( + 255, -129, accepted, 256, 2_730) <= 1_089L); + assertTrue(HydrologyFeatureSearchBounds.tileCount( + 255, -129, accepted + 1, 256, 2_730) > 1_089L); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyBiomePatchTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyBiomePatchTest.java new file mode 100644 index 000000000..39811e233 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyBiomePatchTest.java @@ -0,0 +1,72 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import org.junit.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class IrisHydrologyBiomePatchTest { + @Test + public void twoKeysFormCoherentDeterministicPatchesWithoutCheckerCells() { + int minimum = -64; + int maximum = 64; + int adjacentPairs = 0; + int adjacentMatches = 0; + int checkerCells = 0; + Set selected = new HashSet<>(); + + for (int z = minimum; z <= maximum; z++) { + for (int x = minimum; x <= maximum; x++) { + int value = IrisHydrologyRuntime.coherentKeyIndex(1337L, x, z, 3, 2); + assertEquals(value, IrisHydrologyRuntime.coherentKeyIndex(1337L, x, z, 3, 2)); + selected.add(value); + if (x < maximum) { + adjacentPairs++; + adjacentMatches += value == IrisHydrologyRuntime.coherentKeyIndex( + 1337L, + x + 1, + z, + 3, + 2 + ) ? 1 : 0; + } + if (z < maximum) { + adjacentPairs++; + adjacentMatches += value == IrisHydrologyRuntime.coherentKeyIndex( + 1337L, + x, + z + 1, + 3, + 2 + ) ? 1 : 0; + } + if (x == maximum || z == maximum) { + continue; + } + int east = IrisHydrologyRuntime.coherentKeyIndex(1337L, x + 1, z, 3, 2); + int south = IrisHydrologyRuntime.coherentKeyIndex(1337L, x, z + 1, 3, 2); + int southEast = IrisHydrologyRuntime.coherentKeyIndex(1337L, x + 1, z + 1, 3, 2); + if (value == southEast && east == south && value != east) { + checkerCells++; + } + } + } + + assertEquals(Set.of(0, 1), selected); + assertTrue(adjacentMatches > adjacentPairs * 0.9D); + assertEquals(0, checkerCells); + } + + @Test + public void oneKeyAlwaysSelectsTheOnlyEntry() { + assertEquals(0, IrisHydrologyRuntime.coherentKeyIndex(9L, Integer.MIN_VALUE, Integer.MAX_VALUE, 5, 1)); + } + + @Test(expected = IllegalArgumentException.class) + public void emptyKeySetIsRejected() { + IrisHydrologyRuntime.coherentKeyIndex(9L, 0, 0, 5, 0); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java new file mode 100644 index 000000000..66c140ab1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java @@ -0,0 +1,594 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.engine.hydrology.HydrologyRoutingTerrainSampler; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.util.common.parallel.MultiBurst; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class IrisHydrologyRoutingTerrainSamplerTest { + @Test + public void guardedGridSamplesOneAlignedBasisAtEachCoordinate() { + int[] spacings = new int[]{64, 128, 256}; + for (int spacing : spacings) { + int minimumX = -4096; + int minimumZ = -2048; + int width = 4096 / spacing + 1; + AtomicInteger basisCalls = new AtomicInteger(); + AtomicInteger heightCalls = new AtomicInteger(); + Map coordinateCalls = new HashMap<>(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> { + basisCalls.incrementAndGet(); + return basis(x, z, naturalHeight); + }, + (int x, int z) -> { + assertEquals(0, Math.floorMod(x - minimumX, spacing)); + assertEquals(0, Math.floorMod(z - minimumZ, spacing)); + heightCalls.incrementAndGet(); + coordinateCalls.merge(pack(x, z), 1, Integer::sum); + return height(x, z); + }, + (int x, int z) -> false + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(65_536) + ); + + HydrologyTerrainSample[] samples = sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest( + minimumX, + minimumZ, + width, + spacing + )); + + assertEquals(width * width, samples.length); + assertEquals((width + 1) * (width + 1), basisCalls.get()); + assertEquals(basisCalls.get(), heightCalls.get()); + assertEquals(heightCalls.get(), coordinateCalls.size()); + for (int count : coordinateCalls.values()) { + assertEquals(1, count); + } + } + } + + @Test + public void guardedForwardSlopeMatchesRawBitsAcrossNegativeBoundaries() { + int minimumX = -129; + int minimumZ = -257; + int width = 3; + int spacing = 64; + IrisHydrologyRoutingTerrainSampler sampler = sampler(64); + + HydrologyTerrainSample[] samples = sampler.sampleGrid( + new HydrologyRoutingTerrainSampler.GridRequest(minimumX, minimumZ, width, spacing) + ); + + for (int gridZ = 0; gridZ < width; gridZ++) { + int z = minimumZ + gridZ * spacing; + for (int gridX = 0; gridX < width; gridX++) { + int x = minimumX + gridX * spacing; + double scale = 3D / spacing; + double deltaX = (height(x + spacing, z) - height(x, z)) * scale; + double deltaZ = (height(x, z + spacing) - height(x, z)) * scale; + double expected = StrictMath.sqrt(deltaX * deltaX + deltaZ * deltaZ); + double actual = samples[gridZ * width + gridX].slope(); + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + } + } + } + + @Test + public void adjacentGridsShareBasesAndProduceIdenticalOverlap() { + AtomicInteger calls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> basis(x, z, naturalHeight), + (int x, int z) -> { + calls.incrementAndGet(); + return height(x, z); + }, + (int x, int z) -> false + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(64) + ); + + HydrologyTerrainSample[] first = sampler.sampleGrid( + new HydrologyRoutingTerrainSampler.GridRequest(-128, -128, 3, 64) + ); + assertEquals(16, calls.get()); + HydrologyTerrainSample[] second = sampler.sampleGrid( + new HydrologyRoutingTerrainSampler.GridRequest(0, -128, 3, 64) + ); + + assertEquals(24, calls.get()); + for (int gridZ = 0; gridZ < 3; gridZ++) { + assertEquals(first[gridZ * 3 + 2], second[gridZ * 3]); + } + } + + @Test + public void serialGridKeepsRowMajorProviderCallOrder() { + List calls = new ArrayList<>(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> { + calls.add("basis:" + x + "," + z); + return basis(x, z, naturalHeight); + }, + (int x, int z) -> { + calls.add("height:" + x + "," + z); + return height(x, z); + }, + (int x, int z) -> false + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(64) + ); + + sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest(-16, 32, 2, 16)); + + assertEquals( + List.of( + "height:-16,32", "basis:-16,32", + "height:0,32", "basis:0,32", + "height:16,32", "basis:16,32", + "height:-16,48", "basis:-16,48", + "height:0,48", "basis:0,48", + "height:16,48", "basis:16,48", + "height:-16,64", "basis:-16,64", + "height:0,64", "basis:0,64", + "height:16,64", "basis:16,64" + ), + calls + ); + } + + @Test + public void classifierUsesBasisAndCachesExactUnalignedResults() { + AtomicInteger classifierCalls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> oceanBasis(x, z, naturalHeight), + (int x, int z) -> height(x, z), + (int x, int z) -> { + classifierCalls.incrementAndGet(); + return x >= 0; + } + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16) + ); + sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest(0, 0, 1, 64)); + + assertEquals( + HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN, + sampler.classifyNatural(0, 0) + ); + assertEquals(0, classifierCalls.get()); + assertEquals( + HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN, + sampler.classifyNatural(1, -1) + ); + assertEquals( + HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN, + sampler.classifyNatural(1, -1) + ); + assertEquals(1, classifierCalls.get()); + } + + @Test + public void detailedSlopeKeepsExactPositiveThreeStencil() { + Map calls = new HashMap<>(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> basis(x, z, naturalHeight), + (int x, int z) -> { + calls.merge(pack(x, z), 1, Integer::sum); + return height(x, z); + }, + (int x, int z) -> false + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16) + ); + int x = -65; + int z = -127; + double naturalHeight = sampler.basis(x, z).naturalHeight(); + + double actual = sampler.localSlope(x, z, naturalHeight); + double deltaX = height(x + 3, z) - naturalHeight; + double deltaZ = height(x, z + 3) - naturalHeight; + double expected = Math.sqrt(deltaX * deltaX + deltaZ * deltaZ); + + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + assertEquals(Map.of(pack(x, z), 1, pack(x + 3, z), 1, pack(x, z + 3), 1), calls); + sampler.basis(x + 3, z); + sampler.localSlope(x + 3, z, height(x + 3, z)); + assertEquals(1, calls.get(pack(x + 3, z)).intValue()); + } + + @Test + public void denseDetailedStencilsShareRawNaturalHeights() { + AtomicInteger basisCalls = new AtomicInteger(); + AtomicInteger heightCalls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> { + basisCalls.incrementAndGet(); + return basis(x, z, naturalHeight); + }, + (int x, int z) -> { + heightCalls.incrementAndGet(); + return height(x, z); + }, + (int x, int z) -> false + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(64) + ); + + for (int z = 0; z <= 6; z += 3) { + for (int x = 0; x <= 6; x += 3) { + IrisHydrologyRoutingTerrainSampler.TerrainBasis basis = sampler.basis(x, z); + double actual = sampler.localSlope(x, z, basis.naturalHeight()); + double deltaX = height(x + 3, z) - height(x, z); + double deltaZ = height(x, z + 3) - height(x, z); + double expected = Math.sqrt(deltaX * deltaX + deltaZ * deltaZ); + assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual)); + } + } + + assertEquals(9, basisCalls.get()); + assertEquals(15, heightCalls.get()); + } + + @Test + public void cachesRemainBoundedAndCloseClearsThem() { + IrisHydrologyRoutingTerrainSampler sampler = sampler(3); + + sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest(0, 0, 1, 64)); + for (int coordinate = 1; coordinate <= 5; coordinate++) { + sampler.classifyNatural(coordinate, -coordinate); + } + + assertEquals(3, sampler.basisCacheSize()); + assertEquals(3, sampler.naturalHeightCacheSize()); + assertEquals(3, sampler.oceanClassificationCacheSize()); + sampler.close(); + assertEquals(0, sampler.basisCacheSize()); + assertEquals(0, sampler.naturalHeightCacheSize()); + assertEquals(0, sampler.oceanClassificationCacheSize()); + } + + @Test + public void primitiveCachesPreserveAccessOrderedEviction() { + Map basisCalls = new HashMap<>(); + Map heightCalls = new HashMap<>(); + Map classifierCalls = new HashMap<>(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> { + basisCalls.merge(pack(x, z), 1, Integer::sum); + return basis(x, z, naturalHeight); + }, + (int x, int z) -> { + heightCalls.merge(pack(x, z), 1, Integer::sum); + return height(x, z); + }, + (int x, int z) -> { + classifierCalls.merge(pack(x, z), 1, Integer::sum); + return false; + } + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(2) + ); + + sampler.basis(0, 0); + sampler.basis(1, 1); + sampler.basis(0, 0); + sampler.basis(2, 2); + sampler.basis(1, 1); + + assertEquals(1, basisCalls.get(pack(0, 0)).intValue()); + assertEquals(2, basisCalls.get(pack(1, 1)).intValue()); + assertEquals(1, basisCalls.get(pack(2, 2)).intValue()); + assertEquals(1, heightCalls.get(pack(0, 0)).intValue()); + assertEquals(1, heightCalls.get(pack(1, 1)).intValue()); + assertEquals(1, heightCalls.get(pack(2, 2)).intValue()); + + sampler.classifyNatural(10, 10); + sampler.classifyNatural(11, 11); + sampler.classifyNatural(10, 10); + sampler.classifyNatural(12, 12); + sampler.classifyNatural(11, 11); + + assertEquals(1, classifierCalls.get(pack(10, 10)).intValue()); + assertEquals(2, classifierCalls.get(pack(11, 11)).intValue()); + assertEquals(1, classifierCalls.get(pack(12, 12)).intValue()); + } + + @Test + public void primitiveHeightCacheRetainsNanValues() { + AtomicInteger heightCalls = new AtomicInteger(); + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> basis(x, z, naturalHeight), + (int x, int z) -> { + heightCalls.incrementAndGet(); + return Double.NaN; + }, + (int x, int z) -> false + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(16) + ); + + assertTrue(Double.isNaN(sampler.localSlope(0, 0, 0D))); + assertTrue(Double.isNaN(sampler.localSlope(0, 0, 0D))); + assertEquals(2, heightCalls.get()); + } + + @Test + public void invalidBoundsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> sampler(0)); + IrisHydrologyRoutingTerrainSampler sampler = sampler(16); + assertThrows( + IllegalArgumentException.class, + () -> new HydrologyRoutingTerrainSampler.GridRequest(0, 0, 0, 64) + ); + assertThrows( + IllegalArgumentException.class, + () -> new HydrologyRoutingTerrainSampler.GridRequest(0, 0, 1, 0) + ); + assertThrows( + IllegalArgumentException.class, + () -> IrisHydrologyRoutingTerrainSampler.routingSlope(1D, 2D, 3D, 0) + ); + } + + @Test + public void parallelGridMatchesSerialRawBitsAndSamplesEachProviderOnce() throws Exception { + int minimumX = -2048; + int minimumZ = 4096; + int width = 33; + int spacing = 64; + int expectedCalls = (width + 1) * (width + 1); + AtomicInteger serialBasisCalls = new AtomicInteger(); + AtomicInteger serialHeightCalls = new AtomicInteger(); + AtomicInteger parallelBasisCalls = new AtomicInteger(); + AtomicInteger parallelHeightCalls = new AtomicInteger(); + AtomicInteger submissions = new AtomicInteger(); + Map serialCoordinateCalls = new HashMap<>(); + Map parallelCoordinateCalls = new ConcurrentHashMap<>(); + ExecutorService executor = Executors.newFixedThreadPool(4); + Executor recordingExecutor = (Runnable command) -> { + submissions.incrementAndGet(); + executor.execute(command); + }; + try { + IrisHydrologyRoutingTerrainSampler serial = countedSampler( + 65_536, + 1, + Runnable::run, + serialBasisCalls, + serialHeightCalls, + serialCoordinateCalls + ); + IrisHydrologyRoutingTerrainSampler parallel = countedSampler( + 65_536, + 4, + recordingExecutor, + parallelBasisCalls, + parallelHeightCalls, + parallelCoordinateCalls + ); + HydrologyRoutingTerrainSampler.GridRequest request = new HydrologyRoutingTerrainSampler.GridRequest( + minimumX, + minimumZ, + width, + spacing + ); + + HydrologyTerrainSample[] serialSamples = serial.sampleGrid(request); + HydrologyTerrainSample[] parallelSamples = parallel.sampleGrid(request); + + assertEquals(serialSamples.length, parallelSamples.length); + for (int index = 0; index < serialSamples.length; index++) { + assertSampleRawBits(serialSamples[index], parallelSamples[index]); + } + assertEquals(expectedCalls, serialBasisCalls.get()); + assertEquals(expectedCalls, serialHeightCalls.get()); + assertEquals(expectedCalls, parallelBasisCalls.get()); + assertEquals(expectedCalls, parallelHeightCalls.get()); + assertCoordinateCalls(serialCoordinateCalls, expectedCalls); + assertCoordinateCalls(parallelCoordinateCalls, expectedCalls); + assertTrue(submissions.get() > 1); + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + @Test + public void burstWorkerUsesAvailableParallelRows() throws Exception { + AtomicInteger submissions = new AtomicInteger(); + AtomicInteger heightCalls = new AtomicInteger(); + MultiBurst burst = new MultiBurst("Iris Hydrology Test", Thread.NORM_PRIORITY, () -> 2); + Executor recordingExecutor = (Runnable command) -> { + submissions.incrementAndGet(); + burst.execute(command); + }; + try { + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> basis(x, z, naturalHeight), + (int x, int z) -> { + heightCalls.incrementAndGet(); + return height(x, z); + }, + (int x, int z) -> false + ), + new IrisHydrologyRoutingTerrainSampler.SamplingOptions( + 65_536, + 4, + recordingExecutor + ) + ); + int width = 21; + + Future completion = burst.submit( + () -> sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest(0, 0, width, 64)) + ); + completion.get(5L, TimeUnit.SECONDS); + + assertTrue(submissions.get() > 1); + assertEquals((width + 1) * (width + 1), heightCalls.get()); + } finally { + burst.close(); + } + } + + @Test + public void parallelGridPropagatesProviderFailure() throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> basis(x, z, naturalHeight), + (int x, int z) -> { + if (x == 0 && z == 0) { + throw new IllegalStateException("expected sampling failure"); + } + return height(x, z); + }, + (int x, int z) -> false + ), + new IrisHydrologyRoutingTerrainSampler.SamplingOptions( + 65_536, + 2, + executor + ) + ); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> sampler.sampleGrid(new HydrologyRoutingTerrainSampler.GridRequest(0, 0, 33, 64)) + ); + + assertEquals("expected sampling failure", failure.getMessage()); + } finally { + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5L, TimeUnit.SECONDS)); + } + } + + private IrisHydrologyRoutingTerrainSampler sampler(int maximumEntries) { + return new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> basis(x, z, naturalHeight), + (int x, int z) -> height(x, z), + (int x, int z) -> x >= 1 + ), + IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(maximumEntries) + ); + } + + private IrisHydrologyRoutingTerrainSampler countedSampler( + int maximumEntries, + int maximumWorkers, + Executor executor, + AtomicInteger basisCalls, + AtomicInteger heightCalls, + Map coordinateCalls + ) { + return new IrisHydrologyRoutingTerrainSampler( + new IrisHydrologyRoutingTerrainSampler.Sources( + (int x, int z, double naturalHeight) -> { + basisCalls.incrementAndGet(); + return basis(x, z, naturalHeight); + }, + (int x, int z) -> { + heightCalls.incrementAndGet(); + coordinateCalls.merge(pack(x, z), 1, Integer::sum); + return height(x, z); + }, + (int x, int z) -> false + ), + new IrisHydrologyRoutingTerrainSampler.SamplingOptions( + maximumEntries, + maximumWorkers, + executor + ) + ); + } + + private void assertCoordinateCalls(Map coordinateCalls, int expectedCalls) { + assertEquals(expectedCalls, coordinateCalls.size()); + for (int count : coordinateCalls.values()) { + assertEquals(1, count); + } + } + + private void assertSampleRawBits(HydrologyTerrainSample expected, HydrologyTerrainSample actual) { + assertEquals(expected, actual); + assertEquals(Double.doubleToRawLongBits(expected.slope()), Double.doubleToRawLongBits(actual.slope())); + assertEquals(Double.doubleToRawLongBits(expected.routingCost()), Double.doubleToRawLongBits(actual.routingCost())); + assertEquals( + Double.doubleToRawLongBits(expected.surfaceSourceWeight()), + Double.doubleToRawLongBits(actual.surfaceSourceWeight()) + ); + assertEquals( + Double.doubleToRawLongBits(expected.undergroundSourceWeight()), + Double.doubleToRawLongBits(actual.undergroundSourceWeight()) + ); + assertEquals( + Double.doubleToRawLongBits(expected.widthMultiplier()), + Double.doubleToRawLongBits(actual.widthMultiplier()) + ); + assertEquals( + Double.doubleToRawLongBits(expected.depthMultiplier()), + Double.doubleToRawLongBits(actual.depthMultiplier()) + ); + assertEquals( + Double.doubleToRawLongBits(expected.incisionMultiplier()), + Double.doubleToRawLongBits(actual.incisionMultiplier()) + ); + assertEquals( + Double.doubleToRawLongBits(expected.routingMultiplier()), + Double.doubleToRawLongBits(actual.routingMultiplier()) + ); + } + + private IrisHydrologyRoutingTerrainSampler.TerrainBasis basis(int x, int z, double height) { + return new IrisHydrologyRoutingTerrainSampler.TerrainBasis( + height, + HydrologyTerrainSample.openLand((int) StrictMath.round(height), 0D, "parent") + ); + } + + private IrisHydrologyRoutingTerrainSampler.TerrainBasis oceanBasis(int x, int z, double height) { + return new IrisHydrologyRoutingTerrainSampler.TerrainBasis( + height, + HydrologyTerrainSample.ocean((int) StrictMath.round(height), "parent") + ); + } + + private double height(int x, int z) { + return x * 0.125D + z * 0.0625D + Math.floorMod(x * 31 + z * 17, 11) * 0.03125D; + } + + private long pack(int x, int z) { + return ((long) x << 32) ^ (z & 0xffffffffL); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java new file mode 100644 index 000000000..86bb40d98 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java @@ -0,0 +1,136 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.object.IrisDeepFluidConfig; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisRiverHydrology; +import art.arcane.iris.engine.object.IrisRiverInlandOutlet; +import art.arcane.iris.engine.object.IrisRiverRoutingConfig; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisHydrologyRuntimeSettingsTest { + @Test + public void deepChannelsRemainLocalWhenConfiguredSpacingIsLarge() { + IrisDeepFluidConfig deepFluid = new IrisDeepFluidConfig() + .setShortChannels(true) + .setSpacing(8192); + IrisRiverRoutingConfig routing = new IrisRiverRoutingConfig() + .setTileSize(256) + .setRefinementSpacing(8); + + assertEquals(128, IrisHydrologyRuntime.maximumDeepChannelLength(deepFluid, routing)); + } + + @Test + public void disabledDeepChannelsHaveNoPublicationReach() { + IrisDeepFluidConfig deepFluid = new IrisDeepFluidConfig() + .setShortChannels(false) + .setSpacing(8192); + IrisRiverRoutingConfig routing = new IrisRiverRoutingConfig() + .setTileSize(256) + .setRefinementSpacing(8); + + assertEquals(0, IrisHydrologyRuntime.maximumDeepChannelLength(deepFluid, routing)); + } + + @Test + public void surfaceSinkholeSettingRequiresTheCanonicalEnabledInlandOutlet() { + IrisDimension dimension = new IrisDimension(); + IrisRiverHydrology rivers = dimension.getHydrology().getRivers(); + rivers.setEnabled(true); + rivers.getGrottos().getInland().setEnabled(true).setConnectSurfaceRivers(true); + rivers.getRouting().getInlandOutlets().add(IrisRiverInlandOutlet.SINKHOLE_GROTTO); + + assertTrue(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + .outlets().surfaceSinkholesEnabled()); + + rivers.getRouting().getInlandOutlets().clear(); + + assertFalse(IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + .outlets().surfaceSinkholesEnabled()); + } + + @Test + public void denseRiverSourcesRemainDistinctAndCanFillTheirTileQuota() { + IrisDimension dimension = new IrisDimension(); + IrisRiverHydrology rivers = dimension.getHydrology().getRivers(); + rivers.setEnabled(true); + rivers.getSurface().setEnabled(true); + rivers.getSurface().getSources().setDensity(4.5D); + rivers.getUnderground().setEnabled(true); + rivers.getUnderground().getSources().setDensity(4.5D); + rivers.getRouting().setSampleSpacing(64); + + assertEquals(5, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + .surface().sources().maximumPerTile()); + assertEquals(384, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + .surface().sources().minimumSpacing()); + assertEquals(5, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + .underground().sources().maximumPerTile()); + assertEquals(512, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + .underground().sources().minimumSpacing()); + } + + @Test + public void routingOutletLimitControlsDrainageRootCount() { + IrisDimension dimension = new IrisDimension(); + dimension.getHydrology().getRivers().getRouting().setMaximumOutletsPerTile(2); + + assertEquals(2, IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()) + .outlets().maximumPerTile()); + } + + @Test + public void organicGeometryConfigurationMapsWithoutLosingPrecision() { + IrisDimension dimension = new IrisDimension(); + IrisRiverHydrology rivers = dimension.getHydrology().getRivers(); + rivers.getGeometry().getMeanders() + .setPrimaryWavelength(47) + .setDetailWavelength(9) + .setPrimaryStrength(0.31D) + .setDetailStrength(0.53D) + .setMaximumOffsetRatio(0.44D) + .setSmoothingPasses(2) + .setMaximumTurnDegrees(76D); + rivers.getGeometry().getSurface() + .setBedRoundness(3.1D) + .setBedRoughness(0.37D) + .setWallRoughness(0.29D) + .setRoughnessWavelength(13); + rivers.getGeometry().getDrops() + .setCascadeRunPerBlock(4) + .setCascadeExponent(2.2D) + .setMaximumCascadeStep(1) + .setFlowWidthRatio(0.81D) + .setMaximumFlowDepth(3) + .setBasinWidthRatio(2.1D) + .setMaximumBasinDepth(9); + + HydrologyPlannerSettings.Geometry geometry = IrisHydrologyRuntime + .createSettings(dimension, dimension.getHydrology()) + .geometry(); + + assertEquals(47, geometry.meanders().primaryWavelength()); + assertEquals(9, geometry.meanders().detailWavelength()); + assertEquals(0.31D, geometry.meanders().primaryStrength(), 0D); + assertEquals(0.53D, geometry.meanders().detailStrength(), 0D); + assertEquals(0.44D, geometry.meanders().maximumOffsetRatio(), 0D); + assertEquals(2, geometry.meanders().smoothingPasses()); + assertEquals(76D, geometry.meanders().maximumTurnDegrees(), 0D); + assertEquals(3.1D, geometry.surface().bedRoundness(), 0D); + assertEquals(0.37D, geometry.surface().bedRoughness(), 0D); + assertEquals(0.29D, geometry.surface().wallRoughness(), 0D); + assertEquals(13, geometry.surface().roughnessWavelength()); + assertEquals(4, geometry.drops().cascadeRunPerBlock()); + assertEquals(2.2D, geometry.drops().cascadeExponent(), 0D); + assertEquals(1, geometry.drops().maximumCascadeStep()); + assertEquals(0.81D, geometry.drops().flowWidthRatio(), 0D); + assertEquals(3, geometry.drops().maximumFlowDepth()); + assertEquals(2.1D, geometry.drops().basinWidthRatio(), 0D); + assertEquals(9, geometry.drops().maximumBasinDepth()); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java index 87f62fc4e..a62f60a32 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/EngineMantleCleanupTest.java @@ -2,11 +2,12 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.engine.framework.TreeBlockMaterial; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.project.matter.PreObjectMatterCell; import art.arcane.iris.util.project.matter.TileWrapper; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.Mantle; @@ -18,6 +19,12 @@ import org.junit.Before; import org.junit.Test; import org.mockito.InOrder; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.Answers.CALLS_REAL_METHODS; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -98,6 +105,16 @@ public class EngineMantleCleanupTest { verify(fixture.chunk()).deleteSlices(PlatformBlockState.class); } + @Test + public void preObjectJournalIsReclaimedEvenWhenRetentionWasRequested() { + MantleSliceRetention.retain(PreObjectMatterCell.class.getCanonicalName()); + CleanupFixture fixture = cleanupFixture(); + + assertTrue(fixture.engineMantle().forceCleanupChunk(7, -4)); + + verify(fixture.chunk()).deleteSlices(PreObjectMatterCell.class); + } + @Test public void coveredCleanupRetainsDeferredMaterializationSlices() { CleanupFixture fixture = cleanupFixture(); @@ -117,12 +134,53 @@ public class EngineMantleCleanupTest { verifyCleanup(fixture.chunk()); } + @Test + public void forceCleanupWaitsForTheFullGenerationHalo() { + CleanupFixture fixture = cleanupFixture(); + doReturn(false).when(fixture.engineMantle()).isCovered(7, -4); + + assertFalse(fixture.engineMantle().forceCleanupChunk(7, -4)); + + verify(fixture.chunk(), never()).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any()); + verify(fixture.chunk(), never()).deleteSlices(PreObjectMatterCell.class); + } + + @Test + public void laterRealNeighborRetriesCoveredFrontierAndReportsOnlyActualCleanup() { + CleanupFixture fixture = cleanupFixture(); + doReturn(1).when(fixture.engineMantle()).getRadius(); + doReturn(false).when(fixture.engineMantle()).isCovered(7, -4); + when(fixture.mantle().hasFlag(6, -4, MantleFlag.CLEANED)).thenReturn(true); + List cleaned = new ArrayList<>(); + + fixture.engineMantle().cleanupChunksCoveredBy( + 6, + -4, + false, + (x, z) -> cleaned.add(x + "," + z) + ); + assertTrue(cleaned.isEmpty()); + verify(fixture.engineMantle(), never()).isCovered(6, -4); + + doReturn(true).when(fixture.engineMantle()).isCovered(7, -4); + fixture.engineMantle().cleanupChunksCoveredBy( + 8, + -4, + false, + (x, z) -> cleaned.add(x + "," + z) + ); + + assertEquals(List.of("7,-4"), cleaned); + verify(fixture.chunk()).deleteSlices(PreObjectMatterCell.class); + } + @SuppressWarnings("unchecked") private CleanupFixture cleanupFixture() { EngineMantle engineMantle = mock(EngineMantle.class, CALLS_REAL_METHODS); Mantle mantle = mock(Mantle.class); MantleChunk chunk = mock(MantleChunk.class); when(engineMantle.getMantle()).thenReturn(mantle); + doReturn(true).when(engineMantle).isCovered(7, -4); when(mantle.getChunk(7, -4)).thenReturn(chunk); when(chunk.use()).thenReturn(chunk); doAnswer(invocation -> { @@ -130,20 +188,25 @@ public class EngineMantleCleanupTest { cleanup.run(); return null; }).when(chunk).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any()); - return new CleanupFixture(engineMantle, chunk); + return new CleanupFixture(engineMantle, mantle, chunk); } private void verifyCleanup(MantleChunk chunk) { verify(chunk).deleteSlices(PlatformBlockState.class); verify(chunk).deleteSlices(MatterCavern.class); + verify(chunk).deleteSlices(PreObjectMatterCell.class); verify(chunk, never()).deleteSlices(TileWrapper.class); verify(chunk, never()).deleteSlices(Identifier.class); - verify(chunk, never()).deleteSlices(RiverCaveHydrology.class); + verify(chunk, never()).deleteSlices(HydrologyCaveCell.class); InOrder order = inOrder(chunk); order.verify(chunk).raiseFlagUnchecked(eq(MantleFlag.CLEANED), any()); order.verify(chunk).release(); } - private record CleanupFixture(EngineMantle engineMantle, MantleChunk chunk) { + private record CleanupFixture( + EngineMantle engineMantle, + Mantle mantle, + MantleChunk chunk + ) { } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java index cdedfb14d..910e1b471 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterOverlayTest.java @@ -1,8 +1,10 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.core.link.Identifier; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; @@ -19,6 +21,8 @@ import org.junit.Test; import static org.junit.Assert.assertFalse; 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.doReturn; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -37,7 +41,8 @@ public class MantleWriterOverlayTest { private MatterSlice blockSlice; private MatterSlice identifierSlice; private MatterSlice cavernSlice; - private MatterSlice hydrologySlice; + private MatterSlice hydrologySlice; + private IrisDimension dimension; @Before @SuppressWarnings("unchecked") @@ -51,6 +56,8 @@ public class MantleWriterOverlayTest { IrisPlatforms.bind(platform); EngineMantle engineMantle = mock(EngineMantle.class); + Engine engine = mock(Engine.class); + dimension = mock(IrisDimension.class); Mantle mantle = mock(Mantle.class); chunk = mock(MantleChunk.class); matter = mock(Matter.class); @@ -60,6 +67,8 @@ public class MantleWriterOverlayTest { hydrologySlice = mock(MatterSlice.class); when(mantle.getWorldHeight()).thenReturn(64); + when(engineMantle.getEngine()).thenReturn(engine); + when(engine.getDimension()).thenReturn(dimension); when(mantle.getChunk(0, 0)).thenReturn(chunk); when(chunk.use()).thenReturn(chunk); when(chunk.getOrCreate(0)).thenReturn(matter); @@ -72,8 +81,9 @@ public class MantleWriterOverlayTest { when(matter.getSlice(Identifier.class)).thenReturn(identifierSlice); when(matter.getSlice(MatterCavern.class)).thenReturn(cavernSlice); doReturn(blockSlice).when(matter).slice(PlatformBlockState.class); + doReturn(identifierSlice).when(matter).slice(Identifier.class); doReturn(cavernSlice).when(matter).slice(MatterCavern.class); - doReturn(hydrologySlice).when(matter).getSlice(RiverCaveHydrology.class); + doReturn(hydrologySlice).when(matter).getSlice(HydrologyCaveCell.class); writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false); } @@ -117,13 +127,52 @@ public class MantleWriterOverlayTest { verify(blockSlice).set(X, Y, Z, replacement); } + @Test + public void customBlockWritesBaseAndIdentifierTogether() { + PlatformBlockState base = mock(PlatformBlockState.class); + PlatformBlockState custom = customState(base); + Identifier identifier = Identifier.fromString("iris:custom_block"); + + writer.set(X, Y, Z, custom); + + verify(blockSlice).set(X, Y, Z, base); + verify(identifierSlice).set(X, Y, Z, identifier); + } + + @Test + public void protectedHydrologyRejectsBothPartsOfCustomBlock() { + PlatformBlockState base = mock(PlatformBlockState.class); + PlatformBlockState custom = customState(base); + when(matter.hasSlice(HydrologyCaveCell.class)).thenReturn(true); + when(hydrologySlice.get(X, Y, Z)) + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + + writer.set(X, Y, Z, custom); + + verify(blockSlice, never()).set(X, Y, Z, base); + verify(identifierSlice, never()).set(anyInt(), anyInt(), anyInt(), any(Identifier.class)); + } + + @Test + public void bedrockAndWorldBoundsRejectBothPartsOfCustomBlock() { + PlatformBlockState base = mock(PlatformBlockState.class); + PlatformBlockState custom = customState(base); + when(dimension.isBedrock()).thenReturn(true); + + writer.set(X, 0, Z, custom); + writer.set(X, 64, Z, custom); + + verify(blockSlice, never()).set(anyInt(), anyInt(), anyInt(), any(PlatformBlockState.class)); + verify(identifierSlice, never()).set(anyInt(), anyInt(), anyInt(), any(Identifier.class)); + } + @Test public void protectedHydrologyRejectsLaterBlockAndCavernWrites() { PlatformBlockState replacement = mock(PlatformBlockState.class); MatterCavern cavern = new MatterCavern(true, "", (byte) 3); - when(matter.hasSlice(RiverCaveHydrology.class)).thenReturn(true); + when(matter.hasSlice(HydrologyCaveCell.class)).thenReturn(true); when(hydrologySlice.get(X, Y, Z)) - .thenReturn(RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD)); + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); writer.setData(X, Y, Z, replacement); assertFalse(writer.carveDataIfAbsent(X, Y, Z, cavern)); @@ -138,12 +187,12 @@ public class MantleWriterOverlayTest { @Test public void hydrologyOverridesBaselineCarvedQueriesWithoutChangingIt() { MatterCavern baseline = new MatterCavern(true, "", (byte) 0); - when(matter.hasSlice(RiverCaveHydrology.class)).thenReturn(true); + when(matter.hasSlice(HydrologyCaveCell.class)).thenReturn(true); when(cavernSlice.get(X, Y, Z)).thenReturn(baseline); when(hydrologySlice.get(X, Y, Z)) - .thenReturn(RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD)); + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); when(hydrologySlice.get(X, Y + 1, Z)) - .thenReturn(RiverCaveHydrology.of(RiverCaveAction.DRY_AIR)); + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)); assertFalse(writer.isCarved(X, Y, Z)); assertTrue(writer.isCarved(X, Y + 1, Z)); @@ -153,4 +202,12 @@ public class MantleWriterOverlayTest { assertEquals(1, column[Y + 1]); assertEquals(0, baseline.getLiquid()); } + + private static PlatformBlockState customState(PlatformBlockState base) { + PlatformBlockState custom = mock(PlatformBlockState.class); + when(custom.isCustom()).thenReturn(true); + when(custom.deferredPlacementKey()).thenReturn("iris:custom_block"); + when(custom.placementBaseState()).thenReturn(base); + return custom; + } } diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterPreObjectJournalTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterPreObjectJournalTest.java new file mode 100644 index 000000000..138452354 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MantleWriterPreObjectJournalTest.java @@ -0,0 +1,238 @@ +package art.arcane.iris.engine.mantle; + +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.util.project.matter.IrisMatterSupport; +import art.arcane.iris.util.project.matter.PreObjectMatterCell; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterSlice; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.block.data.BlockData; +import org.junit.After; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +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.Locale; +import java.util.logging.Logger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +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.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class MantleWriterPreObjectJournalTest { + private static final int X = 2; + private static final int Y = 5; + private static final int Z = 4; + + private MantleWriter writer; + private Matter matter; + + @BeforeClass + public static void setUpBukkit() { + if (Bukkit.getServer() != null) { + return; + } + Server server = mock(Server.class); + doReturn(Logger.getLogger("IrisTest")).when(server).getLogger(); + doReturn("IrisTestServer").when(server).getName(); + doReturn("1.0").when(server).getVersion(); + doReturn("1.0").when(server).getBukkitVersion(); + doAnswer(invocation -> blockData(invocation.getArgument(0, Material.class).name() + .toLowerCase(Locale.ROOT))).when(server).createBlockData(any(Material.class)); + try { + Bukkit.setServer(server); + } catch (Throwable ignored) { + } + } + + @Before + @SuppressWarnings("unchecked") + public void setUp() { + IrisPlatforms.unbind(); + PlatformBlockState air = mock(PlatformBlockState.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + IrisPlatform platform = mock(IrisPlatform.class); + when(registries.air()).thenReturn(air); + when(registries.block(anyString())).thenReturn(air); + when(platform.registries()).thenReturn(registries); + IrisPlatforms.bind(platform); + IrisMatterSupport.ensureRegistered(); + + EngineMantle engineMantle = mock(EngineMantle.class); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + matter = new IrisMatter(16, 16, 16); + when(mantle.getWorldHeight()).thenReturn(32); + when(mantle.getChunk(0, 0)).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + when(chunk.exists(0)).thenReturn(true); + when(chunk.getOrCreate(0)).thenReturn(matter); + when(chunk.get(0)).thenReturn(matter); + writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false); + } + + @After + public void tearDown() { + if (writer != null) { + writer.close(); + } + IrisPlatforms.unbind(); + } + + @Test + public void firstMutationCapturesEachSupportedOriginalIndependently() { + PlatformBlockState originalBlock = mock(PlatformBlockState.class); + PlatformBlockState firstBlock = mock(PlatformBlockState.class); + PlatformBlockState secondBlock = mock(PlatformBlockState.class); + MatterCavern originalCavern = new MatterCavern(true, "old", (byte) 1); + MatterCavern replacementCavern = new MatterCavern(true, "new", (byte) 2); + matter.slice(PlatformBlockState.class).set(X, Y, Z, originalBlock); + matter.slice(MatterCavern.class).set(X, Y, Z, originalCavern); + + writer.withComponentPriority(2, () -> { + writer.setData(X, Y, Z, firstBlock); + writer.setData(X, Y, Z, "new-marker"); + writer.setData(X, Y, Z, replacementCavern); + writer.setData(X, Y, Z, secondBlock); + writer.setData(X, Y, Z, "newer-marker"); + }); + + assertSame(originalBlock, writer.getPrerequisiteDataIfPresent(X, Y, Z, PlatformBlockState.class)); + assertNull(writer.getPrerequisiteDataIfPresent(X, Y, Z, String.class)); + assertEquals(originalCavern, writer.getPrerequisiteDataIfPresent(X, Y, Z, MatterCavern.class)); + assertSame(secondBlock, writer.getDataIfPresent(X, Y, Z, PlatformBlockState.class)); + assertEquals("newer-marker", writer.getDataIfPresent(X, Y, Z, String.class)); + assertEquals(replacementCavern, writer.getDataIfPresent(X, Y, Z, MatterCavern.class)); + + PreObjectMatterCell journal = matter.getSlice(PreObjectMatterCell.class) + .get(X, Y, Z); + assertTrue(journal.blockCaptured()); + assertTrue(journal.stringCaptured()); + assertTrue(journal.cavernCaptured()); + } + + @Test + public void priorityScopeIsNestedAndThreadLocal() throws Exception { + writer.withComponentPriority(2, () -> { + writer.withComponentPriority(1, () -> writer.setData(X, Y, Z, "uncaptured-inner")); + writer.setData(X + 1, Y, Z, "captured-outer"); + }); + assertThrows(IllegalStateException.class, () -> writer.withComponentPriority(2, () -> { + throw new IllegalStateException("scope failure"); + })); + writer.setData(X + 2, Y, Z, "uncaptured-after-failure"); + + ExecutorService executor = Executors.newFixedThreadPool(2); + CountDownLatch start = new CountDownLatch(1); + try { + Future scoped = executor.submit(() -> { + await(start); + writer.withComponentPriority(2, () -> writer.setData(X + 3, Y, Z, "captured-thread")); + }); + Future unscoped = executor.submit(() -> { + await(start); + writer.setData(X + 4, Y, Z, "uncaptured-thread"); + }); + start.countDown(); + scoped.get(5, TimeUnit.SECONDS); + unscoped.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + + MatterSlice journal = matter.getSlice(PreObjectMatterCell.class); + assertNull(journal.get(X, Y, Z)); + assertTrue(journal.get(X + 1, Y, Z).stringCaptured()); + assertNull(journal.get(X + 2, Y, Z)); + assertTrue(journal.get(X + 3, Y, Z).stringCaptured()); + assertNull(journal.get(X + 4, Y, Z)); + } + + @Test + public void prerequisiteCarvingUsesJournalTombstonesAndHydrology() { + MatterCavern original = new MatterCavern(true, "", (byte) 0); + MatterCavern replacement = new MatterCavern(true, "", (byte) 1); + matter.slice(MatterCavern.class).set(X, Y + 1, Z, original); + writer.withComponentPriority(2, () -> { + writer.setData(X, Y, Z, replacement); + writer.clearData(X, Y + 1, Z, MatterCavern.class); + }); + matter.slice(HydrologyCaveCell.class) + .set(X, Y + 2, Z, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + matter.slice(MatterCavern.class).set(X, Y + 2, Z, original); + matter.slice(HydrologyCaveCell.class) + .set(X, Y + 3, Z, HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)); + + byte[] column = writer.getPrerequisiteCarvedColumn(X, Z, Y + 4); + + assertEquals(0, column[Y]); + assertEquals(1, column[Y + 1]); + assertEquals(0, column[Y + 2]); + assertEquals(1, column[Y + 3]); + assertFalse(writer.isPrerequisiteCarved(X, Y, Z)); + assertTrue(writer.isPrerequisiteCarved(X, Y + 1, Z)); + } + + @Test + public void restorationWritesCapturedValuesAndNullsWithoutRecapture() { + PlatformBlockState originalBlock = mock(PlatformBlockState.class); + PlatformBlockState replacementBlock = mock(PlatformBlockState.class); + MatterCavern originalCavern = new MatterCavern(true, "old", (byte) 1); + MatterCavern replacementCavern = new MatterCavern(true, "new", (byte) 2); + matter.slice(PlatformBlockState.class).set(X, Y, Z, originalBlock); + matter.slice(MatterCavern.class).set(X, Y, Z, originalCavern); + writer.withComponentPriority(2, () -> { + writer.setData(X, Y, Z, replacementBlock); + writer.setData(X, Y, Z, "marker"); + writer.setData(X, Y, Z, replacementCavern); + }); + + assertTrue(writer.restorePrerequisiteData(X, Y, Z, String.class)); + assertNull(writer.getDataIfPresent(X, Y, Z, String.class)); + assertTrue(writer.restorePrerequisiteCell(X, Y, Z)); + assertSame(originalBlock, writer.getDataIfPresent(X, Y, Z, PlatformBlockState.class)); + assertEquals(originalCavern, writer.getDataIfPresent(X, Y, Z, MatterCavern.class)); + assertFalse(writer.restorePrerequisiteData(X, Y, Z, Integer.class)); + assertFalse(writer.restorePrerequisiteCell(X + 1, Y, Z)); + } + + private void await(CountDownLatch latch) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private static BlockData blockData(String key) { + BlockData data = mock(BlockData.class); + doReturn("minecraft:" + key).when(data).getAsString(); + return data; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java index e7482314e..1ebc5901d 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/MatterGeneratorCarvePassRadiusTest.java @@ -1,6 +1,7 @@ package art.arcane.iris.engine.mantle; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EnginePlatformHooks; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.volmlib.util.mantle.flag.MantleFlag; @@ -23,6 +24,43 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; public class MatterGeneratorCarvePassRadiusTest { + @Test + @SuppressWarnings("unchecked") + public void platformFilterRemovesSkippedComponentFromGenerationReach() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.isUseMantle()).thenReturn(true); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }).when(chunk).raiseFlagSuspend(any(), any(Runnable.class)); + + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + when(engine.getPlatformHooks()).thenReturn(new EnginePlatformHooks() { + @Override + public boolean shouldGenerateMantleComponent(Engine target, MantleComponent component) { + return component.getFlag() != ReservedFlag.OBJECT; + } + }); + + RecordingComponent carving = new RecordingComponent(ReservedFlag.CARVED, 0, 0); + RecordingComponent objects = new RecordingComponent(ReservedFlag.OBJECT, 1, 160); + TestMatterGenerator generator = new TestMatterGenerator(engine, mantle, List.of( + new MantlePass(List.of(carving), 11, 160), + new MantlePass(List.of(objects), 10, 0) + )); + + generator.generateMatter(0, 0, false, mock(ChunkContext.class)); + + assertEquals(Set.of("0,0"), carving.visited); + assertTrue(objects.visited.isEmpty()); + } + @Test @SuppressWarnings("unchecked") public void carvePassCoversEveryChunkTheObjectPassWritesObjectsInto() { @@ -131,6 +169,47 @@ public class MatterGeneratorCarvePassRadiusTest { assertTrue(conditional.accessSucceeded); } + @Test + @SuppressWarnings("unchecked") + public void destinationOwnedOutputGeneratesOnlyDestinationsAndPreparesItsInputHalo() { + IrisDimension dimension = mock(IrisDimension.class); + when(dimension.isUseMantle()).thenReturn(true); + Mantle mantle = mock(Mantle.class); + MantleChunk chunk = mock(MantleChunk.class); + when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); + when(chunk.use()).thenReturn(chunk); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + task.run(); + return null; + }).when(chunk).raiseFlagSuspend(any(), any(Runnable.class)); + + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(dimension); + + RecordingComponent carving = new RecordingComponent(ReservedFlag.CARVED, 0, 1); + RecordingComponent objects = new RecordingComponent( + ReservedFlag.OBJECT, + 1, + 40, + 0, + 0, + false, + 0, + 0, + 89 + ); + TestMatterGenerator generator = new TestMatterGenerator(engine, mantle, List.of( + new MantlePass(List.of(carving), 6, 89), + new MantlePass(List.of(objects), 0, 0) + )); + + generator.generateMatter(0, 0, false, mock(ChunkContext.class)); + + assertEquals(Set.of("0,0"), objects.visited); + assertEquals(169, carving.visited.size()); + } + private static final class RecordingComponent implements MantleComponent { private final MantleFlag flag; private final int priority; @@ -139,11 +218,13 @@ public class MatterGeneratorCarvePassRadiusTest { private final int chunkInputRadius; private final boolean lazyInputGeneration; private final int accessProbeOffset; + private final int outputRadius; + private final int destinationInputRadius; private final Set visited = new LinkedHashSet<>(); private boolean accessSucceeded; private RecordingComponent(MantleFlag flag, int priority, int radius) { - this(flag, priority, radius, 0, 0, false, 0); + this(flag, priority, radius, 0, 0, false, 0, radius, 0); } private RecordingComponent( @@ -153,7 +234,7 @@ public class MatterGeneratorCarvePassRadiusTest { int inputRadius, int chunkInputRadius ) { - this(flag, priority, radius, inputRadius, chunkInputRadius, false, 0); + this(flag, priority, radius, inputRadius, chunkInputRadius, false, 0, radius, 0); } private RecordingComponent( @@ -164,6 +245,21 @@ public class MatterGeneratorCarvePassRadiusTest { int chunkInputRadius, boolean lazyInputGeneration, int accessProbeOffset + ) { + this(flag, priority, radius, inputRadius, chunkInputRadius, lazyInputGeneration, + accessProbeOffset, radius, 0); + } + + private RecordingComponent( + MantleFlag flag, + int priority, + int radius, + int inputRadius, + int chunkInputRadius, + boolean lazyInputGeneration, + int accessProbeOffset, + int outputRadius, + int destinationInputRadius ) { this.flag = flag; this.priority = priority; @@ -172,6 +268,8 @@ public class MatterGeneratorCarvePassRadiusTest { this.chunkInputRadius = chunkInputRadius; this.lazyInputGeneration = lazyInputGeneration; this.accessProbeOffset = accessProbeOffset; + this.outputRadius = outputRadius; + this.destinationInputRadius = destinationInputRadius; } @Override @@ -184,6 +282,11 @@ public class MatterGeneratorCarvePassRadiusTest { return radius; } + @Override + public int getOutputRadius() { + return outputRadius; + } + @Override public int getInputRadius() { return inputRadius; @@ -196,7 +299,7 @@ public class MatterGeneratorCarvePassRadiusTest { int invocationChunkRadius, ChunkContext context ) { - return chunkInputRadius; + return destinationInputRadius > 0 ? destinationInputRadius : chunkInputRadius; } @Override diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java index 5b9ba3af1..9a1510bb0 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/CaveObjectPlacementTransactionTest.java @@ -21,8 +21,8 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.TileData; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.PlatformBlockState; import org.junit.Test; @@ -112,10 +112,10 @@ public class CaveObjectPlacementTransactionTest { } @Test - public void protectedHydrologyRejectsTheWholePlacement() { + public void dryHydrologyHeadroomRejectsTheWholePlacement() { IObjectPlacer delegate = createPlacer(128, 80, 20, 90); - when(delegate.getData(4, 30, 7, RiverCaveHydrology.class)) - .thenReturn(RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE)); + when(delegate.getData(4, 30, 7, HydrologyCaveCell.class)) + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)); CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); transaction.set(4, 30, 7, mock(PlatformBlockState.class)); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentBoundaryRadiusTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentBoundaryRadiusTest.java index 750a9186e..b71601d73 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentBoundaryRadiusTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleCarvingComponentBoundaryRadiusTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.mantle.components; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.mantle.MantleComponent; import art.arcane.iris.engine.mantle.MantlePass; @@ -11,6 +12,7 @@ import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.mantle.runtime.Mantle; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.Matter; @@ -23,11 +25,14 @@ import java.util.List; import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class MantleCarvingComponentBoundaryRadiusTest { @@ -56,6 +61,25 @@ public class MantleCarvingComponentBoundaryRadiusTest { assertEquals(1, Math.ceilDiv(component.getRadius(), 16)); } + @Test + @SuppressWarnings("unchecked") + public void carvingSurfaceInputUsesNaturalTerrainInsteadOfHydrologyHeight() { + IrisComplex complex = mock(IrisComplex.class); + ProceduralStream naturalHeight = mock(ProceduralStream.class); + when(complex.getNaturalHeightStream()).thenReturn(naturalHeight); + when(naturalHeight.getDouble(anyDouble(), anyDouble())).thenReturn(70D); + ChunkContext context = mock(ChunkContext.class); + when(context.getComplex()).thenReturn(complex); + + int[] heights = MantleCarvingComponent.prepareChunkSurfaceHeights(2, -3, context, new int[256]); + + for (int height : heights) { + assertEquals(70, height); + } + verify(complex, never()).getHeightStream(); + verify(context, never()).getHeight(); + } + @Test @SuppressWarnings("unchecked") public void matterGenerationRunsCarvingForAllAdjacentChunks() { diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelViewTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelViewTest.java new file mode 100644 index 000000000..e56e0f26c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyCaveVoxelViewTest.java @@ -0,0 +1,107 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MantleHydrologyCaveVoxelViewTest { + @Test + @SuppressWarnings("unchecked") + public void authoritativeChunkFlagControlsWhetherCarvingInputIsRequired() { + Mantle mantle = mock(Mantle.class); + when(mantle.hasFlag(2, -3, ReservedFlag.CARVED)).thenReturn(true); + + assertFalse(MantleHydrologyCaveVoxelView.requiresCarvingInput(mantle, 2, -3)); + assertTrue(MantleHydrologyCaveVoxelView.requiresCarvingInput(mantle, 3, -3)); + + verify(mantle).hasFlag(2, -3, ReservedFlag.CARVED); + verify(mantle).hasFlag(3, -3, ReservedFlag.CARVED); + } + + @Test + @SuppressWarnings("unchecked") + public void mapsCarvedAndStoredMatterAndLoadsEachChunkOnce() { + Mantle mantle = mock(Mantle.class); + MatterCavern air = new MatterCavern(true, "", (byte) 0); + MatterCavern water = new MatterCavern(true, "", (byte) 1); + MatterCavern lava = new MatterCavern(true, "", (byte) 2); + PlatformBlockState incompatibleFluid = mock(PlatformBlockState.class); + PlatformBlockState lavaBlock = mock(PlatformBlockState.class); + PlatformBlockState solid = mock(PlatformBlockState.class); + doReturn(air).when(mantle).get(0, 20, 0, MatterCavern.class); + doReturn(water).when(mantle).get(1, 20, 0, MatterCavern.class); + doReturn(lava).when(mantle).get(2, 20, 0, MatterCavern.class); + doReturn(incompatibleFluid).when(mantle).get(3, 20, 0, PlatformBlockState.class); + doReturn(lavaBlock).when(mantle).get(4, 20, 0, PlatformBlockState.class); + doReturn(solid).when(mantle).get(5, 20, 0, PlatformBlockState.class); + when(incompatibleFluid.isFluid()).thenReturn(true); + when(incompatibleFluid.materialKey()).thenReturn("minecraft:water"); + when(lavaBlock.isFluid()).thenReturn(true); + when(lavaBlock.materialKey()).thenReturn("minecraft:lava"); + when(solid.isFluid()).thenReturn(false); + List loaded = new ArrayList<>(); + MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView( + mantle, + 128, + (x, z) -> 60, + (chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ) + ); + + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(0, 20, 0))); + assertEquals(CaveVoxel.COMPATIBLE_FLUID, view.voxelAt(new CavePosition(1, 20, 0))); + assertEquals(CaveVoxel.LAVA, view.voxelAt(new CavePosition(2, 20, 0))); + assertEquals(CaveVoxel.INCOMPATIBLE_FLUID, view.voxelAt(new CavePosition(3, 20, 0))); + assertEquals(CaveVoxel.LAVA, view.voxelAt(new CavePosition(4, 20, 0))); + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(5, 20, 0))); + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(6, 20, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(7, 61, 0))); + assertTrue(view.isOpenToSurface(new CavePosition(7, 61, 0))); + assertEquals(CaveVoxel.SOLID, view.voxelAt(new CavePosition(16, 20, 0))); + assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(new CavePosition(16, 61, 0))); + + assertEquals(List.of("0,0", "1,0"), loaded); + } + + @Test + @SuppressWarnings("unchecked") + public void carvedVerticalShaftIsOpenToSurface() { + Mantle mantle = mock(Mantle.class); + MatterCavern air = new MatterCavern(true, "", (byte) 0); + doAnswer(invocation -> { + int x = invocation.getArgument(0); + int y = invocation.getArgument(1); + int z = invocation.getArgument(2); + return x == 4 && z == 4 && y >= 20 && y <= 60 ? air : null; + }).when(mantle).get(anyInt(), anyInt(), anyInt(), eq(MatterCavern.class)); + List loaded = new ArrayList<>(); + MantleHydrologyCaveVoxelView view = new MantleHydrologyCaveVoxelView( + mantle, + 128, + (x, z) -> 60, + (chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ) + ); + + assertTrue(view.isOpenToSurface(new CavePosition(4, 20, 4))); + assertFalse(view.isOpenToSurface(new CavePosition(5, 20, 4))); + assertEquals(List.of("0,0"), loaded); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java new file mode 100644 index 000000000..09a4a6f9d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponentTest.java @@ -0,0 +1,1871 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.render.IrisRenderer; +import art.arcane.iris.engine.framework.render.RenderType; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyDiagnosticRenderSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyPlanner; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; +import art.arcane.iris.engine.hydrology.HydrologyTile; +import art.arcane.iris.engine.hydrology.HydrologyTileKey; +import art.arcane.iris.engine.hydrology.RiverCourse; +import art.arcane.iris.engine.hydrology.RiverCourseType; +import art.arcane.iris.engine.hydrology.RiverOutlet; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveMode; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveRejection; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveSource; +import art.arcane.iris.engine.mantle.ComponentFlag; +import art.arcane.iris.engine.mantle.MantleWriter; +import art.arcane.iris.engine.object.IrisDeepFluidConfig; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.context.ChunkContext; +import art.arcane.volmlib.util.mantle.flag.ReservedFlag; +import art.arcane.volmlib.util.matter.slices.UpdateMatter; +import org.junit.Test; +import org.mockito.InOrder; + +import java.awt.image.BufferedImage; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class MantleHydrologyComponentTest { + @Test + public void fallingPublicationWritesLevelEightFluidAndUpdateMarker() { + CavePosition position = new CavePosition(4, 20, 6); + PlatformBlockState source = mock(PlatformBlockState.class); + PlatformBlockState falling = mock(PlatformBlockState.class); + when(source.key()).thenReturn("minecraft:water[level=0]"); + when(source.withProperty("level", "8")).thenReturn(falling); + IrisComplex complex = mock(IrisComplex.class); + when(complex.resolveHydrologyFluid("river", position.x(), position.z())).thenReturn(source); + ChunkContext context = mock(ChunkContext.class); + when(context.getComplex()).thenReturn(complex); + MantleWriter writer = mock(MantleWriter.class); + MantleHydrologyComponent.Publication publication = new MantleHydrologyComponent.Publication( + Map.of(), + Map.of(position, new MantleHydrologyComponent.SurfaceFluidWrite( + "river", + HydrologyCaveAction.FALLING_FLUID + )), + Set.of(position) + ); + + MantleHydrologyComponent.publish(writer, context, publication); + + InOrder order = inOrder(writer); + order.verify(writer).setData(position.x(), position.y(), position.z(), falling); + order.verify(writer).setData(position.x(), position.y(), position.z(), UpdateMatter.ON); + verify(writer, never()).setData(position.x(), position.y(), position.z(), source); + } + + @Test + public void componentUsesCanonicalFlagPriorityAndConfiguration() { + ComponentFlag flag = MantleHydrologyComponent.class.getAnnotation(ComponentFlag.class); + assertNotNull(flag); + assertEquals(ReservedFlag.RIVER_HYDROLOGY, flag.value()); + assertEquals(1, MantleHydrologyComponent.PRIORITY); + + IrisDimension dimension = new IrisDimension(); + assertFalse(MantleHydrologyComponent.isEnabledFor(dimension)); + dimension.getHydrology().getRivers().setEnabled(true); + assertTrue(MantleHydrologyComponent.isEnabledFor(dimension)); + dimension.getHydrology().getRivers().setEnabled(false); + dimension.getHydrology().getDeepFluids().add(new IrisDeepFluidConfig()); + assertTrue(MantleHydrologyComponent.isEnabledFor(dimension)); + dimension.setCarvingEnabled(false); + assertFalse(MantleHydrologyComponent.isEnabledFor(dimension)); + } + + @Test + public void surfaceDropPublishesSourceLipFallingThroatAndReceivingPool() { + HydrologyColumnLayer drop = layer( + HydrologyFeatureType.WATERFALL, + 11L, + 7L, + 60, + 70, + 70, + true, + false, + "river" + ); + HydrologyColumnLayer receiving = layer( + HydrologyFeatureType.WATERFALL, + 12L, + 7L, + 60, + 64, + 64, + false, + true, + "river" + ); + Map samples = samples(sample(8, 8, false, 78, List.of(drop, receiving))); + + MantleHydrologyComponent.Publication publication = compile( + samples, + List.of(), + new TestCaveVoxelView() + ); + + assertEquals(3, publication.caveCells().size()); + for (HydrologyCaveCell cell : publication.caveCells().values()) { + assertEquals(HydrologyCaveAction.SEAL_GUARD, cell.action()); + } + for (int y = 61; y <= 64; y++) { + assertEquals(HydrologyCaveAction.WET_SOURCE, publication.surfaceWrites() + .get(new CavePosition(8, y, 8)).action()); + } + for (int y = 65; y < 70; y++) { + CavePosition position = new CavePosition(8, y, 8); + assertEquals(HydrologyCaveAction.FALLING_FLUID, publication.surfaceWrites().get(position).action()); + assertFalse(publication.fluidUpdates().contains(position)); + } + assertEquals(HydrologyCaveAction.WET_SOURCE, publication.surfaceWrites() + .get(new CavePosition(8, 70, 8)).action()); + assertTrue(publication.fluidUpdates().contains(new CavePosition(8, 70, 8))); + } + + @Test + public void overlappingSourceMasksACompletelySubmergedFallingThroat() { + HydrologyColumnLayer falling = layer( + HydrologyFeatureType.RIFFLE, + 11L, + 7L, + 60, + 70, + 70, + true, + false, + "river" + ); + HydrologyColumnLayer overlappingSource = layer( + HydrologyFeatureType.RIFFLE, + 12L, + 8L, + 60, + 69, + 69, + false, + true, + "river" + ); + HydrologyColumnSample sample = sample( + 8, + 8, + false, + 78, + List.of(overlappingSource, falling) + ); + assertEquals(falling, sample.primarySurfaceFluidLayer().orElseThrow()); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(), + new TestCaveVoxelView() + ); + CavePosition throat = new CavePosition(8, 61, 8); + + assertEquals(HydrologyCaveAction.WET_SOURCE, publication.surfaceWrites().get(throat).action()); + assertFalse(publication.fluidUpdates().contains(throat)); + } + + @Test + public void surfaceFluidBelowTheComposedTerrainBedIsNotPublished() { + HydrologyColumnLayer selectedTerrain = layer( + HydrologyFeatureType.WATERFALL, + 13L, + 9L, + 65, + 70, + 70, + true, + false, + "river" + ); + HydrologyColumnLayer submergedSource = layer( + HydrologyFeatureType.SURFACE_POOL, + 14L, + 10L, + 60, + 64, + 64, + false, + true, + "river" + ); + HydrologyColumnSample sample = sample( + 8, + 8, + false, + 78, + List.of(submergedSource, selectedTerrain) + ); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(), + new TestCaveVoxelView() + ); + + assertEquals(65, sample.terrainHeight()); + for (int y = 61; y <= 65; y++) { + assertFalse(publication.surfaceWrites().containsKey(new CavePosition(8, y, 8))); + } + assertEquals( + HydrologyCaveAction.FALLING_FLUID, + publication.surfaceWrites().get(new CavePosition(8, 66, 8)).action() + ); + } + + @Test + public void plannedGradualCascadePublishesGradedFaceAndReceivingPool() { + HydrologyTile tile = plannedCascadeTile(); + + int transitionCount = 0; + for (RiverCourse course : tile.courses()) { + if (course.type() != RiverCourseType.SURFACE) { + continue; + } + for (HydraulicSegment segment : course.segments()) { + if (segment.type() != HydrologyFeatureType.CASCADE || segment.drop() <= 0) { + continue; + } + transitionCount++; + assertFalse(segment.fallingFluid()); + + MantleHydrologyComponent.Publication poolPublication = MantleHydrologyComponent.compilePublication( + Math.floorDiv(segment.end().x(), 16), + Math.floorDiv(segment.end().z(), 16), + 128, + tile::columnAt, + tile.cavePlans(), + new TestCaveVoxelView() + ); + CavePosition receiving = new CavePosition( + segment.end().x(), + segment.downstreamHeadY(), + segment.end().z() + ); + assertNotNull(poolPublication.surfaceWrites().get(receiving)); + assertEquals( + HydrologyCaveAction.WET_SOURCE, + poolPublication.surfaceWrites().get(receiving).action() + ); + } + } + assertEquals(1, transitionCount); + } + + @Test + public void acceptedSurfaceHeadwaterMaterializesAsOneContinuousExposedPublishedChannel() { + HydrologyTile tile = plannedCascadeTile(); + ArrayList surfaceCourses = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == RiverCourseType.SURFACE) { + surfaceCourses.add(course); + } + } + assertEquals(tile.diagnosticCandidates().toString(), 1, surfaceCourses.size()); + RiverCourse course = surfaceCourses.getFirst(); + + LinkedHashMap fluidLayers = new LinkedHashMap<>(); + CavePosition sourceCell = null; + long maximumSourceDistanceSquared = 0L; + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + HydrologyColumnLayer layer = column.primarySurfaceFluidLayer().orElse(null); + if (layer == null || layer.feature().courseId() != course.id()) { + continue; + } + assertTrue(layer.channel()); + assertTrue(layer.connectedFluid()); + assertTrue(layer.fluidOwned()); + assertFalse(layer.oceanApron()); + assertEquals(layer.fluidHeadY(), layer.ceilingY()); + assertTrue(layer.bedY() < layer.fluidHeadY()); + if (layer.fallingFluid()) { + assertFalse(layer.terrainOwned()); + } else { + assertTrue(layer.terrainOwned()); + assertEquals(layer.bedY(), column.terrainHeight()); + } + fluidLayers.put(pack(column.x(), column.z()), layer); + if (layer.feature().source()) { + assertNull(sourceCell); + sourceCell = new CavePosition(column.x(), layer.fluidHeadY(), column.z()); + } + } + assertNotNull(sourceCell); + assertTrue(fluidLayers.size() > 64); + + LinkedHashMap chunkPublications = new LinkedHashMap<>(); + for (long packedColumn : fluidLayers.keySet()) { + int x = (int) (packedColumn >> 32); + int z = (int) packedColumn; + int chunkX = Math.floorDiv(x, 16); + int chunkZ = Math.floorDiv(z, 16); + long packedChunk = pack(chunkX, chunkZ); + chunkPublications.computeIfAbsent( + packedChunk, + (Long ignored) -> MantleHydrologyComponent.compilePublication( + chunkX, + chunkZ, + 128, + tile::columnAt, + tile.cavePlans(), + new TestCaveVoxelView() + ) + ); + } + + HashSet materializedFluid = new HashSet<>(); + for (Map.Entry entry : fluidLayers.entrySet()) { + int x = (int) (entry.getKey() >> 32); + int z = (int) entry.getKey().longValue(); + HydrologyColumnLayer layer = entry.getValue(); + for (int y = layer.bedY() + 1; y <= layer.fluidHeadY(); y++) { + materializedFluid.add(new CavePosition(x, y, z)); + } + long deltaX = (long) x - sourceCell.x(); + long deltaZ = (long) z - sourceCell.z(); + maximumSourceDistanceSquared = Math.max( + maximumSourceDistanceSquared, + deltaX * deltaX + deltaZ * deltaZ + ); + } + int fallingWriteCount = 0; + int wetWriteCount = 0; + for (MantleHydrologyComponent.Publication publication : chunkPublications.values()) { + assertFalse(publication.caveCells().isEmpty()); + for (HydrologyCaveCell cell : publication.caveCells().values()) { + assertEquals(HydrologyCaveAction.SEAL_GUARD, cell.action()); + } + materializedFluid.addAll(publication.surfaceWrites().keySet()); + for (Map.Entry entry + : publication.surfaceWrites().entrySet()) { + if (entry.getValue().action() == HydrologyCaveAction.FALLING_FLUID) { + fallingWriteCount++; + assertFalse(publication.fluidUpdates().contains(entry.getKey())); + } else { + assertEquals(HydrologyCaveAction.WET_SOURCE, entry.getValue().action()); + wetWriteCount++; + if (publication.fluidUpdates().contains(entry.getKey())) { + assertEquals(HydrologyCaveAction.WET_SOURCE, entry.getValue().action()); + } + } + } + } + boolean expectsFallingFluid = false; + for (HydraulicSegment segment : course.segments()) { + if (segment.fallingFluid()) { + expectsFallingFluid = true; + break; + } + } + assertEquals(expectsFallingFluid, fallingWriteCount > 0); + assertTrue(wetWriteCount > 0); + int transitionCount = 0; + for (HydraulicSegment segment : course.segments()) { + if (segment.drop() <= 0) { + continue; + } + MantleHydrologyComponent.Publication poolPublication = publicationAt( + chunkPublications, + segment.end().x(), + segment.end().z() + ); + CavePosition receiving = new CavePosition( + segment.end().x(), + segment.downstreamHeadY(), + segment.end().z() + ); + assertEquals( + HydrologyCaveAction.WET_SOURCE, + poolPublication.surfaceWrites().get(receiving).action() + ); + transitionCount++; + } + assertTrue(transitionCount >= 2); + assertTrue(maximumSourceDistanceSquared >= 64L * 64L); + + HashMap publishedWrites = new HashMap<>(); + HashSet publishedUpdates = new HashSet<>(); + for (MantleHydrologyComponent.Publication publication : chunkPublications.values()) { + publishedWrites.putAll(publication.surfaceWrites()); + publishedUpdates.addAll(publication.fluidUpdates()); + } + int simulatedSpillFaces = 0; + int[] offsetsX = {-1, 1, 0, 0}; + int[] offsetsZ = {0, 0, -1, 1}; + for (CavePosition update : publishedUpdates) { + MantleHydrologyComponent.SurfaceFluidWrite upperWrite = publishedWrites.get(update); + if (upperWrite == null) { + continue; + } + for (int index = 0; index < offsetsX.length; index++) { + CavePosition lower = new CavePosition( + update.x() + offsetsX[index], + update.y() - 1, + update.z() + offsetsZ[index] + ); + MantleHydrologyComponent.SurfaceFluidWrite lowerWrite = publishedWrites.get(lower); + if (lowerWrite == null + || lowerWrite.action() != HydrologyCaveAction.WET_SOURCE + || !lowerWrite.profileKey().equals(upperWrite.profileKey())) { + continue; + } + if (materializedFluid.add(new CavePosition(lower.x(), update.y(), lower.z()))) { + simulatedSpillFaces++; + } + } + } + assertTrue(simulatedSpillFaces > 0); + + Set connected = connectedFluidFrom(sourceCell, materializedFluid); + HashSet disconnected = new HashSet<>(materializedFluid); + disconnected.removeAll(connected); + assertEquals(disconnected.stream().limit(12).toList().toString(), materializedFluid.size(), connected.size()); + } + + @Test + public void lShapedCoastTerminatesAtFirstOceanContactWithOnlyBoundedNonOwningApron() { + HydrologyPlannerSettings settings = cascadeSettings(); + HydrologyTerrainSampler terrain = (int x, int z) -> { + boolean ocean = x >= 96 && z >= 48; + int height = ocean ? 54 : 160 - Math.floorDiv(x + z, 8); + return cascadeTerrain(height, ocean, x == 0 && z == 0, !ocean); + }; + HydrologyTile tile = new HydrologyPlanner( + 4207L, + settings, + terrain, + new TestCaveVoxelView() + ).plan(new HydrologyTileKey(0, 0)); + ArrayList surfaceCourses = new ArrayList<>(); + for (RiverCourse candidate : tile.courses()) { + if (candidate.type() == RiverCourseType.SURFACE) { + surfaceCourses.add(candidate); + } + } + assertEquals(1, surfaceCourses.size()); + RiverCourse course = surfaceCourses.getFirst(); + RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow(); + assertTrue(outlet.directOcean()); + assertEquals(HydrologyFeatureType.MOUTH, outlet.type()); + assertFalse(terrain.sample(outlet.landwardPoint().x(), outlet.landwardPoint().z()).ocean()); + assertTrue(terrain.sample(outlet.connectionPoint().x(), outlet.connectionPoint().z()).ocean()); + assertEquals(1L, outlet.landwardPoint().distanceSquared2D(outlet.connectionPoint())); + + List centerline = rasterizedCourseCenterline(course); + int firstOceanIndex = -1; + int firstShoreContactIndex = -1; + for (int index = 0; index < centerline.size(); index++) { + HydrologyPoint point = centerline.get(index); + if (terrain.sample(point.x(), point.z()).ocean()) { + if (firstOceanIndex < 0) { + firstOceanIndex = index; + } + } else if (firstShoreContactIndex < 0 && hasCardinalOceanNeighbor(terrain, point)) { + firstShoreContactIndex = index; + } + } + assertEquals(centerline.size() - 1, firstOceanIndex); + assertEquals(centerline.size() - 2, firstShoreContactIndex); + assertEquals(outlet.landwardPoint().x(), centerline.get(centerline.size() - 2).x()); + assertEquals(outlet.landwardPoint().z(), centerline.get(centerline.size() - 2).z()); + assertEquals(outlet.connectionPoint().x(), centerline.getLast().x()); + assertEquals(outlet.connectionPoint().z(), centerline.getLast().z()); + + HydraulicSegment mouth = course.segments().getLast(); + assertEquals(HydrologyFeatureType.MOUTH, mouth.type()); + List mouthCenterline = rasterizedSegmentCenterline(mouth); + int apronColumns = 0; + HashSet apronChunks = new HashSet<>(); + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + if (!column.ocean()) { + continue; + } + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().courseId() != course.id()) { + continue; + } + apronColumns++; + assertTrue(layer.oceanApron()); + assertEquals(HydrologyFeatureType.MOUTH, layer.feature().type()); + assertFalse(layer.terrainOwned()); + assertFalse(layer.fluidOwned()); + assertFalse(layer.grading()); + assertFalse(layer.shore()); + assertTrue(minimumDistance(column.x(), column.z(), mouthCenterline) + <= settings.outlets().maximumOceanApron() + 0.25D); + apronChunks.add(pack(Math.floorDiv(column.x(), 16), Math.floorDiv(column.z(), 16))); + } + } + assertTrue(apronColumns > 0); + for (long packedChunk : apronChunks) { + int chunkX = (int) (packedChunk >> 32); + int chunkZ = (int) packedChunk; + MantleHydrologyComponent.Publication publication = MantleHydrologyComponent.compilePublication( + chunkX, + chunkZ, + 192, + tile::columnAt, + tile.cavePlans(), + new TestCaveVoxelView() + ); + for (CavePosition position : publication.surfaceWrites().keySet()) { + assertFalse(terrain.sample(position.x(), position.z()).ocean()); + } + } + } + + @Test + public void rendererClassificationMatchesMaterializedPublicationAcrossHydrologyClasses() { + HydrologyColumnLayer surface = layer( + HydrologyFeatureType.SURFACE_POOL, + 101L, + 101L, + 60, + 63, + 63, + false, + false, + "river" + ); + HydrologyColumnLayer drop = layer( + HydrologyFeatureType.WATERFALL, + 102L, + 102L, + 52, + 63, + 63, + true, + false, + "river" + ); + HydrologyColumnLayer underground = layer( + HydrologyFeatureType.UNDERGROUND_POOL, + 103L, + 103L, + 20, + 23, + 28, + false, + false, + "river" + ); + HydrologyColumnLayer grotto = layer( + HydrologyFeatureType.INLAND_GROTTO, + 104L, + 104L, + 18, + 22, + 29, + false, + false, + "river" + ); + HydrologyColumnLayer deep = layer( + HydrologyFeatureType.DEEP_CHANNEL, + 105L, + 105L, + 6, + 9, + 13, + false, + false, + "deep_lava" + ); + List cases = List.of( + terrainParityCase(surface), + surfacePublicationParityCase(drop, HydrologyCaveAction.FALLING_FLUID), + caveParityCase(underground), + caveParityCase(grotto), + caveParityCase(deep) + ); + + for (PublicationParityCase parityCase : cases) { + MantleHydrologyComponent.Publication publication = compile( + samples(parityCase.sample()), + parityCase.plans(), + new TestCaveVoxelView() + ); + BufferedImage rendered = renderRiverColumn(parityCase.sample()); + assertEquals( + parityCase.type().name(), + IrisRenderer.hydrologyFeatureColor(parityCase.type()), + rendered.getRGB(0, 0) + ); + switch (parityCase.target()) { + case TERRAIN -> assertTerrainMaterialization(parityCase, publication); + case SURFACE_PUBLICATION -> assertSurfacePublication(parityCase, publication); + case CAVE_PUBLICATION -> assertCavePublication(parityCase, publication); + } + } + } + + @Test + public void acceptedExactPlanPublishesWetDryAndGuardActions() { + HydrologyColumnLayer pool = layer( + HydrologyFeatureType.UNDERGROUND_POOL, + 21L, + 8L, + 20, + 23, + 30, + false, + false, + "river" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(pool)); + HydrologyCavePlan plan = acceptedPlan(sample, pool); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(plan), + new TestCaveVoxelView() + ); + + assertEquals(13, publication.caveCells().size()); + for (int y = 21; y <= 23; y++) { + HydrologyCaveCell cell = publication.caveCells().get(new CavePosition(8, y, 8)); + assertEquals(HydrologyCaveAction.WET_SOURCE, cell.action()); + assertEquals("river", cell.fluidProfileKey()); + assertEquals("flooded", cell.floodedBiomeKey()); + } + for (int y = 24; y <= 30; y++) { + HydrologyCaveCell cell = publication.caveCells().get(new CavePosition(8, y, 8)); + assertEquals(HydrologyCaveAction.DRY_AIR, cell.action()); + assertEquals("dry", cell.floodedBiomeKey()); + } + assertEquals(HydrologyCaveAction.SEAL_GUARD, publication.caveCells() + .get(new CavePosition(8, 20, 8)).action()); + assertEquals(HydrologyCaveAction.SEAL_GUARD, publication.caveCells() + .get(new CavePosition(8, 31, 8)).action()); + assertEquals(HydrologyCaveAction.SEAL_GUARD, publication.caveCells() + .get(new CavePosition(9, 22, 8)).action()); + } + + @Test + public void lavaPreconditionMismatchPublishesNothing() { + HydrologyColumnLayer pool = cavePool(); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(pool)); + CavePosition tracked = new CavePosition(8, 21, 8); + HydrologyCavePlan plan = acceptedPlan(sample, pool); + TestCaveVoxelView view = new TestCaveVoxelView(); + view.setVoxel(tracked, CaveVoxel.LAVA); + + assertEmpty(compile(samples(sample), List.of(plan), view)); + } + + @Test + public void incompatibleFluidPreconditionMismatchPublishesNothing() { + HydrologyColumnLayer pool = cavePool(); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(pool)); + CavePosition tracked = new CavePosition(8, 21, 8); + HydrologyCavePlan plan = acceptedPlan(sample, pool); + TestCaveVoxelView view = new TestCaveVoxelView(); + view.setVoxel(tracked, CaveVoxel.INCOMPATIBLE_FLUID); + + assertEmpty(compile(samples(sample), List.of(plan), view)); + } + + @Test + public void newlyOpenSurfacePreconditionPublishesNothing() { + HydrologyColumnLayer pool = cavePool(); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(pool)); + CavePosition tracked = new CavePosition(8, 24, 8); + HydrologyCavePlan plan = replacePrecondition( + acceptedPlan(sample, pool), + tracked, + new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false) + ); + TestCaveVoxelView view = new TestCaveVoxelView(); + view.setVoxel(tracked, CaveVoxel.CAVE_AIR); + view.setOpenToSurface(tracked); + + assertEmpty(compile(samples(sample), List.of(plan), view)); + } + + @Test + public void changedSolidPreconditionPublishesNothing() { + HydrologyColumnLayer pool = cavePool(); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(pool)); + CavePosition tracked = new CavePosition(8, 25, 8); + HydrologyCavePlan plan = acceptedPlan(sample, pool); + TestCaveVoxelView view = new TestCaveVoxelView(); + view.setVoxel(tracked, CaveVoxel.CAVE_AIR); + + assertEmpty(compile(samples(sample), List.of(plan), view)); + } + + @Test + public void sameCourseSurfaceFluidOwnsAChangedRidgeBoundary() { + long courseId = 33L; + HydrologyColumnLayer ridge = layerForCourse( + HydrologyFeatureType.RIDGE_BORE, + 34L, + courseId, + 10L, + 20, + 25, + 26, + "river" + ); + HydrologyColumnLayer connectedSurface = layerForCourse( + HydrologyFeatureType.SURFACE_POOL, + 35L, + courseId, + 11L, + 20, + 23, + 23, + "river" + ); + HydrologyColumnLayer selectedSurface = layerForCourse( + HydrologyFeatureType.SURFACE_POOL, + 36L, + 99L, + 12L, + 23, + 25, + 25, + "river" + ); + HydrologyColumnSample sample = sample( + 8, + 8, + false, + 80, + List.of(ridge, connectedSurface, selectedSurface) + ); + CavePosition tracked = new CavePosition(8, 23, 8); + HydrologyCavePlan plan = replacePrecondition( + acceptedPlan(sample, ridge), + tracked, + new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, true) + ); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(plan), + new TestCaveVoxelView() + ); + + assertEquals(HydrologyCaveAction.SEAL_GUARD, publication.caveCells().get(tracked).action()); + assertEquals( + HydrologyCaveAction.WET_SOURCE, + publication.caveCells().get(new CavePosition(8, 24, 8)).action() + ); + } + + @Test + public void newlySolidOpenBoundaryStillPublishesContainedCaveVolume() { + HydrologyColumnLayer pool = cavePool(); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(pool)); + CavePosition tightenedBoundary = new CavePosition(9, 23, 8); + HydrologyCavePlan plan = replacePrecondition( + acceptedPlan(sample, pool), + tightenedBoundary, + new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, true) + ); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(plan), + new TestCaveVoxelView() + ); + + assertEquals( + HydrologyCaveAction.WET_SOURCE, + publication.caveCells().get(new CavePosition(8, 21, 8)).action() + ); + } + + @Test + public void newlyClosedWetCaveAirStillPublishesContainedFluid() { + HydrologyColumnLayer pool = cavePool(); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(pool)); + CavePosition tracked = new CavePosition(8, 21, 8); + HydrologyCavePlan plan = replacePrecondition( + acceptedPlan(sample, pool), + tracked, + new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, true) + ); + TestCaveVoxelView view = new TestCaveVoxelView(); + view.setVoxel(tracked, CaveVoxel.CAVE_AIR); + + MantleHydrologyComponent.Publication publication = compile(samples(sample), List.of(plan), view); + + assertEquals(HydrologyCaveAction.WET_SOURCE, publication.caveCells().get(tracked).action()); + } + + @Test + public void newlyClosedDryOpeningPublishesNothing() { + HydrologyColumnLayer pool = cavePool(); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(pool)); + CavePosition tracked = new CavePosition(8, 24, 8); + HydrologyCavePlan plan = replacePrecondition( + acceptedPlan(sample, pool), + tracked, + new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, true) + ); + TestCaveVoxelView view = new TestCaveVoxelView(); + view.setVoxel(tracked, CaveVoxel.CAVE_AIR); + + assertEmpty(compile(samples(sample), List.of(plan), view)); + } + + @Test + public void remoteCrossChunkPreconditionMismatchDoesNotSuppressLocalPublication() { + HydrologyColumnLayer pool = cavePool(); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(pool)); + CavePosition remote = new CavePosition(16, 22, 8); + HydrologyCavePlan plan = replacePrecondition( + acceptedPlan(sample, pool), + remote, + new CaveVoxelPrecondition(CaveVoxel.SOLID, false) + ); + TestCaveVoxelView view = new TestCaveVoxelView(); + view.setVoxel(remote, CaveVoxel.CAVE_AIR); + + MantleHydrologyComponent.Publication publication = compile(samples(sample), List.of(plan), view); + + assertEquals( + HydrologyCaveAction.WET_SOURCE, + publication.caveCells().get(new CavePosition(8, 21, 8)).action() + ); + } + + @Test + public void deepFluidPublishesItsAcceptedProfileAndExactVolume() { + HydrologyColumnLayer deep = layer( + HydrologyFeatureType.DEEP_CHANNEL, + 61L, + 12L, + 8, + 10, + 14, + false, + false, + "deep_lava" + ); + HydrologyColumnSample sample = sample(7, 7, false, 70, List.of(deep)); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(acceptedPlan(sample, deep)), + new TestCaveVoxelView() + ); + + for (int y = 9; y <= 14; y++) { + HydrologyCaveCell cell = publication.caveCells().get(new CavePosition(7, y, 7)); + assertEquals("deep_lava", cell.fluidProfileKey()); + } + assertEquals(HydrologyCaveAction.WET_SOURCE, publication.caveCells() + .get(new CavePosition(7, 10, 7)).action()); + assertEquals(HydrologyCaveAction.DRY_AIR, publication.caveCells() + .get(new CavePosition(7, 11, 7)).action()); + } + + @Test + public void incompatibleAcceptedCaveActionsCannotMergeAtOneVoxel() { + HydrologyColumnLayer higherHead = layerForCourse( + HydrologyFeatureType.UNDERGROUND_POOL, + 101L, + 101L, + 1L, + 20, + 23, + 25, + "river" + ); + HydrologyColumnLayer lowerHead = layerForCourse( + HydrologyFeatureType.UNDERGROUND_POOL, + 102L, + 102L, + 1L, + 20, + 22, + 25, + "river" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(higherHead, lowerHead)); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> compile( + samples(sample), + List.of(acceptedPlan(sample, higherHead), acceptedPlan(sample, lowerHead)), + new TestCaveVoxelView() + ) + ); + + assertOverlapFailure(failure, 101L, 102L, new CavePosition(8, 23, 8)); + } + + @Test + public void overlappingLayersConsumeTheirCoursesComposedContainmentAction() { + HydrologyColumnLayer higherHead = layerForCourse( + HydrologyFeatureType.UNDERGROUND_DROP, + 111L, + 111L, + 1L, + 20, + 23, + 25, + "river" + ); + HydrologyColumnLayer lowerHead = layerForCourse( + HydrologyFeatureType.UNDERGROUND_DROP, + 112L, + 111L, + 2L, + 20, + 22, + 25, + "river" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(higherHead, lowerHead)); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(acceptedPlan(sample, higherHead)), + new TestCaveVoxelView() + ); + + assertEquals(HydrologyCaveAction.WET_SOURCE, publication.caveCells() + .get(new CavePosition(8, 23, 8)).action()); + } + + @Test + public void incompatibleAcceptedCaveProfilesCannotMergeAtOneVoxel() { + HydrologyColumnLayer river = layerForCourse( + HydrologyFeatureType.UNDERGROUND_POOL, + 201L, + 201L, + 1L, + 20, + 23, + 25, + "river" + ); + HydrologyColumnLayer lava = layerForCourse( + HydrologyFeatureType.DEEP_POOL, + 202L, + 202L, + 1L, + 20, + 23, + 25, + "deep_lava" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(river, lava)); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> compile( + samples(sample), + List.of(acceptedPlan(sample, river), acceptedPlan(sample, lava)), + new TestCaveVoxelView() + ) + ); + + assertOverlapFailure(failure, 201L, 202L, new CavePosition(8, 21, 8)); + } + + @Test + public void incompatibleAcceptedGuardCannotHideAnotherCourseVolume() { + HydrologyColumnLayer guarded = layerForCourse( + HydrologyFeatureType.UNDERGROUND_POOL, + 301L, + 301L, + 1L, + 20, + 23, + 25, + "river" + ); + HydrologyColumnLayer occupying = layerForCourse( + HydrologyFeatureType.UNDERGROUND_POOL, + 302L, + 302L, + 1L, + 20, + 23, + 25, + "river" + ); + HydrologyColumnSample guardedSample = sample(8, 8, false, 80, List.of(guarded)); + HydrologyColumnSample occupyingSample = sample(9, 8, false, 80, List.of(occupying)); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> compile( + samples(guardedSample, occupyingSample), + List.of( + acceptedPlan(guardedSample, guarded), + acceptedPlan(occupyingSample, occupying) + ), + new TestCaveVoxelView() + ) + ); + + assertOverlapFailure(failure, 301L, 302L, new CavePosition(9, 22, 8)); + } + + @Test + public void compatibleAcceptedCaveCoursesMayShareVoxels() { + HydrologyColumnLayer first = layerForCourse( + HydrologyFeatureType.UNDERGROUND_POOL, + 401L, + 401L, + 1L, + 20, + 22, + 24, + "river" + ); + HydrologyColumnLayer second = layerForCourse( + HydrologyFeatureType.UNDERGROUND_POOL, + 402L, + 402L, + 1L, + 20, + 22, + 24, + "river" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(first, second)); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(acceptedPlan(sample, first), acceptedPlan(sample, second)), + new TestCaveVoxelView() + ); + + assertEquals(7, publication.caveCells().size()); + assertEquals( + HydrologyCaveAction.WET_SOURCE, + publication.caveCells().get(new CavePosition(8, 22, 8)).action() + ); + assertEquals( + HydrologyCaveAction.SEAL_GUARD, + publication.caveCells().get(new CavePosition(9, 22, 8)).action() + ); + } + + @Test + public void matchingSurfaceFluidComposesOverAcceptedCaveClearance() { + HydrologyColumnLayer surface = layer( + HydrologyFeatureType.WATERFALL, + 71L, + 14L, + 20, + 23, + 23, + true, + false, + "river" + ); + HydrologyColumnLayer cave = layer( + HydrologyFeatureType.RIDGE_BORE, + 72L, + 15L, + 20, + 23, + 28, + false, + false, + "river" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(surface, cave)); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(acceptedPlan(sample, cave)), + new TestCaveVoxelView() + ); + + assertTrue(publication.surfaceWrites().isEmpty()); + assertEquals( + HydrologyCaveAction.FALLING_FLUID, + publication.caveCells().get(new CavePosition(8, 21, 8)).action() + ); + assertEquals( + HydrologyCaveAction.WET_SOURCE, + publication.caveCells().get(new CavePosition(8, 23, 8)).action() + ); + } + + @Test + public void differentSurfaceAndCaveFluidsCannotOverlap() { + HydrologyColumnLayer surface = layer( + HydrologyFeatureType.WATERFALL, + 71L, + 14L, + 20, + 23, + 23, + true, + false, + "river" + ); + HydrologyColumnLayer cave = layer( + HydrologyFeatureType.RIDGE_BORE, + 72L, + 15L, + 20, + 23, + 28, + false, + false, + "lava" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(surface, cave)); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> compile(samples(sample), List.of(acceptedPlan(sample, cave)), new TestCaveVoxelView()) + ); + + assertTrue(failure.getMessage().contains(new CavePosition(8, 21, 8).toString())); + } + + @Test + public void composedCavePlanPublishesTheExactOverlappingSurfaceAction() { + HydrologyColumnLayer surface = layer( + HydrologyFeatureType.WATERFALL, + 73L, + 16L, + 20, + 23, + 23, + true, + false, + "river" + ); + HydrologyColumnLayer cave = layer( + HydrologyFeatureType.RIDGE_BORE, + 74L, + 17L, + 20, + 23, + 28, + false, + false, + "river" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(surface, cave)); + HydrologyCavePlan basePlan = acceptedPlan(sample, cave); + LinkedHashMap actions = new LinkedHashMap<>(basePlan.actions()); + for (int y = surface.bedY() + 1; y <= surface.fluidHeadY(); y++) { + HydrologyColumnSample.SurfacePublicationCell cell = sample.surfacePublicationCellAt(y).orElseThrow(); + actions.put(new CavePosition(sample.x(), y, sample.z()), cell.action()); + } + HydrologyCavePlan composedPlan = new HydrologyCavePlan( + basePlan.source(), + basePlan.rejection(), + actions, + basePlan.baselinePreconditions(), + basePlan.arbitrationWinnerSourceId() + ); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(composedPlan), + new TestCaveVoxelView() + ); + + assertTrue(publication.surfaceWrites().isEmpty()); + assertEquals( + HydrologyCaveAction.FALLING_FLUID, + publication.caveCells().get(new CavePosition(8, 21, 8)).action() + ); + assertFalse(publication.fluidUpdates().contains(new CavePosition(8, 21, 8))); + } + + @Test + public void surfaceBedGuardOverridesAnOverlappingAcceptedCaveFluidCell() { + HydrologyColumnLayer surface = layer( + HydrologyFeatureType.SURFACE_POOL, + 75L, + 18L, + 20, + 23, + 23, + false, + false, + "lava" + ); + HydrologyColumnLayer cave = layer( + HydrologyFeatureType.RIDGE_BORE, + 76L, + 19L, + 17, + 23, + 28, + false, + false, + "lava" + ); + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(surface, cave)); + + MantleHydrologyComponent.Publication publication = compile( + samples(sample), + List.of(acceptedPlan(sample, cave)), + new TestCaveVoxelView() + ); + + assertEquals(20, sample.terrainHeight()); + for (int y = 18; y <= 20; y++) { + assertEquals( + HydrologyCaveAction.SEAL_GUARD, + publication.caveCells().get(new CavePosition(8, y, 8)).action() + ); + } + assertEquals( + HydrologyCaveAction.WET_SOURCE, + publication.caveCells().get(new CavePosition(8, 21, 8)).action() + ); + } + + private List rasterizedCourseCenterline(RiverCourse course) { + ArrayList points = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + for (HydrologyPoint point : rasterizedSegmentCenterline(segment)) { + HydrologyPoint previous = points.isEmpty() ? null : points.getLast(); + if (previous == null || previous.x() != point.x() || previous.z() != point.z()) { + points.add(point); + } + } + } + return List.copyOf(points); + } + + private List rasterizedSegmentCenterline(HydraulicSegment segment) { + List configured = segment.centerline(); + if (configured.size() == 1) { + return configured; + } + ArrayList points = new ArrayList<>(); + for (int pairIndex = 0; pairIndex < configured.size() - 1; pairIndex++) { + HydrologyPoint start = configured.get(pairIndex); + HydrologyPoint end = configured.get(pairIndex + 1); + int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z())); + if (steps == 0) { + if (points.isEmpty()) { + points.add(start); + } + continue; + } + int firstStep = points.isEmpty() ? 0 : 1; + for (int step = firstStep; step <= steps; step++) { + double progress = step / (double) steps; + HydrologyPoint point = new HydrologyPoint( + (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress), + (int) StrictMath.round(start.y() + (end.y() - start.y()) * progress), + (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress) + ); + HydrologyPoint previous = points.isEmpty() ? null : points.getLast(); + if (previous == null || previous.x() != point.x() || previous.z() != point.z()) { + points.add(point); + } + } + } + return List.copyOf(points); + } + + private boolean hasCardinalOceanNeighbor(HydrologyTerrainSampler terrain, HydrologyPoint point) { + return terrain.sample(point.x() + 1, point.z()).ocean() + || terrain.sample(point.x() - 1, point.z()).ocean() + || terrain.sample(point.x(), point.z() + 1).ocean() + || terrain.sample(point.x(), point.z() - 1).ocean(); + } + + private double minimumDistance(int x, int z, List centerline) { + double minimum = Double.POSITIVE_INFINITY; + for (HydrologyPoint point : centerline) { + minimum = Math.min(minimum, StrictMath.hypot(x - point.x(), z - point.z())); + } + return minimum; + } + + private Set connectedFluidFrom( + CavePosition source, + Set materializedFluid + ) { + assertTrue(materializedFluid.contains(source)); + ArrayDeque pending = new ArrayDeque<>(); + HashSet connected = new HashSet<>(); + pending.add(source); + connected.add(source); + while (!pending.isEmpty()) { + CavePosition position = pending.removeFirst(); + addConnected(position.offset(1, 0, 0), materializedFluid, connected, pending); + addConnected(position.offset(-1, 0, 0), materializedFluid, connected, pending); + addConnected(position.offset(0, 1, 0), materializedFluid, connected, pending); + addConnected(position.offset(0, -1, 0), materializedFluid, connected, pending); + addConnected(position.offset(0, 0, 1), materializedFluid, connected, pending); + addConnected(position.offset(0, 0, -1), materializedFluid, connected, pending); + } + return Set.copyOf(connected); + } + + private MantleHydrologyComponent.Publication publicationAt( + Map publications, + int x, + int z + ) { + MantleHydrologyComponent.Publication publication = publications.get( + pack(Math.floorDiv(x, 16), Math.floorDiv(z, 16)) + ); + assertNotNull(publication); + return publication; + } + + private void addConnected( + CavePosition position, + Set materializedFluid, + Set connected, + ArrayDeque pending + ) { + if (materializedFluid.contains(position) && connected.add(position)) { + pending.addLast(position); + } + } + + private PublicationParityCase terrainParityCase(HydrologyColumnLayer layer) { + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(layer)); + return new PublicationParityCase( + layer.feature().type(), + sample, + List.of(), + new CavePosition(sample.x(), layer.fluidHeadY(), sample.z()), + HydrologyCaveAction.WET_SOURCE, + PublicationTarget.TERRAIN + ); + } + + private PublicationParityCase surfacePublicationParityCase( + HydrologyColumnLayer layer, + HydrologyCaveAction expectedAction + ) { + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(layer)); + int probeY = expectedAction == HydrologyCaveAction.FALLING_FLUID + ? layer.fluidHeadY() - 1 + : layer.fluidHeadY(); + return new PublicationParityCase( + layer.feature().type(), + sample, + List.of(), + new CavePosition(sample.x(), probeY, sample.z()), + expectedAction, + PublicationTarget.SURFACE_PUBLICATION + ); + } + + private PublicationParityCase caveParityCase(HydrologyColumnLayer layer) { + HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(layer)); + return new PublicationParityCase( + layer.feature().type(), + sample, + List.of(acceptedPlan(sample, layer)), + new CavePosition(sample.x(), layer.fluidHeadY(), sample.z()), + HydrologyCaveAction.WET_SOURCE, + PublicationTarget.CAVE_PUBLICATION + ); + } + + private void assertTerrainMaterialization( + PublicationParityCase parityCase, + MantleHydrologyComponent.Publication publication + ) { + HydrologyColumnLayer selected = parityCase.sample().primarySurfaceFluidLayer().orElseThrow(); + assertEquals(parityCase.type().name(), parityCase.type(), selected.feature().type()); + assertEquals(selected.bedY(), parityCase.sample().terrainHeight()); + assertTrue(parityCase.probe().y() > selected.bedY()); + assertTrue(parityCase.probe().y() <= selected.fluidHeadY()); + assertFalse(publication.surfaceWrites().containsKey(parityCase.probe())); + assertFalse(publication.caveCells().containsKey(parityCase.probe())); + } + + private void assertSurfacePublication( + PublicationParityCase parityCase, + MantleHydrologyComponent.Publication publication + ) { + MantleHydrologyComponent.SurfaceFluidWrite write = publication.surfaceWrites().get(parityCase.probe()); + assertNotNull(parityCase.type().name(), write); + assertEquals(parityCase.type().name(), parityCase.expectedAction(), write.action()); + assertFalse(publication.caveCells().containsKey(parityCase.probe())); + } + + private void assertCavePublication( + PublicationParityCase parityCase, + MantleHydrologyComponent.Publication publication + ) { + HydrologyCaveCell cell = publication.caveCells().get(parityCase.probe()); + assertNotNull(parityCase.type().name(), cell); + assertEquals(parityCase.type().name(), parityCase.expectedAction(), cell.action()); + assertFalse(publication.surfaceWrites().containsKey(parityCase.probe())); + } + + private BufferedImage renderRiverColumn(HydrologyColumnSample sample) { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getHydrologyRuntime()).thenReturn(runtime); + when(runtime.sampleRenderFootprint(anyDouble(), anyDouble(), anyDouble(), anyDouble())) + .thenReturn(sample.renderSample()); + when(runtime.sampleDiagnosticFootprint(anyDouble(), anyDouble(), anyDouble(), anyDouble())) + .thenReturn(new HydrologyDiagnosticRenderSample(sample.x(), sample.z(), List.of())); + return new IrisRenderer(engine).renderStudio( + sample.x(), + sample.z(), + 1D, + 1, + RenderType.RIVER, + () -> false + ); + } + + private MantleHydrologyComponent.Publication compile( + Map samples, + List plans, + CaveVoxelView caveView + ) { + return MantleHydrologyComponent.compilePublication( + 0, + 0, + 128, + (x, z) -> Optional.ofNullable(samples.get(pack(x, z))), + plans, + caveView + ); + } + + private static HydrologyTile plannedCascadeTile() { + return CascadeTileHolder.TILE; + } + + private static HydrologyTile createCascadeTile() { + HydrologyTerrainSampler terrainSampler = (int x, int z) -> { + if (x >= 112) { + return cascadeTerrain(54, true, false, false); + } + int terrace = Math.max(0, Math.floorDiv(x, 32)); + return cascadeTerrain(120 - terrace * 2, false, x == 0 && z == 0, z == 0); + }; + return new HydrologyPlanner( + 1441L, + cascadeSettings(), + terrainSampler, + new TestCaveVoxelView() + ).plan(new HydrologyTileKey(0, 0)); + } + + private static HydrologyPlannerSettings cascadeSettings() { + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( + true, + 1D, + 80, + 1, + 1, + 16 + ); + HydrologyPlannerSettings.Source disabledSources = new HydrologyPlannerSettings.Source( + false, + 0D, + Integer.MIN_VALUE, + 0, + 0, + 0 + ); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing(128, 16, 8, 512, 256, + new HydrologyPlannerSettings.Branching(16, 8), 0.5D, 12D, 0.5D, 0.1D), + new HydrologyPlannerSettings.Surface( + true, + surfaceSources, + 2, + 2, + 2, + 2, + 4, + 8, + 128, + 0D, + 0, + 0, + false, + 0, + 0 + ), + new HydrologyPlannerSettings.Hydraulics(8, 16, 1, 3, 8, 4), + new HydrologyPlannerSettings.Underground( + false, + disabledSources, + 64, + 84, + 2, + 2, + 1, + 1, + 2, + 2, + false + ), + new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), + new HydrologyPlannerSettings.Grotto(false, 2, 2, 2, 512), + false, + 32, + 16, + 1, + 8 + ), + HydrologyPlannerSettings.Geometry.defaults(), + List.of() + ); + } + + private static HydrologyTerrainSample cascadeTerrain( + int height, + boolean ocean, + boolean source, + boolean transit + ) { + return new HydrologyTerrainSample( + height, + ocean ? 0D : 1D, + ocean, + !ocean, + 68, + 74, + transit, + transit, + source, + source, + false, + false, + 0D, + source ? 1D : 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + ocean ? "ocean" : "land", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default") + ); + } + + private void assertEmpty(MantleHydrologyComponent.Publication publication) { + assertTrue(publication.caveCells().isEmpty()); + assertTrue(publication.surfaceWrites().isEmpty()); + assertTrue(publication.fluidUpdates().isEmpty()); + } + + private void assertOverlapFailure( + IllegalStateException failure, + long firstCourseId, + long secondCourseId, + CavePosition position + ) { + assertTrue(failure.getMessage().contains(Long.toString(firstCourseId))); + assertTrue(failure.getMessage().contains(Long.toString(secondCourseId))); + assertTrue(failure.getMessage().contains(position.toString())); + } + + private HydrologyCavePlan acceptedPlan( + HydrologyColumnSample sample, + HydrologyColumnLayer layer + ) { + LinkedHashMap actions = new LinkedHashMap<>(); + for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) { + HydrologyCaveAction action; + if (y > layer.fluidHeadY()) { + action = HydrologyCaveAction.DRY_AIR; + } else if (layer.fallingFluid() && y < layer.fluidHeadY()) { + action = HydrologyCaveAction.FALLING_FLUID; + } else { + action = HydrologyCaveAction.WET_SOURCE; + } + actions.put(new CavePosition(sample.x(), y, sample.z()), action); + } + actions.put(new CavePosition(sample.x(), layer.bedY(), sample.z()), HydrologyCaveAction.SEAL_GUARD); + actions.put(new CavePosition(sample.x(), layer.ceilingY() + 1, sample.z()), HydrologyCaveAction.SEAL_GUARD); + actions.put(new CavePosition(sample.x() + 1, layer.bedY() + 2, sample.z()), HydrologyCaveAction.SEAL_GUARD); + + LinkedHashMap preconditions = new LinkedHashMap<>(); + for (CavePosition position : actions.keySet()) { + preconditions.put(position, new CaveVoxelPrecondition(CaveVoxel.SOLID, false)); + } + HydrologyCaveSource source = new HydrologyCaveSource( + layer.feature().courseId(), + new CavePosition(sample.x(), layer.ceilingY(), sample.z()), + new CavePosition(sample.x(), layer.bedY() + 1, sample.z()), + layer.fluidHeadY(), + HydrologyCaveMode.CLOSED_COMPONENT + ); + return new HydrologyCavePlan( + source, + HydrologyCaveRejection.NONE, + actions, + preconditions, + OptionalLong.empty() + ); + } + + private HydrologyCavePlan replacePrecondition( + HydrologyCavePlan plan, + CavePosition position, + CaveVoxelPrecondition precondition + ) { + LinkedHashMap preconditions = new LinkedHashMap<>( + plan.baselinePreconditions() + ); + preconditions.put(position, precondition); + return new HydrologyCavePlan( + plan.source(), + plan.rejection(), + plan.actions(), + preconditions, + plan.arbitrationWinnerSourceId() + ); + } + + private HydrologyColumnLayer cavePool() { + return layer( + HydrologyFeatureType.UNDERGROUND_POOL, + 31L, + 9L, + 20, + 23, + 30, + false, + false, + "river" + ); + } + + private Map samples(HydrologyColumnSample... samples) { + HashMap indexed = new HashMap<>(); + for (HydrologyColumnSample sample : samples) { + indexed.put(pack(sample.x(), sample.z()), sample); + } + return indexed; + } + + private HydrologyColumnSample sample( + int x, + int z, + boolean ocean, + int naturalHeight, + List layers + ) { + return new HydrologyColumnSample(x, z, naturalHeight, 63, ocean, "parent", layers); + } + + private HydrologyColumnLayer layer( + HydrologyFeatureType type, + long id, + long segmentId, + int bed, + int head, + int ceiling, + boolean falling, + boolean receiving, + String profileKey + ) { + return layerForCourse( + type, + id, + 1L, + segmentId, + bed, + head, + ceiling, + falling, + receiving, + profileKey + ); + } + + private HydrologyColumnLayer layerForCourse( + HydrologyFeatureType type, + long id, + long courseId, + long segmentId, + int bed, + int head, + int ceiling, + String profileKey + ) { + return layerForCourse( + type, + id, + courseId, + segmentId, + bed, + head, + ceiling, + false, + false, + profileKey + ); + } + + private HydrologyColumnLayer layerForCourse( + HydrologyFeatureType type, + long id, + long courseId, + long segmentId, + int bed, + int head, + int ceiling, + boolean falling, + boolean receiving, + String profileKey + ) { + return new HydrologyColumnLayer( + feature(type, id, courseId, segmentId, head), + bed, + head, + ceiling, + true, + false, + false, + true, + falling, + receiving, + true, + true, + false, + profileKey, + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + } + + private HydrologyFeatureRef feature(HydrologyFeatureType type, long id, long segmentId, int y) { + return feature(type, id, 1L, segmentId, y); + } + + private HydrologyFeatureRef feature( + HydrologyFeatureType type, + long id, + long courseId, + long segmentId, + int y + ) { + return new HydrologyFeatureRef(id, type, courseId, segmentId, 8, y, 8, 1, 0, false); + } + + private long pack(int x, int z) { + return ((long) x << 32) ^ (z & 0xffffffffL); + } + + private record PublicationParityCase( + HydrologyFeatureType type, + HydrologyColumnSample sample, + List plans, + CavePosition probe, + HydrologyCaveAction expectedAction, + PublicationTarget target + ) { + } + + private enum PublicationTarget { + TERRAIN, + SURFACE_PUBLICATION, + CAVE_PUBLICATION + } + + private static final class CascadeTileHolder { + private static final HydrologyTile TILE = createCascadeTile(); + } + + private static final class TestCaveVoxelView implements CaveVoxelView { + private final Map voxels = new HashMap<>(); + private final Set openToSurface = new HashSet<>(); + + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > 0 && position.y() < 127; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return voxels.getOrDefault(position, CaveVoxel.SOLID); + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return openToSurface.contains(position); + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return false; + } + + private void setVoxel(CavePosition position, CaveVoxel voxel) { + voxels.put(position, voxel); + } + + private void setOpenToSurface(CavePosition position) { + openToSurface.add(position); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java index ab41d7edc..b45ff18bd 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentBoundaryRadiusTest.java @@ -2,10 +2,12 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.mantle.EngineMantle; import art.arcane.iris.engine.mantle.MantleComponent; import art.arcane.iris.engine.mantle.MantlePass; +import art.arcane.iris.engine.mantle.MantleWriter; import art.arcane.iris.engine.mantle.MatterGenerator; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimension; @@ -42,9 +44,9 @@ import java.io.File; import java.io.FileOutputStream; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; 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.ArgumentMatchers.anyString; @@ -60,9 +62,16 @@ public class MantleObjectComponentBoundaryRadiusTest { @Before public void bindPlatform() { IrisPlatforms.unbind(); - PlatformBlockState block = mock(PlatformBlockState.class); PlatformRegistries registries = mock(PlatformRegistries.class); - when(registries.block(anyString())).thenReturn(block); + when(registries.block(anyString())).thenAnswer(invocation -> { + String key = invocation.getArgument(0); + PlatformBlockState block = mock(PlatformBlockState.class); + when(block.key()).thenReturn(key); + when(block.materialKey()).thenReturn(key); + when(block.isSolid()).thenReturn(!key.toLowerCase().contains("air")); + when(block.isOccluding()).thenReturn(!key.toLowerCase().contains("air")); + return block; + }); IrisPlatform platform = mock(IrisPlatform.class); when(platform.registries()).thenReturn(registries); IrisPlatforms.bind(platform); @@ -93,6 +102,49 @@ public class MantleObjectComponentBoundaryRadiusTest { assertEquals(43, MantleObjectComponent.calculatePlacementReach(new IrisBlockVector(5, 4, 3), placement)); } + @Test + public void sourceReplayUsesAscendingSignedCoordinates() { + List replayedSources = new ArrayList<>(); + + MantleObjectComponent.replaySourceChunks( + -2, + -3, + 16, + (chunkX, chunkZ) -> replayedSources.add(chunkX + "," + chunkZ) + ); + + assertEquals(List.of( + "-3,-4", "-3,-3", "-3,-2", + "-2,-4", "-2,-3", "-2,-2", + "-1,-4", "-1,-3", "-1,-2" + ), replayedSources); + } + + @Test + public void sourcePredecessorReplayUsesTheStableAnchoredPrefix() { + List replayedSources = new ArrayList<>(); + + MantleObjectComponent.replaySourcePredecessors( + -2, + -3, + 16, + (chunkX, chunkZ) -> replayedSources.add(chunkX + "," + chunkZ) + ); + + assertEquals(List.of( + "-3,-4", "-3,-3", "-3,-2", + "-2,-4" + ), replayedSources); + } + + @Test + public void collisionInputRadiusCoversBothSourceTraversalLegs() { + assertEquals(82, MantleObjectComponent.calculateInputRadius(33, false)); + assertEquals(130, MantleObjectComponent.calculateInputRadius(33, true)); + assertEquals(Integer.MAX_VALUE, + MantleObjectComponent.calculateInputRadius(Integer.MAX_VALUE, true)); + } + @Test public void proceduralTreeTransformsContributeToRadius() { IrisObjectPlacement placement = new IrisObjectPlacement() @@ -120,6 +172,47 @@ public class MantleObjectComponentBoundaryRadiusTest { assertEquals(53, new MantleObjectComponent(engineMantle).getRadius()); } + @Test + public void upperDimensionObjectsDriveRadiusAndCollisionMode() { + IrisObjectPlacement placement = new IrisObjectPlacement() + .setRotation(IrisObjectRotation.of(90, 0, 0)) + .setTranslate(new IrisObjectTranslate().setX(3).setY(4)); + placement.getForbiddenCollisions().add("upper/blocker"); + IrisData baseData = mock(IrisData.class); + IrisData upperData = mock(IrisData.class); + IrisProceduralPlacement proceduralPlacement = mock(IrisProceduralPlacement.class); + when(proceduralPlacement.getVariantObjects(upperData)) + .thenReturn(new KList<>(new IrisObject(5, 48, 7))); + when(proceduralPlacement.asPlacement()).thenReturn(placement); + IrisProceduralObjects proceduralObjects = mock(IrisProceduralObjects.class); + when(proceduralObjects.isEmpty()).thenReturn(false); + when(proceduralObjects.getAllPlacements()).thenReturn(new KList<>(proceduralPlacement)); + IrisRegion upperRegion = mock(IrisRegion.class); + when(upperRegion.getObjects()).thenReturn(new KList<>()); + when(upperRegion.getProceduralObjects()).thenReturn(proceduralObjects); + IrisDimension baseDimension = mock(IrisDimension.class); + when(baseDimension.isUpperDimensionObjects()).thenReturn(true); + when(baseDimension.getAllRegions(any())).thenReturn(new KList<>()); + when(baseDimension.getReachableBiomes(any())).thenReturn(new KList<>()); + IrisDimension upperDimension = mock(IrisDimension.class); + when(upperDimension.getAllRegions(any())).thenReturn(new KList<>(upperRegion)); + when(upperDimension.getReachableBiomes(any())).thenReturn(new KList<>()); + UpperDimensionContext upperContext = mock(UpperDimensionContext.class); + when(upperContext.getDimension()).thenReturn(upperDimension); + when(upperContext.getData()).thenReturn(upperData); + Engine engine = mock(Engine.class); + when(engine.getDimension()).thenReturn(baseDimension); + when(engine.getUpperContext()).thenReturn(upperContext); + EngineMantle engineMantle = mock(EngineMantle.class); + when(engineMantle.getEngine()).thenReturn(engine); + when(engineMantle.getData()).thenReturn(baseData); + + MantleObjectComponent component = new MantleObjectComponent(engineMantle); + + assertEquals(53, component.getRadius()); + assertEquals(182, component.getInputRadius()); + } + @Test @SuppressWarnings("unchecked") public void translatedTreeSchedulesDistantOwnerChunks() throws Exception { @@ -153,11 +246,12 @@ public class MantleObjectComponentBoundaryRadiusTest { MantleObjectComponent component = spy(new MantleObjectComponent(engineMantle)); assertEquals(33, component.getRadius()); - int chunkRadius = Math.ceilDiv(component.getRadius(), 16); - assertEquals(3, chunkRadius); + assertEquals(33, component.getOutputRadius()); + assertEquals(0, component.getInputRadius()); Mantle mantle = mock(Mantle.class); MantleChunk chunk = mock(MantleChunk.class); + when(mantle.getWorldHeight()).thenReturn(64); when(mantle.getChunk(anyInt(), anyInt())).thenReturn(chunk); when(chunk.use()).thenReturn(chunk); doAnswer(invocation -> { @@ -166,6 +260,25 @@ public class MantleObjectComponentBoundaryRadiusTest { return null; }).when(chunk).raiseFlagSuspend(any(), any(Runnable.class)); + MantleWriter directWriter = mock(MantleWriter.class); + when(directWriter.getMantle()).thenReturn(mantle); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(2); + task.run(); + return null; + }).when(directWriter).withChunkFence(anyInt(), anyInt(), any(Runnable.class)); + AtomicInteger directOrigins = new AtomicInteger(); + doAnswer(invocation -> { + directOrigins.incrementAndGet(); + return null; + }).when(component).generateOrigin(any(), anyInt(), anyInt(), any()); + + component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class)); + + assertEquals(1, directOrigins.get()); + component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class)); + assertEquals(2, directOrigins.get()); + List generatedChunks = new ArrayList<>(); doAnswer(invocation -> { int chunkX = invocation.getArgument(1); @@ -174,11 +287,31 @@ public class MantleObjectComponentBoundaryRadiusTest { return null; }).when(component).generateLayer(any(), anyInt(), anyInt(), any()); - TestMatterGenerator generator = new TestMatterGenerator(new GeneratorOptions(engine, mantle, component, chunkRadius)); + TestMatterGenerator generator = new TestMatterGenerator(new GeneratorOptions( + engine, + mantle, + component, + component.getOutputRadius() + )); generator.generateMatter(0, 0, false, mock(ChunkContext.class)); - assertTrue(generatedChunks.contains("-2,0")); - assertTrue(generatedChunks.contains("2,0")); + assertEquals(49, generatedChunks.size()); + + List replayedSources = new ArrayList<>(); + MantleObjectComponent.replaySourceChunks( + 0, + 0, + component.getRadius(), + (chunkX, chunkZ) -> replayedSources.add(chunkX + "," + chunkZ) + ); + assertEquals(49, replayedSources.size()); + assertEquals("-3,-3", replayedSources.getFirst()); + assertEquals("-3,-2", replayedSources.get(1)); + assertEquals("3,3", replayedSources.getLast()); + + placement.getForbiddenCollisions().add("test/tree"); + component.hotload(); + assertEquals(130, component.getInputRadius()); } private static final class TestMatterGenerator implements MatterGenerator { diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java index 938be8a38..f9c3251f7 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCaveAnchorTest.java @@ -1,8 +1,8 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.volmlib.util.matter.MatterCavern; import org.junit.Test; @@ -49,15 +49,18 @@ public class MantleObjectComponentCaveAnchorTest { } @Test - public void protectedRiverHydrologyCannotBecomeAnObjectAnchor() { + public void plannedHydrologyCannotBecomeAnObjectAnchor() { MatterCavern water = new MatterCavern(true, "", (byte) 1); + MatterCavern forcedAir = new MatterCavern(true, "", (byte) 3); assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( - true, water, RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), 20, 8)); + true, water, HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), 20, 8)); assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( - true, water, RiverCaveHydrology.of(RiverCaveAction.FALLING_FLUID), 20, 8)); + true, water, HydrologyCaveCell.of(HydrologyCaveAction.FALLING_FLUID), 20, 8)); assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( - true, null, RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD), 20, 8)); + true, null, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD), 20, 8)); + assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( + false, forcedAir, HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR), 20, 8)); } private static IrisBiome biome(String loadKey) { diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCollisionDeterminismTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCollisionDeterminismTest.java new file mode 100644 index 000000000..3d5968f40 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleObjectComponentCollisionDeterminismTest.java @@ -0,0 +1,234 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.engine.mantle.MantleWriter; +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.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.assertFalse; +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 MantleObjectComponentCollisionDeterminismTest { + private static final int RADIUS = 16; + private static final int TARGET_Y = 10; + private static final String BLOCKER = "blocker@1"; + private static final String TARGET = "target@2"; + private static final Position LEFT = new Position(15, TARGET_Y, 0); + private static final Position RIGHT = new Position(16, TARGET_Y, 0); + + @Test + public void shiftedDestinationWindowsWouldSplitOneCollisionDecision() { + Scenario scenario = new Scenario(false, false, false); + Map blocks = new HashMap<>(); + + blocks.putAll(generateDirectDestination(0, scenario)); + blocks.putAll(generateDirectDestination(1, scenario)); + + assertEquals(BLOCKER, blocks.get(LEFT)); + assertEquals(TARGET, blocks.get(RIGHT)); + } + + @Test + public void sourceAnchoredPlansMatchForForwardReverseAndParallelDestinations() throws Exception { + Scenario scenario = new Scenario(false, false, false); + Map forward = generateAnchoredWorld(List.of(0, 1), scenario); + Map reverse = generateAnchoredWorld(List.of(1, 0), scenario); + Map parallel = generateAnchoredWorldParallel(scenario); + + assertEquals(forward, reverse); + assertEquals(forward, parallel); + assertFalse(TARGET.equals(forward.get(LEFT))); + assertFalse(TARGET.equals(forward.get(RIGHT))); + } + + @Test + public void allowedCollisionOverrideAcceptsEveryDestinationFragment() { + Map blocks = generateAnchoredWorld( + List.of(0, 1), + new Scenario(true, false, false) + ); + + assertEquals(TARGET, blocks.get(LEFT)); + assertEquals(TARGET, blocks.get(RIGHT)); + } + + @Test + public void sameSourcePredecessorStillVetoesEveryDestinationFragment() { + Map blocks = generateAnchoredWorld( + List.of(0, 1), + new Scenario(false, true, false) + ); + + assertEquals(BLOCKER, blocks.get(LEFT)); + assertFalse(TARGET.equals(blocks.get(RIGHT))); + } + + @Test + public void blockerOutsideTheAnchoredHaloIsUniformlyIgnored() { + Map blocks = generateAnchoredWorld( + List.of(0, 1), + new Scenario(false, false, true) + ); + + assertEquals(TARGET, blocks.get(LEFT)); + assertEquals(TARGET, blocks.get(RIGHT)); + } + + private static Map generateAnchoredWorld(List destinations, Scenario scenario) { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(256L); + Map blocks = new HashMap<>(); + for (Integer destination : destinations) { + blocks.putAll(generateAnchoredDestination(destination, scenario, cache)); + } + return blocks; + } + + private static Map generateAnchoredWorldParallel(Scenario scenario) throws Exception { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(256L); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> left = executor.submit( + () -> generateAnchoredDestinationAfterBarrier(0, scenario, cache, ready, start) + ); + Future> right = executor.submit( + () -> generateAnchoredDestinationAfterBarrier(1, scenario, cache, ready, start) + ); + assertTrue(ready.await(5L, TimeUnit.SECONDS)); + start.countDown(); + Map blocks = new HashMap<>(left.get(5L, TimeUnit.SECONDS)); + blocks.putAll(right.get(5L, TimeUnit.SECONDS)); + return blocks; + } finally { + executor.shutdownNow(); + } + } + + private static Map generateAnchoredDestinationAfterBarrier( + int destination, + Scenario scenario, + ObjectSourcePlanCache cache, + CountDownLatch ready, + CountDownLatch start + ) throws Exception { + ready.countDown(); + assertTrue(start.await(5L, TimeUnit.SECONDS)); + return generateAnchoredDestination(destination, scenario, cache); + } + + private static Map generateAnchoredDestination( + int destination, + Scenario scenario, + ObjectSourcePlanCache cache + ) { + Map blocks = new HashMap<>(); + MantleWriter writer = writer(blocks); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, destination, 0); + MantleObjectComponent.replaySourceChunks(destination, 0, RADIUS, (sourceX, sourceZ) -> + transaction.apply(cache.get(sourceX, sourceZ, + () -> buildSourcePlan(writer, sourceX, sourceZ, scenario)))); + transaction.commit(); + return blocks; + } + + private static Map generateDirectDestination(int destination, Scenario scenario) { + Map blocks = new HashMap<>(); + MantleWriter writer = writer(blocks); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, destination, 0); + MantleObjectComponent.replaySourceChunks(destination, 0, RADIUS, + (sourceX, sourceZ) -> generateOrigin(transaction, sourceX, sourceZ, scenario)); + transaction.commit(); + return blocks; + } + + private static ObjectSourcePlan buildSourcePlan( + MantleWriter writer, + int sourceX, + int sourceZ, + Scenario scenario + ) { + ObjectDestinationTransaction scratch = new ObjectDestinationTransaction(writer, sourceX, sourceZ); + MantleObjectComponent.replaySourcePredecessors(sourceX, sourceZ, RADIUS, + (predecessorX, predecessorZ) -> generateOrigin(scratch, predecessorX, predecessorZ, scenario)); + int checkpoint = scratch.mutationCheckpoint(); + generateOrigin(scratch, sourceX, sourceZ, scenario); + return scratch.sourcePlanSince(checkpoint); + } + + private static void generateOrigin( + ObjectDestinationTransaction transaction, + int sourceX, + int sourceZ, + Scenario scenario + ) { + if (isBlockerSource(sourceX, sourceZ, scenario)) { + transaction.setData(LEFT.x(), LEFT.y(), LEFT.z(), BLOCKER); + } + if (sourceX != 0 || sourceZ != 0) { + return; + } + String collision = transaction.getDataIfPresent(LEFT.x(), LEFT.y(), LEFT.z(), String.class); + if (BLOCKER.equals(collision) && !scenario.allowed()) { + return; + } + transaction.setData(LEFT.x(), LEFT.y(), LEFT.z(), TARGET); + transaction.setData(RIGHT.x(), RIGHT.y(), RIGHT.z(), TARGET); + } + + private static boolean isBlockerSource(int sourceX, int sourceZ, Scenario scenario) { + if (sourceZ != 0) { + return false; + } + if (scenario.sameSource()) { + return sourceX == 0; + } + return sourceX == (scenario.outsideHalo() ? -2 : -1); + } + + @SuppressWarnings("unchecked") + private static MantleWriter writer(Map blocks) { + MantleWriter writer = mock(MantleWriter.class); + Mantle mantle = mock(Mantle.class); + when(mantle.getWorldHeight()).thenReturn(64); + when(writer.getMantle()).thenReturn(mantle); + when(writer.getPrerequisiteDataIfPresent(anyInt(), anyInt(), anyInt(), any())).thenReturn(null); + doAnswer(invocation -> { + int x = invocation.getArgument(0); + int y = invocation.getArgument(1); + int z = invocation.getArgument(2); + Object value = invocation.getArgument(3); + if (value instanceof String marker) { + blocks.put(new Position(x, y, z), marker); + } + return null; + }).when(writer).setData(anyInt(), anyInt(), anyInt(), any()); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(2); + task.run(); + return null; + }).when(writer).withChunkFence(anyInt(), anyInt(), any(Runnable.class)); + return writer; + } + + private record Scenario(boolean allowed, boolean sameSource, boolean outsideHalo) { + } + + private record Position(int x, int y, int z) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java deleted file mode 100644 index fdd1f4af3..000000000 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverCaveVoxelViewTest.java +++ /dev/null @@ -1,182 +0,0 @@ -package art.arcane.iris.engine.mantle.components; - -import art.arcane.iris.engine.river.cave.CavePosition; -import art.arcane.iris.engine.river.cave.CaveVoxel; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; -import art.arcane.volmlib.util.collection.KMap; -import art.arcane.volmlib.util.mantle.runtime.Mantle; -import art.arcane.volmlib.util.mantle.runtime.MantleChunk; -import art.arcane.volmlib.util.mantle.runtime.TectonicPlate; -import art.arcane.volmlib.util.matter.Matter; -import art.arcane.volmlib.util.matter.MatterSlice; -import art.arcane.volmlib.util.matter.MatterCavern; -import art.arcane.iris.spi.PlatformBlockState; -import org.junit.Test; - -import java.util.ArrayList; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; - -public class MantleRiverCaveVoxelViewTest { - @Test - @SuppressWarnings("unchecked") - public void absentCellsAboveLocalTerrainAreOpenAir() { - Mantle mantle = mock(Mantle.class); - when(mantle.getLoadedRegions()).thenReturn(new KMap>()); - MantleRiverCaveVoxelView view = new MantleRiverCaveVoxelView( - mantle, - 128, - (x, z) -> x < 0 ? 20 : 60, - (x, z) -> null, - RiverCaveFluidKind.RIVER, - (chunkX, chunkZ) -> { - } - ); - CavePosition cliffAir = new CavePosition(-1, 21, 0); - CavePosition terrain = new CavePosition(-1, 20, 0); - - assertEquals(CaveVoxel.CAVE_AIR, view.voxelAt(cliffAir)); - assertTrue(view.isOpenToSurface(cliffAir)); - assertEquals(CaveVoxel.SOLID, view.voxelAt(terrain)); - assertFalse(view.isOpenToSurface(terrain)); - } - - @Test - @SuppressWarnings("unchecked") - public void carvingInputLoadsOnlyOncePerReadChunk() { - Mantle mantle = mock(Mantle.class); - when(mantle.getLoadedRegions()).thenReturn(new KMap>()); - List loaded = new ArrayList<>(); - MantleRiverCaveVoxelView view = new MantleRiverCaveVoxelView( - mantle, - 128, - (x, z) -> 60, - (x, z) -> null, - RiverCaveFluidKind.RIVER, - (chunkX, chunkZ) -> loaded.add(chunkX + "," + chunkZ) - ); - - view.voxelAt(new CavePosition(0, 20, 0)); - view.voxelAt(new CavePosition(15, 21, 15)); - view.riverHydrologyAt(new CavePosition(1, 22, 1)); - view.voxelAt(new CavePosition(16, 20, 0)); - - assertEquals(List.of("0,0", "1,0"), loaded); - } - - @Test - @SuppressWarnings("unchecked") - public void publishedRiverActionsDoNotReplaceTheUnderlyingPlanningBaseline() { - Mantle mantle = mock(Mantle.class); - TectonicPlate plate = mock(TectonicPlate.class); - MantleChunk chunk = mock(MantleChunk.class); - Matter matter = mock(Matter.class); - MatterSlice hydrologySlice = mock(MatterSlice.class); - KMap> regions = new KMap<>(); - regions.put(Mantle.key(0, 0), plate); - CavePosition position = new CavePosition(0, 20, 0); - when(mantle.getLoadedRegions()).thenReturn(regions); - when(plate.get(0, 0)).thenReturn(chunk); - when(chunk.exists(1)).thenReturn(true); - when(chunk.get(1)).thenReturn(matter); - when(matter.hasSlice(RiverCaveHydrology.class)).thenReturn(true); - doReturn(hydrologySlice).when(matter).getSlice(RiverCaveHydrology.class); - when(hydrologySlice.get(0, 4, 0)).thenReturn(RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE)); - MantleRiverCaveVoxelView view = new MantleRiverCaveVoxelView( - mantle, - 128, - (x, z) -> 60, - (x, z) -> null, - RiverCaveFluidKind.RIVER, - (chunkX, chunkZ) -> { - } - ); - - assertEquals(CaveVoxel.SOLID, view.voxelAt(position)); - assertEquals(RiverCaveAction.WET_SOURCE, view.riverHydrologyAt(position).action()); - } - - @Test - @SuppressWarnings("unchecked") - public void configuredLavaRiverTreatsPublishedLavaAsCompatibleFluid() { - Mantle mantle = mock(Mantle.class); - TectonicPlate plate = mock(TectonicPlate.class); - MantleChunk chunk = mock(MantleChunk.class); - Matter matter = mock(Matter.class); - MatterSlice cavernSlice = mock(MatterSlice.class); - PlatformBlockState lava = mock(PlatformBlockState.class); - KMap> regions = new KMap<>(); - regions.put(Mantle.key(0, 0), plate); - when(mantle.getLoadedRegions()).thenReturn(regions); - when(plate.get(0, 0)).thenReturn(chunk); - when(chunk.exists(1)).thenReturn(true); - when(chunk.get(1)).thenReturn(matter); - when(matter.hasSlice(MatterCavern.class)).thenReturn(true); - doReturn(cavernSlice).when(matter).getSlice(MatterCavern.class); - when(cavernSlice.get(0, 4, 0)).thenReturn(new MatterCavern(true, "", (byte) 2)); - when(lava.materialKey()).thenReturn("minecraft:lava"); - MantleRiverCaveVoxelView view = new MantleRiverCaveVoxelView( - mantle, - 128, - (x, z) -> 60, - (x, z) -> lava, - RiverCaveFluidKind.RIVER, - (chunkX, chunkZ) -> { - } - ); - - assertEquals(CaveVoxel.COMPATIBLE_FLUID, view.voxelAt(new CavePosition(0, 20, 0))); - } - - @Test - @SuppressWarnings("unchecked") - public void planningRejectsHydrologyOwnedByTheOtherFluidKind() { - Mantle mantle = mock(Mantle.class); - TectonicPlate plate = mock(TectonicPlate.class); - MantleChunk chunk = mock(MantleChunk.class); - Matter matter = mock(Matter.class); - MatterSlice hydrologySlice = mock(MatterSlice.class); - KMap> regions = new KMap<>(); - regions.put(Mantle.key(0, 0), plate); - CavePosition position = new CavePosition(0, 20, 0); - when(mantle.getLoadedRegions()).thenReturn(regions); - when(plate.get(0, 0)).thenReturn(chunk); - when(chunk.exists(1)).thenReturn(true); - when(chunk.get(1)).thenReturn(matter); - when(matter.hasSlice(RiverCaveHydrology.class)).thenReturn(true); - doReturn(hydrologySlice).when(matter).getSlice(RiverCaveHydrology.class); - when(hydrologySlice.get(0, 4, 0)).thenReturn(RiverCaveHydrology.of( - RiverCaveAction.WET_SOURCE, - RiverCaveFluidKind.DEEP_POOL - )); - MantleRiverCaveVoxelView riverView = new MantleRiverCaveVoxelView( - mantle, - 128, - (x, z) -> 60, - (x, z) -> null, - RiverCaveFluidKind.RIVER, - (chunkX, chunkZ) -> { - } - ); - MantleRiverCaveVoxelView deepPoolView = new MantleRiverCaveVoxelView( - mantle, - 128, - (x, z) -> 60, - (x, z) -> null, - RiverCaveFluidKind.DEEP_POOL, - (chunkX, chunkZ) -> { - } - ); - - assertEquals(CaveVoxel.INCOMPATIBLE_FLUID, riverView.voxelAt(position)); - assertEquals(CaveVoxel.SOLID, deepPoolView.voxelAt(position)); - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java deleted file mode 100644 index 4846d71a4..000000000 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/MantleRiverHydrologyComponentTest.java +++ /dev/null @@ -1,1025 +0,0 @@ -package art.arcane.iris.engine.mantle.components; - -import art.arcane.iris.engine.IrisComplex; -import art.arcane.iris.engine.framework.Engine; -import art.arcane.iris.engine.mantle.EngineMantle; -import art.arcane.iris.engine.object.IrisGeneratorStyle; -import art.arcane.iris.engine.object.IrisDimension; -import art.arcane.iris.engine.object.IrisRiverCaveFallback; -import art.arcane.iris.engine.object.IrisRiverCaveMode; -import art.arcane.iris.engine.object.IrisRiverCaves; -import art.arcane.iris.engine.object.IrisRiverDeepPools; -import art.arcane.iris.engine.object.IrisRiverExistingFluidPolicy; -import art.arcane.iris.engine.object.NoiseStyle; -import art.arcane.iris.engine.mantle.ComponentFlag; -import art.arcane.iris.engine.river.RiverAnchor; -import art.arcane.iris.engine.river.RiverEdgeId; -import art.arcane.iris.engine.river.RiverNodeId; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverSection; -import art.arcane.iris.engine.river.RiverTopologyComplexity; -import art.arcane.iris.engine.river.cave.CavePosition; -import art.arcane.iris.engine.river.cave.CaveVoxel; -import art.arcane.iris.engine.river.cave.CaveVoxelPrecondition; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveContainmentPlanner; -import art.arcane.iris.engine.river.cave.RiverCaveFluidPolicy; -import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; -import art.arcane.iris.engine.river.cave.RiverCaveMode; -import art.arcane.iris.engine.river.cave.RiverCavePlan; -import art.arcane.iris.engine.river.cave.RiverCavePlannerSettings; -import art.arcane.iris.engine.river.cave.RiverCaveRejection; -import art.arcane.iris.engine.river.cave.RiverCaveSource; -import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample; -import art.arcane.iris.engine.river.runtime.IrisRiverTunnelSample; -import art.arcane.iris.engine.river.runtime.IrisRiverRuntime; -import art.arcane.iris.spi.IrisPlatform; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.spi.PlatformBlockState; -import art.arcane.iris.spi.PlatformRegistries; -import art.arcane.iris.util.project.context.ChunkContext; -import org.junit.Test; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; - -import art.arcane.volmlib.util.mantle.flag.ReservedFlag; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.ArgumentMatchers.anyDouble; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -public class MantleRiverHydrologyComponentTest { - private final RiverCaveContainmentPlanner planner = new RiverCaveContainmentPlanner(); - - @Test - public void componentRunsBetweenCarvingAndPlacementWithStableFlagOrdinal() { - ComponentFlag flag = MantleRiverHydrologyComponent.class.getAnnotation(ComponentFlag.class); - - assertEquals(1, MantleRiverHydrologyComponent.PRIORITY); - assertEquals(ReservedFlag.RIVER_HYDROLOGY, flag.value()); - assertEquals(17, ReservedFlag.FLOATING_OBJECT.ordinal()); - assertEquals(18, ReservedFlag.RIVER_HYDROLOGY.ordinal()); - } - - @Test - public void enablementSeparatesSealedBoresFromOptionalCaveConnections() { - IrisDimension dimension = new IrisDimension(); - - assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); - dimension.getRivers().setEnabled(true); - assertTrue(MantleRiverHydrologyComponent.isEnabledFor(dimension)); - assertFalse(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); - dimension.getRivers().getCaves().setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT); - assertTrue(MantleRiverHydrologyComponent.isEnabledFor(dimension)); - assertTrue(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); - dimension.getRivers().getCaves().setMaximumPerReach(0); - assertTrue(MantleRiverHydrologyComponent.isEnabledFor(dimension)); - assertFalse(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); - dimension.getRivers().getCaves().setMode(IrisRiverCaveMode.SEALED); - dimension.getRivers().getCaves().getDeepPools().setEnabled(true); - assertTrue(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); - dimension.getRivers().getCaves().getDeepPools().setMaximumPerReach(0); - assertFalse(MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension)); - dimension.getRivers().getCaves().getDeepPools().setEnabled(false).setMaximumPerReach(1); - dimension.getRivers().getCaves().setMaximumPerReach(1); - dimension.setCarvingEnabled(false); - assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); - dimension.setCarvingEnabled(true); - dimension.setUseMantle(false); - assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); - dimension.setUseMantle(true); - dimension.getDisabledComponents().add(ReservedFlag.CARVED); - assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); - dimension.getDisabledComponents().clear(); - dimension.getDisabledComponents().add(ReservedFlag.RIVER_HYDROLOGY); - assertFalse(MantleRiverHydrologyComponent.isEnabledFor(dimension)); - } - - @Test - public void adaptiveInputRadiusRetainsOnlyRequiredHydrologyHalos() { - bindMockPlatform(); - try { - IrisDimension dimension = new IrisDimension(); - dimension.setCarvingEnabled(true); - dimension.getRivers().setEnabled(true); - IrisRiverCaves caves = dimension.getRivers().getCaves(); - caves.setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT).setMaximumPerReach(1); - IrisRiverDeepPools deepPools = caves.getDeepPools(); - deepPools.setEnabled(true).setMaximumPerReach(1); - - Engine engine = mock(Engine.class); - EngineMantle engineMantle = mock(EngineMantle.class); - IrisComplex complex = mock(IrisComplex.class); - ChunkContext context = mock(ChunkContext.class); - IrisRiverRuntime runtime = mock(IrisRiverRuntime.class); - RiverAnchor anchor = mock(RiverAnchor.class); - when(engineMantle.getEngine()).thenReturn(engine); - when(engine.getDimension()).thenReturn(dimension); - when(engine.getComplex()).thenReturn(complex); - when(context.getComplex()).thenReturn(complex); - when(complex.getRiverRuntime()).thenReturn(runtime); - when(runtime.caveSettings()).thenReturn(caves); - when(runtime.maximumChannelWidth()).thenReturn(12D); - when(runtime.maximumTunnelWidthMultiplier()).thenReturn(1D); - when(runtime.tunnelMouthBlend()).thenReturn(0D); - when(runtime.candidateAnchors( - anyInt(), - anyInt(), - anyInt(), - anyInt(), - anyDouble(), - anyLong() - )).thenReturn(List.of(anchor)); - - MantleRiverHydrologyComponent component = new MantleRiverHydrologyComponent(engineMantle); - assertEquals(0, component.getInputRadius(0, 0, 0, context)); - - when(runtime.hasRiverFootprint(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(true); - assertEquals( - RiverTopologyComplexity.tunnelHalo(12D, 1D, 0D), - component.getInputRadius(0, 0, 0, context) - ); - - when(runtime.hasRiverFootprint(anyInt(), anyInt(), anyInt(), anyInt())).thenReturn(false); - when(runtime.acceptsCaveAnchor(anchor)).thenReturn(true); - assertEquals( - MantleRiverHydrologyComponent.planningHalo(caves), - component.getInputRadius(0, 0, 0, context) - ); - - when(runtime.acceptsCaveAnchor(anchor)).thenReturn(false); - when(runtime.acceptsDeepPoolAnchor(anchor)).thenReturn(true); - assertEquals( - MantleRiverHydrologyComponent.deepPoolPlanningHalo(deepPools), - component.getInputRadius(0, 0, 0, context) - ); - } finally { - IrisPlatforms.unbind(); - } - } - - @Test - public void buriedChannelPlansWetCoreDryRoofAndSolidGuardShell() { - TestVoxelView view = new TestVoxelView(); - IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( - riverSample(RiverRouteState.WET, RiverSection.CHANNEL), - 10, - 12, - 14 - ); - - MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( - view, - 0, - 0, - 1, - 2, - (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( - RiverRouteState.WET, - RiverSection.CHANNEL - ), - (x, z) -> x == 0 && z == 0 ? tunnel : null, - (x, z) -> IrisRiverSurfaceSample.none(70D, 63D) - ); - - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 11, 0))); - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 12, 0))); - assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(new CavePosition(0, 13, 0))); - assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(new CavePosition(0, 14, 0))); - assertEquals(RiverCaveAction.SEAL_GUARD, plan.actions().get(new CavePosition(1, 12, 0))); - assertTrue(MantleRiverHydrologyComponent.preconditionsHold(view, plan.preconditions())); - } - - @Test - public void buriedChannelCutsIntoCaveWhileGuardingItsWaterline() { - TestVoxelView view = new TestVoxelView(); - view.set(new CavePosition(1, 12, 0), CaveVoxel.CAVE_AIR); - view.set(new CavePosition(1, 14, 0), CaveVoxel.CAVE_AIR); - IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( - riverSample(RiverRouteState.WET, RiverSection.CHANNEL), - 10, - 12, - 14 - ); - - MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( - view, - 0, - 0, - 1, - 2, - (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( - RiverRouteState.WET, - RiverSection.CHANNEL - ), - (x, z) -> x == 0 && z == 0 ? tunnel : null, - (x, z) -> IrisRiverSurfaceSample.none(70D, 63D) - ); - - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 12, 0))); - assertEquals(RiverCaveAction.SEAL_GUARD, plan.actions().get(new CavePosition(1, 12, 0))); - assertFalse(plan.actions().containsKey(new CavePosition(1, 14, 0))); - assertEquals( - new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false), - plan.preconditions().get(new CavePosition(1, 14, 0)) - ); - assertTrue(MantleRiverHydrologyComponent.preconditionsHold(view, plan.preconditions())); - } - - @Test - public void buriedChannelRejectsSurfaceOpenCaveAir() { - TestVoxelView view = new TestVoxelView(); - CavePosition contact = new CavePosition(1, 12, 0); - view.set(contact, CaveVoxel.CAVE_AIR); - view.open(contact); - IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( - riverSample(RiverRouteState.WET, RiverSection.CHANNEL), - 10, - 12, - 14 - ); - - MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( - view, - 0, - 0, - 1, - 2, - (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( - RiverRouteState.WET, - RiverSection.CHANNEL - ), - (x, z) -> x == 0 && z == 0 ? tunnel : null, - (x, z) -> IrisRiverSurfaceSample.none(70D, 63D) - ); - - assertTrue(plan.actions().isEmpty()); - } - - @Test - public void buriedChannelStillRejectsAnUncontainedFluidCave() { - TestVoxelView view = new TestVoxelView(); - view.set(new CavePosition(1, 12, 0), CaveVoxel.COMPATIBLE_FLUID); - IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( - riverSample(RiverRouteState.WET, RiverSection.CHANNEL), - 10, - 12, - 14 - ); - - MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( - view, - 0, - 0, - 1, - 2, - (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( - RiverRouteState.WET, - RiverSection.CHANNEL - ), - (x, z) -> x == 0 && z == 0 ? tunnel : null, - (x, z) -> IrisRiverSurfaceSample.none(70D, 63D) - ); - - assertTrue(plan.actions().isEmpty()); - assertTrue(plan.preconditions().isEmpty()); - } - - @Test - public void buriedChannelMouthMayFlareIntoTheWetSurfaceBank() { - TestVoxelView view = new TestVoxelView(); - view.set(new CavePosition(1, 11, 0), CaveVoxel.COMPATIBLE_FLUID); - view.set(new CavePosition(1, 12, 0), CaveVoxel.COMPATIBLE_FLUID); - view.set(new CavePosition(1, 13, 0), CaveVoxel.CAVE_AIR); - view.set(new CavePosition(1, 14, 0), CaveVoxel.CAVE_AIR); - IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( - riverSample(RiverRouteState.WET, RiverSection.CHANNEL), - 10, - 12, - 14 - ); - IrisRiverSurfaceSample mouth = new IrisRiverSurfaceSample( - riverSample(RiverRouteState.WET, RiverSection.BANK), - 14D, - 10D, - 12D, - false, - true - ); - - MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( - view, - 0, - 0, - 1, - 2, - (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( - RiverRouteState.WET, - RiverSection.CHANNEL - ), - (x, z) -> x == 0 && z == 0 ? tunnel : null, - (x, z) -> x == 1 && z == 0 ? mouth : IrisRiverSurfaceSample.none(70D, 63D) - ); - - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 12, 0))); - assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(new CavePosition(0, 14, 0))); - assertFalse(plan.preconditions().containsKey(new CavePosition(1, 12, 0))); - } - - @Test - public void buriedChannelContinuesAcrossPreviouslyPublishedNeighborCells() { - TestVoxelView view = new TestVoxelView(); - view.publish(new CavePosition(-1, 11, 0), RiverCaveAction.WET_SOURCE); - view.publish(new CavePosition(-1, 12, 0), RiverCaveAction.WET_SOURCE); - view.publish(new CavePosition(-1, 13, 0), RiverCaveAction.DRY_AIR); - view.publish(new CavePosition(-1, 14, 0), RiverCaveAction.DRY_AIR); - IrisRiverTunnelSample tunnel = new IrisRiverTunnelSample( - riverSample(RiverRouteState.WET, RiverSection.CHANNEL), - 10, - 12, - 14 - ); - - MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( - view, - 0, - 0, - 1, - 2, - (minimumX, minimumZ, maximumX, maximumZ) -> riverSample( - RiverRouteState.WET, - RiverSection.CHANNEL - ), - (x, z) -> (x == -1 || x == 0) && z == 0 ? tunnel : null, - (x, z) -> IrisRiverSurfaceSample.none(70D, 63D) - ); - - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 12, 0))); - assertTrue(MantleRiverHydrologyComponent.preconditionsHold(view, plan.preconditions())); - } - - @Test - public void sinkholeTerminalForcesGeneratedGrottoEvenInClosedMode() { - assertEquals( - RiverCaveMode.CLOSED_COMPONENT, - MantleRiverHydrologyComponent.sourceMode( - IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT, - false - ) - ); - assertEquals( - RiverCaveMode.GENERATED_GROTTO, - MantleRiverHydrologyComponent.sourceMode( - IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT, - true - ) - ); - } - - @Test - public void wetClosedCavePlansSourcesWithoutChangingBaseline() { - TestVoxelView view = new TestVoxelView(); - CavePosition target = new CavePosition(8, 42, 8); - view.set(target, CaveVoxel.CAVE_AIR); - Map baseline = view.snapshot(); - IrisRiverCaves caves = caves(); - RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 91L, null); - RiverCaveSource source = new RiverCaveSource( - 1L, - new CavePosition(8, 48, 8), - target, - 46, - RiverCaveMode.CLOSED_COMPONENT - ); - - RiverCavePlan plan = planner.plan(view, source, settings); - - assertTrue(plan.accepted()); - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(target)); - assertEquals(baseline, view.snapshot()); - } - - @Test - public void onlyWetChannelBedsAreEligible() { - IrisRiverSurfaceSample wet = surface(RiverRouteState.WET, RiverSection.CHANNEL, true); - IrisRiverSurfaceSample dry = surface(RiverRouteState.DRY, RiverSection.DRY_CHANNEL, false); - IrisRiverSurfaceSample bank = surface(RiverRouteState.WET, RiverSection.BANK, true); - IrisRiverSurfaceSample absent = IrisRiverSurfaceSample.none(64D, 63D); - - assertTrue(MantleRiverHydrologyComponent.isWetChannelBed(wet)); - assertFalse(MantleRiverHydrologyComponent.isWetChannelBed(dry)); - assertFalse(MantleRiverHydrologyComponent.isWetChannelBed(bank)); - assertFalse(MantleRiverHydrologyComponent.isWetChannelBed(absent)); - } - - @Test - public void openLavaAndOversizeCandidatesRejectWhileSolidFallbackCanPlan() { - IrisRiverCaves caves = caves().setMaxFloodVolume(32); - RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 92L, null); - RiverCaveSource closed = new RiverCaveSource( - 2L, - new CavePosition(0, 50, 0), - new CavePosition(0, 44, 0), - 48, - RiverCaveMode.CLOSED_COMPONENT - ); - TestVoxelView open = new TestVoxelView(); - open.set(closed.target(), CaveVoxel.CAVE_AIR); - open.open(closed.target()); - TestVoxelView lava = new TestVoxelView(); - lava.set(closed.target(), CaveVoxel.LAVA); - TestVoxelView oversize = new TestVoxelView(); - for (int y = 43; y <= 44; y++) { - for (int x = -2; x <= 2; x++) { - for (int z = -2; z <= 2; z++) { - oversize.set(new CavePosition(x, y, z), CaveVoxel.CAVE_AIR); - } - } - } - - assertEquals(RiverCaveRejection.OPEN_SURFACE, planner.plan(open, closed, settings).rejection()); - assertEquals(RiverCaveRejection.LAVA_CONTACT, planner.plan(lava, closed, settings).rejection()); - assertEquals(RiverCaveRejection.VOLUME_LIMIT, planner.plan(oversize, closed, settings).rejection()); - - IrisRiverCaves fallbackCaves = caves() - .setMaxFloodVolume(4096) - .setFallback(IrisRiverCaveFallback.GENERATE_GROTTO); - RiverCavePlannerSettings fallbackSettings = MantleRiverHydrologyComponent.plannerSettings( - fallbackCaves, - 92L, - null - ); - RiverCaveSource fallback = new RiverCaveSource( - 2L, - closed.entry(), - new CavePosition(3, 46, 0), - closed.waterHeadY(), - RiverCaveMode.GENERATED_GROTTO - ); - assertEquals( - RiverCaveRejection.NONE, - planner.plan(new TestVoxelView(), fallback, fallbackSettings).rejection() - ); - } - - @Test - public void waterfallPoolPoursThroughAnOpenPitWithoutFloodingItsComponent() { - IrisRiverCaves caves = caves().setMode(IrisRiverCaveMode.WATERFALL_POOL); - RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 94L, null); - CavePosition entry = new CavePosition(0, 50, 0); - CavePosition target = new CavePosition(0, 44, 0); - CavePosition connectedPit = new CavePosition(1, 44, 0); - TestVoxelView view = new TestVoxelView(); - view.set(target, CaveVoxel.CAVE_AIR); - view.set(connectedPit, CaveVoxel.CAVE_AIR); - view.open(target); - view.open(connectedPit); - RiverCaveSource source = new RiverCaveSource( - 3L, - entry, - target, - 48, - RiverCaveMode.WATERFALL_POOL - ); - - RiverCavePlan plan = planner.plan(view, source, settings); - - assertTrue(plan.accepted()); - assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(entry)); - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(target)); - assertFalse(plan.actions().containsKey(connectedPit)); - } - - @Test - public void generatedTargetKeepsItsGrottoShellBelowTheRiverBed() { - IrisRiverCaves caves = caves() - .setDryHeadroom(4) - .setGrottoVerticalRadius(6) - .setMaxBoreDepth(64); - CavePosition entry = new CavePosition(0, 54, 0); - int waterHeadY = 48; - - CavePosition target = MantleRiverHydrologyComponent.findGeneratedTarget( - new TestVoxelView(), - caves, - entry, - waterHeadY, - 0, - 0 - ); - - assertEquals(new CavePosition(0, 46, 0), target); - assertTrue(target.y() + caves.getGrottoVerticalRadius() < entry.y()); - assertTrue(target.y() + caves.getGrottoVerticalRadius() >= waterHeadY + caves.getDryHeadroom()); - } - - @Test - public void fluidPolicyMappingPreservesReplaceSemantics() { - assertEquals( - RiverCaveFluidPolicy.REJECT_EXISTING, - MantleRiverHydrologyComponent.fluidPolicy(IrisRiverExistingFluidPolicy.REJECT) - ); - assertEquals( - RiverCaveFluidPolicy.ALLOW_COMPATIBLE, - MantleRiverHydrologyComponent.fluidPolicy(IrisRiverExistingFluidPolicy.ALLOW_SAME) - ); - assertEquals( - RiverCaveFluidPolicy.REPLACE_CONTAINED, - MantleRiverHydrologyComponent.fluidPolicy(IrisRiverExistingFluidPolicy.REPLACE) - ); - } - - @Test - public void authoredHeadThroatAndProofLimitsMapDirectly() { - IrisRiverCaves caves = caves() - .setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT) - .setWaterLevelOffset(-3) - .setThroatRadius(5) - .setDryHeadroom(6) - .setMaxFloodRadius(23) - .setMaxFloodDepth(17); - RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 93L, null); - - assertEquals(61, MantleRiverHydrologyComponent.waterHeadY( - surface(RiverRouteState.WET, RiverSection.CHANNEL, true), - caves - )); - assertEquals(5, settings.throatRadius()); - assertEquals(6, settings.dryHeadroom()); - assertEquals(23, settings.maxClosedComponentHorizontalRadius()); - assertEquals(17, settings.maxClosedComponentDepth()); - assertEquals(24, MantleRiverHydrologyComponent.closedComponentPublicationRadius(caves)); - assertEquals(5, MantleRiverHydrologyComponent.generatedGrottoPublicationRadius(caves)); - assertEquals(24, MantleRiverHydrologyComponent.cavePublicationRadius(caves)); - assertEquals(72, MantleRiverHydrologyComponent.candidateHalo(caves)); - assertEquals(96, MantleRiverHydrologyComponent.planningHalo(caves)); - } - - @Test - public void generatedGrottosDoNotInheritClosedComponentProofRadius() { - IrisRiverCaves caves = caves() - .setMode(IrisRiverCaveMode.GENERATE_GROTTO) - .setGrottoHorizontalRadius(12) - .setThroatRadius(2) - .setMaxFloodRadius(48); - - assertEquals(18, MantleRiverHydrologyComponent.generatedGrottoPublicationRadius(caves)); - assertEquals(49, MantleRiverHydrologyComponent.closedComponentPublicationRadius(caves)); - assertEquals(18, MantleRiverHydrologyComponent.cavePublicationRadius(caves)); - assertEquals(54, MantleRiverHydrologyComponent.candidateHalo(caves)); - assertEquals(72, MantleRiverHydrologyComponent.planningHalo(caves)); - - caves.setFallback(IrisRiverCaveFallback.GENERATE_GROTTO); - - assertEquals(21, MantleRiverHydrologyComponent.generatedGrottoPublicationRadius(caves)); - assertEquals(84, MantleRiverHydrologyComponent.planningHalo(caves)); - } - - @Test - public void inputRadiusUsesOnlyCapabilitiesActiveForTheConfiguredMode() { - IrisRiverCaves caves = caves() - .setMode(IrisRiverCaveMode.GENERATE_GROTTO) - .setGrottoHorizontalRadius(12) - .setThroatRadius(2) - .setMaxFloodRadius(48); - - assertEquals(72, MantleRiverHydrologyComponent.inputRadius(caves, 6)); - - caves.setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT); - assertEquals(196, MantleRiverHydrologyComponent.inputRadius(caves, 6)); - - caves.setMode(IrisRiverCaveMode.SEALED); - assertEquals(6, MantleRiverHydrologyComponent.inputRadius(caves, 6)); - - caves.getDeepPools() - .setEnabled(true) - .setSearchRadius(20) - .setHorizontalRadius(24); - assertEquals(180, MantleRiverHydrologyComponent.inputRadius(caves, 6)); - - caves.getDeepPools().setEnabled(false); - caves.setMode(IrisRiverCaveMode.GENERATE_GROTTO).setMaximumPerReach(0); - assertEquals(6, MantleRiverHydrologyComponent.inputRadius(caves, 6)); - } - - @Test - public void deepPoolSourceSearchUsesAbsoluteConfiguredHeightAndCaveFloor() { - IrisRiverDeepPools deepPools = new IrisRiverDeepPools() - .setMinimumFluidY(-180) - .setMaximumFluidY(-130) - .setSearchRadius(0) - .setSearchAttempts(1) - .setVerticalRadius(8) - .setDryHeadroom(4); - TestVoxelView view = new TestVoxelView(); - view.set(new CavePosition(12, 101, 20), CaveVoxel.CAVE_AIR); - RiverAnchor anchor = new RiverAnchor( - new RiverEdgeId(new RiverNodeId(1L, 1L), new RiverNodeId(2L, 2L)), - 0, - 91L, - 768D, - 73L, - 12D, - 20D, - 0.5D, - RiverRouteState.WET, - 1, - 1 - ); - - RiverCaveSource source = MantleRiverHydrologyComponent.deepPoolSourceFor( - view, - deepPools, - anchor, - -256, - 42L - ); - - assertNotNull(source); - assertEquals(new CavePosition(12, 100, 20), source.entry()); - assertEquals(new CavePosition(12, 96, 20), source.target()); - assertEquals(100, source.waterHeadY()); - assertEquals(-156, source.waterHeadY() - 256); - assertEquals(RiverCaveMode.DEEP_POOL, source.mode()); - } - - @Test - public void deepPoolProofRadiusContainsNoisyDiagonalLobesAndShell() { - IrisRiverDeepPools deepPools = new IrisRiverDeepPools() - .setHorizontalRadius(24) - .setVerticalRadius(10) - .setDryHeadroom(5); - - RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.deepPoolPlannerSettings( - deepPools, - 42L, - null - ); - - assertEquals(35, settings.maxHorizontalRadius()); - assertEquals(16, settings.maxDepth()); - assertEquals(35, settings.maxClosedComponentHorizontalRadius()); - } - - @Test - public void managedScaleWarpedDeepPoolPassesAsOneContainedBlob() { - IrisRiverDeepPools deepPools = new IrisRiverDeepPools() - .setHorizontalRadius(24) - .setVerticalRadius(10) - .setDryHeadroom(5) - .setShapeVariation(0.6D) - .setWarpStrength(8D) - .setMaximumVolume(65536); - TestVoxelView view = new TestVoxelView(); - view.set(new CavePosition(0, 101, 0), CaveVoxel.CAVE_AIR); - RiverCaveSource source = new RiverCaveSource( - 92L, - new CavePosition(0, 100, 0), - new CavePosition(0, 95, 0), - 100, - RiverCaveMode.DEEP_POOL - ); - - RiverCavePlan plan = planner.plan( - view, - source, - MantleRiverHydrologyComponent.deepPoolPlannerSettings(deepPools, 42L, null) - ); - - assertTrue(plan.rejection().toString(), plan.accepted()); - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(new CavePosition(0, 100, 0))); - assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(new CavePosition(0, 101, 0))); - assertTrue(plan.actions().size() > 10000); - } - - @Test - public void absentRiverFootprintSkipsEveryTunnelColumnSample() { - AtomicInteger footprintSamples = new AtomicInteger(); - AtomicInteger tunnelSamples = new AtomicInteger(); - AtomicInteger surfaceSamples = new AtomicInteger(); - - MantleRiverHydrologyComponent.TunnelPlan plan = MantleRiverHydrologyComponent.planTunnels( - new TestVoxelView(), - 3, - -4, - 6, - 2, - (minimumX, minimumZ, maximumX, maximumZ) -> { - footprintSamples.incrementAndGet(); - assertEquals(42D, minimumX, 0D); - assertEquals(-70D, minimumZ, 0D); - assertEquals(70D, maximumX, 0D); - assertEquals(-42D, maximumZ, 0D); - return RiverSample.none(); - }, - (x, z) -> { - tunnelSamples.incrementAndGet(); - return null; - }, - (x, z) -> { - surfaceSamples.incrementAndGet(); - return IrisRiverSurfaceSample.none(70D, 63D); - } - ); - - assertTrue(plan.actions().isEmpty()); - assertEquals(1, footprintSamples.get()); - assertEquals(0, tunnelSamples.get()); - assertEquals(0, surfaceSamples.get()); - } - - @Test - public void configuredShapeAndWarpChangeDeterministicGrottoFootprints() { - IrisRiverCaves flatCaves = caves() - .setGrottoHorizontalRadius(6) - .setGrottoVerticalRadius(5) - .setDryHeadroom(1) - .setMaxFloodVolume(10000) - .setGrottoShapeStyle(new IrisGeneratorStyle(NoiseStyle.FLAT)) - .setGrottoWarpStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX)) - .setGrottoWarpStrength(0D); - IrisRiverCaves shapedCaves = caves() - .setGrottoHorizontalRadius(6) - .setGrottoVerticalRadius(5) - .setDryHeadroom(1) - .setMaxFloodVolume(10000) - .setGrottoShapeStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(9D)) - .setGrottoWarpStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX)) - .setGrottoWarpStrength(0D); - IrisRiverCaves warpedCaves = caves() - .setGrottoHorizontalRadius(6) - .setGrottoVerticalRadius(5) - .setDryHeadroom(1) - .setMaxFloodVolume(10000) - .setGrottoShapeStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(9D)) - .setGrottoWarpStyle(new IrisGeneratorStyle(NoiseStyle.SIMPLEX).zoomed(7D)) - .setGrottoWarpStrength(3D); - RiverCaveSource source = new RiverCaveSource( - 8L, - new CavePosition(37, 58, -21), - new CavePosition(37, 52, -21), - 56, - RiverCaveMode.GENERATED_GROTTO - ); - TestVoxelView view = new TestVoxelView(); - - RiverCavePlan flat = planner.plan( - view, - source, - MantleRiverHydrologyComponent.plannerSettings(flatCaves, 555L, null) - ); - RiverCavePlannerSettings shapedSettings = MantleRiverHydrologyComponent.plannerSettings( - shapedCaves, - 555L, - null - ); - RiverCavePlan shaped = planner.plan(view, source, shapedSettings); - RiverCavePlan shapedRepeat = planner.plan(view, source, shapedSettings); - RiverCavePlan warped = planner.plan( - view, - source, - MantleRiverHydrologyComponent.plannerSettings(warpedCaves, 555L, null) - ); - - assertTrue(flat.accepted()); - assertTrue(shaped.accepted()); - assertTrue(warped.accepted()); - assertEquals(shaped, shapedRepeat); - assertNotEquals(mutations(flat), mutations(shaped)); - assertNotEquals(mutations(shaped), mutations(warped)); - } - - @Test - public void chunkOwnershipIsOrderInvariantAndNonOverlapping() { - Set actions = Set.of( - new CavePosition(15, 40, 0), - new CavePosition(16, 40, 0), - new CavePosition(31, 40, 0), - new CavePosition(32, 40, 0) - ); - Set forward = partition(actions, new int[]{0, 1, 2}); - Set reverse = partition(actions, new int[]{2, 1, 0}); - - assertEquals(actions, forward); - assertEquals(forward, reverse); - } - - @Test - public void perChunkCandidateWindowsMatchGlobalDirectOverlapArbitration() { - IrisRiverCaves caves = caves() - .setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT) - .setMaxFloodRadius(16) - .setMaxFloodVolume(1024); - RiverCavePlannerSettings settings = MantleRiverHydrologyComponent.plannerSettings(caves, 777L, null); - TestVoxelView view = new TestVoxelView(); - view.set(new CavePosition(14, 45, 0), CaveVoxel.CAVE_AIR); - view.set(new CavePosition(16, 45, 0), CaveVoxel.CAVE_AIR); - view.set(new CavePosition(18, 45, 0), CaveVoxel.CAVE_AIR); - view.set(new CavePosition(200, 45, 0), CaveVoxel.CAVE_AIR); - List sources = List.of( - closedSource(30L, 14, 50), - closedSource(20L, 16, 51), - closedSource(10L, 18, 52), - closedSource(1L, 200, 60) - ); - Map global = planner.planAll(view, sources, settings).actions(); - - for (int chunkX = 0; chunkX <= 1; chunkX++) { - List localSources = candidateWindow(sources, caves, chunkX); - Map local = planner.planAll(view, localSources, settings).actions(); - assertEquals(owned(global, chunkX), owned(local, chunkX)); - } - assertFalse(global.containsKey(new CavePosition(14, 45, 0))); - assertTrue(global.containsKey(new CavePosition(18, 45, 0))); - } - - @Test - public void transactionRejectsAnyChangedActionOrBoundaryGuard() { - TestVoxelView view = new TestVoxelView(); - CavePosition wet = new CavePosition(0, 20, 0); - CavePosition guard = new CavePosition(1, 20, 0); - Map preconditions = Map.of( - wet, new CaveVoxelPrecondition(CaveVoxel.CAVE_AIR, false), - guard, new CaveVoxelPrecondition(CaveVoxel.SOLID, false) - ); - view.set(wet, CaveVoxel.CAVE_AIR); - - assertTrue(MantleRiverHydrologyComponent.preconditionsHold(view, preconditions)); - view.set(guard, CaveVoxel.CAVE_AIR); - assertFalse(MantleRiverHydrologyComponent.preconditionsHold(view, preconditions)); - } - - private IrisRiverCaves caves() { - return new IrisRiverCaves() - .setThroatRadius(1) - .setDryHeadroom(2) - .setGrottoHorizontalRadius(3) - .setGrottoVerticalRadius(4) - .setMaxBoreDepth(24) - .setMaxFloodRadius(16) - .setMaxFloodDepth(16) - .setMaxFloodVolume(512) - .setGrottoShapeStyle(new IrisGeneratorStyle(NoiseStyle.FLAT)) - .setGrottoWarpStyle(new IrisGeneratorStyle(NoiseStyle.FLAT)) - .setGrottoWarpStrength(0D); - } - - private IrisRiverSurfaceSample surface(RiverRouteState state, RiverSection section, boolean fluid) { - return new IrisRiverSurfaceSample(riverSample(state, section), 68D, 61D, 64D, false, fluid); - } - - private RiverSample riverSample(RiverRouteState state, RiverSection section) { - RiverEdgeId edge = new RiverEdgeId(new RiverNodeId(1L, 1L), new RiverNodeId(2L, 2L)); - return new RiverSample( - true, - state, - section, - 0D, - 0.5D, - 1D, - 2, - 1, - 8D, - 4D, - 3D, - false, - edge - ); - } - - private Set mutations(RiverCavePlan plan) { - Set positions = new HashSet<>(); - for (Map.Entry entry : plan.actions().entrySet()) { - if (entry.getValue() != RiverCaveAction.SEAL_GUARD) { - positions.add(entry.getKey()); - } - } - return positions; - } - - private Set partition(Set actions, int[] order) { - Set published = new HashSet<>(); - for (int chunkX : order) { - for (CavePosition position : actions) { - if (MantleRiverHydrologyComponent.owns(chunkX, 0, position)) { - assertTrue(published.add(position)); - } - } - } - return published; - } - - private RiverCaveSource closedSource(long id, int x, int head) { - return new RiverCaveSource( - id, - new CavePosition(x, 60, 0), - new CavePosition(x, 45, 0), - head, - RiverCaveMode.CLOSED_COMPONENT - ); - } - - private List candidateWindow( - List sources, - IrisRiverCaves caves, - int chunkX - ) { - int halo = MantleRiverHydrologyComponent.candidateHalo(caves); - int minimum = (chunkX << 4) - halo; - int maximum = ((chunkX + 1) << 4) + halo; - List local = new ArrayList<>(); - for (RiverCaveSource source : sources) { - if (source.entry().x() >= minimum && source.entry().x() < maximum) { - local.add(source); - } - } - return local; - } - - private Map owned( - Map actions, - int chunkX - ) { - Map owned = new HashMap<>(); - for (Map.Entry entry : actions.entrySet()) { - if (MantleRiverHydrologyComponent.owns(chunkX, 0, entry.getKey())) { - owned.put(entry.getKey(), entry.getValue()); - } - } - return Map.copyOf(owned); - } - - private static void bindMockPlatform() { - IrisPlatforms.unbind(); - PlatformBlockState block = mock(PlatformBlockState.class); - PlatformRegistries registries = mock(PlatformRegistries.class); - when(registries.block(anyString())).thenReturn(block); - IrisPlatform platform = mock(IrisPlatform.class); - when(platform.registries()).thenReturn(registries); - IrisPlatforms.bind(platform); - } - - private static final class TestVoxelView implements MantleRiverHydrologyComponent.TunnelVoxelView { - private final Map voxels = new HashMap<>(); - private final Map riverActions = new HashMap<>(); - private final Set open = new HashSet<>(); - - @Override - public boolean isInWorld(CavePosition position) { - return position.y() > 0 && position.y() < 128; - } - - @Override - public CaveVoxel voxelAt(CavePosition position) { - return voxels.getOrDefault(position, CaveVoxel.SOLID); - } - - @Override - public boolean isOpenToSurface(CavePosition position) { - return open.contains(position); - } - - @Override - public RiverCaveHydrology riverHydrologyAt(CavePosition position) { - return riverActions.get(position); - } - - private void set(CavePosition position, CaveVoxel voxel) { - voxels.put(position, voxel); - } - - private void open(CavePosition position) { - open.add(position); - } - - private void publish(CavePosition position, RiverCaveAction action) { - riverActions.put(position, RiverCaveHydrology.of(action, RiverCaveFluidKind.RIVER)); - voxels.put( - position, - action == RiverCaveAction.WET_SOURCE - ? CaveVoxel.COMPATIBLE_FLUID - : CaveVoxel.CAVE_AIR - ); - } - - private Map snapshot() { - return Map.copyOf(voxels); - } - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransactionTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransactionTest.java new file mode 100644 index 000000000..ab13868ba --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectDestinationTransactionTest.java @@ -0,0 +1,244 @@ +package art.arcane.iris.engine.mantle.components; + +import art.arcane.iris.core.link.Identifier; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.mantle.MantleWriter; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.mantle.runtime.Mantle; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.Test; +import org.mockito.InOrder; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class ObjectDestinationTransactionTest { + @Test + public void overlayReadsEarlierWritesAndCommitsOnlyDestinationChunk() { + MantleWriter writer = writer(); + Marker prerequisite = new Marker("prerequisite"); + Marker outside = new Marker("outside"); + Marker first = new Marker("first"); + Marker second = new Marker("second"); + when(writer.getPrerequisiteDataIfPresent(0, 4, 0, Marker.class)).thenReturn(prerequisite); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + + assertSame(prerequisite, transaction.getDataIfPresent(0, 4, 0, Marker.class)); + transaction.setData(-1, 4, 0, outside); + transaction.setData(0, 4, 0, first); + transaction.setData(0, 4, 0, second); + assertSame(outside, transaction.getDataIfPresent(-1, 4, 0, Marker.class)); + assertSame(second, transaction.getDataIfPresent(0, 4, 0, Marker.class)); + + transaction.commit(); + + verify(writer, never()).setData(-1, 4, 0, outside); + InOrder order = inOrder(writer); + order.verify(writer).setData(0, 4, 0, first); + order.verify(writer).setData(0, 4, 0, second); + } + + @Test + public void sourcePlanCapturesOnlyTheSourceMutationTail() { + MantleWriter writer = writer(); + Marker predecessor = new Marker("predecessor"); + Marker source = new Marker("source"); + ObjectDestinationTransaction scratch = new ObjectDestinationTransaction(writer, 0, 0); + scratch.setData(0, 4, 0, predecessor); + int checkpoint = scratch.mutationCheckpoint(); + scratch.setData(1, 4, 0, source); + ObjectSourcePlan plan = scratch.sourcePlanSince(checkpoint); + ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, 0, 0); + + destination.apply(plan); + + assertNull(destination.getDataIfPresent(0, 4, 0, Marker.class)); + assertSame(source, destination.getDataIfPresent(1, 4, 0, Marker.class)); + destination.commit(); + verify(writer, never()).setData(0, 4, 0, predecessor); + verify(writer).setData(1, 4, 0, source); + } + + @Test + public void sourcePlanRejectsAnInvalidCheckpoint() { + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer(), 0, 0); + + assertThrows(IllegalArgumentException.class, () -> transaction.sourcePlanSince(1)); + } + + @Test + public void carvedColumnComposesPrerequisiteHydrologyAndEarlierOriginWrites() { + MantleWriter writer = writer(); + byte[] prerequisite = new byte[8]; + prerequisite[4] = 1; + when(writer.getPrerequisiteCarvedColumn(0, 0, 8)).thenReturn(prerequisite); + when(writer.isPrerequisiteCarved(0, 4, 0)).thenReturn(true); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + HydrologyCaveCell sealed = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD); + MatterCavern cavern = new MatterCavern(true, "", (byte) 0); + + transaction.setData(0, 4, 0, sealed); + transaction.setData(0, 5, 0, cavern); + + byte[] expected = new byte[8]; + expected[5] = 1; + assertArrayEquals(expected, transaction.getCarvedColumn(0, 0, 8)); + assertFalse(transaction.isCarved(0, 4, 0)); + assertTrue(transaction.isCarved(0, 5, 0)); + assertArrayEquals(new byte[]{0, 0}, transaction.getCarvedColumn(0, 0, 2)); + } + + @Test + public void failedCommitRestoresEveryTouchedDestinationCell() { + MantleWriter writer = writer(); + Marker firstOriginal = new Marker("first-original"); + Marker secondOriginal = new Marker("second-original"); + Marker first = new Marker("first"); + Marker second = new Marker("second"); + when(writer.getPrerequisiteDataIfPresent(0, 4, 0, Marker.class)).thenReturn(firstOriginal); + when(writer.getPrerequisiteDataIfPresent(1, 4, 0, Marker.class)).thenReturn(secondOriginal); + doThrow(new IllegalStateException("publication failed")) + .when(writer).setData(1, 4, 0, second); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + transaction.setData(0, 4, 0, first); + transaction.setData(1, 4, 0, second); + + assertThrows(IllegalStateException.class, transaction::commit); + + verify(writer).clearData(0, 4, 0, Marker.class); + verify(writer).clearData(1, 4, 0, Marker.class); + verify(writer).setData(0, 4, 0, firstOriginal); + verify(writer).setData(1, 4, 0, secondOriginal); + } + + @Test + public void rejectedWriterMutationsNeverEnterTheOverlay() { + MantleWriter writer = writer(); + PlatformBlockState prerequisite = mock(PlatformBlockState.class); + PlatformBlockState rejected = mock(PlatformBlockState.class); + MatterCavern cavern = new MatterCavern(true, "", (byte) 0); + when(writer.getPrerequisiteBlock(0, 0, 0)).thenReturn(prerequisite); + when(writer.getEngine().getDimension().isBedrock()).thenReturn(true); + when(writer.getPrerequisiteDataIfPresent(1, 4, 0, HydrologyCaveCell.class)) + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + + transaction.setData(0, 0, 0, rejected); + transaction.setData(1, 4, 0, cavern); + + assertSame(prerequisite, transaction.get(0, 0, 0)); + assertFalse(transaction.isCarved(1, 4, 0)); + transaction.commit(); + verify(writer, never()).setData(0, 0, 0, rejected); + verify(writer, never()).setData(1, 4, 0, cavern); + } + + @Test + public void acceptedCustomPlacementPublishesBlockAndIdentifierAsOneMutation() { + MantleWriter writer = writer(); + PlatformBlockState custom = mock(PlatformBlockState.class); + PlatformBlockState base = mock(PlatformBlockState.class); + Identifier identifier = Identifier.fromString("iris:custom_block"); + when(custom.isCustom()).thenReturn(true); + when(custom.deferredPlacementKey()).thenReturn(identifier.toString()); + when(custom.placementBaseState()).thenReturn(base); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + + transaction.set(1, 4, 2, custom); + + assertSame(base, transaction.get(1, 4, 2)); + assertEquals(identifier, transaction.getDataIfPresent(1, 4, 2, Identifier.class)); + transaction.commit(); + verify(writer).set(1, 4, 2, custom); + verify(writer, never()).setData(1, 4, 2, base); + verify(writer, never()).setData(1, 4, 2, identifier); + } + + @Test + public void rejectedCustomPlacementStagesNeitherBlockNorIdentifier() { + MantleWriter writer = writer(); + PlatformBlockState custom = mock(PlatformBlockState.class); + PlatformBlockState base = mock(PlatformBlockState.class); + when(custom.isCustom()).thenReturn(true); + when(custom.deferredPlacementKey()).thenReturn("iris:custom_block"); + when(custom.placementBaseState()).thenReturn(base); + when(writer.getEngine().getDimension().isBedrock()).thenReturn(true); + when(writer.getPrerequisiteDataIfPresent(1, 4, 0, HydrologyCaveCell.class)) + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + + transaction.set(0, 0, 0, custom); + transaction.set(1, 4, 0, custom); + transaction.set(2, 64, 0, custom); + + assertNull(transaction.getDataIfPresent(0, 0, 0, Identifier.class)); + assertNull(transaction.getDataIfPresent(1, 4, 0, Identifier.class)); + assertNull(transaction.getDataIfPresent(2, 64, 0, Identifier.class)); + transaction.commit(); + verify(writer, never()).set(anyInt(), anyInt(), anyInt(), any(PlatformBlockState.class)); + verify(writer, never()).setData(anyInt(), anyInt(), anyInt(), any(Identifier.class)); + } + + @Test + public void blockReplacementClearsOverlayIdentifierAndRestoresItAfterFailure() { + MantleWriter writer = writer(); + PlatformBlockState block = mock(PlatformBlockState.class); + Marker failure = new Marker("failure"); + Identifier originalIdentifier = Identifier.fromString("iris:deferred"); + when(writer.getPrerequisiteDataIfPresent(0, 4, 0, Identifier.class)).thenReturn(originalIdentifier); + doThrow(new IllegalStateException("publication failed")) + .when(writer).setData(1, 4, 0, failure); + ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, 0, 0); + + transaction.setData(0, 4, 0, block); + assertNull(transaction.getDataIfPresent(0, 4, 0, Identifier.class)); + transaction.setData(1, 4, 0, failure); + + assertThrows(IllegalStateException.class, transaction::commit); + verify(writer).clearData(0, 4, 0, Identifier.class); + verify(writer).setData(0, 4, 0, originalIdentifier); + } + + @SuppressWarnings("unchecked") + private static MantleWriter writer() { + MantleWriter writer = mock(MantleWriter.class); + Mantle mantle = mock(Mantle.class); + Engine engine = mock(Engine.class); + IrisDimension dimension = mock(IrisDimension.class); + when(mantle.getWorldHeight()).thenReturn(64); + when(writer.getMantle()).thenReturn(mantle); + when(writer.getEngine()).thenReturn(engine); + when(engine.getDimension()).thenReturn(dimension); + when(writer.getPrerequisiteCarvedColumn(anyInt(), anyInt(), anyInt())) + .thenAnswer(invocation -> new byte[invocation.getArgument(2)]); + when(writer.getPrerequisiteDataIfPresent(anyInt(), anyInt(), anyInt(), any())) + .thenReturn(null); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(2); + task.run(); + return null; + }).when(writer).withChunkFence(anyInt(), anyInt(), any(Runnable.class)); + return writer; + } + + private record Marker(String value) { + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlanCacheTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlanCacheTest.java new file mode 100644 index 000000000..aaa8c9899 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/ObjectSourcePlanCacheTest.java @@ -0,0 +1,84 @@ +package art.arcane.iris.engine.mantle.components; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class ObjectSourcePlanCacheTest { + @Test + public void concurrentRequestsBuildOneSourcePlan() throws Exception { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(64L); + ObjectSourcePlan expected = new ObjectSourcePlan(List.of()); + AtomicInteger builds = new AtomicInteger(); + CountDownLatch ready = new CountDownLatch(8); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(8); + List> futures = new ArrayList<>(); + try { + for (int index = 0; index < 8; index++) { + futures.add(executor.submit(() -> { + ready.countDown(); + assertTrue(start.await(5L, TimeUnit.SECONDS)); + return cache.get(4, -7, () -> { + builds.incrementAndGet(); + return expected; + }); + })); + } + assertTrue(ready.await(5L, TimeUnit.SECONDS)); + start.countDown(); + for (Future future : futures) { + assertSame(expected, future.get(5L, TimeUnit.SECONDS)); + } + } finally { + executor.shutdownNow(); + } + + assertEquals(1, builds.get()); + } + + @Test + public void cacheEvictsByMutationWeightAndCanBeCleared() { + ObjectSourcePlanCache cache = new ObjectSourcePlanCache(3L); + ObjectSourcePlan first = planAt(0); + ObjectSourcePlan second = planAt(1); + + cache.get(0, 0, () -> first); + cache.get(1, 0, () -> second); + + assertEquals(1L, cache.estimatedSize()); + cache.clear(); + assertEquals(0L, cache.estimatedSize()); + } + + @Test + public void cacheRejectsNonPositiveMutationCapacity() { + assertThrows(IllegalArgumentException.class, () -> new ObjectSourcePlanCache(0L)); + } + + private static ObjectSourcePlan planAt(int x) { + ObjectDestinationTransaction.DataKey key = new ObjectDestinationTransaction.DataKey( + x, + 4, + 0, + String.class + ); + ObjectDestinationTransaction.Mutation mutation = new ObjectDestinationTransaction.SetMutation( + key, + "marker" + ); + return new ObjectSourcePlan(List.of(mutation)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java index 9c58fa3a5..be7084159 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierBoundarySupportTest.java @@ -5,8 +5,8 @@ import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisDimensionCarvingResolver; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; @@ -110,40 +110,36 @@ public class IrisCarveModifierBoundarySupportTest { } @Test - public void riverGuardOnlyAcceptsStableSolidBoundaryLayers() { - RiverCaveHydrology guard = RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD); + public void hydrologyGuardOnlyAcceptsStableSolidBoundaryLayers() { + HydrologyCaveCell guard = HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD); PlatformBlockState stone = state("minecraft:stone", true); PlatformBlockState sand = state("minecraft:sand", true); PlatformBlockState water = state("minecraft:water", false, true); - assertTrue(IrisCarveModifier.canReplaceRiverGuard(guard, stone, false)); - assertTrue(IrisCarveModifier.canReplaceRiverGuard(guard, stone, true)); - assertTrue(IrisCarveModifier.canReplaceRiverGuard(guard, sand, false)); - assertFalse(IrisCarveModifier.canReplaceRiverGuard(guard, sand, true)); - assertFalse(IrisCarveModifier.canReplaceRiverGuard(guard, water, false)); + assertTrue(IrisCarveModifier.canReplaceHydrologyGuard(guard, stone, false)); + assertTrue(IrisCarveModifier.canReplaceHydrologyGuard(guard, stone, true)); + assertTrue(IrisCarveModifier.canReplaceHydrologyGuard(guard, sand, false)); + assertFalse(IrisCarveModifier.canReplaceHydrologyGuard(guard, sand, true)); + assertFalse(IrisCarveModifier.canReplaceHydrologyGuard(guard, water, false)); } @Test - public void riverBiomeInheritanceIsColumnCoherentAndHonorsLimits() { - long seed = 7845123L; - boolean cell = IrisCarveModifier.selectsParentRiverBiome(seed, 8, 12, 0.5D); - for (int x = 8; x < 12; x++) { - for (int z = 12; z < 16; z++) { - assertTrue(cell == IrisCarveModifier.selectsParentRiverBiome(seed, x, z, 0.5D)); - } - } - assertFalse(IrisCarveModifier.selectsParentRiverBiome(seed, 8, 12, 0D)); - assertTrue(IrisCarveModifier.selectsParentRiverBiome(seed, 8, 12, 1D)); + @SuppressWarnings("unchecked") + public void submergedCaveFloorUsesBuriedSubstrateInsteadOfVegetatedSurface() { + Hunk output = mock(Hunk.class); + PlatformBlockState grass = state("minecraft:grass_block", true); + PlatformBlockState dirt = state("minecraft:dirt", true); + PlatformBlockState moss = state("minecraft:moss_block", true); + HydrologyCaveCell wet = HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE); - boolean inherited = false; - boolean overridden = false; - for (int x = -128; x <= 128; x += 4) { - boolean selected = IrisCarveModifier.selectsParentRiverBiome(seed, x, 0, 0.5D); - inherited |= selected; - overridden |= !selected; - } - assertTrue(inherited); - assertTrue(overridden); + doReturn(dirt).when(output).getRaw(0, 4, 0); + + assertSame(dirt, IrisCarveModifier.resolveSubmergedCaveFloorLayer( + output, 0, 5, 0, grass, wet)); + assertSame(dirt, IrisCarveModifier.resolveSubmergedCaveFloorLayer( + output, 0, 5, 0, moss, wet)); + assertSame(grass, IrisCarveModifier.resolveSubmergedCaveFloorLayer( + output, 0, 5, 0, grass, HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR))); } private PlatformBlockState state(String key, boolean solid) { diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierHydrologyTest.java similarity index 72% rename from core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java rename to core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierHydrologyTest.java index a30b29b7b..dadc01802 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierRiverHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierHydrologyTest.java @@ -1,37 +1,49 @@ package art.arcane.iris.engine.modifier; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.volmlib.util.matter.MatterCavern; import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -public class IrisCarveModifierRiverHydrologyTest { +public class IrisCarveModifierHydrologyTest { + @Test + public void explicitHydrologyCanMaterializeIntoAnUninitializedCell() { + PlatformBlockState air = mock(PlatformBlockState.class); + when(air.isAir()).thenReturn(true); + + assertFalse(IrisCarveModifier.shouldSkipEmptyCarve(null, true)); + assertFalse(IrisCarveModifier.shouldSkipEmptyCarve(air, true)); + assertTrue(IrisCarveModifier.shouldSkipEmptyCarve(null, false)); + assertTrue(IrisCarveModifier.shouldSkipEmptyCarve(air, false)); + } + @Test public void overlayCompositionDoesNotMutateBaselineCavern() { MatterCavern baseline = new MatterCavern(true, "baseline", (byte) 2); assertSame(baseline, IrisCarveModifier.composeCavern(baseline, null)); assertNull(IrisCarveModifier.composeCavern( - baseline, RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD))); + baseline, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD))); MatterCavern wet = IrisCarveModifier.composeCavern( - baseline, new RiverCaveHydrology( - RiverCaveAction.WET_SOURCE, - "iris:flooded", - RiverCaveFluidKind.DEEP_POOL + baseline, new HydrologyCaveCell( + HydrologyCaveAction.WET_SOURCE, + "deep_lava", + "iris:flooded" )); MatterCavern dry = IrisCarveModifier.composeCavern( - baseline, RiverCaveHydrology.of(RiverCaveAction.DRY_AIR)); + baseline, HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)); assertEquals(1, wet.getLiquid()); assertEquals("iris:flooded", wet.getCustomBiome()); @@ -50,11 +62,11 @@ public class IrisCarveModifierRiverHydrologyTest { when(source.withProperty("level", "8")).thenReturn(falling); assertSame(source, IrisCarveModifier.resolveHydrologyState( - RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), current, source, air)); + HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), current, source, air)); assertSame(falling, IrisCarveModifier.resolveHydrologyState( - RiverCaveHydrology.of(RiverCaveAction.FALLING_FLUID), current, source, air)); + HydrologyCaveCell.of(HydrologyCaveAction.FALLING_FLUID), current, source, air)); assertSame(air, IrisCarveModifier.resolveHydrologyState( - RiverCaveHydrology.of(RiverCaveAction.DRY_AIR), current, source, air)); + HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR), current, source, air)); } @Test @@ -71,7 +83,7 @@ public class IrisCarveModifierRiverHydrologyTest { assertSame(dry, IrisCarveModifier.normalizeWaterlogging(waterlogged, null)); assertSame(dry, IrisCarveModifier.normalizeWaterlogging(waterlogged, lava)); assertSame(dry, IrisCarveModifier.resolveHydrologyState( - RiverCaveHydrology.of(RiverCaveAction.SEAL_GUARD), waterlogged, water, null)); + HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD), waterlogged, water, null)); } @Test @@ -102,13 +114,13 @@ public class IrisCarveModifierRiverHydrologyTest { assertSame(waterlogged, IrisCarveModifier.normalizeHydrologyWaterlogging( waterlogged, flooded, - RiverCaveHydrology.of(RiverCaveAction.WET_SOURCE), + HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), water )); assertSame(dry, IrisCarveModifier.normalizeHydrologyWaterlogging( waterlogged, flooded, - RiverCaveHydrology.of(RiverCaveAction.DRY_AIR), + HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR), water )); } diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java index 06ea0083c..1096c4fbb 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierInferenceIsolationTest.java @@ -70,7 +70,7 @@ public class IrisCarveModifierInferenceIsolationTest { Map customBiomes = new HashMap<>(); customBiomes.put("shared", biome); CarveWallBuffer walls = new CarveWallBuffer(1); - walls.put(0, 1, 0, new MatterCavern(true, "shared", (byte) 0), false); + walls.put(0, 1, 0, new MatterCavern(true, "shared", (byte) 0)); Method paintBoundaryZone = IrisCarveModifier.class.getDeclaredMethod( "paintBoundaryZone", Hunk.class, diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveScratchTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveScratchTest.java index 3e38380c7..d03e4cea1 100644 --- a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveScratchTest.java +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveScratchTest.java @@ -22,21 +22,19 @@ public class IrisCarveScratchTest { int y = 20 + index; int z = (index * 3) & 15; MatterCavern cavern = new MatterCavern(true, "cave-" + index, (byte) 0); - buffer.put(x, y, z, cavern, (index & 1) == 0); + buffer.put(x, y, z, cavern); expected.put(key(x, y, z), cavern); } MatterCavern replacement = new MatterCavern(true, "replacement", (byte) 0); - buffer.put(5, 25, 15, replacement, true); + buffer.put(5, 25, 15, replacement); expected.put(key(5, 25, 15), replacement); assertSame(replacement, buffer.get(5, 25, 15)); - assertTrue(buffer.isRiverBoundary(5, 25, 15)); assertNull(buffer.get(5, 26, 15)); - assertFalse(buffer.isRiverBoundary(5, 26, 15)); Map actual = new HashMap<>(); - buffer.forEach((x, y, z, cavern, riverBoundary) -> actual.put(key(x, y, z), cavern)); + buffer.forEach((x, y, z, cavern) -> actual.put(key(x, y, z), cavern)); assertEquals(expected.keySet(), actual.keySet()); for (Map.Entry entry : expected.entrySet()) { assertSame(entry.getValue(), actual.get(entry.getKey())); @@ -49,7 +47,7 @@ public class IrisCarveScratchTest { MatterCavern cavern = new MatterCavern(true, "cave", (byte) 0); scratch.columnMasks[0].add(12); scratch.boundaryMasks[0].add(13); - scratch.walls.put(1, 12, 2, cavern, true); + scratch.walls.put(1, 12, 2, cavern); scratch.customBiomeCache.put("cave", null); scratch.customCaveBiomePresent = true; @@ -60,7 +58,7 @@ public class IrisCarveScratchTest { assertTrue(scratch.customBiomeCache.isEmpty()); assertFalse(scratch.customCaveBiomePresent); int[] wallCount = new int[1]; - scratch.walls.forEach((x, y, z, value, riverBoundary) -> wallCount[0]++); + scratch.walls.forEach((x, y, z, value) -> wallCount[0]++); assertEquals(0, wallCount[0]); } diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisBiomePaletteLayerGeneratorCacheTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomePaletteLayerGeneratorCacheTest.java new file mode 100644 index 000000000..1904e2571 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisBiomePaletteLayerGeneratorCacheTest.java @@ -0,0 +1,92 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +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 static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public final class IrisBiomePaletteLayerGeneratorCacheTest { + private static final int SIGNATURE = 7; + private static final long LAYER_SIGNATURE = 2L; + + @Test + public void callerSeedsDoNotShareAFirstWinnerGenerator() { + GeneratorFixture fixture = new GeneratorFixture(); + + assertSame(fixture.carveGenerator, + fixture.layer.getLayerGenerator(new RNG(11L), SIGNATURE, fixture.data)); + assertSame(fixture.postGenerator, + fixture.layer.getLayerGenerator(new RNG(29L), SIGNATURE, fixture.data)); + assertSame(fixture.carveGenerator, + fixture.layer.getLayerGenerator(new RNG(11L), SIGNATURE, fixture.data)); + } + + @Test + public void reverseInitializationOrderKeepsSeedAssignments() { + GeneratorFixture fixture = new GeneratorFixture(); + + assertSame(fixture.postGenerator, + fixture.layer.getLayerGenerator(new RNG(29L), SIGNATURE, fixture.data)); + assertSame(fixture.carveGenerator, + fixture.layer.getLayerGenerator(new RNG(11L), SIGNATURE, fixture.data)); + } + + @Test + public void concurrentInitializationKeepsSeedAssignments() throws Exception { + GeneratorFixture fixture = new GeneratorFixture(); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future carve = executor.submit(() -> { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + return fixture.layer.getLayerGenerator(new RNG(11L), SIGNATURE, fixture.data); + }); + Future post = executor.submit(() -> { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + return fixture.layer.getLayerGenerator(new RNG(29L), SIGNATURE, fixture.data); + }); + start.countDown(); + + assertSame(fixture.carveGenerator, carve.get(5L, TimeUnit.SECONDS)); + assertSame(fixture.postGenerator, post.get(5L, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } + + private static final class GeneratorFixture { + private final IrisData data = mock(IrisData.class); + private final Engine engine = mock(Engine.class); + private final IrisGeneratorStyle style = mock(IrisGeneratorStyle.class); + private final CNG carveGenerator = mock(CNG.class); + private final CNG postGenerator = mock(CNG.class); + private final IrisBiomePaletteLayer layer = new IrisBiomePaletteLayer().zero().setStyle(style); + + private GeneratorFixture() { + when(data.getEngine()).thenReturn(engine); + Map generators = Map.of( + 11L + SIGNATURE + LAYER_SIGNATURE, carveGenerator, + 29L + SIGNATURE + LAYER_SIGNATURE, postGenerator + ); + when(style.create(any(RNG.class), same(data), same(engine))).thenAnswer(invocation -> { + RNG rng = invocation.getArgument(0); + return generators.get(rng.getSeed()); + }); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDepositTuningTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDepositTuningTest.java index 0ec8d93c1..7c48a8290 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDepositTuningTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDepositTuningTest.java @@ -69,6 +69,28 @@ public class IrisDepositTuningTest { assertEquals(firstWorld, new IrisDepositGenerator.ClumpCacheKey(41L, 4, 8)); } + @Test + public void clumpSaltIsStableAcrossEquivalentConfigInstances() { + IrisData data = mock(IrisData.class); + IrisDepositGenerator first = generatorWithState(data, false, "minecraft:granite"); + IrisDepositGenerator second = generatorWithState(data, false, "minecraft:granite"); + + assertEquals(first.stableClumpSalt(data), second.stableClumpSalt(data)); + } + + @Test + public void clumpSaltIncludesAuthoredConfigAndPalette() { + IrisData data = mock(IrisData.class); + IrisDepositGenerator granite = generatorWithState(data, false, "minecraft:granite"); + IrisDepositGenerator andesite = generatorWithState(data, false, "minecraft:andesite"); + IrisDepositGenerator alteredShape = generatorWithState(data, false, "minecraft:granite"); + alteredShape.setShape(IrisDepositShape.VANILLA_SCATTERED); + + assertEquals(3112546198474861350L, granite.stableClumpSalt(data)); + assertNotEquals(granite.stableClumpSalt(data), andesite.stableClumpSalt(data)); + assertNotEquals(granite.stableClumpSalt(data), alteredShape.stableClumpSalt(data)); + } + @Test public void onlyOreDepositPalettesReceiveBiomeTuning() { IrisData data = mock(IrisData.class); @@ -143,10 +165,15 @@ public class IrisDepositTuningTest { } private IrisDepositGenerator generatorWithState(IrisData data, boolean ore) { + return generatorWithState(data, ore, ore ? "minecraft:iron_ore" : "minecraft:stone"); + } + + private IrisDepositGenerator generatorWithState(IrisData data, boolean ore, String key) { IrisBlockData block = mock(IrisBlockData.class); PlatformBlockState state = mock(PlatformBlockState.class); when(block.getBlockData(data)).thenReturn(state); when(state.isOre()).thenReturn(ore); + when(state.key()).thenReturn(key); IrisDepositGenerator generator = new IrisDepositGenerator(); generator.getPalette().add(block); return generator; diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java index 29dea9d93..70cd34930 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java @@ -130,6 +130,120 @@ public class IrisDimensionReachableBiomesTest { verify(biomeLoader, times(1)).load("d"); } + @Test + @SuppressWarnings("unchecked") + public void includesEveryBiomeReferencedByEnabledPolicyHierarchy() { + IrisDimension dimension = new IrisDimension() + .setRegions(new KList<>("reachable")) + .setRiverPolicy(new IrisRiverPolicy() + .setSurfaceBiomes(new KList<>("dimension-channel"))); + dimension.getHydrology().getRivers().setEnabled(true); + IrisRegion reachable = new IrisRegion() + .setLandBiomes(new KList<>("root")) + .setRiverPolicy(new IrisRiverPolicy() + .setMouthBiomes(new KList<>("region-mouth"))); + IrisBiome root = biome("root").setRiverPolicy(new IrisRiverPolicy() + .setShoreBiomes(new KList<>("biome-shore"))); + IrisBiome dimensionChannel = biome("dimension-channel") + .setChildren(new KList<>("dimension-child")); + IrisBiome dimensionChild = biome("dimension-child"); + IrisBiome regionMouth = biome("region-mouth").setRiverPolicy(new IrisRiverPolicy() + .setDryBiomes(new KList<>("nested-dry"))); + IrisBiome biomeShore = biome("biome-shore"); + IrisBiome nestedDry = biome("nested-dry"); + + IrisData data = mock(IrisData.class); + ResourceLoader regionLoader = mock(ResourceLoader.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regionLoader); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(regionLoader.load("reachable")).thenReturn(reachable); + when(biomeLoader.load("root")).thenReturn(root); + when(biomeLoader.load("dimension-channel")).thenReturn(dimensionChannel); + when(biomeLoader.load("dimension-child")).thenReturn(dimensionChild); + when(biomeLoader.load("region-mouth")).thenReturn(regionMouth); + when(biomeLoader.load("biome-shore")).thenReturn(biomeShore); + when(biomeLoader.load("nested-dry")).thenReturn(nestedDry); + + Set keys = dimension.getReachableBiomes(() -> data).stream() + .map(IrisBiome::getLoadKey) + .collect(Collectors.toSet()); + + assertEquals(Set.of( + "root", + "dimension-channel", + "dimension-child", + "region-mouth", + "biome-shore", + "nested-dry" + ), keys); + } + + @Test + @SuppressWarnings("unchecked") + public void excludesPolicyOnlyBiomesWhenRiverHydrologyIsDisabled() { + IrisDimension dimension = new IrisDimension() + .setRegions(new KList<>("reachable")) + .setRiverPolicy(new IrisRiverPolicy() + .setSurfaceBiomes(new KList<>("dimension-channel"))); + IrisRegion reachable = new IrisRegion() + .setLandBiomes(new KList<>("root")) + .setRiverPolicy(new IrisRiverPolicy() + .setMouthBiomes(new KList<>("region-mouth"))); + IrisBiome root = biome("root").setRiverPolicy(new IrisRiverPolicy() + .setShoreBiomes(new KList<>("biome-shore"))); + + IrisData data = mock(IrisData.class); + ResourceLoader regionLoader = mock(ResourceLoader.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regionLoader); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(regionLoader.load("reachable")).thenReturn(reachable); + when(biomeLoader.load("root")).thenReturn(root); + + Set keys = dimension.getReachableBiomes(() -> data).stream() + .map(IrisBiome::getLoadKey) + .collect(Collectors.toSet()); + + assertEquals(Set.of("root"), keys); + } + + @Test + @SuppressWarnings("unchecked") + public void includesPolicyBiomesForActiveDeepOnlyHydrology() { + IrisDimension dimension = new IrisDimension() + .setRegions(new KList<>("reachable")) + .setRiverPolicy(new IrisRiverPolicy() + .setFloodedCaveBiomes(new KList<>("dimension-flooded"))); + dimension.getHydrology().getRivers().setEnabled(false); + dimension.getHydrology().getDeepFluids().add(new IrisDeepFluidConfig() + .setDensity(1D) + .setContainedPools(true)); + IrisRegion reachable = new IrisRegion() + .setLandBiomes(new KList<>("root")) + .setRiverPolicy(new IrisRiverPolicy() + .setDryBiomes(new KList<>("region-dry"))); + IrisBiome root = biome("root").setRiverPolicy(new IrisRiverPolicy() + .setFloodedCaveBiomes(new KList<>("biome-flooded"))); + + IrisData data = mock(IrisData.class); + ResourceLoader regionLoader = mock(ResourceLoader.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + when(data.getRegionLoader()).thenReturn(regionLoader); + when(data.getBiomeLoader()).thenReturn(biomeLoader); + when(regionLoader.load("reachable")).thenReturn(reachable); + when(biomeLoader.load("root")).thenReturn(root); + when(biomeLoader.load("dimension-flooded")).thenReturn(biome("dimension-flooded")); + when(biomeLoader.load("region-dry")).thenReturn(biome("region-dry")); + when(biomeLoader.load("biome-flooded")).thenReturn(biome("biome-flooded")); + + Set keys = dimension.getReachableBiomes(() -> data).stream() + .map(IrisBiome::getLoadKey) + .collect(Collectors.toSet()); + + assertEquals(Set.of("root", "dimension-flooded", "region-dry", "biome-flooded"), keys); + } + private IrisBiome biome(String loadKey) { IrisBiome biome = new IrisBiome(); biome.setLoadKey(loadKey); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisMaterialPaletteGeneratorCacheTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisMaterialPaletteGeneratorCacheTest.java new file mode 100644 index 000000000..a778b5541 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisMaterialPaletteGeneratorCacheTest.java @@ -0,0 +1,86 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +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 static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public final class IrisMaterialPaletteGeneratorCacheTest { + private static final long GENERATOR_SALT = -23_498_896L; + + @Test + public void callerSeedsDoNotShareAFirstWinnerGenerator() { + GeneratorFixture fixture = new GeneratorFixture(); + + assertSame(fixture.firstGenerator, fixture.palette.getLayerGenerator(new RNG(11L), fixture.data)); + assertSame(fixture.secondGenerator, fixture.palette.getLayerGenerator(new RNG(29L), fixture.data)); + assertSame(fixture.firstGenerator, fixture.palette.getLayerGenerator(new RNG(11L), fixture.data)); + } + + @Test + public void reverseInitializationOrderKeepsSeedAssignments() { + GeneratorFixture fixture = new GeneratorFixture(); + + assertSame(fixture.secondGenerator, fixture.palette.getLayerGenerator(new RNG(29L), fixture.data)); + assertSame(fixture.firstGenerator, fixture.palette.getLayerGenerator(new RNG(11L), fixture.data)); + } + + @Test + public void concurrentInitializationKeepsSeedAssignments() throws Exception { + GeneratorFixture fixture = new GeneratorFixture(); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + return fixture.palette.getLayerGenerator(new RNG(11L), fixture.data); + }); + Future second = executor.submit(() -> { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + return fixture.palette.getLayerGenerator(new RNG(29L), fixture.data); + }); + start.countDown(); + + assertSame(fixture.firstGenerator, first.get(5L, TimeUnit.SECONDS)); + assertSame(fixture.secondGenerator, second.get(5L, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } + + private static final class GeneratorFixture { + private final IrisData data = mock(IrisData.class); + private final Engine engine = mock(Engine.class); + private final IrisGeneratorStyle style = mock(IrisGeneratorStyle.class); + private final CNG firstGenerator = mock(CNG.class); + private final CNG secondGenerator = mock(CNG.class); + private final IrisMaterialPalette palette = new IrisMaterialPalette().qclear().setStyle(style); + + private GeneratorFixture() { + when(data.getEngine()).thenReturn(engine); + Map generators = Map.of( + 11L + GENERATOR_SALT, firstGenerator, + 29L + GENERATOR_SALT, secondGenerator + ); + when(style.create(any(RNG.class), same(data), same(engine))).thenAnswer(invocation -> { + RNG rng = invocation.getArgument(0); + return generators.get(rng.getSeed()); + }); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisMathNoiseHotPathParityTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisMathNoiseHotPathParityTest.java index f08eb5422..c1a6520a0 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisMathNoiseHotPathParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisMathNoiseHotPathParityTest.java @@ -43,7 +43,7 @@ public class IrisMathNoiseHotPathParityTest { .setOpacity(0.91D) .setComposite(new KList().qadd(noiseGenerator)); - assertEquals(0.451949817597527D, generator.getHeight(63.0D, -27.0D, 445566L), 0D); + assertEquals(0.5671980255964687D, generator.getHeight(63.0D, -27.0D, 445566L), 0D); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisNoiseGeneratorCacheTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisNoiseGeneratorCacheTest.java new file mode 100644 index 000000000..f0831d23b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisNoiseGeneratorCacheTest.java @@ -0,0 +1,93 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.project.noise.CNG; +import art.arcane.volmlib.util.math.RNG; +import org.junit.Test; + +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 static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public final class IrisNoiseGeneratorCacheTest { + private static final long GENERATOR_SALT = 33_955_677L; + private static final long CONFIGURED_SEED = 7L; + private static final int OCTAVES = 3; + + @Test + public void callerSeedsDoNotShareAFirstWinnerGenerator() { + GeneratorFixture fixture = new GeneratorFixture(); + + assertSame(fixture.firstGenerator, fixture.generator.getGenerator(11L, fixture.data)); + assertSame(fixture.secondGenerator, fixture.generator.getGenerator(29L, fixture.data)); + assertSame(fixture.firstGenerator, fixture.generator.getGenerator(11L, fixture.data)); + } + + @Test + public void reverseInitializationOrderKeepsSeedAssignments() { + GeneratorFixture fixture = new GeneratorFixture(); + + assertSame(fixture.secondGenerator, fixture.generator.getGenerator(29L, fixture.data)); + assertSame(fixture.firstGenerator, fixture.generator.getGenerator(11L, fixture.data)); + } + + @Test + public void concurrentInitializationKeepsSeedAssignments() throws Exception { + GeneratorFixture fixture = new GeneratorFixture(); + CountDownLatch start = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + return fixture.generator.getGenerator(11L, fixture.data); + }); + Future second = executor.submit(() -> { + assertTrue(start.await(5L, TimeUnit.SECONDS)); + return fixture.generator.getGenerator(29L, fixture.data); + }); + start.countDown(); + + assertSame(fixture.firstGenerator, first.get(5L, TimeUnit.SECONDS)); + assertSame(fixture.secondGenerator, second.get(5L, TimeUnit.SECONDS)); + } finally { + executor.shutdownNow(); + } + } + + private static final class GeneratorFixture { + private final IrisData data = mock(IrisData.class); + private final Engine engine = mock(Engine.class); + private final IrisGeneratorStyle style = mock(IrisGeneratorStyle.class); + private final CNG firstGenerator = mock(CNG.class); + private final CNG secondGenerator = mock(CNG.class); + private final IrisNoiseGenerator generator = new IrisNoiseGenerator() + .setStyle(style) + .setSeed(CONFIGURED_SEED) + .setOctaves(OCTAVES); + + private GeneratorFixture() { + when(data.getEngine()).thenReturn(engine); + when(firstGenerator.oct(OCTAVES)).thenReturn(firstGenerator); + when(secondGenerator.oct(OCTAVES)).thenReturn(secondGenerator); + Map generators = Map.of( + 11L + GENERATOR_SALT - CONFIGURED_SEED, firstGenerator, + 29L + GENERATOR_SALT - CONFIGURED_SEED, secondGenerator + ); + when(style.createNoCache(any(RNG.class), same(data))).thenAnswer(invocation -> { + RNG rng = invocation.getArgument(0); + return generators.get(rng.getSeed()); + }); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java index 6d061132f..4798a6ed8 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectPlacementRunnerRegressionTest.java @@ -25,6 +25,8 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class IrisObjectPlacementRunnerRegressionTest { @@ -94,13 +96,9 @@ public class IrisObjectPlacementRunnerRegressionTest { @Test public void collisionCheckUsesTheTransformedBounds() { PlacedObject forbidden = new PlacedObject(null, object("forbidden"), 1, 0, 0); - when(engine.getObjectPlacement(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { - int x = invocation.getArgument(0); - int y = invocation.getArgument(1); - int z = invocation.getArgument(2); - return x == 0 && y == ANCHOR_Y && z == -4 ? forbidden : null; - }); RecordingPlacer placer = new RecordingPlacer(engine); + placer.setData(0, ANCHOR_Y, -4, "forbidden@1"); + when(engine.resolveObjectPlacementMarker(0, -4, "forbidden@1")).thenReturn(forbidden); IrisObjectPlacement placement = placement(); placement.setRotation(IrisObjectRotation.of(0, 90, 0)); placement.setTranslate(new IrisObjectTranslate().setX(4)); @@ -110,6 +108,25 @@ public class IrisObjectPlacementRunnerRegressionTest { assertEquals(-1, result); assertTrue(placer.writes().isEmpty()); + verify(engine).resolveObjectPlacementMarker(0, -4, "forbidden@1"); + verify(engine, never()).getObjectPlacement(anyInt(), anyInt(), anyInt()); + } + + @Test + public void allowedCollisionOverridesTheForbiddenKey() { + PlacedObject allowed = new PlacedObject(null, object("allowed"), 1, 0, 0); + RecordingPlacer placer = new RecordingPlacer(engine); + placer.setData(0, ANCHOR_Y, 0, "allowed@1"); + when(engine.resolveObjectPlacementMarker(0, 0, "allowed@1")).thenReturn(allowed); + IrisObjectPlacement placement = placement(); + placement.getForbiddenCollisions().add("allowed"); + placement.getAllowedCollisions().add("allowed"); + + int result = lineObject(1).place(0, ANCHOR_Y, 0, placer, placement, new RNG(2L), data); + + assertEquals(ANCHOR_Y, result); + assertFalse(placer.writes().isEmpty()); + verify(engine).resolveObjectPlacementMarker(0, 0, "allowed@1"); } @Test @@ -216,6 +233,7 @@ public class IrisObjectPlacementRunnerRegressionTest { private static final class RecordingPlacer implements IObjectPlacer { private final List writes = new ArrayList<>(); private final Map world = new HashMap<>(); + private final Map data = new HashMap<>(); private final List sampledColumns = new ArrayList<>(); private final Engine engine; private int failAfterWrites = Integer.MAX_VALUE; @@ -308,11 +326,12 @@ public class IrisObjectPlacementRunnerRegressionTest { @Override public void setData(int x, int y, int z, T data) { + this.data.put(x + ":" + y + ":" + z + ":" + data.getClass().getName(), data); } @Override public T getData(int x, int y, int z, Class type) { - return null; + return type.cast(data.get(x + ":" + y + ":" + z + ":" + type.getName())); } @Override diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java index 08e8050c6..0a2bb3634 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisObjectSurfaceSupportPlacementTest.java @@ -34,6 +34,7 @@ public class IrisObjectSurfaceSupportPlacementTest { private IrisData data; private Engine engine; + private IrisComplex complex; @Before public void bindPlatform() { @@ -48,11 +49,12 @@ public class IrisObjectSurfaceSupportPlacementTest { @SuppressWarnings("unchecked") ProceduralStream heightStream = mock(ProceduralStream.class); when(heightStream.get(anyDouble(), anyDouble())).thenReturn((double) SURFACE_Y); - IrisComplex complex = mock(IrisComplex.class); + complex = mock(IrisComplex.class); when(complex.getHeightStream()).thenReturn(heightStream); engine = mock(Engine.class); when(engine.getHeight()).thenReturn(256); when(engine.getComplex()).thenReturn(complex); + when(engine.getDimension()).thenReturn(new IrisDimension()); data = mock(IrisData.class); when(data.getEngine()).thenReturn(engine); } @@ -102,6 +104,35 @@ public class IrisObjectSurfaceSupportPlacementTest { assertTrue(place(placer, placement(ObjectPlaceMode.CENTER_HEIGHT), -1) >= 0); } + @Test + public void automaticSurfaceObjectCannotOverlapRiverWater() { + when(complex.hasHydrologySurfaceFluid(0, 0)).thenReturn(true); + SurfacePlacer placer = new SurfacePlacer(engine); + + assertEquals(-1, place(placer, placement(ObjectPlaceMode.CENTER_HEIGHT), -1)); + assertTrue(placer.written().isEmpty()); + } + + @Test + public void automaticForcePlacementCannotOverlapRiverWater() { + when(complex.hasHydrologySurfaceFluid(0, 0)).thenReturn(true); + SurfacePlacer placer = new SurfacePlacer(engine); + IrisObjectPlacement placement = placement(ObjectPlaceMode.CENTER_HEIGHT); + placement.setForcePlace(true); + + assertEquals(-1, place(placer, placement, -1)); + assertTrue(placer.written().isEmpty()); + } + + @Test + public void explicitObjectPlacementRemainsAllowedInsideRiverWater() { + when(complex.hasHydrologySurfaceFluid(0, 0)).thenReturn(true); + SurfacePlacer placer = new SurfacePlacer(engine); + + assertTrue(place(placer, placement(ObjectPlaceMode.CENTER_HEIGHT), SURFACE_Y) >= 0); + assertFalse(placer.written().isEmpty()); + } + @Test public void explicitSurfaceAnchorIsGuardedByTheBufferRing() { SurfacePlacer placer = new SurfacePlacer(); diff --git a/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java index c6db9087d..c3399ed7a 100644 --- a/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java @@ -1,317 +1,282 @@ package art.arcane.iris.engine.object; -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.core.loader.ResourceLoader; import art.arcane.volmlib.util.collection.KList; import com.google.gson.Gson; import org.junit.Test; import java.util.Set; -import java.util.stream.Collectors; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; public class IrisRiverConfigurationTest { @Test - public void defaultsKeepRiverGenerationDisabledAndContained() { + public void defaultsExposeCanonicalHydrologyHierarchy() { IrisDimension dimension = new IrisDimension(); - assertNotNull(dimension.getRivers()); - assertFalse(dimension.getRivers().isEnabled()); - assertEquals(IrisRiverWaterMode.FIXED, dimension.getRivers().getWater().getMode()); - assertEquals(63, dimension.getRivers().getWater().getFluidHeight()); - assertEquals("water", dimension.getRivers().getWater().getFluidPalette().getPalette().getFirst().getBlock()); - assertFalse(dimension.getRivers().getTopology().isRequireOcean()); - assertEquals(512, dimension.getRivers().getTopology().getCellSize()); - assertEquals(16, dimension.getRivers().getTopology().getMaxRouteReaches()); - assertEquals(0, dimension.getRivers().getTopology().getMinimumSourcesPerTile()); - assertEquals(64, dimension.getRivers().getTopology().getRoutingBasinCells()); - assertEquals(8D, dimension.getRivers().getTopology().getRoutingPlateauHeight(), 0D); - assertEquals(0.05D, dimension.getRivers().getTopology().getSource().getChance(), 0D); - assertEquals(0.035D, dimension.getRivers().getTopology().getSource().getInfluence(), 0D); - assertEquals(10D, dimension.getRivers().getTerrain().getMaxChannelWidth(), 0D); - assertEquals(4D, dimension.getRivers().getTerrain().getMaxBankWidth(), 0D); - assertEquals(10D, dimension.getRivers().getTerrain().getMaxDepth(), 0D); - assertTrue(dimension.getRivers().getTerrain().getWorms().isEmpty()); - assertEquals(IrisRiverCaveMode.SEALED, dimension.getRivers().getCaves().getMode()); - assertEquals(IrisRiverCaveFallback.SEALED, dimension.getRivers().getCaves().getFallback()); - assertEquals(IrisRiverExistingFluidPolicy.REJECT, - dimension.getRivers().getCaves().getExistingFluidPolicy()); - assertNotNull(dimension.getRivers().getCaves().getDeepPools()); - assertFalse(dimension.getRivers().getCaves().getDeepPools().isEnabled()); - assertEquals(-224, dimension.getRivers().getCaves().getDeepPools().getMinimumFluidY()); - assertEquals(-104, dimension.getRivers().getCaves().getDeepPools().getMaximumFluidY()); - assertEquals("lava", dimension.getRivers().getCaves().getDeepPools() + assertNotNull(dimension.getHydrology()); + assertNotNull(dimension.getHydrology().getRivers()); + assertFalse(dimension.getHydrology().getRivers().isEnabled()); + assertNotNull(dimension.getHydrology().getRivers().getRouting()); + assertNotNull(dimension.getHydrology().getRivers().getSurface()); + assertNotNull(dimension.getHydrology().getRivers().getUnderground()); + assertNotNull(dimension.getHydrology().getRivers().getGrottos()); + assertEquals(1, dimension.getHydrology().getRivers().getProfiles().size()); + assertEquals("default", dimension.getHydrology().getRivers().getProfiles().getFirst().getId()); + assertEquals("water", dimension.getHydrology().getRivers().getProfiles().getFirst() .getFluidPalette().getPalette().getFirst().getBlock()); - assertTrue(dimension.getRivers().getBiomes().getAllBiomeIds().isEmpty()); - assertNull(new IrisRegion().getRiverOverride()); - assertNull(new IrisBiome().getRiverOverride()); + assertTrue(dimension.getHydrology().getDeepFluids().isEmpty()); + + IrisRiverRoutingConfig routing = dimension.getHydrology().getRivers().getRouting(); + assertEquals(2048, routing.getTileSize()); + assertEquals(16384, routing.getMaximumRouteLength()); + assertEquals(384, routing.getBranching().getMinimumSurfaceCourseLength()); + assertEquals(192, routing.getBranching().getMinimumUndergroundCourseLength()); + + IrisSurfaceRiverConfig surface = dimension.getHydrology().getRivers().getSurface(); + assertTrue(surface.isEnabled()); + assertEquals(0.5D, surface.getSources().getDensity(), 0D); + assertEquals(0, surface.getSources().getMinimumPerTile()); + assertEquals(384, surface.getSources().getMinimumSpacing()); + assertEquals(4D, surface.getChannel().getWidth().getMin(), 0D); + assertEquals(8D, surface.getChannel().getWidth().getMax(), 0D); + assertEquals(1D, surface.getChannel().getDepth().getMin(), 0D); + assertEquals(4D, surface.getChannel().getDepth().getMax(), 0D); + assertEquals(2D, surface.getChannel().getDepth().getStyle().getExponent(), 0D); + assertEquals(3D, surface.getChannel().getSurfaceInset().getMin(), 0D); + assertEquals(7D, surface.getChannel().getSurfaceInset().getMax(), 0D); + assertEquals(2D, surface.getChannel().getSurfaceInset().getStyle().getExponent(), 0D); + assertEquals(6, surface.getChannel().getMaximumIncision()); + assertEquals(2D, surface.getChannel().getShoreWidth(), 0D); + assertEquals(64, surface.getMouths().getLevelingDistance()); + assertEquals(8, surface.getMouths().getMaximumOceanApron()); + assertEquals(80D, surface.getHydraulics().getTargetPoolLength().getMin(), 0D); + assertEquals(180D, surface.getHydraulics().getTargetPoolLength().getMax(), 0D); + assertEquals(8, surface.getHydraulics().getWaterfallMinimumDrop()); + IrisRiverDropShapeConfig drops = dimension.getHydrology().getRivers().getGeometry().getDrops(); + assertEquals(2, drops.getCascadeRunPerBlock()); + assertEquals(1.4D, drops.getCascadeExponent(), 0D); + assertEquals(2, drops.getMaximumCascadeStep()); + assertEquals(0.45D, drops.getFlowWidthRatio(), 0D); + assertEquals(2, drops.getMaximumFlowDepth()); + assertEquals(1.8D, drops.getBasinWidthRatio(), 0D); + assertEquals(8, drops.getMaximumBasinDepth()); + + IrisUndergroundRiverConfig underground = dimension.getHydrology().getRivers().getUnderground(); + assertTrue(underground.isEnabled()); + assertEquals(0.25D, underground.getSources().getDensity(), 0D); + assertEquals(0, underground.getSources().getMinimumPerTile()); + assertEquals(512, underground.getSources().getMinimumSpacing()); + assertEquals(-48D, underground.getFluidLevel().getMin(), 0D); + assertEquals(50D, underground.getFluidLevel().getMax(), 0D); + assertEquals(3D, underground.getChannelWidth().getMin(), 0D); + assertEquals(8D, underground.getChannelWidth().getMax(), 0D); + assertTrue(underground.isConnectToExistingCaves()); + + IrisCoastalRiverGrottoConfig coastal = dimension.getHydrology().getRivers().getGrottos().getCoastal(); + IrisInlandRiverGrottoConfig inland = dimension.getHydrology().getRivers().getGrottos().getInland(); + assertEquals(12, coastal.getHorizontalRadius()); + assertEquals(7, coastal.getVerticalRadius()); + assertEquals(10, coastal.getHeadroom()); + assertEquals(8192, coastal.getMaximumVolume()); + assertEquals(10, inland.getHorizontalRadius()); + assertEquals(6, inland.getVerticalRadius()); + assertEquals(10, inland.getHeadroom()); + assertEquals(8192, inland.getMaximumVolume()); + assertFalse(inland.isConnectSurfaceRivers()); + + IrisDeepFluidConfig deepFluid = new IrisDeepFluidConfig(); + assertEquals(0.125D, deepFluid.getDensity(), 0D); + assertEquals(768, deepFluid.getSpacing()); + assertEquals(14, deepFluid.getHorizontalRadius()); + assertEquals(6, deepFluid.getVerticalRadius()); + assertEquals(3, deepFluid.getChannelWidth()); + assertEquals(1, deepFluid.getDepth()); + assertEquals(6, deepFluid.getHeadroom()); + assertFalse(deepFluid.isShortChannels()); + assertNotNull(dimension.getRiverPolicy()); + assertNull(dimension.getRiverPolicy().getPlacement()); + assertNull(dimension.getRiverPolicy().getRouting()); + assertNull(dimension.getRiverPolicy().getOutletAdmission()); + assertNull(new IrisRegion().getRiverPolicy()); + assertNull(new IrisBiome().getRiverPolicy()); } @Test - public void deserializesTypedDimensionRegionAndBiomeSettings() { + public void deserializesCanonicalDimensionRegionAndBiomeConfiguration() { Gson gson = new Gson(); IrisDimension dimension = gson.fromJson(""" { - "rivers": { - "enabled": true, - "topology": { - "cellSize": 512, - "minimumSourcesPerTile": 2, - "routingBasinCells": 96, - "routingPlateauHeight": 12, - "requireOcean": false, - "source": {"chance": 0.27, "influence": 0.4} - }, - "terrain": { - "channelRadiusBonus": 3, - "maxChannelWidth": 9, - "maxBankWidth": 2.5, - "maxDepth": 8, - "maxIncision": 36, - "worms": [ - { - "seed": 73, - "weight": 2.5, - "wavelength": 1536, - "detailWavelength": 192, - "tortuosity": 0.65, - "detailTortuosity": 0.2, - "maxOffset": 420, - "segments": 56, - "widthMultiplier": 1.4, - "bankMultiplier": 1.2, - "depthMultiplier": 0.8, - "bodyWavelength": 704, - "bodyDetailWavelength": 18, - "bodyDetailInfluence": 0.82, - "widthVariation": 0.75, - "bankVariation": 0.65, - "depthVariation": 0.55, - "roofVariation": 0.45 - } - ], - "terminalMode": "SUPPRESS" - }, - "water": { - "mode": "TERRACED", - "fluidHeight": -48, - "fluidPalette": { - "palette": [{"block": "minecraft:lava"}] - }, - "poolLength": 80 - }, - "biomes": { - "channel": ["river/channel"], - "floodedCave": ["river/grotto"] - }, - "caves": { - "mode": "FLOOD_CLOSED_COMPONENT", - "maxFloodVolume": 2048, - "existingFluidPolicy": "ALLOW_SAME", - "deepPools": { - "enabled": true, - "reach": { - "chance": 0.4, - "influence": 0.1 + "hydrology": { + "rivers": { + "enabled": true, + "routing": { + "tileSize": 4096, + "sampleSpacing": 96, + "refinementSpacing": 6, + "branching": { + "minimumSurfaceCourseLength": 640, + "minimumUndergroundCourseLength": 480 }, - "minimumSpacing": 896, - "maximumPerReach": 2, - "minimumFluidY": -220, - "maximumFluidY": -112, - "searchRadius": 24, - "searchAttempts": 18, - "horizontalRadius": 28, - "verticalRadius": 11, - "dryHeadroom": 5, - "shapeVariation": 0.7, - "warpStrength": 9, - "maximumVolume": 65536, - "fluidPalette": { - "palette": [{"block": "minecraft:lava"}] + "maximumRouteLength": 32768, + "maximumOutletsPerTile": 3, + "oceanOutlets": true, + "inlandOutlets": ["SINKHOLE_GROTTO"] + }, + "geometry": { + "drops": { + "cascadeRunPerBlock": 5, + "cascadeExponent": 2.4, + "maximumCascadeStep": 3, + "flowWidthRatio": 0.6, + "maximumFlowDepth": 4, + "basinWidthRatio": 2.2, + "maximumBasinDepth": 11 } - } - } + }, + "surface": { + "sources": {"density": 0.75, "minimumElevation": 104, "minimumPerTile": 2, + "minimumSpacing": 768}, + "channel": { + "width": {"min": 5, "max": 24}, + "depth": {"min": 2, "max": 6}, + "surfaceInset": {"min": 5, "max": 9}, + "maximumIncision": 14, + "shoreWidth": 1.5, + "terrainBlendWidth": {"min": 8, "max": 10} + }, + "hydraulics": { + "targetPoolLength": {"min": 64, "max": 192}, + "riffleDrop": 1, + "maximumGradualDrop": 4, + "maximumGradualLength": 12, + "waterfallMinimumDrop": 5 + }, + "ridgeTunnels": {"enabled": true, "maximumLength": 224, "headroom": 12}, + "mouths": {"levelingDistance": 80, "maximumOceanApron": 1} + }, + "underground": { + "sources": {"density": 0.4, "minimumPerTile": 3, "minimumSpacing": 896}, + "fluidLevel": {"min": -96, "max": 12}, + "channelWidth": {"min": 4, "max": 16}, + "depth": {"min": 2, "max": 4}, + "headroom": {"min": 7, "max": 15}, + "connectToExistingCaves": false + }, + "grottos": { + "coastal": { + "enabled": true, + "poolLevel": "SEA_LEVEL", + "horizontalRadius": 40, + "verticalRadius": 18, + "headroom": 9, + "maximumVolume": 70000 + }, + "inland": { + "enabled": true, + "connectSurfaceRivers": true, + "horizontalRadius": 30, + "verticalRadius": 15, + "headroom": 7, + "maximumVolume": 60000 + } + }, + "profiles": [{ + "id": "underworld", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]} + }] + }, + "deepFluids": [{ + "id": "deep_lava", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "density": 0.3, + "spacing": 896, + "height": {"min": -220, "max": -112}, + "horizontalRadius": 36, + "verticalRadius": 12, + "channelWidth": 6, + "depth": 3, + "headroom": 7, + "containedPools": true, + "shortChannels": false + }] + }, + "riverPolicy": { + "placement": "PREFERRED_HEADWATER", + "routing": "PREFER", + "outletAdmission": false, + "profiles": ["underworld"], + "surfaceBiomes": ["river/surface"], + "shoreBiomes": ["river/shore"], + "widthMultiplier": 1.5, + "incisionMultiplier": 0.75 } } """, IrisDimension.class); IrisRegion region = gson.fromJson(""" - { - "riverOverride": { - "allowSources": false, - "routingPolicy": "AVOID", - "widthMultiplier": 0.75, - "bankBiomes": ["river/region-bank"] - } - } + {"riverPolicy": { + "placement": "TRANSIT_ONLY", + "routing": "AVOID", + "mouthBiomes": ["river/mouth"], + "routingMultiplier": 2.5 + }} """, IrisRegion.class); IrisBiome biome = gson.fromJson(""" - { - "riverOverride": { - "routingPolicy": "BLOCK", - "caveEntryMultiplier": 0.2, - "floodedCaveBiomes": [] - } - } + {"riverPolicy": { + "placement": "REQUIRED_HEADWATER", + "routing": "BLOCK", + "floodedCaveBiomes": [], + "depthMultiplier": 0.8 + }} """, IrisBiome.class); - assertTrue(dimension.getRivers().isEnabled()); - assertEquals(512, dimension.getRivers().getTopology().getCellSize()); - assertEquals(2, dimension.getRivers().getTopology().getMinimumSourcesPerTile()); - assertEquals(96, dimension.getRivers().getTopology().getRoutingBasinCells()); - assertEquals(12D, dimension.getRivers().getTopology().getRoutingPlateauHeight(), 0D); - assertFalse(dimension.getRivers().getTopology().isRequireOcean()); - assertEquals(0.27D, dimension.getRivers().getTopology().getSource().getChance(), 0D); - assertEquals(9D, dimension.getRivers().getTerrain().getMaxChannelWidth(), 0D); - assertEquals(3D, dimension.getRivers().getTerrain().getChannelRadiusBonus(), 0D); - assertEquals(2.5D, dimension.getRivers().getTerrain().getMaxBankWidth(), 0D); - assertEquals(8D, dimension.getRivers().getTerrain().getMaxDepth(), 0D); - assertEquals(36, dimension.getRivers().getTerrain().getMaxIncision()); - IrisRiverWorm worm = dimension.getRivers().getTerrain().getWorms().get(0); - assertEquals(73L, worm.getSeed()); - assertEquals(2.5D, worm.getWeight(), 0D); - assertEquals(1536D, worm.getWavelength(), 0D); - assertEquals(192D, worm.getDetailWavelength(), 0D); - assertEquals(0.65D, worm.getTortuosity(), 0D); - assertEquals(0.2D, worm.getDetailTortuosity(), 0D); - assertEquals(420D, worm.getMaxOffset(), 0D); - assertEquals(56, worm.getSegments()); - assertEquals(1.4D, worm.getWidthMultiplier(), 0D); - assertEquals(1.2D, worm.getBankMultiplier(), 0D); - assertEquals(0.8D, worm.getDepthMultiplier(), 0D); - assertEquals(704D, worm.getBodyWavelength(), 0D); - assertEquals(18D, worm.getBodyDetailWavelength(), 0D); - assertEquals(0.82D, worm.getBodyDetailInfluence(), 0D); - assertEquals(0.75D, worm.getWidthVariation(), 0D); - assertEquals(0.65D, worm.getBankVariation(), 0D); - assertEquals(0.55D, worm.getDepthVariation(), 0D); - assertEquals(0.45D, worm.getRoofVariation(), 0D); - assertEquals(IrisRiverTerminalMode.SUPPRESS, - dimension.getRivers().getTerrain().getTerminalMode()); - assertEquals(IrisRiverWaterMode.TERRACED, dimension.getRivers().getWater().getMode()); - assertEquals(-48, dimension.getRivers().getWater().getFluidHeight()); - assertEquals("minecraft:lava", - dimension.getRivers().getWater().getFluidPalette().getPalette().getFirst().getBlock()); - assertEquals(Set.of("river/channel", "river/grotto"), - Set.copyOf(dimension.getRivers().getBiomes().getAllBiomeIds())); - assertEquals(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT, - dimension.getRivers().getCaves().getMode()); - assertEquals(IrisRiverExistingFluidPolicy.ALLOW_SAME, - dimension.getRivers().getCaves().getExistingFluidPolicy()); - IrisRiverDeepPools deepPools = dimension.getRivers().getCaves().getDeepPools(); - assertTrue(deepPools.isEnabled()); - assertEquals(0.4D, deepPools.getReach().getChance(), 0D); - assertEquals(0.1D, deepPools.getReach().getInfluence(), 0D); - assertEquals(896, deepPools.getMinimumSpacing()); - assertEquals(2, deepPools.getMaximumPerReach()); - assertEquals(-220, deepPools.getMinimumFluidY()); - assertEquals(-112, deepPools.getMaximumFluidY()); - assertEquals(24, deepPools.getSearchRadius()); - assertEquals(18, deepPools.getSearchAttempts()); - assertEquals(28, deepPools.getHorizontalRadius()); - assertEquals(11, deepPools.getVerticalRadius()); - assertEquals(5, deepPools.getDryHeadroom()); - assertEquals(0.7D, deepPools.getShapeVariation(), 0D); - assertEquals(9D, deepPools.getWarpStrength(), 0D); - assertEquals(65536, deepPools.getMaximumVolume()); - assertEquals("minecraft:lava", deepPools.getFluidPalette().getPalette().getFirst().getBlock()); + IrisRiverHydrology rivers = dimension.getHydrology().getRivers(); + assertTrue(rivers.isEnabled()); + assertEquals(4096, rivers.getRouting().getTileSize()); + assertEquals(32768, rivers.getRouting().getMaximumRouteLength()); + assertEquals(3, rivers.getRouting().getMaximumOutletsPerTile()); + assertEquals(640, rivers.getRouting().getBranching().getMinimumSurfaceCourseLength()); + assertEquals(480, rivers.getRouting().getBranching().getMinimumUndergroundCourseLength()); + assertEquals(new KList<>(IrisRiverInlandOutlet.SINKHOLE_GROTTO), rivers.getRouting().getInlandOutlets()); + assertEquals(24D, rivers.getSurface().getChannel().getWidth().getMax(), 0D); + assertEquals(9D, rivers.getSurface().getChannel().getSurfaceInset().getMax(), 0D); + assertEquals(5, rivers.getSurface().getHydraulics().getWaterfallMinimumDrop()); + assertEquals(768, rivers.getSurface().getSources().getMinimumSpacing()); + assertEquals(5, rivers.getGeometry().getDrops().getCascadeRunPerBlock()); + assertEquals(2.4D, rivers.getGeometry().getDrops().getCascadeExponent(), 0D); + assertEquals(3, rivers.getGeometry().getDrops().getMaximumCascadeStep()); + assertEquals(0.6D, rivers.getGeometry().getDrops().getFlowWidthRatio(), 0D); + assertEquals(4, rivers.getGeometry().getDrops().getMaximumFlowDepth()); + assertEquals(2.2D, rivers.getGeometry().getDrops().getBasinWidthRatio(), 0D); + assertEquals(11, rivers.getGeometry().getDrops().getMaximumBasinDepth()); + assertEquals(224, rivers.getSurface().getRidgeTunnels().getMaximumLength()); + assertEquals(-96D, rivers.getUnderground().getFluidLevel().getMin(), 0D); + assertEquals(896, rivers.getUnderground().getSources().getMinimumSpacing()); + assertFalse(rivers.getUnderground().isConnectToExistingCaves()); + assertEquals(40, rivers.getGrottos().getCoastal().getHorizontalRadius()); + assertTrue(rivers.getGrottos().getInland().isEnabled()); + assertTrue(rivers.getGrottos().getInland().isConnectSurfaceRivers()); + assertEquals("underworld", rivers.getProfiles().getFirst().getId()); - assertEquals(Boolean.FALSE, region.getRiverOverride().getAllowSources()); - assertEquals(IrisRiverRoutingPolicy.AVOID, region.getRiverOverride().getRoutingPolicy()); - assertEquals(Double.valueOf(0.75D), region.getRiverOverride().getWidthMultiplier()); - assertNull(region.getRiverOverride().getChannelBiomes()); - assertEquals(new KList<>("river/region-bank"), region.getRiverOverride().getBankBiomes()); + IrisDeepFluidConfig configuredDeepFluid = dimension.getHydrology().getDeepFluids().getFirst(); + assertEquals("deep_lava", configuredDeepFluid.getId()); + assertEquals(0.3D, configuredDeepFluid.getDensity(), 0D); + assertEquals(896, configuredDeepFluid.getSpacing()); + assertEquals(-220D, configuredDeepFluid.getHeight().getMin(), 0D); + assertEquals(36, configuredDeepFluid.getHorizontalRadius()); + assertEquals(6, configuredDeepFluid.getChannelWidth()); + assertFalse(configuredDeepFluid.isShortChannels()); - assertEquals(IrisRiverRoutingPolicy.BLOCK, biome.getRiverOverride().getRoutingPolicy()); - assertNull(biome.getRiverOverride().getChannelBiomes()); - assertNotNull(biome.getRiverOverride().getFloodedCaveBiomes()); - assertTrue(biome.getRiverOverride().getFloodedCaveBiomes().isEmpty()); - } - - @Test - @SuppressWarnings("unchecked") - public void resolvesRiverPoolsOnlyWhenRiversAreEnabled() { - IrisRiverBiomes dimensionBiomes = new IrisRiverBiomes() - .setChannel(new KList<>("dimension-channel")); - IrisRiverOverride regionOverride = new IrisRiverOverride() - .setBankBiomes(new KList<>("region-bank")); - IrisRiverOverride biomeOverride = new IrisRiverOverride() - .setMouthBiomes(new KList<>("biome-mouth")) - .setFloodedCaveBiomes(new KList<>("biome-grotto")); - IrisDimension dimension = new IrisDimension() - .setRegions(new KList<>("region")) - .setRivers(new IrisRiverNetwork() - .setEnabled(true) - .setTerrain(new IrisRiverTerrain() - .setWorms(new KList<>(new IrisRiverWorm()))) - .setBiomes(dimensionBiomes)); - IrisRegion region = new IrisRegion() - .setLandBiomes(new KList<>("natural")) - .setRiverOverride(regionOverride); - IrisBiome natural = biome("natural").setRiverOverride(biomeOverride); - - IrisData data = mock(IrisData.class); - ResourceLoader regionLoader = mock(ResourceLoader.class); - ResourceLoader biomeLoader = mock(ResourceLoader.class); - when(data.getRegionLoader()).thenReturn(regionLoader); - when(data.getBiomeLoader()).thenReturn(biomeLoader); - when(regionLoader.load("region")).thenReturn(region); - when(biomeLoader.load("natural")).thenReturn(natural); - when(biomeLoader.load("dimension-channel")).thenReturn(biome("dimension-channel")); - when(biomeLoader.load("region-bank")).thenReturn(biome("region-bank")); - when(biomeLoader.load("biome-mouth")).thenReturn(biome("biome-mouth")); - when(biomeLoader.load("biome-grotto")).thenReturn(biome("biome-grotto")); - - Set enabledKeys = keys(dimension.getReachableBiomes(() -> data)); - dimension.getRivers().setEnabled(false); - Set disabledKeys = keys(dimension.getReachableBiomes(() -> data)); - - assertEquals(Set.of("natural", "dimension-channel", "region-bank", "biome-mouth", "biome-grotto"), - enabledKeys); - assertEquals(Set.of("natural"), disabledKeys); - } - - @Test - @SuppressWarnings("unchecked") - public void separatesNaturalAndRiverOnlyRegionBiomes() { - IrisRegion region = new IrisRegion() - .setLandBiomes(new KList<>("natural-parent")) - .setRiverOverride(new IrisRiverOverride() - .setChannelBiomes(new KList<>("river-parent"))); - IrisBiome naturalParent = biome("natural-parent").setChildren(new KList<>("natural-child")); - IrisBiome naturalChild = biome("natural-child"); - IrisBiome riverParent = biome("river-parent").setChildren(new KList<>("river-child")); - IrisBiome riverChild = biome("river-child"); - - IrisData data = mock(IrisData.class); - ResourceLoader biomeLoader = mock(ResourceLoader.class); - when(data.getBiomeLoader()).thenReturn(biomeLoader); - when(biomeLoader.load("natural-parent")).thenReturn(naturalParent); - when(biomeLoader.load("natural-child")).thenReturn(naturalChild); - when(biomeLoader.load("river-parent")).thenReturn(riverParent); - when(biomeLoader.load("river-child")).thenReturn(riverChild); - - assertEquals(Set.of("natural-parent"), Set.copyOf(region.getNaturalBiomeIds())); - assertEquals(Set.of("natural-parent", "river-parent"), Set.copyOf(region.getAllBiomeIds())); - assertEquals(Set.of("natural-parent", "natural-child"), - keys(region.getNaturalBiomes(() -> data))); - assertEquals(Set.of("natural-parent", "natural-child", "river-parent", "river-child"), - keys(region.getAllBiomes(() -> data))); - } - - private static IrisBiome biome(String loadKey) { - IrisBiome biome = new IrisBiome(); - biome.setLoadKey(loadKey); - return biome; - } - - private static Set keys(KList biomes) { - return biomes.stream().map(IrisBiome::getLoadKey).collect(Collectors.toSet()); + assertEquals(IrisRiverPlacementMode.PREFERRED_HEADWATER, dimension.getRiverPolicy().getPlacement()); + assertEquals(IrisRiverRoutingMode.PREFER, dimension.getRiverPolicy().getRouting()); + assertFalse(dimension.getRiverPolicy().getOutletAdmission()); + assertEquals(Set.of("river/surface", "river/shore"), dimension.getRiverPolicy().getAllBiomeIds()); + assertEquals(IrisRiverPlacementMode.TRANSIT_ONLY, region.getRiverPolicy().getPlacement()); + assertEquals(new KList<>("river/mouth"), region.getRiverPolicy().getMouthBiomes()); + assertEquals(IrisRiverPlacementMode.REQUIRED_HEADWATER, biome.getRiverPolicy().getPlacement()); + assertTrue(biome.getRiverPolicy().getFloodedCaveBiomes().isEmpty()); } } diff --git a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitialSpawnTest.java b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitialSpawnTest.java index f03b16ff6..02dc444ee 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitialSpawnTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/BukkitChunkGeneratorInitialSpawnTest.java @@ -56,7 +56,6 @@ public class BukkitChunkGeneratorInitialSpawnTest { chunkFuture.complete(chunk); assertNotNull(regionTask.get()); assertFalse(readiness.isDone()); - regionTask.get().run(); readiness.get(5L, TimeUnit.SECONDS); } diff --git a/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java index 25d7bb5bb..f29b7d741 100644 --- a/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java +++ b/core/src/test/java/art/arcane/iris/engine/platform/EngineBukkitOpsDeferredMaterializationTest.java @@ -2,16 +2,26 @@ package art.arcane.iris.engine.platform; import art.arcane.iris.core.link.Identifier; import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineMetrics; +import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.util.project.matter.TileWrapper; +import art.arcane.volmlib.util.function.Consumer4; import art.arcane.volmlib.util.mantle.flag.MantleFlag; import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; import art.arcane.volmlib.util.mantle.runtime.MantleHooks; import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterUpdate; +import art.arcane.volmlib.util.matter.slices.UpdateMatter; import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.data.BlockData; import org.junit.Test; import org.mockito.InOrder; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; @@ -20,13 +30,55 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; public class EngineBukkitOpsDeferredMaterializationTest { + @Test + @SuppressWarnings("unchecked") + public void updateMarkerUsesWorldHeightAndDeletesSliceAfterDispatch() { + MantleChunk mantleChunk = mock(MantleChunk.class); + doAnswer(invocation -> { + Consumer4 iterator = invocation.getArgument(1); + iterator.accept(-1, 5, -2, UpdateMatter.ON); + return null; + }).when(mantleChunk).iterate(eq(MatterUpdate.class), any()); + IrisWorld irisWorld = mock(IrisWorld.class); + when(irisWorld.minHeight()).thenReturn(-64); + Engine engine = mock(Engine.class); + when(engine.getWorld()).thenReturn(irisWorld); + when(engine.getMetrics()).thenReturn(new EngineMetrics(8)); + Chunk chunk = mock(Chunk.class); + ArrayList updates = new ArrayList<>(); + + EngineBukkitOps.materializeUpdates( + engine, + chunk, + mantleChunk, + (x, y, z) -> updates.add(x + "," + y + "," + z) + ); + + assertEquals(List.of("-1,-59,-2"), updates); + verify(mantleChunk).deleteSlices(MatterUpdate.class); + } + + @Test + public void fallingFluidUpdateRetainsLevelEightStateAndRequestsPhysics() { + Block block = mock(Block.class); + BlockData falling = mock(BlockData.class); + + EngineBukkitOps.applyPhysicsUpdate(block, falling); + + InOrder order = inOrder(block); + order.verify(block).setType(Material.AIR, false); + order.verify(block).setBlockData(falling, true); + } + @Test @SuppressWarnings("unchecked") public void tileSliceIsDeletedAfterIteration() { diff --git a/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java b/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java deleted file mode 100644 index 4d22b6209..000000000 --- a/core/src/test/java/art/arcane/iris/engine/river/RiverNetworkTest.java +++ /dev/null @@ -1,1944 +0,0 @@ -package art.arcane.iris.engine.river; - -import org.junit.Test; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.Callable; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -public class RiverNetworkTest { - @Test - public void mapsNegativeWorldCoordinatesWithFloorDivision() { - RiverNetwork network = new RiverNetwork(options(1L).build()); - RiverTerrainSampler terrain = slopedTerrain(false); - - assertEquals(new RiverNodeId(-1L, -1L), network.nodeAtWorld(-1, -1, terrain).id()); - assertEquals(new RiverNodeId(-1L, 0L), network.nodeAtWorld(-64, 0, terrain).id()); - assertEquals(new RiverNodeId(0L, 0L), network.nodeAtWorld(0, 0, terrain).id()); - assertEquals(-1, network.tileXForBlock(-1)); - assertEquals(0, network.tileXForBlock(0)); - } - - @Test - public void expandedSamplingAddsOnlyTheRequestedLateralRadius() { - RiverNode from = node(0L, 0L, 0D, 0D); - RiverNode to = node(1L, 0L, 100D, 0D); - RiverTile tile = new RiverTile( - 0, - 0, - 0, - -64, - 128, - 64, - List.of(reach(from, to, 4D, 1D)) - ); - - assertFalse(tile.sample(50D, 5D).present()); - RiverSample expanded = tile.sampleExpanded(50D, 5D, 2D); - assertTrue(expanded.present()); - assertEquals(RiverSection.BANK, expanded.section()); - assertEquals(5D, expanded.distance(), 0D); - assertThrows(IllegalArgumentException.class, () -> tile.sampleExpanded(50D, 5D, -1D)); - } - - @Test - public void graphHasReciprocalCardinalsAndOneStableDiagonalPerSquare() { - RiverNetwork network = new RiverNetwork(options(2L).build()); - RiverNodeId origin = new RiverNodeId(0L, 0L); - List neighbors = network.neighbors(origin); - - assertTrue(neighbors.contains(new RiverNodeId(-1L, 0L))); - assertTrue(neighbors.contains(new RiverNodeId(1L, 0L))); - assertTrue(neighbors.contains(new RiverNodeId(0L, -1L))); - assertTrue(neighbors.contains(new RiverNodeId(0L, 1L))); - assertTrue(neighbors.size() >= 4 && neighbors.size() <= 8); - for (RiverNodeId neighbor : neighbors) { - assertTrue(network.neighbors(neighbor).contains(origin)); - } - - boolean ascending = network.neighbors(new RiverNodeId(0L, 0L)).contains(new RiverNodeId(1L, 1L)); - boolean descending = network.neighbors(new RiverNodeId(0L, 1L)).contains(new RiverNodeId(1L, 0L)); - assertNotEquals(ascending, descending); - } - - @Test - public void downstreamAlwaysLowersStrictRankAndCannotCycle() { - RiverNetwork network = new RiverNetwork(options(3L).routingNoiseWeight(20.0).build()); - RiverTerrainSampler terrain = flatTerrain(false); - RiverNode current = network.nodeAtCell(12L, -7L, terrain); - Set visited = new HashSet<>(); - - for (int step = 0; step < 64; step++) { - assertTrue(visited.add(current.id())); - RiverNode next = network.downstream(current.id(), terrain); - if (next == null) { - break; - } - assertTrue(compareRank(next, current) < 0); - assertTrue(next.ocean() || next.hydraulicHeight() <= current.hydraulicHeight()); - current = next; - } - } - - @Test - public void routingPlateausCanCrossSmallNaturalRisesWithoutRaisingTheHydraulicHead() { - RiverNetwork network = new RiverNetwork(options(71L) - .siteJitter(0D) - .routingPlateauHeight(16D) - .routingNoiseWeight(0D) - .build()); - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double naturalHeight(int blockX, int blockZ) { - if (blockX >= 0 && blockX < 64 && blockZ >= 0 && blockZ < 64) { - return 68D; - } - return blockX >= 64 ? 70D : 80D; - } - - @Override - public double routingCost(int blockX, int blockZ) { - return blockX >= 64 ? 0D : 10D; - } - }; - - RiverNode source = network.nodeAtCell(0L, 0L, terrain); - RiverNode next = network.downstream(source.id(), terrain); - - assertNotNull(next); - assertTrue(next.naturalHeight() > source.naturalHeight()); - assertTrue(next.hydraulicHeight() <= source.hydraulicHeight()); - } - - @Test - public void longRoutesConvergeIntoDeterministicBranches() { - RiverNetwork network = new RiverNetwork(options(72L) - .tileCells(4) - .maxRouteReaches(16) - .routingPlateauHeight(8D) - .requireOcean(false) - .build()); - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double naturalHeight(int blockX, int blockZ) { - return 512D - blockX * 0.25D; - } - - @Override - public double routingCost(int blockX, int blockZ) { - return StrictMath.abs(blockZ) * 0.5D; - } - - @Override - public double flowNoise(double x, double z) { - return StrictMath.sin(x / 384D) + StrictMath.cos(z / 512D); - } - }; - - RiverRoute route = network.trace(new RiverNodeId(0L, 4L), terrain); - RiverTile tile = network.buildTile(0, 0, terrain); - HashMap incoming = new HashMap<>(); - for (RiverReach reach : tile.reaches()) { - incoming.merge(reach.to().id(), 1, Integer::sum); - } - - assertEquals(16, route.edges().size()); - assertTrue(incoming.values().stream().anyMatch(count -> count >= 2)); - assertEquals(digest(tile), digest(network.buildTile(0, 0, terrain))); - } - - @Test - public void recursiveBranchesApplyTheProfileChildSoftCapAtEveryNode() { - RiverWorm constrained = wormProfile( - "constrained", - 720L, - 6, - 1D, - 1D, - 1D, - 4, - 0D, - 1D, - 0D, - 0D, - List.of() - ); - RiverNetwork network = new RiverNetwork(options(720L) - .requireOcean(false) - .routingBasinCells(16) - .worms(List.of(constrained)) - .build()); - RiverTerrainSampler terrain = flatTerrain(false); - HashMap incoming = new HashMap<>(); - for (long cellX = -24L; cellX <= 24L; cellX++) { - for (long cellZ = -24L; cellZ <= 24L; cellZ++) { - RiverNode downstream = network.downstream(new RiverNodeId(cellX, cellZ), terrain); - if (downstream != null) { - incoming.merge(downstream.id(), 1, Integer::sum); - } - } - } - - assertFalse(incoming.isEmpty()); - assertTrue(incoming.values().stream().allMatch(children -> children <= 4)); - assertTrue(incoming.values().stream().anyMatch(children -> children >= 3)); - } - - @Test - public void candidateRankingAndTracingAreIndependentOfWormGeometry() { - RiverNetwork straightNetwork = new RiverNetwork(options(75L) - .requireOcean(false) - .worms(List.of(worm(1L, 0D, 0D, 0D, 1))) - .build()); - RiverNetwork windingNetwork = new RiverNetwork(options(75L) - .requireOcean(false) - .worms(List.of(worm(2L, 0.8D, 0.2D, 32D, 32))) - .build()); - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double reachRoutingCost(RiverRoutingContext context) { - return context.midpointX() + context.midpointZ(); - } - }; - RiverNodeId source = new RiverNodeId(0L, 0L); - - assertFalse(straightNetwork.downstreamCandidates(source, terrain).isEmpty()); - assertEquals( - straightNetwork.downstreamCandidates(source, terrain), - windingNetwork.downstreamCandidates(source, terrain) - ); - assertEquals(straightNetwork.trace(source, terrain), windingNetwork.trace(source, terrain)); - } - - @Test - public void routingFieldAlignmentSelectsTheMostParallelDownstreamReach() { - RiverNetwork reference = new RiverNetwork(options(74L) - .siteJitter(0D) - .flowAlignmentWeight(0D) - .build()); - RiverNodeId sourceId = new RiverNodeId(0L, 0L); - RiverTerrainSampler flat = flatTerrain(false); - RiverNode source = reference.nodeAtCell(sourceId.cellX(), sourceId.cellZ(), flat); - List candidates = reference.downstreamCandidates(sourceId, flat); - assertTrue(candidates.size() > 1); - RiverNode target = null; - double targetGap = 0D; - for (RiverNode proposed : candidates) { - double proposedX = proposed.x() - source.x(); - double proposedZ = proposed.z() - source.z(); - double proposedLength = StrictMath.hypot(proposedX, proposedZ); - double firstAlignment = -1D; - double secondAlignment = -1D; - RiverNode first = null; - for (RiverNode candidate : candidates) { - double candidateX = candidate.x() - source.x(); - double candidateZ = candidate.z() - source.z(); - double candidateLength = StrictMath.hypot(candidateX, candidateZ); - double alignment = StrictMath.abs( - proposedX * candidateX + proposedZ * candidateZ - ) / (proposedLength * candidateLength); - if (alignment > firstAlignment) { - secondAlignment = firstAlignment; - firstAlignment = alignment; - first = candidate; - } else if (alignment > secondAlignment) { - secondAlignment = alignment; - } - } - double gap = firstAlignment - secondAlignment; - if (gap > targetGap) { - targetGap = gap; - target = first; - } - } - assertNotNull(target); - assertTrue(targetGap > 0.000001D); - double tangentX = target.x() - source.x(); - double tangentZ = target.z() - source.z(); - RiverTerrainSampler guidedTerrain = new TestTerrain(false) { - @Override - public double flowNoise(double x, double z) { - return x * tangentZ - z * tangentX; - } - }; - RiverNetwork guided = new RiverNetwork(options(74L) - .siteJitter(0D) - .flowAlignmentWeight(10_000D) - .build()); - - RiverNode downstream = guided.downstream(sourceId, guidedTerrain); - - assertNotNull(downstream); - assertEquals(target.id(), downstream.id()); - } - - @Test - public void basinPotentialCarriesLongRoutesAcrossLocalTerrainRises() { - RiverNetwork network = new RiverNetwork(options(73L) - .siteJitter(0D) - .maxRouteReaches(16) - .routingPlateauHeight(8D) - .terrainHeightWeight(0D) - .requireOcean(false) - .build()); - RiverTerrainSampler flat = flatTerrain(false); - RiverRoute baseline = network.trace(new RiverNodeId(0L, 0L), flat); - HashSet raisedNodes = new HashSet<>(); - RiverNodeId baselineNode = new RiverNodeId(0L, 0L); - for (int edgeIndex = 0; edgeIndex < baseline.edges().size(); edgeIndex++) { - RiverEdgeId edge = baseline.edges().get(edgeIndex); - baselineNode = edge.first().equals(baselineNode) ? edge.second() : edge.first(); - if ((edgeIndex & 1) == 0) { - raisedNodes.add(baselineNode); - } - } - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double naturalHeight(int blockX, int blockZ) { - RiverNodeId id = new RiverNodeId(Math.floorDiv(blockX, 64), Math.floorDiv(blockZ, 64)); - return raisedNodes.contains(id) ? 104D : 64D; - } - }; - - RiverRoute route = network.trace(new RiverNodeId(0L, 0L), terrain); - RiverNode current = network.nodeAtCell(0L, 0L, terrain); - boolean crossedNaturalRise = false; - for (RiverEdgeId edge : route.edges()) { - RiverNode next = network.downstream(current.id(), terrain); - assertNotNull(next); - assertEquals(edge, RiverEdgeId.of(current.id(), next.id())); - crossedNaturalRise |= next.naturalHeight() > current.naturalHeight(); - assertTrue(next.rank() < current.rank()); - assertTrue(next.hydraulicHeight() <= current.hydraulicHeight()); - current = next; - } - - assertEquals(16, route.edges().size()); - assertTrue(crossedNaturalRise); - } - - @Test - public void rejectedBestReachReroutesToAnotherStrictlyDownhillCandidate() { - RiverNetwork network = new RiverNetwork(options(31L).downstreamCandidateLimit(4).build()); - RiverNodeId source = new RiverNodeId(0L, 0L); - RiverTerrainSampler terrain = slopedTerrain(false); - List candidates = network.downstreamCandidates(source, terrain); - assertTrue(candidates.size() > 1); - RiverEdgeId rejected = RiverEdgeId.of(source, candidates.getFirst().id()); - - RiverTerrainSampler reroutingTerrain = new TestTerrain(false) { - @Override - public boolean allowsReach(RiverRoutingContext context) { - return !context.edgeId().equals(rejected); - } - }; - RiverNode rerouted = network.downstream(source, reroutingTerrain); - assertNotNull(rerouted); - assertNotEquals(candidates.getFirst().id(), rerouted.id()); - assertTrue(rerouted.rank() < network.nodeAtCell(0L, 0L, reroutingTerrain).rank()); - } - - @Test - public void failedContinuationStopsAtTheFirstPhysicallyValidReach() { - RiverNetwork network = new RiverNetwork(options(32L).reachChance(0D).build()); - int[] feasibilityChecks = new int[1]; - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public boolean allowsReach(RiverRoutingContext context) { - feasibilityChecks[0]++; - return true; - } - }; - - assertEquals(null, network.downstream(new RiverNodeId(0L, 0L), terrain)); - assertEquals(1, feasibilityChecks[0]); - } - - @Test - public void classifiesCompleteOceanRoutesAsWetAndIncompleteRoutesAsDryOrSuppressed() { - RiverNodeId source = new RiverNodeId(0L, 0L); - RiverNetwork wetNetwork = new RiverNetwork(options(4L).maxRouteReaches(16).build()); - RiverTerrainSampler oceanNeighbor = new TestTerrain(false) { - @Override - public boolean isOcean(int blockX, int blockZ) { - return Math.floorDiv(blockX, 64) != 0 || Math.floorDiv(blockZ, 64) != 0; - } - }; - RiverRoute wet = wetNetwork.trace(source, oceanNeighbor); - assertEquals(RiverRouteState.WET, wet.state()); - assertFalse(wet.edges().isEmpty()); - - RiverNetwork dryNetwork = new RiverNetwork(options(4L).maxRouteReaches(6).build()); - RiverTerrainSampler physicalTerminal = new TestTerrain(false) { - @Override - public boolean allowsReach(RiverRoutingContext context) { - return context.from().id().equals(source); - } - }; - RiverRoute dry = dryNetwork.trace(source, physicalTerminal); - assertEquals(RiverRouteState.DRY, dry.state()); - assertFalse(dry.edges().isEmpty()); - - RiverNetwork suppressedNetwork = new RiverNetwork( - options(4L).maxRouteReaches(6).dryChannelChance(0.0).build() - ); - RiverRoute suppressed = suppressedNetwork.trace(source, physicalTerminal); - assertEquals(RiverRouteState.SUPPRESSED, suppressed.state()); - assertTrue(suppressed.edges().isEmpty()); - } - - @Test - public void oceanOptionalRoutesDoNotTurnTheTraceHorizonIntoATerminal() { - RiverNetwork network = new RiverNetwork( - options(41L).maxRouteReaches(3).requireOcean(false).build() - ); - RiverRoute route = network.trace(new RiverNodeId(0L, 0L), slopedTerrain(false)); - assertEquals(RiverRouteState.WET, route.state()); - assertFalse(route.oceanConnected()); - assertFalse(route.terminal()); - assertFalse(route.edges().isEmpty()); - } - - @Test - public void sampledTerminalPoliciesOverrideTheNetworkFallback() { - RiverNodeId source = new RiverNodeId(0L, 0L); - RiverNetwork requiredOcean = new RiverNetwork(options(42L).maxRouteReaches(3).requireOcean(true).build()); - RiverTerrainSampler wetTerminal = new TestTerrain(false) { - @Override - public boolean allowsReach(RiverRoutingContext context) { - return context.from().id().equals(source); - } - - @Override - public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { - return RiverTerminalPolicy.WET; - } - }; - RiverTerrainSampler suppressedTerminal = new TestTerrain(false) { - @Override - public boolean allowsReach(RiverRoutingContext context) { - return context.from().id().equals(source); - } - - @Override - public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { - return RiverTerminalPolicy.SUPPRESS; - } - }; - - assertEquals(RiverRouteState.WET, requiredOcean.trace(source, wetTerminal).state()); - assertEquals(RiverRouteState.SUPPRESSED, requiredOcean.trace(source, suppressedTerminal).state()); - - RiverNetwork optionalOcean = new RiverNetwork(options(43L).maxRouteReaches(3).requireOcean(false).build()); - RiverTerrainSampler dryTerminal = new TestTerrain(false) { - @Override - public boolean allowsReach(RiverRoutingContext context) { - return context.from().id().equals(source); - } - - @Override - public RiverTerminalPolicy terminalPolicy(int blockX, int blockZ) { - return RiverTerminalPolicy.DRY; - } - }; - assertEquals(RiverRouteState.DRY, optionalOcean.trace(source, dryTerminal).state()); - } - - @Test - public void sourceAndReachPoliciesGateWholeGraphEvents() { - RiverNetwork sourceNetwork = new RiverNetwork(options(5L).build()); - RiverRoute sourceSuppressed = sourceNetwork.trace(new RiverNodeId(0L, 0L), new TestTerrain(false) { - @Override - public double sourceChanceMultiplier(int blockX, int blockZ) { - return 0.0; - } - }); - assertEquals(RiverRouteState.SUPPRESSED, sourceSuppressed.state()); - assertTrue(sourceSuppressed.edges().isEmpty()); - - RiverNetwork reachNetwork = new RiverNetwork(options(5L).reachChance(0.0).build()); - RiverRoute reachSuppressed = reachNetwork.trace(new RiverNodeId(0L, 0L), slopedTerrain(false)); - assertEquals(RiverRouteState.SUPPRESSED, reachSuppressed.state()); - assertTrue(reachSuppressed.edges().isEmpty()); - - RiverTerrainSampler blocked = new TestTerrain(false) { - @Override - public boolean allowsRiver(int blockX, int blockZ) { - return blockX < 0; - } - }; - RiverNode blockedNode = sourceNetwork.nodeAtCell(0L, 0L, blocked); - assertFalse(blockedNode.riverAllowed()); - assertEquals(null, sourceNetwork.downstream(blockedNode.id(), blocked)); - } - - @Test - public void impossibleSourceRollsSkipExpensiveTerrainSettings() { - int[] sampledSettings = new int[1]; - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double maximumSourceChanceMultiplier() { - return 0D; - } - - @Override - public double sourceChanceMultiplier(int blockX, int blockZ) { - sampledSettings[0]++; - return 1D; - } - - @Override - public double naturalHeight(int blockX, int blockZ) { - throw new AssertionError("A source rejected by its upper bound must not sample terrain"); - } - }; - RiverNetwork network = new RiverNetwork(options(53L) - .sourceChance(0.05D) - .minimumSourcesPerTile(0) - .build()); - - RiverRoute route = network.trace(new RiverNodeId(0L, 0L), terrain); - - assertEquals(RiverRouteState.SUPPRESSED, route.state()); - assertTrue(route.edges().isEmpty()); - assertEquals(0, sampledSettings[0]); - } - - @Test - public void minimumSourceFloorKeepsEligibleRoutingTilesActive() { - RiverNetwork network = new RiverNetwork(options(52L) - .maxRouteReaches(4) - .minimumSourcesPerTile(1) - .sourceChance(0.000001D) - .requireOcean(false) - .build()); - RiverTerrainSampler terrain = slopedTerrain(false); - - for (int tileX = -2; tileX <= 2; tileX++) { - for (int tileZ = -2; tileZ <= 2; tileZ++) { - assertFalse(network.buildTile(tileX, tileZ, terrain).reaches().isEmpty()); - } - } - - RiverNetwork disabled = new RiverNetwork(options(52L) - .maxRouteReaches(4) - .minimumSourcesPerTile(1) - .sourceChance(0D) - .requireOcean(false) - .build()); - assertTrue(disabled.buildTile(0, 0, terrain).reaches().isEmpty()); - } - - @Test - public void minimumSourceFloorDoesNotResolveEveryRandomCandidate() { - int[] sampledSettings = new int[1]; - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double maximumSourceChanceMultiplier() { - return 1D; - } - - @Override - public double sourceChanceMultiplier(int blockX, int blockZ) { - sampledSettings[0]++; - return 1D; - } - }; - RiverNetwork network = new RiverNetwork(options(54L) - .maxRouteReaches(1) - .minimumSourcesPerTile(1) - .sourceChance(0.000000001D) - .requireOcean(false) - .build()); - - RiverTile tile = network.buildTile(0, 0, terrain); - - assertFalse(tile.reaches().isEmpty()); - assertTrue(sampledSettings[0] <= 25); - } - - @Test - public void minimumSourceFloorRejectsNonFiniteLocalChanceMultipliers() { - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double sourceChanceMultiplier(int blockX, int blockZ) { - return Double.NaN; - } - }; - RiverNetwork network = new RiverNetwork(options(55L) - .maxRouteReaches(4) - .minimumSourcesPerTile(1) - .sourceChance(1D) - .requireOcean(false) - .build()); - - assertTrue(network.buildTile(0, 0, terrain).reaches().isEmpty()); - } - - @Test - public void tileBuildIsDeterministicUnderParallelEvaluation() throws Exception { - RiverNetwork network = new RiverNetwork(options(6L).build()); - RiverTerrainSampler terrain = slopedTerrain(false); - long expected = digest(network.buildTile(-1, 0, terrain)); - ExecutorService executor = Executors.newFixedThreadPool(4); - try { - ArrayList> tasks = new ArrayList<>(); - for (int task = 0; task < 12; task++) { - tasks.add(() -> digest(network.buildTile(-1, 0, terrain))); - } - List> results = executor.invokeAll(tasks); - for (Future result : results) { - assertEquals(expected, result.get().longValue()); - } - } finally { - executor.shutdownNow(); - } - } - - @Test - public void tileBuildPreservesPinnedOutputDigests() { - RiverTerrainSampler terrain = slopedTerrain(false); - List actual = List.of( - digest(new RiverNetwork(options(6L).build()).buildTile(-1, 0, terrain)), - digest(new RiverNetwork(options(6L).build()).buildTile(0, 0, terrain)), - digest(new RiverNetwork(options(6L).build()).buildTile(1, 1, terrain)), - digest(new RiverNetwork(options(61L).build()).buildTile(-2, -1, terrain)) - ); - - assertEquals(List.of( - 973031325888677478L, - 8932177974453767311L, - -5189009084208004632L, - 6374264141259432071L - ), actual); - } - - @Test - public void adjacentTilesShareIdenticalPinnedReachGeometry() { - RiverNetwork network = new RiverNetwork(options(7L).build()); - RiverTerrainSampler terrain = slopedTerrain(false); - RiverTile first = network.buildTile(0, 0, terrain); - RiverTile second = network.buildTile(1, 0, terrain); - HashMap secondById = new HashMap<>(); - for (RiverReach reach : second.reaches()) { - secondById.put(reach.id(), reach); - } - int sharedCount = 0; - for (RiverReach candidate : first.reaches()) { - RiverReach matching = secondById.get(candidate.id()); - if (matching == null) { - continue; - } - sharedCount++; - assertReachEquals(candidate, matching); - assertEquals(candidate.from().x(), candidate.polyline().x(0), 0.0); - assertEquals(candidate.from().z(), candidate.polyline().z(0), 0.0); - int last = candidate.polyline().size() - 1; - assertEquals(candidate.to().x(), candidate.polyline().x(last), 0.0); - assertEquals(candidate.to().z(), candidate.polyline().z(last), 0.0); - } - assertTrue(sharedCount > 0); - } - - @Test - public void geometryEnvelopeKeepsWideReachesIdenticalAcrossTileBoundaries() { - RiverNetwork network = new RiverNetwork(options(71L) - .maxRouteReaches(1) - .siteJitter(0D) - .maximumReachRadius(256D) - .channelWidth(512D) - .maxChannelWidth(512D) - .bankWidth(0D) - .worms(List.of(wormProfile( - "straight", - 1L, - 1, - 1D, - 1D, - 1D, - 8, - 1D, - 1D, - 0D, - 0D, - List.of() - ))) - .build()); - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public double sourceChanceMultiplier(int blockX, int blockZ) { - return blockX == -96 ? 1D : 0D; - } - }; - RiverTile west = network.buildTile(0, 0, terrain); - RiverTile east = network.buildTile(1, 0, terrain); - - boolean present = false; - for (int z = 0; z < 128; z++) { - RiverSample westSample = west.sample(128D, z); - RiverSample eastSample = east.sample(128D, z); - assertEquals(westSample, eastSample); - present |= westSample.present(); - } - assertTrue(present); - } - - @Test - public void mergedReachesIncreaseFlowAndOrderAndCanBeSampled() { - RiverNetwork network = new RiverNetwork(options(8L).sourceChance(1.0).build()); - RiverTile tile = network.buildTile(0, 0, slopedTerrain(false)); - boolean merged = false; - boolean sampled = false; - - for (RiverReach reach : tile.reaches()) { - assertEquals(1 + (31 - Integer.numberOfLeadingZeros(reach.flow())), reach.order()); - if (reach.flow() > 1) { - merged = true; - } - int middle = reach.polyline().size() / 2; - RiverSample sample = tile.sample(reach.polyline().x(middle), reach.polyline().z(middle)); - if (sample.present()) { - sampled = true; - assertTrue(sample.carveWeight() > 0.0); - assertTrue(sample.alongReach() >= 0.0 && sample.alongReach() <= 1.0); - } - } - - assertTrue(merged); - assertTrue(sampled); - assertThrows(UnsupportedOperationException.class, () -> tile.reaches().add(tile.reaches().getFirst())); - } - - @Test - public void candidateAnchorsAreStableUniqueAndOwnedByOneTile() { - RiverNetwork network = new RiverNetwork(options(9L).build()); - RiverTile tile = network.buildTile(-1, -1, slopedTerrain(false)); - List first = tile.candidateAnchors(24.0, 77L); - List second = tile.candidateAnchors(24.0, 77L); - Set identities = new HashSet<>(); - - assertEquals(first, second); - for (RiverAnchor anchor : first) { - assertTrue(identities.add(anchor.stableId())); - assertTrue(anchor.x() >= tile.minimumX() && anchor.x() < tile.maximumX()); - assertTrue(anchor.z() >= tile.minimumZ() && anchor.z() < tile.maximumZ()); - assertTrue(anchor.alongReach() >= 0.0 && anchor.alongReach() <= 1.0); - } - assertThrows(UnsupportedOperationException.class, () -> first.add(first.getFirst())); - } - - @Test - public void spatialIndexReducesColumnCandidatesAndBoundsAnchorQueries() { - RiverNetwork network = new RiverNetwork(options(51L).tileCells(8).build()); - RiverTile tile = network.buildTile(0, 0, slopedTerrain(false)); - int minimumCandidates = Integer.MAX_VALUE; - for (int x = tile.minimumX() + 32; x < tile.maximumX(); x += 64) { - for (int z = tile.minimumZ() + 32; z < tile.maximumZ(); z += 64) { - minimumCandidates = StrictMath.min(minimumCandidates, tile.sampleCandidateCount(x, z)); - } - } - assertTrue(tile.reaches().size() > 8); - assertTrue(minimumCandidates < tile.reaches().size() / 2); - - double queryMaximumX = tile.minimumX() + 64.0; - double queryMaximumZ = tile.minimumZ() + 64.0; - List anchors = tile.candidateAnchors( - tile.minimumX(), - tile.minimumZ(), - queryMaximumX, - queryMaximumZ, - 12.0, - 12L - ); - for (RiverAnchor anchor : anchors) { - assertTrue(anchor.x() >= tile.minimumX() && anchor.x() < queryMaximumX); - assertTrue(anchor.z() >= tile.minimumZ() && anchor.z() < queryMaximumZ); - } - } - - @Test - public void sampleReportsNormalizedClosestPositionAndTerminalMetadata() { - RiverNode from = new RiverNode( - new RiverNodeId(0L, 0L), - 0.0, - 0.0, - 20.0, - 20.0, - 20.0, - 20.0, - false, - true - ); - RiverNode to = new RiverNode( - new RiverNodeId(1L, 0L), - 100.0, - 0.0, - 10.0, - 10.0, - 10.0, - 10.0, - false, - true - ); - RiverReach reach = new RiverReach( - RiverEdgeId.of(from.id(), to.id()), - from, - to, - RiverRouteState.DRY, - 1, - 1, - 10.0, - 5.0, - 3.0, - RiverBodyProfile.constant(10.0, 5.0, 3.0), - false, - true, - new RiverPolyline(new double[]{0.0, 100.0}, new double[]{0.0, 0.0}) - ); - RiverTile tile = new RiverTile(0, 0, 0, -32, 128, 32, List.of(reach)); - - RiverSample sample = tile.sample(75.0, 0.0); - assertTrue(sample.present()); - assertEquals(0.75, sample.alongReach(), 0.0000001); - assertEquals(RiverSection.DRY_CHANNEL, sample.section()); - assertTrue(sample.terminal()); - } - - @Test - public void uncoveredNarrowReachDoesNotMaskCoveringWideReach() { - RiverNode narrowFrom = node(0L, 0L, 0D, 0D); - RiverNode narrowTo = node(1L, 0L, 100D, 0D); - RiverNode wideFrom = node(0L, 1L, 0D, 4D); - RiverNode wideTo = node(1L, 1L, 100D, 4D); - RiverReach narrow = reach(narrowFrom, narrowTo, 2D, 0D); - RiverReach wide = reach(wideFrom, wideTo, 8D, 0D); - RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(narrow, wide)); - - RiverSample sample = tile.sample(50D, 1.5D); - - assertTrue(sample.present()); - assertEquals(wide.id(), sample.reachId()); - } - - @Test - public void footprintSamplingFindsChannelThatPointSamplingMisses() { - RiverNode from = node(0L, 0L, 0D, 0D); - RiverNode to = node(1L, 0L, 100D, 0D); - RiverReach reach = reach(from, to, 2D, 0D); - RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach)); - - assertFalse(tile.sample(50D, 2.5D).present()); - RiverSample sample = tile.sampleFootprint(48D, 0.5D, 52D, 4.5D); - - assertTrue(sample.present()); - assertEquals(RiverSection.CHANNEL, sample.section()); - assertEquals(0.5D, sample.distance(), 0D); - } - - @Test - public void footprintSamplingFindsBankThatPointSamplingMisses() { - RiverNode from = node(0L, 0L, 0D, 0D); - RiverNode to = node(1L, 0L, 100D, 0D); - RiverReach reach = reach(from, to, 2D, 3D); - RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach)); - - assertFalse(tile.sample(50D, 5D).present()); - RiverSample sample = tile.sampleFootprint(48D, 2D, 52D, 8D); - - assertTrue(sample.present()); - assertEquals(RiverSection.BANK, sample.section()); - assertEquals(2D, sample.distance(), 0D); - } - - @Test - public void footprintSamplingRejectsBoundingBoxOnlyCornerOverlap() { - RiverNode from = node(0L, 0L, 0D, 0D); - RiverNode to = node(1L, 0L, 100D, 0D); - RiverReach reach = reach(from, to, 1D, 2D); - RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach)); - - RiverSample sample = tile.sampleFootprint(101.8D, 1.8D, 104D, 4D); - - assertFalse(sample.present()); - } - - @Test - public void terrainSamplerDimensionsAreClampedAfterOrderScaling() { - RiverNetwork network = new RiverNetwork(options(61L) - .maxChannelWidth(10D) - .maxBankWidth(3D) - .maxDepth(6D) - .worms(List.of(worm(1L, 0D, 0D, 0D, 1))) - .build()); - RiverTerrainSampler styled = new TestTerrain(false) { - @Override - public double channelWidth(RiverRoutingContext context, double fallback) { - return 20.0; - } - - @Override - public double bankWidth(RiverRoutingContext context, double fallback) { - return 9.0; - } - - @Override - public double depth(RiverRoutingContext context, double fallback) { - return 7.0; - } - }; - RiverTile tile = network.buildTile(0, 0, styled); - RiverReach reach = tile.reaches().getFirst(); - double deltaX = reach.to().x() - reach.from().x(); - double deltaZ = reach.to().z() - reach.from().z(); - - assertEquals(10D, reach.width(), 0D); - assertEquals(3D, reach.bankWidth(), 0D); - assertEquals(6D, reach.depth(), 0D); - for (int point = 0; point < reach.polyline().size(); point++) { - double pointDeltaX = reach.polyline().x(point) - reach.from().x(); - double pointDeltaZ = reach.polyline().z(point) - reach.from().z(); - assertEquals(0.0, pointDeltaX * deltaZ - pointDeltaZ * deltaX, 0.0000001); - } - } - - @Test - public void bodyAnatomyVariesInsideOneReachAndRemainsSpatiallyQueryable() { - RiverNode from = node(0L, 0L, 0D, 0D); - RiverNode to = node(1L, 0L, 100D, 0D); - RiverBodyProfile profile = new RiverBodyProfile( - new double[]{0D, 0.5D, 1D}, - new double[]{1D, 7D, 1D}, - new double[]{1D, 9D, 1D}, - new double[]{2D, 8D, 2D}, - new double[]{1D, 0.35D, 1D} - ); - RiverReach reach = new RiverReach( - RiverEdgeId.of(from.id(), to.id()), - from, - to, - RiverRouteState.WET, - 1, - 1, - 7D, - 9D, - 8D, - profile, - false, - false, - new RiverPolyline(new double[]{0D, 100D}, new double[]{0D, 0D}) - ); - RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach)); - - RiverSample narrow = tile.sample(10D, 7D); - RiverSample swollen = tile.sample(50D, 7D); - - assertFalse(narrow.present()); - assertTrue(swollen.present()); - assertEquals(7D, swollen.width(), 0D); - assertEquals(9D, swollen.bankWidth(), 0D); - assertEquals(8D, swollen.depth(), 0D); - assertEquals(0.35D, reach.roofScaleAt(swollen.alongReach()), 0.0000001D); - assertEquals(RiverSection.BANK, swollen.section()); - } - - @Test - public void perlinWormBodyAnatomyVariesAllDimensionsDeterministically() { - RiverWorm anatomy = wormProfile( - "anatomy", - 917L, - 24, - 1D, - 1D, - 1D, - 64D, - 32D, - 0.875D, - 0.875D, - 0.875D, - 0.875D, - 4, - 0.35D, - 1D, - 0D, - 0D, - List.of() - ); - RiverNetwork network = new RiverNetwork(options(917L) - .requireOcean(false) - .maxChannelWidth(64D) - .maxBankWidth(64D) - .maxDepth(64D) - .maximumReachRadius(96D) - .worms(List.of(anatomy)) - .build()); - RiverTerrainSampler terrain = flatTerrain(false); - - RiverTile first = network.buildTile(0, 0, terrain); - RiverTile second = network.buildTile(0, 0, terrain); - - assertFalse(first.reaches().isEmpty()); - assertEquals(digest(first), digest(second)); - double minimumWidth = Double.POSITIVE_INFINITY; - double maximumWidth = 0D; - double minimumBank = Double.POSITIVE_INFINITY; - double maximumBank = 0D; - double minimumDepth = Double.POSITIVE_INFINITY; - double maximumDepth = 0D; - double minimumRoof = Double.POSITIVE_INFINITY; - double maximumRoof = 0D; - for (RiverReach reach : first.reaches()) { - for (int index = 0; index < reach.bodyProfile().size(); index++) { - minimumWidth = StrictMath.min(minimumWidth, reach.bodyProfile().widthAtIndex(index)); - maximumWidth = StrictMath.max(maximumWidth, reach.bodyProfile().widthAtIndex(index)); - minimumBank = StrictMath.min(minimumBank, reach.bodyProfile().bankWidthAtIndex(index)); - maximumBank = StrictMath.max(maximumBank, reach.bodyProfile().bankWidthAtIndex(index)); - minimumDepth = StrictMath.min(minimumDepth, reach.bodyProfile().depthAtIndex(index)); - maximumDepth = StrictMath.max(maximumDepth, reach.bodyProfile().depthAtIndex(index)); - minimumRoof = StrictMath.min(minimumRoof, reach.bodyProfile().roofScaleAtIndex(index)); - maximumRoof = StrictMath.max(maximumRoof, reach.bodyProfile().roofScaleAtIndex(index)); - } - } - assertTrue(maximumWidth - minimumWidth > 2D); - assertTrue(maximumBank - minimumBank > 1.5D); - assertTrue(maximumDepth - minimumDepth > 0.75D); - assertTrue(maximumRoof - minimumRoof > 0.1D); - } - - @Test - public void volatileBodyProfilesResolveSubTenBlockThicknessChanges() { - RiverWorm volatileBody = new RiverWorm( - "volatile-body", - 4815L, - 1D, - 1024D, - 128D, - 0.65D, - 0.2D, - 180D, - 48, - 1D, - 1D, - 1D, - 24D, - 8D, - 0.95D, - 0.875D, - 0.75D, - 0.75D, - 0.75D, - 4, - 0.35D, - 1D, - 0D, - 0D, - List.of() - ); - RiverNetwork network = new RiverNetwork(options(4815L) - .cellSize(1700) - .tileCells(1) - .siteJitter(0D) - .requireOcean(false) - .channelWidth(14D) - .maxChannelWidth(38D) - .maximumReachRadius(64D) - .worms(List.of(volatileBody)) - .build()); - - RiverReach reach = network.buildTile(0, 0, flatTerrain(false)).reaches().getFirst(); - RiverBodyProfile profile = reach.bodyProfile(); - double maximumStationSpacing = 0D; - for (int index = 1; index < profile.size(); index++) { - maximumStationSpacing = StrictMath.max( - maximumStationSpacing, - reach.polyline().length() * (profile.position(index) - profile.position(index - 1)) - ); - } - boolean volatileChangeFound = false; - for (double distance = 0D; distance + 10D <= reach.polyline().length(); distance += 2D) { - double start = distance / reach.polyline().length(); - double end = (distance + 10D) / reach.polyline().length(); - if (StrictMath.abs(profile.width(start) - profile.width(end)) >= 2D) { - volatileChangeFound = true; - break; - } - } - - assertTrue(maximumStationSpacing <= 10D); - assertTrue(volatileChangeFound); - } - - @Test - public void channelRadiusBonusAddsThreeBlocksPerSideAfterShaping() { - RiverWorm constantBody = wormProfile( - "radius-bonus", - 6501L, - 8, - 1D, - 1D, - 1D, - 64D, - 16D, - 0D, - 0D, - 0D, - 0D, - 4, - 0.35D, - 1D, - 0D, - 0D, - List.of() - ); - RiverNetwork baseline = new RiverNetwork(options(6501L) - .siteJitter(0D) - .requireOcean(false) - .channelWidth(10D) - .maxChannelWidth(38D) - .maximumReachRadius(64D) - .worms(List.of(constantBody)) - .build()); - RiverNetwork expanded = new RiverNetwork(options(6501L) - .siteJitter(0D) - .requireOcean(false) - .channelWidth(10D) - .channelRadiusBonus(3D) - .maxChannelWidth(38D) - .maximumReachRadius(64D) - .worms(List.of(constantBody)) - .build()); - - RiverReach baselineReach = baseline.buildTile(0, 0, flatTerrain(false)).reaches().getFirst(); - RiverReach expandedReach = expanded.buildTile(0, 0, flatTerrain(false)).reaches().getFirst(); - - assertEquals(baselineReach.id(), expandedReach.id()); - assertEquals(6D, expandedReach.bodyProfile().width(0.5D) - - baselineReach.bodyProfile().width(0.5D), 0.0000001D); - } - - @Test - public void foldedReachSamplingFindsFartherCoveringWidthEnvelope() { - RiverNode from = node(0L, 0L, 0D, 0D); - RiverNode to = node(1L, 0L, 0D, 10D); - RiverBodyProfile profile = new RiverBodyProfile( - new double[]{0D, 0.48D, 0.53D, 1D}, - new double[]{1D, 1D, 18D, 18D}, - new double[]{0D, 0D, 0D, 0D}, - new double[]{3D, 3D, 3D, 3D}, - new double[]{1D, 1D, 1D, 1D} - ); - RiverReach reach = new RiverReach( - RiverEdgeId.of(from.id(), to.id()), - from, - to, - RiverRouteState.WET, - 1, - 1, - 18D, - 0D, - 3D, - profile, - false, - false, - new RiverPolyline( - new double[]{0D, 100D, 100D, 0D}, - new double[]{0D, 0D, 10D, 10D} - ) - ); - RiverTile tile = new RiverTile(0, 0, 0, -16, 128, 32, List.of(reach)); - - RiverSample point = tile.sample(50D, 2D); - RiverSample footprint = tile.sampleFootprint(49D, 1.5D, 51D, 2.5D); - - assertTrue(point.present()); - assertTrue(point.alongReach() > 0.53D); - assertEquals(18D, point.width(), 0.000001D); - assertTrue(footprint.present()); - assertTrue(footprint.alongReach() > 0.53D); - } - - @Test - public void reachFeasibilityReceivesTheFinalPinnedWormPolyline() { - RiverNetwork network = new RiverNetwork(options(62L) - .worms(List.of(worm(62L, 0.8D, 0.2D, 20D, 32))) - .build()); - boolean[] observedWorm = new boolean[1]; - RiverTerrainSampler terrain = new TestTerrain(false) { - @Override - public boolean allowsReach(RiverRoutingContext context) { - RiverPolyline polyline = context.polyline(); - double deltaX = context.to().x() - context.from().x(); - double deltaZ = context.to().z() - context.from().z(); - for (int point = 1; point < polyline.size() - 1; point++) { - double pointX = polyline.x(point) - context.from().x(); - double pointZ = polyline.z(point) - context.from().z(); - if (StrictMath.abs(pointX * deltaZ - pointZ * deltaX) > 0.000001D) { - observedWorm[0] = true; - return false; - } - } - return true; - } - }; - - RiverNode downstream = network.downstream(new RiverNodeId(0L, 0L), terrain); - - assertTrue(observedWorm[0]); - assertEquals(null, downstream); - } - - @Test - public void wormSeedChangesDeterministicGeometryWithoutBreakingItsEnvelope() { - double maximumOffset = 20D; - RiverNetwork firstNetwork = new RiverNetwork(options(63L) - .siteJitter(0D) - .requireOcean(false) - .worms(List.of(worm(101L, 0.8D, 0.2D, maximumOffset, 32))) - .build()); - RiverNetwork secondNetwork = new RiverNetwork(options(63L) - .siteJitter(0D) - .requireOcean(false) - .worms(List.of(worm(202L, 0.8D, 0.2D, maximumOffset, 32))) - .build()); - RiverTerrainSampler terrain = new TestTerrain(false); - - RiverReach firstReach = firstNetwork.buildTile(0, 0, terrain).reaches().getFirst(); - RiverReach repeatedReach = firstNetwork.buildTile(0, 0, terrain).reaches().getFirst(); - RiverReach secondReach = secondNetwork.buildTile(0, 0, terrain).reaches().getFirst(); - - assertReachEquals(firstReach, repeatedReach); - assertEquals(firstReach.id(), secondReach.id()); - assertTrue(polylinesDiffer(firstReach.polyline(), secondReach.polyline())); - assertWormBounds(firstReach, maximumOffset); - assertWormBounds(secondReach, maximumOffset); - } - - @Test - public void weightedWormProfilesSelectDistinctConfigurableVariants() { - RiverWorm gentle = new RiverWorm( - "gentle", 301L, 1D, 1024D, 256D, 0.2D, 0.05D, 10D, 8, 0.5D, 0.5D, 0.5D, - 512D, 128D, 0.3D, 0D, 0D, 0D, 0D, - 4, 0.35D, 1D, 0D, 0D, List.of() - ); - RiverWorm winding = new RiverWorm( - "winding", 302L, 1D, 512D, 128D, 0.55D, 0.15D, 20D, 16, 1D, 1D, 1D, - 512D, 128D, 0.3D, 0D, 0D, 0D, 0D, - 4, 0.35D, 1D, 0D, 0D, List.of() - ); - RiverWorm restless = new RiverWorm( - "restless", 303L, 1D, 192D, 48D, 0.9D, 0.35D, 30D, 32, 2D, 2D, 2D, - 512D, 128D, 0.3D, 0D, 0D, 0D, 0D, - 4, 0.35D, 1D, 0D, 0D, List.of() - ); - RiverNetwork network = new RiverNetwork(options(64L) - .siteJitter(0D) - .routingBasinCells(8) - .requireOcean(false) - .maxChannelWidth(32D) - .maxBankWidth(32D) - .maxDepth(32D) - .orderWidthFactor(0D) - .orderDepthFactor(0D) - .maximumReachRadius(32D) - .worms(List.of(gentle, winding, restless)) - .build()); - RiverTerrainSampler terrain = new TestTerrain(false); - - HashMap uniqueReaches = new HashMap<>(); - for (int tileX = -2; tileX <= 2; tileX++) { - for (int tileZ = -2; tileZ <= 2; tileZ++) { - for (RiverReach reach : network.buildTile(tileX, tileZ, terrain).reaches()) { - uniqueReaches.putIfAbsent(reach.id(), reach); - } - } - } - Set selectedPointCounts = new HashSet<>(); - HashMap maximumDisplacementRatios = new HashMap<>(); - for (RiverReach reach : uniqueReaches.values()) { - int pointCount = reach.polyline().size(); - selectedPointCounts.add(pointCount); - RiverWorm selected = switch (pointCount) { - case 9 -> gentle; - case 17 -> winding; - case 33 -> restless; - default -> throw new AssertionError("Unexpected worm point count " + pointCount); - }; - assertEquals(8D * selected.widthMultiplier(), reach.width(), 0.0000001D); - assertEquals(6D * selected.bankMultiplier(), reach.bankWidth(), 0.0000001D); - assertEquals(3D * selected.depthMultiplier(), reach.depth(), 0.0000001D); - assertWormBounds(reach, selected.maxOffset()); - maximumDisplacementRatios.merge( - pointCount, - maximumWormDisplacement(reach) / selected.maxOffset(), - StrictMath::max - ); - } - assertEquals(Set.of(9, 17, 33), selectedPointCounts); - assertTrue(maximumDisplacementRatios.toString(), maximumDisplacementRatios.get(9) > 0.03D); - assertTrue(maximumDisplacementRatios.toString(), maximumDisplacementRatios.get(17) > 0.18D); - assertTrue(maximumDisplacementRatios.toString(), maximumDisplacementRatios.get(33) > 0.4D); - } - - @Test - public void forcedChildHierarchyControlsReachGeometryAndDimensionsDeterministically() { - RiverWorm child = wormProfile( - "child", - 402L, - 24, - 2D, - 1.5D, - 0.5D, - 8, - 1D, - 1D, - 0D, - 0D, - List.of() - ); - RiverWorm root = wormProfile( - "root", - 401L, - 8, - 0.5D, - 0.5D, - 0.5D, - 8, - 1D, - 1D, - 1D, - 0D, - List.of(child) - ); - RiverNetwork network = new RiverNetwork(options(68L) - .siteJitter(0D) - .requireOcean(false) - .maxChannelWidth(32D) - .maxBankWidth(32D) - .maxDepth(32D) - .orderWidthFactor(0D) - .orderDepthFactor(0D) - .maximumReachRadius(32D) - .worms(List.of(root)) - .build()); - RiverTerrainSampler terrain = new TestTerrain(false); - - RiverTile first = network.buildTile(0, 0, terrain); - RiverTile second = network.buildTile(0, 0, terrain); - - assertFalse(first.reaches().isEmpty()); - assertEquals(digest(first), digest(second)); - for (RiverReach reach : first.reaches()) { - assertEquals(child.segments() + 1, reach.polyline().size()); - assertEquals(8D * child.widthMultiplier(), reach.width(), 0.0000001D); - assertEquals(6D * child.bankMultiplier(), reach.bankWidth(), 0.0000001D); - assertEquals(3D * child.depthMultiplier(), reach.depth(), 0.0000001D); - assertWormBounds(reach, child.maxOffset()); - } - } - - @Test - public void wormHierarchyRejectsInvalidIdsDepthAndCounts() { - assertThrows(IllegalArgumentException.class, () -> wormProfile( - "Invalid", - 1L, - 8, - 1D, - 1D, - 1D, - 512D, - 128D, - 0D, - 0D, - 0D, - 0D, - 4, - 0.35D, - 1D, - 0D, - 0D, - List.of() - )); - - RiverWorm duplicateChild = wormProfile( - "duplicate", 2L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 0D, List.of() - ); - RiverWorm duplicateRoot = wormProfile( - "duplicate", 3L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 1D, 0D, List.of(duplicateChild) - ); - assertThrows( - IllegalArgumentException.class, - () -> options(10L).worms(List.of(duplicateRoot)).build() - ); - - RiverWorm tooDeep = wormProfile( - "depth-5", 5L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 0D, List.of() - ); - for (int depth = 4; depth >= 1; depth--) { - tooDeep = wormProfile( - "depth-" + depth, - depth, - 8, - 1D, - 1D, - 1D, - 4, - 0.35D, - 1D, - 1D, - 0D, - List.of(tooDeep) - ); - } - RiverWorm depthRoot = tooDeep; - assertThrows( - IllegalArgumentException.class, - () -> options(10L).worms(List.of(depthRoot)).build() - ); - - ArrayList roots = new ArrayList<>(); - for (int root = 0; root < 17; root++) { - roots.add(wormProfile( - "root-" + root, root, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 0D, List.of() - )); - } - assertThrows(IllegalArgumentException.class, () -> options(10L).worms(roots).build()); - - ArrayList branches = new ArrayList<>(); - for (int branch = 0; branch < 8; branch++) { - ArrayList leaves = new ArrayList<>(); - for (int leaf = 0; leaf < 16; leaf++) { - leaves.add(wormProfile( - "leaf-" + branch + "-" + leaf, - branch * 16L + leaf, - 8, - 1D, - 1D, - 1D, - 4, - 0.35D, - 1D, - 0D, - 0D, - List.of() - )); - } - branches.add(wormProfile( - "branch-" + branch, - branch, - 8, - 1D, - 1D, - 1D, - 4, - 0.35D, - 1D, - 1D, - 0D, - leaves - )); - } - RiverWorm oversized = wormProfile( - "oversized", 900L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 1D, 0D, branches - ); - assertThrows( - IllegalArgumentException.class, - () -> options(10L).worms(List.of(oversized)).build() - ); - } - - @Test - public void wormProfileRejectsInvalidBranchControls() { - assertThrows(IllegalArgumentException.class, () -> wormProfile( - "cap", 1L, 8, 1D, 1D, 1D, 0, 0.35D, 1D, 0D, 0D, List.of() - )); - assertThrows(IllegalArgumentException.class, () -> wormProfile( - "decay", 1L, 8, 1D, 1D, 1D, 4, 1.1D, 1D, 0D, 0D, List.of() - )); - assertThrows(IllegalArgumentException.class, () -> wormProfile( - "confluence", 1L, 8, 1D, 1D, 1D, 4, 0.35D, 8.1D, 0D, 0D, List.of() - )); - assertThrows(IllegalArgumentException.class, () -> wormProfile( - "child-chance", 1L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 1.1D, 0D, List.of() - )); - assertThrows(IllegalArgumentException.class, () -> wormProfile( - "sibling-chance", 1L, 8, 1D, 1D, 1D, 4, 0.35D, 1D, 0D, 1.1D, List.of() - )); - } - - @Test - public void drainageDomainWarpChangesRoutesDeterministically() { - RiverTerrainSampler terrain = flatTerrain(false); - RiverNetwork straight = new RiverNetwork(options(65L) - .requireOcean(false) - .routingDeviationStrengthCells(0D) - .build()); - RiverNetwork deviated = new RiverNetwork(options(65L) - .requireOcean(false) - .routingDeviationScaleCells(8) - .routingDeviationStrengthCells(4D) - .build()); - boolean changed = false; - for (long cellX = -8L; cellX <= 8L; cellX++) { - for (long cellZ = -8L; cellZ <= 8L; cellZ++) { - RiverNode straightDownstream = straight.downstream(new RiverNodeId(cellX, cellZ), terrain); - RiverNode deviatedDownstream = deviated.downstream(new RiverNodeId(cellX, cellZ), terrain); - if (!Objects.equals( - straightDownstream == null ? null : straightDownstream.id(), - deviatedDownstream == null ? null : deviatedDownstream.id() - )) { - changed = true; - } - RiverNode repeated = deviated.downstream(new RiverNodeId(cellX, cellZ), terrain); - assertEquals( - deviatedDownstream == null ? null : deviatedDownstream.id(), - repeated == null ? null : repeated.id() - ); - } - } - assertTrue(changed); - } - - @Test - public void confluenceAttractionChangesDownstreamChoicesDeterministically() { - RiverTerrainSampler terrain = flatTerrain(false); - RiverNetwork dispersed = new RiverNetwork(options(67L) - .requireOcean(false) - .confluenceWeight(0D) - .build()); - RiverNetwork branching = new RiverNetwork(options(67L) - .requireOcean(false) - .confluenceWeight(512D) - .build()); - boolean changed = false; - for (long cellX = -8L; cellX <= 8L; cellX++) { - for (long cellZ = -8L; cellZ <= 8L; cellZ++) { - RiverNodeId id = new RiverNodeId(cellX, cellZ); - RiverNode dispersedDownstream = dispersed.downstream(id, terrain); - RiverNode branchingDownstream = branching.downstream(id, terrain); - if (!Objects.equals( - dispersedDownstream == null ? null : dispersedDownstream.id(), - branchingDownstream == null ? null : branchingDownstream.id() - )) { - changed = true; - } - RiverNode repeated = branching.downstream(id, terrain); - assertEquals( - branchingDownstream == null ? null : branchingDownstream.id(), - repeated == null ? null : repeated.id() - ); - } - } - assertTrue(changed); - } - - @Test - public void optionsRejectNonFiniteOrUnsafeValues() { - assertThrows(IllegalArgumentException.class, () -> options(10L).sourceChance(Double.NaN).build()); - assertThrows(IllegalArgumentException.class, () -> options(10L).cellSize(0).build()); - assertThrows(IllegalArgumentException.class, () -> options(10L).routingBasinCells(7).build()); - assertThrows(IllegalArgumentException.class, () -> options(10L).routingPlateauHeight(0D).build()); - assertThrows(IllegalArgumentException.class, () -> options(10L).hydraulicBaseHeight(Double.NaN).build()); - assertThrows(IllegalArgumentException.class, () -> options(10L).confluenceWeight(-1D).build()); - assertThrows(IllegalArgumentException.class, () -> options(10L).channelWidth(-1.0).build()); - assertThrows(IllegalArgumentException.class, () -> options(10L) - .tileCells(1) - .minimumSourcesPerTile(2) - .build()); - } - - @Test - public void derivedComplexityAllowsDefaultsAndRejectsExpensiveTopologies() { - double defaultRadius = 10D * 0.5D + 4D; - RiverTopologyComplexity.Estimate defaults = RiverTopologyComplexity.estimate( - 768, - 4, - 0.35D, - 4, - defaultRadius, - 72D, - 8 - ); - assertTrue(defaults.violations().toString(), defaults.safe()); - assertEquals(2L, defaults.geometryPaddingCells()); - assertEquals(16L, defaults.sourceWindowAxis()); - assertEquals(256L, defaults.sourceWindowCells()); - assertEquals(1_024L, defaults.maximumRouteScanSteps()); - - double expensiveRadius = defaultRadius; - RiverTopologyComplexity.Estimate expensive = RiverTopologyComplexity.estimate( - 768, - 4, - 0.35D, - 48, - expensiveRadius, - 72D, - 8 - ); - assertFalse(expensive.safe()); - assertTrue(expensive.violations().toString(), expensive.maximumRouteScanSteps() - > RiverTopologyComplexity.MAXIMUM_ROUTE_SCAN_STEPS); - - double pathologicalRadius = 2048D * 0.5D + 2048D; - RiverTopologyComplexity.Estimate pathological = RiverTopologyComplexity.estimate( - 64, - 64, - 0.49D, - 256, - pathologicalRadius, - 1024D, - 64 - ); - assertFalse(pathological.safe()); - assertTrue(pathological.sourceWindowCells() > 400_000L); - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> options(10L) - .cellSize(64) - .tileCells(64) - .siteJitter(0.49D) - .maxRouteReaches(256) - .maximumReachRadius(pathologicalRadius) - .worms(List.of(worm(1L, 1D, 1D, 1024D, 64))) - .build() - ); - assertTrue(failure.getMessage(), failure.getMessage().contains("source window")); - } - - private static RiverNetworkOptions.Builder options(long seed) { - return RiverNetworkOptions.builder(seed) - .cellSize(64) - .tileCells(2) - .siteJitter(0.25) - .maxRouteReaches(12) - .sourceChance(1.0) - .reachChance(1.0) - .dryChannelChance(1.0) - .requireOcean(true) - .terrainHeightWeight(1.0) - .routingNoiseWeight(0.0) - .oceanAttraction(256.0) - .channelWidth(8.0) - .bankWidth(6.0) - .depth(3.0) - .worms(List.of(worm(1L, 0.5D, 0.15D, 12D, 6))); - } - - private static RiverWorm worm( - long seed, - double tortuosity, - double detailTortuosity, - double maximumOffset, - int segments - ) { - return new RiverWorm( - "worm-" + Long.toUnsignedString(seed), - seed, - 1D, - 1024D, - 256D, - tortuosity, - detailTortuosity, - maximumOffset, - segments, - 1D, - 1D, - 1D, - 512D, - 128D, - 0.3D, - 0D, - 0D, - 0D, - 0D, - 4, - 0.35D, - 1D, - 0D, - 0D, - List.of() - ); - } - - private static RiverWorm wormProfile( - String id, - long seed, - int segments, - double widthMultiplier, - double bankMultiplier, - double depthMultiplier, - int branchCap, - double branchDecay, - double confluenceMultiplier, - double childChance, - double branchChildChance, - List children - ) { - return wormProfile( - id, - seed, - segments, - widthMultiplier, - bankMultiplier, - depthMultiplier, - 512D, - 128D, - 0D, - 0D, - 0D, - 0D, - branchCap, - branchDecay, - confluenceMultiplier, - childChance, - branchChildChance, - children - ); - } - - private static RiverWorm wormProfile( - String id, - long seed, - int segments, - double widthMultiplier, - double bankMultiplier, - double depthMultiplier, - double bodyWavelength, - double bodyDetailWavelength, - double widthVariation, - double bankVariation, - double depthVariation, - double roofVariation, - int branchCap, - double branchDecay, - double confluenceMultiplier, - double childChance, - double branchChildChance, - List children - ) { - return new RiverWorm( - id, - seed, - 1D, - 256D, - 64D, - 0.7D, - 0.2D, - 32D, - segments, - widthMultiplier, - bankMultiplier, - depthMultiplier, - bodyWavelength, - bodyDetailWavelength, - 0.3D, - widthVariation, - bankVariation, - depthVariation, - roofVariation, - branchCap, - branchDecay, - confluenceMultiplier, - childChance, - branchChildChance, - children - ); - } - - private static RiverNode node(long cellX, long cellZ, double x, double z) { - return new RiverNode( - new RiverNodeId(cellX, cellZ), - x, - z, - 64D, - 64D, - 64D, - 64D, - false, - true - ); - } - - private static RiverReach reach(RiverNode from, RiverNode to, double width, double bankWidth) { - return new RiverReach( - RiverEdgeId.of(from.id(), to.id()), - from, - to, - RiverRouteState.WET, - 1, - 1, - width, - bankWidth, - 3D, - RiverBodyProfile.constant(width, bankWidth, 3D), - false, - false, - new RiverPolyline( - new double[]{from.x(), to.x()}, - new double[]{from.z(), to.z()} - ) - ); - } - - private static RiverTerrainSampler slopedTerrain(boolean ocean) { - return new TestTerrain(ocean); - } - - private static RiverTerrainSampler flatTerrain(boolean ocean) { - return new RiverTerrainSampler() { - @Override - public double naturalHeight(int blockX, int blockZ) { - return 64.0; - } - - @Override - public boolean isOcean(int blockX, int blockZ) { - return ocean && blockX >= 224; - } - }; - } - - private static int compareRank(RiverNode first, RiverNode second) { - if (first.ocean() != second.ocean()) { - return first.ocean() ? -1 : 1; - } - int comparison = Double.compare(first.rank(), second.rank()); - if (comparison != 0) { - return comparison; - } - int hydraulicComparison = Double.compare(first.hydraulicHeight(), second.hydraulicHeight()); - return hydraulicComparison != 0 ? hydraulicComparison : first.id().compareTo(second.id()); - } - - private static void assertWormBounds(RiverReach reach, double maximumOffset) { - assertTrue(maximumWormDisplacement(reach) <= maximumOffset + 0.0000001D); - RiverPolyline polyline = reach.polyline(); - assertEquals(reach.from().x(), polyline.x(0), 0D); - assertEquals(reach.from().z(), polyline.z(0), 0D); - int last = polyline.size() - 1; - assertEquals(reach.to().x(), polyline.x(last), 0D); - assertEquals(reach.to().z(), polyline.z(last), 0D); - } - - private static double maximumWormDisplacement(RiverReach reach) { - RiverPolyline polyline = reach.polyline(); - double deltaX = reach.to().x() - reach.from().x(); - double deltaZ = reach.to().z() - reach.from().z(); - double maximumDisplacement = 0D; - for (int point = 0; point < polyline.size(); point++) { - double t = (double) point / (polyline.size() - 1); - double straightX = reach.from().x() + deltaX * t; - double straightZ = reach.from().z() + deltaZ * t; - double displacement = StrictMath.hypot( - polyline.x(point) - straightX, - polyline.z(point) - straightZ - ); - maximumDisplacement = StrictMath.max(maximumDisplacement, displacement); - } - return maximumDisplacement; - } - - private static boolean polylinesDiffer(RiverPolyline first, RiverPolyline second) { - if (first.size() != second.size()) { - return true; - } - for (int point = 0; point < first.size(); point++) { - if (Double.doubleToLongBits(first.x(point)) != Double.doubleToLongBits(second.x(point)) - || Double.doubleToLongBits(first.z(point)) != Double.doubleToLongBits(second.z(point))) { - return true; - } - } - return false; - } - - private static long digest(RiverTile tile) { - long hash = 0xCBF29CE484222325L; - for (RiverReach reach : tile.reaches()) { - hash = RiverNetwork.mix(hash ^ reach.id().stableId()); - hash = digestNode(hash, reach.from()); - hash = digestNode(hash, reach.to()); - hash = RiverNetwork.mix(hash ^ reach.flow()); - hash = RiverNetwork.mix(hash ^ reach.order()); - hash = RiverNetwork.mix(hash ^ reach.state().ordinal()); - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.width())); - for (int index = 0; index < reach.bodyProfile().size(); index++) { - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().position(index))); - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().widthAtIndex(index))); - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().bankWidthAtIndex(index))); - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().depthAtIndex(index))); - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bodyProfile().roofScaleAtIndex(index))); - } - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.bankWidth())); - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.depth())); - hash = RiverNetwork.mix(hash ^ (reach.mouth() ? 1L : 0L)); - hash = RiverNetwork.mix(hash ^ (reach.terminal() ? 1L : 0L)); - for (int point = 0; point < reach.polyline().size(); point++) { - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.polyline().x(point))); - hash = RiverNetwork.mix(hash ^ Double.doubleToLongBits(reach.polyline().z(point))); - } - } - return hash; - } - - private static long digestNode(long hash, RiverNode node) { - long result = RiverNetwork.mix(hash ^ node.id().stableId()); - result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.x())); - result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.z())); - result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.naturalHeight())); - result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.hydraulicHeight())); - result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.rank())); - result = RiverNetwork.mix(result ^ Double.doubleToLongBits(node.routingScore())); - result = RiverNetwork.mix(result ^ (node.ocean() ? 1L : 0L)); - return RiverNetwork.mix(result ^ (node.riverAllowed() ? 1L : 0L)); - } - - private static void assertReachEquals(RiverReach first, RiverReach second) { - assertEquals(first.id(), second.id()); - assertEquals(first.state(), second.state()); - assertEquals(first.flow(), second.flow()); - assertEquals(first.order(), second.order()); - assertEquals(first.width(), second.width(), 0.0); - assertEquals(first.bodyProfile(), second.bodyProfile()); - assertEquals(first.bankWidth(), second.bankWidth(), 0.0); - assertEquals(first.depth(), second.depth(), 0.0); - assertEquals(first.mouth(), second.mouth()); - assertEquals(first.terminal(), second.terminal()); - assertEquals(first.from(), second.from()); - assertEquals(first.to(), second.to()); - assertEquals(first.polyline().size(), second.polyline().size()); - for (int point = 0; point < first.polyline().size(); point++) { - assertEquals(first.polyline().x(point), second.polyline().x(point), 0.0); - assertEquals(first.polyline().z(point), second.polyline().z(point), 0.0); - } - } - - private static class TestTerrain implements RiverTerrainSampler { - private final boolean ocean; - - private TestTerrain(boolean ocean) { - this.ocean = ocean; - } - - @Override - public double naturalHeight(int blockX, int blockZ) { - return 256.0 - blockX; - } - - @Override - public boolean isOcean(int blockX, int blockZ) { - return ocean && blockX >= 224; - } - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java b/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java deleted file mode 100644 index af1452c8b..000000000 --- a/core/src/test/java/art/arcane/iris/engine/river/RiverTileCacheTest.java +++ /dev/null @@ -1,306 +0,0 @@ -package art.arcane.iris.engine.river; - -import org.junit.Test; - -import java.util.List; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutionException; -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 static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertSame; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -public class RiverTileCacheTest { - @Test - public void concurrentRequestsBuildOneTileOnce() throws Exception { - int requestCount = 16; - AtomicInteger builds = new AtomicInteger(); - CountDownLatch ready = new CountDownLatch(requestCount); - CountDownLatch start = new CountDownLatch(1); - CountDownLatch builderEntered = new CountDownLatch(1); - CountDownLatch releaseBuilder = new CountDownLatch(1); - RiverTileCache cache = new RiverTileCache(8, (tileX, tileZ) -> { - builds.incrementAndGet(); - builderEntered.countDown(); - assertTrue(releaseBuilder.await(5, TimeUnit.SECONDS)); - return emptyTile(tileX, tileZ); - }); - ExecutorService executor = Executors.newFixedThreadPool(requestCount); - try { - @SuppressWarnings("unchecked") - Future[] futures = new Future[requestCount]; - for (int request = 0; request < requestCount; request++) { - futures[request] = executor.submit(() -> { - ready.countDown(); - assertTrue(start.await(5, TimeUnit.SECONDS)); - return cache.get(-7, -11); - }); - } - assertTrue(ready.await(5, TimeUnit.SECONDS)); - start.countDown(); - assertTrue(builderEntered.await(5, TimeUnit.SECONDS)); - releaseBuilder.countDown(); - - RiverTile first = futures[0].get(5, TimeUnit.SECONDS); - for (Future future : futures) { - assertSame(first, future.get(5, TimeUnit.SECONDS)); - } - assertEquals(-7, first.tileX()); - assertEquals(-11, first.tileZ()); - assertEquals(1, builds.get()); - assertEquals(1, cache.completedSize()); - } finally { - releaseBuilder.countDown(); - executor.shutdownNow(); - cache.close(); - } - } - - @Test - public void differentKeysBuildConcurrentlyOutsideCacheLock() throws Exception { - CountDownLatch buildersEntered = new CountDownLatch(2); - CountDownLatch releaseBuilders = new CountDownLatch(1); - RiverTileCache cache = new RiverTileCache(4, (tileX, tileZ) -> { - buildersEntered.countDown(); - assertTrue(releaseBuilders.await(5, TimeUnit.SECONDS)); - return emptyTile(tileX, tileZ); - }); - ExecutorService executor = Executors.newFixedThreadPool(2); - try { - Future first = executor.submit(() -> cache.get(1, 1)); - Future second = executor.submit(() -> cache.get(2, 2)); - assertTrue(buildersEntered.await(5, TimeUnit.SECONDS)); - releaseBuilders.countDown(); - assertEquals(1, first.get(5, TimeUnit.SECONDS).tileX()); - assertEquals(2, second.get(5, TimeUnit.SECONDS).tileX()); - } finally { - releaseBuilders.countDown(); - executor.shutdownNow(); - cache.close(); - } - } - - @Test - public void completedEntriesUseBoundedAccessOrderEviction() { - AtomicInteger builds = new AtomicInteger(); - RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> { - builds.incrementAndGet(); - return emptyTile(tileX, tileZ); - }); - try { - RiverTile zero = cache.get(0, 0); - RiverTile one = cache.get(1, 0); - assertSame(zero, cache.get(0, 0)); - cache.get(2, 0); - assertEquals(2, cache.completedSize()); - assertSame(zero, cache.get(0, 0)); - - RiverTile rebuiltOne = cache.get(1, 0); - assertFalse(one == rebuiltOne); - assertEquals(4, builds.get()); - assertEquals(2, cache.completedSize()); - } finally { - cache.close(); - } - } - - @Test - public void failedBuildsAreRemovedAndRetryable() { - AtomicInteger attempts = new AtomicInteger(); - RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> { - if (attempts.incrementAndGet() == 1) { - throw new IllegalArgumentException("expected failure"); - } - return emptyTile(tileX, tileZ); - }); - try { - IllegalArgumentException failure = assertThrows( - IllegalArgumentException.class, - () -> cache.get(-3, 4) - ); - assertEquals("expected failure", failure.getMessage()); - assertEquals(0, cache.completedSize()); - - RiverTile retried = cache.get(-3, 4); - assertEquals(-3, retried.tileX()); - assertEquals(2, attempts.get()); - assertEquals(1, cache.completedSize()); - } finally { - cache.close(); - } - } - - @Test - public void mismatchedBuilderResultIsRemovedAndRetryable() { - AtomicInteger attempts = new AtomicInteger(); - RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> attempts.incrementAndGet() == 1 - ? emptyTile(tileX + 1, tileZ) - : emptyTile(tileX, tileZ)); - try { - assertThrows(IllegalStateException.class, () -> cache.get(5, 6)); - RiverTile retried = cache.get(5, 6); - assertEquals(5, retried.tileX()); - assertEquals(2, attempts.get()); - } finally { - cache.close(); - } - } - - @Test - public void clearAndCloseReleaseEntriesAndEnforceLifecycle() { - AtomicInteger builds = new AtomicInteger(); - RiverTileCache cache = new RiverTileCache(4, (tileX, tileZ) -> { - builds.incrementAndGet(); - return emptyTile(tileX, tileZ); - }); - cache.get(0, 0); - cache.get(1, 0); - assertEquals(2, cache.completedSize()); - - cache.clear(); - assertEquals(0, cache.completedSize()); - cache.get(0, 0); - assertEquals(3, builds.get()); - - cache.close(); - cache.close(); - assertTrue(cache.isClosed()); - assertEquals(0, cache.completedSize()); - assertThrows(IllegalStateException.class, () -> cache.get(0, 0)); - assertThrows(IllegalStateException.class, cache::clear); - } - - @Test - public void closeInvalidatesInflightBuildWithoutRetainingItsResult() throws Exception { - CountDownLatch builderEntered = new CountDownLatch(1); - CountDownLatch releaseBuilder = new CountDownLatch(1); - RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> { - builderEntered.countDown(); - assertTrue(releaseBuilder.await(5, TimeUnit.SECONDS)); - return emptyTile(tileX, tileZ); - }); - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - Future result = executor.submit(() -> cache.get(9, -9)); - assertTrue(builderEntered.await(5, TimeUnit.SECONDS)); - cache.close(); - releaseBuilder.countDown(); - - ExecutionException failure = assertThrows( - ExecutionException.class, - () -> result.get(5, TimeUnit.SECONDS) - ); - assertTrue(failure.getCause() instanceof IllegalStateException); - assertEquals(0, cache.completedSize()); - } finally { - releaseBuilder.countDown(); - executor.shutdownNow(); - cache.close(); - } - } - - @Test - public void clearInvalidatesInflightBuildAndFreshRequestPublishesNewEntry() throws Exception { - AtomicInteger attempts = new AtomicInteger(); - CountDownLatch firstBuilderEntered = new CountDownLatch(1); - CountDownLatch releaseFirstBuilder = new CountDownLatch(1); - RiverTileCache cache = new RiverTileCache(2, (tileX, tileZ) -> { - if (attempts.incrementAndGet() == 1) { - firstBuilderEntered.countDown(); - assertTrue(releaseFirstBuilder.await(5, TimeUnit.SECONDS)); - } - return emptyTile(tileX, tileZ); - }); - ExecutorService executor = Executors.newSingleThreadExecutor(); - try { - Future invalidated = executor.submit(() -> cache.get(4, -2)); - assertTrue(firstBuilderEntered.await(5, TimeUnit.SECONDS)); - cache.clear(); - - RiverTile replacement = cache.get(4, -2); - releaseFirstBuilder.countDown(); - - ExecutionException failure = assertThrows( - ExecutionException.class, - () -> invalidated.get(5, TimeUnit.SECONDS) - ); - assertTrue(failure.getCause() instanceof IllegalStateException); - assertEquals(4, replacement.tileX()); - assertEquals(-2, replacement.tileZ()); - assertEquals(2, attempts.get()); - assertEquals(1, cache.completedSize()); - assertSame(replacement, cache.get(4, -2)); - } finally { - releaseFirstBuilder.countDown(); - executor.shutdownNow(); - cache.close(); - } - } - - @Test - public void tileProvidesConstantTimeReachIdentityLookup() { - RiverReach reach = reach(); - RiverTile tile = new RiverTile(0, 0, 0, 0, 64, 64, List.of(reach)); - - assertSame(reach, tile.reach(reach.id())); - assertNull(tile.reach(RiverEdgeId.of(new RiverNodeId(2L, 0L), new RiverNodeId(3L, 0L)))); - assertThrows( - IllegalArgumentException.class, - () -> new RiverTile(0, 0, 0, 0, 64, 64, List.of(reach, reach)) - ); - } - - private static RiverTile emptyTile(int tileX, int tileZ) { - int minimumX = tileX * 64; - int minimumZ = tileZ * 64; - return new RiverTile(tileX, tileZ, minimumX, minimumZ, minimumX + 64, minimumZ + 64, List.of()); - } - - private static RiverReach reach() { - RiverNode from = new RiverNode( - new RiverNodeId(0L, 0L), - 8.0, - 8.0, - 20.0, - 20.0, - 20.0, - 20.0, - false, - true - ); - RiverNode to = new RiverNode( - new RiverNodeId(1L, 0L), - 56.0, - 8.0, - 10.0, - 10.0, - 10.0, - 10.0, - false, - true - ); - return new RiverReach( - RiverEdgeId.of(from.id(), to.id()), - from, - to, - RiverRouteState.WET, - 1, - 1, - 8.0, - 4.0, - 3.0, - RiverBodyProfile.constant(8.0, 4.0, 3.0), - false, - false, - new RiverPolyline(new double[]{8.0, 56.0}, new double[]{8.0, 8.0}) - ); - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java deleted file mode 100644 index de2b39d7b..000000000 --- a/core/src/test/java/art/arcane/iris/engine/river/cave/RiverCaveContainmentPlannerTest.java +++ /dev/null @@ -1,757 +0,0 @@ -package art.arcane.iris.engine.river.cave; - -import org.junit.Test; - -import java.util.ArrayDeque; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Queue; -import java.util.Set; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; - -public class RiverCaveContainmentPlannerTest { - private static final List DIRECTIONS = List.of( - new CavePosition(1, 0, 0), - new CavePosition(-1, 0, 0), - new CavePosition(0, 1, 0), - new CavePosition(0, -1, 0), - new CavePosition(0, 0, 1), - new CavePosition(0, 0, -1) - ); - - private final RiverCaveContainmentPlanner planner = new RiverCaveContainmentPlanner(); - - @Test - public void closedComponentProducesConnectedThroatPoolAndGuards() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(1, 6, 0)); - RiverCaveSource source = source(14L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); - - RiverCavePlan plan = planner.plan(view, source, settings()); - - assertTrue(plan.accepted()); - assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(position(0, 12, 0))); - assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(position(0, 11, 0))); - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(1, 6, 0))); - assertTrue(plan.actions().containsValue(RiverCaveAction.SEAL_GUARD)); - assertMutationConnected(plan, source.entry(), position(1, 6, 0)); - assertEquals(plan.actions().keySet(), plan.baselinePreconditions().keySet()); - assertEquals(CaveVoxel.CAVE_AIR, plan.baselinePreconditions().get(position(1, 6, 0)).voxel()); - assertEquals(CaveVoxel.SOLID, plan.baselinePreconditions().get(position(0, 12, 0)).voxel()); - } - - @Test - public void compatibleFluidFollowsExplicitPolicy() { - TestVoxelView view = new TestVoxelView(); - CavePosition target = position(0, 7, 0); - view.set(CaveVoxel.COMPATIBLE_FLUID, target); - RiverCaveSource source = source(1L, 10, RiverCaveMode.CLOSED_COMPONENT, target); - - RiverCavePlan allowed = planner.plan(view, source, settings(RiverCaveFluidPolicy.ALLOW_COMPATIBLE)); - RiverCavePlan rejected = planner.plan(view, source, settings(RiverCaveFluidPolicy.REJECT_EXISTING)); - - assertTrue(allowed.accepted()); - assertEquals(RiverCaveAction.WET_SOURCE, allowed.actions().get(target)); - assertEquals(RiverCaveRejection.EXISTING_FLUID, rejected.rejection()); - assertTrue(rejected.actions().isEmpty()); - } - - @Test - public void replacementPolicyIsDistinctAndStillRejectsLava() { - CavePosition target = position(0, 7, 0); - TestVoxelView compatibleView = new TestVoxelView(); - compatibleView.set(CaveVoxel.COMPATIBLE_FLUID, target); - TestVoxelView incompatibleView = new TestVoxelView(); - incompatibleView.set(CaveVoxel.INCOMPATIBLE_FLUID, target); - TestVoxelView lavaView = new TestVoxelView(); - lavaView.set(CaveVoxel.LAVA, target); - RiverCaveSource source = source(101L, 10, RiverCaveMode.CLOSED_COMPONENT, target); - - RiverCavePlan rejectedCompatible = planner.plan( - compatibleView, - source, - settings(RiverCaveFluidPolicy.REJECT_EXISTING) - ); - RiverCavePlan allowedCompatible = planner.plan( - compatibleView, - source, - settings(RiverCaveFluidPolicy.ALLOW_COMPATIBLE) - ); - RiverCavePlan rejectedIncompatible = planner.plan( - incompatibleView, - source, - settings(RiverCaveFluidPolicy.ALLOW_COMPATIBLE) - ); - RiverCavePlan replacedIncompatible = planner.plan( - incompatibleView, - source, - settings(RiverCaveFluidPolicy.REPLACE_CONTAINED) - ); - RiverCavePlan rejectedLava = planner.plan( - lavaView, - source, - settings(RiverCaveFluidPolicy.REPLACE_CONTAINED) - ); - - assertEquals(RiverCaveRejection.EXISTING_FLUID, rejectedCompatible.rejection()); - assertTrue(allowedCompatible.accepted()); - assertEquals(RiverCaveRejection.INCOMPATIBLE_FLUID, rejectedIncompatible.rejection()); - assertTrue(replacedIncompatible.accepted()); - assertEquals(RiverCaveAction.WET_SOURCE, replacedIncompatible.actions().get(target)); - assertEquals(RiverCaveRejection.LAVA_CONTACT, rejectedLava.rejection()); - } - - @Test - public void throatRadiusExpandsTheConnectedBoreFootprint() { - TestVoxelView thinView = new TestVoxelView(); - TestVoxelView thickView = new TestVoxelView(); - CavePosition target = position(0, 7, 0); - thinView.set(CaveVoxel.CAVE_AIR, target); - thickView.set(CaveVoxel.CAVE_AIR, target); - RiverCaveSource source = source(102L, 10, RiverCaveMode.CLOSED_COMPONENT, target); - RiverCavePlannerSettings thinSettings = detailedSettings( - 1, - 0, - RiverCaveGrottoShape.ELLIPSOID, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - ); - RiverCavePlannerSettings thickSettings = detailedSettings( - 3, - 0, - RiverCaveGrottoShape.ELLIPSOID, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - RiverCavePlan thin = planner.plan(thinView, source, thinSettings); - RiverCavePlan thick = planner.plan(thickView, source, thickSettings); - - assertTrue(thin.accepted()); - assertTrue(thick.accepted()); - assertFalse(thin.actions().containsKey(position(2, 10, 0))); - assertEquals(RiverCaveAction.WET_SOURCE, thick.actions().get(position(2, 10, 0))); - assertTrue(mutationCount(thick) > mutationCount(thin)); - } - - @Test - public void generatedGrottoRetainsConfiguredDryHeadroom() { - TestVoxelView view = new TestVoxelView(); - RiverCaveSource source = source(103L, 10, RiverCaveMode.GENERATED_GROTTO, position(0, 8, 0)); - RiverCavePlannerSettings acceptedSettings = detailedSettings( - 1, - 2, - RiverCaveGrottoShape.ELLIPSOID, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - ); - RiverCavePlannerSettings rejectedSettings = detailedSettings( - 1, - 5, - RiverCaveGrottoShape.ELLIPSOID, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - RiverCavePlan accepted = planner.plan(view, source, acceptedSettings); - RiverCavePlan rejected = planner.plan(view, source, rejectedSettings); - - assertTrue(accepted.accepted()); - assertEquals(RiverCaveAction.FALLING_FLUID, accepted.actions().get(position(0, 11, 0))); - assertEquals(RiverCaveAction.FALLING_FLUID, accepted.actions().get(position(0, 12, 0))); - assertEquals(RiverCaveRejection.DRY_HEADROOM_LIMIT, rejected.rejection()); - } - - @Test - public void configuredGrottoPredicateChangesFootprintDeterministically() { - TestVoxelView view = new TestVoxelView(); - RiverCaveSource source = source(104L, 10, RiverCaveMode.GENERATED_GROTTO, position(0, 8, 0)); - RiverCavePlannerSettings ellipsoid = detailedSettings( - 1, - 0, - RiverCaveGrottoShape.ELLIPSOID, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - ); - RiverCaveGrottoShape compactShape = (candidate, settings, dx, dy, dz) -> { - double horizontal = settings.grottoHorizontalRadius(); - double vertical = settings.grottoVerticalRadius(); - double normalized = (dx * dx / (horizontal * horizontal)) - + (dy * dy / (vertical * vertical)) - + (dz * dz / (horizontal * horizontal)); - return normalized <= 0.5D; - }; - RiverCavePlannerSettings compact = detailedSettings( - 1, - 0, - compactShape, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - RiverCavePlan full = planner.plan(view, source, ellipsoid); - RiverCavePlan firstCompact = planner.plan(view, source, compact); - RiverCavePlan secondCompact = planner.plan(view, source, compact); - - assertTrue(full.accepted()); - assertTrue(firstCompact.accepted()); - assertEquals(firstCompact, secondCompact); - assertTrue(mutationCount(full) > mutationCount(firstCompact)); - } - - @Test - public void radiusLimitRejectsWholeComponentWithoutClipping() { - TestVoxelView view = new TestVoxelView(); - for (int x = 0; x <= 3; x++) { - view.set(CaveVoxel.CAVE_AIR, position(x, 7, 0)); - } - RiverCavePlannerSettings settings = new RiverCavePlannerSettings( - 2, - 20, - 64, - 32, - 1, - 1, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - RiverCavePlan plan = planner.plan( - view, - source(2L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), - settings - ); - - assertRejectedWithoutPublication(plan, RiverCaveRejection.RADIUS_LIMIT); - } - - @Test - public void depthLimitRejectsWholeComponentWithoutClipping() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 8, 0), position(0, 7, 0)); - RiverCavePlannerSettings settings = new RiverCavePlannerSettings( - 8, - 4, - 64, - 32, - 1, - 1, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - RiverCavePlan plan = planner.plan( - view, - source(3L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 8, 0)), - settings - ); - - assertRejectedWithoutPublication(plan, RiverCaveRejection.DEPTH_LIMIT); - } - - @Test - public void volumeLimitRejectsWholeComponentWithoutClipping() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); - RiverCavePlannerSettings settings = new RiverCavePlannerSettings( - 8, - 20, - 2, - 32, - 1, - 1, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - ); - - RiverCavePlan plan = planner.plan( - view, - source(4L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), - settings - ); - - assertRejectedWithoutPublication(plan, RiverCaveRejection.VOLUME_LIMIT); - } - - @Test - public void worldBoundaryRejectsWholeComponent() { - TestVoxelView view = new TestVoxelView(-16, 0, 0, 32, -16, 16); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - - RiverCavePlan plan = planner.plan( - view, - source(5L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), - settings() - ); - - assertRejectedWithoutPublication(plan, RiverCaveRejection.WORLD_BOUNDARY); - } - - @Test - public void openSurfaceRejectsWholeComponent() { - TestVoxelView view = new TestVoxelView(); - CavePosition target = position(0, 7, 0); - view.set(CaveVoxel.CAVE_AIR, target); - view.openToSurface(target); - - RiverCavePlan plan = planner.plan( - view, - source(6L, 10, RiverCaveMode.CLOSED_COMPONENT, target), - settings() - ); - - assertRejectedWithoutPublication(plan, RiverCaveRejection.OPEN_SURFACE); - } - - @Test - public void lavaAndIncompatibleFluidContactsRejectWholeComponent() { - TestVoxelView lavaView = new TestVoxelView(); - lavaView.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - lavaView.set(CaveVoxel.LAVA, position(1, 7, 0)); - TestVoxelView fluidView = new TestVoxelView(); - fluidView.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - fluidView.set(CaveVoxel.INCOMPATIBLE_FLUID, position(1, 7, 0)); - RiverCaveSource source = source(7L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); - - RiverCavePlan lavaPlan = planner.plan(lavaView, source, settings()); - RiverCavePlan fluidPlan = planner.plan(fluidView, source, settings()); - - assertRejectedWithoutPublication(lavaPlan, RiverCaveRejection.LAVA_CONTACT); - assertRejectedWithoutPublication(fluidPlan, RiverCaveRejection.INCOMPATIBLE_FLUID); - } - - @Test - public void fallingHazardAbovePoolRejectsWholeComponent() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - view.set(CaveVoxel.LAVA, position(1, 11, 0)); - - RiverCavePlan plan = planner.plan( - view, - source(15L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)), - settings() - ); - - assertRejectedWithoutPublication(plan, RiverCaveRejection.LAVA_CONTACT); - } - - @Test - public void generatedGrottoHasBoundedWetDryAndGuardActions() { - TestVoxelView view = new TestVoxelView(); - RiverCaveSource source = source(8L, 10, RiverCaveMode.GENERATED_GROTTO, position(0, 8, 0)); - - RiverCavePlan plan = planner.plan(view, source, settings()); - - assertTrue(plan.accepted()); - assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(position(0, 12, 0))); - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 8, 0))); - assertTrue(plan.actions().containsValue(RiverCaveAction.SEAL_GUARD)); - assertEquals(plan.actions().keySet(), plan.baselinePreconditions().keySet()); - assertFalse(plan.actions().containsKey(position(0, 13, 0))); - assertMutationConnected(plan, source.entry(), source.target()); - } - - @Test - public void generatedGrottoAcceptsItsCompleteSurfaceMouth() { - TestVoxelView view = new TestVoxelView(); - CavePosition[] wetMouth = { - position(-1, 12, 0), - position(1, 12, 0), - position(0, 12, -1), - position(0, 12, 1) - }; - CavePosition[] dryMouth = { - position(-1, 13, 0), - position(1, 13, 0), - position(0, 13, -1), - position(0, 13, 1) - }; - view.set(CaveVoxel.COMPATIBLE_FLUID, wetMouth); - view.set(CaveVoxel.CAVE_AIR, dryMouth); - for (CavePosition position : wetMouth) { - view.openToSurface(position); - } - for (CavePosition position : dryMouth) { - view.openToSurface(position); - } - RiverCaveSource source = source(108L, 10, RiverCaveMode.GENERATED_GROTTO, position(0, 8, 0)); - - RiverCavePlan plan = planner.plan(view, source, detailedSettings( - 2, - 2, - RiverCaveGrottoShape.ELLIPSOID, - RiverCaveFluidPolicy.ALLOW_COMPATIBLE - )); - - assertTrue(plan.rejection().toString(), plan.accepted()); - for (CavePosition position : wetMouth) { - assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(position)); - } - } - - @Test - public void generatedGrottoRejectsAnOpenShell() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(3, 8, 0)); - - RiverCavePlan plan = planner.plan( - view, - source(9L, 10, RiverCaveMode.GENERATED_GROTTO, position(0, 8, 0)), - settings() - ); - - assertRejectedWithoutPublication(plan, RiverCaveRejection.GROTTO_SHELL_OPEN); - } - - @Test - public void deepPoolOpensOnlyAboveItsContainedFluidHead() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 11, 0), position(0, 13, 0)); - RiverCaveSource source = new RiverCaveSource( - 109L, - position(0, 10, 0), - position(0, 8, 0), - 10, - RiverCaveMode.DEEP_POOL - ); - - RiverCavePlan plan = planner.plan(view, source, detailedSettings( - 1, - 2, - RiverCaveGrottoShape.ELLIPSOID, - RiverCaveFluidPolicy.REJECT_EXISTING - )); - - assertTrue(plan.rejection().toString(), plan.accepted()); - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); - assertEquals(RiverCaveAction.DRY_AIR, plan.actions().get(position(0, 11, 0))); - assertFalse(plan.actions().containsKey(position(0, 13, 0))); - } - - @Test - public void deepPoolRejectsCaveLeakAtOrBelowItsFluidHead() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(5, 8, 0)); - RiverCaveSource source = new RiverCaveSource( - 110L, - position(0, 10, 0), - position(0, 8, 0), - 10, - RiverCaveMode.DEEP_POOL - ); - - RiverCavePlan plan = planner.plan(view, source, detailedSettings( - 1, - 2, - RiverCaveGrottoShape.ELLIPSOID, - RiverCaveFluidPolicy.REJECT_EXISTING - )); - - assertRejectedWithoutPublication(plan, RiverCaveRejection.GROTTO_SHELL_OPEN); - } - - @Test - public void combinedModeUsesClosedCaveOrGeneratedGrottoFromBaseline() { - TestVoxelView caveView = new TestVoxelView(); - CavePosition target = position(0, 8, 0); - caveView.set(CaveVoxel.CAVE_AIR, target); - TestVoxelView solidView = new TestVoxelView(); - RiverCaveSource source = source(10L, 10, RiverCaveMode.GROTTO_OR_CLOSED_COMPONENT, target); - - RiverCavePlan cavePlan = planner.plan(caveView, source, settings()); - RiverCavePlan grottoPlan = planner.plan(solidView, source, settings()); - - assertTrue(cavePlan.accepted()); - assertTrue(grottoPlan.accepted()); - assertEquals(CaveVoxel.CAVE_AIR, cavePlan.baselinePreconditions().get(target).voxel()); - assertEquals(CaveVoxel.SOLID, grottoPlan.baselinePreconditions().get(target).voxel()); - } - - @Test - public void waterfallKeepsColumnDistinctFromSourcePoolAndDryAir() { - TestVoxelView view = new TestVoxelView(); - RiverCaveSource source = source(11L, 10, RiverCaveMode.WATERFALL_POOL, position(0, 8, 0)); - - RiverCavePlan plan = planner.plan(view, source, settings()); - - assertTrue(plan.accepted()); - assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(position(0, 12, 0))); - assertEquals(RiverCaveAction.FALLING_FLUID, plan.actions().get(position(0, 11, 0))); - assertEquals(RiverCaveAction.WET_SOURCE, plan.actions().get(position(0, 10, 0))); - assertFalse(plan.actions().containsValue(RiverCaveAction.DRY_AIR)); - } - - @Test - public void waterfallRejectsAnUnsealedFallingColumn() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 8, 0), position(1, 11, 0)); - RiverCaveSource source = source(13L, 10, RiverCaveMode.WATERFALL_POOL, position(0, 8, 0)); - - RiverCavePlan plan = planner.plan(view, source, settings()); - - assertRejectedWithoutPublication(plan, RiverCaveRejection.WATERFALL_SHAFT_OPEN); - } - - @Test - public void overlapArbitrationIsOrderIndependentAndNamesWinner() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); - RiverCaveSource lowerHead = new RiverCaveSource( - 1L, - position(2, 12, 0), - position(2, 7, 0), - 10, - RiverCaveMode.CLOSED_COMPONENT - ); - RiverCaveSource higherHead = new RiverCaveSource( - 20L, - position(0, 12, 0), - position(0, 7, 0), - 11, - RiverCaveMode.CLOSED_COMPONENT - ); - - RiverCavePlanningResult forward = planner.planAll(view, List.of(lowerHead, higherHead), settings()); - RiverCavePlanningResult reverse = planner.planAll(view, List.of(higherHead, lowerHead), settings()); - - assertEquals(forward, reverse); - assertEquals(higherHead, forward.plans().get(0).source()); - assertTrue(forward.plans().get(0).accepted()); - assertEquals(RiverCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); - assertEquals(20L, forward.plans().get(1).arbitrationWinnerSourceId().getAsLong()); - assertEquals(forward.actions().keySet(), forward.baselinePreconditions().keySet()); - } - - @Test - public void equalHeadOverlapUsesLowestStableSourceId() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0), position(1, 7, 0), position(2, 7, 0)); - RiverCaveSource largerId = new RiverCaveSource( - 20L, - position(0, 12, 0), - position(0, 7, 0), - 10, - RiverCaveMode.CLOSED_COMPONENT - ); - RiverCaveSource smallerId = new RiverCaveSource( - 1L, - position(2, 12, 0), - position(2, 7, 0), - 10, - RiverCaveMode.CLOSED_COMPONENT - ); - - RiverCavePlanningResult result = planner.planAll(view, List.of(largerId, smallerId), settings()); - - assertEquals(smallerId, result.plans().get(0).source()); - assertTrue(result.plans().get(0).accepted()); - assertEquals(1L, result.plans().get(1).arbitrationWinnerSourceId().getAsLong()); - } - - @Test - public void arbitrationUsesDirectHigherPriorityPlansEvenWhenTheyAreRejected() { - TestVoxelView view = new TestVoxelView(); - view.set( - CaveVoxel.CAVE_AIR, - position(0, 7, 0), - position(2, 7, 0), - position(4, 7, 0) - ); - RiverCaveSource lowest = new RiverCaveSource( - 30L, - position(0, 12, 0), - position(0, 7, 0), - 10, - RiverCaveMode.CLOSED_COMPONENT - ); - RiverCaveSource middle = new RiverCaveSource( - 20L, - position(2, 12, 0), - position(2, 7, 0), - 11, - RiverCaveMode.CLOSED_COMPONENT - ); - RiverCaveSource highest = new RiverCaveSource( - 10L, - position(4, 12, 0), - position(4, 7, 0), - 12, - RiverCaveMode.CLOSED_COMPONENT - ); - - RiverCavePlanningResult forward = planner.planAll( - view, - List.of(lowest, middle, highest), - settings() - ); - RiverCavePlanningResult reverse = planner.planAll( - view, - List.of(highest, middle, lowest), - settings() - ); - - assertEquals(forward, reverse); - assertTrue(forward.plans().get(0).accepted()); - assertEquals(RiverCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(1).rejection()); - assertEquals(10L, forward.plans().get(1).arbitrationWinnerSourceId().getAsLong()); - assertEquals(RiverCaveRejection.OVERLAPPING_SOURCE, forward.plans().get(2).rejection()); - assertEquals(20L, forward.plans().get(2).arbitrationWinnerSourceId().getAsLong()); - assertFalse(forward.actions().containsKey(position(0, 7, 0))); - assertTrue(forward.actions().containsKey(position(4, 7, 0))); - } - - @Test - public void planOutputsAreImmutable() { - TestVoxelView view = new TestVoxelView(); - view.set(CaveVoxel.CAVE_AIR, position(0, 7, 0)); - RiverCaveSource source = source(12L, 10, RiverCaveMode.CLOSED_COMPONENT, position(0, 7, 0)); - RiverCavePlanningResult result = planner.planAll(view, List.of(source), settings()); - RiverCavePlan plan = result.plans().get(0); - - assertThrows( - UnsupportedOperationException.class, - () -> plan.actions().put(position(9, 9, 9), RiverCaveAction.WET_SOURCE) - ); - assertThrows( - UnsupportedOperationException.class, - () -> plan.baselinePreconditions().put( - position(9, 9, 9), - new CaveVoxelPrecondition(CaveVoxel.SOLID, false) - ) - ); - assertThrows(UnsupportedOperationException.class, () -> result.plans().add(plan)); - assertThrows( - UnsupportedOperationException.class, - () -> result.actions().put(position(9, 9, 9), RiverCaveAction.WET_SOURCE) - ); - } - - private void assertMutationConnected(RiverCavePlan plan, CavePosition start, CavePosition expected) { - Set mutations = new HashSet<>(); - for (Map.Entry entry : plan.actions().entrySet()) { - if (entry.getValue() != RiverCaveAction.SEAL_GUARD) { - mutations.add(entry.getKey()); - } - } - Queue queue = new ArrayDeque<>(); - Set visited = new HashSet<>(); - queue.add(start); - visited.add(start); - while (!queue.isEmpty()) { - CavePosition position = queue.remove(); - for (CavePosition direction : DIRECTIONS) { - CavePosition neighbor = position.offset(direction.x(), direction.y(), direction.z()); - if (mutations.contains(neighbor) && visited.add(neighbor)) { - queue.add(neighbor); - } - } - } - assertTrue(visited.contains(expected)); - assertEquals(mutations, visited); - } - - private int mutationCount(RiverCavePlan plan) { - int count = 0; - for (RiverCaveAction action : plan.actions().values()) { - if (action != RiverCaveAction.SEAL_GUARD) { - count++; - } - } - return count; - } - - private void assertRejectedWithoutPublication(RiverCavePlan plan, RiverCaveRejection rejection) { - assertFalse(plan.accepted()); - assertEquals(rejection, plan.rejection()); - assertTrue(plan.actions().isEmpty()); - assertTrue(plan.baselinePreconditions().isEmpty()); - } - - private RiverCaveSource source(long sourceId, int waterHeadY, RiverCaveMode mode, CavePosition target) { - return new RiverCaveSource(sourceId, position(0, 12, 0), target, waterHeadY, mode); - } - - private RiverCavePlannerSettings settings() { - return settings(RiverCaveFluidPolicy.ALLOW_COMPATIBLE); - } - - private RiverCavePlannerSettings settings(RiverCaveFluidPolicy policy) { - return new RiverCavePlannerSettings(12, 20, 64, 32, 2, 2, policy); - } - - private RiverCavePlannerSettings detailedSettings( - int throatRadius, - int dryHeadroom, - RiverCaveGrottoShape shape, - RiverCaveFluidPolicy policy - ) { - return new RiverCavePlannerSettings( - 12, - 20, - 2048, - 32, - throatRadius, - 4, - 4, - dryHeadroom, - policy, - shape - ); - } - - private CavePosition position(int x, int y, int z) { - return new CavePosition(x, y, z); - } - - private static final class TestVoxelView implements CaveVoxelView { - private final int minX; - private final int maxX; - private final int minY; - private final int maxY; - private final int minZ; - private final int maxZ; - private final Map voxels = new HashMap<>(); - private final Set surfaceOpenings = new HashSet<>(); - - private TestVoxelView() { - this(-32, 32, 0, 32, -32, 32); - } - - private TestVoxelView(int minX, int maxX, int minY, int maxY, int minZ, int maxZ) { - this.minX = minX; - this.maxX = maxX; - this.minY = minY; - this.maxY = maxY; - this.minZ = minZ; - this.maxZ = maxZ; - } - - @Override - public boolean isInWorld(CavePosition position) { - return position.x() >= minX - && position.x() <= maxX - && position.y() >= minY - && position.y() <= maxY - && position.z() >= minZ - && position.z() <= maxZ; - } - - @Override - public CaveVoxel voxelAt(CavePosition position) { - return voxels.getOrDefault(position, CaveVoxel.SOLID); - } - - @Override - public boolean isOpenToSurface(CavePosition position) { - return surfaceOpenings.contains(position); - } - - private void set(CaveVoxel voxel, CavePosition... positions) { - for (CavePosition position : positions) { - voxels.put(position, voxel); - } - } - - private void openToSurface(CavePosition position) { - surfaceOpenings.add(position); - } - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettingsTest.java b/core/src/test/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettingsTest.java deleted file mode 100644 index 4ba2cc289..000000000 --- a/core/src/test/java/art/arcane/iris/engine/river/runtime/EffectiveRiverSettingsTest.java +++ /dev/null @@ -1,62 +0,0 @@ -package art.arcane.iris.engine.river.runtime; - -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisRiverBiomes; -import art.arcane.iris.engine.object.IrisRiverNetwork; -import art.arcane.iris.engine.object.IrisRiverOverride; -import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; -import art.arcane.iris.engine.object.IrisRiverTerminalMode; -import art.arcane.volmlib.util.collection.KList; -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; - -public class EffectiveRiverSettingsTest { - @Test - public void appliesRegionThenBiomeOverridesWithoutChangingGraphSettings() { - IrisRiverNetwork network = new IrisRiverNetwork() - .setBiomes(new IrisRiverBiomes() - .setChannel(new KList<>("dimension-channel")) - .setBank(new KList<>("dimension-bank"))); - IrisRegion region = new IrisRegion().setRiverOverride(new IrisRiverOverride() - .setAllowSources(false) - .setRoutingPolicy(IrisRiverRoutingPolicy.AVOID) - .setWidthMultiplier(1.5D) - .setChannelBiomes(new KList<>("region-channel"))); - IrisBiome biome = new IrisBiome().setRiverOverride(new IrisRiverOverride() - .setRoutingPolicy(IrisRiverRoutingPolicy.BLOCK) - .setWidthMultiplier(0.5D) - .setChannelBiomes(new KList<>()) - .setTerminalMode(IrisRiverTerminalMode.SUPPRESS)); - - EffectiveRiverSettings settings = EffectiveRiverSettings.resolve(network, region, biome); - - assertFalse(settings.allowSources()); - assertEquals(IrisRiverRoutingPolicy.BLOCK, settings.routingPolicy()); - assertEquals(0.5D, settings.widthMultiplier(), 0D); - assertTrue(settings.channelBiomes().isEmpty()); - assertEquals(List.of("dimension-bank"), settings.bankBiomes()); - assertEquals(IrisRiverTerminalMode.SUPPRESS, settings.terminalMode()); - assertTrue(settings.terminalModeOverridden()); - } - - @Test - public void inheritsDimensionDefaultsWhenOverridesAreAbsent() { - IrisRiverNetwork network = new IrisRiverNetwork() - .setBiomes(new IrisRiverBiomes().setDry(new KList<>("dry"))); - - EffectiveRiverSettings settings = EffectiveRiverSettings.resolve(network, null, null); - - assertTrue(settings.allowSources()); - assertEquals(IrisRiverRoutingPolicy.ALLOW, settings.routingPolicy()); - assertEquals(1D, settings.routingCostMultiplier(), 0D); - assertEquals(List.of("dry"), settings.dryBiomes()); - assertEquals(IrisRiverTerminalMode.DRY_CHANNEL, settings.terminalMode()); - assertFalse(settings.terminalModeOverridden()); - } -} diff --git a/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java b/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java deleted file mode 100644 index d1b7c8d96..000000000 --- a/core/src/test/java/art/arcane/iris/engine/river/runtime/IrisRiverRuntimeTest.java +++ /dev/null @@ -1,1178 +0,0 @@ -package art.arcane.iris.engine.river.runtime; - -import art.arcane.iris.core.loader.IrisData; -import art.arcane.iris.engine.object.InferredType; -import art.arcane.iris.engine.object.IrisBiome; -import art.arcane.iris.engine.object.IrisGeneratorStyle; -import art.arcane.iris.engine.object.IrisRegion; -import art.arcane.iris.engine.object.IrisRiverNetwork; -import art.arcane.iris.engine.object.IrisRiverCaveMode; -import art.arcane.iris.engine.object.IrisRiverOverride; -import art.arcane.iris.engine.object.IrisRiverRoutingPolicy; -import art.arcane.iris.engine.object.IrisRiverTerminalMode; -import art.arcane.iris.engine.object.IrisRiverWaterMode; -import art.arcane.iris.engine.object.IrisRiverWorm; -import art.arcane.iris.engine.object.IrisStyledRange; -import art.arcane.iris.engine.object.NoiseStyle; -import art.arcane.iris.engine.river.RiverEdgeId; -import art.arcane.iris.engine.river.RiverNode; -import art.arcane.iris.engine.river.RiverNodeId; -import art.arcane.iris.engine.river.RiverPolyline; -import art.arcane.iris.engine.river.RiverReach; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverAnchor; -import art.arcane.iris.engine.river.RiverBodyProfile; -import art.arcane.iris.engine.river.RiverRoutingContext; -import art.arcane.iris.engine.river.RiverTerrainNodeSample; -import art.arcane.iris.engine.river.RiverTopologyComplexity; -import art.arcane.iris.engine.river.RiverTerrainSourceSample; -import art.arcane.iris.util.project.interpolation.NoiseBounds; -import art.arcane.iris.util.project.stream.ProceduralStream; -import art.arcane.iris.util.project.stream.interpolation.Interpolated; -import art.arcane.volmlib.util.collection.KList; -import org.junit.Test; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.mock; - -public class IrisRiverRuntimeTest { - @Test - public void tunnelCrossSectionRoundsAndNoiseModulatesBothSurfaces() { - assertEquals(62, IrisRiverRuntime.shapedTunnelBedY(70, 64D, 1D, 2D)); - assertEquals(69, IrisRiverRuntime.shapedTunnelBedY(70, 64D, 0D, 2D)); - assertEquals(65, IrisRiverRuntime.shapedTunnelBedY(70, 64D, 1D, -1D)); - assertEquals(77, IrisRiverRuntime.shapedTunnelCeilingY(70, 4D, 1D, 3D)); - assertEquals(70, IrisRiverRuntime.shapedTunnelCeilingY(70, 4D, 0D, 3D)); - assertEquals(71, IrisRiverRuntime.shapedTunnelCeilingY(70, 4D, 0.5D, -2D)); - } - - @Test - public void widenedTunnelHaloAccountsForMaximumConfiguredMultiplier() { - assertEquals(17, RiverTopologyComplexity.tunnelHalo(7D, 4D, 2D)); - assertEquals(2500L, RiverTopologyComplexity.tunnelSampleColumns(7D, 4D, 2D)); - } - - @Test - public void tunnelWidthMultiplierWidensOnlySubterraneanFootprint() { - IrisRiverNetwork narrowConfiguration = configuration(false); - narrowConfiguration.getTerrain() - .setMaxChannelWidth(4D) - .setMaxBankWidth(0D) - .setBankWidth(range(0D)) - .setMaxIncision(10) - .setTunnelMouthBlend(0D) - .setTunnelWidthMultiplier(range(1D)); - IrisRiverNetwork wideConfiguration = configuration(false); - wideConfiguration.getTerrain() - .setMaxChannelWidth(4D) - .setMaxBankWidth(0D) - .setBankWidth(range(0D)) - .setMaxIncision(10) - .setTunnelMouthBlend(0D) - .setTunnelWidthMultiplier(range(4D)); - - try (IrisRiverRuntime narrow = runtime( - narrowConfiguration, - constantHeight(100D), - constantLandBiome(), - new IrisRegion(), - true, - false - ); IrisRiverRuntime wide = runtime( - wideConfiguration, - constantHeight(100D), - constantLandBiome(), - new IrisRegion(), - true, - false - )) { - int narrowTunnelColumns = 0; - int wideTunnelColumns = 0; - int narrowSurfaceColumns = 0; - int wideSurfaceColumns = 0; - for (int x = -128; x < 128; x += 2) { - for (int z = -128; z < 128; z += 2) { - if (narrow.sampleTunnel(x, z) != null) { - narrowTunnelColumns++; - } - if (wide.sampleTunnel(x, z) != null) { - wideTunnelColumns++; - } - if (narrow.sample(x, z).river().present()) { - narrowSurfaceColumns++; - } - if (wide.sample(x, z).river().present()) { - wideSurfaceColumns++; - } - } - } - - assertTrue(wideTunnelColumns > narrowTunnelColumns * 2); - assertEquals(narrowSurfaceColumns, wideSurfaceColumns); - } - } - - @Test - public void terminalTaperUsesMeasuredReachLength() { - assertEquals(1D, IrisRiverRuntime.terminalWeight(40, 200D, 0.8D), 0D); - assertEquals(0.5D, IrisRiverRuntime.terminalWeight(40, 200D, 0.9D), 0.0000001D); - assertEquals(0.5D, IrisRiverRuntime.terminalWeight(40, 20D, 0.5D), 0.0000001D); - } - - @Test - public void footprintSamplingBuildsOnlyItsCenterTile() { - IrisRiverNetwork configuration = configuration(false); - - try (IrisRiverRuntime runtime = runtime(configuration)) { - runtime.sampleFootprint(-4096D, -4096D, 4096D, 4096D); - - assertEquals(1, runtime.completedTileCount()); - } - } - - @Test - public void footprintPresenceQueryDistinguishesEmptyAndRoutedAreas() { - IrisRiverNetwork emptyConfiguration = configuration(false); - emptyConfiguration.getTopology().getSource().setChance(0D); - - try (IrisRiverRuntime empty = runtime(emptyConfiguration); - IrisRiverRuntime routed = runtime(configuration(false))) { - assertFalse(empty.hasRiverFootprint(-128, -128, 128, 128)); - assertTrue(routed.hasRiverFootprint(-128, -128, 128, 128)); - } - } - - @Test - public void settingsAtSkipsNaturalBiomeWhenBiomeOverridesAreUnreachable() { - IrisRiverNetwork configuration = configuration(false); - IrisRegion region = new IrisRegion().setRiverOverride( - new IrisRiverOverride().setWidthMultiplier(1.75D) - ); - IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND); - AtomicInteger biomeSamples = new AtomicInteger(); - - try (IrisRiverRuntime runtime = runtime( - configuration, - constantHeight(80D), - countedBiome(biome, biomeSamples), - region, - false, - true, - true, - false - )) { - EffectiveRiverSettings settings = runtime.settingsAt(12D, -7D); - - assertEquals(1.75D, settings.widthMultiplier(), 0D); - assertEquals(0, biomeSamples.get()); - } - } - - @Test - public void settingsAtSamplesNaturalBiomeWhenBiomeOverrideIsReachable() { - IrisRiverNetwork configuration = configuration(false); - IrisRegion region = new IrisRegion().setRiverOverride( - new IrisRiverOverride().setWidthMultiplier(1.75D) - ); - IrisBiome biome = new IrisBiome() - .setInferredType(InferredType.LAND) - .setRiverOverride(new IrisRiverOverride().setWidthMultiplier(2.5D)); - AtomicInteger biomeSamples = new AtomicInteger(); - - try (IrisRiverRuntime runtime = runtime( - configuration, - constantHeight(80D), - countedBiome(biome, biomeSamples), - region, - false, - true, - true, - true - )) { - EffectiveRiverSettings settings = runtime.settingsAt(12D, -7D); - - assertEquals(2.5D, settings.widthMultiplier(), 0D); - assertEquals(1, biomeSamples.get()); - } - } - - @Test - public void nodeSamplingResolvesInputsOnceAndSkipsZeroWeightSlope() { - IrisRiverNetwork configuration = configuration(false); - configuration.getTopology().setTerrainHeightWeight(0D); - configuration.getTopology().setTerrainSlopeWeight(0D); - IrisRegion region = new IrisRegion().setRiverOverride( - new IrisRiverOverride().setRoutingCostMultiplier(2D) - ); - IrisBiome biome = new IrisBiome() - .setInferredType(InferredType.LAND) - .setRiverOverride(new IrisRiverOverride() - .setRoutingPolicy(IrisRiverRoutingPolicy.BLOCK) - .setRoutingCostMultiplier(0D)); - AtomicInteger heightSamples = new AtomicInteger(); - AtomicInteger slopeSamples = new AtomicInteger(); - AtomicInteger oceanSamples = new AtomicInteger(); - AtomicInteger biomeSamples = new AtomicInteger(); - AtomicInteger regionSamples = new AtomicInteger(); - ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { - heightSamples.incrementAndGet(); - return 80D; - }); - ProceduralStream slope = ProceduralStream.ofDouble((x, z) -> { - slopeSamples.incrementAndGet(); - return 0.25D; - }); - ProceduralStream ocean = ProceduralStream.of((x, z) -> { - oceanSamples.incrementAndGet(); - return false; - }, Interpolated.BOOLEAN); - ProceduralStream biomes = countedBiome(biome, biomeSamples); - ProceduralStream regions = ProceduralStream.of( - (x, z) -> { - regionSamples.incrementAndGet(); - return region; - }, - Interpolated.of(value -> 0D, value -> region) - ); - - try (IrisRiverRuntime runtime = new IrisRiverRuntime(new IrisRiverRuntimeContext( - 4829759234L, - configuration, - mock(IrisData.class), - 63, - 63, - false, - true, - true, - true, - true, - (x, z) -> new NoiseBounds(0D, 512D), - height, - slope, - ocean, - biomes, - regions - ))) { - RiverTerrainNodeSample sample = runtime.sampleNode(12, -7); - RiverTerrainSourceSample sourceSample = runtime.sampleSource(13, -8); - - assertEquals(63D, sample.naturalHeight(), 0D); - assertFalse(sample.ocean()); - assertFalse(sample.riverAllowed()); - assertEquals(0D, sample.routingCost(), 0D); - assertFalse(sourceSample.ocean()); - assertFalse(sourceSample.riverAllowed()); - assertEquals(0, heightSamples.get()); - assertEquals(0, slopeSamples.get()); - assertEquals(2, oceanSamples.get()); - assertEquals(2, biomeSamples.get()); - assertEquals(2, regionSamples.get()); - } - } - - @Test - public void oceanIntentOnlyTerminatesRiversWhereTheNaturalSurfaceIsSubmerged() { - IrisRiverNetwork configuration = configuration(false); - configuration.getTopology().setTerrainHeightWeight(0D); - IrisBiome oceanBiome = new IrisBiome().setInferredType(InferredType.SEA); - IrisRegion region = new IrisRegion(); - ProceduralStream height = ProceduralStream.ofDouble((x, z) -> x < 0D ? 50D : 200D); - ProceduralStream oceanIntent = ProceduralStream.of( - (x, z) -> true, - Interpolated.BOOLEAN - ); - ProceduralStream biomes = ProceduralStream.of( - (x, z) -> oceanBiome, - Interpolated.of(value -> 0D, value -> oceanBiome) - ); - ProceduralStream regions = ProceduralStream.of( - (x, z) -> region, - Interpolated.of(value -> 0D, value -> region) - ); - - try (IrisRiverRuntime runtime = new IrisRiverRuntime(new IrisRiverRuntimeContext( - 4829759234L, - configuration, - mock(IrisData.class), - 63, - 63, - true, - true, - false, - false, - false, - (x, z) -> new NoiseBounds(0D, 512D), - height, - ProceduralStream.ofDouble((x, z) -> 0D), - oceanIntent, - biomes, - regions - ))) { - assertTrue(runtime.sampleNode(-1, 0).ocean()); - assertFalse(runtime.sampleNode(1, 0).ocean()); - assertTrue(runtime.sampleSource(-1, 0).ocean()); - assertFalse(runtime.sampleSource(1, 0).ocean()); - } - } - - @Test - public void wetTerminalRiverCarvesDownAndPublishesAFluidHead() { - IrisRiverNetwork configuration = configuration(false); - - try (IrisRiverRuntime runtime = runtime(configuration)) { - IrisRiverSurfaceSample sample = findRiver(runtime, RiverRouteState.WET); - - assertNotNull(sample); - assertTrue(sample.river().present()); - assertTrue(sample.surfaceFluid()); - assertTrue(sample.terrainHeight() <= sample.naturalHeight()); - assertTrue(sample.waterSurfaceY() >= sample.terrainHeight()); - assertTrue(runtime.completedTileCount() <= 32); - } - } - - @Test - public void failedOceanRouteCanProduceDryTerrainWithoutSurfaceWater() { - IrisRiverNetwork configuration = configuration(true); - - try (IrisRiverRuntime runtime = runtime(configuration)) { - IrisRiverSurfaceSample sample = findRiver(runtime, RiverRouteState.DRY); - - assertNotNull(sample); - assertFalse(sample.surfaceFluid()); - assertTrue(sample.terrainHeight() <= sample.naturalHeight()); - assertTrue(sample.waterSurfaceY() == sample.terrainHeight()); - } - } - - @Test - public void localSinkholeOverrideMakesRequiredOceanTerminalWet() { - IrisRiverNetwork configuration = configuration(true); - IrisBiome land = new IrisBiome() - .setInferredType(InferredType.LAND) - .setRiverOverride(new IrisRiverOverride().setTerminalMode(IrisRiverTerminalMode.SINKHOLE_GROTTO)); - - try (IrisRiverRuntime runtime = runtime(configuration, land, new IrisRegion())) { - IrisRiverSurfaceSample sample = findRiver(runtime, RiverRouteState.WET); - - assertNotNull(sample); - assertTrue(sample.surfaceFluid()); - } - } - - @Test - public void sinkholeTerminalPublishesAGuaranteedSpecificCaveAnchor() { - IrisRiverNetwork configuration = configuration(true); - configuration.getTopology().setMaxRouteReaches(1); - configuration.getCaves() - .setMode(IrisRiverCaveMode.GROTTO_OR_CLOSED_COMPONENT) - .setMaximumPerReach(1); - configuration.getCaves().getEntry() - .setChance(0D) - .setInfluence(0D) - .setStyle(flat()); - IrisBiome land = new IrisBiome() - .setInferredType(InferredType.LAND) - .setRiverOverride(new IrisRiverOverride().setTerminalMode(IrisRiverTerminalMode.SINKHOLE_GROTTO)); - - try (IrisRiverRuntime runtime = runtime(configuration, land, new IrisRegion())) { - List anchors = runtime.candidateAnchors(-128, -128, 256, 256, 16D, 994L); - RiverAnchor terminal = null; - for (RiverAnchor anchor : anchors) { - if (runtime.isTerminalCaveAnchor(anchor)) { - terminal = anchor; - break; - } - } - - assertNotNull(terminal); - assertTrue(runtime.acceptsCaveAnchor(terminal)); - IrisRiverSurfaceSample terminalSample = runtime.sample( - StrictMath.floor(terminal.x()), - StrictMath.floor(terminal.z()) - ); - assertTrue(terminalSample.surfaceFluid()); - assertTrue(Math.round(terminalSample.terrainHeight()) - < Math.round(terminalSample.waterSurfaceY())); - for (RiverAnchor anchor : anchors) { - if (anchor.reachId().equals(terminal.reachId()) - && !runtime.isTerminalCaveAnchor(anchor)) { - assertFalse(runtime.acceptsCaveAnchor(anchor)); - } - } - configuration.getCaves().setMaximumPerReach(0); - assertFalse(runtime.acceptsCaveAnchor(terminal)); - } - } - - @Test - public void localSuppressOverrideRemovesDimensionSinkholeTerminalRoute() { - IrisRiverNetwork configuration = configuration(true); - configuration.getTerrain().setTerminalMode(IrisRiverTerminalMode.SINKHOLE_GROTTO); - IrisBiome land = new IrisBiome() - .setInferredType(InferredType.LAND) - .setRiverOverride(new IrisRiverOverride().setTerminalMode(IrisRiverTerminalMode.SUPPRESS)); - - try (IrisRiverRuntime runtime = runtime(configuration, land, new IrisRegion())) { - assertFalse(hasRiver(runtime)); - } - } - - @Test - public void inactiveCaveHydrologySuppressesLocalSinkholeTerminalRoute() { - IrisRiverNetwork configuration = configuration(true); - IrisBiome land = new IrisBiome() - .setInferredType(InferredType.LAND) - .setRiverOverride(new IrisRiverOverride().setTerminalMode(IrisRiverTerminalMode.SINKHOLE_GROTTO)); - - try (IrisRiverRuntime runtime = runtime(configuration, land, new IrisRegion(), false)) { - assertFalse(hasRiver(runtime)); - } - } - - @Test - public void caveEntryGateHonorsWetStateAndMaximumPerReach() { - IrisRiverNetwork configuration = configuration(false); - configuration.getCaves().setMode(IrisRiverCaveMode.FLOOD_CLOSED_COMPONENT); - configuration.getCaves().setMaximumPerReach(1); - configuration.getCaves().getEntry() - .setChance(0.35D) - .setInfluence(0D) - .setStyle(flat()); - - try (IrisRiverRuntime runtime = runtime(configuration)) { - List anchors = runtime.candidateAnchors(0, 0, 256, 256, 16D, 773L); - Map acceptedPerReach = new HashMap<>(); - boolean acceptedAfterRejectedRawIndex = false; - for (RiverAnchor anchor : anchors) { - if (runtime.acceptsCaveAnchor(anchor)) { - acceptedPerReach.merge(anchor.reachId(), 1, Integer::sum); - acceptedAfterRejectedRawIndex |= anchor.index() >= 1; - } - } - - assertFalse(acceptedPerReach.isEmpty()); - for (int accepted : acceptedPerReach.values()) { - assertEquals(1, accepted); - } - assertTrue(acceptedAfterRejectedRawIndex); - } - } - - @Test - public void deepPoolReachGateIsIndependentSparseAndReachLimited() { - IrisRiverNetwork configuration = configuration(false); - configuration.getCaves().getDeepPools() - .setEnabled(true) - .setMaximumPerReach(1); - configuration.getCaves().getDeepPools().getReach() - .setChance(1D) - .setInfluence(0D) - .setStyle(flat()); - - try (IrisRiverRuntime runtime = runtime(configuration)) { - List anchors = runtime.candidateAnchors(0, 0, 256, 256, 16D, 882L); - Map acceptedPerReach = new HashMap<>(); - for (RiverAnchor anchor : anchors) { - if (runtime.acceptsDeepPoolAnchor(anchor)) { - acceptedPerReach.merge(anchor.reachId(), 1, Integer::sum); - } - } - - assertFalse(acceptedPerReach.isEmpty()); - for (int accepted : acceptedPerReach.values()) { - assertEquals(1, accepted); - } - configuration.getCaves().getDeepPools().setEnabled(false); - for (RiverAnchor anchor : anchors) { - assertFalse(runtime.acceptsDeepPoolAnchor(anchor)); - } - } - } - - @Test - public void finalPolylineSupercoverRejectsOneBlockedColumnMissedByWidthSpacing() { - IrisRiverNetwork configuration = configuration(false); - IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); - IrisBiome blocked = new IrisBiome() - .setInferredType(InferredType.LAND) - .setRiverOverride(new IrisRiverOverride().setRoutingPolicy(IrisRiverRoutingPolicy.BLOCK)); - ProceduralStream biomes = ProceduralStream.of( - (x, z) -> x == 1D && z == 0D ? blocked : land, - Interpolated.of(value -> 0D, value -> land) - ); - - try (IrisRiverRuntime runtime = runtime(configuration, constantHeight(80D), biomes)) { - assertFalse(runtime.allowsReach(straightReach(80D, 79D))); - } - } - - @Test - public void finalPolylineSupercoverRejectsOneUnincisableTerrainSpike() { - IrisRiverNetwork configuration = configuration(false); - configuration.getTerrain().setMaxIncision(48); - ProceduralStream height = ProceduralStream.of( - (x, z) -> x == 1D && z == 0D ? 200D : 80D, - Interpolated.DOUBLE - ); - - try (IrisRiverRuntime runtime = runtime(configuration, height, constantLandBiome())) { - assertFalse(runtime.allowsReach(straightReach(80D, 79D))); - } - } - - @Test - public void longReachFeasibilityHasADeterministicSampleCeiling() { - IrisRiverNetwork configuration = configuration(false); - int[] heightSamples = new int[1]; - ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { - heightSamples[0]++; - return 80D; - }); - - try (IrisRiverRuntime runtime = runtime(configuration, height, constantLandBiome())) { - heightSamples[0] = 0; - - assertTrue(runtime.allowsReach(straightReach(80D, 79D, 4_096D))); - assertEquals(IrisRiverRuntime.MAXIMUM_REACH_FEASIBILITY_SAMPLES, heightSamples[0]); - } - } - - @Test - public void invariantFeasibilitySettingsAreResolvedOncePerReach() { - IrisRiverNetwork configuration = configuration(false); - AtomicInteger regionSamples = new AtomicInteger(); - IrisRegion region = new IrisRegion(); - ProceduralStream regions = ProceduralStream.of( - (x, z) -> { - regionSamples.incrementAndGet(); - return region; - }, - Interpolated.of(value -> 0D, value -> region) - ); - ProceduralStream height = constantHeight(80D); - ProceduralStream biome = constantLandBiome(); - ProceduralStream oceans = ProceduralStream.of( - (x, z) -> false, - Interpolated.BOOLEAN - ); - try (IrisRiverRuntime runtime = new IrisRiverRuntime(new IrisRiverRuntimeContext( - 4829759234L, - configuration, - mock(IrisData.class), - 63, - 63, - false, - true, - false, - false, - false, - (x, z) -> new NoiseBounds(0D, 512D), - height, - ProceduralStream.ofDouble((x, z) -> 0.025D), - oceans, - biome, - regions - ))) { - assertTrue(runtime.allowsReach(straightReach(80D, 79D, 4_096D))); - assertEquals(1, regionSamples.get()); - } - } - - @Test - public void feasibilityBoundsMatchFullIncisionAcrossRoundingBoundaries() { - double[] heads = new double[]{62.5D, Math.nextDown(62.5D), Math.nextUp(62.5D), 63D}; - double[] heights = new double[]{62.49D, 62.5D, 63D, 80D}; - double[] incisions = new double[]{0D, 0.5D, 16D, 512D}; - double[] depths = new double[]{0.25D, 1D, 4D}; - double[] roughnessBounds = new double[]{0D, 0.75D, 2D}; - double[] bedNoiseValues = new double[]{-1D, 0D, 1D}; - for (double head : heads) { - for (double naturalHeight : heights) { - for (double maximumIncision : incisions) { - for (double depth : depths) { - for (double roughnessBound : roughnessBounds) { - int decision = IrisRiverRuntime.boundedFeasibility( - naturalHeight, - head, - maximumIncision, - depth, - roughnessBound); - if (decision == IrisRiverRuntime.FEASIBILITY_SAMPLE_BED) { - continue; - } - for (double bedNoise : bedNoiseValues) { - double bedHeight = head - depth + roughnessBound * bedNoise; - double finalHeight = Math.min( - naturalHeight, - Math.max( - bedHeight, - naturalHeight - Math.max(0D, maximumIncision))); - boolean expected = Math.round(finalHeight) < Math.round(head); - assertEquals(expected, decision == IrisRiverRuntime.FEASIBILITY_ACCEPT); - } - } - } - } - } - } - } - - @Test - public void mantleBoreSkipsSurfaceFeasibilitySampling() { - IrisRiverNetwork configuration = configuration(false); - int[] heightSamples = new int[1]; - ProceduralStream height = ProceduralStream.ofDouble((x, z) -> { - heightSamples[0]++; - return 512D; - }); - - try (IrisRiverRuntime runtime = runtime( - configuration, - height, - constantLandBiome(), - new IrisRegion(), - true, - false, - false, - true - )) { - heightSamples[0] = 0; - - assertTrue(runtime.allowsReach(straightReach(80D, 79D, 4_096D))); - assertEquals(0, heightSamples[0]); - } - } - - @Test - public void mantleBoreStillHonorsBlockedRoutingPolicy() { - IrisRiverNetwork configuration = configuration(false); - IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); - IrisBiome blocked = new IrisBiome() - .setInferredType(InferredType.LAND) - .setRiverOverride(new IrisRiverOverride().setRoutingPolicy(IrisRiverRoutingPolicy.BLOCK)); - ProceduralStream biomes = ProceduralStream.of( - (x, z) -> x == 1D && z == 0D ? blocked : land, - Interpolated.of(value -> 0D, value -> land) - ); - - try (IrisRiverRuntime runtime = runtime( - configuration, - constantHeight(512D), - biomes, - new IrisRegion(), - true, - false, - true, - true - )) { - assertFalse(runtime.allowsReach(straightReach(80D, 79D))); - } - } - - @Test - public void regionalDepthMultiplierCannotCollapseWetChannelBelowOneBlock() { - IrisRiverNetwork configuration = configuration(false); - configuration.getTerrain() - .setDepth(range(2D)) - .setBedRoughness(0.65D) - .setBedRoughnessStyle(flat()); - IrisRegion shallow = new IrisRegion() - .setRiverOverride(new IrisRiverOverride().setDepthMultiplier(0.1D)); - - try (IrisRiverRuntime runtime = runtime( - configuration, - constantHeight(80D), - constantLandBiome(), - shallow - )) { - assertTrue(runtime.allowsReach(straightReach(80D, 79D))); - } - } - - @Test - public void incisionLimitTurnsADeepWetReachIntoAHiddenMantleTunnel() { - IrisRiverNetwork configuration = configuration(false); - configuration.getTerrain().setMaxIncision(10); - ProceduralStream height = constantHeight(100D); - - try (IrisRiverRuntime runtime = runtime( - configuration, - height, - constantLandBiome(), - new IrisRegion(), - true, - false - )) { - IrisRiverSurfaceSample sample = null; - IrisRiverTunnelSample tunnel = null; - for (int x = -128; x < 192 && tunnel == null; x += 2) { - for (int z = -128; z < 192; z += 2) { - IrisRiverSurfaceSample candidate = runtime.sample(x, z); - IrisRiverTunnelSample candidateTunnel = runtime.sampleTunnel(x, z); - if (candidate.river().present() - && candidate.river().state() == RiverRouteState.WET - && candidateTunnel != null) { - sample = candidate; - tunnel = candidateTunnel; - break; - } - } - } - - assertNotNull(sample); - assertTrue(sample.subterranean()); - assertFalse(sample.surfaceFluid()); - assertEquals(sample.naturalHeight(), sample.terrainHeight(), 0D); - assertNotNull(tunnel); - assertTrue(tunnel.bedY() < tunnel.waterHeadY()); - assertTrue(tunnel.ceilingY() >= tunnel.waterHeadY()); - } - } - - @Test - public void cliffTransitionCreatesAFlaredTunnelMouth() { - IrisRiverNetwork sealedConfiguration = configuration(false); - sealedConfiguration.getTerrain() - .setMaxIncision(10) - .setTunnelMouthBlend(0D); - IrisRiverNetwork mouthConfiguration = configuration(false); - mouthConfiguration.getTerrain() - .setMaxIncision(10) - .setTunnelMouthBlend(6D); - ProceduralStream height = ProceduralStream.ofDouble((x, z) -> x < 0D ? 50D : 100D); - - try (IrisRiverRuntime sealed = runtime( - sealedConfiguration, - height, - constantLandBiome(), - new IrisRegion(), - true, - false - ); IrisRiverRuntime mouth = runtime( - mouthConfiguration, - height, - constantLandBiome(), - new IrisRegion(), - true, - false - )) { - boolean transitionFound = false; - for (int z = -2048; z <= 2048 && !transitionFound; z++) { - IrisRiverSurfaceSample open = mouth.sample(-1, z); - IrisRiverSurfaceSample solid = mouth.sample(0, z); - IrisRiverTunnelSample tunnel = mouth.sampleTunnel(0, z); - IrisRiverTunnelSample unflared = sealed.sampleTunnel(0, z); - if (open.river().present() - && solid.river().present() - && open.river().reachId().equals(solid.river().reachId()) - && open.surfaceFluid() - && tunnel != null - && (unflared == null || tunnel.ceilingY() > unflared.ceilingY())) { - assertFalse(open.subterranean()); - assertTrue(solid.subterranean()); - transitionFound = true; - } - } - - assertTrue(transitionFound); - } - } - - @Test - public void terracedWaterUsesFixedInteriorPoolsAndPreservesNodeHeads() { - IrisRiverNetwork configuration = configuration(false); - configuration.getWater() - .setMode(IrisRiverWaterMode.TERRACED) - .setMaximumPoolRise(8) - .setDropHeight(2) - .setPoolLength(96); - - try (IrisRiverRuntime runtime = runtime(configuration)) { - assertEquals(71D, runtime.terracedWaterSurface(72D, 64D, 600D, 155D / 600D), 0D); - assertEquals(69D, runtime.terracedWaterSurface(72D, 64D, 600D, 156D / 600D), 0D); - assertEquals(69D, runtime.terracedWaterSurface(72D, 64D, 600D, 251D / 600D), 0D); - assertEquals(67D, runtime.terracedWaterSurface(72D, 64D, 600D, 252D / 600D), 0D); - assertEquals(65D, runtime.terracedWaterSurface(72D, 64D, 600D, 443D / 600D), 0D); - assertEquals(63D, runtime.terracedWaterSurface(72D, 64D, 600D, 444D / 600D), 0D); - assertEquals(63D, runtime.terracedWaterSurface(72D, 64D, 600D, 1D), 0D); - assertEquals( - runtime.terracedWaterSurface(72D, 64D, 600D, 1D), - runtime.terracedWaterSurface(64D, 63D, 600D, 0D), - 0D - ); - assertEquals(67D, runtime.terracedWaterSurface(68D, 64D, 100D, 0.33D), 0D); - assertEquals(65D, runtime.terracedWaterSurface(68D, 64D, 100D, 0.34D), 0D); - assertEquals(65D, runtime.terracedWaterSurface(68D, 64D, 100D, 0.66D), 0D); - assertEquals(63D, runtime.terracedWaterSurface(68D, 64D, 100D, 0.67D), 0D); - } - } - - @Test - public void terracedRiverMouthCannotRiseAboveNaturalOcean() { - IrisRiverNetwork configuration = configuration(false); - configuration.getWater() - .setMode(IrisRiverWaterMode.TERRACED) - .setMaximumPoolRise(8) - .setDropHeight(1) - .setPoolLength(128); - RiverNode from = new RiverNode( - new RiverNodeId(0L, 0L), - -600D, - 0D, - 72D, - 72D, - 2D, - 0D, - false, - true - ); - RiverNode to = new RiverNode( - new RiverNodeId(1L, 0L), - 0D, - 0D, - 63D, - 63D, - -Double.MAX_VALUE, - 0D, - true, - true - ); - RiverReach mouth = new RiverReach( - RiverEdgeId.of(from.id(), to.id()), - from, - to, - RiverRouteState.WET, - 1, - 1, - 4D, - 2D, - 4D, - RiverBodyProfile.constant(4D, 2D, 4D), - true, - false, - new RiverPolyline(new double[]{-600D, 0D}, new double[]{0D, 0D}) - ); - - try (IrisRiverRuntime runtime = runtime( - configuration, - constantHeight(80D), - constantLandBiome(), - new IrisRegion() - )) { - assertEquals(71D, runtime.waterSurface(mouth, 0.1D, false), 0D); - assertEquals(63D, runtime.waterSurface(mouth, 0.9D, true), 0D); - } - } - - @Test - public void fixedRiverHeightIsIndependentFromNaturalOceanHeight() { - IrisRiverNetwork configuration = configuration(false); - configuration.getWater() - .setMode(IrisRiverWaterMode.FIXED) - .setFluidHeight(-48); - - try (IrisRiverRuntime runtime = runtimeWithFluidHeights(configuration, -48, 63)) { - assertEquals(-48D, runtime.waterSurface(null, 0D, false), 0D); - assertEquals(63D, runtime.waterSurface(null, 0D, true), 0D); - } - } - - private static IrisRiverRuntime runtime(IrisRiverNetwork configuration) { - IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); - IrisRegion region = new IrisRegion(); - return runtime(configuration, land, region); - } - - private static IrisRiverRuntime runtime(IrisRiverNetwork configuration, IrisBiome land, IrisRegion region) { - return runtime(configuration, land, region, true); - } - - private static IrisRiverRuntime runtime( - IrisRiverNetwork configuration, - IrisBiome land, - IrisRegion region, - boolean caveHydrologyActive - ) { - ProceduralStream height = ProceduralStream.of( - (x, z) -> 180D - x * 0.01D - z * 0.015D, - Interpolated.DOUBLE - ); - ProceduralStream biome = ProceduralStream.of( - (x, z) -> land, - Interpolated.of(value -> 0D, value -> land) - ); - return runtime(configuration, height, biome, region, caveHydrologyActive); - } - - private static IrisRiverRuntime runtime( - IrisRiverNetwork configuration, - ProceduralStream height, - ProceduralStream biome - ) { - return runtime(configuration, height, biome, new IrisRegion()); - } - - private static IrisRiverRuntime runtime( - IrisRiverNetwork configuration, - ProceduralStream height, - ProceduralStream biome, - IrisRegion region - ) { - return runtime(configuration, height, biome, region, true); - } - - private static IrisRiverRuntime runtime( - IrisRiverNetwork configuration, - ProceduralStream height, - ProceduralStream biome, - IrisRegion region, - boolean caveHydrologyActive - ) { - return runtime(configuration, height, biome, region, false, caveHydrologyActive); - } - - private static IrisRiverRuntime runtime( - IrisRiverNetwork configuration, - ProceduralStream height, - ProceduralStream biome, - IrisRegion region, - boolean boreMantleActive, - boolean caveHydrologyActive - ) { - return runtime( - configuration, - height, - biome, - region, - boreMantleActive, - caveHydrologyActive, - true, - true - ); - } - - private static IrisRiverRuntime runtime( - IrisRiverNetwork configuration, - ProceduralStream height, - ProceduralStream biome, - IrisRegion region, - boolean boreMantleActive, - boolean caveHydrologyActive, - boolean blockingRoutingPossible, - boolean biomeRiverOverridesPossible - ) { - return runtime( - configuration, - height, - biome, - region, - boreMantleActive, - caveHydrologyActive, - blockingRoutingPossible, - biomeRiverOverridesPossible, - 63, - 63 - ); - } - - private static IrisRiverRuntime runtimeWithFluidHeights( - IrisRiverNetwork configuration, - int riverFluidHeight, - int dimensionFluidHeight - ) { - IrisBiome biome = new IrisBiome().setInferredType(InferredType.LAND); - return runtime( - configuration, - constantHeight(80D), - ProceduralStream.of( - (x, z) -> biome, - Interpolated.of(value -> 0D, value -> biome) - ), - new IrisRegion(), - false, - true, - true, - true, - riverFluidHeight, - dimensionFluidHeight - ); - } - - private static IrisRiverRuntime runtime( - IrisRiverNetwork configuration, - ProceduralStream height, - ProceduralStream biome, - IrisRegion region, - boolean boreMantleActive, - boolean caveHydrologyActive, - boolean blockingRoutingPossible, - boolean biomeRiverOverridesPossible, - int riverFluidHeight, - int dimensionFluidHeight - ) { - ProceduralStream slope = ProceduralStream.ofDouble((x, z) -> 0.025D); - ProceduralStream oceans = ProceduralStream.of( - (x, z) -> height.getDouble(x, z) < 62D, - Interpolated.BOOLEAN - ); - ProceduralStream regions = ProceduralStream.of( - (x, z) -> region, - Interpolated.of(value -> 0D, value -> region) - ); - return new IrisRiverRuntime(new IrisRiverRuntimeContext( - 4829759234L, - configuration, - mock(IrisData.class), - riverFluidHeight, - dimensionFluidHeight, - boreMantleActive, - caveHydrologyActive, - blockingRoutingPossible, - true, - biomeRiverOverridesPossible, - (x, z) -> new NoiseBounds(0D, 512D), - height, - slope, - oceans, - biome, - regions - )); - } - - private static RiverRoutingContext straightReach(double fromHeight, double toHeight) { - return straightReach(fromHeight, toHeight, 32D); - } - - private static RiverRoutingContext straightReach(double fromHeight, double toHeight, double length) { - RiverNode from = new RiverNode( - new RiverNodeId(0L, 0L), - 0D, - 0D, - fromHeight, - fromHeight, - fromHeight, - fromHeight, - false, - true - ); - RiverNode to = new RiverNode( - new RiverNodeId(1L, 0L), - length, - 0D, - toHeight, - toHeight, - toHeight, - toHeight, - false, - true - ); - return new RiverRoutingContext( - RiverEdgeId.of(from.id(), to.id()), - from, - to, - new RiverPolyline(new double[]{0D, length}, new double[]{0D, 0D}) - ); - } - - private static ProceduralStream constantHeight(double height) { - return ProceduralStream.of((x, z) -> height, Interpolated.DOUBLE); - } - - private static ProceduralStream constantLandBiome() { - IrisBiome land = new IrisBiome().setInferredType(InferredType.LAND); - return ProceduralStream.of( - (x, z) -> land, - Interpolated.of(value -> 0D, value -> land) - ); - } - - private static ProceduralStream countedBiome(IrisBiome biome, AtomicInteger samples) { - return ProceduralStream.of( - (x, z) -> { - samples.incrementAndGet(); - return biome; - }, - Interpolated.of(value -> 0D, value -> biome) - ); - } - - private static IrisRiverNetwork configuration(boolean requireOcean) { - IrisRiverNetwork configuration = new IrisRiverNetwork().setEnabled(true); - configuration.getTopology() - .setCellSize(64) - .setTileCells(1) - .setSiteJitter(0D) - .setMaxRouteReaches(4) - .setSinkSearchReaches(3) - .setRequireOcean(requireOcean); - configuration.getTopology().getSource() - .setChance(1D) - .setInfluence(0D) - .setStyle(flat()); - configuration.getTopology().getContinuation() - .setChance(1D) - .setInfluence(0D) - .setStyle(flat()); - configuration.getTopology().setRoutingStyle(flat()); - configuration.getTerrain() - .setChannelWidth(range(24D)) - .setBankWidth(range(12D)) - .setDepth(range(5D)) - .setMaxIncision(512) - .setWorms(new KList<>(new IrisRiverWorm() - .setSeed(1L) - .setTortuosity(0D) - .setDetailTortuosity(0D) - .setMaxOffset(0D) - .setSegments(1))) - .setBedRoughness(0D) - .setBedRoughnessStyle(flat()) - .setDryContinuationChance(1D); - configuration.getTerrain().getIncision() - .setChance(1D) - .setInfluence(0D) - .setStyle(flat()); - configuration.getBiomes().setSelectionStyle(flat()); - return configuration; - } - - private static IrisRiverSurfaceSample findRiver(IrisRiverRuntime runtime, RiverRouteState state) { - for (int x = -128; x < 192; x += 2) { - for (int z = -128; z < 192; z += 2) { - IrisRiverSurfaceSample sample = runtime.sample(x, z); - if (sample.river().present() && sample.river().state() == state) { - return sample; - } - } - } - return null; - } - - private static boolean hasRiver(IrisRiverRuntime runtime) { - for (int x = 0; x < 64; x += 2) { - for (int z = 0; z < 64; z += 2) { - if (runtime.sample(x, z).river().present()) { - return true; - } - } - } - return false; - } - - private static IrisStyledRange range(double value) { - return new IrisStyledRange(value, value, flat()); - } - - private static IrisGeneratorStyle flat() { - return new IrisGeneratorStyle(NoiseStyle.FLAT); - } -} diff --git a/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandlerTest.java b/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandlerTest.java new file mode 100644 index 000000000..b3447f34f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/common/director/specialhandlers/ReachableBiomeHandlerTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.util.common.director.specialhandlers; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.director.exceptions.DirectorParsingException; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ReachableBiomeHandlerTest { + @Test + public void exposesAndParsesOnlyReachableBiomes() throws Exception { + Engine engine = mock(Engine.class); + IrisBiome natural = new IrisBiome(); + natural.setLoadKey("natural"); + IrisBiome river = new IrisBiome(); + river.setLoadKey("river"); + KList reachable = new KList<>(); + reachable.add(natural); + reachable.add(river); + when(engine.getAllBiomes()).thenReturn(reachable); + + TestReachableBiomeHandler handler = new TestReachableBiomeHandler(engine); + + assertEquals(reachable, handler.getPossibilities()); + assertEquals(river, handler.parse("river", false)); + assertThrows(DirectorParsingException.class, () -> handler.parse("unused", false)); + } + + private static final class TestReachableBiomeHandler extends ReachableBiomeHandler { + private final Engine engine; + + private TestReachableBiomeHandler(Engine engine) { + this.engine = engine; + } + + @Override + public Engine engine() { + return engine; + } + } +} diff --git a/core/src/test/java/art/arcane/iris/util/project/context/ChunkContextPrefillPlanTest.java b/core/src/test/java/art/arcane/iris/util/project/context/ChunkContextPrefillPlanTest.java index eacef45c4..41717d103 100644 --- a/core/src/test/java/art/arcane/iris/util/project/context/ChunkContextPrefillPlanTest.java +++ b/core/src/test/java/art/arcane/iris/util/project/context/ChunkContextPrefillPlanTest.java @@ -1,20 +1,29 @@ package art.arcane.iris.util.project.context; import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.util.project.stream.ProceduralStream; import org.bukkit.block.data.BlockData; import org.junit.Test; +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 static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyDouble; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; public class ChunkContextPrefillPlanTest { @Test @@ -83,6 +92,73 @@ public class ChunkContextPrefillPlanTest { assertFalse(ChunkContext.shouldPrefillAsync(1)); } + @Test + public void hydrologyPreparationCompletesBeforeDependentPrefills() throws Exception { + AtomicInteger caveCalls = new AtomicInteger(); + AtomicInteger heightCalls = new AtomicInteger(); + AtomicInteger biomeCalls = new AtomicInteger(); + AtomicInteger rockCalls = new AtomicInteger(); + AtomicInteger fluidCalls = new AtomicInteger(); + AtomicInteger regionCalls = new AtomicInteger(); + CountDownLatch preparationStarted = new CountDownLatch(1); + CountDownLatch preparationRelease = new CountDownLatch(1); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + doAnswer(invocation -> { + preparationStarted.countDown(); + assertTrue(preparationRelease.await(10L, TimeUnit.SECONDS)); + return null; + }).when(runtime).prepareChunkColumns(32, 48); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future future = executor.submit(() -> createContext( + ChunkContext.PrefillPlan.NO_CAVE, + caveCalls, + heightCalls, + biomeCalls, + rockCalls, + fluidCalls, + regionCalls, + runtime + )); + assertTrue(preparationStarted.await(10L, TimeUnit.SECONDS)); + assertEquals(0, heightCalls.get()); + assertEquals(0, biomeCalls.get()); + preparationRelease.countDown(); + future.get(10L, TimeUnit.SECONDS); + } finally { + preparationRelease.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(10L, TimeUnit.SECONDS)); + } + assertEquals(256, heightCalls.get()); + assertEquals(256, biomeCalls.get()); + verify(runtime).prepareChunkColumns(32, 48); + } + + @Test + public void hydrologyPreparationIsSkippedWithoutDependentPrefills() { + AtomicInteger caveCalls = new AtomicInteger(); + AtomicInteger heightCalls = new AtomicInteger(); + AtomicInteger biomeCalls = new AtomicInteger(); + AtomicInteger rockCalls = new AtomicInteger(); + AtomicInteger fluidCalls = new AtomicInteger(); + AtomicInteger regionCalls = new AtomicInteger(); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + + createContext( + ChunkContext.PrefillPlan.NONE, + caveCalls, + heightCalls, + biomeCalls, + rockCalls, + fluidCalls, + regionCalls, + runtime + ); + + verify(runtime, never()).prepareChunkColumns(32, 48); + } + private ChunkContext createContext( ChunkContext.PrefillPlan prefillPlan, AtomicInteger caveCalls, @@ -91,6 +167,28 @@ public class ChunkContextPrefillPlanTest { AtomicInteger rockCalls, AtomicInteger fluidCalls, AtomicInteger regionCalls + ) { + return createContext( + prefillPlan, + caveCalls, + heightCalls, + biomeCalls, + rockCalls, + fluidCalls, + regionCalls, + null + ); + } + + private ChunkContext createContext( + ChunkContext.PrefillPlan prefillPlan, + AtomicInteger caveCalls, + AtomicInteger heightCalls, + AtomicInteger biomeCalls, + AtomicInteger rockCalls, + AtomicInteger fluidCalls, + AtomicInteger regionCalls, + IrisHydrologyRuntime runtime ) { IrisComplex complex = mock(IrisComplex.class); @@ -151,6 +249,7 @@ public class ChunkContextPrefillPlanTest { doReturn(rockStream).when(complex).getRockStream(); doReturn(fluidStream).when(complex).getFluidStream(); doReturn(regionStream).when(complex).getRegionStream(); + doReturn(runtime).when(complex).getHydrologyRuntime(); return new ChunkContext(32, 48, complex, true, prefillPlan, null); } diff --git a/core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java b/core/src/test/java/art/arcane/iris/util/project/matter/slices/HydrologyCaveMatterTest.java similarity index 50% rename from core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java rename to core/src/test/java/art/arcane/iris/util/project/matter/slices/HydrologyCaveMatterTest.java index c0008d65f..d9f95fc3d 100644 --- a/core/src/test/java/art/arcane/iris/util/project/matter/slices/RiverCaveHydrologyMatterTest.java +++ b/core/src/test/java/art/arcane/iris/util/project/matter/slices/HydrologyCaveMatterTest.java @@ -1,8 +1,7 @@ package art.arcane.iris.util.project.matter.slices; -import art.arcane.iris.engine.river.cave.RiverCaveAction; -import art.arcane.iris.engine.river.cave.RiverCaveFluidKind; -import art.arcane.iris.engine.river.cave.RiverCaveHydrology; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import org.junit.Test; import java.io.ByteArrayInputStream; @@ -10,25 +9,27 @@ import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.List; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; -public class RiverCaveHydrologyMatterTest { +public class HydrologyCaveMatterTest { @Test public void everyActionAndBiomeRoundTrips() throws IOException { - RiverCaveHydrologyMatter matter = new RiverCaveHydrologyMatter(); - for (RiverCaveAction action : RiverCaveAction.values()) { - for (RiverCaveFluidKind fluidKind : RiverCaveFluidKind.values()) { - RiverCaveHydrology expected = new RiverCaveHydrology( + HydrologyCaveMatter matter = new HydrologyCaveMatter(); + for (HydrologyCaveAction action : HydrologyCaveAction.values()) { + for (String profileKey : List.of("default", "underworld", "deep_lava")) { + HydrologyCaveCell expected = new HydrologyCaveCell( action, - "iris:flooded_grotto", - fluidKind + profileKey, + "iris:flooded_grotto" ); ByteArrayOutputStream bytes = new ByteArrayOutputStream(); matter.writeNode(expected, new DataOutputStream(bytes)); - RiverCaveHydrology actual = matter.readNode( + HydrologyCaveCell actual = matter.readNode( new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); assertEquals(expected, actual); @@ -38,8 +39,8 @@ public class RiverCaveHydrologyMatterTest { @Test public void emptyBiomeAndInvalidActionRemainUnambiguous() throws IOException { - RiverCaveHydrologyMatter matter = new RiverCaveHydrologyMatter(); - RiverCaveHydrology expected = RiverCaveHydrology.of(RiverCaveAction.DRY_AIR); + HydrologyCaveMatter matter = new HydrologyCaveMatter(); + HydrologyCaveCell expected = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR); ByteArrayOutputStream bytes = new ByteArrayOutputStream(); matter.writeNode(expected, new DataOutputStream(bytes)); @@ -47,7 +48,17 @@ public class RiverCaveHydrologyMatterTest { new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())))); assertThrows(IOException.class, () -> matter.readNode( new DataInputStream(new ByteArrayInputStream(new byte[]{99})))); - assertThrows(IOException.class, () -> matter.readNode( - new DataInputStream(new ByteArrayInputStream(new byte[]{1, 99})))); + assertThrows(IllegalArgumentException.class, () -> new HydrologyCaveCell( + HydrologyCaveAction.WET_SOURCE, + " ", + "iris:flooded_grotto" + )); + } + + @Test + public void everyPlannedCellProtectsItsAcceptedVolume() { + for (HydrologyCaveAction action : HydrologyCaveAction.values()) { + assertTrue(HydrologyCaveCell.of(action).protectsPlacement()); + } } } diff --git a/core/src/test/java/art/arcane/iris/util/project/matter/slices/PreObjectMatterTest.java b/core/src/test/java/art/arcane/iris/util/project/matter/slices/PreObjectMatterTest.java new file mode 100644 index 000000000..6544d51e9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/project/matter/slices/PreObjectMatterTest.java @@ -0,0 +1,154 @@ +package art.arcane.iris.util.project.matter.slices; + +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.matter.IrisMatterSupport; +import art.arcane.iris.util.project.matter.PreObjectMatterCell; +import art.arcane.volmlib.util.matter.IrisMatter; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterCavern; +import art.arcane.volmlib.util.matter.MatterSlice; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.block.data.BlockData; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Locale; +import java.util.logging.Logger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +public class PreObjectMatterTest { + @BeforeClass + public static void setUpBukkit() { + if (Bukkit.getServer() != null) { + return; + } + Server server = mock(Server.class); + doReturn(Logger.getLogger("IrisTest")).when(server).getLogger(); + doReturn("IrisTestServer").when(server).getName(); + doReturn("1.0").when(server).getVersion(); + doReturn("1.0").when(server).getBukkitVersion(); + doAnswer(invocation -> blockData(invocation.getArgument(0, Material.class).name() + .toLowerCase(Locale.ROOT))).when(server).createBlockData(any(Material.class)); + try { + Bukkit.setServer(server); + } catch (Throwable ignored) { + } + } + + @Test + public void capturedNullsAndValuesRoundTripThroughMatter() throws IOException { + IrisMatterSupport.ensureRegistered(); + MatterCavern cavern = new MatterCavern(true, "iris:flooded", (byte) 1); + PreObjectMatterCell expected = PreObjectMatterCell.block(null) + .captureString("object-owner") + .captureCavern(cavern); + Matter matter = new IrisMatter(16, 16, 16); + matter.slice(PreObjectMatterCell.class).set(2, 3, 4, expected); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.write(bytes); + Matter restored = Matter.read(new ByteArrayInputStream(bytes.toByteArray())); + PreObjectMatterCell actual = restored.getSlice(PreObjectMatterCell.class) + .get(2, 3, 4); + + assertEquals(expected, actual); + assertTrue(actual.blockCaptured()); + assertNull(actual.block()); + assertEquals(1, restored.getSlice(PreObjectMatterCell.class).getEntryCount()); + } + + @Test + public void everySupportedNullTombstoneRoundTripsIndependently() throws IOException { + IrisMatterSupport.ensureRegistered(); + Matter matter = new IrisMatter(16, 16, 16); + MatterSlice journal = matter.slice(PreObjectMatterCell.class); + journal.set(1, 1, 1, PreObjectMatterCell.block(null)); + journal.set(2, 2, 2, PreObjectMatterCell.string(null)); + journal.set(3, 3, 3, PreObjectMatterCell.cavern(null)); + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.write(bytes); + Matter restored = Matter.read(new ByteArrayInputStream(bytes.toByteArray())); + MatterSlice restoredJournal = restored.getSlice(PreObjectMatterCell.class); + + PreObjectMatterCell block = restoredJournal.get(1, 1, 1); + PreObjectMatterCell string = restoredJournal.get(2, 2, 2); + PreObjectMatterCell cavern = restoredJournal.get(3, 3, 3); + assertTrue(block.blockCaptured()); + assertNull(block.block()); + assertTrue(string.stringCaptured()); + assertNull(string.string()); + assertTrue(cavern.cavernCaptured()); + assertNull(cavern.cavern()); + assertEquals(3, restoredJournal.getEntryCount()); + } + + @Test + public void blockKeyHasAnExplicitPresenceFlag() throws IOException { + PlatformBlockState block = mock(PlatformBlockState.class); + when(block.key()).thenReturn("minecraft:stone"); + PreObjectMatter matter = new PreObjectMatter(); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.writeNode(PreObjectMatterCell.block(block), new DataOutputStream(bytes)); + + DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())); + assertEquals(3, input.readUnsignedByte()); + assertEquals("minecraft:stone", input.readUTF()); + assertEquals(0, input.available()); + } + + @Test + public void malformedFlagsAreRejected() { + PreObjectMatter matter = new PreObjectMatter(); + + assertThrows(IOException.class, () -> matter.readNode(input(0))); + assertThrows(IOException.class, () -> matter.readNode(input(1 << 1))); + assertThrows(IOException.class, () -> matter.readNode(input(1 << 6))); + } + + @Test + public void capturesRemainIndependentAndFirstWriteWins() { + MatterCavern cavern = new MatterCavern(true, "", (byte) 0); + PlatformBlockState block = mock(PlatformBlockState.class); + PreObjectMatterCell cell = PreObjectMatterCell.string(null) + .captureBlock(block) + .captureCavern(cavern) + .captureString("ignored"); + + assertTrue(cell.blockCaptured()); + assertTrue(cell.stringCaptured()); + assertTrue(cell.cavernCaptured()); + assertEquals(block, cell.original(PlatformBlockState.class)); + assertNull(cell.original(String.class)); + assertEquals(cavern, cell.original(MatterCavern.class)); + assertFalse(cell.captures(Integer.class)); + assertThrows(IllegalArgumentException.class, () -> cell.original(Integer.class)); + } + + private DataInputStream input(int flags) { + return new DataInputStream(new ByteArrayInputStream(new byte[]{(byte) flags})); + } + + private static BlockData blockData(String key) { + BlockData data = mock(BlockData.class); + doReturn("minecraft:" + key).when(data).getAsString(); + return data; + } +} diff --git a/core/src/test/java/art/arcane/iris/util/project/stream/ProceduralStreamCacheBypassTest.java b/core/src/test/java/art/arcane/iris/util/project/stream/ProceduralStreamCacheBypassTest.java new file mode 100644 index 000000000..e02417a3a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/project/stream/ProceduralStreamCacheBypassTest.java @@ -0,0 +1,173 @@ +package art.arcane.iris.util.project.stream; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; +import art.arcane.iris.util.project.stream.utility.CachedDoubleStream2D; +import art.arcane.iris.util.project.stream.utility.CachedStream2D; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; + +public class ProceduralStreamCacheBypassTest { + private PreservationRegistry previousRegistry; + private Engine engine; + + @Before + public void registerPreservationService() { + previousRegistry = IrisServices.getOrNull(PreservationRegistry.class); + IrisServices.register(PreservationRegistry.class, new NoOpPreservationRegistry()); + engine = mock(Engine.class); + } + + @After + public void restorePreservationService() { + if (previousRegistry == null) { + IrisServices.remove(PreservationRegistry.class); + } else { + IrisServices.register(PreservationRegistry.class, previousRegistry); + } + } + + @Test + public void normalAccessCachesIntegerQuantizedValues() { + AtomicInteger genericCalls = new AtomicInteger(); + ProceduralStream genericSource = ProceduralStream.of( + (x, z) -> genericCalls.incrementAndGet(), + Interpolated.INT + ); + CachedStream2D genericCache = new CachedStream2D<>("generic", engine, genericSource, 8); + + assertEquals(Integer.valueOf(1), genericCache.get(12.9D, -3.9D)); + assertEquals(Integer.valueOf(1), genericCache.get(12.1D, -3.1D)); + assertEquals(1, genericCalls.get()); + assertEquals(256L, genericCache.getSize()); + + AtomicInteger doubleCalls = new AtomicInteger(); + ProceduralStream doubleSource = ProceduralStream.ofDouble((x, z) -> { + doubleCalls.incrementAndGet(); + return x * 31D + z; + }); + CachedDoubleStream2D doubleCache = new CachedDoubleStream2D("double", engine, doubleSource, 8); + + assertEquals(-1_026D, doubleCache.getDouble(-33.9D, -3.9D), 0D); + assertEquals(-1_026D, doubleCache.getDouble(-33.1D, -3.1D), 0D); + assertEquals(1, doubleCalls.get()); + assertEquals(256L, doubleCache.getSize()); + } + + @Test + public void bypassMatchesNestedTwoDimensionalSemanticsWithoutGrowingCaches() { + DoubleChain baseline = new DoubleChain(engine); + DoubleChain bypassed = new DoubleChain(engine); + for (int index = 0; index < 32; index++) { + double x = index * 64D + 4.875D; + double z = index * -96D + 5.625D; + double expected = baseline.output.get(x, z); + double actual = ProceduralStream.bypass2DCaches(() -> bypassed.output.get(x, z)); + assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual)); + } + + assertEquals(2_048L, baseline.inner.getSize()); + assertEquals(2_048L, baseline.converted.getSize()); + assertEquals(2_048L, baseline.output.getSize()); + assertEquals(0L, bypassed.inner.getSize()); + assertEquals(0L, bypassed.converted.getSize()); + assertEquals(0L, bypassed.output.getSize()); + } + + @Test + public void nestedScopesRestoreAfterExceptions() { + DoubleChain chain = new DoubleChain(engine); + + ProceduralStream.bypass2DCaches(() -> { + assertThrows(IllegalStateException.class, () -> ProceduralStream.bypass2DCaches(() -> { + chain.output.getDouble(3_000.75D, -4_000.25D); + throw new IllegalStateException("expected"); + })); + chain.output.getDouble(5_000.75D, -6_000.25D); + assertEquals(0L, chain.inner.getSize()); + assertEquals(0L, chain.converted.getSize()); + assertEquals(0L, chain.output.getSize()); + return null; + }); + + chain.output.getDouble(7_000.75D, -8_000.25D); + assertEquals(256L, chain.inner.getSize()); + assertEquals(256L, chain.converted.getSize()); + assertEquals(256L, chain.output.getSize()); + } + + @Test + public void bypassIsThreadLocal() { + CachedDoubleStream2D cache = new CachedDoubleStream2D( + "thread-local", + engine, + ProceduralStream.ofDouble((x, z) -> x * 17D + z), + 8 + ); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + ProceduralStream.bypass2DCaches(() -> { + CompletableFuture otherThread = CompletableFuture.supplyAsync( + () -> cache.getDouble(1_024.75D, 2_048.25D), + executor + ); + assertEquals(19_456D, otherThread.join(), 0D); + assertEquals(256L, cache.getSize()); + cache.getDouble(4_096.75D, 8_192.25D); + assertEquals(256L, cache.getSize()); + return null; + }); + cache.getDouble(12_288.75D, 16_384.25D); + assertEquals(512L, cache.getSize()); + } finally { + executor.shutdownNow(); + } + } + + private static final class DoubleChain { + private final CachedDoubleStream2D inner; + private final CachedStream2D converted; + private final CachedDoubleStream2D output; + + private DoubleChain(Engine engine) { + ProceduralStream source = ProceduralStream.ofDouble( + (x, z) -> x * x * 0.125D + z * z * 0.25D + x * z * 0.0625D + ); + inner = new CachedDoubleStream2D("inner", engine, source, 8); + ProceduralStream conversion = inner.convert(value -> value * 1.25D - 7D); + converted = new CachedStream2D<>("converted", engine, conversion, 8); + output = new CachedDoubleStream2D("output", engine, converted.slope(3), 8); + } + } + + private static final class NoOpPreservationRegistry implements PreservationRegistry { + @Override + public void register(Thread thread) { + } + + @Override + public void register(ExecutorService service) { + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } +} diff --git a/probe/build.gradle b/probe/build.gradle index c01c08d75..952062d96 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -49,6 +49,15 @@ tasks.named('test').configure { dependsOn(':core:compileJava') } +tasks.register('hydrologyProbe', JavaExec) { + group = 'verification' + description = 'Scans deterministic hydrology seeds and reports accepted feature coordinates plus cold and warm planner timings.' + mainClass = 'art.arcane.iris.probe.HydrologyPlannerProbe' + classpath = sourceSets.main.runtimeClasspath + jvmArgs('--add-modules', 'jdk.incubator.vector') + dependsOn(':core:compileJava') +} + Provider probePack = providers.gradleProperty('probePack') Provider probeDimension = providers.gradleProperty('probeDimension') Provider probeWarmupChunks = providers.gradleProperty('probeWarmupChunks').orElse('256') @@ -57,6 +66,22 @@ Provider probeCenterChunkX = providers.gradleProperty('probeCenterChunkX Provider probeCenterChunkZ = providers.gradleProperty('probeCenterChunkZ').orElse('2048') Provider probeMulticore = providers.gradleProperty('probeMulticore').orElse('false') Provider probeStudio = providers.gradleProperty('probeStudio').orElse('false') +Provider explicitProbeMulticore = providers.gradleProperty('probeMulticore') +Provider explicitProbeStudio = providers.gradleProperty('probeStudio') +Provider probeSeeds = providers.gradleProperty('probeSeeds') +Provider probeMinimumTileX = providers.gradleProperty('probeMinimumTileX') +Provider probeMaximumTileX = providers.gradleProperty('probeMaximumTileX') +Provider probeMinimumTileZ = providers.gradleProperty('probeMinimumTileZ') +Provider probeMaximumTileZ = providers.gradleProperty('probeMaximumTileZ') +Provider probeRequiredCoverage = providers.gradleProperty('probeRequiredCoverage') +Provider probeSeed = providers.gradleProperty('probeSeed') +Provider probeMinimumChunkX = providers.gradleProperty('probeMinimumChunkX') +Provider probeMaximumChunkX = providers.gradleProperty('probeMaximumChunkX') +Provider probeMinimumChunkZ = providers.gradleProperty('probeMinimumChunkZ') +Provider probeMaximumChunkZ = providers.gradleProperty('probeMaximumChunkZ') +Provider probeParallelism = providers.gradleProperty('probeParallelism') +Provider probeShuffleSeed = providers.gradleProperty('probeShuffleSeed') +Provider probeJfr = providers.gradleProperty('probeJfr') tasks.register('genProbe', JavaExec) { group = 'verification' @@ -84,6 +109,117 @@ tasks.register('genProbe', JavaExec) { } } +tasks.register('hydrologyPackProbe', JavaExec) { + group = 'verification' + description = 'Scans explicitly bounded real-pack hydrology tiles across multiple engine seeds and enforces accepted feature/profile coverage.' + mainClass = 'art.arcane.iris.probe.HydrologyPackProbe' + classpath = sourceSets.main.runtimeClasspath + jvmArgs('--add-modules', 'jdk.incubator.vector') + dependsOn(':core:compileJava') + doFirst { + Map> required = [ + probePack: probePack, + probeDimension: probeDimension, + probeSeeds: probeSeeds, + probeMinimumTileX: probeMinimumTileX, + probeMaximumTileX: probeMaximumTileX, + probeMinimumTileZ: probeMinimumTileZ, + probeMaximumTileZ: probeMaximumTileZ, + probeRequiredCoverage: probeRequiredCoverage, + probeStudio: explicitProbeStudio + ] + required.each { String name, Provider value -> + if (!value.isPresent() || value.get().isBlank()) { + throw new GradleException("hydrologyPackProbe requires -P${name}=") + } + } + args( + probePack.get(), + probeDimension.get(), + probeSeeds.get(), + probeMinimumTileX.get(), + probeMaximumTileX.get(), + probeMinimumTileZ.get(), + probeMaximumTileZ.get(), + probeRequiredCoverage.get(), + explicitProbeStudio.get()) + } +} + +tasks.register('generationOrderProbe', JavaExec) { + group = 'verification' + description = 'Compares complete real-pack chunk block and biome hashes across forward, reverse, shuffled and bounded-parallel generation orders.' + mainClass = 'art.arcane.iris.probe.GenerationOrderProbe' + classpath = sourceSets.main.runtimeClasspath + jvmArgs('--add-modules', 'jdk.incubator.vector') + dependsOn(':core:compileJava') + doFirst { + Map> required = [ + probePack: probePack, + probeDimension: probeDimension, + probeSeed: probeSeed, + probeMinimumChunkX: probeMinimumChunkX, + probeMaximumChunkX: probeMaximumChunkX, + probeMinimumChunkZ: probeMinimumChunkZ, + probeMaximumChunkZ: probeMaximumChunkZ, + probeParallelism: probeParallelism, + probeShuffleSeed: probeShuffleSeed, + probeMulticore: explicitProbeMulticore, + probeStudio: explicitProbeStudio + ] + required.each { String name, Provider value -> + if (!value.isPresent() || value.get().isBlank()) { + throw new GradleException("generationOrderProbe requires -P${name}=") + } + } + args( + probePack.get(), + probeDimension.get(), + probeSeed.get(), + probeMinimumChunkX.get(), + probeMaximumChunkX.get(), + probeMinimumChunkZ.get(), + probeMaximumChunkZ.get(), + probeParallelism.get(), + probeShuffleSeed.get(), + explicitProbeMulticore.get(), + explicitProbeStudio.get()) + } +} + +['hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe'].each { String taskName -> + tasks.named(taskName, JavaExec).configure { JavaExec task -> + doFirst { + if (probeJfr.isPresent()) { + String requestedPath = probeJfr.get() + if (requestedPath.isBlank()) { + throw new GradleException('probeJfr must be a nonblank absolute path') + } + File recording = new File(requestedPath) + if (!recording.isAbsolute()) { + throw new GradleException('probeJfr must be an absolute path') + } + if (recording.isDirectory()) { + throw new GradleException('probeJfr must name a recording file, not a directory') + } + java.nio.file.Path normalized = recording.canonicalFile.toPath() + java.nio.file.Path repository = rootProject.projectDir.canonicalFile.toPath() + if (normalized.startsWith(repository)) { + throw new GradleException('probeJfr must point outside the repository') + } + File parent = normalized.toFile().parentFile + if (parent == null || !parent.isDirectory()) { + throw new GradleException('probeJfr parent directory must already exist') + } + task.jvmArgs( + '-XX:FlightRecorderOptions=stackdepth=256', + "-XX:StartFlightRecording=filename=${normalized},settings=profile,dumponexit=true" + ) + } + } + } +} + String deserializationPack = providers.gradleProperty('deserializationPack') .orElse(project.file('src/main/resources/deserialization-pack').absolutePath) .get() diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java new file mode 100644 index 000000000..48e819fe3 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationOrderProbe.java @@ -0,0 +1,599 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Random; +import java.util.TreeMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +public final class GenerationOrderProbe { + private static final String LOG_PREFIX = "[generation-order-probe]"; + private static final int MAXIMUM_CHUNKS = 1_024; + private static final int MAXIMUM_PARALLELISM = 32; + private static final int MINIMUM_SAFE_CHUNK_COORDINATE = -134_217_728; + private static final int MAXIMUM_SAFE_CHUNK_COORDINATE = 134_217_727; + + private GenerationOrderProbe() { + } + + enum GenerationOrder { + FORWARD, + REVERSE, + SHUFFLED, + BOUNDED_PARALLEL + } + + record ProbeConfiguration( + File packSource, + String dimensionKey, + long seed, + int minimumChunkX, + int maximumChunkX, + int minimumChunkZ, + int maximumChunkZ, + int parallelism, + long shuffleSeed, + boolean multicore, + boolean studio + ) { + ProbeConfiguration { + if (packSource == null) { + throw new IllegalArgumentException("Pack folder is required."); + } + if (dimensionKey == null || dimensionKey.isBlank() + || dimensionKey.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("Dimension key must be non-blank and contain no whitespace."); + } + if (minimumChunkX > maximumChunkX || minimumChunkZ > maximumChunkZ) { + throw new IllegalArgumentException("Minimum chunk bounds cannot exceed maximum chunk bounds."); + } + if (minimumChunkX < MINIMUM_SAFE_CHUNK_COORDINATE + || maximumChunkX > MAXIMUM_SAFE_CHUNK_COORDINATE + || minimumChunkZ < MINIMUM_SAFE_CHUNK_COORDINATE + || maximumChunkZ > MAXIMUM_SAFE_CHUNK_COORDINATE) { + throw new IllegalArgumentException("Chunk bounds overflow their block origins."); + } + long chunks = boundedArea(minimumChunkX, maximumChunkX, minimumChunkZ, maximumChunkZ); + if (chunks > MAXIMUM_CHUNKS) { + throw new IllegalArgumentException("Generation-order probe exceeds " + + MAXIMUM_CHUNKS + " chunks."); + } + if (parallelism < 2 || parallelism > MAXIMUM_PARALLELISM) { + throw new IllegalArgumentException("Parallelism must be between 2 and " + + MAXIMUM_PARALLELISM + "."); + } + } + + static ProbeConfiguration parse(String[] arguments) { + if (arguments.length != 11) { + throw new IllegalArgumentException( + "Expected: " + + " "); + } + return new ProbeConfiguration( + new File(arguments[0]), + arguments[1], + Long.parseLong(arguments[2]), + Integer.parseInt(arguments[3]), + Integer.parseInt(arguments[4]), + Integer.parseInt(arguments[5]), + Integer.parseInt(arguments[6]), + Integer.parseInt(arguments[7]), + Long.parseLong(arguments[8]), + RealPackProbeSupport.parseBoolean(arguments[9], "multicore"), + RealPackProbeSupport.parseBoolean(arguments[10], "studio") + ); + } + + int chunkCount() { + return Math.toIntExact(boundedArea( + minimumChunkX, maximumChunkX, minimumChunkZ, maximumChunkZ)); + } + } + + record ChunkCoordinate(int x, int z) implements Comparable { + @Override + public int compareTo(ChunkCoordinate other) { + int zComparison = Integer.compare(z, other.z); + return zComparison != 0 ? zComparison : Integer.compare(x, other.x); + } + } + + record ChunkHash( + String blocks, + String biomes, + String combined, + Map blockCounts, + Map blockCountsByY + ) { + ChunkHash(String blocks, String biomes, String combined) { + this(blocks, biomes, combined, Map.of(), Map.of()); + } + + ChunkHash { + blockCounts = Map.copyOf(blockCounts); + blockCountsByY = Map.copyOf(blockCountsByY); + } + } + + record AggregateSignature(String blocks, String biomes, String combined) { + } + + record ChunkOutput(ChunkCoordinate coordinate, ChunkHash hash) { + } + + record ModeResult( + GenerationOrder order, + Map chunks, + AggregateSignature signature, + long engineReadyNanos, + long generationNanos + ) { + } + + record ProbeResult( + String status, + String dimensionKey, + long seed, + int chunks, + int minimumChunkX, + int maximumChunkX, + int minimumChunkZ, + int maximumChunkZ, + int parallelism, + long shuffleSeed, + boolean multicore, + boolean studio, + AggregateSignature signature + ) { + String machineLine() { + return String.format( + Locale.ROOT, + "IRIS_GENERATION_ORDER_RESULT version=1 status=%s dimension=%s seed=%d chunks=%d " + + "chunk_x=%d..%d chunk_z=%d..%d parallelism=%d shuffle_seed=%d " + + "multicore=%s studio=%s " + + "block_signature=%s biome_signature=%s combined_signature=%s", + status, + dimensionKey, + seed, + chunks, + minimumChunkX, + maximumChunkX, + minimumChunkZ, + maximumChunkZ, + parallelism, + shuffleSeed, + multicore, + studio, + signature.blocks(), + signature.biomes(), + signature.combined() + ); + } + } + + public static void main(String[] arguments) { + ProbeConfiguration configuration; + try { + configuration = ProbeConfiguration.parse(arguments); + } catch (Throwable failure) { + System.out.println(LOG_PREFIX + " FAIL: " + failure.getMessage()); + failure.printStackTrace(System.out); + System.exit(2); + return; + } + + try { + ProbeResult result = run(configuration); + System.out.println(result.machineLine()); + System.exit(0); + } catch (Throwable failure) { + System.out.println(LOG_PREFIX + " FAIL: probe execution failed"); + failure.printStackTrace(System.out); + AggregateSignature unavailable = new AggregateSignature("unavailable", "unavailable", "unavailable"); + ProbeResult failed = new ProbeResult( + "FAIL", + configuration.dimensionKey(), + configuration.seed(), + configuration.chunkCount(), + configuration.minimumChunkX(), + configuration.maximumChunkX(), + configuration.minimumChunkZ(), + configuration.maximumChunkZ(), + configuration.parallelism(), + configuration.shuffleSeed(), + configuration.multicore(), + configuration.studio(), + unavailable + ); + System.out.println(failed.machineLine()); + System.exit(1); + } + } + + static List coordinates(ProbeConfiguration configuration) { + ArrayList coordinates = new ArrayList<>(configuration.chunkCount()); + for (long chunkZ = configuration.minimumChunkZ(); chunkZ <= configuration.maximumChunkZ(); chunkZ++) { + for (long chunkX = configuration.minimumChunkX(); chunkX <= configuration.maximumChunkX(); chunkX++) { + coordinates.add(new ChunkCoordinate((int) chunkX, (int) chunkZ)); + } + } + return List.copyOf(coordinates); + } + + static List orderedCoordinates( + List coordinates, + GenerationOrder order, + long shuffleSeed + ) { + ArrayList ordered = new ArrayList<>(coordinates); + switch (order) { + case FORWARD -> { + } + case REVERSE -> Collections.reverse(ordered); + case SHUFFLED, BOUNDED_PARALLEL -> Collections.shuffle(ordered, new Random(shuffleSeed)); + } + return List.copyOf(ordered); + } + + static AggregateSignature aggregateSignature(Map chunks) { + MessageDigest blocks = sha256(); + MessageDigest biomes = sha256(); + MessageDigest combined = sha256(); + TreeMap ordered = new TreeMap<>(chunks); + for (Map.Entry entry : ordered.entrySet()) { + ChunkCoordinate coordinate = entry.getKey(); + ChunkHash hash = entry.getValue(); + updateDigest(blocks, coordinate.x() + "," + coordinate.z()); + updateDigest(blocks, hash.blocks()); + updateDigest(biomes, coordinate.x() + "," + coordinate.z()); + updateDigest(biomes, hash.biomes()); + updateDigest(combined, coordinate.x() + "," + coordinate.z()); + updateDigest(combined, hash.combined()); + } + return new AggregateSignature( + HexFormat.of().formatHex(blocks.digest()), + HexFormat.of().formatHex(biomes.digest()), + HexFormat.of().formatHex(combined.digest()) + ); + } + + static List mismatchedChunks( + Map expected, + Map observed + ) { + TreeMap all = new TreeMap<>(expected); + for (Map.Entry entry : observed.entrySet()) { + all.putIfAbsent(entry.getKey(), entry.getValue()); + } + ArrayList mismatches = new ArrayList<>(); + for (ChunkCoordinate coordinate : all.keySet()) { + if (!Objects.equals(expected.get(coordinate), observed.get(coordinate))) { + mismatches.add(coordinate); + } + } + return List.copyOf(mismatches); + } + + static ChunkHash hashChunk( + ChunkCoordinate coordinate, + Hunk blocks, + Hunk biomes, + int height + ) { + MessageDigest blockDigest = sha256(); + MessageDigest biomeDigest = sha256(); + MessageDigest combinedDigest = sha256(); + HashMap encodedKeys = new HashMap<>(); + HashMap blockCounts = new HashMap<>(); + HashMap blockCountsByY = new HashMap<>(); + updateDigest(blockDigest, coordinate.x() + "," + coordinate.z() + "," + height); + updateDigest(biomeDigest, coordinate.x() + "," + coordinate.z() + "," + height); + updateDigest(combinedDigest, coordinate.x() + "," + coordinate.z() + "," + height); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 0; y < height; y++) { + PlatformBlockState state = blocks.get(x, y, z); + PlatformBiome biome = biomes.get(x, y, z); + String blockKey = state == null ? "minecraft:air" : state.key(); + String biomeKey = biome == null ? "" : biome.key(); + blockCounts.merge(blockKey, 1, Integer::sum); + blockCountsByY.merge(y + ":" + blockKey, 1, Integer::sum); + byte[] encodedBlock = encoded(encodedKeys, blockKey); + byte[] encodedBiome = encoded(encodedKeys, biomeKey); + updateDigest(blockDigest, encodedBlock); + updateDigest(biomeDigest, encodedBiome); + combinedDigest.update((byte) 'B'); + updateDigest(combinedDigest, encodedBlock); + combinedDigest.update((byte) 'M'); + updateDigest(combinedDigest, encodedBiome); + } + } + } + return new ChunkHash( + HexFormat.of().formatHex(blockDigest.digest()), + HexFormat.of().formatHex(biomeDigest.digest()), + HexFormat.of().formatHex(combinedDigest.digest()), + blockCounts, + blockCountsByY + ); + } + + private static ProbeResult run(ProbeConfiguration configuration) throws Exception { + System.out.println(LOG_PREFIX + " pack: " + configuration.packSource().getAbsolutePath()); + System.out.println(LOG_PREFIX + " dimension: " + configuration.dimensionKey()); + System.out.println(LOG_PREFIX + " seed: " + configuration.seed()); + System.out.println(LOG_PREFIX + " chunk bounds: " + configuration.minimumChunkX() + ".." + + configuration.maximumChunkX() + "," + configuration.minimumChunkZ() + ".." + + configuration.maximumChunkZ()); + System.out.println(LOG_PREFIX + " chunks: " + configuration.chunkCount()); + System.out.println(LOG_PREFIX + " parallelism: " + configuration.parallelism()); + System.out.println(LOG_PREFIX + " shuffle seed: " + configuration.shuffleSeed()); + System.out.println(LOG_PREFIX + " multicore: " + configuration.multicore()); + System.out.println(LOG_PREFIX + " studio: " + configuration.studio()); + + List coordinates = coordinates(configuration); + LinkedHashMap results = new LinkedHashMap<>(); + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + configuration.packSource(), configuration.dimensionKey(), LOG_PREFIX)) { + for (GenerationOrder order : GenerationOrder.values()) { + ModeResult result = generateMode(configuration, workspace, coordinates, order); + results.put(order, result); + printMode(result); + } + } + + ModeResult baseline = results.get(GenerationOrder.FORWARD); + for (GenerationOrder order : GenerationOrder.values()) { + if (order == GenerationOrder.FORWARD) { + continue; + } + ModeResult observed = results.get(order); + List mismatches = mismatchedChunks(baseline.chunks(), observed.chunks()); + if (mismatches.isEmpty()) { + continue; + } + int reportCount = Math.min(16, mismatches.size()); + for (int index = 0; index < reportCount; index++) { + ChunkCoordinate coordinate = mismatches.get(index); + ChunkHash expected = baseline.chunks().get(coordinate); + ChunkHash actual = observed.chunks().get(coordinate); + System.out.println("IRIS_GENERATION_ORDER_MISMATCH mode=" + order.name() + + " chunk_x=" + coordinate.x() + " chunk_z=" + coordinate.z() + + " forward_blocks=" + expected.blocks() + + " observed_blocks=" + actual.blocks() + + " forward_biomes=" + expected.biomes() + + " observed_biomes=" + actual.biomes() + + " block_count_delta=" + blockCountDelta(expected, actual) + + " block_y_count_delta=" + blockYCountDelta(expected, actual)); + } + throw new IllegalStateException(order + " generation differed from forward generation in " + + mismatches.size() + " chunk(s)."); + } + + return new ProbeResult( + "PASS", + configuration.dimensionKey(), + configuration.seed(), + configuration.chunkCount(), + configuration.minimumChunkX(), + configuration.maximumChunkX(), + configuration.minimumChunkZ(), + configuration.maximumChunkZ(), + configuration.parallelism(), + configuration.shuffleSeed(), + configuration.multicore(), + configuration.studio(), + baseline.signature() + ); + } + + private static ModeResult generateMode( + ProbeConfiguration configuration, + RealPackProbeSupport.Workspace workspace, + List coordinates, + GenerationOrder order + ) throws Exception { + List scheduled = orderedCoordinates(coordinates, order, configuration.shuffleSeed()); + try (RealPackProbeSupport.EngineSession session = workspace.openEngine( + configuration.seed(), configuration.studio(), order.name().toLowerCase(Locale.ROOT))) { + RealPackProbeSupport.drainReported(); + long started = System.nanoTime(); + Map chunks = order == GenerationOrder.BOUNDED_PARALLEL + ? generateParallel(session.engine(), scheduled, configuration) + : generateSequential(session.engine(), scheduled, configuration); + long generationNanos = System.nanoTime() - started; + List reports = RealPackProbeSupport.settleAndDrain(); + if (!reports.isEmpty()) { + RealPackProbeSupport.printReports(LOG_PREFIX, order + " generation reports", reports); + throw reportedFailure(order + " generation", reports); + } + TreeMap ordered = new TreeMap<>(chunks); + return new ModeResult( + order, + Collections.unmodifiableMap(ordered), + aggregateSignature(ordered), + session.readyNanos(), + generationNanos + ); + } + } + + private static Map generateSequential( + Engine engine, + List coordinates, + ProbeConfiguration configuration + ) throws Exception { + LinkedHashMap chunks = new LinkedHashMap<>(coordinates.size()); + int completed = 0; + for (ChunkCoordinate coordinate : coordinates) { + ChunkOutput output = generateChunk(engine, coordinate, configuration.multicore()); + chunks.put(output.coordinate(), output.hash()); + completed++; + printProgress(completed, coordinates.size()); + } + return chunks; + } + + private static Map generateParallel( + Engine engine, + List coordinates, + ProbeConfiguration configuration + ) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(configuration.parallelism()); + try { + ArrayList> futures = new ArrayList<>(coordinates.size()); + for (ChunkCoordinate coordinate : coordinates) { + futures.add(executor.submit(() -> generateChunk(engine, coordinate, configuration.multicore()))); + } + LinkedHashMap chunks = new LinkedHashMap<>(coordinates.size()); + int completed = 0; + for (Future future : futures) { + ChunkOutput output; + try { + output = future.get(); + } catch (ExecutionException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof Error error) { + throw error; + } + if (cause instanceof Exception exception) { + throw exception; + } + throw new IllegalStateException(cause); + } + chunks.put(output.coordinate(), output.hash()); + completed++; + printProgress(completed, coordinates.size()); + } + return chunks; + } finally { + executor.shutdown(); + if (!executor.awaitTermination(30L, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } + } + + private static ChunkOutput generateChunk( + Engine engine, + ChunkCoordinate coordinate, + boolean multicore + ) throws Exception { + int height = engine.getTarget().getHeight(); + Hunk blocks = Hunk.newArrayHunk(16, height, 16); + Hunk biomes = Hunk.newArrayHunk(16, height, 16); + engine.generate(coordinate.x() << 4, coordinate.z() << 4, blocks, biomes, multicore); + return new ChunkOutput(coordinate, hashChunk(coordinate, blocks, biomes, height)); + } + + private static void printProgress(int completed, int total) { + if (completed == total || completed % 32 == 0) { + System.out.println(LOG_PREFIX + " generation progress: " + completed + "/" + total); + } + } + + private static void printMode(ModeResult result) { + System.out.printf( + Locale.ROOT, + "IRIS_GENERATION_ORDER_MODE mode=%s chunks=%d engine_ready_ms=%.3f generation_ms=%.3f " + + "block_signature=%s biome_signature=%s combined_signature=%s%n", + result.order().name(), + result.chunks().size(), + result.engineReadyNanos() / 1_000_000D, + result.generationNanos() / 1_000_000D, + result.signature().blocks(), + result.signature().biomes(), + result.signature().combined() + ); + } + + private static IllegalStateException reportedFailure(String operation, List reports) { + IllegalStateException failure = new IllegalStateException( + operation + " reported " + reports.size() + " error(s)."); + for (Throwable report : reports) { + failure.addSuppressed(report); + } + return failure; + } + + static Map blockCountDelta(ChunkHash expected, ChunkHash observed) { + return countDelta(expected.blockCounts(), observed.blockCounts()); + } + + static Map blockYCountDelta(ChunkHash expected, ChunkHash observed) { + return countDelta(expected.blockCountsByY(), observed.blockCountsByY()); + } + + private static Map countDelta( + Map expected, + Map observed + ) { + TreeMap deltas = new TreeMap<>(); + for (Map.Entry entry : expected.entrySet()) { + deltas.put(entry.getKey(), -entry.getValue()); + } + for (Map.Entry entry : observed.entrySet()) { + deltas.merge(entry.getKey(), entry.getValue(), Integer::sum); + } + deltas.entrySet().removeIf(entry -> entry.getValue() == 0); + return Collections.unmodifiableMap(deltas); + } + + private static long boundedArea(int minimumX, int maximumX, int minimumZ, int maximumZ) { + long width = (long) maximumX - minimumX + 1L; + long depth = (long) maximumZ - minimumZ + 1L; + try { + return Math.multiplyExact(width, depth); + } catch (ArithmeticException failure) { + throw new IllegalArgumentException("Chunk bounds overflow their bounded area.", failure); + } + } + + private static void updateDigest(MessageDigest digest, String value) { + updateDigest(digest, value.getBytes(StandardCharsets.UTF_8)); + } + + private static void updateDigest(MessageDigest digest, byte[] value) { + digest.update(value); + digest.update((byte) 0); + } + + private static byte[] encoded(Map cache, String value) { + byte[] encoded = cache.get(value); + if (encoded != null) { + return encoded; + } + byte[] created = value.getBytes(StandardCharsets.UTF_8); + cache.put(value, created); + return created; + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException(failure); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java index 29226dc57..391df1142 100644 --- a/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/GenerationProbe.java @@ -482,17 +482,28 @@ public final class GenerationProbe { private static void updateSignature(MessageDigest digest, ChunkCoordinate coordinate, Hunk blocks, Hunk biomes, int height) { + MessageDigest blockDigest = sha256(); + MessageDigest biomeDigest = sha256(); updateDigest(digest, coordinate.x() + "," + coordinate.z()); + updateDigest(blockDigest, coordinate.x() + "," + coordinate.z()); + updateDigest(biomeDigest, coordinate.x() + "," + coordinate.z()); for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { for (int y = 0; y < height; y++) { PlatformBlockState state = blocks.get(x, y, z); PlatformBiome biome = biomes.get(x, y, z); - updateDigest(digest, state == null ? "minecraft:air" : state.key()); - updateDigest(digest, biome == null ? "null" : biome.key()); + String stateKey = state == null ? "minecraft:air" : state.key(); + String biomeKey = biome == null ? "null" : biome.key(); + updateDigest(digest, stateKey); + updateDigest(digest, biomeKey); + updateDigest(blockDigest, stateKey); + updateDigest(biomeDigest, biomeKey); } } } + System.out.println("GENPROBE_CHUNK_HASH chunk=" + coordinate.x() + "," + coordinate.z() + + " blocks=" + HexFormat.of().formatHex(blockDigest.digest()).substring(0, 16) + + " biomes=" + HexFormat.of().formatHex(biomeDigest.digest()).substring(0, 16)); } private static void updateDigest(MessageDigest digest, String value) { diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java new file mode 100644 index 000000000..e60df886f --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java @@ -0,0 +1,4362 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydrologyCandidateKind; +import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.DrainageEdge; +import art.arcane.iris.engine.hydrology.DrainageNode; +import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTile; +import art.arcane.iris.engine.hydrology.HydrologyTileKey; +import art.arcane.iris.engine.hydrology.RiverCourse; +import art.arcane.iris.engine.hydrology.RiverCourseType; +import art.arcane.iris.engine.hydrology.RiverFootprint; +import art.arcane.iris.engine.hydrology.RiverOutlet; +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelPrecondition; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; +import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.mantle.components.MantleHydrologyCaveVoxelView; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBiomeCustom; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.volmlib.util.matter.MatterUpdate; +import art.arcane.volmlib.util.math.RNG; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; + +public final class HydrologyPackProbe { + private static final String LOG_PREFIX = "[hydrology-pack-probe]"; + private static final String GENERATED_RESULT_PREFIX = "IRIS_HYDROLOGY_GENERATED_RESULT"; + private static final int MAXIMUM_SEEDS = 32; + private static final int MAXIMUM_TILES_PER_SEED = 256; + private static final int MAXIMUM_TOTAL_TILES = 1_024; + private static final int MAXIMUM_REQUIRED_SELECTORS = 128; + private static final Set WATER_COLUMN_REPLACEMENTS = Set.of( + "minecraft:bubble_column", + "minecraft:kelp", + "minecraft:kelp_plant", + "minecraft:seagrass", + "minecraft:tall_seagrass" + ); + + private HydrologyPackProbe() { + } + + record CoverageSelector(HydrologyFeatureType type, String profileKey) { + CoverageSelector { + if (type == null) { + throw new IllegalArgumentException("Coverage feature type is required."); + } + if (profileKey == null || profileKey.isBlank() + || profileKey.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("Coverage profile must be non-blank and contain no whitespace."); + } + } + + static CoverageSelector parse(String value) { + int separator = value.indexOf('@'); + if (separator <= 0 || separator != value.lastIndexOf('@') || separator == value.length() - 1) { + throw new IllegalArgumentException( + "Coverage selector must use TYPE@profile or TYPE@*: " + value); + } + HydrologyFeatureType type; + try { + type = HydrologyFeatureType.valueOf(value.substring(0, separator).toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException failure) { + throw new IllegalArgumentException("Unknown hydrology feature type in selector: " + value, failure); + } + return new CoverageSelector(type, value.substring(separator + 1)); + } + + boolean matches(CoverageKey key) { + return type == key.type() && (profileKey.equals("*") || profileKey.equals(key.profileKey())); + } + + String label() { + return type.name() + "@" + profileKey; + } + } + + record ProbeConfiguration( + File packSource, + String dimensionKey, + List seeds, + int minimumTileX, + int maximumTileX, + int minimumTileZ, + int maximumTileZ, + List requiredCoverage, + boolean studio + ) { + ProbeConfiguration { + if (packSource == null) { + throw new IllegalArgumentException("Pack folder is required."); + } + if (dimensionKey == null || dimensionKey.isBlank() + || dimensionKey.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("Dimension key must be non-blank and contain no whitespace."); + } + if (seeds == null || seeds.size() < 2 || seeds.size() > MAXIMUM_SEEDS) { + throw new IllegalArgumentException("Hydrology pack scans require between 2 and " + + MAXIMUM_SEEDS + " explicit seeds."); + } + seeds = List.copyOf(seeds); + if (new HashSet<>(seeds).size() != seeds.size()) { + throw new IllegalArgumentException("Hydrology pack scan seeds must be unique."); + } + if (minimumTileX > maximumTileX || minimumTileZ > maximumTileZ) { + throw new IllegalArgumentException("Minimum tile bounds cannot exceed maximum tile bounds."); + } + long tilesPerSeed = boundedArea( + minimumTileX, maximumTileX, minimumTileZ, maximumTileZ, "tile"); + if (tilesPerSeed > MAXIMUM_TILES_PER_SEED) { + throw new IllegalArgumentException("Hydrology pack scan exceeds " + + MAXIMUM_TILES_PER_SEED + " tiles per seed."); + } + if (tilesPerSeed * seeds.size() > MAXIMUM_TOTAL_TILES) { + throw new IllegalArgumentException("Hydrology pack scan exceeds " + + MAXIMUM_TOTAL_TILES + " total seed-tile evaluations."); + } + if (requiredCoverage == null || requiredCoverage.isEmpty() + || requiredCoverage.size() > MAXIMUM_REQUIRED_SELECTORS) { + throw new IllegalArgumentException("Hydrology pack scans require between 1 and " + + MAXIMUM_REQUIRED_SELECTORS + " coverage selectors."); + } + requiredCoverage = List.copyOf(requiredCoverage); + Set uniqueSelectors = new HashSet<>(requiredCoverage); + if (uniqueSelectors.size() != requiredCoverage.size()) { + throw new IllegalArgumentException("Hydrology coverage selectors must be unique."); + } + } + + static ProbeConfiguration parse(String[] arguments) { + if (arguments.length != 9) { + throw new IllegalArgumentException( + "Expected: " + + " "); + } + return new ProbeConfiguration( + new File(arguments[0]), + arguments[1], + parseSeeds(arguments[2]), + Integer.parseInt(arguments[3]), + Integer.parseInt(arguments[4]), + Integer.parseInt(arguments[5]), + Integer.parseInt(arguments[6]), + parseCoverage(arguments[7]), + RealPackProbeSupport.parseBoolean(arguments[8], "studio") + ); + } + + int tilesPerSeed() { + return Math.toIntExact(boundedArea( + minimumTileX, maximumTileX, minimumTileZ, maximumTileZ, "tile")); + } + } + + record CoverageKey(HydrologyFeatureType type, String profileKey) implements Comparable { + CoverageKey { + if (type == null || profileKey == null || profileKey.isBlank()) { + throw new IllegalArgumentException("Observed hydrology coverage requires a type and profile."); + } + } + + @Override + public int compareTo(CoverageKey other) { + int typeComparison = Integer.compare(type.ordinal(), other.type.ordinal()); + return typeComparison != 0 ? typeComparison : profileKey.compareTo(other.profileKey); + } + + String label() { + return type.name() + "@" + profileKey; + } + } + + enum CoverageFamily { + SURFACE, + UNDERGROUND, + DEEP + } + + enum VerificationFamily { + SURFACE, + CAVE, + GROTTO, + DEEP + } + + record ConfiguredCoverage(CoverageFamily family, String profileKey) implements Comparable { + ConfiguredCoverage { + if (family == null || profileKey == null || profileKey.isBlank()) { + throw new IllegalArgumentException("Configured coverage requires a family and profile."); + } + } + + String label() { + return family.name() + "@" + profileKey; + } + + @Override + public int compareTo(ConfiguredCoverage other) { + int familyComparison = Integer.compare(family.ordinal(), other.family.ordinal()); + return familyComparison != 0 ? familyComparison : profileKey.compareTo(other.profileKey); + } + } + + record SeedCoverageKey(long seed, CoverageKey coverage) implements Comparable { + @Override + public int compareTo(SeedCoverageKey other) { + int seedComparison = Long.compare(seed, other.seed); + return seedComparison != 0 ? seedComparison : coverage.compareTo(other.coverage); + } + } + + record ObservedFeature( + long seed, + HydrologyTileKey tile, + String profileKey, + HydrologyFeatureRef feature, + int minimumWorldY + ) { + } + + record GeneratedWitness( + CoverageSelector selector, + ObservedFeature observed, + HydrologyColumnLayer layer, + CavePosition position, + HydrologyCaveAction action + ) { + } + + record ChunkCoordinate(int chunkX, int chunkZ) { + } + + record GeneratedWitnessDescriptor( + CoverageSelector selector, + HydrologyTileKey tile, + String profileKey, + long featureId, + CavePosition position, + HydrologyCaveAction action + ) { + GeneratedWitnessDescriptor { + if (selector == null || tile == null || profileKey == null || profileKey.isBlank() + || position == null || action == null) { + throw new IllegalArgumentException("Generated witness descriptor is incomplete."); + } + } + + static GeneratedWitnessDescriptor from(GeneratedWitness witness) { + return new GeneratedWitnessDescriptor( + witness.selector(), + witness.observed().tile(), + witness.observed().profileKey(), + witness.observed().feature().id(), + witness.position(), + witness.action() + ); + } + + static GeneratedWitnessDescriptor parse(String value) { + String[] fields = value.split("\\|", -1); + if (fields.length != 9) { + throw new IllegalArgumentException("Generated witness descriptor has an invalid field count."); + } + return new GeneratedWitnessDescriptor( + CoverageSelector.parse(decodeToken(fields[0])), + new HydrologyTileKey(Integer.parseInt(fields[1]), Integer.parseInt(fields[2])), + decodeToken(fields[3]), + Long.parseUnsignedLong(fields[4]), + new CavePosition( + Integer.parseInt(fields[5]), + Integer.parseInt(fields[6]), + Integer.parseInt(fields[7]) + ), + HydrologyCaveAction.valueOf(fields[8]) + ); + } + + String encode() { + return String.join( + "|", + encodeToken(selector.label()), + Integer.toString(tile.tileX()), + Integer.toString(tile.tileZ()), + encodeToken(profileKey), + Long.toUnsignedString(featureId), + Integer.toString(position.x()), + Integer.toString(position.y()), + Integer.toString(position.z()), + action.name() + ); + } + } + + record GeneratedVerification(String blockStateKey, String biomeKey) { + GeneratedVerification { + if (blockStateKey == null || biomeKey == null) { + throw new IllegalArgumentException("Generated verification keys are required."); + } + } + } + + record GeneratedProcessResult(CoverageSelector selector, GeneratedVerification verification) { + GeneratedProcessResult { + if (selector == null || verification == null) { + throw new IllegalArgumentException("Generated process result is incomplete."); + } + } + + static GeneratedProcessResult parse(String line) { + if (line == null || !line.startsWith(GENERATED_RESULT_PREFIX + " ")) { + throw new IllegalArgumentException("Generated process result has an invalid prefix."); + } + String[] fields = line.substring(GENERATED_RESULT_PREFIX.length() + 1).split(" ", -1); + if (fields.length != 3 + || !fields[0].startsWith("selector=") + || !fields[1].startsWith("block=") + || !fields[2].startsWith("biome=")) { + throw new IllegalArgumentException("Generated process result has invalid fields."); + } + return new GeneratedProcessResult( + CoverageSelector.parse(decodeToken(fields[0].substring("selector=".length()))), + new GeneratedVerification( + decodeToken(fields[1].substring("block=".length())), + decodeToken(fields[2].substring("biome=".length())) + ) + ); + } + + String machineLine() { + return GENERATED_RESULT_PREFIX + + " selector=" + encodeToken(selector.label()) + + " block=" + encodeToken(verification.blockStateKey()) + + " biome=" + encodeToken(verification.biomeKey()); + } + } + + record SeedChunk(long seed, int chunkX, int chunkZ) { + } + + record RejectionKey( + HydrologyCandidateKind kind, + HydrologyFeatureType type, + HydrologyCandidateRejection rejection + ) implements Comparable { + RejectionKey { + if (kind == null || type == null || rejection == null) { + throw new IllegalArgumentException("Hydrology rejection coverage requires complete classification."); + } + } + + @Override + public int compareTo(RejectionKey other) { + int kindComparison = Integer.compare(kind.ordinal(), other.kind.ordinal()); + if (kindComparison != 0) { + return kindComparison; + } + int typeComparison = Integer.compare(type.ordinal(), other.type.ordinal()); + return typeComparison != 0 + ? typeComparison + : Integer.compare(rejection.ordinal(), other.rejection.ordinal()); + } + } + + record CourseMorphologyInput( + long seed, + long courseId, + int maximumWidth, + int minimumWidth, + int minimumExposedSurfaceLength, + double exposedSurfaceLength, + boolean complete, + boolean terminalExposed, + List centerline, + Set ownedCells + ) { + CourseMorphologyInput { + if (maximumWidth < 1 || minimumWidth < 1 + || minimumExposedSurfaceLength < 0 || exposedSurfaceLength < 0D + || centerline == null || centerline.size() < 2 || ownedCells == null) { + throw new IllegalArgumentException("Published morphology input is incomplete."); + } + centerline = List.copyOf(centerline); + ownedCells = Set.copyOf(ownedCells); + } + + HydrologyPoint source() { + return centerline.getFirst(); + } + + HydrologyPoint terminal() { + return centerline.getLast(); + } + } + + record CourseMorphologyResult( + long seed, + long courseId, + int ownedCells, + double routedLength, + double exposedSurfaceLength, + int minimumExposedSurfaceLength, + double gridLockedFraction, + double longestGridLockedRun, + int isolatedTurns, + double p95TurnDegrees, + int hardEndpointRamps, + int sourceEndpointWidth, + int sourceCruiseWidth, + double sourceRampLength, + int terminalEndpointWidth, + int terminalCruiseWidth, + double terminalRampLength, + int clippedTerminalCaps, + double terminalCapLength, + int minimumInteriorWidth, + double maximumWidthTroughDepthRatio, + int unexpectedLeaves, + List violations + ) { + CourseMorphologyResult { + violations = List.copyOf(violations); + } + + boolean accepted() { + return violations.isEmpty(); + } + } + + record SeedMorphologyResult( + long seed, + List courses, + int unexpectedLeaves, + List violations + ) { + SeedMorphologyResult { + courses = List.copyOf(courses); + violations = List.copyOf(violations); + } + + boolean accepted() { + return violations.isEmpty(); + } + } + + static final class PublishedMorphologyMetrics { + private static final double SAMPLE_SPACING = 16D; + private static final double GRID_LOCK_TOLERANCE_DEGREES = 5D; + private static final double MAXIMUM_GRID_LOCKED_FRACTION = 0.5D; + private static final double MAXIMUM_GRID_LOCKED_RUN = 64D; + private static final double ISOLATED_TURN_DEGREES = 35D; + private static final double ISOLATED_NEIGHBOR_TURN_DEGREES = 10D; + private static final double MAXIMUM_P95_TURN_DEGREES = 35D; + private static final double MINIMUM_ENDPOINT_RAMP_WIDTHS = 2D; + private static final double MINIMUM_PLANFORM_LENGTH = 96D; + private static final int WIDTH_SAMPLE_SPACING = 4; + private static final int MINIMUM_ENDPOINT_PROFILE_MARGIN = 48; + private static final int MINIMUM_TROUGH_LENGTH = 32; + private static final int MINIMUM_TROUGH_WIDTH_DEFICIT = 3; + private static final double MAXIMUM_TROUGH_WIDTH_RATIO = 0.65D; + private static final double MINIMUM_TERMINAL_CAP_WIDTH_RATIO = 0.2D; + private static final int MINIMUM_TERMINAL_CAP_LENGTH = 2; + private static final int MAXIMUM_IMAGE_DIMENSION = 2048; + + private final TreeMap seeds = new TreeMap<>(); + + void observe(HydrologyTile tile, int minimumWidth, int minimumExposedSurfaceLength) { + SeedAccumulator seed = seeds.computeIfAbsent(tile.worldSeed(), SeedAccumulator::new); + HashMap observedCourses = new HashMap<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() != RiverCourseType.SURFACE) { + continue; + } + CourseAccumulator accumulator = seed.courses.computeIfAbsent( + course.id(), + (Long ignored) -> new CourseAccumulator(tile.worldSeed(), course.id()) + ); + accumulator.register(course, tile, minimumWidth, minimumExposedSurfaceLength); + observedCourses.put(course.id(), accumulator); + } + for (HydrologyColumnSample sample : tile.footprint().columns().values()) { + HydrologyColumnLayer layer = sample.primarySurfaceFluidLayer().orElse(null); + if (layer == null + || !layer.feature().type().isSurface() + || !layer.channel() + || !layer.connectedFluid() + || !layer.fluidOwned() + || layer.oceanApron()) { + continue; + } + CourseAccumulator course = observedCourses.get(layer.feature().courseId()); + if (course == null) { + course = seed.courses.get(layer.feature().courseId()); + } + if (course == null) { + continue; + } + long packed = RiverFootprint.pack(sample.x(), sample.z()); + course.ownedCells.add(packed); + seed.courseByCell.put(packed, course.courseId); + } + seed.result = null; + } + + List results() { + ArrayList results = new ArrayList<>(seeds.size()); + for (SeedAccumulator seed : seeds.values()) { + results.add(seed.result()); + } + return List.copyOf(results); + } + + void requireValid() { + requireValidResults(results()); + } + + static void requireValidResults(List results) { + ArrayList failures = new ArrayList<>(); + for (SeedMorphologyResult result : results) { + if (result.courses().isEmpty()) { + failures.add("seed=" + result.seed() + " [NO_SURFACE_COURSES]"); + continue; + } + if (!result.accepted()) { + failures.add("seed=" + result.seed() + " " + result.violations()); + } + } + if (results.isEmpty()) { + failures.add("scan=[NO_SURFACE_COURSES]"); + } + if (!failures.isEmpty()) { + throw new IllegalStateException("Real-pack published surface morphology failed: " + failures); + } + } + + void writeReports(File reportDirectory) throws IOException { + Path directory = reportDirectory.toPath(); + Files.createDirectories(directory); + for (SeedAccumulator seed : seeds.values()) { + writeReport(reportDirectory, seed.result(), seed.courseByCell); + } + } + + static void writeReport( + File reportDirectory, + SeedMorphologyResult result, + Map courseByCell + ) throws IOException { + Path directory = reportDirectory.toPath(); + Files.createDirectories(directory); + String fileSeed = Long.toString(result.seed()); + Path imagePath = directory.resolve("seed-" + fileSeed + "-surface-footprint.png"); + Path metricsPath = directory.resolve("seed-" + fileSeed + "-surface-morphology.json"); + writeImage(courseByCell, imagePath); + Files.writeString(metricsPath, json(result, imagePath.getFileName().toString()), StandardCharsets.UTF_8); + } + + String summary() { + int courses = 0; + int failures = 0; + int unexpectedLeaves = 0; + int clippedTerminalCaps = 0; + int minimumInteriorWidth = Integer.MAX_VALUE; + double maximumWidthTroughDepthRatio = 0D; + for (SeedMorphologyResult result : results()) { + courses += result.courses().size(); + unexpectedLeaves += result.unexpectedLeaves(); + for (CourseMorphologyResult course : result.courses()) { + clippedTerminalCaps += course.clippedTerminalCaps(); + if (course.minimumInteriorWidth() > 0) { + minimumInteriorWidth = Math.min(minimumInteriorWidth, course.minimumInteriorWidth()); + } + maximumWidthTroughDepthRatio = Math.max( + maximumWidthTroughDepthRatio, + course.maximumWidthTroughDepthRatio() + ); + } + if (!result.accepted()) { + failures++; + } + } + return "morphology_seeds=" + seeds.size() + + " morphology_courses=" + courses + + " morphology_failed_seeds=" + failures + + " morphology_unexpected_leaves=" + unexpectedLeaves + + " morphology_clipped_terminal_caps=" + clippedTerminalCaps + + " morphology_minimum_interior_width=" + + (minimumInteriorWidth == Integer.MAX_VALUE ? 0 : minimumInteriorWidth) + + " morphology_maximum_width_trough_depth_ratio=" + + format(maximumWidthTroughDepthRatio); + } + + static CourseMorphologyResult analyzeCourse(CourseMorphologyInput input) { + ArrayList violations = new ArrayList<>(); + List raster = rasterCenterline(input.centerline()); + List> publishedRuns = publishedRuns(input, raster); + if (publishedRuns.isEmpty()) { + violations.add("NO_PUBLISHED_CENTERLINE"); + return new CourseMorphologyResult( + input.seed(), + input.courseId(), + input.ownedCells().size(), + 0D, + input.exposedSurfaceLength(), + input.minimumExposedSurfaceLength(), + 1D, + 0D, + 0, + 0D, + 2, + 0, + 0, + 0D, + 0, + 0, + 0D, + 0, + 0D, + 0, + 0D, + 0, + violations + ); + } + double routedLength = 0D; + double lockedLength = 0D; + double longestGridLockedRun = 0D; + int isolatedTurns = 0; + ArrayList turnDegrees = new ArrayList<>(); + for (List published : publishedRuns) { + List smoothed = smoothCenterline(published, 4); + List sampled = resampleCenterline(smoothed, SAMPLE_SPACING); + double runLength = routeLength(sampled); + HeadingMetrics heading = headingMetrics(sampled); + routedLength += runLength; + lockedLength += heading.gridLockedFraction() * runLength; + longestGridLockedRun = Math.max(longestGridLockedRun, heading.longestGridLockedRun()); + if (runLength >= SAMPLE_SPACING * 2D && sampled.size() >= 3) { + TurnMetrics turns = turnMetrics(sampled); + isolatedTurns += turns.isolatedTurns(); + turnDegrees.addAll(turns.degrees()); + } + } + double gridLockedFraction = routedLength == 0D ? 0D : lockedLength / routedLength; + turnDegrees.sort(Double::compare); + double p95TurnDegrees = percentile95(turnDegrees); + EndpointRampMetrics endpointRamps = endpointRampMetrics(input, raster); + int hardEndpointRamps = endpointRamps.hardRamps(); + TerminalCapMetrics terminalCap = terminalCapMetrics(input, raster); + InteriorWidthMetrics interiorWidths = interiorWidthMetrics(input, publishedRuns); + List expectedLeaves = publishedEndpoints(input, raster); + int unexpectedLeaves = unexpectedLeaves( + input.ownedCells(), + expectedLeaves, + raster, + input.maximumWidth() + ); + if (routedLength >= MINIMUM_PLANFORM_LENGTH) { + if (gridLockedFraction > MAXIMUM_GRID_LOCKED_FRACTION) { + violations.add("GRID_LOCKED_FRACTION"); + } + if (longestGridLockedRun > MAXIMUM_GRID_LOCKED_RUN) { + violations.add("GRID_LOCKED_RUN"); + } + if (isolatedTurns > 0) { + violations.add("ISOLATED_TURN"); + } + if (p95TurnDegrees > MAXIMUM_P95_TURN_DEGREES) { + violations.add("P95_TURN"); + } + } + if (input.complete() + && input.exposedSurfaceLength() < input.minimumExposedSurfaceLength()) { + violations.add("INSUFFICIENT_EXPOSED_SURFACE"); + } + if (hardEndpointRamps > 0) { + violations.add("HARD_ENDPOINT_RAMP"); + } + int sourceWidthTolerance = Math.max( + 1, + (int) StrictMath.ceil(endpointRamps.sourceCruiseWidth() * 0.1D) + ); + if (endpointRamps.sourceEndpointWidth() + > endpointRamps.sourceCruiseWidth() + sourceWidthTolerance) { + violations.add("WIDENED_SOURCE_BASIN"); + } + if (terminalCap.clipped()) { + violations.add("CLIPPED_TERMINAL_CAP"); + } + if (interiorWidths.minimumWidth() > 0 + && interiorWidths.minimumWidth() < input.minimumWidth()) { + violations.add("NARROW_INTERIOR_WIDTH"); + } + if (interiorWidths.maximumTroughDepthRatio() >= 1D - MAXIMUM_TROUGH_WIDTH_RATIO) { + violations.add("CONCAVE_WIDTH_TROUGH"); + } + if (unexpectedLeaves > 0) { + violations.add("UNEXPECTED_BRANCH_LEAVES=" + unexpectedLeaves); + } + return new CourseMorphologyResult( + input.seed(), + input.courseId(), + input.ownedCells().size(), + routedLength, + input.exposedSurfaceLength(), + input.minimumExposedSurfaceLength(), + gridLockedFraction, + longestGridLockedRun, + isolatedTurns, + p95TurnDegrees, + hardEndpointRamps, + endpointRamps.sourceEndpointWidth(), + endpointRamps.sourceCruiseWidth(), + endpointRamps.sourceRampLength(), + endpointRamps.terminalEndpointWidth(), + endpointRamps.terminalCruiseWidth(), + endpointRamps.terminalRampLength(), + terminalCap.clipped() ? 1 : 0, + terminalCap.length(), + interiorWidths.minimumWidth(), + interiorWidths.maximumTroughDepthRatio(), + unexpectedLeaves, + violations + ); + } + + static SeedMorphologyResult analyzeSeed(long seed, List inputs) { + ArrayList courses = new ArrayList<>(inputs.size()); + HashSet ownedCells = new HashSet<>(); + ArrayList expectedLeaves = new ArrayList<>(); + ArrayList expectedCenterlines = new ArrayList<>(); + int maximumWidth = 1; + for (CourseMorphologyInput input : inputs) { + if (input.seed() != seed) { + throw new IllegalArgumentException("Morphology course seed differs from its seed report."); + } + courses.add(analyzeCourse(input)); + ownedCells.addAll(input.ownedCells()); + List raster = rasterCenterline(input.centerline()); + expectedLeaves.addAll(publishedEndpoints(input, raster)); + expectedCenterlines.addAll(raster); + maximumWidth = Math.max(maximumWidth, input.maximumWidth()); + } + int unexpectedLeaves = unexpectedLeaves( + ownedCells, + expectedLeaves, + expectedCenterlines, + maximumWidth + ); + ArrayList violations = new ArrayList<>(); + for (CourseMorphologyResult course : courses) { + for (String violation : course.violations()) { + violations.add(Long.toUnsignedString(course.courseId(), 16) + ":" + violation); + } + } + if (unexpectedLeaves > 0) { + violations.add("UNEXPECTED_BRANCH_LEAVES=" + unexpectedLeaves); + } + return new SeedMorphologyResult(seed, courses, unexpectedLeaves, violations); + } + + private static EndpointRampMetrics endpointRampMetrics( + CourseMorphologyInput input, + List raster + ) { + if (raster.size() < 2 || input.ownedCells().isEmpty()) { + return new EndpointRampMetrics(2, 0, 0, 0D, 0, 0, 0D); + } + int hard = 0; + int sourceEndpointWidth = 0; + int sourceCruiseWidth = 0; + double sourceRampLength = 0D; + if (ownedNear( + input.ownedCells(), + input.source().x(), + input.source().z(), + input.maximumWidth() + )) { + sourceCruiseWidth = cruiseWidth(input.ownedCells(), raster, input.maximumWidth(), true); + sourceEndpointWidth = endpointWidth(input.ownedCells(), raster, input.maximumWidth(), true); + sourceRampLength = endpointTransitionLength( + input.ownedCells(), + raster, + input.maximumWidth(), + sourceCruiseWidth, + true + ); + if (sourceRampLength < sourceCruiseWidth * MINIMUM_ENDPOINT_RAMP_WIDTHS) { + hard++; + } + } + if (!input.terminalExposed() || !ownedNear( + input.ownedCells(), + input.terminal().x(), + input.terminal().z(), + input.maximumWidth() + )) { + return new EndpointRampMetrics( + hard, + sourceEndpointWidth, + sourceCruiseWidth, + sourceRampLength, + 0, + 0, + 0D + ); + } + int terminalWidth = cruiseWidth(input.ownedCells(), raster, input.maximumWidth(), false); + int terminalEndpointWidth = endpointWidth(input.ownedCells(), raster, input.maximumWidth(), false); + double terminalRamp = endpointTransitionLength( + input.ownedCells(), + raster, + input.maximumWidth(), + terminalWidth, + false + ); + return new EndpointRampMetrics( + terminalRamp < terminalWidth * MINIMUM_ENDPOINT_RAMP_WIDTHS ? hard + 1 : hard, + sourceEndpointWidth, + sourceCruiseWidth, + sourceRampLength, + terminalEndpointWidth, + terminalWidth, + terminalRamp + ); + } + + private static int endpointWidth( + Set cells, + List raster, + int maximumWidth, + boolean source + ) { + int endpointIndex = source ? 0 : raster.size() - 1; + int adjacentIndex = source ? 1 : raster.size() - 2; + return crossSectionWidth( + cells, + raster.get(endpointIndex), + raster.get(endpointIndex), + raster.get(adjacentIndex), + maximumWidth + ); + } + + private static int cruiseWidth( + Set cells, + List raster, + int maximumWidth, + boolean source + ) { + int start = Math.min(raster.size() - 2, Math.max(48, maximumWidth * 4)); + int end = Math.min(raster.size() - 1, Math.max(start + 16, maximumWidth * 6)); + ArrayList widths = new ArrayList<>(); + for (int offset = start; offset <= end; offset++) { + int index = source ? offset : raster.size() - 1 - offset; + if (index <= 0 || index >= raster.size() - 1) { + continue; + } + int width = crossSectionWidth( + cells, + raster.get(index), + raster.get(index - 1), + raster.get(index + 1), + maximumWidth + ); + if (width > 0) { + widths.add(width); + } + } + if (widths.isEmpty()) { + return maximumWidth; + } + widths.sort(Integer::compare); + return Math.max(1, widths.get(widths.size() / 2)); + } + + private static double endpointTransitionLength( + Set cells, + List raster, + int maximumWidth, + int cruiseWidth, + boolean source + ) { + int endpointIndex = source ? 0 : raster.size() - 1; + int direction = source ? 1 : -1; + int search = Math.max(32, cruiseWidth * 6); + int adjacentIndex = endpointIndex + direction; + int endpointWidth = crossSectionWidth( + cells, + raster.get(endpointIndex), + raster.get(endpointIndex), + raster.get(adjacentIndex), + maximumWidth + ); + int widthTolerance = Math.max(1, (int) StrictMath.ceil(cruiseWidth * 0.1D)); + if (Math.abs(endpointWidth - cruiseWidth) <= widthTolerance) { + return 0D; + } + double distance = 0D; + int index = adjacentIndex; + while (index >= 0 && index < raster.size() && distance <= search) { + int previousIndex = Math.max(0, Math.min(raster.size() - 1, index - direction)); + int nextIndex = Math.max(0, Math.min(raster.size() - 1, index + direction)); + HydrologyPoint previous = raster.get(previousIndex); + HydrologyPoint station = raster.get(index); + HydrologyPoint next = raster.get(nextIndex); + distance += StrictMath.hypot(station.x() - previous.x(), station.z() - previous.z()); + int width = crossSectionWidth(cells, station, previous, next, maximumWidth); + if (Math.abs(width - cruiseWidth) <= widthTolerance) { + return distance; + } + index += direction; + } + return distance; + } + + private static TerminalCapMetrics terminalCapMetrics( + CourseMorphologyInput input, + List raster + ) { + if (!input.complete() || !input.terminalExposed() || raster.size() < 2 + || !ownedNear( + input.ownedCells(), + input.terminal().x(), + input.terminal().z(), + input.maximumWidth() + )) { + return new TerminalCapMetrics(false, 0D); + } + int terminalIndex = raster.size() - 1; + int tangentIndex = Math.max(0, terminalIndex - Math.max(8, input.maximumWidth())); + HydrologyPoint tangentStart = raster.get(tangentIndex); + HydrologyPoint terminal = raster.get(terminalIndex); + double tangentX = terminal.x() - tangentStart.x(); + double tangentZ = terminal.z() - tangentStart.z(); + double tangentLength = StrictMath.hypot(tangentX, tangentZ); + if (tangentLength == 0D) { + return new TerminalCapMetrics(true, 0D); + } + tangentX /= tangentLength; + tangentZ /= tangentLength; + int terminalWidth = crossSectionWidth( + input.ownedCells(), + terminal, + tangentStart, + terminal, + input.maximumWidth() + ); + int searchLength = Math.max(MINIMUM_TERMINAL_CAP_LENGTH, input.maximumWidth() * 2); + double capLength = 0D; + for (int offset = 1; offset <= searchLength; offset++) { + double centerX = terminal.x() + tangentX * offset; + double centerZ = terminal.z() + tangentZ * offset; + int width = crossSectionWidth( + input.ownedCells(), + centerX, + centerZ, + tangentX, + tangentZ, + input.maximumWidth() + ); + if (width == 0) { + break; + } + capLength = offset; + } + double requiredLength = Math.max( + MINIMUM_TERMINAL_CAP_LENGTH, + terminalWidth * MINIMUM_TERMINAL_CAP_WIDTH_RATIO + ); + return new TerminalCapMetrics(capLength + 0.25D < requiredLength, capLength); + } + + private static InteriorWidthMetrics interiorWidthMetrics( + CourseMorphologyInput input, + List> publishedRuns + ) { + if (!input.complete()) { + return new InteriorWidthMetrics(0, 0D); + } + int minimumWidth = Integer.MAX_VALUE; + double maximumTroughDepthRatio = 0D; + int endpointMargin = Math.max( + MINIMUM_ENDPOINT_PROFILE_MARGIN, + input.maximumWidth() * 4 + ); + int troughWindowSamples = Math.max( + 1, + Math.floorDiv( + Math.max(MINIMUM_TROUGH_LENGTH, input.minimumWidth() * 4) + + WIDTH_SAMPLE_SPACING - 1, + WIDTH_SAMPLE_SPACING + ) + ); + for (List run : publishedRuns) { + int firstIndex = Math.min(run.size(), endpointMargin); + int lastIndex = Math.max(firstIndex, run.size() - endpointMargin); + ArrayList widths = new ArrayList<>(); + for (int index = firstIndex; index < lastIndex; index += WIDTH_SAMPLE_SPACING) { + int previousIndex = Math.max(0, index - 1); + int nextIndex = Math.min(run.size() - 1, index + 1); + int width = crossSectionWidth( + input.ownedCells(), + run.get(index), + run.get(previousIndex), + run.get(nextIndex), + input.maximumWidth() + ); + if (width <= 0) { + continue; + } + widths.add(width); + minimumWidth = Math.min(minimumWidth, width); + } + for (int start = 0; start + troughWindowSamples * 3 <= widths.size(); start++) { + int leftWidth = median(widths, start, start + troughWindowSamples); + int middleWidth = median( + widths, + start + troughWindowSamples, + start + troughWindowSamples * 2 + ); + int rightWidth = median( + widths, + start + troughWindowSamples * 2, + start + troughWindowSamples * 3 + ); + int rimWidth = Math.min(leftWidth, rightWidth); + if (rimWidth <= 0 || rimWidth - middleWidth < MINIMUM_TROUGH_WIDTH_DEFICIT) { + continue; + } + maximumTroughDepthRatio = Math.max( + maximumTroughDepthRatio, + (rimWidth - middleWidth) / (double) rimWidth + ); + } + } + return new InteriorWidthMetrics( + minimumWidth == Integer.MAX_VALUE ? 0 : minimumWidth, + maximumTroughDepthRatio + ); + } + + private static int median(List values, int firstIndex, int lastIndex) { + ArrayList sorted = new ArrayList<>(values.subList(firstIndex, lastIndex)); + sorted.sort(Integer::compare); + return sorted.get(sorted.size() / 2); + } + + private static List publishedEndpoints( + CourseMorphologyInput input, + List raster + ) { + List> runs = publishedRuns(input, raster); + if (runs.isEmpty()) { + return List.of(input.source(), input.terminal()); + } + ArrayList endpoints = new ArrayList<>(runs.size() * 2); + for (List run : runs) { + endpoints.add(run.getFirst()); + endpoints.add(run.getLast()); + } + return List.copyOf(endpoints); + } + + private static List> publishedRuns( + CourseMorphologyInput input, + List raster + ) { + ArrayList> runs = new ArrayList<>(); + ArrayList current = new ArrayList<>(); + for (HydrologyPoint point : raster) { + if (ownedNear(input.ownedCells(), point.x(), point.z(), 1)) { + current.add(point); + continue; + } + if (current.size() >= 2) { + runs.add(List.copyOf(current)); + } + current.clear(); + } + if (current.size() >= 2) { + runs.add(List.copyOf(current)); + } + return List.copyOf(runs); + } + + private static int crossSectionWidth( + Set cells, + HydrologyPoint center, + HydrologyPoint before, + HydrologyPoint after, + int maximumWidth + ) { + double tangentX = after.x() - before.x(); + double tangentZ = after.z() - before.z(); + double tangentLength = StrictMath.hypot(tangentX, tangentZ); + if (tangentLength == 0D) { + tangentX = 1D; + tangentLength = 1D; + } + double perpendicularX = -tangentZ / tangentLength; + double perpendicularZ = tangentX / tangentLength; + int limit = Math.max(4, maximumWidth * 2); + HashSet sampled = new HashSet<>(); + for (int offset = -limit; offset <= limit; offset++) { + int x = (int) StrictMath.round(center.x() + perpendicularX * offset); + int z = (int) StrictMath.round(center.z() + perpendicularZ * offset); + long packed = RiverFootprint.pack(x, z); + if (cells.contains(packed)) { + sampled.add(packed); + } + } + return sampled.size(); + } + + private static int crossSectionWidth( + Set cells, + double centerX, + double centerZ, + double tangentX, + double tangentZ, + int maximumWidth + ) { + double tangentLength = StrictMath.hypot(tangentX, tangentZ); + if (tangentLength == 0D) { + tangentX = 1D; + tangentLength = 1D; + } + double perpendicularX = -tangentZ / tangentLength; + double perpendicularZ = tangentX / tangentLength; + int limit = Math.max(4, maximumWidth * 2); + HashSet sampled = new HashSet<>(); + for (int offset = -limit; offset <= limit; offset++) { + int x = (int) StrictMath.round(centerX + perpendicularX * offset); + int z = (int) StrictMath.round(centerZ + perpendicularZ * offset); + long packed = RiverFootprint.pack(x, z); + if (cells.contains(packed)) { + sampled.add(packed); + } + } + return sampled.size(); + } + + private static HeadingMetrics headingMetrics(List points) { + double routedLength = 0D; + double lockedLength = 0D; + double currentRun = 0D; + double longestRun = 0D; + for (int index = 1; index < points.size(); index++) { + HydrologyPoint previous = points.get(index - 1); + HydrologyPoint point = points.get(index); + double deltaX = point.x() - previous.x(); + double deltaZ = point.z() - previous.z(); + double length = StrictMath.hypot(deltaX, deltaZ); + if (length == 0D) { + continue; + } + routedLength += length; + double angle = StrictMath.toDegrees(StrictMath.atan2(deltaZ, deltaX)); + double remainder = Math.floorMod((int) StrictMath.round(angle * 1_000_000D), 45_000_000) + / 1_000_000D; + double distance = Math.min(remainder, 45D - remainder); + if (distance <= GRID_LOCK_TOLERANCE_DEGREES) { + lockedLength += length; + currentRun += length; + longestRun = Math.max(longestRun, currentRun); + } else { + currentRun = 0D; + } + } + return new HeadingMetrics( + routedLength == 0D ? 0D : lockedLength / routedLength, + longestRun + ); + } + + private static TurnMetrics turnMetrics(List points) { + ArrayList turns = new ArrayList<>(); + for (int index = 1; index < points.size() - 1; index++) { + turns.add(turnDegrees(points.get(index - 1), points.get(index), points.get(index + 1))); + } + int isolated = 0; + for (int index = 0; index < turns.size(); index++) { + double turn = turns.get(index); + double previous = index == 0 ? 0D : turns.get(index - 1); + double next = index + 1 == turns.size() ? 0D : turns.get(index + 1); + if (turn > ISOLATED_TURN_DEGREES + && previous <= ISOLATED_NEIGHBOR_TURN_DEGREES + && next <= ISOLATED_NEIGHBOR_TURN_DEGREES) { + isolated++; + } + } + return new TurnMetrics(isolated, turns); + } + + private static double percentile95(List sortedValues) { + return sortedValues.isEmpty() + ? 0D + : sortedValues.get(Math.max(0, (int) StrictMath.ceil(sortedValues.size() * 0.95D) - 1)); + } + + private static double turnDegrees(HydrologyPoint previous, HydrologyPoint point, HydrologyPoint next) { + double incomingX = point.x() - previous.x(); + double incomingZ = point.z() - previous.z(); + double outgoingX = next.x() - point.x(); + double outgoingZ = next.z() - point.z(); + double incomingLength = StrictMath.hypot(incomingX, incomingZ); + double outgoingLength = StrictMath.hypot(outgoingX, outgoingZ); + if (incomingLength == 0D || outgoingLength == 0D) { + return 0D; + } + double cosine = (incomingX * outgoingX + incomingZ * outgoingZ) + / (incomingLength * outgoingLength); + return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, cosine)))); + } + + private static List rasterCenterline(List points) { + ArrayList raster = new ArrayList<>(); + for (int pair = 0; pair < points.size() - 1; pair++) { + HydrologyPoint start = points.get(pair); + HydrologyPoint end = points.get(pair + 1); + int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z())); + if (steps == 0) { + continue; + } + int first = raster.isEmpty() ? 0 : 1; + for (int step = first; step <= steps; step++) { + double progress = step / (double) steps; + raster.add(new HydrologyPoint( + (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress), + (int) StrictMath.round(start.y() + (end.y() - start.y()) * progress), + (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress) + )); + } + } + return List.copyOf(raster); + } + + private static List smoothCenterline(List points, int radius) { + ArrayList smoothed = new ArrayList<>(points.size()); + for (int index = 0; index < points.size(); index++) { + int first = Math.max(0, index - radius); + int last = Math.min(points.size() - 1, index + radius); + long x = 0L; + long y = 0L; + long z = 0L; + for (int sample = first; sample <= last; sample++) { + HydrologyPoint point = points.get(sample); + x += point.x(); + y += point.y(); + z += point.z(); + } + int count = last - first + 1; + HydrologyPoint point = new HydrologyPoint( + (int) StrictMath.round(x / (double) count), + (int) StrictMath.round(y / (double) count), + (int) StrictMath.round(z / (double) count) + ); + if (smoothed.isEmpty() + || point.x() != smoothed.getLast().x() + || point.z() != smoothed.getLast().z()) { + smoothed.add(point); + } + } + return List.copyOf(smoothed); + } + + private static List resampleCenterline(List points, double spacing) { + if (points.size() < 2) { + return points; + } + double[] cumulative = new double[points.size()]; + for (int index = 1; index < points.size(); index++) { + HydrologyPoint previous = points.get(index - 1); + HydrologyPoint point = points.get(index); + cumulative[index] = cumulative[index - 1] + + StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); + } + double total = cumulative[cumulative.length - 1]; + if (total <= spacing) { + return points; + } + ArrayList sampled = new ArrayList<>(); + int segment = 0; + for (double distance = 0D; distance < total; distance += spacing) { + while (segment < points.size() - 2 && cumulative[segment + 1] < distance) { + segment++; + } + HydrologyPoint start = points.get(segment); + HydrologyPoint end = points.get(segment + 1); + double length = cumulative[segment + 1] - cumulative[segment]; + double progress = length == 0D ? 0D : (distance - cumulative[segment]) / length; + HydrologyPoint point = new HydrologyPoint( + (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress), + (int) StrictMath.round(start.y() + (end.y() - start.y()) * progress), + (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress) + ); + if (sampled.isEmpty() + || point.x() != sampled.getLast().x() + || point.z() != sampled.getLast().z()) { + sampled.add(point); + } + } + HydrologyPoint last = points.getLast(); + if (sampled.isEmpty() + || last.x() != sampled.getLast().x() + || last.z() != sampled.getLast().z()) { + sampled.add(last); + } + return List.copyOf(sampled); + } + + private static double routeLength(List points) { + double length = 0D; + for (int index = 1; index < points.size(); index++) { + HydrologyPoint previous = points.get(index - 1); + HydrologyPoint point = points.get(index); + length += StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); + } + return length; + } + + private static boolean ownedNear(Set cells, int x, int z, int radius) { + for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) { + for (int deltaX = -radius; deltaX <= radius; deltaX++) { + if (cells.contains(RiverFootprint.pack(x + deltaX, z + deltaZ))) { + return true; + } + } + } + return false; + } + + private static int unexpectedLeaves( + Set ownedCells, + List expectedLeaves, + List expectedCenterlines, + int maximumWidth + ) { + if (ownedCells.isEmpty()) { + return 0; + } + HashSet skeleton = thin(ownedCells); + pruneShortSpurs(skeleton, Math.max(32, maximumWidth * 8)); + int tolerance = Math.max(64, maximumWidth * 4); + int centerlineTolerance = Math.max(8, maximumWidth * 2); + int unexpected = 0; + for (Set component : connectedComponents(skeleton)) { + ArrayList leaves = new ArrayList<>(); + for (long packed : component) { + if (neighborCount(component, packed) <= 1) { + leaves.add(packed); + } + } + if (leaves.size() <= 2) { + continue; + } + for (long packed : leaves) { + int x = RiverFootprint.unpackX(packed); + int z = RiverFootprint.unpackZ(packed); + boolean expected = false; + for (HydrologyPoint endpoint : expectedLeaves) { + if (StrictMath.hypot(x - endpoint.x(), z - endpoint.z()) <= tolerance) { + expected = true; + break; + } + } + if (!expected) { + for (HydrologyPoint centerline : expectedCenterlines) { + if (StrictMath.hypot( + x - centerline.x(), + z - centerline.z() + ) <= centerlineTolerance) { + expected = true; + break; + } + } + } + if (!expected) { + unexpected++; + } + } + } + return unexpected; + } + + private static List> connectedComponents(Set cells) { + HashSet remaining = new HashSet<>(cells); + ArrayList> components = new ArrayList<>(); + while (!remaining.isEmpty()) { + long first = remaining.iterator().next(); + HashSet component = new HashSet<>(); + ArrayList pending = new ArrayList<>(); + pending.add(first); + remaining.remove(first); + while (!pending.isEmpty()) { + long current = pending.removeLast(); + component.add(current); + for (long adjacent : adjacent(remaining, current, Long.MIN_VALUE)) { + if (remaining.remove(adjacent)) { + pending.add(adjacent); + } + } + } + components.add(Set.copyOf(component)); + } + return List.copyOf(components); + } + + private static HashSet thin(Set cells) { + HashSet skeleton = new HashSet<>(cells); + boolean changed; + do { + changed = thinStep(skeleton, false); + changed |= thinStep(skeleton, true); + } while (changed); + return skeleton; + } + + private static boolean thinStep(Set skeleton, boolean second) { + ArrayList remove = new ArrayList<>(); + ArrayList ordered = new ArrayList<>(skeleton); + ordered.sort(Long::compare); + for (long packed : ordered) { + boolean[] neighbors = neighbors(skeleton, packed); + int count = 0; + for (boolean neighbor : neighbors) { + if (neighbor) { + count++; + } + } + if (count < 2 || count > 6 || transitions(neighbors) != 1) { + continue; + } + boolean north = neighbors[0]; + boolean east = neighbors[2]; + boolean south = neighbors[4]; + boolean west = neighbors[6]; + boolean firstConstraint = second ? north && east && west : north && east && south; + boolean secondConstraint = second ? north && south && west : east && south && west; + if (!firstConstraint && !secondConstraint) { + remove.add(packed); + } + } + skeleton.removeAll(remove); + return !remove.isEmpty(); + } + + private static int transitions(boolean[] neighbors) { + int transitions = 0; + for (int index = 0; index < neighbors.length; index++) { + if (!neighbors[index] && neighbors[(index + 1) % neighbors.length]) { + transitions++; + } + } + return transitions; + } + + private static boolean[] neighbors(Set cells, long packed) { + int x = RiverFootprint.unpackX(packed); + int z = RiverFootprint.unpackZ(packed); + return new boolean[]{ + cells.contains(RiverFootprint.pack(x, z - 1)), + cells.contains(RiverFootprint.pack(x + 1, z - 1)), + cells.contains(RiverFootprint.pack(x + 1, z)), + cells.contains(RiverFootprint.pack(x + 1, z + 1)), + cells.contains(RiverFootprint.pack(x, z + 1)), + cells.contains(RiverFootprint.pack(x - 1, z + 1)), + cells.contains(RiverFootprint.pack(x - 1, z)), + cells.contains(RiverFootprint.pack(x - 1, z - 1)) + }; + } + + private static int neighborCount(Set cells, long packed) { + int count = 0; + for (boolean neighbor : neighbors(cells, packed)) { + if (neighbor) { + count++; + } + } + return count; + } + + private static void pruneShortSpurs(Set skeleton, int maximumLength) { + boolean changed; + do { + changed = false; + ArrayList leaves = new ArrayList<>(); + for (long packed : skeleton) { + if (neighborCount(skeleton, packed) == 1) { + leaves.add(packed); + } + } + for (long leaf : leaves) { + ArrayList spurPath = new ArrayList<>(); + long previous = Long.MIN_VALUE; + long current = leaf; + while (spurPath.size() <= maximumLength) { + spurPath.add(current); + ArrayList adjacent = adjacent(skeleton, current, previous); + if (adjacent.size() != 1) { + if (adjacent.size() > 1 && spurPath.size() <= maximumLength) { + skeleton.removeAll(spurPath.subList(0, spurPath.size() - 1)); + changed = true; + } + break; + } + previous = current; + current = adjacent.getFirst(); + } + } + } while (changed); + } + + private static ArrayList adjacent(Set cells, long packed, long excluded) { + int x = RiverFootprint.unpackX(packed); + int z = RiverFootprint.unpackZ(packed); + ArrayList adjacent = new ArrayList<>(3); + for (int deltaZ = -1; deltaZ <= 1; deltaZ++) { + for (int deltaX = -1; deltaX <= 1; deltaX++) { + if (deltaX == 0 && deltaZ == 0) { + continue; + } + long neighbor = RiverFootprint.pack(x + deltaX, z + deltaZ); + if (neighbor != excluded && cells.contains(neighbor)) { + adjacent.add(neighbor); + } + } + } + return adjacent; + } + + private static void writeImage(Map courseByCell, Path path) throws IOException { + if (courseByCell.isEmpty()) { + BufferedImage empty = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + ImageIO.write(empty, "png", path.toFile()); + return; + } + int minimumX = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (long packed : courseByCell.keySet()) { + int x = RiverFootprint.unpackX(packed); + int z = RiverFootprint.unpackZ(packed); + minimumX = Math.min(minimumX, x); + maximumX = Math.max(maximumX, x); + minimumZ = Math.min(minimumZ, z); + maximumZ = Math.max(maximumZ, z); + } + int worldWidth = Math.addExact(Math.subtractExact(maximumX, minimumX), 1); + int worldHeight = Math.addExact(Math.subtractExact(maximumZ, minimumZ), 1); + int scale = Math.max(1, (int) StrictMath.ceil( + Math.max(worldWidth, worldHeight) / (double) MAXIMUM_IMAGE_DIMENSION + )); + int width = Math.max(1, (int) StrictMath.ceil(worldWidth / (double) scale)); + int height = Math.max(1, (int) StrictMath.ceil(worldHeight / (double) scale)); + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); + for (Map.Entry entry : courseByCell.entrySet()) { + int x = (RiverFootprint.unpackX(entry.getKey()) - minimumX) / scale; + int z = (RiverFootprint.unpackZ(entry.getKey()) - minimumZ) / scale; + image.setRGB(x, z, courseColor(entry.getValue())); + } + ImageIO.write(image, "png", path.toFile()); + } + + private static int courseColor(long courseId) { + long mixed = courseId ^ courseId >>> 33; + int red = 32 + (int) Math.floorMod(mixed, 96L); + int green = 96 + (int) Math.floorMod(mixed >>> 8, 96L); + int blue = 160 + (int) Math.floorMod(mixed >>> 16, 96L); + return 0xff000000 | red << 16 | green << 8 | blue; + } + + private static String json(SeedMorphologyResult result, String image) { + StringBuilder output = new StringBuilder(); + output.append("{\n \"seed\": ").append(result.seed()) + .append(",\n \"accepted\": ").append(result.accepted()) + .append(",\n \"image\": \"").append(image).append("\"") + .append(",\n \"unexpectedLeaves\": ").append(result.unexpectedLeaves()) + .append(",\n \"violations\": ").append(jsonStrings(result.violations())) + .append(",\n \"courses\": ["); + for (int index = 0; index < result.courses().size(); index++) { + CourseMorphologyResult course = result.courses().get(index); + if (index > 0) { + output.append(','); + } + output.append("\n {\"courseId\": \"") + .append(Long.toUnsignedString(course.courseId(), 16)) + .append("\", \"accepted\": ").append(course.accepted()) + .append(", \"ownedCells\": ").append(course.ownedCells()) + .append(", \"routedLength\": ").append(format(course.routedLength())) + .append(", \"exposedSurfaceLength\": ").append(format(course.exposedSurfaceLength())) + .append(", \"minimumExposedSurfaceLength\": ") + .append(course.minimumExposedSurfaceLength()) + .append(", \"gridLockedFraction\": ").append(format(course.gridLockedFraction())) + .append(", \"longestGridLockedRun\": ").append(format(course.longestGridLockedRun())) + .append(", \"isolatedTurns\": ").append(course.isolatedTurns()) + .append(", \"p95TurnDegrees\": ").append(format(course.p95TurnDegrees())) + .append(", \"hardEndpointRamps\": ").append(course.hardEndpointRamps()) + .append(", \"sourceEndpointWidth\": ").append(course.sourceEndpointWidth()) + .append(", \"sourceCruiseWidth\": ").append(course.sourceCruiseWidth()) + .append(", \"sourceRampLength\": ").append(format(course.sourceRampLength())) + .append(", \"terminalEndpointWidth\": ").append(course.terminalEndpointWidth()) + .append(", \"terminalCruiseWidth\": ").append(course.terminalCruiseWidth()) + .append(", \"terminalRampLength\": ").append(format(course.terminalRampLength())) + .append(", \"clippedTerminalCaps\": ").append(course.clippedTerminalCaps()) + .append(", \"terminalCapLength\": ").append(format(course.terminalCapLength())) + .append(", \"minimumInteriorWidth\": ").append(course.minimumInteriorWidth()) + .append(", \"maximumWidthTroughDepthRatio\": ") + .append(format(course.maximumWidthTroughDepthRatio())) + .append(", \"unexpectedLeaves\": ").append(course.unexpectedLeaves()) + .append(", \"violations\": ").append(jsonStrings(course.violations())) + .append('}'); + } + output.append("\n ]\n}\n"); + return output.toString(); + } + + private static String jsonStrings(List values) { + StringBuilder output = new StringBuilder("["); + for (int index = 0; index < values.size(); index++) { + if (index > 0) { + output.append(','); + } + output.append('"').append(values.get(index).replace("\\", "\\\\").replace("\"", "\\\"")) + .append('"'); + } + return output.append(']').toString(); + } + + private static String format(double value) { + return String.format(Locale.ROOT, "%.6f", value); + } + + private record HeadingMetrics(double gridLockedFraction, double longestGridLockedRun) { + } + + private record TurnMetrics(int isolatedTurns, List degrees) { + private TurnMetrics { + degrees = List.copyOf(degrees); + } + } + + private record EndpointRampMetrics( + int hardRamps, + int sourceEndpointWidth, + int sourceCruiseWidth, + double sourceRampLength, + int terminalEndpointWidth, + int terminalCruiseWidth, + double terminalRampLength + ) { + } + + private record TerminalCapMetrics(boolean clipped, double length) { + } + + private record InteriorWidthMetrics(int minimumWidth, double maximumTroughDepthRatio) { + } + + private static final class CourseAccumulator { + private final long seed; + private final long courseId; + private final HashSet ownedCells = new HashSet<>(); + private List centerline = List.of(); + private int maximumWidth = 1; + private int minimumWidth = 1; + private int minimumExposedSurfaceLength; + private double exposedSurfaceLength; + private boolean complete; + + private CourseAccumulator(long seed, long courseId) { + this.seed = seed; + this.courseId = courseId; + } + + private void register( + RiverCourse course, + HydrologyTile tile, + int configuredMinimumWidth, + int configuredMinimumExposedSurfaceLength + ) { + List observedCenterline = surfaceCenterline(course); + if (observedCenterline.size() > centerline.size()) { + centerline = observedCenterline; + } + exposedSurfaceLength = Math.max(exposedSurfaceLength, exposedSurfaceLength(course)); + for (HydraulicSegment segment : course.segments()) { + if (segment.type().isSurface()) { + maximumWidth = Math.max(maximumWidth, segment.width()); + } + } + minimumWidth = Math.max(minimumWidth, configuredMinimumWidth); + minimumExposedSurfaceLength = Math.max( + minimumExposedSurfaceLength, + configuredMinimumExposedSurfaceLength + ); + RiverOutlet outlet = course.outletId().isPresent() + ? tile.outlet(course.outletId().getAsLong()).orElse(null) + : null; + boolean reachesOutlet = outlet != null + && !course.drainageEdges().isEmpty() + && course.drainageEdges().getLast().downstreamNodeId() == outlet.drainageNodeId(); + complete |= reachesOutlet; + } + + private CourseMorphologyInput input() { + return new CourseMorphologyInput( + seed, + courseId, + maximumWidth, + minimumWidth, + minimumExposedSurfaceLength, + exposedSurfaceLength, + complete, + false, + centerline, + ownedCells + ); + } + + private static List surfaceCenterline(RiverCourse course) { + ArrayList points = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + if (!segment.type().isSurface() && segment.type() != HydrologyFeatureType.RIDGE_BORE) { + break; + } + for (HydrologyPoint point : segment.centerline()) { + if (points.isEmpty() + || point.x() != points.getLast().x() + || point.z() != points.getLast().z()) { + points.add(point); + } + } + } + return List.copyOf(points); + } + + private static double exposedSurfaceLength(RiverCourse course) { + double length = 0D; + for (HydraulicSegment segment : course.segments()) { + if (!segment.type().isSurface() || segment.type() == HydrologyFeatureType.MOUTH) { + continue; + } + for (int pointIndex = 1; pointIndex < segment.centerline().size(); pointIndex++) { + HydrologyPoint previous = segment.centerline().get(pointIndex - 1); + HydrologyPoint point = segment.centerline().get(pointIndex); + length += StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); + } + } + return length; + } + } + + private static final class SeedAccumulator { + private final long seed; + private final TreeMap courses = new TreeMap<>(); + private final HashMap courseByCell = new HashMap<>(); + private SeedMorphologyResult result; + + private SeedAccumulator(long seed) { + this.seed = seed; + } + + private SeedMorphologyResult result() { + if (result != null) { + return result; + } + ArrayList inputs = new ArrayList<>(courses.size()); + for (CourseAccumulator course : courses.values()) { + if (course.centerline.size() >= 2) { + inputs.add(course.input()); + } + } + result = analyzeSeed(seed, inputs); + return result; + } + } + } + + static final class ShapeMetrics { + private int surfaceEdges; + private int visiblyCurvedEdges; + private int surfaceNodes; + private int displacedNodes; + private int renderedSurfaceCourses; + private int renderedBroadWindows; + private int renderedCurvedWindows; + private int renderedAlignedWindows; + private int renderedTurns; + private int renderedSharpTurns; + private int abruptWidthChanges; + private int surfaceOutletGroups; + private int completeSurfaceCourses; + private int nonCompleteSurfaceCourses; + private int invalidSurfaceOutletGroups; + private int maximumSurfaceWidth; + private int configuredMaximumSurfaceWidth; + private int alignedWindows; + private int routeWindows; + private int verticalChanges; + private int longestCourse; + private int surfaceTransitions; + private int fallingWaterfalls; + private int receivingBasins; + private int shortTransitionRecoveries; + private int unsupportedSurfaceTransitions; + private int unsupportedWaterfalls; + private int fallingTerrainOwnershipColumns; + private int oceanTerrainOwnershipColumns; + private int oceanTerrainMutationColumns; + private int submergedSurfaceTerrainOwnershipColumns; + private int submergedSurfaceTerrainMutationColumns; + private int uncontainedSurfaceBankEdges; + private int missingSurfaceBankEdges; + private int lowSurfaceBankEdges; + private int invalidSurfaceIncisionColumns; + private int shallowSurfaceIncisionColumns; + private int excessiveSurfaceIncisionColumns; + private int maximumUnsupportedDrop; + private long maximumUnsupportedCourseId; + private HydrologyFeatureType maximumUnsupportedType; + private int maximumUnsupportedStartX; + private int maximumUnsupportedStartZ; + private int maximumUnsupportedEndX; + private int maximumUnsupportedEndZ; + private int maximumUnsupportedUpstreamHead; + private int maximumUnsupportedDownstreamHead; + private int maximumUnsupportedUpstreamTerrain; + private int maximumUnsupportedDownstreamTerrain; + private int conflictingSurfaceHeadColumns; + private long wetSurfaceColumns; + private long wetSurfaceBlocks; + private double minimumTransitionRecovery = Double.POSITIVE_INFINITY; + private int minimumTargetPoolLength; + private int sharpTurns; + private int routeTurns; + private double maximumTurnDegrees; + private final ArrayList turnAngles = new ArrayList<>(); + private double routedLength; + private double directLength; + private double courseRoutedLength; + private double courseDirectLength; + private double renderedRoutedLength; + private double renderedDirectLength; + private double maximumRenderedDeviationRatio; + private double maximumRenderedTurnDegrees; + private long maximumRenderedTurnCourseId; + private long maximumRenderedTurnSeed; + private int maximumRenderedTurnTileX; + private int maximumRenderedTurnTileZ; + private int maximumRenderedTurnX; + private int maximumRenderedTurnZ; + private int maximumRenderedTurnPreviousX; + private int maximumRenderedTurnPreviousZ; + private int maximumRenderedTurnNextX; + private int maximumRenderedTurnNextZ; + private final ArrayList renderedTurnAngles = new ArrayList<>(); + private final PublishedMorphologyMetrics publishedMorphology = new PublishedMorphologyMetrics(); + + void observe(HydrologyTile tile, HydrologyPlannerSettings settings) { + publishedMorphology.observe( + tile, + settings.surface().minimumWidth(), + settings.routing().branching().minimumSurfaceCourseLength() + ); + HydrologyPlannerSettings.Routing routing = settings.routing(); + int sampleSpacing = routing.sampleSpacing(); + minimumTargetPoolLength = settings.hydraulics().minimumTargetPoolLength(); + configuredMaximumSurfaceWidth = settings.surface().maximumWidth(); + Set ownedCompleteSurfaceCourseIds = ownedCompleteSurfaceCourseIds(tile); + HashSet surfaceNodeIds = new HashSet<>(); + for (DrainageEdge edge : tile.edges()) { + if (edge.contributingSurfaceSources() <= 0) { + continue; + } + surfaceEdges++; + surfaceNodeIds.add(edge.upstreamNodeId()); + surfaceNodeIds.add(edge.downstreamNodeId()); + List centerline = edge.centerline(); + for (int pointIndex = 0; pointIndex < centerline.size() - 1; pointIndex++) { + HydrologyPoint start = centerline.get(pointIndex); + HydrologyPoint end = centerline.get(pointIndex + 1); + routedLength += StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); + if (start.y() != end.y()) { + verticalChanges++; + } + } + HydrologyPoint start = centerline.getFirst(); + HydrologyPoint end = centerline.getLast(); + directLength += StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); + if (maximumChordDeviationRatio(centerline) >= 0.07D) { + visiblyCurvedEdges++; + } + for (int pointIndex = 0; pointIndex < centerline.size() - 3; pointIndex++) { + HydrologyPoint windowStart = centerline.get(pointIndex); + HydrologyPoint windowEnd = centerline.get(pointIndex + 3); + int deltaX = windowEnd.x() - windowStart.x(); + int deltaZ = windowEnd.z() - windowStart.z(); + routeWindows++; + if (deltaX == 0 || deltaZ == 0 || StrictMath.abs(deltaX) == StrictMath.abs(deltaZ)) { + alignedWindows++; + } + } + for (int pointIndex = 1; pointIndex < centerline.size() - 1; pointIndex++) { + HydrologyPoint previous = centerline.get(pointIndex - 1); + HydrologyPoint current = centerline.get(pointIndex); + HydrologyPoint next = centerline.get(pointIndex + 1); + double incomingX = current.x() - previous.x(); + double incomingZ = current.z() - previous.z(); + double outgoingX = next.x() - current.x(); + double outgoingZ = next.z() - current.z(); + double incomingLength = StrictMath.hypot(incomingX, incomingZ); + double outgoingLength = StrictMath.hypot(outgoingX, outgoingZ); + if (incomingLength == 0D || outgoingLength == 0D) { + continue; + } + double cosine = (incomingX * outgoingX + incomingZ * outgoingZ) + / (incomingLength * outgoingLength); + double angle = StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, cosine)))); + routeTurns++; + turnAngles.add(angle); + maximumTurnDegrees = Math.max(maximumTurnDegrees, angle); + if (angle > 45D) { + sharpTurns++; + } + } + } + for (long nodeId : surfaceNodeIds) { + tile.node(nodeId).ifPresent((DrainageNode node) -> { + surfaceNodes++; + if (Math.floorMod(node.x(), sampleSpacing) != 0 + || Math.floorMod(node.z(), sampleSpacing) != 0) { + displacedNodes++; + } + }); + } + for (RiverCourse course : tile.courses()) { + if (course.type() == RiverCourseType.SURFACE) { + longestCourse = Math.max(longestCourse, course.segments().size()); + observeSurfaceTransitionRecovery(course); + observeRenderedSurfaceCourse(tile, course, routing.refinementSpacing()); + observeSurfaceCenterlineIncision( + tile, + course, + settings, + ownedCompleteSurfaceCourseIds + ); + if (!course.drainageEdges().isEmpty()) { + HydrologyPoint courseStart = course.drainageEdges().getFirst().centerline().getFirst(); + HydrologyPoint courseEnd = course.drainageEdges().getLast().centerline().getLast(); + courseRoutedLength += course.drainageEdges().stream() + .mapToDouble((DrainageEdge edge) -> routeLength(edge.centerline())) + .sum(); + courseDirectLength += StrictMath.hypot( + courseEnd.x() - courseStart.x(), + courseEnd.z() - courseStart.z() + ); + } + } + for (HydraulicSegment segment : course.segments()) { + if (course.type() == RiverCourseType.SURFACE + && segment.type().isSurface() + && !segment.receivingPool()) { + maximumSurfaceWidth = Math.max(maximumSurfaceWidth, segment.width()); + } + if (segment.type() == HydrologyFeatureType.WATERFALL && segment.fallingFluid()) { + fallingWaterfalls++; + } + if (segment.receivingPool()) { + receivingBasins++; + } + observeSurfaceTerrainSupport(tile, course, segment); + } + } + observeSurfaceOutletCourses(tile); + SurfaceBankEdgeMetrics bankEdges = surfaceBankEdgeMetrics( + tile.footprint(), + ownedCompleteSurfaceCourseIds + ); + uncontainedSurfaceBankEdges += bankEdges.total(); + missingSurfaceBankEdges += bankEdges.missing(); + lowSurfaceBankEdges += bankEdges.low(); + for (HydrologyColumnSample sample : tile.footprint().columns().values()) { + observeSurfaceHeadConflicts(sample); + observeFallingTerrainOwnership(sample, ownedCompleteSurfaceCourseIds); + observeOceanIntegrity(sample, ownedCompleteSurfaceCourseIds); + HydrologyColumnLayer layer = sample.primarySurfaceFluidLayer().orElse(null); + if (layer == null || layer.oceanApron() || !layer.channel() || !layer.fluidOwned()) { + continue; + } + wetSurfaceColumns++; + wetSurfaceBlocks += Math.max(0, layer.fluidHeadY() - layer.bedY()); + } + } + + private void observeSurfaceCenterlineIncision( + HydrologyTile tile, + RiverCourse course, + HydrologyPlannerSettings settings, + Set scopedCourseIds + ) { + if (!scopedCourseIds.contains(course.id())) { + return; + } + for (HydraulicSegment segment : course.segments()) { + if (!segment.type().isSurface() || segment.type() == HydrologyFeatureType.MOUTH) { + continue; + } + for (HydrologyPoint point : segment.centerline()) { + HydrologyColumnSample sample = tile.footprint().sample(point.x(), point.z()).orElse(null); + if (sample == null) { + continue; + } + HydrologyColumnLayer layer = surfaceFluidLayer(sample, course.id()); + if (layer == null) { + continue; + } + int incision = sample.naturalHeight() - layer.fluidHeadY(); + if (incision < settings.surface().minimumSurfaceInset()) { + shallowSurfaceIncisionColumns++; + } else if (incision > settings.surface().maximumIncision()) { + excessiveSurfaceIncisionColumns++; + invalidSurfaceIncisionColumns++; + System.out.printf( + "IRIS_HYDROLOGY_PACK_SURFACE_INCISION seed=%d tile_x=%d tile_z=%d " + + "course=%016x segment=%016x type=%s upstream_y=%d downstream_y=%d " + + "receiving_pool=%s x=%d z=%d natural_y=%d head_y=%d " + + "incision=%d maximum=%d%n", + tile.worldSeed(), + tile.key().tileX(), + tile.key().tileZ(), + course.id(), + segment.id(), + segment.type(), + segment.upstreamHeadY(), + segment.downstreamHeadY(), + segment.receivingPool(), + point.x(), + point.z(), + sample.naturalHeight(), + layer.fluidHeadY(), + incision, + settings.surface().maximumIncision() + ); + } + } + } + } + + private HydrologyColumnLayer surfaceFluidLayer(HydrologyColumnSample sample, long courseId) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == courseId + && layer.feature().type().isSurface() + && !layer.oceanApron() + && layer.channel() + && layer.fluidOwned()) { + return layer; + } + } + return null; + } + + void requireOrganicSurfaceNetwork() { + publishedMorphology.requireValid(); + if (surfaceEdges == 0 + || surfaceNodes == 0 + || abruptWidthChanges > 0 + || invalidSurfaceOutletGroups > 0 + || nonCompleteSurfaceCourses > 0 + || maximumSurfaceWidth > configuredMaximumSurfaceWidth + || verticalChanges == 0 + || longestCourse < 16 + || receivingBasins == 0 + || shortTransitionRecoveries > 0 + || unsupportedSurfaceTransitions > 0 + || unsupportedWaterfalls > 0 + || fallingTerrainOwnershipColumns > 0 + || oceanTerrainOwnershipColumns > 0 + || oceanTerrainMutationColumns > 0 + || submergedSurfaceTerrainOwnershipColumns > 0 + || submergedSurfaceTerrainMutationColumns > 0 + || uncontainedSurfaceBankEdges > 0 + || invalidSurfaceIncisionColumns > 0 + || conflictingSurfaceHeadColumns > 0) { + throw new IllegalStateException("Real-pack hydrology failed organic shape requirements: " + + summary()); + } + } + + String machineLine() { + return "IRIS_HYDROLOGY_PACK_SHAPE version=14 " + summary() + + " " + publishedMorphology.summary(); + } + + void writeReports(File directory) throws IOException { + publishedMorphology.writeReports(directory); + } + + private String summary() { + return String.format( + Locale.ROOT, + "surface_edges=%d curved_edge_ratio=%.6f surface_nodes=%d displaced_ratio=%.6f aligned_ratio=%.6f " + + "sharp_turn_ratio=%.6f p95_turn_degrees=%.6f max_turn_degrees=%.6f " + + "sinuosity=%.6f course_sinuosity=%.6f surface_outlet_groups=%d " + + "complete_surface_courses=%d non_complete_surface_courses=%d " + + "invalid_surface_outlet_groups=%d " + + "rendered_courses=%d rendered_curved_window_ratio=%.6f rendered_aligned_window_ratio=%.6f " + + "rendered_sharp_turn_ratio=%.6f rendered_p95_turn_degrees=%.6f " + + "rendered_max_turn_degrees=%.6f rendered_max_turn_course=%016x " + + "rendered_max_turn_owner=%d,%d,%d " + + "rendered_max_turn_previous=%d,%d rendered_max_turn_at=%d,%d " + + "rendered_max_turn_next=%d,%d rendered_sinuosity=%.6f " + + "rendered_max_deviation_ratio=%.6f abrupt_width_changes=%d " + + "maximum_surface_width=%d " + + "vertical_changes=%d longest_course=%d " + + "surface_transitions=%d falling_waterfalls=%d receiving_basins=%d " + + "short_transition_recoveries=%d minimum_transition_recovery=%.3f " + + "unsupported_surface_transitions=%d unsupported_waterfalls=%d " + + "falling_terrain_ownership_columns=%d " + + "ocean_terrain_ownership_columns=%d " + + "ocean_terrain_mutation_columns=%d " + + "submerged_surface_terrain_ownership_columns=%d " + + "submerged_surface_terrain_mutation_columns=%d " + + "uncontained_surface_bank_edges=%d " + + "missing_surface_bank_edges=%d " + + "low_surface_bank_edges=%d " + + "invalid_surface_incision_columns=%d " + + "shallow_surface_incision_columns=%d " + + "excessive_surface_incision_columns=%d " + + "maximum_unsupported_drop=%d maximum_unsupported_course=%016x " + + "maximum_unsupported_type=%s maximum_unsupported_start=%d,%d " + + "maximum_unsupported_end=%d,%d maximum_unsupported_heads=%d,%d " + + "maximum_unsupported_terrain=%d,%d conflicting_surface_head_columns=%d " + + "wet_surface_columns=%d " + + "wet_surface_blocks=%d", + surfaceEdges, + ratio(visiblyCurvedEdges, surfaceEdges), + surfaceNodes, + ratio(displacedNodes, surfaceNodes), + ratio(alignedWindows, routeWindows), + ratio(sharpTurns, routeTurns), + percentileTurnDegrees(0.95D), + maximumTurnDegrees, + directLength == 0D ? 0D : routedLength / directLength, + courseDirectLength == 0D ? 0D : courseRoutedLength / courseDirectLength, + surfaceOutletGroups, + completeSurfaceCourses, + nonCompleteSurfaceCourses, + invalidSurfaceOutletGroups, + renderedSurfaceCourses, + ratio(renderedCurvedWindows, renderedBroadWindows), + ratio(renderedAlignedWindows, renderedBroadWindows), + ratio(renderedSharpTurns, renderedTurns), + renderedPercentileTurnDegrees(0.95D), + maximumRenderedTurnDegrees, + maximumRenderedTurnCourseId, + maximumRenderedTurnSeed, + maximumRenderedTurnTileX, + maximumRenderedTurnTileZ, + maximumRenderedTurnPreviousX, + maximumRenderedTurnPreviousZ, + maximumRenderedTurnX, + maximumRenderedTurnZ, + maximumRenderedTurnNextX, + maximumRenderedTurnNextZ, + renderedDirectLength == 0D ? 0D : renderedRoutedLength / renderedDirectLength, + maximumRenderedDeviationRatio, + abruptWidthChanges, + maximumSurfaceWidth, + verticalChanges, + longestCourse, + surfaceTransitions, + fallingWaterfalls, + receivingBasins, + shortTransitionRecoveries, + finiteOrZero(minimumTransitionRecovery), + unsupportedSurfaceTransitions, + unsupportedWaterfalls, + fallingTerrainOwnershipColumns, + oceanTerrainOwnershipColumns, + oceanTerrainMutationColumns, + submergedSurfaceTerrainOwnershipColumns, + submergedSurfaceTerrainMutationColumns, + uncontainedSurfaceBankEdges, + missingSurfaceBankEdges, + lowSurfaceBankEdges, + invalidSurfaceIncisionColumns, + shallowSurfaceIncisionColumns, + excessiveSurfaceIncisionColumns, + maximumUnsupportedDrop, + maximumUnsupportedCourseId, + maximumUnsupportedType == null ? "NONE" : maximumUnsupportedType.name(), + maximumUnsupportedStartX, + maximumUnsupportedStartZ, + maximumUnsupportedEndX, + maximumUnsupportedEndZ, + maximumUnsupportedUpstreamHead, + maximumUnsupportedDownstreamHead, + maximumUnsupportedUpstreamTerrain, + maximumUnsupportedDownstreamTerrain, + conflictingSurfaceHeadColumns, + wetSurfaceColumns, + wetSurfaceBlocks + ); + } + + private void observeSurfaceOutletCourses(HydrologyTile tile) { + HashMap> coursesByOutlet = new HashMap<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() != RiverCourseType.SURFACE || course.outletId().isEmpty()) { + continue; + } + coursesByOutlet.computeIfAbsent( + course.outletId().getAsLong(), + (Long ignored) -> new ArrayList<>() + ).add(course); + } + for (Map.Entry> entry : coursesByOutlet.entrySet()) { + surfaceOutletGroups++; + RiverOutlet outlet = tile.outlet(entry.getKey()).orElse(null); + if (outlet == null) { + nonCompleteSurfaceCourses += entry.getValue().size(); + invalidSurfaceOutletGroups++; + continue; + } + int complete = 0; + int nonComplete = 0; + for (RiverCourse course : entry.getValue()) { + boolean reachesOutlet = !course.drainageEdges().isEmpty() + && course.drainageEdges().getLast().downstreamNodeId() == outlet.drainageNodeId(); + if (reachesOutlet) { + complete++; + } else { + nonComplete++; + } + } + completeSurfaceCourses += complete; + nonCompleteSurfaceCourses += nonComplete; + if (!validSurfaceOutletCourseGroup(complete, nonComplete)) { + invalidSurfaceOutletGroups++; + } + } + } + + private Set ownedCompleteSurfaceCourseIds(HydrologyTile tile) { + HashSet completeCourseIds = new HashSet<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() != RiverCourseType.SURFACE || course.outletId().isEmpty() + || course.drainageEdges().isEmpty()) { + continue; + } + RiverOutlet outlet = tile.outlet(course.outletId().getAsLong()).orElse(null); + if (outlet != null + && course.drainageEdges().getLast().downstreamNodeId() == outlet.drainageNodeId()) { + completeCourseIds.add(course.id()); + } + } + HashSet ownedCourseIds = new HashSet<>(); + for (HydrologyColumnSample sample : tile.footprint().columns().values()) { + HydrologyColumnLayer layer = sample.primarySurfaceFluidLayer().orElse(null); + if (layer != null && layer.fluidOwned() + && completeCourseIds.contains(layer.feature().courseId())) { + ownedCourseIds.add(layer.feature().courseId()); + } + } + return Set.copyOf(ownedCourseIds); + } + + private void observeOceanIntegrity( + HydrologyColumnSample sample, + Set ownedCompleteSurfaceCourseIds + ) { + boolean ocean = sample.ocean(); + boolean naturallySubmerged = sample.naturalHeight() <= sample.seaLevel(); + if (!ocean && !naturallySubmerged) { + return; + } + boolean scoped = false; + boolean ownsSurfaceTerrain = false; + for (HydrologyColumnLayer layer : sample.layers()) { + if (!ownedCompleteSurfaceCourseIds.contains(layer.feature().courseId())) { + continue; + } + scoped = true; + ownsSurfaceTerrain |= layer.feature().type().isSurface() + && !layer.oceanApron() + && ownsOceanTerrain(layer); + } + if (!scoped) { + return; + } + boolean terrainMutation = mutatesOceanTerrain(sample.naturalHeight(), sample.terrainHeight()); + if (ocean) { + if (ownsSurfaceTerrain) { + oceanTerrainOwnershipColumns++; + } + if (terrainMutation) { + oceanTerrainMutationColumns++; + } + } + if (naturallySubmerged) { + if (ownsSurfaceTerrain) { + submergedSurfaceTerrainOwnershipColumns++; + } + if (terrainMutation) { + submergedSurfaceTerrainMutationColumns++; + } + } + } + + static int uncontainedSurfaceBankEdges( + RiverFootprint footprint, + Set scopedCourseIds + ) { + return surfaceBankEdgeMetrics(footprint, scopedCourseIds).total(); + } + + private static SurfaceBankEdgeMetrics surfaceBankEdgeMetrics( + RiverFootprint footprint, + Set scopedCourseIds + ) { + int missing = 0; + int low = 0; + int[] offsets = {-1, 0, 1, 0, -1}; + for (HydrologyColumnSample sample : footprint.columns().values()) { + HydrologyColumnLayer layer = sample.primarySurfaceFluidLayer().orElse(null); + if (layer == null + || layer.oceanApron() + || layer.fallingFluid() + || !scopedCourseIds.contains(layer.feature().courseId())) { + continue; + } + for (int offsetIndex = 0; offsetIndex < offsets.length - 1; offsetIndex++) { + HydrologyColumnSample neighbor = footprint.sample( + sample.x() + offsets[offsetIndex], + sample.z() + offsets[offsetIndex + 1] + ).orElse(null); + if (ownsSurfaceFluid(neighbor, layer.feature().courseId())) { + continue; + } + if (neighbor != null + && layer.fluidHeadY() <= neighbor.seaLevel() + && (neighbor.ocean() || neighbor.naturalHeight() <= neighbor.seaLevel())) { + continue; + } + if (neighbor == null) { + missing++; + } else if (neighbor.terrainHeight() <= layer.fluidHeadY()) { + low++; + } + } + } + return new SurfaceBankEdgeMetrics(missing, low); + } + + private record SurfaceBankEdgeMetrics(int missing, int low) { + private int total() { + return Math.addExact(missing, low); + } + } + + private static boolean ownsSurfaceFluid(HydrologyColumnSample sample, long courseId) { + if (sample == null) { + return false; + } + HydrologyColumnLayer layer = sample.primarySurfaceFluidLayer().orElse(null); + return layer != null + && layer.fluidOwned() + && layer.feature().courseId() == courseId; + } + + private void observeFallingTerrainOwnership( + HydrologyColumnSample sample, + Set ownedCompleteSurfaceCourseIds + ) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (ownedCompleteSurfaceCourseIds.contains(layer.feature().courseId()) + && layer.fallingFluid() + && layer.terrainOwned()) { + fallingTerrainOwnershipColumns++; + return; + } + } + } + + static boolean ownsOceanTerrain(HydrologyColumnLayer layer) { + return layer.terrainOwned(); + } + + static boolean mutatesOceanTerrain(int naturalHeight, int terrainHeight) { + return naturalHeight != terrainHeight; + } + + static boolean validSurfaceOutletCourseGroup(int complete, int nonComplete) { + return complete == 1 && nonComplete == 0; + } + + private void observeRenderedSurfaceCourse( + HydrologyTile tile, + RiverCourse course, + int refinementSpacing + ) { + List points = renderedCenterline(course); + if (points.size() < 3) { + return; + } + renderedSurfaceCourses++; + double routeLength = routeLength(points); + HydrologyPoint start = points.getFirst(); + HydrologyPoint end = points.getLast(); + double directLength = StrictMath.hypot(end.x() - start.x(), end.z() - start.z()); + renderedRoutedLength += routeLength; + renderedDirectLength += directLength; + maximumRenderedDeviationRatio = Math.max( + maximumRenderedDeviationRatio, + maximumChordDeviationRatio(points) + ); + observeRenderedTurns( + tile, + course, + resampleCenterline(points, Math.max(16D, refinementSpacing * 4D)) + ); + observeBroadRenderedWindows(points, refinementSpacing); + observeWidthContinuity(course); + } + + private List renderedCenterline(RiverCourse course) { + ArrayList points = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + if (!segment.type().isSurface() + && segment.type() != HydrologyFeatureType.RIDGE_BORE) { + break; + } + for (HydrologyPoint point : segment.centerline()) { + if (points.isEmpty() + || point.x() != points.getLast().x() + || point.z() != points.getLast().z()) { + points.add(point); + } + } + } + return List.copyOf(points); + } + + private void observeRenderedTurns( + HydrologyTile tile, + RiverCourse course, + List points + ) { + for (int pointIndex = 1; pointIndex < points.size() - 1; pointIndex++) { + HydrologyPoint previous = points.get(pointIndex - 1); + HydrologyPoint current = points.get(pointIndex); + HydrologyPoint next = points.get(pointIndex + 1); + double incomingX = current.x() - previous.x(); + double incomingZ = current.z() - previous.z(); + double outgoingX = next.x() - current.x(); + double outgoingZ = next.z() - current.z(); + double incomingLength = StrictMath.hypot(incomingX, incomingZ); + double outgoingLength = StrictMath.hypot(outgoingX, outgoingZ); + if (incomingLength == 0D || outgoingLength == 0D) { + continue; + } + double cosine = (incomingX * outgoingX + incomingZ * outgoingZ) + / (incomingLength * outgoingLength); + double angle = StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, cosine)))); + renderedTurns++; + renderedTurnAngles.add(angle); + if (angle > maximumRenderedTurnDegrees) { + maximumRenderedTurnDegrees = angle; + maximumRenderedTurnCourseId = course.id(); + maximumRenderedTurnSeed = tile.worldSeed(); + maximumRenderedTurnTileX = tile.key().tileX(); + maximumRenderedTurnTileZ = tile.key().tileZ(); + maximumRenderedTurnX = current.x(); + maximumRenderedTurnZ = current.z(); + maximumRenderedTurnPreviousX = previous.x(); + maximumRenderedTurnPreviousZ = previous.z(); + maximumRenderedTurnNextX = next.x(); + maximumRenderedTurnNextZ = next.z(); + } + if (angle > 40D) { + renderedSharpTurns++; + } + } + } + + private List resampleCenterline(List points, double spacing) { + double[] cumulative = new double[points.size()]; + for (int index = 1; index < points.size(); index++) { + HydrologyPoint previous = points.get(index - 1); + HydrologyPoint point = points.get(index); + cumulative[index] = cumulative[index - 1] + + StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); + } + double totalLength = cumulative[cumulative.length - 1]; + if (totalLength <= spacing) { + return points; + } + int samples = Math.max(2, (int) StrictMath.floor(totalLength / spacing)); + ArrayList sampled = new ArrayList<>(samples + 2); + int segmentIndex = 0; + for (double distance = 0D; distance < totalLength; distance += spacing) { + while (segmentIndex < points.size() - 2 && cumulative[segmentIndex + 1] < distance) { + segmentIndex++; + } + HydrologyPoint start = points.get(segmentIndex); + HydrologyPoint end = points.get(segmentIndex + 1); + double segmentLength = cumulative[segmentIndex + 1] - cumulative[segmentIndex]; + double progress = segmentLength <= 0D + ? 0D + : (distance - cumulative[segmentIndex]) / segmentLength; + HydrologyPoint point = new HydrologyPoint( + (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress), + (int) StrictMath.round(start.y() + (end.y() - start.y()) * progress), + (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress) + ); + if (sampled.isEmpty() + || point.x() != sampled.getLast().x() + || point.z() != sampled.getLast().z()) { + sampled.add(point); + } + } + HydrologyPoint last = points.getLast(); + HydrologyPoint sampledLast = sampled.isEmpty() ? null : sampled.getLast(); + double remainingLength = sampledLast == null + ? Double.POSITIVE_INFINITY + : StrictMath.hypot(last.x() - sampledLast.x(), last.z() - sampledLast.z()); + if (remainingLength >= spacing * 0.5D) { + sampled.add(last); + } + return List.copyOf(sampled); + } + + private void observeBroadRenderedWindows(List points, int refinementSpacing) { + double[] cumulative = new double[points.size()]; + for (int index = 1; index < points.size(); index++) { + HydrologyPoint previous = points.get(index - 1); + HydrologyPoint point = points.get(index); + cumulative[index] = cumulative[index - 1] + + StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); + } + double windowLength = Math.max(64D, refinementSpacing * 16D); + double stride = windowLength / 3D; + int startIndex = 0; + for (double startDistance = 0D; + startDistance + windowLength <= cumulative[cumulative.length - 1]; + startDistance += stride) { + while (startIndex < cumulative.length - 2 && cumulative[startIndex] < startDistance) { + startIndex++; + } + int endIndex = startIndex + 1; + double endDistance = startDistance + windowLength; + while (endIndex < cumulative.length - 1 && cumulative[endIndex] < endDistance) { + endIndex++; + } + if (endIndex - startIndex < 2) { + continue; + } + List window = points.subList(startIndex, endIndex + 1); + double deviationRatio = maximumChordDeviationRatio(window); + maximumRenderedDeviationRatio = Math.max(maximumRenderedDeviationRatio, deviationRatio); + renderedBroadWindows++; + if (deviationRatio >= 0.08D) { + renderedCurvedWindows++; + } + HydrologyPoint start = window.getFirst(); + HydrologyPoint end = window.getLast(); + int deltaX = end.x() - start.x(); + int deltaZ = end.z() - start.z(); + if (deltaX == 0 || deltaZ == 0 || StrictMath.abs(deltaX) == StrictMath.abs(deltaZ)) { + renderedAlignedWindows++; + } + } + } + + private void observeWidthContinuity(RiverCourse course) { + HydraulicSegment previous = null; + for (HydraulicSegment segment : course.segments()) { + boolean flatSurface = segment.type().isSurface() + && segment.drop() == 0 + && !segment.fallingFluid(); + if (!flatSurface) { + previous = null; + continue; + } + if (previous != null) { + int maximumWidth = Math.max(previous.width(), segment.width()); + int permittedChange = Math.max(2, (int) StrictMath.ceil(maximumWidth * 0.35D)); + if (StrictMath.abs(previous.width() - segment.width()) > permittedChange) { + abruptWidthChanges++; + } + } + previous = segment; + } + } + + private double maximumChordDeviationRatio(List points) { + if (points.size() < 3) { + return 0D; + } + HydrologyPoint start = points.getFirst(); + HydrologyPoint end = points.getLast(); + double chordX = end.x() - start.x(); + double chordZ = end.z() - start.z(); + double chordLength = StrictMath.hypot(chordX, chordZ); + if (chordLength == 0D) { + return 0D; + } + double maximumDeviation = 0D; + for (int index = 1; index < points.size() - 1; index++) { + HydrologyPoint point = points.get(index); + double localX = point.x() - start.x(); + double localZ = point.z() - start.z(); + double deviation = StrictMath.abs(chordX * localZ - chordZ * localX) / chordLength; + maximumDeviation = Math.max(maximumDeviation, deviation); + } + return maximumDeviation / chordLength; + } + + private double routeLength(List points) { + double length = 0D; + for (int index = 1; index < points.size(); index++) { + HydrologyPoint previous = points.get(index - 1); + HydrologyPoint point = points.get(index); + length += StrictMath.hypot(point.x() - previous.x(), point.z() - previous.z()); + } + return length; + } + + private void observeSurfaceHeadConflicts(HydrologyColumnSample sample) { + HashMap headsByCourse = new HashMap<>(); + for (HydrologyColumnLayer layer : sample.layers()) { + if (!layer.feature().type().isSurface() + || !layer.channel() + || !layer.connectedFluid() + || !layer.fluidOwned() + || layer.fallingFluid()) { + continue; + } + long courseId = layer.feature().courseId(); + Integer previousHead = headsByCourse.putIfAbsent(courseId, layer.fluidHeadY()); + if (previousHead != null && previousHead != layer.fluidHeadY()) { + conflictingSurfaceHeadColumns++; + return; + } + } + } + + private void observeSurfaceTerrainSupport( + HydrologyTile tile, + RiverCourse course, + HydraulicSegment segment + ) { + if (!segment.type().isSurface() || segment.drop() <= 0) { + return; + } + if (!segment.fallingFluid()) { + observeGradedSurfaceTerrainSupport(tile, course, segment); + return; + } + HydrologyColumnSample upstream = tile.footprint() + .sample(segment.start().x(), segment.start().z()) + .orElse(null); + HydrologyColumnSample downstream = tile.footprint() + .sample(segment.end().x(), segment.end().z()) + .orElse(null); + if (upstream == null || downstream == null) { + unsupportedSurfaceTransitions++; + observeMaximumUnsupportedTransition( + course, + segment, + Integer.MIN_VALUE, + Integer.MIN_VALUE + ); + if (segment.type() == HydrologyFeatureType.WATERFALL) { + unsupportedWaterfalls++; + } + return; + } + RiverOutlet outlet = course.outletId().isPresent() + ? tile.outlet(course.outletId().getAsLong()).orElse(null) + : null; + int downstreamNaturalHeight = isCoastalOutletDrop(course, segment) + || outlet != null + && outlet.directOcean() + && segment.end().x() == outlet.connectionPoint().x() + && segment.end().z() == outlet.connectionPoint().z() + ? downstream.seaLevel() + : downstream.naturalHeight(); + long naturalLoss = (long) upstream.naturalHeight() - downstreamNaturalHeight; + if (naturalLoss < segment.drop()) { + unsupportedSurfaceTransitions++; + observeMaximumUnsupportedTransition( + course, + segment, + upstream.naturalHeight(), + downstreamNaturalHeight + ); + if (segment.type() == HydrologyFeatureType.WATERFALL) { + unsupportedWaterfalls++; + } + } + } + + private boolean isCoastalOutletDrop(RiverCourse course, HydraulicSegment segment) { + int segmentIndex = course.segments().indexOf(segment); + return segmentIndex >= 0 + && segmentIndex + 1 < course.segments().size() + && course.segments().get(segmentIndex + 1).type() == HydrologyFeatureType.MOUTH; + } + + private void observeGradedSurfaceTerrainSupport( + HydrologyTile tile, + RiverCourse course, + HydraulicSegment segment + ) { + RiverOutlet outlet = course.outletId().isPresent() + ? tile.outlet(course.outletId().getAsLong()).orElse(null) + : null; + for (HydrologyPoint point : segment.centerline()) { + HydrologyColumnSample sample = tile.footprint() + .sample(point.x(), point.z()) + .orElse(null); + boolean directMouthEndpoint = outlet != null + && outlet.directOcean() + && point.x() == outlet.landwardPoint().x() + && point.z() == outlet.landwardPoint().z() + && point.y() == sampleSeaLevel(sample); + if (sample != null && (point.y() < sample.naturalHeight() || directMouthEndpoint)) { + continue; + } + unsupportedSurfaceTransitions++; + observeMaximumUnsupportedTransition( + course, + segment, + sample == null ? Integer.MIN_VALUE : sample.naturalHeight(), + sample == null ? Integer.MIN_VALUE : sample.naturalHeight() + ); + return; + } + } + + private int sampleSeaLevel(HydrologyColumnSample sample) { + return sample == null ? Integer.MIN_VALUE : sample.seaLevel(); + } + + private void observeMaximumUnsupportedTransition( + RiverCourse course, + HydraulicSegment segment, + int upstreamTerrain, + int downstreamTerrain + ) { + if (segment.drop() <= maximumUnsupportedDrop) { + return; + } + maximumUnsupportedDrop = segment.drop(); + maximumUnsupportedCourseId = course.id(); + maximumUnsupportedType = segment.type(); + maximumUnsupportedStartX = segment.start().x(); + maximumUnsupportedStartZ = segment.start().z(); + maximumUnsupportedEndX = segment.end().x(); + maximumUnsupportedEndZ = segment.end().z(); + maximumUnsupportedUpstreamHead = segment.upstreamHeadY(); + maximumUnsupportedDownstreamHead = segment.downstreamHeadY(); + maximumUnsupportedUpstreamTerrain = upstreamTerrain; + maximumUnsupportedDownstreamTerrain = downstreamTerrain; + } + + private void observeSurfaceTransitionRecovery(RiverCourse course) { + double recovery = Double.POSITIVE_INFINITY; + boolean transitionActive = false; + for (int segmentIndex = 0; segmentIndex < course.segments().size(); segmentIndex++) { + HydraulicSegment segment = course.segments().get(segmentIndex); + boolean surfaceDrop = segment.type().isSurface() && segment.type().isDrop(); + boolean coastalOutletDrop = surfaceDrop + && segmentIndex + 1 < course.segments().size() + && course.segments().get(segmentIndex + 1).type() == HydrologyFeatureType.MOUTH; + if (coastalOutletDrop) { + continue; + } + if (!segment.type().isSurface()) { + recovery = Double.POSITIVE_INFINITY; + transitionActive = false; + continue; + } + if (surfaceDrop) { + if (!transitionActive) { + surfaceTransitions++; + if (Double.isFinite(recovery)) { + minimumTransitionRecovery = Math.min(minimumTransitionRecovery, recovery); + if (recovery < minimumTargetPoolLength) { + shortTransitionRecoveries++; + } + } + } + recovery = 0D; + transitionActive = !segment.receivingPool(); + continue; + } + if (transitionActive && segment.receivingPool()) { + transitionActive = false; + } else if (!transitionActive && Double.isFinite(recovery)) { + recovery += segmentLength(segment); + } + } + } + + private double segmentLength(HydraulicSegment segment) { + double length = 0D; + for (int pointIndex = 1; pointIndex < segment.centerline().size(); pointIndex++) { + HydrologyPoint previous = segment.centerline().get(pointIndex - 1); + HydrologyPoint current = segment.centerline().get(pointIndex); + length += StrictMath.hypot(current.x() - previous.x(), current.z() - previous.z()); + } + return length; + } + + private double finiteOrZero(double value) { + return Double.isFinite(value) ? value : 0D; + } + + private static double ratio(int numerator, int denominator) { + return denominator == 0 ? 0D : numerator / (double) denominator; + } + + private double percentileTurnDegrees(double percentile) { + if (turnAngles.isEmpty()) { + return 0D; + } + ArrayList ordered = new ArrayList<>(turnAngles); + ordered.sort(Double::compare); + int index = (int) StrictMath.ceil(percentile * ordered.size()) - 1; + return ordered.get(Math.max(0, Math.min(index, ordered.size() - 1))); + } + + private double renderedPercentileTurnDegrees(double percentile) { + if (renderedTurnAngles.isEmpty()) { + return 0D; + } + ArrayList ordered = new ArrayList<>(renderedTurnAngles); + ordered.sort(Double::compare); + int index = (int) StrictMath.ceil(percentile * ordered.size()) - 1; + return ordered.get(Math.max(0, Math.min(index, ordered.size() - 1))); + } + } + + record SeedSummary( + long seed, + long hydrologySeed, + int tiles, + int courses, + int surfaceCourses, + int undergroundCourses, + int deepCourses, + int features, + int coveragePairs, + long engineReadyNanos + ) { + } + + record ProbeResult( + String status, + String dimensionKey, + int seeds, + int tilesPerSeed, + int totalTiles, + int minimumTileX, + int maximumTileX, + int minimumTileZ, + int maximumTileZ, + int observedCoveragePairs, + int requiredCoveragePairs, + int configuredRequirements, + int generatedChunks, + int verifiedFeatures, + String signature + ) { + String machineLine() { + return String.format( + Locale.ROOT, + "IRIS_HYDROLOGY_PACK_RESULT version=2 status=%s dimension=%s seeds=%d " + + "tiles_per_seed=%d total_tiles=%d tile_x=%d..%d tile_z=%d..%d " + + "observed_pairs=%d required_pairs=%d configured_requirements=%d " + + "generated_chunks=%d verified_features=%d signature=%s", + status, + dimensionKey, + seeds, + tilesPerSeed, + totalTiles, + minimumTileX, + maximumTileX, + minimumTileZ, + maximumTileZ, + observedCoveragePairs, + requiredCoveragePairs, + configuredRequirements, + generatedChunks, + verifiedFeatures, + signature + ); + } + } + + public static void main(String[] arguments) { + ProbeConfiguration configuration; + try { + configuration = ProbeConfiguration.parse(arguments); + } catch (Throwable failure) { + System.out.println(LOG_PREFIX + " FAIL: " + failure.getMessage()); + failure.printStackTrace(System.out); + System.exit(2); + return; + } + + try { + ProbeResult result = run(configuration); + System.out.println(result.machineLine()); + System.exit(0); + } catch (Throwable failure) { + System.out.println(LOG_PREFIX + " FAIL: probe execution failed"); + failure.printStackTrace(System.out); + ProbeResult failed = new ProbeResult( + "FAIL", + configuration.dimensionKey(), + configuration.seeds().size(), + configuration.tilesPerSeed(), + configuration.tilesPerSeed() * configuration.seeds().size(), + configuration.minimumTileX(), + configuration.maximumTileX(), + configuration.minimumTileZ(), + configuration.maximumTileZ(), + 0, + configuration.requiredCoverage().size(), + 0, + 0, + 0, + "unavailable" + ); + System.out.println(failed.machineLine()); + System.exit(1); + } + } + + static List missingCoverage( + Set observed, + List required + ) { + ArrayList missing = new ArrayList<>(); + for (CoverageSelector selector : required) { + boolean matched = false; + for (CoverageKey coverage : observed) { + if (selector.matches(coverage)) { + matched = true; + break; + } + } + if (!matched) { + missing.add(selector); + } + } + return List.copyOf(missing); + } + + private static ProbeResult run(ProbeConfiguration configuration) throws Exception { + System.out.println(LOG_PREFIX + " pack: " + configuration.packSource().getAbsolutePath()); + System.out.println(LOG_PREFIX + " dimension: " + configuration.dimensionKey()); + System.out.println(LOG_PREFIX + " seeds: " + configuration.seeds()); + System.out.println(LOG_PREFIX + " tile bounds: " + configuration.minimumTileX() + ".." + + configuration.maximumTileX() + "," + configuration.minimumTileZ() + ".." + + configuration.maximumTileZ()); + System.out.println(LOG_PREFIX + " required coverage: " + + configuration.requiredCoverage().stream().map(CoverageSelector::label).toList()); + System.out.println(LOG_PREFIX + " studio: " + configuration.studio()); + + TreeMap aggregateCounts = new TreeMap<>(); + TreeMap rejectionCounts = new TreeMap<>(); + TreeMap coordinates = new TreeMap<>(); + LinkedHashSet requiredConfiguredCoverage = new LinkedHashSet<>(); + LinkedHashSet observedConfiguredCoverage = new LinkedHashSet<>(); + LinkedHashSet verifiedSelectors = new LinkedHashSet<>(); + LinkedHashSet generatedChunks = new LinkedHashSet<>(); + ArrayList summaries = new ArrayList<>(configuration.seeds().size()); + MessageDigest signature = sha256(); + ShapeMetrics shapeMetrics = new ShapeMetrics(); + + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + configuration.packSource(), configuration.dimensionKey(), LOG_PREFIX)) { + for (long seed : configuration.seeds()) { + scanSeed( + configuration, + workspace, + seed, + aggregateCounts, + rejectionCounts, + coordinates, + requiredConfiguredCoverage, + observedConfiguredCoverage, + verifiedSelectors, + generatedChunks, + summaries, + signature, + shapeMetrics + ); + } + } + + File morphologyReports = new File( + new File(System.getProperty("user.dir"), "probe/build/reports"), + "hydrology" + ); + shapeMetrics.writeReports(morphologyReports); + System.out.println(LOG_PREFIX + " morphology reports: " + morphologyReports.getAbsolutePath()); + System.out.println(shapeMetrics.machineLine()); + + for (Map.Entry entry : coordinates.entrySet()) { + ObservedFeature observed = entry.getValue(); + HydrologyFeatureRef feature = observed.feature(); + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_PACK_COORDINATE seed=%d tile_x=%d tile_z=%d type=%s profile=%s " + + "x=%d local_y=%d world_y=%d z=%d id=%016x%n", + observed.seed(), + observed.tile().tileX(), + observed.tile().tileZ(), + feature.type().name(), + observed.profileKey(), + feature.x(), + feature.y(), + Math.addExact(feature.y(), observed.minimumWorldY()), + feature.z(), + feature.id() + ); + } + for (Map.Entry entry : rejectionCounts.entrySet()) { + RejectionKey rejection = entry.getKey(); + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_PACK_REJECTION kind=%s type=%s rejection=%s count=%d%n", + rejection.kind().name(), + rejection.type().name(), + rejection.rejection().name(), + entry.getValue() + ); + } + ArrayList orderedConfiguredCoverage = new ArrayList<>(requiredConfiguredCoverage); + orderedConfiguredCoverage.sort(Comparator.naturalOrder()); + for (ConfiguredCoverage requirement : orderedConfiguredCoverage) { + System.out.println("IRIS_HYDROLOGY_PACK_CONFIGURED requirement=" + requirement.label()); + } + for (SeedSummary summary : summaries) { + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_PACK_SEED seed=%d hydrology_seed=%d tiles=%d courses=%d " + + "surface_courses=%d underground_courses=%d deep_courses=%d features=%d " + + "pairs=%d engine_ready_ms=%.3f%n", + summary.seed(), + summary.hydrologySeed(), + summary.tiles(), + summary.courses(), + summary.surfaceCourses(), + summary.undergroundCourses(), + summary.deepCourses(), + summary.features(), + summary.coveragePairs(), + summary.engineReadyNanos() / 1_000_000D + ); + } + shapeMetrics.requireOrganicSurfaceNetwork(); + + List missing = missingCoverage(aggregateCounts.keySet(), configuration.requiredCoverage()); + for (CoverageSelector selector : missing) { + System.out.println("IRIS_HYDROLOGY_PACK_MISSING selector=" + selector.label()); + } + List missingConfigured = missingConfiguredCoverage( + observedConfiguredCoverage, requiredConfiguredCoverage); + for (ConfiguredCoverage requirement : missingConfigured) { + System.out.println("IRIS_HYDROLOGY_PACK_MISSING configured=" + requirement.label()); + } + if (!missing.isEmpty() || !missingConfigured.isEmpty()) { + throw new IllegalStateException("Missing required accepted hydrology coverage: selectors=" + + missing.stream().map(CoverageSelector::label).toList() + + ", configured=" + missingConfigured.stream().map(ConfiguredCoverage::label).toList()); + } + ArrayList unverified = new ArrayList<>(); + for (CoverageSelector selector : configuration.requiredCoverage()) { + if (!verifiedSelectors.contains(selector)) { + unverified.add(selector); + System.out.println("IRIS_HYDROLOGY_PACK_UNVERIFIED selector=" + selector.label()); + } + } + if (!unverified.isEmpty()) { + throw new IllegalStateException("Accepted hydrology coverage had no verifiable generated witness: " + + unverified.stream().map(CoverageSelector::label).toList()); + } + + return new ProbeResult( + "PASS", + configuration.dimensionKey(), + configuration.seeds().size(), + configuration.tilesPerSeed(), + configuration.tilesPerSeed() * configuration.seeds().size(), + configuration.minimumTileX(), + configuration.maximumTileX(), + configuration.minimumTileZ(), + configuration.maximumTileZ(), + aggregateCounts.size(), + configuration.requiredCoverage().size(), + requiredConfiguredCoverage.size(), + generatedChunks.size(), + verifiedSelectors.size(), + HexFormat.of().formatHex(signature.digest()) + ); + } + + private static void scanSeed( + ProbeConfiguration configuration, + RealPackProbeSupport.Workspace workspace, + long seed, + Map aggregateCounts, + Map rejectionCounts, + Map coordinates, + Set requiredConfiguredCoverage, + Set observedConfiguredCoverage, + Set verifiedSelectors, + Set generatedChunks, + List summaries, + MessageDigest signature, + ShapeMetrics shapeMetrics + ) throws Exception { + int courses = 0; + int surfaceCourses = 0; + int undergroundCourses = 0; + int deepCourses = 0; + int features = 0; + long hydrologySeed; + long engineReadyNanos; + Set seedCoverage = new HashSet<>(); + LinkedHashMap pendingWitnesses = new LinkedHashMap<>(); + try (RealPackProbeSupport.EngineSession session = workspace.openEngine( + seed, configuration.studio(), "coverage-" + seed)) { + Engine engine = session.engine(); + IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime(); + if (runtime == null) { + throw new IllegalStateException("Dimension '" + configuration.dimensionKey() + + "' has no active hydrology runtime."); + } + validateRequiredProfiles(runtime, configuration.requiredCoverage()); + requiredConfiguredCoverage.addAll(configuredCoverage(runtime)); + validateTileBlockBounds(runtime, configuration); + for (long tileZ = configuration.minimumTileZ(); tileZ <= configuration.maximumTileZ(); tileZ++) { + int completedRows = Math.toIntExact(tileZ - configuration.minimumTileZ()); + System.out.printf( + Locale.ROOT, + "%s scan seed=%d row=%d/%d tile_z=%d%n", + LOG_PREFIX, + seed, + completedRows + 1, + configuration.maximumTileZ() - configuration.minimumTileZ() + 1, + tileZ + ); + for (long tileX = configuration.minimumTileX(); tileX <= configuration.maximumTileX(); tileX++) { + HydrologyTileKey tileKey = new HydrologyTileKey((int) tileX, (int) tileZ); + HydrologyTile tile = runtime.tile(tileKey); + shapeMetrics.observe(tile, runtime.settings()); + List orderedColumns = null; + courses += tile.courses().size(); + HashMap profilesByCourse = new HashMap<>(tile.courses().size()); + for (RiverCourse course : tile.courses()) { + profilesByCourse.put(course.id(), course.profileKey()); + observedConfiguredCoverage.add(courseCoverage(course)); + if (course.type() == RiverCourseType.SURFACE) { + surfaceCourses++; + } else if (course.type() == RiverCourseType.UNDERGROUND) { + undergroundCourses++; + } else if (course.type() == RiverCourseType.DEEP_FLUID) { + deepCourses++; + } + } + for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + RejectionKey rejection = new RejectionKey( + candidate.kind(), + candidate.projectedType(), + candidate.rejection() + ); + rejectionCounts.merge(rejection, 1, Integer::sum); + if (surfaceBuildRejection(candidate.rejection())) { + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_PACK_SURFACE_BUILD_REJECTION seed=%d tile_x=%d tile_z=%d " + + "x=%d y=%d z=%d id=%016x rejection=%s%n", + seed, + tileKey.tileX(), + tileKey.tileZ(), + candidate.point().x(), + candidate.point().y(), + candidate.point().z(), + candidate.id(), + candidate.rejection().name() + ); + } + } + ArrayList orderedFeatures = new ArrayList<>(tile.features()); + orderedFeatures.sort(Comparator.comparingLong(HydrologyFeatureRef::id)); + for (HydrologyFeatureRef feature : orderedFeatures) { + String profileKey = profilesByCourse.get(feature.courseId()); + if (profileKey == null) { + throw new IllegalStateException("Accepted hydrology feature references unknown course " + + Long.toUnsignedString(feature.courseId(), 16) + "."); + } + CoverageKey coverage = new CoverageKey(feature.type(), profileKey); + ObservedFeature observedFeature = new ObservedFeature( + seed, + tileKey, + profileKey, + feature, + engine.getWorld().minHeight() + ); + aggregateCounts.merge(coverage, 1, Integer::sum); + seedCoverage.add(coverage); + coordinates.putIfAbsent( + new SeedCoverageKey(seed, coverage), + observedFeature + ); + for (CoverageSelector selector : configuration.requiredCoverage()) { + if (!selector.type().isSurface() && verifiedSelectors.contains(selector) + || pendingWitnesses.containsKey(selector) + || !selector.matches(coverage)) { + continue; + } + if (orderedColumns == null) { + orderedColumns = orderedFootprintColumns(tile); + } + findGeneratedWitness(runtime, tile, orderedColumns, observedFeature, selector) + .ifPresent((GeneratedWitness witness) -> pendingWitnesses.put(selector, witness)); + } + updateSignature(signature, seed, tileKey, profileKey, feature); + features++; + } + } + int loadedBefore = engine.getMantle().getLoadedRegionCount(); + engine.getMantle().trim(0L, 0); + int unloaded = engine.getMantle().unloadTectonicPlate(0); + int loadedAfter = engine.getMantle().getLoadedRegionCount(); + System.out.printf( + Locale.ROOT, + "%s mantle seed=%d tile_z=%d loaded_before=%d unloaded=%d loaded_after=%d%n", + LOG_PREFIX, + seed, + tileZ, + loadedBefore, + unloaded, + loadedAfter + ); + } + hydrologySeed = engine.getSeedManager().getBodies(); + engineReadyNanos = session.readyNanos(); + List reports = RealPackProbeSupport.settleAndDrain(); + if (!reports.isEmpty()) { + RealPackProbeSupport.printReports(LOG_PREFIX, "seed " + seed + " planning reports", reports); + throw reportedFailure("Hydrology coverage scan", reports); + } + } + List missingSeedSurfaceCoverage = missingCoverage( + seedCoverage, + configuration.requiredCoverage().stream() + .filter((CoverageSelector selector) -> selector.type().isSurface()) + .toList() + ); + if (!missingSeedSurfaceCoverage.isEmpty()) { + throw new IllegalStateException("Seed " + seed + " is missing required surface coverage: " + + missingSeedSurfaceCoverage.stream().map(CoverageSelector::label).toList() + + "; rejections=" + rejectionCounts); + } + for (CoverageSelector selector : configuration.requiredCoverage()) { + if (selector.type().isSurface() && !pendingWitnesses.containsKey(selector)) { + throw new IllegalStateException("Seed " + seed + + " has no generated witness for required surface coverage " + selector.label() + "."); + } + } + verifyGeneratedWitnesses( + configuration, + workspace, + seed, + pendingWitnesses, + verifiedSelectors, + generatedChunks, + signature + ); + summaries.add(new SeedSummary( + seed, + hydrologySeed, + configuration.tilesPerSeed(), + courses, + surfaceCourses, + undergroundCourses, + deepCourses, + features, + seedCoverage.size(), + engineReadyNanos + )); + } + + private static boolean surfaceBuildRejection(HydrologyCandidateRejection rejection) { + return switch (rejection) { + case SURFACE_HEAD_RANGE, + SURFACE_HEAD_DISTRIBUTION, + SURFACE_RIDGE_TUNNEL_LIMIT, + SURFACE_DROP_UNSUPPORTED, + SURFACE_CORRIDOR_UNSUPPORTED, + SURFACE_TRANSITION_UNSUPPORTED, + SURFACE_SEGMENT_UNSUPPORTED, + SURFACE_SINKHOLE_CLEARANCE, + SURFACE_SHAPE_UNSUPPORTED -> true; + default -> false; + }; + } + + private static void validateRequiredProfiles( + IrisHydrologyRuntime runtime, + List selectors + ) { + Set riverProfiles = runtime.profileKeys(); + LinkedHashSet deepProfiles = new LinkedHashSet<>(); + for (HydrologyPlannerSettings.DeepFluid deepFluid : runtime.settings().deepFluids()) { + if (deepFluid.enabled()) { + deepProfiles.add(deepFluid.id()); + } + } + for (CoverageSelector selector : selectors) { + if (selector.profileKey().equals("*")) { + continue; + } + Set configuredProfiles = selector.type().isDeepFluid() ? deepProfiles : riverProfiles; + if (!configuredProfiles.contains(selector.profileKey())) { + throw new IllegalArgumentException("Required coverage selector " + selector.label() + + " references a profile not configured for that feature family."); + } + } + } + + static VerificationFamily verificationFamily(HydrologyFeatureType type) { + if (type.isSurface()) { + return VerificationFamily.SURFACE; + } + if (type.isGrotto() || type == HydrologyFeatureType.SINKHOLE) { + return VerificationFamily.GROTTO; + } + return type.isDeepFluid() ? VerificationFamily.DEEP : VerificationFamily.CAVE; + } + + private static Optional findGeneratedWitness( + IrisHydrologyRuntime runtime, + HydrologyTile tile, + List columns, + ObservedFeature observed, + CoverageSelector selector + ) { + GeneratedWitness fallback = null; + for (HydrologyColumnSample sample : columns) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().type() != selector.type() + || layer.feature().courseId() != observed.feature().courseId() + || !layer.profileKey().equals(observed.profileKey()) + || layer.oceanApron()) { + continue; + } + HydrologyColumnSample publicationSample = runtime.sample(sample.x(), sample.z()) + .orElse(sample); + HydrologyColumnLayer publicationLayer = layerByFeatureId( + publicationSample, + layer.feature().id() + ); + if (publicationLayer == null) { + continue; + } + VerificationFamily family = verificationFamily(selector.type()); + if (family == VerificationFamily.SURFACE) { + HydrologyColumnLayer primary = publicationSample.primarySurfaceFluidLayer().orElse(null); + if (!publicationLayer.equals(primary) + || !publicationLayer.fluidOwned() + || publicationLayer.fluidHeadY() <= publicationLayer.bedY()) { + continue; + } + CavePosition fallingPosition = null; + if (publicationLayer.fallingFluid()) { + for (int y = publicationLayer.bedY() + 1; y < publicationLayer.fluidHeadY(); y++) { + HydrologyColumnSample.SurfacePublicationCell surfaceCell = publicationSample + .surfacePublicationCellAt(y) + .orElse(null); + if (surfaceCell != null + && surfaceCell.layer().equals(publicationLayer) + && surfaceCell.action() == HydrologyCaveAction.FALLING_FLUID) { + fallingPosition = new CavePosition(publicationSample.x(), y, publicationSample.z()); + break; + } + } + } + boolean falling = fallingPosition != null; + CavePosition position = new CavePosition( + publicationSample.x(), + falling ? fallingPosition.y() : publicationLayer.fluidHeadY(), + publicationSample.z() + ); + GeneratedWitness witness = new GeneratedWitness( + selector, + observed, + publicationLayer, + position, + falling ? HydrologyCaveAction.FALLING_FLUID : HydrologyCaveAction.WET_SOURCE + ); + if (falling || fallback == null) { + fallback = witness; + } + if (!selector.type().isDrop() || falling) { + return Optional.of(witness); + } + continue; + } + + HydrologyCavePlan plan = tile.cavePlan(layer.feature().courseId()).orElse(null); + if (plan == null || !plan.accepted() || !exposureMatchesFamily(plan, family)) { + continue; + } + for (int y = publicationLayer.bedY() + 1; y <= publicationLayer.ceilingY(); y++) { + CavePosition position = new CavePosition( + publicationSample.x(), + y, + publicationSample.z() + ); + if (surfaceBedGuardCovers(publicationSample, position)) { + continue; + } + HydrologyCaveAction action = composedCaveActionAt( + runtime, + plan, + publicationLayer, + position + ); + if (action == null || action == HydrologyCaveAction.SEAL_GUARD) { + continue; + } + GeneratedWitness witness = new GeneratedWitness( + selector, + observed, + publicationLayer, + position, + action + ); + if (action == HydrologyCaveAction.FALLING_FLUID) { + return Optional.of(witness); + } + if (fallback == null) { + fallback = witness; + } + } + } + } + return Optional.ofNullable(fallback); + } + + private static HydrologyColumnLayer layerByFeatureId( + HydrologyColumnSample sample, + long featureId + ) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().id() == featureId) { + return layer; + } + } + return null; + } + + private static HydrologyCaveAction composedCaveActionAt( + IrisHydrologyRuntime runtime, + HydrologyCavePlan plan, + HydrologyColumnLayer layer, + CavePosition position + ) { + HydrologyCaveAction action = plan.actions().get(position); + if (action == null || action == HydrologyCaveAction.SEAL_GUARD) { + return action; + } + HydrologyColumnSample sample = runtime.sample(position.x(), position.z()).orElse(null); + if (sample == null) { + return action; + } + HydrologyColumnSample.SurfacePublicationCell surfaceCell = sample + .surfacePublicationCellAt(position.y()) + .orElse(null); + if (surfaceCell != null && layer.profileKey().equals(surfaceCell.layer().profileKey())) { + return surfaceCell.action(); + } + return action; + } + + private static boolean surfaceBedGuardCovers(HydrologyColumnSample sample, CavePosition position) { + HydrologyColumnLayer surface = sample.primarySurfaceLayer().orElse(null); + if (surface == null) { + return false; + } + int minimumGuardY = Math.max(1, surface.bedY() - 2); + return position.y() >= minimumGuardY && position.y() <= surface.bedY(); + } + + static List orderedFootprintColumns(HydrologyTile tile) { + ArrayList columns = new ArrayList<>(tile.footprint().columns().values()); + columns.sort(Comparator.comparingInt(HydrologyColumnSample::x) + .thenComparingInt(HydrologyColumnSample::z)); + return List.copyOf(columns); + } + + private static boolean exposureMatchesFamily( + HydrologyCavePlan plan, + VerificationFamily family + ) { + boolean openToSurface = false; + for (CaveVoxelPrecondition precondition : plan.baselinePreconditions().values()) { + if (precondition.openToSurface()) { + openToSurface = true; + break; + } + } + return family == VerificationFamily.GROTTO ? openToSurface + : family != VerificationFamily.DEEP || !openToSurface; + } + + static List generatedWitnessChunks(List positions) { + LinkedHashSet chunks = new LinkedHashSet<>(); + for (CavePosition position : positions) { + chunks.add(new ChunkCoordinate( + Math.floorDiv(position.x(), 16), + Math.floorDiv(position.z(), 16) + )); + } + return List.copyOf(chunks); + } + + private static void verifyGeneratedWitnesses( + ProbeConfiguration configuration, + RealPackProbeSupport.Workspace workspace, + long seed, + Map pending, + Set verified, + Set generatedChunks, + MessageDigest signature + ) throws Exception { + ArrayList positions = new ArrayList<>(pending.size()); + for (GeneratedWitness witness : pending.values()) { + positions.add(witness.position()); + } + for (ChunkCoordinate chunk : generatedWitnessChunks(positions)) { + ArrayList chunkWitnesses = new ArrayList<>(); + for (GeneratedWitness witness : pending.values()) { + int chunkX = Math.floorDiv(witness.position().x(), 16); + int chunkZ = Math.floorDiv(witness.position().z(), 16); + if (chunkX == chunk.chunkX() && chunkZ == chunk.chunkZ()) { + chunkWitnesses.add(witness); + } + } + Map results = runGeneratedChunkProcess( + configuration, + workspace.pack(), + seed, + chunk, + chunkWitnesses + ); + generatedChunks.add(new SeedChunk(seed, chunk.chunkX(), chunk.chunkZ())); + for (GeneratedWitness witness : chunkWitnesses) { + GeneratedVerification result = results.get(witness.selector()); + if (result == null) { + throw new IllegalStateException("Generated chunk process returned no result for " + + witness.selector().label() + "."); + } + updateGeneratedSignature(signature, witness, result); + verified.add(witness.selector()); + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_PACK_VERIFIED selector=%s seed=%d chunk_x=%d chunk_z=%d " + + "x=%d y=%d z=%d action=%s%n", + witness.selector().label(), + witness.observed().seed(), + chunk.chunkX(), + chunk.chunkZ(), + witness.position().x(), + witness.position().y(), + witness.position().z(), + witness.action().name() + ); + } + } + } + + private static Map runGeneratedChunkProcess( + ProbeConfiguration configuration, + File preparedPack, + long seed, + ChunkCoordinate chunk, + List witnesses + ) throws Exception { + ArrayList command = new ArrayList<>(); + command.add(new File(System.getProperty("java.home"), "bin/java").getAbsolutePath()); + command.add("--add-modules"); + command.add("jdk.incubator.vector"); + command.add("-Xmx" + Runtime.getRuntime().maxMemory()); + command.add("-cp"); + command.add(System.getProperty("java.class.path")); + command.add(GeneratedChunkProcess.class.getName()); + command.add(preparedPack.getAbsolutePath()); + command.add(configuration.dimensionKey()); + command.add(Long.toString(seed)); + command.add(Boolean.toString(configuration.studio())); + command.add(Integer.toString(chunk.chunkX())); + command.add(Integer.toString(chunk.chunkZ())); + for (GeneratedWitness witness : witnesses) { + command.add(GeneratedWitnessDescriptor.from(witness).encode()); + } + + ProcessBuilder processBuilder = new ProcessBuilder(command).redirectErrorStream(true); + processBuilder.environment().remove("JAVA_TOOL_OPTIONS"); + processBuilder.environment().remove("JDK_JAVA_OPTIONS"); + processBuilder.environment().remove("_JAVA_OPTIONS"); + Process process = processBuilder.start(); + ArrayList output = new ArrayList<>(); + LinkedHashMap results = new LinkedHashMap<>(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.add(line); + } + } + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IllegalStateException("Generated chunk process failed for seed " + seed + + " at " + chunk.chunkX() + "," + chunk.chunkZ() + + " for " + witnesses.stream() + .map((GeneratedWitness witness) -> witness.selector().label() + + "@" + witness.position() + "/" + witness.action().name()) + .toList() + ":\n" + + String.join("\n", output)); + } + for (String line : output) { + if (!line.startsWith(GENERATED_RESULT_PREFIX + " ")) { + continue; + } + GeneratedProcessResult result = GeneratedProcessResult.parse(line); + GeneratedVerification previous = results.put(result.selector(), result.verification()); + if (previous != null) { + throw new IllegalStateException("Generated chunk process returned duplicate selector " + + result.selector().label() + "."); + } + } + if (results.size() != witnesses.size()) { + throw new IllegalStateException("Generated chunk process returned " + results.size() + + " result(s) for " + witnesses.size() + " witness(es)."); + } + return Map.copyOf(results); + } + + private static GeneratedVerification verifyGeneratedWitness( + Engine engine, + IrisHydrologyRuntime runtime, + RealPackProbeSupport.GeneratedChunk generated, + GeneratedWitness witness + ) { + CavePosition position = witness.position(); + HydrologyColumnSample composed = runtime.sample(position.x(), position.z()).orElseThrow( + () -> new IllegalStateException("Generated witness lost its accepted hydrology column.")); + if (!composed.layers().contains(witness.layer())) { + throw new IllegalStateException("Generated witness differs from the accepted composed hydrology layer."); + } + HydrologyColumnLayer layer = witness.layer(); + PlatformBlockState expectedFluid = engine.getComplex().resolveHydrologyFluid( + layer.profileKey(), + position.x(), + position.z() + ); + VerificationFamily family = verificationFamily(witness.selector().type()); + if (family == VerificationFamily.SURFACE) { + verifySurfaceWitness(engine, generated, composed, layer, position, expectedFluid); + } else { + verifyCaveWitness(engine, runtime, generated, witness, expectedFluid, family); + } + if (witness.action() == HydrologyCaveAction.FALLING_FLUID) { + requireNoUpdateMarker(engine, position); + } + String blockStateKey = stateKey(generated.blockAt(position.x(), position.y(), position.z())); + PlatformBiome biome = generated.biomeAt( + position.x(), + Math.min(position.y(), generated.height() - 1), + position.z() + ); + return new GeneratedVerification(blockStateKey, biome == null ? "null" : biome.key()); + } + + private static void updateGeneratedSignature( + MessageDigest signature, + GeneratedWitness witness, + GeneratedVerification verification + ) { + CavePosition position = witness.position(); + updateDigest(signature, "generated"); + updateDigest(signature, witness.selector().label()); + updateDigest(signature, Long.toString(witness.observed().seed())); + updateDigest(signature, Integer.toString(position.x())); + updateDigest(signature, Integer.toString(position.y())); + updateDigest(signature, Integer.toString(position.z())); + updateDigest(signature, verification.blockStateKey()); + updateDigest(signature, verification.biomeKey()); + } + + private static void verifySurfaceWitness( + Engine engine, + RealPackProbeSupport.GeneratedChunk generated, + HydrologyColumnSample sample, + HydrologyColumnLayer layer, + CavePosition position, + PlatformBlockState expectedFluid + ) { + if (sample.terrainHeight() != layer.bedY()) { + throw new IllegalStateException("Accepted surface witness does not own the composed terrain height."); + } + if (layer.fluidHeadY() >= sample.naturalHeight()) { + throw new IllegalStateException("Generated surface witness is not recessed below natural terrain."); + } + PlatformBlockState bed = generated.blockAt(position.x(), layer.bedY(), position.z()); + if (bed == null || bed.isAirOrFluid() || matchesConfiguredFluid(bed, expectedFluid, true)) { + throw new IllegalStateException("Generated surface witness has no non-fluid channel bed: state=" + + stateKey(bed) + + ", position=" + new CavePosition(position.x(), layer.bedY(), position.z()) + + ", layer=" + layer + + ", sampleLayers=" + sample.layers()); + } + requireNonVegetatedBed(bed, "surface"); + PlatformBlockState fluid = generated.blockAt(position.x(), position.y(), position.z()); + requireMatchingFluid( + fluid, + expectedFluid, + "surface", + position.y() == layer.fluidHeadY() + ); + int composedFluidHead = composedSurfaceFluidHead(sample); + if (composedFluidHead + 1 < generated.height()) { + PlatformBlockState above = generated.blockAt(position.x(), composedFluidHead + 1, position.z()); + if (above != null && matchesConfiguredFluid(above, expectedFluid, true)) { + throw new IllegalStateException("Generated surface witness contains " + stateKey(above) + + " above its accepted head at " + + new CavePosition(position.x(), composedFluidHead + 1, position.z()) + + ": layer=" + layer + ", sampleLayers=" + sample.layers() + "."); + } + } + PlatformBiome biome = generated.biomeAt(position.x(), layer.fluidHeadY(), position.z()); + IrisBiome surfaceBiome = engine.getComplex().getTrueBiomeStream().get(position.x(), position.z()); + String expectedBiomeKey = generatedSurfaceBiomeKey( + surfaceBiome, + engine, + engine.getSeedManager().getBiome(), + engine.getDimension().getLoadKey(), + position.x(), + position.z() + ); + String actualBiomeKey = biome == null ? null : biome.key(); + if (!expectedBiomeKey.equals(actualBiomeKey)) { + throw new IllegalStateException("Generated surface biome differs from its resolved hydrology biome: " + + "selector=" + layer.feature().type().name() + + ", accepted=" + layer.biomeKey() + + ", expected=" + expectedBiomeKey + + ", actual=" + actualBiomeKey + + ", x=" + position.x() + + ", y=" + layer.fluidHeadY() + + ", z=" + position.z() + "."); + } + } + + static String generatedSurfaceBiomeKey( + IrisBiome biome, + Engine engine, + long biomeSeed, + String dimensionKey, + int x, + int z + ) { + if (biome == null) { + throw new IllegalStateException("Generated surface biome resolution returned no Iris biome."); + } + RNG rng = new RNG(biomeSeed); + if (!biome.isCustom()) { + String key = biome.getSkyBiomeKey(rng, engine, x, 0, z); + if (key == null || key.isBlank()) { + throw new IllegalStateException("Generated surface Iris biome resolved no platform biome key."); + } + return key; + } + IrisBiomeCustom custom = biome.getCustomBiome(rng, engine, x, 0, z); + if (custom == null || custom.getId() == null || custom.getId().isBlank()) { + throw new IllegalStateException("Generated custom surface Iris biome resolved no custom biome id."); + } + return dimensionKey + ":" + custom.getId(); + } + + private static void verifyCaveWitness( + Engine engine, + IrisHydrologyRuntime runtime, + RealPackProbeSupport.GeneratedChunk generated, + GeneratedWitness witness, + PlatformBlockState expectedFluid, + VerificationFamily family + ) { + CavePosition position = witness.position(); + HydrologyCavePlan plan = runtime.tile(witness.observed().tile()) + .cavePlan(witness.layer().feature().courseId()) + .orElse(null); + if (plan == null || composedCaveActionAt(runtime, plan, witness.layer(), position) != witness.action() + || !exposureMatchesFamily(plan, family)) { + throw new IllegalStateException("Generated cave witness differs from its accepted containment plan."); + } + HydrologyCaveCell cell = HydrologyCaveStorage.getIfPresent( + engine.getMantle().getMantle(), + position.x(), + position.y(), + position.z() + ); + String expectedBiome = witness.action() == HydrologyCaveAction.DRY_AIR + ? witness.layer().dryBiomeKey() + : witness.layer().floodedCaveBiomeKey(); + if (cell == null + || cell.action() != witness.action() + || !cell.fluidProfileKey().equals(witness.layer().profileKey()) + || !cell.floodedBiomeKey().equals(expectedBiome)) { + String preconditionMismatch = firstPublicationPreconditionMismatch( + engine, + runtime, + plan, + generated.chunkX(), + generated.chunkZ() + ); + HydrologyColumnSample debugSample = runtime.sample(position.x(), position.z()).orElse(null); + throw new IllegalStateException("Retained cave hydrology differs from the accepted plan: expected=" + + witness.action() + "/" + witness.layer().profileKey() + "/" + expectedBiome + + ", actual=" + (cell == null + ? "null" + : cell.action() + "/" + cell.fluidProfileKey() + "/" + cell.floodedBiomeKey()) + + ", position=" + position + + ", publicationPrecondition=" + preconditionMismatch + + ", terrain=" + (debugSample == null ? "null" : debugSample.terrainHeight()) + + ", natural=" + (debugSample == null ? "null" : debugSample.naturalHeight()) + + ", sampleLayers=" + (debugSample == null ? "null" : debugSample.layers()) + + ", layer=" + witness.layer() + "."); + } + PlatformBlockState state = generated.blockAt(position.x(), position.y(), position.z()); + if (cell.isWet()) { + requireMatchingFluid(state, expectedFluid, "cave", false); + PlatformBlockState bed = generated.blockAt( + position.x(), + witness.layer().bedY(), + position.z() + ); + requireNonVegetatedBed( + bed, + "cave at " + new CavePosition(position.x(), witness.layer().bedY(), position.z()) + + " with " + witness.layer() + ); + } else if (state != null && !state.isAir()) { + throw new IllegalStateException("Generated dry hydrology cave cell is not air."); + } + } + + private static String firstPublicationPreconditionMismatch( + Engine engine, + IrisHydrologyRuntime runtime, + HydrologyCavePlan plan, + int chunkX, + int chunkZ + ) { + CaveVoxelView view = new MantleHydrologyCaveVoxelView( + engine, + engine.getComplex(), + (int x, int z, int naturalHeight) -> runtime.sample(x, z) + .map(HydrologyColumnSample::terrainHeight) + .orElse(naturalHeight) + ); + int minimumX = chunkX << 4; + int minimumZ = chunkZ << 4; + int maximumX = minimumX + 15; + int maximumZ = minimumZ + 15; + for (Map.Entry entry + : plan.baselinePreconditions().entrySet()) { + CavePosition candidate = entry.getKey(); + if (candidate.x() < minimumX || candidate.x() > maximumX + || candidate.z() < minimumZ || candidate.z() > maximumZ) { + continue; + } + CaveVoxelPrecondition expected = entry.getValue(); + CaveVoxel actual = view.voxelAt(candidate); + boolean actualOpen = view.isOpenToSurface(candidate); + if ((actual != expected.voxel() || actualOpen != expected.openToSurface()) + && !publicationAllowsChangedCaveBoundary( + runtime.sample(candidate.x(), candidate.z()).orElse(null), + plan, + candidate, + expected, + actual, + actualOpen + )) { + HydrologyColumnSample mismatchSample = runtime.sample(candidate.x(), candidate.z()).orElse(null); + return candidate + "/expected=" + expected.voxel() + "/" + expected.openToSurface() + + "/actual=" + actual + "/" + actualOpen + + "/action=" + plan.actions().get(candidate) + + "/terrain=" + (mismatchSample == null ? "null" : mismatchSample.terrainHeight()); + } + } + return "none"; + } + + private static boolean publicationAllowsChangedCaveBoundary( + HydrologyColumnSample sample, + HydrologyCavePlan plan, + CavePosition position, + CaveVoxelPrecondition expected, + CaveVoxel actual, + boolean actualOpen + ) { + HydrologyCaveAction action = plan.actions().get(position); + if (expected.voxel() == CaveVoxel.CAVE_AIR + && expected.openToSurface() + && actual == CaveVoxel.CAVE_AIR + && !actualOpen + && action != HydrologyCaveAction.DRY_AIR) { + return true; + } + if (expected.voxel() != CaveVoxel.CAVE_AIR + || !expected.openToSurface() + || actual != CaveVoxel.SOLID + || actualOpen) { + return false; + } + if (action == null || action == HydrologyCaveAction.SEAL_GUARD) { + return true; + } + if (sample == null + || action != HydrologyCaveAction.WET_SOURCE + && action != HydrologyCaveAction.FALLING_FLUID) { + return false; + } + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == plan.source().sourceId() + && layer.feature().type().isSurface() + && !layer.oceanApron() + && layer.channel() + && layer.terrainOwned() + && layer.fluidOwned() + && layer.connectedFluid() + && position.y() > layer.bedY() + && position.y() <= layer.fluidHeadY()) { + return true; + } + } + return false; + } + + private static void requireNoUpdateMarker(Engine engine, CavePosition position) { + MatterUpdate update = engine.getMantle().getMantle().get( + position.x(), + position.y(), + position.z(), + MatterUpdate.class + ); + if (update != null && update.isUpdate()) { + throw new IllegalStateException("Generated falling fluid retained a lateral update marker."); + } + } + + private static void requireNonVegetatedBed(PlatformBlockState bed, String label) { + String key = stateKey(bed).toLowerCase(Locale.ROOT); + if (key.endsWith(":grass_block") || key.endsWith(":moss_block")) { + throw new IllegalStateException("Generated " + label + " hydrology rests on " + key + "."); + } + } + + private static int composedSurfaceFluidHead(HydrologyColumnSample sample) { + int maximumHead = sample.terrainHeight(); + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().type().isSurface() + && !layer.oceanApron() + && layer.channel() + && layer.terrainOwned() + && layer.fluidOwned() + && layer.connectedFluid() + && layer.fluidHeadY() > layer.bedY()) { + maximumHead = Math.max(maximumHead, layer.fluidHeadY()); + } + } + return maximumHead; + } + + private static void requireMatchingFluid( + PlatformBlockState actual, + PlatformBlockState expected, + String label, + boolean allowWaterColumnReplacement + ) { + if (!matchesConfiguredFluid(actual, expected, allowWaterColumnReplacement)) { + throw new IllegalStateException("Generated " + label + " fluid differs from its configured profile: " + + "actual=" + (actual == null ? "null" : actual.key()) + + ", actual_material=" + (actual == null ? "null" : materialKey(actual)) + + ", expected=" + (expected == null ? "null" : expected.key()) + + ", expected_material=" + (expected == null ? "null" : materialKey(expected)) + "."); + } + } + + static boolean matchesConfiguredFluid( + PlatformBlockState actual, + PlatformBlockState expected, + boolean allowWaterColumnReplacement + ) { + if (actual == null || expected == null) { + return false; + } + if (actual.isFluid() && materialKey(actual).equals(materialKey(expected))) { + return true; + } + return allowWaterColumnReplacement + && expected.isWater() + && (actual.isWaterLogged() || WATER_COLUMN_REPLACEMENTS.contains(materialKey(actual))); + } + + private static String materialKey(PlatformBlockState state) { + String cached = state.materialKey(); + if (cached != null) { + return cached; + } + String key = state.key(); + int bracket = key.indexOf('['); + return bracket < 0 ? key : key.substring(0, bracket); + } + + private static String stateKey(PlatformBlockState state) { + return state == null ? "minecraft:air" : state.key(); + } + + private static GeneratedWitness resolveGeneratedWitness( + IrisHydrologyRuntime runtime, + long seed, + int minimumWorldY, + GeneratedWitnessDescriptor descriptor + ) { + HydrologyTile tile = runtime.tile(descriptor.tile()); + HydrologyFeatureRef feature = null; + for (HydrologyFeatureRef candidate : tile.features()) { + if (candidate.id() == descriptor.featureId()) { + feature = candidate; + break; + } + } + if (feature == null) { + throw new IllegalStateException("Generated witness feature is absent from the accepted tile: " + + Long.toUnsignedString(descriptor.featureId()) + "."); + } + String profileKey = null; + for (RiverCourse course : tile.courses()) { + if (course.id() == feature.courseId()) { + profileKey = course.profileKey(); + break; + } + } + if (!descriptor.profileKey().equals(profileKey)) { + throw new IllegalStateException("Generated witness profile changed across process isolation."); + } + ObservedFeature observed = new ObservedFeature( + seed, + descriptor.tile(), + profileKey, + feature, + minimumWorldY + ); + GeneratedWitness witness = findGeneratedWitness( + runtime, + tile, + orderedFootprintColumns(tile), + observed, + descriptor.selector() + ).orElseThrow(() -> new IllegalStateException( + "Generated witness could not be reconstructed from the accepted tile.")); + if (!witness.position().equals(descriptor.position()) || witness.action() != descriptor.action()) { + throw new IllegalStateException("Generated witness changed across process isolation: expected " + + descriptor.position() + "/" + descriptor.action().name() + + ", resolved " + witness.position() + "/" + witness.action().name() + "."); + } + return witness; + } + + private static String encodeToken(String value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String decodeToken(String value) { + return new String(Base64.getUrlDecoder().decode(value), StandardCharsets.UTF_8); + } + + public static final class GeneratedChunkProcess { + private static final String LOG_PREFIX = "[hydrology-generated-chunk-probe]"; + + private GeneratedChunkProcess() { + } + + public static void main(String[] arguments) { + try { + run(arguments); + System.exit(0); + } catch (Throwable failure) { + System.out.println(LOG_PREFIX + " FAIL: generated chunk verification failed"); + failure.printStackTrace(System.out); + System.exit(1); + } + } + + private static void run(String[] arguments) throws Exception { + if (arguments.length < 7) { + throw new IllegalArgumentException("Generated chunk process requires a pack, dimension, seed, " + + "studio flag, chunk coordinates, and at least one witness."); + } + File preparedPack = new File(arguments[0]); + String dimensionKey = arguments[1]; + long seed = Long.parseLong(arguments[2]); + boolean studio = RealPackProbeSupport.parseBoolean(arguments[3], "studio"); + int chunkX = Integer.parseInt(arguments[4]); + int chunkZ = Integer.parseInt(arguments[5]); + ArrayList descriptors = new ArrayList<>(arguments.length - 6); + for (int index = 6; index < arguments.length; index++) { + descriptors.add(GeneratedWitnessDescriptor.parse(arguments[index])); + } + + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openPreparedWorkspace( + preparedPack, dimensionKey, LOG_PREFIX, true); + RealPackProbeSupport.EngineSession session = workspace.openEngine( + seed, studio, "generated-" + seed + "-" + chunkX + "-" + chunkZ)) { + Engine engine = session.engine(); + IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime(); + if (runtime == null) { + throw new IllegalStateException("Dimension '" + dimensionKey + + "' has no hydrology runtime during generated verification."); + } + ArrayList witnesses = new ArrayList<>(descriptors.size()); + for (GeneratedWitnessDescriptor descriptor : descriptors) { + GeneratedWitness witness = resolveGeneratedWitness( + runtime, + seed, + engine.getWorld().minHeight(), + descriptor + ); + int witnessChunkX = Math.floorDiv(witness.position().x(), 16); + int witnessChunkZ = Math.floorDiv(witness.position().z(), 16); + if (witnessChunkX != chunkX || witnessChunkZ != chunkZ) { + throw new IllegalStateException("Generated witness is outside the requested chunk."); + } + witnesses.add(witness); + } + RealPackProbeSupport.GeneratedChunk generated = RealPackProbeSupport.generateChunk( + engine, chunkX, chunkZ); + for (GeneratedWitness witness : witnesses) { + GeneratedVerification verification = verifyGeneratedWitness( + engine, runtime, generated, witness); + System.out.println(new GeneratedProcessResult( + witness.selector(), verification).machineLine()); + } + List reports = RealPackProbeSupport.settleAndDrain(); + if (!reports.isEmpty()) { + RealPackProbeSupport.printReports(LOG_PREFIX, "generated chunk reports", reports); + throw reportedFailure("Hydrology generated chunk process", reports); + } + } + } + } + + static List missingConfiguredCoverage( + Set observed, + Set required + ) { + ArrayList missing = new ArrayList<>(); + ArrayList ordered = new ArrayList<>(required); + ordered.sort(Comparator.naturalOrder()); + for (ConfiguredCoverage requirement : ordered) { + if (!observed.contains(requirement)) { + missing.add(requirement); + } + } + return List.copyOf(missing); + } + + private static Set configuredCoverage(IrisHydrologyRuntime runtime) { + LinkedHashSet required = new LinkedHashSet<>(); + HydrologyPlannerSettings settings = runtime.settings(); + if (settings.surface().enabled() + && settings.surface().sources().enabled() + && settings.surface().sources().maximumPerTile() > 0) { + for (String profileKey : runtime.profileKeys()) { + required.add(new ConfiguredCoverage(CoverageFamily.SURFACE, profileKey)); + } + } + if (settings.underground().enabled() + && settings.underground().sources().enabled() + && settings.underground().sources().maximumPerTile() > 0) { + for (String profileKey : runtime.profileKeys()) { + required.add(new ConfiguredCoverage(CoverageFamily.UNDERGROUND, profileKey)); + } + } + for (HydrologyPlannerSettings.DeepFluid deepFluid : settings.deepFluids()) { + if (deepFluid.enabled() && deepFluid.maximumPerTile() > 0) { + required.add(new ConfiguredCoverage(CoverageFamily.DEEP, deepFluid.id())); + } + } + return Set.copyOf(required); + } + + private static ConfiguredCoverage courseCoverage(RiverCourse course) { + CoverageFamily family = switch (course.type()) { + case SURFACE -> CoverageFamily.SURFACE; + case UNDERGROUND -> CoverageFamily.UNDERGROUND; + case DEEP_FLUID -> CoverageFamily.DEEP; + }; + return new ConfiguredCoverage(family, course.profileKey()); + } + + private static void validateTileBlockBounds( + IrisHydrologyRuntime runtime, + ProbeConfiguration configuration + ) { + int tileSize = runtime.settings().routing().tileSize(); + int publicationRadius = runtime.settings().publicationRadius(); + long minimumBlockX = (long) configuration.minimumTileX() * tileSize - publicationRadius; + long maximumBlockX = ((long) configuration.maximumTileX() + 1L) * tileSize + - 1L + publicationRadius; + long minimumBlockZ = (long) configuration.minimumTileZ() * tileSize - publicationRadius; + long maximumBlockZ = ((long) configuration.maximumTileZ() + 1L) * tileSize + - 1L + publicationRadius; + if (minimumBlockX < Integer.MIN_VALUE || maximumBlockX > Integer.MAX_VALUE + || minimumBlockZ < Integer.MIN_VALUE || maximumBlockZ > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Hydrology tile bounds exceed the planner's integer block range."); + } + } + + private static IllegalStateException reportedFailure(String operation, List reports) { + IllegalStateException failure = new IllegalStateException( + operation + " reported " + reports.size() + " error(s)."); + for (Throwable report : reports) { + failure.addSuppressed(report); + } + return failure; + } + + private static List parseSeeds(String value) { + String[] parts = value.split(",", -1); + ArrayList seeds = new ArrayList<>(parts.length); + for (String part : parts) { + if (part.isBlank()) { + throw new IllegalArgumentException("Seed list cannot contain an empty entry."); + } + seeds.add(Long.parseLong(part)); + } + return List.copyOf(seeds); + } + + private static List parseCoverage(String value) { + String[] parts = value.split(",", -1); + ArrayList selectors = new ArrayList<>(parts.length); + for (String part : parts) { + if (part.isBlank()) { + throw new IllegalArgumentException("Coverage selector list cannot contain an empty entry."); + } + selectors.add(CoverageSelector.parse(part)); + } + return List.copyOf(selectors); + } + + private static long boundedArea( + int minimumX, + int maximumX, + int minimumZ, + int maximumZ, + String label + ) { + long width = (long) maximumX - minimumX + 1L; + long depth = (long) maximumZ - minimumZ + 1L; + try { + return Math.multiplyExact(width, depth); + } catch (ArithmeticException failure) { + throw new IllegalArgumentException(label + " bounds overflow their bounded area.", failure); + } + } + + private static void updateSignature( + MessageDigest digest, + long seed, + HydrologyTileKey tile, + String profileKey, + HydrologyFeatureRef feature + ) { + updateDigest(digest, Long.toString(seed)); + updateDigest(digest, Integer.toString(tile.tileX())); + updateDigest(digest, Integer.toString(tile.tileZ())); + updateDigest(digest, Long.toUnsignedString(feature.id())); + updateDigest(digest, feature.type().name()); + updateDigest(digest, profileKey); + updateDigest(digest, Integer.toString(feature.x())); + updateDigest(digest, Integer.toString(feature.y())); + updateDigest(digest, Integer.toString(feature.z())); + } + + private static void updateDigest(MessageDigest digest, String value) { + digest.update(value.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException(failure); + } + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java new file mode 100644 index 000000000..236ecde12 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java @@ -0,0 +1,524 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydrologyPlanner; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; +import art.arcane.iris.engine.hydrology.HydrologyTile; +import art.arcane.iris.engine.hydrology.HydrologyTileCache; +import art.arcane.iris.engine.hydrology.HydrologyTileKey; +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.CaveVoxel; +import art.arcane.iris.engine.hydrology.cave.CaveVoxelView; + +import java.util.Arrays; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +public final class HydrologyPlannerProbe { + private static final long[] DEFAULT_SEEDS = {1L, 19L, 52L, 77L, 331L, 501L, 712L, 992L}; + private static final long SURFACE_INLAND_SEED = 7012L; + private static final long UNDERGROUND_INLAND_SEED = 7013L; + private static final HydrologyTileKey ORIGIN = new HydrologyTileKey(0, 0); + private static final int WARM_SAMPLES = 2_048; + + private HydrologyPlannerProbe() { + } + + public static void main(String[] arguments) { + long[] seeds = parseSeeds(arguments); + long[] coldTimings = new long[seeds.length]; + long[] warmTimings = new long[seeds.length]; + long signature = 0xCBF29CE484222325L; + EnumMap firstFeatures = + new EnumMap<>(HydrologyFeatureType.class); + + for (int seedIndex = 0; seedIndex < seeds.length; seedIndex++) { + long seed = seeds[seedIndex]; + HydrologyPlanner planner = new HydrologyPlanner(seed, settings(), terrain(seed)); + HydrologyTileCache cache = new HydrologyTileCache(planner, 32); + long coldStart = System.nanoTime(); + HydrologyTile tile = cache.get(ORIGIN); + coldTimings[seedIndex] = System.nanoTime() - coldStart; + HydrologyTile repeated = new HydrologyPlanner(seed, settings(), terrain(seed)).plan(ORIGIN); + if (!tile.equals(repeated)) { + throw new IllegalStateException("Hydrology plan changed across deterministic replay for seed " + seed); + } + + EnumMap counts = new EnumMap<>(HydrologyFeatureType.class); + for (HydrologyFeatureRef feature : tile.features()) { + counts.merge(feature.type(), 1, Integer::sum); + signature = mix(signature, feature.id()); + signature = mix(signature, feature.type().ordinal()); + signature = mix(signature, feature.x()); + signature = mix(signature, feature.y()); + signature = mix(signature, feature.z()); + } + collectAccepted(firstFeatures, tile); + + long warmStart = System.nanoTime(); + for (int sampleIndex = 0; sampleIndex < WARM_SAMPLES; sampleIndex++) { + int x = Math.floorMod(sampleIndex * 1_229, 256); + int z = Math.floorMod(sampleIndex * 811, 256); + Optional sample = cache.columnAt(x, z); + signature = mix(signature, sample.map(HydrologyColumnSample::layers).map(List::size).orElse(0)); + } + warmTimings[seedIndex] = System.nanoTime() - warmStart; + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_SEED seed=%d cold_nanos=%d warm_nanos=%d courses=%d features=%d diagnostics=%d counts=%s%n", + seed, + coldTimings[seedIndex], + warmTimings[seedIndex], + tile.courses().size(), + tile.features().size(), + tile.diagnosticCandidates().size(), + counts + ); + cache.close(); + } + + collectAccepted(firstFeatures, surfaceInlandTile()); + collectAccepted(firstFeatures, undergroundInlandTile()); + collectAccepted(firstFeatures, surfaceMouthTile()); + requireCompleteCoverage(firstFeatures.keySet()); + + for (Map.Entry entry : firstFeatures.entrySet()) { + HydrologyFeatureRef feature = entry.getValue(); + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_COORDINATE type=%s x=%d y=%d z=%d id=%016x%n", + entry.getKey(), + feature.x(), + feature.y(), + feature.z(), + feature.id() + ); + } + + long[] sortedCold = coldTimings.clone(); + long[] sortedWarm = warmTimings.clone(); + Arrays.sort(sortedCold); + Arrays.sort(sortedWarm); + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_RESULT version=1 seeds=%d warm_samples=%d cold_median_nanos=%d cold_p95_nanos=%d warm_median_nanos=%d warm_p95_nanos=%d feature_types=%d signature=%016x%n", + seeds.length, + WARM_SAMPLES, + percentile(sortedCold, 0.5D), + percentile(sortedCold, 0.95D), + percentile(sortedWarm, 0.5D), + percentile(sortedWarm, 0.95D), + firstFeatures.size(), + signature + ); + } + + static Map deterministicAcceptedCoverage() { + EnumMap coverage = new EnumMap<>(HydrologyFeatureType.class); + for (long seed : DEFAULT_SEEDS) { + collectAccepted( + coverage, + new HydrologyPlanner(seed, settings(), terrain(seed)).plan(ORIGIN) + ); + } + collectAccepted(coverage, surfaceInlandTile()); + collectAccepted(coverage, undergroundInlandTile()); + collectAccepted(coverage, surfaceMouthTile()); + requireCompleteCoverage(coverage.keySet()); + return Map.copyOf(coverage); + } + + private static void collectAccepted( + Map features, + HydrologyTile tile + ) { + for (HydrologyFeatureRef feature : tile.features()) { + features.putIfAbsent(feature.type(), feature); + } + } + + private static void requireCompleteCoverage(Set observed) { + EnumSet missing = EnumSet.allOf(HydrologyFeatureType.class); + missing.removeAll(observed); + if (!missing.isEmpty()) { + throw new IllegalStateException("Hydrology accepted-feature coverage is missing " + missing + "."); + } + } + + private static HydrologyTile surfaceInlandTile() { + return new HydrologyPlanner( + SURFACE_INLAND_SEED, + inlandSettings(0D, 0D), + inlandTerrain(true, false), + solidCaveView() + ).plan(ORIGIN); + } + + private static HydrologyTile undergroundInlandTile() { + return new HydrologyPlanner( + UNDERGROUND_INLAND_SEED, + inlandSettings(0D, 1D), + inlandTerrain(false, true), + solidCaveView() + ).plan(ORIGIN); + } + + private static HydrologyTile surfaceMouthTile() { + return new HydrologyPlanner( + DEFAULT_SEEDS[0], + settings(false), + terrain(DEFAULT_SEEDS[0]) + ).plan(ORIGIN); + } + + private static HydrologyPlannerSettings inlandSettings( + double surfaceDensity, + double undergroundDensity + ) { + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( + true, + surfaceDensity, + 80, + 0, + 6, + 24 + ); + HydrologyPlannerSettings.Source undergroundSources = new HydrologyPlannerSettings.Source( + true, + undergroundDensity, + Integer.MIN_VALUE, + 0, + 4, + 32 + ); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing( + 128, + 16, + 4, + 512, + 256, + new HydrologyPlannerSettings.Branching(32, 16), + 0.5D, + 12D, + 0.5D, + 0.1D + ), + new HydrologyPlannerSettings.Surface( + true, + surfaceSources, + 4, + 18, + 2, + 4, + 4, + 8, + 10, + 1.5D, + 4, + 8, + true, + 96, + 8 + ), + new HydrologyPlannerSettings.Hydraulics(12, 28, 1, 3, 8, 4), + new HydrologyPlannerSettings.Underground( + undergroundDensity > 0D, + undergroundSources, + 68, + 82, + 4, + 12, + 2, + 4, + 5, + 9, + true + ), + new HydrologyPlannerSettings.Outlets( + false, + new HydrologyPlannerSettings.Grotto(false, 4, 3, 3, 4096), + new HydrologyPlannerSettings.Grotto(true, 4, 3, 3, 4096), + true, + 12, + 32, + 2, + 4 + ), + HydrologyPlannerSettings.Geometry.defaults(), + List.of() + ); + } + + private static HydrologyTerrainSampler inlandTerrain( + boolean surfaceSource, + boolean undergroundSource + ) { + return (int x, int z) -> { + boolean source = x == 0 && z == 0; + boolean outlet = x == 64 && z == 64; + int height = 120 - Math.floorDiv(x + z, 16); + return new HydrologyTerrainSample( + height, + 1D, + false, + true, + 72, + 74, + true, + outlet, + surfaceSource && source, + surfaceSource && source, + undergroundSource && source, + undergroundSource && source, + 0D, + surfaceSource && source ? 1D : 0D, + undergroundSource && source ? 1D : 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water") + ); + }; + } + + private static CaveVoxelView solidCaveView() { + return new CaveVoxelView() { + @Override + public boolean isInWorld(CavePosition position) { + return position.y() > -4096 && position.y() < 4096; + } + + @Override + public CaveVoxel voxelAt(CavePosition position) { + return CaveVoxel.SOLID; + } + + @Override + public boolean isOpenToSurface(CavePosition position) { + return false; + } + + @Override + public boolean isAboveTerrainSurface(CavePosition position) { + return false; + } + }; + } + + private static HydrologyPlannerSettings settings() { + return settings(true); + } + + private static HydrologyPlannerSettings settings(boolean coastalGrottosEnabled) { + HydrologyPlannerSettings.Source surfaceSources = new HydrologyPlannerSettings.Source( + true, + 4D, + 80, + 1, + 6, + 32 + ); + HydrologyPlannerSettings.Source undergroundSources = new HydrologyPlannerSettings.Source( + true, + 4D, + Integer.MIN_VALUE, + 1, + 4, + 48 + ); + HydrologyPlannerSettings.DeepFluid deepFluid = new HydrologyPlannerSettings.DeepFluid( + "deep_lava", + true, + 3D, + 48, + 18, + 42, + 3, + 5, + 2, + 3, + 8, + 24, + 4, + 2, + 3, + 4096, + 4, + true, + true + ); + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing( + 256, + 16, + 4, + 1_089, + 256, + new HydrologyPlannerSettings.Branching(32, 16), + 0.5D, + 12D, + 0.5D, + 0.2D + ), + new HydrologyPlannerSettings.Surface( + true, + surfaceSources, + 4, + 20, + 2, + 5, + 4, + 8, + 12, + 1.5D, + 4, + 10, + true, + 96, + 10 + ), + new HydrologyPlannerSettings.Hydraulics(12, 40, 1, 3, 8, 4), + new HydrologyPlannerSettings.Underground( + true, + undergroundSources, + 68, + 82, + 4, + 14, + 2, + 5, + 6, + 12, + true + ), + new HydrologyPlannerSettings.Outlets( + true, + new HydrologyPlannerSettings.Grotto(coastalGrottosEnabled, 4, 3, 3, 4096), + new HydrologyPlannerSettings.Grotto(true, 4, 3, 3, 4096), + true, + 12, + 48, + 2, + 8 + ), + HydrologyPlannerSettings.Geometry.defaults(), + List.of(deepFluid) + ); + } + + private static HydrologyTerrainSampler terrain(long seed) { + return (int x, int z) -> { + if (x >= 224) { + return oceanTerrain(); + } + double wave = StrictMath.sin((z + seed % 37L) / 19D) * 3D; + double ridge = x >= 96 && x <= 128 ? 34D : 0D; + double cliff = x >= 144 ? -24D : 0D; + int height = 126 - Math.floorDiv(x, 13) + (int) StrictMath.round(wave + ridge + cliff); + double slope = ridge > 0D || x >= 136 && x <= 144 + ? 18D + : 1D + StrictMath.abs(wave) * 0.25D; + boolean surfaceSource = x >= 0 && x <= 48; + boolean undergroundSource = x >= 0 && x <= 64; + return new HydrologyTerrainSample( + height, + slope, + false, + true, + 70, + 74 + Math.floorMod(x + z, 7), + true, + true, + surfaceSource, + surfaceSource, + undergroundSource, + undergroundSource, + 0D, + surfaceSource ? 1D : 0D, + undergroundSource ? 1D : 0D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water") + ); + }; + } + + private static HydrologyTerrainSample oceanTerrain() { + return new HydrologyTerrainSample( + 54, + 0D, + true, + false, + 30, + 32, + false, + false, + false, + false, + false, + false, + 0D, + 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + "ocean_parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water") + ); + } + + private static long[] parseSeeds(String[] arguments) { + if (arguments.length == 0) { + return DEFAULT_SEEDS.clone(); + } + long[] seeds = new long[arguments.length]; + for (int index = 0; index < arguments.length; index++) { + seeds[index] = Long.parseLong(arguments[index]); + } + return seeds; + } + + private static long percentile(long[] sorted, double percentile) { + int index = Math.max(0, (int) StrictMath.ceil(sorted.length * percentile) - 1); + return sorted[index]; + } + + private static long mix(long hash, long value) { + return (hash ^ value) * 0x100000001B3L; + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java b/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java new file mode 100644 index 000000000..27ccc3b4d --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/RealPackProbeSupport.java @@ -0,0 +1,527 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.pack.PackValidationResult; +import art.arcane.iris.core.pack.PackValidator; +import art.arcane.iris.engine.IrisEngine; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.framework.EngineAssignedComponent; +import art.arcane.iris.engine.framework.EngineEffects; +import art.arcane.iris.engine.framework.EngineEffectsProvider; +import art.arcane.iris.engine.framework.EnginePlatformHooks; +import art.arcane.iris.engine.framework.EngineTarget; +import art.arcane.iris.engine.framework.EngineWorldManager; +import art.arcane.iris.engine.framework.EngineWorldManagerProvider; +import art.arcane.iris.engine.framework.MeteredCache; +import art.arcane.iris.engine.framework.PreservationRegistry; +import art.arcane.iris.engine.mantle.MantleComponent; +import art.arcane.iris.engine.mantle.components.MantleObjectComponent; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisWorld; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.IrisServices; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.stream.Stream; + +final class RealPackProbeSupport { + private static final List REPORTED = Collections.synchronizedList(new ArrayList<>()); + + private RealPackProbeSupport() { + } + + static Workspace openWorkspace(File packSource, String dimensionKey, String logPrefix) throws Exception { + if (packSource == null || !packSource.isDirectory()) { + throw new IllegalArgumentException("Pack folder not found: " + + (packSource == null ? "null" : packSource.getAbsolutePath())); + } + if (dimensionKey == null || dimensionKey.isBlank() + || dimensionKey.chars().anyMatch(Character::isWhitespace)) { + throw new IllegalArgumentException("Dimension key must be non-blank and contain no whitespace."); + } + File workRoot = Files.createTempDirectory("iris-real-pack-probe-").toFile(); + try { + File pack = clonePack(packSource, workRoot); + configureRuntime(new File(workRoot, "platform-validation"), false); + validatePack(pack, logPrefix); + return new Workspace(workRoot, pack, dimensionKey, logPrefix, false); + } catch (Throwable failure) { + Throwable cleanupFailure = deleteWorkRoot(workRoot); + if (cleanupFailure != null) { + failure.addSuppressed(cleanupFailure); + } + throwAsException(failure); + throw new IllegalStateException("Unreachable"); + } + } + + static Workspace openPreparedWorkspace( + File pack, + String dimensionKey, + String logPrefix, + boolean hydrologyGeneratedVerification + ) throws Exception { + if (pack == null || !pack.isDirectory()) { + throw new IllegalArgumentException("Prepared pack folder not found: " + + (pack == null ? "null" : pack.getAbsolutePath())); + } + File workRoot = Files.createTempDirectory("iris-generated-chunk-probe-").toFile(); + try { + configureRuntime(new File(workRoot, "platform-validation"), hydrologyGeneratedVerification); + return new Workspace(workRoot, pack, dimensionKey, logPrefix, hydrologyGeneratedVerification); + } catch (Throwable failure) { + Throwable cleanupFailure = deleteWorkRoot(workRoot); + if (cleanupFailure != null) { + failure.addSuppressed(cleanupFailure); + } + throwAsException(failure); + throw new IllegalStateException("Unreachable"); + } + } + + static List drainReported() { + synchronized (REPORTED) { + List drained = new ArrayList<>(REPORTED); + REPORTED.clear(); + return drained; + } + } + + static List settleAndDrain() throws InterruptedException { + List drained = new ArrayList<>(); + long quietSince = System.currentTimeMillis(); + long start = quietSince; + while (System.currentTimeMillis() - start < 15_000L) { + List batch = drainReported(); + if (batch.isEmpty()) { + if (System.currentTimeMillis() - quietSince >= 1_500L) { + break; + } + } else { + drained.addAll(batch); + quietSince = System.currentTimeMillis(); + } + Thread.sleep(50L); + } + return drained; + } + + static void printReports(String logPrefix, String label, List reports) { + if (reports.isEmpty()) { + return; + } + Map distinct = new LinkedHashMap<>(); + for (Throwable report : reports) { + distinct.merge(causeKey(report), 1, Integer::sum); + } + System.out.println(logPrefix + " " + label + " (" + distinct.size() + " distinct):"); + for (Map.Entry entry : distinct.entrySet()) { + System.out.println(" x" + entry.getValue() + " " + entry.getKey()); + } + for (Throwable report : reports) { + report.printStackTrace(System.out); + } + } + + static boolean parseBoolean(String value, String name) { + if ("true".equalsIgnoreCase(value)) { + return true; + } + if ("false".equalsIgnoreCase(value)) { + return false; + } + throw new IllegalArgumentException(name + " must be true or false."); + } + + static GeneratedChunk generateChunk(Engine engine, int chunkX, int chunkZ) { + int height = engine.getTarget().getHeight(); + Hunk blocks = Hunk.newArrayHunk(16, height, 16); + Hunk biomes = Hunk.newArrayHunk(16, height, 16); + try { + engine.generate(chunkX << 4, chunkZ << 4, blocks, biomes, false); + } catch (Exception failure) { + throw new IllegalStateException("Failed to generate probe chunk " + chunkX + "," + chunkZ, failure); + } + return new GeneratedChunk(chunkX, chunkZ, height, blocks, biomes); + } + + record GeneratedChunk( + int chunkX, + int chunkZ, + int height, + Hunk blocks, + Hunk biomes + ) { + GeneratedChunk { + if (height < 1 || blocks == null || biomes == null) { + throw new IllegalArgumentException("Generated chunk requires buffers and a positive height."); + } + } + + PlatformBlockState blockAt(int blockX, int y, int blockZ) { + validatePosition(blockX, y, blockZ); + return blocks.get(Math.floorMod(blockX, 16), y, Math.floorMod(blockZ, 16)); + } + + PlatformBiome biomeAt(int blockX, int y, int blockZ) { + validatePosition(blockX, y, blockZ); + return biomes.get(Math.floorMod(blockX, 16), y, Math.floorMod(blockZ, 16)); + } + + private void validatePosition(int blockX, int y, int blockZ) { + if (Math.floorDiv(blockX, 16) != chunkX || Math.floorDiv(blockZ, 16) != chunkZ) { + throw new IllegalArgumentException("Block coordinate is outside the generated chunk."); + } + if (y < 0 || y >= height) { + throw new IllegalArgumentException("Block Y is outside the generated chunk height."); + } + } + } + + static final class Workspace implements AutoCloseable { + private final File workRoot; + private final File pack; + private final String dimensionKey; + private final String logPrefix; + private final boolean hydrologyGeneratedVerification; + private int sessionSequence; + private boolean sessionOpen; + private boolean closed; + + private Workspace( + File workRoot, + File pack, + String dimensionKey, + String logPrefix, + boolean hydrologyGeneratedVerification + ) { + this.workRoot = workRoot; + this.pack = pack; + this.dimensionKey = dimensionKey; + this.logPrefix = logPrefix; + this.hydrologyGeneratedVerification = hydrologyGeneratedVerification; + } + + EngineSession openEngine(long seed, boolean studio, String runLabel) throws Exception { + if (closed) { + throw new IllegalStateException("Probe workspace is closed."); + } + if (sessionOpen) { + throw new IllegalStateException("Only one real-pack engine session may be open at a time."); + } + sessionOpen = true; + int sequence = sessionSequence++; + File platformRoot = new File(workRoot, "platform-" + sequence); + File worldRoot = new File(workRoot, "world-" + sequence); + IrisData data = null; + Engine engine = null; + try { + configureRuntime(platformRoot, hydrologyGeneratedVerification); + data = IrisData.openRuntime(pack); + IrisDimension dimension = data.getDimensionLoader().load(dimensionKey); + if (dimension == null) { + throw new IllegalStateException("Dimension '" + dimensionKey + + "' did not load from " + pack.getAbsolutePath()); + } + IrisWorld world = IrisWorld.builder() + .platformIdentity("iris:probe") + .name("probe") + .seed(seed) + .worldFolder(worldRoot) + .minHeight(dimension.getMinHeight()) + .maxHeight(dimension.getMaxHeight()) + .build(); + long started = System.nanoTime(); + engine = new IrisEngine( + new EngineTarget(world, dimension, data), + studio ? IrisEngine.InitializationMode.STUDIO : IrisEngine.InitializationMode.RUNTIME + ); + long readyNanos = System.nanoTime() - started; + List initializationReports = settleAndDrain(); + printReports(logPrefix, runLabel + " engine-init reports", initializationReports); + return new EngineSession(this, data, engine, readyNanos); + } catch (Throwable failure) { + Throwable cleanupFailure = closeResources(engine, data); + sessionOpen = false; + if (cleanupFailure != null) { + failure.addSuppressed(cleanupFailure); + } + throwAsException(failure); + throw new IllegalStateException("Unreachable"); + } + } + + File pack() { + return pack; + } + + @Override + public void close() throws Exception { + if (closed) { + return; + } + if (sessionOpen) { + throw new IllegalStateException("Cannot close a probe workspace with an open engine session."); + } + closed = true; + IrisPlatforms.unbind(); + StubPlatform.errorSink(null); + Throwable failure = deleteWorkRoot(workRoot); + if (failure != null) { + throwAsException(failure); + } + } + + private void releaseSession() { + sessionOpen = false; + } + } + + static final class EngineSession implements AutoCloseable { + private final Workspace workspace; + private final IrisData data; + private final Engine engine; + private final long readyNanos; + private boolean closed; + + private EngineSession(Workspace workspace, IrisData data, Engine engine, long readyNanos) { + this.workspace = workspace; + this.data = data; + this.engine = engine; + this.readyNanos = readyNanos; + } + + Engine engine() { + return engine; + } + + long readyNanos() { + return readyNanos; + } + + @Override + public void close() throws Exception { + if (closed) { + return; + } + closed = true; + Throwable failure = closeResources(engine, data); + List reported = drainReported(); + if (!reported.isEmpty()) { + IllegalStateException reportedFailure = new IllegalStateException( + "The real-pack engine reported " + reported.size() + " error(s) during shutdown."); + for (Throwable report : reported) { + reportedFailure.addSuppressed(report); + } + failure = appendFailure(failure, reportedFailure); + } + workspace.releaseSession(); + if (failure != null) { + throwAsException(failure); + } + } + } + + private static final class InertPreservation implements PreservationRegistry { + @Override + public void register(Thread thread) { + } + + @Override + public void register(ExecutorService service) { + } + + @Override + public void registerCache(MeteredCache cache) { + } + + @Override + public void dereference() { + } + } + + private static final class InertWorldManager implements EngineWorldManager { + @Override + public void close() { + } + + @Override + public int getEntityCount() { + return 0; + } + + @Override + public int getChunkCount() { + return 0; + } + + @Override + public double getEntitySaturation() { + return 0; + } + + @Override + public void onTick() { + } + + @Override + public void onSave() { + } + } + + private static final class InertEffects extends EngineAssignedComponent implements EngineEffects { + private InertEffects(Engine engine) { + super(engine, "FX"); + } + + @Override + public void updatePlayerMap() { + } + + @Override + public void tickRandomPlayer() { + } + } + + private static final class InertPlatformHooks implements EnginePlatformHooks { + private final boolean hydrologyGeneratedVerification; + + private InertPlatformHooks(boolean hydrologyGeneratedVerification) { + this.hydrologyGeneratedVerification = hydrologyGeneratedVerification; + } + + @Override + public boolean shouldGenerateMantleComponent(Engine engine, MantleComponent component) { + return !hydrologyGeneratedVerification || !(component instanceof MantleObjectComponent); + } + } + + private static void configureRuntime(File platformRoot, boolean hydrologyGeneratedVerification) { + REPORTED.clear(); + StubPlatform.bindGenerationStateHandlers(); + StubPlatform.verbose(false); + StubPlatform.errorSink(REPORTED::add); + IrisServices.register(PreservationRegistry.class, new InertPreservation()); + IrisServices.register(EngineWorldManagerProvider.class, + (EngineWorldManagerProvider) (Engine engine) -> new InertWorldManager()); + IrisServices.register(EngineEffectsProvider.class, (EngineEffectsProvider) InertEffects::new); + IrisServices.register(EnginePlatformHooks.class, new InertPlatformHooks(hydrologyGeneratedVerification)); + IrisPlatforms.unbind(); + IrisPlatforms.bind(new StubPlatform(platformRoot)); + } + + private static void validatePack(File pack, String logPrefix) { + PackValidationResult validation = PackValidator.validateForDatapackBootstrap(pack); + for (String warning : validation.getWarnings()) { + System.out.println(logPrefix + " pack warning: " + warning); + } + if (!validation.isLoadable()) { + for (String error : validation.getBlockingErrors()) { + System.out.println(logPrefix + " pack error: " + error); + } + throw new IllegalStateException("Pack validation blocked generation."); + } + System.out.println(logPrefix + " offline pack validation: PASS"); + } + + private static File clonePack(File source, File workRoot) throws Exception { + File destination = new File(workRoot, "pack"); + Process clone = new ProcessBuilder("cp", "-Rc", source.getAbsolutePath(), destination.getAbsolutePath()) + .inheritIO() + .start(); + if (clone.waitFor() != 0) { + deleteRecursively(destination.toPath()); + Process copy = new ProcessBuilder("cp", "-R", source.getAbsolutePath(), destination.getAbsolutePath()) + .inheritIO() + .start(); + if (copy.waitFor() != 0) { + throw new IllegalStateException("Failed to copy pack to " + destination.getAbsolutePath()); + } + } + return destination; + } + + private static Throwable closeResources(Engine engine, IrisData data) { + Throwable failure = null; + if (engine != null) { + try { + engine.close(); + } catch (Throwable engineFailure) { + failure = engineFailure; + } + } + if (data != null) { + try { + data.close(); + } catch (Throwable dataFailure) { + failure = appendFailure(failure, dataFailure); + } + } + return failure; + } + + private static Throwable deleteWorkRoot(File workRoot) { + try { + deleteRecursively(workRoot.toPath()); + return null; + } catch (Throwable failure) { + return failure; + } + } + + private static void deleteRecursively(Path root) throws Exception { + if (!Files.exists(root)) { + return; + } + List paths; + try (Stream walk = Files.walk(root)) { + paths = walk.sorted(Comparator.reverseOrder()).toList(); + } + for (Path path : paths) { + Files.deleteIfExists(path); + } + } + + private static Throwable appendFailure(Throwable failure, Throwable additional) { + if (failure == null) { + return additional; + } + if (failure != additional) { + failure.addSuppressed(additional); + } + return failure; + } + + private static void throwAsException(Throwable failure) throws Exception { + if (failure instanceof Error error) { + throw error; + } + if (failure instanceof Exception exception) { + throw exception; + } + throw new IllegalStateException(failure); + } + + private static String causeKey(Throwable failure) { + List chain = new ArrayList<>(); + Throwable cause = failure; + while (cause != null && !chain.contains(cause)) { + chain.add(cause); + cause = cause.getCause(); + } + Throwable root = chain.getLast(); + return root.getClass().getName() + ": " + root.getMessage(); + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/RiverTileProbe.java b/probe/src/main/java/art/arcane/iris/probe/RiverTileProbe.java deleted file mode 100644 index 4c141e7b4..000000000 --- a/probe/src/main/java/art/arcane/iris/probe/RiverTileProbe.java +++ /dev/null @@ -1,157 +0,0 @@ -package art.arcane.iris.probe; - -import art.arcane.iris.engine.river.RiverBodyProfile; -import art.arcane.iris.engine.river.RiverEdgeId; -import art.arcane.iris.engine.river.RiverNode; -import art.arcane.iris.engine.river.RiverNodeId; -import art.arcane.iris.engine.river.RiverPolyline; -import art.arcane.iris.engine.river.RiverReach; -import art.arcane.iris.engine.river.RiverRouteState; -import art.arcane.iris.engine.river.RiverSample; -import art.arcane.iris.engine.river.RiverTile; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; - -public final class RiverTileProbe { - private static final int WARMUP_ROUNDS = 5; - private static final int MEASURED_ROUNDS = 25; - private static final int SAMPLES_PER_ROUND = 8_192; - - private RiverTileProbe() { - } - - public static void main(String[] args) { - RiverTile tile = createTile(); - long expectedSignature = sampleRound(tile); - for (int round = 0; round < WARMUP_ROUNDS; round++) { - requireSignature(expectedSignature, sampleRound(tile)); - } - - long[] timings = new long[MEASURED_ROUNDS]; - for (int round = 0; round < MEASURED_ROUNDS; round++) { - long start = System.nanoTime(); - long signature = sampleRound(tile); - timings[round] = System.nanoTime() - start; - requireSignature(expectedSignature, signature); - System.out.printf(Locale.ROOT, - "IRIS_RIVER_TILE_SAMPLE round=%d nanos=%d signature=%016x%n", - round, - timings[round], - signature); - } - long[] sorted = timings.clone(); - Arrays.sort(sorted); - System.out.printf(Locale.ROOT, - "IRIS_RIVER_TILE_RESULT version=1 rounds=%d samples_per_round=%d median_nanos=%d p95_nanos=%d signature=%016x%n", - MEASURED_ROUNDS, - SAMPLES_PER_ROUND, - sorted[MEASURED_ROUNDS / 2], - sorted[(int) StrictMath.ceil(MEASURED_ROUNDS * 0.95D) - 1], - expectedSignature); - } - - private static RiverTile createTile() { - List reaches = new ArrayList<>(64); - for (int reachIndex = 0; reachIndex < 64; reachIndex++) { - RiverNode from = node(0L, reachIndex, 0D, reachIndex * 32D); - RiverNode to = node(1L, reachIndex, 2_048D, reachIndex * 32D); - RiverBodyProfile profile = profile(reachIndex); - double[] x = new double[33]; - double[] z = new double[33]; - for (int point = 0; point < x.length; point++) { - x[point] = point * 64D; - z[point] = reachIndex * 32D - + StrictMath.sin(point * 0.625D + reachIndex * 0.25D) * 12D; - } - reaches.add(new RiverReach( - RiverEdgeId.of(from.id(), to.id()), - from, - to, - RiverRouteState.WET, - 1 + reachIndex % 4, - 1 + reachIndex % 3, - profile.maximumWidth(), - profile.maximumBankWidth(), - profile.maximumDepth(), - profile, - false, - false, - new RiverPolyline(x, z) - )); - } - return new RiverTile(0, 0, 0, 0, 2_048, 2_048, reaches); - } - - private static RiverBodyProfile profile(int reachIndex) { - double[] positions = new double[17]; - double[] widths = new double[17]; - double[] bankWidths = new double[17]; - double[] depths = new double[17]; - double[] roofScales = new double[17]; - for (int index = 0; index < positions.length; index++) { - double position = index / 16D; - double body = StrictMath.sin(StrictMath.PI * position); - positions[index] = position; - widths[index] = 8D + reachIndex % 5 + body * 6D; - bankWidths[index] = 4D + reachIndex % 3 + body * 4D; - depths[index] = 3D + reachIndex % 2 + body * 2D; - roofScales[index] = 1D - body * 0.4D; - } - return new RiverBodyProfile(positions, widths, bankWidths, depths, roofScales); - } - - private static RiverNode node(long cellX, long cellZ, double x, double z) { - return new RiverNode( - new RiverNodeId(cellX, cellZ), - x, - z, - 64D, - 64D, - 64D, - 64D, - false, - true - ); - } - - private static long sampleRound(RiverTile tile) { - long signature = 0xCBF29CE484222325L; - for (int sampleIndex = 0; sampleIndex < SAMPLES_PER_ROUND; sampleIndex++) { - double x = Math.floorMod(sampleIndex * 1_229, 2_048) + 0.375D; - double z = Math.floorMod(sampleIndex * 811, 2_048) + 0.625D; - double additionalRadius = 16D + sampleIndex % 17; - RiverSample sample = tile.sampleExpanded(x, z, additionalRadius); - signature = mix(signature, sample.present() ? 1L : 0L); - if (!sample.present()) { - continue; - } - signature = mix(signature, sample.reachId().stableId()); - signature = mix(signature, Double.doubleToLongBits(sample.distance())); - signature = mix(signature, Double.doubleToLongBits(sample.alongReach())); - signature = mix(signature, Double.doubleToLongBits(sample.carveWeight())); - signature = mix(signature, Double.doubleToLongBits(sample.width())); - signature = mix(signature, Double.doubleToLongBits(sample.bankWidth())); - signature = mix(signature, Double.doubleToLongBits(sample.depth())); - signature = mix(signature, sample.section().ordinal()); - } - return signature; - } - - private static long mix(long hash, long value) { - return (hash ^ value) * 0x100000001B3L; - } - - private static void requireSignature(long expected, long actual) { - if (actual != expected) { - throw new IllegalStateException(String.format( - Locale.ROOT, - "River tile output changed: expected %016x but got %016x", - expected, - actual - )); - } - } -} diff --git a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java index 9620b9572..493a26248 100644 --- a/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java +++ b/probe/src/main/java/art/arcane/iris/probe/StubPlatform.java @@ -40,6 +40,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Consumer; @@ -91,13 +92,23 @@ public final class StubPlatform implements IrisPlatform { private static final class StubBlockState implements PlatformBlockState { private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); private final String key; + private final String blockKey; + private final LinkedHashMap properties; - private StubBlockState(String key) { + private StubBlockState(String key, ParsedState parsed) { this.key = key; + this.blockKey = parsed.blockKey(); + this.properties = new LinkedHashMap<>(parsed.properties()); } static StubBlockState of(String key) { - return CACHE.computeIfAbsent(key, StubBlockState::new); + StubBlockState cached = key == null ? null : CACHE.get(key); + return cached == null ? of(ParsedState.parse(key)) : cached; + } + + static StubBlockState of(ParsedState parsed) { + String key = parsed.serialize(); + return CACHE.computeIfAbsent(key, ignored -> new StubBlockState(key, parsed)); } @Override @@ -147,7 +158,7 @@ public final class StubPlatform implements IrisPlatform { @Override public boolean isWaterLogged() { - return false; + return "true".equals(properties.get("waterlogged")); } @Override @@ -227,9 +238,14 @@ public final class StubPlatform implements IrisPlatform { @Override public PlatformBlockState withProperty(String name, String value) { - ParsedState parsed = ParsedState.parse(key); - parsed.properties().put(normalizeProperty(name), normalizeProperty(value)); - return of(parsed.serialize()); + String propertyName = normalizeProperty(name); + String propertyValue = normalizeProperty(value); + if (propertyValue.equals(properties.get(propertyName))) { + return this; + } + ParsedState parsed = parsed(); + parsed.properties().put(propertyName, propertyValue); + return of(parsed); } @Override @@ -238,8 +254,11 @@ public final class StubPlatform implements IrisPlatform { } private String blockKey() { - int properties = key.indexOf('['); - return properties >= 0 ? key.substring(0, properties) : key; + return blockKey; + } + + private ParsedState parsed() { + return new ParsedState(blockKey, new LinkedHashMap<>(properties)); } } @@ -248,18 +267,33 @@ public final class StubPlatform implements IrisPlatform { if (state == null || rotation == null || !rotation.canRotate()) { return state; } - ParsedState parsed = ParsedState.parse(state.key()); + ParsedState parsed = parsedState(state); Map properties = parsed.properties(); if (properties.containsKey("facing")) { - properties.put("facing", rotateFace(rotation, properties.get("facing"), spinX, spinY, spinZ)); + String facing = properties.get("facing"); + String rotated = rotateFace(rotation, facing, spinX, spinY, spinZ); + if (facing.equals(rotated)) { + return state; + } + properties.put("facing", rotated); } else if (properties.containsKey("rotation")) { - properties.put("rotation", rotateSegment(rotation, properties.get("rotation"), spinX, spinY, spinZ)); + String segment = properties.get("rotation"); + String rotated = rotateSegment(rotation, segment, spinX, spinY, spinZ); + if (segment.equals(rotated)) { + return state; + } + properties.put("rotation", rotated); } else if (properties.containsKey("axis")) { - properties.put("axis", rotateAxis(rotation, properties.get("axis"), spinX, spinY, spinZ)); - } else { - rotateFaceProperties(rotation, properties, spinX, spinY, spinZ); + String axis = properties.get("axis"); + String rotated = rotateAxis(rotation, axis, spinX, spinY, spinZ); + if (axis.equals(rotated)) { + return state; + } + properties.put("axis", rotated); + } else if (!rotateFaceProperties(rotation, properties, spinX, spinY, spinZ)) { + return state; } - return StubBlockState.of(parsed.serialize()); + return StubBlockState.of(parsed); } private static PlatformBlockState mergeStates(PlatformBlockState base, PlatformBlockState update) { @@ -269,13 +303,21 @@ public final class StubPlatform implements IrisPlatform { if (update == null) { return base; } - ParsedState parsedBase = ParsedState.parse(base.key()); - ParsedState parsedUpdate = ParsedState.parse(update.key()); + ParsedState parsedBase = parsedState(base); + ParsedState parsedUpdate = parsedState(update); if (!parsedBase.blockKey().equals(parsedUpdate.blockKey())) { return update; } + if (parsedUpdate.properties().isEmpty()) { + return base; + } + LinkedHashMap original = new LinkedHashMap<>(parsedBase.properties()); parsedBase.properties().putAll(parsedUpdate.properties()); - return StubBlockState.of(parsedBase.serialize()); + return original.equals(parsedBase.properties()) ? base : StubBlockState.of(parsedBase); + } + + private static ParsedState parsedState(PlatformBlockState state) { + return state instanceof StubBlockState stub ? stub.parsed() : ParsedState.parse(state.key()); } private static String rotateFace(IrisObjectRotation rotation, String face, @@ -327,8 +369,8 @@ public final class StubPlatform implements IrisPlatform { return Integer.toString(Math.floorMod(rotatedSegment, 16)); } - private static void rotateFaceProperties(IrisObjectRotation rotation, Map properties, - int spinX, int spinY, int spinZ) { + private static boolean rotateFaceProperties(IrisObjectRotation rotation, Map properties, + int spinX, int spinY, int spinZ) { List faces = List.of("north", "east", "south", "west", "up", "down"); int present = 0; for (String face : faces) { @@ -337,7 +379,7 @@ public final class StubPlatform implements IrisPlatform { } } if (present < 2) { - return; + return false; } Map rotated = new LinkedHashMap<>(); for (String face : faces) { @@ -346,13 +388,16 @@ public final class StubPlatform implements IrisPlatform { rotated.put(rotateFace(rotation, face, spinX, spinY, spinZ), value); } } + boolean changed = false; for (String face : faces) { if (properties.containsKey(face)) { String defaultValue = "true".equals(properties.get(face)) || "false".equals(properties.get(face)) ? "false" : "none"; - properties.put(face, rotated.getOrDefault(face, defaultValue)); + String value = rotated.getOrDefault(face, defaultValue); + changed |= !Objects.equals(properties.put(face, value), value); } } + return changed; } private static IrisBlockVector faceVector(String face) { @@ -392,14 +437,19 @@ public final class StubPlatform implements IrisPlatform { return new ParsedState(normalized, new LinkedHashMap<>()); } LinkedHashMap properties = new LinkedHashMap<>(); - String body = normalized.substring(open + 1, normalized.length() - 1); - if (!body.isBlank()) { - for (String property : body.split(",")) { - int separator = property.indexOf('='); - if (separator > 0 && separator < property.length() - 1) { - properties.put(property.substring(0, separator), property.substring(separator + 1)); - } + int end = normalized.length() - 1; + int start = open + 1; + while (start < end) { + int comma = normalized.indexOf(',', start); + int propertyEnd = comma < 0 || comma > end ? end : comma; + int separator = normalized.indexOf('=', start); + if (separator > start && separator < propertyEnd - 1) { + properties.put( + normalized.substring(start, separator), + normalized.substring(separator + 1, propertyEnd) + ); } + start = propertyEnd + 1; } return new ParsedState(normalized.substring(0, open), properties); } diff --git a/probe/src/test/java/art/arcane/iris/probe/GenerationOrderProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/GenerationOrderProbeTest.java new file mode 100644 index 000000000..d8acd9926 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/GenerationOrderProbeTest.java @@ -0,0 +1,168 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import org.junit.Test; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class GenerationOrderProbeTest { + @Test + public void parsesExplicitBoundedOrderInputs() { + GenerationOrderProbe.ProbeConfiguration configuration = GenerationOrderProbe.ProbeConfiguration.parse( + new String[]{ + "/tmp/pack", + "underworld", + "1337", + "-2", + "2", + "-1", + "1", + "4", + "991", + "true", + "false" + }); + + assertEquals(new File("/tmp/pack"), configuration.packSource()); + assertEquals("underworld", configuration.dimensionKey()); + assertEquals(1337L, configuration.seed()); + assertEquals(15, configuration.chunkCount()); + assertEquals(4, configuration.parallelism()); + assertTrue(configuration.multicore()); + assertTrue(!configuration.studio()); + } + + @Test + public void rejectsImplicitUnboundedOrInvalidOrderInputs() { + assertThrows(IllegalArgumentException.class, + () -> GenerationOrderProbe.ProbeConfiguration.parse(new String[]{"/tmp/pack"})); + assertThrows(IllegalArgumentException.class, + () -> GenerationOrderProbe.ProbeConfiguration.parse(new String[]{ + "/tmp/pack", "overworld", "1", "0", "40", "0", "40", "4", "2", "false", "false" + })); + assertThrows(IllegalArgumentException.class, + () -> GenerationOrderProbe.ProbeConfiguration.parse(new String[]{ + "/tmp/pack", "overworld", "1", "0", "0", "0", "0", "1", "2", "false", "false" + })); + assertThrows(IllegalArgumentException.class, + () -> GenerationOrderProbe.ProbeConfiguration.parse(new String[]{ + "/tmp/pack", "overworld", "1", "0", "0", "0", "0", "2", "2", "maybe", "false" + })); + assertThrows(IllegalArgumentException.class, + () -> new GenerationOrderProbe.ProbeConfiguration( + new File("/tmp/pack"), "overworld", 1L, + 134_217_728, 134_217_728, 0, 0, 2, 2L, false, false)); + } + + @Test + public void createsForwardReverseAndDeterministicShuffleSchedules() { + GenerationOrderProbe.ProbeConfiguration configuration = new GenerationOrderProbe.ProbeConfiguration( + new File("/tmp/pack"), "overworld", 1L, -1, 1, 0, 1, 2, 91L, false, false); + List forward = GenerationOrderProbe.coordinates(configuration); + List reverse = GenerationOrderProbe.orderedCoordinates( + forward, GenerationOrderProbe.GenerationOrder.REVERSE, 91L); + List shuffled = GenerationOrderProbe.orderedCoordinates( + forward, GenerationOrderProbe.GenerationOrder.SHUFFLED, 91L); + List parallel = GenerationOrderProbe.orderedCoordinates( + forward, GenerationOrderProbe.GenerationOrder.BOUNDED_PARALLEL, 91L); + + assertEquals(new GenerationOrderProbe.ChunkCoordinate(-1, 0), forward.getFirst()); + assertEquals(new GenerationOrderProbe.ChunkCoordinate(1, 1), reverse.getFirst()); + assertEquals(shuffled, parallel); + assertEquals(shuffled, GenerationOrderProbe.orderedCoordinates( + forward, GenerationOrderProbe.GenerationOrder.SHUFFLED, 91L)); + } + + @Test + public void aggregateSignatureIgnoresMapInsertionOrderButNotChunkContent() { + GenerationOrderProbe.ChunkCoordinate first = new GenerationOrderProbe.ChunkCoordinate(0, 0); + GenerationOrderProbe.ChunkCoordinate second = new GenerationOrderProbe.ChunkCoordinate(1, 0); + GenerationOrderProbe.ChunkHash firstHash = new GenerationOrderProbe.ChunkHash("a", "b", "c"); + GenerationOrderProbe.ChunkHash secondHash = new GenerationOrderProbe.ChunkHash("d", "e", "f"); + LinkedHashMap forward = + new LinkedHashMap<>(); + forward.put(first, firstHash); + forward.put(second, secondHash); + LinkedHashMap reverse = + new LinkedHashMap<>(); + reverse.put(second, secondHash); + reverse.put(first, firstHash); + + assertEquals( + GenerationOrderProbe.aggregateSignature(forward), + GenerationOrderProbe.aggregateSignature(reverse) + ); + reverse.put(first, new GenerationOrderProbe.ChunkHash("changed", "b", "changed")); + assertNotEquals( + GenerationOrderProbe.aggregateSignature(forward), + GenerationOrderProbe.aggregateSignature(reverse) + ); + } + + @Test + public void completeChunkHashSeparatesBlockAndBiomeChanges() { + StubPlatform platform = new StubPlatform(new File("/tmp/iris-order-probe-test")); + PlatformBlockState stone = StubPlatform.blockStateForTest("minecraft:stone"); + PlatformBlockState water = StubPlatform.blockStateForTest("minecraft:water[level=0]"); + PlatformBiome plains = platform.registries().biome("minecraft:plains"); + PlatformBiome forest = platform.registries().biome("minecraft:forest"); + Hunk baselineBlocks = Hunk.newArrayHunk(16, 2, 16); + Hunk baselineBiomes = Hunk.newArrayHunk(16, 2, 16); + baselineBlocks.set(0, 0, 0, stone); + baselineBiomes.set(0, 0, 0, plains); + GenerationOrderProbe.ChunkCoordinate coordinate = new GenerationOrderProbe.ChunkCoordinate(0, 0); + GenerationOrderProbe.ChunkHash baseline = GenerationOrderProbe.hashChunk( + coordinate, baselineBlocks, baselineBiomes, 2); + + Hunk changedBiomes = Hunk.newArrayHunk(16, 2, 16); + changedBiomes.set(0, 0, 0, forest); + GenerationOrderProbe.ChunkHash biomeChange = GenerationOrderProbe.hashChunk( + coordinate, baselineBlocks, changedBiomes, 2); + assertEquals(baseline.blocks(), biomeChange.blocks()); + assertNotEquals(baseline.biomes(), biomeChange.biomes()); + assertNotEquals(baseline.combined(), biomeChange.combined()); + + Hunk changedBlocks = Hunk.newArrayHunk(16, 2, 16); + changedBlocks.set(0, 0, 0, water); + GenerationOrderProbe.ChunkHash blockChange = GenerationOrderProbe.hashChunk( + coordinate, changedBlocks, baselineBiomes, 2); + assertNotEquals(baseline.blocks(), blockChange.blocks()); + assertEquals(baseline.biomes(), blockChange.biomes()); + assertNotEquals(baseline.combined(), blockChange.combined()); + assertEquals( + Map.of("minecraft:stone", -1, "minecraft:water[level=0]", 1), + GenerationOrderProbe.blockCountDelta(baseline, blockChange) + ); + assertEquals( + Map.of("0:minecraft:stone", -1, "0:minecraft:water[level=0]", 1), + GenerationOrderProbe.blockYCountDelta(baseline, blockChange) + ); + } + + @Test + public void mismatchDetectionIncludesMissingAndChangedChunks() { + GenerationOrderProbe.ChunkCoordinate first = new GenerationOrderProbe.ChunkCoordinate(0, 0); + GenerationOrderProbe.ChunkCoordinate second = new GenerationOrderProbe.ChunkCoordinate(1, 0); + GenerationOrderProbe.ChunkHash firstHash = new GenerationOrderProbe.ChunkHash("a", "b", "c"); + GenerationOrderProbe.ChunkHash secondHash = new GenerationOrderProbe.ChunkHash("d", "e", "f"); + Map expected = Map.of( + first, firstHash, + second, secondHash + ); + Map observed = Map.of( + first, new GenerationOrderProbe.ChunkHash("changed", "b", "changed") + ); + + assertEquals(List.of(first, second), GenerationOrderProbe.mismatchedChunks(expected, observed)); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java new file mode 100644 index 000000000..3298cd16c --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java @@ -0,0 +1,998 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTile; +import art.arcane.iris.engine.hydrology.HydrologyTileKey; +import art.arcane.iris.engine.hydrology.RiverFootprint; +import art.arcane.iris.engine.hydrology.cave.CavePosition; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.util.project.hunk.Hunk; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public final class HydrologyPackProbeTest { + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void parsesExplicitBoundedCoverageInputs() { + HydrologyPackProbe.ProbeConfiguration configuration = HydrologyPackProbe.ProbeConfiguration.parse( + new String[]{ + "/tmp/pack", + "overworld", + "1,-19,331", + "-2", + "2", + "-1", + "1", + "SURFACE_POOL@water,WATERFALL@*,DEEP_POOL@deep_lava", + "false" + }); + + assertEquals(new File("/tmp/pack"), configuration.packSource()); + assertEquals("overworld", configuration.dimensionKey()); + assertEquals(List.of(1L, -19L, 331L), configuration.seeds()); + assertEquals(15, configuration.tilesPerSeed()); + assertEquals(3, configuration.requiredCoverage().size()); + assertTrue(!configuration.studio()); + } + + @Test + public void rejectsImplicitUnboundedOrDuplicateInputs() { + assertThrows(IllegalArgumentException.class, + () -> HydrologyPackProbe.ProbeConfiguration.parse(new String[]{"/tmp/pack"})); + assertThrows(IllegalArgumentException.class, + () -> HydrologyPackProbe.ProbeConfiguration.parse(new String[]{ + "/tmp/pack", "overworld", "1", "0", "0", "0", "0", + "SURFACE_POOL@water", "false" + })); + assertThrows(IllegalArgumentException.class, + () -> HydrologyPackProbe.ProbeConfiguration.parse(new String[]{ + "/tmp/pack", "overworld", "1,1", "0", "0", "0", "0", + "SURFACE_POOL@water", "false" + })); + assertThrows(IllegalArgumentException.class, + () -> HydrologyPackProbe.ProbeConfiguration.parse(new String[]{ + "/tmp/pack", "overworld", "1,2", "0", "20", "0", "20", + "SURFACE_POOL@water", "false" + })); + assertThrows(IllegalArgumentException.class, + () -> HydrologyPackProbe.ProbeConfiguration.parse(new String[]{ + "/tmp/pack", "overworld", "1,2", "0", "0", "0", "0", + "SURFACE_POOL@water", "sometimes" + })); + } + + @Test + public void selectorsMatchExactOrWildcardProfiles() { + HydrologyPackProbe.CoverageKey water = new HydrologyPackProbe.CoverageKey( + HydrologyFeatureType.WATERFALL, "water"); + HydrologyPackProbe.CoverageKey lava = new HydrologyPackProbe.CoverageKey( + HydrologyFeatureType.WATERFALL, "lava"); + HydrologyPackProbe.CoverageSelector exact = HydrologyPackProbe.CoverageSelector.parse( + "WATERFALL@water"); + HydrologyPackProbe.CoverageSelector wildcard = HydrologyPackProbe.CoverageSelector.parse( + "WATERFALL@*"); + + assertTrue(exact.matches(water)); + assertTrue(!exact.matches(lava)); + assertTrue(wildcard.matches(water)); + assertTrue(wildcard.matches(lava)); + assertThrows(IllegalArgumentException.class, + () -> HydrologyPackProbe.CoverageSelector.parse("WATERFALL")); + assertThrows(IllegalArgumentException.class, + () -> HydrologyPackProbe.CoverageSelector.parse("UNKNOWN@water")); + } + + @Test + public void wildcardSelectorsRepresentEveryAcceptedFeatureType() { + for (HydrologyFeatureType type : HydrologyFeatureType.values()) { + HydrologyPackProbe.CoverageSelector selector = HydrologyPackProbe.CoverageSelector.parse( + type.name() + "@*"); + HydrologyPackProbe.CoverageKey observed = new HydrologyPackProbe.CoverageKey(type, "profile"); + + assertTrue(selector.matches(observed)); + } + } + + @Test + public void everyFeatureTypeMapsToOneGeneratedAssertionFamily() { + assertEquals(HydrologyPackProbe.VerificationFamily.SURFACE, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.SURFACE_POOL)); + assertEquals(HydrologyPackProbe.VerificationFamily.SURFACE, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.RIFFLE)); + assertEquals(HydrologyPackProbe.VerificationFamily.SURFACE, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.CASCADE)); + assertEquals(HydrologyPackProbe.VerificationFamily.SURFACE, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.WATERFALL)); + assertEquals(HydrologyPackProbe.VerificationFamily.SURFACE, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.MOUTH)); + assertEquals(HydrologyPackProbe.VerificationFamily.CAVE, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.RIDGE_BORE)); + assertEquals(HydrologyPackProbe.VerificationFamily.CAVE, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.UNDERGROUND_POOL)); + assertEquals(HydrologyPackProbe.VerificationFamily.CAVE, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.UNDERGROUND_DROP)); + assertEquals(HydrologyPackProbe.VerificationFamily.GROTTO, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.SINKHOLE)); + assertEquals(HydrologyPackProbe.VerificationFamily.GROTTO, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.COASTAL_GROTTO)); + assertEquals(HydrologyPackProbe.VerificationFamily.GROTTO, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.INLAND_GROTTO)); + assertEquals(HydrologyPackProbe.VerificationFamily.DEEP, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.DEEP_POOL)); + assertEquals(HydrologyPackProbe.VerificationFamily.DEEP, + HydrologyPackProbe.verificationFamily(HydrologyFeatureType.DEEP_CHANNEL)); + } + + @Test + public void generatedChunkMapsNegativeWorldCoordinatesToLocalBuffers() { + StubPlatform platform = new StubPlatform(new File("/tmp/iris-hydrology-pack-probe-test")); + PlatformBlockState water = StubPlatform.blockStateForTest("minecraft:water[level=8]"); + PlatformBiome biome = platform.registries().biome("minecraft:river"); + Hunk blocks = Hunk.newArrayHunk(16, 2, 16); + Hunk biomes = Hunk.newArrayHunk(16, 2, 16); + blocks.set(15, 1, 15, water); + biomes.set(15, 1, 15, biome); + RealPackProbeSupport.GeneratedChunk chunk = new RealPackProbeSupport.GeneratedChunk( + -1, -2, 2, blocks, biomes); + + assertEquals(water, chunk.blockAt(-1, 1, -17)); + assertEquals(biome, chunk.biomeAt(-1, 1, -17)); + assertThrows(IllegalArgumentException.class, () -> chunk.blockAt(-17, 1, -17)); + assertThrows(IllegalArgumentException.class, () -> chunk.blockAt(-1, 2, -17)); + } + + @Test + public void generatedSurfaceFluidAcceptsOnlyWaterBearingDecorantsForWaterProfiles() { + PlatformBlockState water = StubPlatform.blockStateForTest("minecraft:water[level=0]"); + PlatformBlockState lava = StubPlatform.blockStateForTest("minecraft:lava[level=0]"); + PlatformBlockState kelp = StubPlatform.blockStateForTest("minecraft:kelp_plant"); + PlatformBlockState coral = StubPlatform.blockStateForTest( + "minecraft:brain_coral_fan[waterlogged=true]"); + PlatformBlockState stone = StubPlatform.blockStateForTest("minecraft:stone"); + + assertTrue(HydrologyPackProbe.matchesConfiguredFluid(water, water, false)); + assertTrue(HydrologyPackProbe.matchesConfiguredFluid(kelp, water, true)); + assertTrue(HydrologyPackProbe.matchesConfiguredFluid(coral, water, true)); + assertFalse(HydrologyPackProbe.matchesConfiguredFluid(kelp, water, false)); + assertFalse(HydrologyPackProbe.matchesConfiguredFluid(kelp, lava, true)); + assertFalse(HydrologyPackProbe.matchesConfiguredFluid(stone, water, true)); + } + + @Test + public void generatedWitnessColumnsAreSortedOnceBySignedCoordinates() { + HydrologyColumnSample eastern = column(1, -2); + HydrologyColumnSample northwest = column(-1, -4); + HydrologyColumnSample southwest = column(-1, 3); + HydrologyTile tile = new HydrologyTile( + new HydrologyTileKey(0, 0), + 1L, + 2L, + 64, + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + List.of(), + new RiverFootprint(Map.of( + RiverFootprint.pack(eastern.x(), eastern.z()), eastern, + RiverFootprint.pack(northwest.x(), northwest.z()), northwest, + RiverFootprint.pack(southwest.x(), southwest.z()), southwest + )) + ); + + assertEquals( + List.of(northwest, southwest, eastern), + HydrologyPackProbe.orderedFootprintColumns(tile) + ); + } + + @Test + public void generatedWitnessChunksAreDistinctInFirstEncounterOrder() { + assertEquals( + List.of( + new HydrologyPackProbe.ChunkCoordinate(-1, -2), + new HydrologyPackProbe.ChunkCoordinate(0, 0), + new HydrologyPackProbe.ChunkCoordinate(2, -1) + ), + HydrologyPackProbe.generatedWitnessChunks(List.of( + new CavePosition(-1, 40, -17), + new CavePosition(-16, 50, -32), + new CavePosition(0, 60, 0), + new CavePosition(47, 70, -1) + )) + ); + } + + @Test + public void generatedSurfaceBiomeUsesThePlatformDerivativeKey() { + IrisBiome biome = new IrisBiome(); + biome.setDerivative("minecraft:river"); + + assertEquals( + "minecraft:river", + HydrologyPackProbe.generatedSurfaceBiomeKey( + biome, + null, + 19L, + "overworld", + 32, + -48 + ) + ); + } + + @Test + public void generatedWitnessDescriptorRoundTripsUnsignedIdsAndNegativeCoordinates() { + HydrologyPackProbe.GeneratedWitnessDescriptor descriptor = + new HydrologyPackProbe.GeneratedWitnessDescriptor( + HydrologyPackProbe.CoverageSelector.parse("UNDERGROUND_DROP@water"), + new HydrologyTileKey(-3, 7), + "water", + -5L, + new CavePosition(-33, 41, -1), + HydrologyCaveAction.FALLING_FLUID + ); + + assertEquals( + descriptor, + HydrologyPackProbe.GeneratedWitnessDescriptor.parse(descriptor.encode()) + ); + } + + @Test + public void generatedProcessResultRoundTripsStateAndBiomeKeys() { + HydrologyPackProbe.GeneratedProcessResult result = new HydrologyPackProbe.GeneratedProcessResult( + HydrologyPackProbe.CoverageSelector.parse("SURFACE_POOL@water"), + new HydrologyPackProbe.GeneratedVerification( + "minecraft:water[level=0]", + "minecraft:river" + ) + ); + + assertEquals(result, HydrologyPackProbe.GeneratedProcessResult.parse(result.machineLine())); + } + + @Test + public void versionTwoMachineLineReportsGeneratedVerificationCounts() { + HydrologyPackProbe.ProbeResult result = new HydrologyPackProbe.ProbeResult( + "PASS", "overworld", 2, 4, 8, 0, 1, 0, 1, 13, 13, 3, 7, 13, "abc"); + + String line = result.machineLine(); + + assertTrue(line.contains("version=2")); + assertTrue(line.contains("generated_chunks=7")); + assertTrue(line.contains("verified_features=13")); + } + + @Test + public void reportsEveryMissingExactCoverageSelector() { + Set observed = Set.of( + new HydrologyPackProbe.CoverageKey(HydrologyFeatureType.SURFACE_POOL, "water"), + new HydrologyPackProbe.CoverageKey(HydrologyFeatureType.DEEP_POOL, "deep_lava") + ); + List required = List.of( + HydrologyPackProbe.CoverageSelector.parse("SURFACE_POOL@water"), + HydrologyPackProbe.CoverageSelector.parse("WATERFALL@water"), + HydrologyPackProbe.CoverageSelector.parse("DEEP_CHANNEL@deep_lava") + ); + + assertEquals( + List.of(required.get(1), required.get(2)), + HydrologyPackProbe.missingCoverage(observed, required) + ); + } + + @Test + public void configuredFamiliesRequireEachEnabledProfileFamily() { + Set observed = Set.of( + new HydrologyPackProbe.ConfiguredCoverage( + HydrologyPackProbe.CoverageFamily.SURFACE, "water"), + new HydrologyPackProbe.ConfiguredCoverage( + HydrologyPackProbe.CoverageFamily.UNDERGROUND, "water") + ); + LinkedHashSet required = new LinkedHashSet<>(List.of( + new HydrologyPackProbe.ConfiguredCoverage( + HydrologyPackProbe.CoverageFamily.SURFACE, "water"), + new HydrologyPackProbe.ConfiguredCoverage( + HydrologyPackProbe.CoverageFamily.UNDERGROUND, "water"), + new HydrologyPackProbe.ConfiguredCoverage( + HydrologyPackProbe.CoverageFamily.DEEP, "deep_lava") + )); + + assertEquals( + List.of(new HydrologyPackProbe.ConfiguredCoverage( + HydrologyPackProbe.CoverageFamily.DEEP, "deep_lava")), + HydrologyPackProbe.missingConfiguredCoverage(observed, required) + ); + } + + @Test + public void surfaceOutletRequiresExactlyOneCompleteCourse() { + assertTrue(HydrologyPackProbe.ShapeMetrics.validSurfaceOutletCourseGroup(1, 0)); + assertFalse(HydrologyPackProbe.ShapeMetrics.validSurfaceOutletCourseGroup(0, 0)); + assertFalse(HydrologyPackProbe.ShapeMetrics.validSurfaceOutletCourseGroup(0, 1)); + assertFalse(HydrologyPackProbe.ShapeMetrics.validSurfaceOutletCourseGroup(1, 1)); + assertFalse(HydrologyPackProbe.ShapeMetrics.validSurfaceOutletCourseGroup(2, 0)); + } + + @Test + public void nearDiagonalStickCountsAsGridLocked() { + List centerline = List.of( + point(0, 0), + point(96, 4), + point(192, 8) + ); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 1L, + centerline, + thickChannel(centerline, 4, 32), + false + )); + + assertTrue(result.gridLockedFraction() > 0.9D); + assertTrue(result.longestGridLockedRun() > 64D); + assertTrue(result.violations().contains("GRID_LOCKED_FRACTION")); + assertTrue(result.violations().contains("GRID_LOCKED_RUN")); + } + + @Test + public void isolatedRightAngleDoglegFailsTurnGates() { + List centerline = List.of( + point(0, 0), + point(96, 0), + point(96, 96), + point(192, 96) + ); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 2L, + centerline, + thickChannel(centerline, 4, 32), + false + )); + + assertTrue(result.isolatedTurns() > 0); + assertTrue(result.p95TurnDegrees() > 25D); + assertTrue(result.violations().contains("ISOLATED_TURN")); + assertTrue(result.violations().contains("P95_TURN")); + } + + @Test + public void gradualSourceWidthRampPassesEndpointGate() { + List centerline = List.of(point(0, 0), point(80, 24)); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 3L, + centerline, + thickChannel(centerline, 4, 32), + false + )); + + assertEquals(0, result.hardEndpointRamps()); + assertEquals(0, result.unexpectedLeaves()); + assertFalse(result.violations().contains("HARD_ENDPOINT_RAMP")); + } + + @Test + public void widenedSourceBasinFailsEndpointGate() { + List centerline = List.of(point(0, 0), point(80, 24)); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 7L, + centerline, + widenedSourceBasin(centerline, 4, 7, 32), + false + )); + + assertTrue(result.violations().contains("WIDENED_SOURCE_BASIN")); + } + + @Test + public void fullWidthSourceCapFailsEndpointRampGate() { + List centerline = List.of(point(0, 0), point(80, 24)); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 4L, + centerline, + thickChannel(centerline, 4, 0), + false + )); + + assertTrue(result.hardEndpointRamps() > 0); + assertTrue(result.violations().contains("HARD_ENDPOINT_RAMP")); + } + + @Test + public void fullWidthExposedTerminalFailsEndpointRampGate() { + List centerline = List.of(point(0, 0), point(80, 24)); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 5L, + centerline, + thickChannel(centerline, 4, 32), + true + )); + + assertEquals(1, result.hardEndpointRamps()); + assertTrue(result.violations().contains("HARD_ENDPOINT_RAMP")); + } + + @Test + public void clippedCourseDoesNotInventEndpointRampFailures() { + List centerline = List.of(point(0, 0), point(160, 48)); + Set observedCells = thickChannel( + List.of(point(40, 12), point(120, 36)), + 4, + 0 + ); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 8L, + centerline, + observedCells, + true + )); + + assertEquals(0, result.hardEndpointRamps()); + assertFalse(result.violations().contains("HARD_ENDPOINT_RAMP")); + } + + @Test + public void roundedExposedTerminalCapPassesCapGate() { + List centerline = List.of(point(0, 0), point(160, 0)); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 9L, + centerline, + terminalBasinChannel(centerline, 4, 7, 32, false), + true + )); + + assertEquals(0, result.clippedTerminalCaps()); + assertTrue(result.terminalCapLength() >= 3D); + assertFalse(result.violations().contains("CLIPPED_TERMINAL_CAP")); + } + + @Test + public void squareExposedTerminalCapFailsCapGateAfterGradualBasinRamp() { + List centerline = List.of(point(0, 0), point(160, 0)); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 10L, + centerline, + terminalBasinChannel(centerline, 4, 7, 32, true), + true + )); + + assertEquals(0, result.hardEndpointRamps()); + assertEquals(1, result.clippedTerminalCaps()); + assertEquals(0D, result.terminalCapLength(), 0D); + assertTrue(result.violations().contains("CLIPPED_TERMINAL_CAP")); + } + + @Test + public void configuredMinimumWidthRejectsNarrowOwnedInterior() { + List centerline = List.of(point(0, 0), point(192, 0)); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 11L, + centerline, + thickChannel(centerline, 1, 32), + false + )); + + assertEquals(3, result.minimumInteriorWidth()); + assertTrue(result.violations().contains("NARROW_INTERIOR_WIDTH")); + } + + @Test + public void constantOwnedInteriorPassesWidthGates() { + List centerline = List.of(point(0, 0), point(192, 0)); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 12L, + centerline, + thickChannel(centerline, 2, 32), + false + )); + + assertTrue(result.minimumInteriorWidth() >= 4); + assertEquals(0D, result.maximumWidthTroughDepthRatio(), 0D); + assertFalse(result.violations().contains("NARROW_INTERIOR_WIDTH")); + assertFalse(result.violations().contains("CONCAVE_WIDTH_TROUGH")); + } + + @Test + public void borePortalRunMarginsAreExcludedFromInteriorWidth() { + List centerline = List.of(point(0, 0), point(320, 0)); + HashSet ownedCells = new HashSet<>(thickChannel( + List.of(point(0, 0), point(128, 0)), + 2, + 32 + )); + ownedCells.addAll(thickChannel( + List.of(point(192, 0), point(320, 0)), + 2, + 32 + )); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 15L, + centerline, + ownedCells, + false + )); + + assertTrue(result.minimumInteriorWidth() >= 4); + assertFalse(result.violations().contains("NARROW_INTERIOR_WIDTH")); + } + + @Test + public void sustainedMidCourseWidthTroughFailsConcavityGate() { + List centerline = List.of(point(0, 0), point(256, 0)); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(courseInput( + 1337L, + 13L, + centerline, + troughChannel(centerline, 5, 2, 96, 160), + false + )); + + assertTrue(result.maximumWidthTroughDepthRatio() >= 0.35D); + assertTrue(result.violations().contains("CONCAVE_WIDTH_TROUGH")); + } + + @Test + public void incompleteCourseSkipsCompleteCourseMorphologyGates() { + List centerline = List.of(point(0, 0), point(160, 0)); + HydrologyPackProbe.CourseMorphologyInput input = new HydrologyPackProbe.CourseMorphologyInput( + 1337L, + 14L, + 9, + 16, + 384, + 0D, + false, + true, + centerline, + terminalBasinChannel(centerline, 1, 2, 32, true) + ); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(input); + + assertEquals(0, result.clippedTerminalCaps()); + assertEquals(0, result.minimumInteriorWidth()); + assertEquals(0D, result.maximumWidthTroughDepthRatio(), 0D); + assertFalse(result.violations().contains("CLIPPED_TERMINAL_CAP")); + assertFalse(result.violations().contains("NARROW_INTERIOR_WIDTH")); + assertFalse(result.violations().contains("CONCAVE_WIDTH_TROUGH")); + } + + @Test + public void midCourseSpurCreatesUnexpectedLeaf() { + List centerline = List.of(point(-80, 0), point(80, 0)); + HashSet ownedCells = new HashSet<>(thickChannel(centerline, 2, 16)); + ownedCells.addAll(thickChannel(List.of(point(0, 0), point(0, 96)), 2, 0)); + HydrologyPackProbe.SeedMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeSeed( + 1337L, + List.of(courseInput(1337L, 6L, centerline, ownedCells, false)) + ); + + assertTrue(result.unexpectedLeaves() > 0); + assertTrue(result.violations().stream().anyMatch( + (String violation) -> violation.contains("UNEXPECTED_BRANCH_LEAVES"))); + assertTrue(result.courses().getFirst().unexpectedLeaves() > 0); + } + + @Test + public void oceanIntegrityHelpersDetectOwnershipAndFloorMutation() { + HydrologyColumnLayer apron = surfaceLayer(true, false); + HydrologyColumnLayer inertLayer = surfaceLayer(false, false); + HydrologyColumnLayer ownedTerrain = surfaceLayer(false, true); + + assertFalse(HydrologyPackProbe.ShapeMetrics.ownsOceanTerrain(apron)); + assertFalse(HydrologyPackProbe.ShapeMetrics.ownsOceanTerrain(inertLayer)); + assertTrue(HydrologyPackProbe.ShapeMetrics.ownsOceanTerrain(ownedTerrain)); + assertFalse(HydrologyPackProbe.ShapeMetrics.mutatesOceanTerrain(54, 54)); + assertTrue(HydrologyPackProbe.ShapeMetrics.mutatesOceanTerrain(54, 48)); + } + + @Test + public void surfaceBankIntegrityRequiresFreeboardAroundOwnedFluid() { + assertEquals( + 0, + HydrologyPackProbe.ShapeMetrics.uncontainedSurfaceBankEdges( + surfaceBankFootprint(71), + Set.of(32L) + ) + ); + assertEquals( + 1, + HydrologyPackProbe.ShapeMetrics.uncontainedSurfaceBankEdges( + surfaceBankFootprint(70), + Set.of(32L) + ) + ); + HashMap missingBank = new HashMap<>(surfaceBankFootprint(71).columns()); + missingBank.remove(RiverFootprint.pack(1, 0)); + assertEquals( + 1, + HydrologyPackProbe.ShapeMetrics.uncontainedSurfaceBankEdges( + new RiverFootprint(missingBank), + Set.of(32L) + ) + ); + } + + @Test + public void seedsWithoutSurfaceCoursesDoNotMaskPublishedFailures() { + HydrologyPackProbe.CourseMorphologyResult acceptedCourse = + new HydrologyPackProbe.CourseMorphologyResult( + 642L, 5L, 20, 80D, 80D, 0, 0D, 0D, 0, 0D, 0, + 0, 0, 0D, 0, 0, 0D, 0, 0D, 0, 0D, 0, List.of()); + HydrologyPackProbe.SeedMorphologyResult accepted = + new HydrologyPackProbe.SeedMorphologyResult(642L, List.of(acceptedCourse), 0, List.of()); + HydrologyPackProbe.SeedMorphologyResult empty = + new HydrologyPackProbe.SeedMorphologyResult(1337L, List.of(), 0, List.of()); + + IllegalStateException perSeedFailure = assertThrows( + IllegalStateException.class, + () -> HydrologyPackProbe.PublishedMorphologyMetrics.requireValidResults(List.of(accepted, empty)) + ); + assertTrue(perSeedFailure.getMessage().contains("seed=1337")); + IllegalStateException emptyFailure = assertThrows( + IllegalStateException.class, + () -> HydrologyPackProbe.PublishedMorphologyMetrics.requireValidResults(List.of(empty)) + ); + assertTrue(emptyFailure.getMessage().contains("NO_SURFACE_COURSES")); + + HydrologyPackProbe.CourseMorphologyResult rejectedCourse = + new HydrologyPackProbe.CourseMorphologyResult( + 52L, 5L, 20, 80D, 80D, 0, 0D, 0D, 0, 0D, 0, + 0, 0, 0D, 0, 0, 0D, 0, 0D, 2, 0D, 0, + List.of("NARROW_INTERIOR_WIDTH")); + HydrologyPackProbe.SeedMorphologyResult rejected = + new HydrologyPackProbe.SeedMorphologyResult( + 52L, List.of(rejectedCourse), 0, List.of("5:NARROW_INTERIOR_WIDTH")); + IllegalStateException failure = assertThrows( + IllegalStateException.class, + () -> HydrologyPackProbe.PublishedMorphologyMetrics.requireValidResults( + List.of(accepted, empty, rejected)) + ); + + assertTrue(failure.getMessage().contains("seed=52")); + } + + @Test + public void completeSurfaceCourseRequiresTheConfiguredExposedLength() { + List centerline = List.of( + new HydrologyPoint(0, 72, 0), + new HydrologyPoint(512, 72, 0) + ); + HydrologyPackProbe.CourseMorphologyInput input = new HydrologyPackProbe.CourseMorphologyInput( + 642L, + 5L, + 9, + 4, + 384, + 40D, + true, + false, + centerline, + thickChannel(centerline, 4, 24) + ); + + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(input); + + assertTrue(result.violations().contains("INSUFFICIENT_EXPOSED_SURFACE")); + } + + @Test + public void writesTopDownPngAndJsonMetrics() throws Exception { + File reportDirectory = temporaryFolder.newFolder("hydrology-reports"); + HydrologyPackProbe.CourseMorphologyResult course = + new HydrologyPackProbe.CourseMorphologyResult( + 1337L, 6L, 3, 2D, 2D, 0, 0D, 0D, 0, 0D, 0, + 0, 0, 0D, 0, 0, 0D, 0, 0D, 0, 0D, 0, List.of()); + HydrologyPackProbe.SeedMorphologyResult result = + new HydrologyPackProbe.SeedMorphologyResult(1337L, List.of(course), 0, List.of()); + Map courseByCell = Map.of( + RiverFootprint.pack(-1, 2), 6L, + RiverFootprint.pack(0, 2), 6L, + RiverFootprint.pack(1, 3), 6L + ); + + HydrologyPackProbe.PublishedMorphologyMetrics.writeReport( + reportDirectory, + result, + courseByCell + ); + + File imageFile = new File(reportDirectory, "seed-1337-surface-footprint.png"); + File metricsFile = new File(reportDirectory, "seed-1337-surface-morphology.json"); + BufferedImage image = ImageIO.read(imageFile); + String metrics = Files.readString(metricsFile.toPath(), StandardCharsets.UTF_8); + assertTrue(imageFile.isFile()); + assertTrue(metricsFile.isFile()); + assertEquals(3, image.getWidth()); + assertEquals(2, image.getHeight()); + assertTrue(metrics.contains("\"seed\": 1337")); + assertTrue(metrics.contains("\"clippedTerminalCaps\": 0")); + assertTrue(metrics.contains("\"minimumInteriorWidth\": 0")); + assertTrue(metrics.contains("\"maximumWidthTroughDepthRatio\": 0.000000")); + assertTrue(metrics.contains("\"unexpectedLeaves\": 0")); + } + + private static HydrologyPackProbe.CourseMorphologyInput courseInput( + long seed, + long courseId, + List centerline, + Set ownedCells, + boolean terminalExposed + ) { + return new HydrologyPackProbe.CourseMorphologyInput( + seed, + courseId, + 9, + 4, + 0, + 0D, + true, + terminalExposed, + centerline, + ownedCells + ); + } + + private static Set thickChannel(List centerline, int radius, int sourceRamp) { + List raster = raster(centerline); + HashSet cells = new HashSet<>(); + for (int index = 0; index < raster.size(); index++) { + int effectiveRadius = sourceRamp == 0 + ? radius + : Math.min(radius, (int) StrictMath.round(radius * Math.min(1D, index / (double) sourceRamp))); + addDisc(cells, raster.get(index), effectiveRadius); + } + return Set.copyOf(cells); + } + + private static Set widenedSourceBasin( + List centerline, + int channelRadius, + int basinRadius, + int taperLength + ) { + List raster = raster(centerline); + HashSet cells = new HashSet<>(); + for (int index = 0; index < raster.size(); index++) { + double taper = Math.min(1D, index / (double) taperLength); + int radius = channelRadius + (int) StrictMath.round((basinRadius - channelRadius) * (1D - taper)); + addDisc(cells, raster.get(index), radius); + } + return Set.copyOf(cells); + } + + private static Set terminalBasinChannel( + List centerline, + int channelRadius, + int basinRadius, + int profileLength, + boolean clipped + ) { + List raster = raster(centerline); + HashSet cells = new HashSet<>(); + for (int index = 0; index < raster.size(); index++) { + int sourceRadius = Math.min( + channelRadius, + (int) StrictMath.round(channelRadius * Math.min(1D, index / (double) profileLength)) + ); + int remaining = raster.size() - 1 - index; + double terminalWeight = 1D - Math.min(1D, remaining / (double) profileLength); + int radius = sourceRadius + + (int) StrictMath.round((basinRadius - sourceRadius) * terminalWeight); + addDisc(cells, raster.get(index), radius); + } + if (!clipped) { + return Set.copyOf(cells); + } + HydrologyPoint terminal = raster.getLast(); + cells.removeIf((Long packed) -> RiverFootprint.unpackX(packed) > terminal.x()); + return Set.copyOf(cells); + } + + private static Set troughChannel( + List centerline, + int outerRadius, + int innerRadius, + int troughStart, + int troughEnd + ) { + List raster = raster(centerline); + HashSet cells = new HashSet<>(); + for (int index = 0; index < raster.size(); index++) { + int sourceRadius = Math.min( + outerRadius, + (int) StrictMath.round(outerRadius * Math.min(1D, index / 32D)) + ); + int radius = index >= troughStart && index <= troughEnd ? innerRadius : outerRadius; + addDisc(cells, raster.get(index), Math.min(sourceRadius, radius)); + } + return Set.copyOf(cells); + } + + private static HydrologyColumnLayer surfaceLayer(boolean oceanApron, boolean terrainOwned) { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 31L, + HydrologyFeatureType.MOUTH, + 32L, + 33L, + 0, + 70, + 0, + 1, + 0, + false + ); + return new HydrologyColumnLayer( + feature, + 54, + 63, + 63, + true, + false, + false, + true, + false, + false, + terrainOwned, + false, + oceanApron, + "water", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + } + + private static RiverFootprint surfaceBankFootprint(int easternBankHeight) { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 31L, + HydrologyFeatureType.RIFFLE, + 32L, + 33L, + 0, + 63, + 0, + 1, + 0, + false + ); + HydrologyColumnLayer wetLayer = new HydrologyColumnLayer( + feature, + 67, + 70, + 70, + true, + false, + false, + true, + false, + false, + true, + true, + false, + "water", + "surface", + "mouth", + "shore", + "dry", + "flooded" + ); + HydrologyColumnSample center = new HydrologyColumnSample( + 0, 0, 80, 63, false, "parent", List.of(wetLayer)); + HydrologyColumnSample west = bankColumn(-1, 0, 71); + HydrologyColumnSample east = bankColumn(1, 0, easternBankHeight); + HydrologyColumnSample north = bankColumn(0, -1, 71); + HydrologyColumnSample south = bankColumn(0, 1, 71); + return new RiverFootprint(Map.of( + RiverFootprint.pack(center.x(), center.z()), center, + RiverFootprint.pack(west.x(), west.z()), west, + RiverFootprint.pack(east.x(), east.z()), east, + RiverFootprint.pack(north.x(), north.z()), north, + RiverFootprint.pack(south.x(), south.z()), south + )); + } + + private static HydrologyColumnSample bankColumn(int x, int z, int naturalHeight) { + return new HydrologyColumnSample(x, z, naturalHeight, 63, false, "parent", List.of()); + } + + private static List raster(List points) { + ArrayList raster = new ArrayList<>(); + for (int pair = 0; pair < points.size() - 1; pair++) { + HydrologyPoint start = points.get(pair); + HydrologyPoint end = points.get(pair + 1); + int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z())); + for (int step = raster.isEmpty() ? 0 : 1; step <= steps; step++) { + double progress = step / (double) steps; + HydrologyPoint point = new HydrologyPoint( + (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress), + 64, + (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress) + ); + if (raster.isEmpty() + || point.x() != raster.getLast().x() + || point.z() != raster.getLast().z()) { + raster.add(point); + } + } + } + return List.copyOf(raster); + } + + private static void addDisc(Set cells, HydrologyPoint center, int radius) { + for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) { + for (int deltaX = -radius; deltaX <= radius; deltaX++) { + if (deltaX * deltaX + deltaZ * deltaZ <= radius * radius) { + cells.add(RiverFootprint.pack(center.x() + deltaX, center.z() + deltaZ)); + } + } + } + } + + private static HydrologyPoint point(int x, int z) { + return new HydrologyPoint(x, 64, z); + } + + private static HydrologyColumnSample column(int x, int z) { + return new HydrologyColumnSample(x, z, 80, 63, false, "parent", List.of()); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/HydrologyPlannerProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/HydrologyPlannerProbeTest.java new file mode 100644 index 000000000..91541d306 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyPlannerProbeTest.java @@ -0,0 +1,23 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.hydrology.HydrologyFeatureRef; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import org.junit.Test; + +import java.util.EnumSet; +import java.util.Map; + +import static org.junit.Assert.assertEquals; + +public final class HydrologyPlannerProbeTest { + @Test + public void deterministicAcceptedCoveragePublishesEveryFeatureType() { + Map coverage = + HydrologyPlannerProbe.deterministicAcceptedCoverage(); + + assertEquals(EnumSet.allOf(HydrologyFeatureType.class), coverage.keySet()); + for (Map.Entry entry : coverage.entrySet()) { + assertEquals(entry.getKey(), entry.getValue().type()); + } + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java b/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java index 3a118d693..4cc149a7f 100644 --- a/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/StubPlatformStateTest.java @@ -8,6 +8,7 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; public final class StubPlatformStateTest { @@ -43,6 +44,19 @@ public final class StubPlatformStateTest { assertEquals("minecraft:oak_leaves[distance=7,persistent=true]", merged.key()); } + @Test + public void noOpStateTransformsReuseTheCanonicalInstance() { + PlatformBlockState stone = state("minecraft:stone"); + PlatformBlockState leaves = state("minecraft:oak_leaves[distance=7,persistent=false]"); + + assertSame(stone, StubPlatform.rotateForTest(IrisObjectRotation.of(0, 90, 0), stone)); + assertSame(leaves, leaves.withProperty("distance", "7")); + assertSame(leaves, StubPlatform.mergeForTest( + leaves, + state("minecraft:oak_leaves[persistent=false]") + )); + } + @Test public void classifiesVanillaFluidStates() { PlatformBlockState water = state("minecraft:water[level=0]"); @@ -59,6 +73,13 @@ public final class StubPlatformStateTest { assertTrue(stone.isSolid()); } + @Test + public void classifiesCanonicalWaterloggedProperty() { + assertTrue(state("minecraft:brain_coral_fan[waterlogged=true]").isWaterLogged()); + assertFalse(state("minecraft:brain_coral_fan[waterlogged=false]").isWaterLogged()); + assertFalse(state("minecraft:brain_coral_fan").isWaterLogged()); + } + private PlatformBlockState state(String key) { return IrisPlatforms.get().registries().block(key); }