diff --git a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java index 88fb95dab..4d61690c4 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java +++ b/adapters/bukkit/nms/v26_2_R1/src/main/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSource.java @@ -62,6 +62,7 @@ public class CustomBiomeSource extends BiomeSource { private final ConcurrentHashMap> noiseBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> structureBiomeCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> surfaceStructureBiomeCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> naturalSurfaceStructureBiomeCache = new ConcurrentHashMap<>(); private volatile KMap> customBiomes; private volatile Map> vanillaSpawnBiomes; private volatile IrisDimension cacheDimension; @@ -331,8 +332,18 @@ public class CustomBiomeSource extends BiomeSource { if (quartStep == 1) { return super.findBiomeHorizontal(x, y, z, searchRadius, allowed, random, sampler); } - return super.findBiomeHorizontal( - x, y, z, searchRadius, quartStep, allowed, random, false, sampler); + GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_structure_ring_biome"); + if (lease == null) { + throw new IllegalStateException("Iris structure ring biome lookup was rejected during an engine transition"); + } + try (lease; IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) { + if (!isRuntimeAvailable()) { + throw new IllegalStateException("Iris structure ring biome lookup has no active engine runtime"); + } + ensureCachesCurrent(); + return findNaturalSurfaceBiomeHorizontal( + x, y, z, searchRadius, quartStep, allowed, random); + } } static int horizontalBiomeSearchQuartStep(int blockY, int searchRadius) { @@ -390,6 +401,60 @@ public class CustomBiomeSource extends BiomeSource { return resolvedSurfaceHolder; } + private Pair> findNaturalSurfaceBiomeHorizontal( + int x, + int y, + int z, + int searchRadius, + int quartStep, + Predicate> allowed, + RandomSource random + ) { + int centerQuartX = QuartPos.fromBlock(x); + int centerQuartZ = QuartPos.fromBlock(z); + int quartRadius = QuartPos.fromBlock(searchRadius); + Pair> selected = null; + int matches = 0; + for (int radius = 0; radius <= quartRadius; radius += quartStep) { + for (int offsetZ = -radius; offsetZ <= radius; offsetZ += quartStep) { + for (int offsetX = -radius; offsetX <= radius; offsetX += quartStep) { + int quartX = centerQuartX + offsetX; + int quartZ = centerQuartZ + offsetZ; + Holder holder = getNaturalSurfaceStructureBiomeHolder(quartX, quartZ); + if (!allowed.test(holder)) { + continue; + } + if (selected == null || random.nextInt(matches + 1) == 0) { + selected = Pair.of(new BlockPos( + QuartPos.toBlock(quartX), + y, + QuartPos.toBlock(quartZ)), holder); + } + matches++; + } + } + } + return selected; + } + + private Holder getNaturalSurfaceStructureBiomeHolder(int x, int z) { + long columnKey = packColumnKey(x, z); + Holder cachedHolder = naturalSurfaceStructureBiomeCache.get(columnKey); + if (cachedHolder != null) { + return cachedHolder; + } + Holder resolvedHolder = resolveNaturalSurfaceStructureBiomeHolder(x, z); + Holder existingHolder = naturalSurfaceStructureBiomeCache.putIfAbsent( + columnKey, resolvedHolder); + if (existingHolder != null) { + return existingHolder; + } + if (naturalSurfaceStructureBiomeCache.size() > NOISE_BIOME_CACHE_MAX) { + naturalSurfaceStructureBiomeCache.clear(); + } + return resolvedHolder; + } + private boolean isGuaranteedSurfaceBiome(int quartY) { if (engine == null || engine.isClosed() || engine.getComplex() == null) { return false; @@ -417,6 +482,23 @@ public class CustomBiomeSource extends BiomeSource { return holder; } + private Holder resolveNaturalSurfaceStructureBiomeHolder(int x, int z) { + int blockX = x << 2; + int blockZ = z << 2; + IrisBiome irisBiome = engine.getComplex().getNaturalTrueBiomeStream().get(blockX, blockZ); + if (irisBiome == null) { + throw new IllegalStateException("Iris returned no natural structure biome at block " + + blockX + "," + blockZ); + } + Holder holder = resolveBiomeHolder(biomeRegistry, irisBiome.getStructureDerivativeKey()); + if (holder == null) { + throw new IllegalStateException("Iris natural structure biome derivative '" + + irisBiome.getStructureDerivativeKey() + "' is not registered at block " + + blockX + "," + blockZ); + } + return holder; + } + public Holder getVisibleNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { GenerationSessionLease lease = tryAcquireGenerationLease("bukkit_visible_biome"); if (lease == null) { @@ -506,6 +588,7 @@ public class CustomBiomeSource extends BiomeSource { noiseBiomeCache.clear(); structureBiomeCache.clear(); surfaceStructureBiomeCache.clear(); + naturalSurfaceStructureBiomeCache.clear(); customBiomes = refreshedCustomBiomes; vanillaSpawnBiomes = refreshedSpawnBiomes; cacheDimension = dimension; diff --git a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java index 4dc02779f..3d4d48e82 100644 --- a/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java +++ b/adapters/bukkit/nms/v26_2_R1/src/test/java/art/arcane/iris/core/nms/v26_2_R1/CustomBiomeSourceStructureContractTest.java @@ -45,9 +45,36 @@ public class CustomBiomeSourceStructureContractTest { assertTrue(source.contains("private static final int STRONGHOLD_RING_SEARCH_Y = 0")); assertTrue(source.contains("private static final int STRONGHOLD_RING_SEARCH_RADIUS = 112")); assertTrue(source.contains("private static final int STRONGHOLD_RING_SEARCH_QUART_STEP = 4")); - assertTrue(source.contains("x, y, z, searchRadius, quartStep, allowed, random, false, sampler")); assertTrue(source.contains( "return super.findBiomeHorizontal(x, y, z, searchRadius, allowed, random, sampler)")); + assertTrue(source.contains("tryAcquireGenerationLease(\"bukkit_structure_ring_biome\")")); + assertTrue(source.contains("findNaturalSurfaceBiomeHorizontal(")); + assertTrue(source.contains("radius += quartStep")); + assertTrue(source.contains("offsetZ += quartStep")); + assertTrue(source.contains("offsetX += quartStep")); + assertTrue(source.contains("random.nextInt(matches + 1) == 0")); + } + + @Test + public void onlyConcentricRingSuitabilityUsesTheNaturalTerrainStream() throws IOException { + String source = Files.readString(Path.of(System.getProperty("iris.customBiomeSource"))); + int realResolutionStart = source.indexOf("private Holder resolveSurfaceStructureBiomeHolder("); + int naturalResolutionStart = source.indexOf( + "private Holder resolveNaturalSurfaceStructureBiomeHolder("); + int resolutionEnd = source.indexOf("public Holder getVisibleNoiseBiome(", naturalResolutionStart); + + assertTrue(realResolutionStart >= 0); + assertTrue(naturalResolutionStart > realResolutionStart); + assertTrue(resolutionEnd > naturalResolutionStart); + String realResolution = source.substring(realResolutionStart, naturalResolutionStart); + String naturalResolution = source.substring(naturalResolutionStart, resolutionEnd); + + assertTrue(realResolution.contains("engine.getComplex().getTrueBiomeStream().get(blockX, blockZ)")); + assertFalse(realResolution.contains("getNaturalTrueBiomeStream()")); + assertTrue(naturalResolution.contains( + "engine.getComplex().getNaturalTrueBiomeStream().get(blockX, blockZ)")); + assertFalse(naturalResolution.contains("getTrueBiomeStream()")); + assertFalse(source.contains("studioBootstrapSurfaceStructureBiomeCache")); } @Test diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java index 1d3748e42..53b429e65 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnField.java @@ -2,6 +2,11 @@ package art.arcane.iris.api.terrain; public enum IrisColumnField { SURFACE_HEIGHT, + NATURAL_HEIGHT, SURFACE_KIND, - BIOME_KEY + BIOME_KEY, + RIVER_STATE, + RIVER_DISTANCE, + RIVER_FLOW, + RIVER_WATER_SURFACE_Y } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSample.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSample.java new file mode 100644 index 000000000..4bbabe286 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSample.java @@ -0,0 +1,64 @@ +package art.arcane.iris.api.terrain; + +import java.util.Objects; + +public record IrisColumnSample( + int blockX, + int blockZ, + int surfaceHeight, + int naturalHeight, + IrisSurfaceKind surfaceKind, + String biomeKey, + IrisRiverState riverState, + double riverDistance, + int riverFlow, + int riverWaterSurfaceY +) { + public static final int UNAVAILABLE_HEIGHT = Integer.MIN_VALUE; + public static final double UNAVAILABLE_RIVER_DISTANCE = Double.NaN; + public static final int UNAVAILABLE_RIVER_FLOW = -1; + + public IrisColumnSample { + Objects.requireNonNull(surfaceKind, "surfaceKind"); + Objects.requireNonNull(riverState, "riverState"); + biomeKey = biomeKey == null || biomeKey.isBlank() ? null : biomeKey; + if (!Double.isNaN(riverDistance) && (!Double.isFinite(riverDistance) || riverDistance < 0D)) { + throw new IllegalArgumentException("riverDistance must be non-negative, finite, or unavailable"); + } + if (riverFlow < UNAVAILABLE_RIVER_FLOW) { + throw new IllegalArgumentException("riverFlow must be non-negative or unavailable"); + } + } + + public boolean hasSurfaceHeight() { + return surfaceHeight != UNAVAILABLE_HEIGHT; + } + + public boolean hasNaturalHeight() { + return naturalHeight != UNAVAILABLE_HEIGHT; + } + + public boolean hasSurfaceKind() { + return surfaceKind != IrisSurfaceKind.UNKNOWN; + } + + public boolean hasBiomeKey() { + return biomeKey != null; + } + + public boolean hasRiverState() { + return riverState != IrisRiverState.NONE; + } + + public boolean hasRiverDistance() { + return !Double.isNaN(riverDistance); + } + + public boolean hasRiverFlow() { + return riverFlow != UNAVAILABLE_RIVER_FLOW; + } + + public boolean hasRiverWaterSurfaceY() { + return riverWaterSurfaceY != UNAVAILABLE_HEIGHT; + } +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java index 4c6bccffe..ac6bb40ab 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisColumnSink.java @@ -2,5 +2,5 @@ package art.arcane.iris.api.terrain; @FunctionalInterface public interface IrisColumnSink { - void accept(int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey); + void accept(IrisColumnSample sample); } diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisRiverState.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisRiverState.java new file mode 100644 index 000000000..c8bdbdec0 --- /dev/null +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisRiverState.java @@ -0,0 +1,7 @@ +package art.arcane.iris.api.terrain; + +public enum IrisRiverState { + NONE, + WET, + DRY +} diff --git a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java index be60360b3..8d81f7c2e 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/api/terrain/IrisSurfaceKind.java @@ -4,6 +4,9 @@ public enum IrisSurfaceKind { UNKNOWN, LAND, SHORE, + RIVER, + RIVER_SHORE, + DRY_CHANNEL, OCEAN, VOID } 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 1222f8ea6..15677459a 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,75 @@ 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, + (int visited) -> sendStructureMessage(target, commandSender, + C.GRAY + "Searched " + visited + " hydrology tiles for " + type + "...") + ).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/gui/BukkitVisionOverlay.java b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java index a5fdb12dd..ff21f5bc4 100644 --- a/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java +++ b/adapters/bukkit/plugin/src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java @@ -318,7 +318,7 @@ public final class BukkitVisionOverlay implements GuiOverlay { public String openInEditor(double worldX, double worldZ, RenderType type) { IrisComplex complex = engine.getComplex(); File file = switch (type) { - case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT -> + case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER -> complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode(); 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 ee1ed09ec..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 @@ -2,7 +2,9 @@ package art.arcane.iris.core.service; import art.arcane.iris.api.terrain.IrisColumnField; import art.arcane.iris.api.terrain.IrisColumnQuery; +import art.arcane.iris.api.terrain.IrisColumnSample; import art.arcane.iris.api.terrain.IrisColumnSink; +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.api.terrain.IrisWorldInfo; @@ -13,6 +15,8 @@ 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; @@ -101,13 +105,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { try { int surface = engine.getHeight(blockX, blockZ); - int fluid = engine.getDimension().getFluidHeight(); + 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); + return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology); } catch (Throwable error) { reportQueryFault("surfaceKind", world, error); return IrisSurfaceKind.UNKNOWN; @@ -224,26 +230,75 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { EnumSet fields = query.fields(); boolean wantHeight = fields.contains(IrisColumnField.SURFACE_HEIGHT); + boolean wantNaturalHeight = fields.contains(IrisColumnField.NATURAL_HEIGHT); boolean wantKind = fields.contains(IrisColumnField.SURFACE_KIND); boolean wantBiome = fields.contains(IrisColumnField.BIOME_KEY); + boolean wantRiverState = fields.contains(IrisColumnField.RIVER_STATE); + boolean wantRiverDistance = fields.contains(IrisColumnField.RIVER_DISTANCE); + boolean wantRiverFlow = fields.contains(IrisColumnField.RIVER_FLOW); + boolean wantRiverWaterSurface = fields.contains(IrisColumnField.RIVER_WATER_SURFACE_Y); + boolean wantRiver = wantKind || wantRiverState || wantRiverDistance || wantRiverFlow + || wantRiverWaterSurface; try { int minHeight = engine.getMinHeight(); - int fluid = engine.getDimension().getFluidHeight(); long visited = IrisColumnWalk.walk(query, (int blockX, int blockZ) -> { if (engine.isClosed()) { return false; } + HydrologyColumnSample hydrology = wantRiver + ? hydrologySample(engine, blockX, blockZ) + : null; int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0; + 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; IrisSurfaceKind kind = wantKind - ? IrisSurfaceClassifier.classify(surface, fluid, biome == null ? null : biome.getInferredType()) + ? IrisSurfaceClassifier.classify( + surface, + fluid, + biome == null ? null : biome.getInferredType(), + hydrology + ) : IrisSurfaceKind.UNKNOWN; String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null; - sink.accept(blockX, blockZ, wantHeight ? surface + minHeight : -1, kind, biomeKey); + int natural = wantNaturalHeight + ? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight + : IrisColumnSample.UNAVAILABLE_HEIGHT; + HydrologyColumnLayer surfaceLayer = hydrology == null + ? null + : hydrology.primarySurfaceLayer().orElse(null); + boolean riverPresent = surfaceLayer != null; + IrisRiverState riverState = wantRiverState + ? riverState(hydrology) + : IrisRiverState.NONE; + double riverDistance = wantRiverDistance && riverPresent + ? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ) + : IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE; + int riverFlow = wantRiverFlow && riverPresent + ? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ)) + : IrisColumnSample.UNAVAILABLE_RIVER_FLOW; + int riverWaterSurfaceY = wantRiverWaterSurface + && surfaceLayer != null + && surfaceLayer.connectedFluid() + ? fluid + minHeight + : IrisColumnSample.UNAVAILABLE_HEIGHT; + sink.accept(new IrisColumnSample( + blockX, + blockZ, + wantHeight ? surface + minHeight : IrisColumnSample.UNAVAILABLE_HEIGHT, + natural, + kind, + biomeKey, + riverState, + riverDistance, + riverFlow, + riverWaterSurfaceY + )); return true; }); return visited == query.columnCount(); @@ -253,6 +308,23 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService { } } + static IrisRiverState riverState(HydrologyColumnSample hydrology) { + HydrologyColumnLayer layer = hydrology == null + ? null + : hydrology.primarySurfaceLayer().orElse(null); + if (layer == null || !layer.channel()) { + return IrisRiverState.NONE; + } + return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY; + } + + 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) { String loadKey = biome == null ? null : biome.getLoadKey(); return loadKey == null || loadKey.isEmpty() ? Optional.empty() : Optional.of(loadKey); 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 75be7325a..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,6 +1,8 @@ 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; public final class IrisSurfaceClassifier { @@ -22,4 +24,30 @@ public final class IrisSurfaceClassifier { return inferredType == InferredType.SHORE ? IrisSurfaceKind.SHORE : IrisSurfaceKind.LAND; } + + public static IrisSurfaceKind classify( + int engineSurfaceHeight, + int engineFluidHeight, + InferredType inferredType, + HydrologyColumnSample hydrology + ) { + if (engineSurfaceHeight <= 0) { + return IrisSurfaceKind.VOID; + } + HydrologyColumnLayer layer = hydrology == null + ? null + : hydrology.primarySurfaceLayer().orElse(null); + if (layer != null) { + if (layer.shore()) { + return IrisSurfaceKind.RIVER_SHORE; + } + if (layer.channel()) { + return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL; + } + if (layer.grading()) { + return IrisSurfaceKind.LAND; + } + } + return classify(engineSurfaceHeight, engineFluidHeight, inferredType); + } } diff --git a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnSampleTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnSampleTest.java new file mode 100644 index 000000000..6947f39f3 --- /dev/null +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/api/terrain/IrisColumnSampleTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.api.terrain; + +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; + +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.junit.Assert.fail; + +public class IrisColumnSampleTest { + @Test + public void unavailableFieldsHaveUnambiguousSentinels() { + IrisColumnSample sample = sample( + IrisColumnSample.UNAVAILABLE_HEIGHT, + IrisColumnSample.UNAVAILABLE_HEIGHT, + IrisSurfaceKind.UNKNOWN, + null, + IrisRiverState.NONE, + IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE, + IrisColumnSample.UNAVAILABLE_RIVER_FLOW, + IrisColumnSample.UNAVAILABLE_HEIGHT + ); + + assertFalse(sample.hasSurfaceHeight()); + assertFalse(sample.hasNaturalHeight()); + assertFalse(sample.hasSurfaceKind()); + assertFalse(sample.hasBiomeKey()); + assertFalse(sample.hasRiverState()); + assertFalse(sample.hasRiverDistance()); + assertFalse(sample.hasRiverFlow()); + assertFalse(sample.hasRiverWaterSurfaceY()); + } + + @Test + public void negativeWorldHeightsRemainAvailableValues() { + IrisColumnSample sample = sample( + -1, + -64, + IrisSurfaceKind.DRY_CHANNEL, + "test:river", + IrisRiverState.DRY, + 0D, + 0, + -1 + ); + + assertTrue(sample.hasSurfaceHeight()); + assertTrue(sample.hasNaturalHeight()); + assertTrue(sample.hasSurfaceKind()); + assertTrue(sample.hasBiomeKey()); + assertTrue(sample.hasRiverState()); + assertTrue(sample.hasRiverDistance()); + assertTrue(sample.hasRiverFlow()); + assertTrue(sample.hasRiverWaterSurfaceY()); + } + + @Test + public void blankBiomeKeysNormalizeToUnavailable() { + IrisColumnSample sample = sample( + 64, + 65, + IrisSurfaceKind.LAND, + " ", + IrisRiverState.NONE, + IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE, + IrisColumnSample.UNAVAILABLE_RIVER_FLOW, + IrisColumnSample.UNAVAILABLE_HEIGHT + ); + + assertNull(sample.biomeKey()); + assertFalse(sample.hasBiomeKey()); + } + + @Test + public void theSinkReceivesTheTypedSample() { + IrisColumnSample sample = sample( + 64, + 65, + IrisSurfaceKind.RIVER, + "test:river", + IrisRiverState.WET, + 0.5D, + 3, + 67 + ); + AtomicReference received = new AtomicReference<>(); + IrisColumnSink sink = received::set; + + sink.accept(sample); + + assertSame(sample, received.get()); + } + + @Test + public void invalidHydrologyValuesAreRejected() { + assertInvalid(Double.POSITIVE_INFINITY, 1); + assertInvalid(-0.1D, 1); + assertInvalid(0D, -2); + } + + private static void assertInvalid(double distance, int flow) { + try { + sample( + 64, + 65, + IrisSurfaceKind.RIVER, + "test:river", + IrisRiverState.WET, + distance, + flow, + 67 + ); + fail("Expected invalid hydrology values to be rejected"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().startsWith("river")); + } + } + + private static IrisColumnSample sample( + int surfaceHeight, + int naturalHeight, + IrisSurfaceKind surfaceKind, + String biomeKey, + IrisRiverState riverState, + double riverDistance, + int riverFlow, + int riverWaterSurfaceY + ) { + return new IrisColumnSample( + 12, + -7, + surfaceHeight, + naturalHeight, + surfaceKind, + biomeKey, + riverState, + riverDistance, + riverFlow, + riverWaterSurfaceY + ); + } +} 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/CommandObjectFluidHeightTest.java b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectFluidHeightTest.java index 81febecb2..ec5cecbe9 100644 --- a/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectFluidHeightTest.java +++ b/adapters/bukkit/plugin/src/test/java/art/arcane/iris/core/commands/CommandObjectFluidHeightTest.java @@ -1,8 +1,10 @@ package art.arcane.iris.core.commands; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.object.IObjectPlacer; import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.util.project.stream.ProceduralStream; import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.data.BlockData; @@ -13,22 +15,31 @@ import java.util.Map; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class CommandObjectFluidHeightTest { @Test - public void absoluteObjectPlacementShiftsFluidHeightFromNegativeMinY() { + public void absoluteObjectPlacementShiftsTheColumnRiverHeadFromNegativeMinY() { World world = mock(World.class); Engine engine = mock(Engine.class); IrisDimension dimension = mock(IrisDimension.class); + IrisComplex complex = mock(IrisComplex.class); + @SuppressWarnings("unchecked") + ProceduralStream riverHead = mock(ProceduralStream.class); Map future = new HashMap<>(); when(engine.getMinHeight()).thenReturn(-64); when(engine.getDimension()).thenReturn(dimension); + when(engine.getComplex()).thenReturn(complex); when(dimension.getFluidHeight()).thenReturn(127); + when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead); + when(riverHead.get(12, -7)).thenReturn(131D); IObjectPlacer placer = CommandObject.createPlacer(world, future, engine); assertEquals(63, placer.getFluidHeight()); + assertEquals(67, placer.getFluidHeight(12, -7)); + verify(riverHead).get(12, -7); } } 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 5e9a2dec1..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 @@ -2,8 +2,13 @@ package art.arcane.iris.core.service; import art.arcane.iris.api.terrain.IrisColumnField; 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.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; @@ -12,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; @@ -82,9 +88,7 @@ public class IrisTerrainSVCTest { IrisTerrainSVC service = new IrisTerrainSVC(); AtomicInteger sinkCalls = new AtomicInteger(); - boolean answered = service.sampleColumns(null, SMALL, - (int blockX, int blockZ, int surfaceHeight, IrisSurfaceKind kind, String biomeKey) - -> sinkCalls.incrementAndGet()); + boolean answered = service.sampleColumns(null, SMALL, sample -> sinkCalls.incrementAndGet()); assertFalse(answered); assertEquals(0, sinkCalls.get()); @@ -97,4 +101,48 @@ public class IrisTerrainSVCTest { assertFalse(service.sampleColumns(null, null, null)); assertFalse(service.sampleColumns(null, SMALL, null)); } + + @Test + 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 HydrologyColumnSample river(boolean connectedFluid) { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 1L, + HydrologyFeatureType.SURFACE_POOL, + 2L, + 3L, + 0, + 63, + 0, + 1, + 0, + false + ); + HydrologyColumnLayer layer = new HydrologyColumnLayer( + feature, + 60, + 63, + 63, + true, + false, + false, + connectedFluid, + false, + false, + true, + connectedFluid, + false, + "water", + "river", + "mouth", + "shore", + "dry", + "cave" + ); + return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer)); + } } 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 a9368289b..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,9 +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 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; @@ -53,9 +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 acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() { + assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( + 60, + 63, + InferredType.SEA, + hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true) + )); + assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( + 60, + 63, + InferredType.SEA, + hydrology(HydrologyFeatureType.MOUTH, true, false, true) + )); + assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify( + 64, + 63, + InferredType.SHORE, + hydrology(HydrologyFeatureType.SURFACE_POOL, false, true, false) + )); + assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify( + 60, + 60, + InferredType.LAND, + hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, false) + )); + assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify( + 60, + 60, + InferredType.LAND, + hydrology(HydrologyFeatureType.SURFACE_POOL, false, false, false) + )); + } + + @Test + public void voidClassificationWinsOverAcceptedRiverGeometry() { + assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify( + 0, + 63, + InferredType.LAND, + hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true) + )); + } + + @Test + public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() { + assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify( + 64, + 63, + InferredType.LAND, + null + )); + } + + private static HydrologyColumnSample hydrology( + HydrologyFeatureType type, + boolean channel, + boolean shore, + boolean connectedFluid + ) { + HydrologyFeatureRef feature = new HydrologyFeatureRef( + 1L, + type, + 2L, + 3L, + 0, + 63, + 0, + 1, + 0, + false + ); + HydrologyColumnLayer layer = new HydrologyColumnLayer( + feature, + 60, + 63, + 63, + channel, + shore, + !channel && !shore, + connectedFluid, + false, + false, + true, + connectedFluid && channel, + false, + "water", + "river", + "mouth", + "shore", + "dry", + "cave" + ); + return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer)); + } } diff --git a/adapters/fabric/build.gradle b/adapters/fabric/build.gradle index 890b776d9..467cea14c 100644 --- a/adapters/fabric/build.gradle +++ b/adapters/fabric/build.gradle @@ -115,6 +115,9 @@ dependencies { 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 7991cfed5..513e806a7 100644 --- a/adapters/forge/build.gradle +++ b/adapters/forge/build.gradle @@ -132,6 +132,9 @@ 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/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..505f4f07c 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,67 @@ 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, + (int visited) -> server.execute(() -> IrisModdedCommands.ok(source, + "Searched " + visited + " hydrology tiles for " + type + "..."))).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/command/ModdedVisionOverlay.java b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java index c42519c48..fd0f55219 100644 --- a/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java +++ b/adapters/modded-common/src/main/java/art/arcane/iris/modded/command/ModdedVisionOverlay.java @@ -126,7 +126,7 @@ public final class ModdedVisionOverlay implements GuiOverlay { } IrisComplex complex = engine.getComplex(); File file = switch (type) { - case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT -> + case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER -> complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode(); case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode(); 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 8f256eda9..841a29891 100644 --- a/adapters/neoforge/build.gradle +++ b/adapters/neoforge/build.gradle @@ -106,6 +106,9 @@ 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 4274f887d..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,6 +23,8 @@ 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; @@ -69,6 +71,7 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -484,11 +487,52 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi } private void renderLegend(Graphics2D canvas) { - if (currentType == RenderType.HEIGHT) { + if (currentType == RenderType.RIVER) { + renderRiverLegend(canvas); + } else if (currentType == RenderType.HEIGHT) { renderHeightLegend(canvas); } } + private void renderRiverLegend(Graphics2D canvas) { + HydrologyFeatureType[] types = HydrologyFeatureType.values(); + HydrologyCandidateKind[] candidateKinds = HydrologyCandidateKind.values(); + int lineHeight = 18; + 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); + 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 = 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); + } + } + private void renderHeightLegend(Graphics2D canvas) { int width = 244; int height = 54; @@ -1037,6 +1081,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi case BIOME_SEA -> DesktopUiMessages.VISION_MODE_BIOME_SEA; case REGION -> DesktopUiMessages.VISION_MODE_REGION; case CAVE_LAND -> DesktopUiMessages.VISION_MODE_CAVE_LAND; + case RIVER -> DesktopUiMessages.VISION_MODE_RIVER; case HEIGHT -> DesktopUiMessages.VISION_MODE_HEIGHT; case OBJECT_LOAD -> DesktopUiMessages.VISION_MODE_OBJECT_LOAD; case DECORATOR_LOAD -> DesktopUiMessages.VISION_MODE_DECORATOR_LOAD; diff --git a/core/src/main/java/art/arcane/iris/core/gui/VisionRenderController.java b/core/src/main/java/art/arcane/iris/core/gui/VisionRenderController.java index 836a7830d..9702a829b 100644 --- a/core/src/main/java/art/arcane/iris/core/gui/VisionRenderController.java +++ b/core/src/main/java/art/arcane/iris/core/gui/VisionRenderController.java @@ -275,7 +275,8 @@ final class VisionRenderController implements AutoCloseable { if (!isCurrent(work)) { return; } - while (work.inFlight() < renderWorkerCount) { + int admissionLimit = work.frame().spec().type() == RenderType.RIVER ? 1 : renderWorkerCount; + while (work.inFlight() < admissionLimit) { VisibleTile tile = work.nextMissing(); if (tile == null) { return; 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 a5fcaf550..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 @@ -52,6 +52,7 @@ public final class DesktopUiMessages { public static final TextKey VISION_MODE_BIOME_SEA = TextKey.of("iris.desktop.vision.mode.biome_sea", "Biome sea"); public static final TextKey VISION_MODE_REGION = TextKey.of("iris.desktop.vision.mode.region", "Region"); public static final TextKey VISION_MODE_CAVE_LAND = TextKey.of("iris.desktop.vision.mode.cave_land", "Cave land"); + public static final TextKey VISION_MODE_RIVER = TextKey.of("iris.desktop.vision.mode.river", "River network"); public static final TextKey VISION_MODE_HEIGHT = TextKey.of("iris.desktop.vision.mode.height", "Height"); public static final TextKey VISION_MODE_OBJECT_LOAD = TextKey.of("iris.desktop.vision.mode.object_load", "Object load"); public static final TextKey VISION_MODE_DECORATOR_LOAD = TextKey.of("iris.desktop.vision.mode.decorator_load", "Decorator load"); @@ -171,7 +172,7 @@ public final class DesktopUiMessages { VISION_HELP_RESET_ZOOM, VISION_HELP_CYCLE_MODE, VISION_HELP_FPS, VISION_HELP_GRID, VISION_HELP_BIOME, VISION_HELP_TELEPORT, VISION_HELP_EDITOR, VISION_OPENED, VISION_TELEPORTING, VISION_MODE_BIOME, VISION_MODE_BIOME_LAND, VISION_MODE_BIOME_SEA, - VISION_MODE_REGION, VISION_MODE_CAVE_LAND, VISION_MODE_HEIGHT, VISION_MODE_OBJECT_LOAD, + VISION_MODE_REGION, VISION_MODE_CAVE_LAND, VISION_MODE_RIVER, VISION_MODE_HEIGHT, VISION_MODE_OBJECT_LOAD, VISION_MODE_DECORATOR_LOAD, VISION_MODE_CONTINENT, VISION_MODE_LAYER_LOAD, NOISE_TITLE, NOISE_TITLE_GENERATOR, NOISE_SEARCH, NOISE_STATUS, NOISE_CATEGORY_CUSTOM, NOISE_CATEGORY_PACK_GENERATORS, NOISE_CATEGORY_SIMPLEX, NOISE_CATEGORY_PERLIN, 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..0ae3e4c51 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/pack/PackHydrologyValidator.java @@ -0,0 +1,1551 @@ +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 int CORRIDOR_SAMPLE_SPACING = 4; + private static final List SAMPLE_SPACINGS = List.of(8, 16, 32, 64); + 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", + "bankBiomes", + "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, biomes, errors, warnings); + if (!hydrology.active()) { + continue; + } + validateMantleCapabilities(dimensionPath + " hydrology", dimension, errors); + if (hydrology.riversEnabled()) { + validateReachablePolicies( + dimensionKey, + dimension, + dimensionPolicy, + hydrology.profileIds(), + hydrology.surfacePoolIds(), + regions, + biomes, + imageMaps, + errors + ); + } + } + return new Validation(errors, warnings); + } + + private static HydrologyValidation validateHydrology( + String dimensionPath, + JSONObject dimension, + Map biomes, + 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 + ); + Set surfacePoolIds = validateSurfacePools(dimensionPath + ".hydrology", hydrology, profileIds, biomes, errors); + return new HydrologyValidation(riversEnabled, deepFluidsActive || !surfacePoolIds.isEmpty(), profileIds, surfacePoolIds); + } + + 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, errors, warnings); + SourceBudget undergroundBudget = underground == null + ? SourceBudget.defaults() + : validateUnderground(path + ".underground", underground, dimension, errors, warnings); + if (underground != null + && integerValue(underground, "mouthLevelingDistance", 64) > routingValues.maximumRouteLength()) { + errors.add(path + ".underground.mouthLevelingDistance must not exceed routing.maximumRouteLength."); + } + 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 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 (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, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "maximumRouteLength", 256, 32768, errors); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, + routing, + "minimumSurfaceCourseLength", + 0, + 32768, + errors + ); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, + routing, + "minimumUndergroundCourseLength", + 0, + 32768, + errors + ); + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "maximumOutletsPerTile", 1, 256, errors); + PackJsonFieldChecks.validateOptionalBoolean(path, routing, "oceanOutlets", errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "valleyPreference", 0D, 8D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "uphillPenalty", 0D, 128D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "slopePenalty", 0D, 16D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "confluenceAttraction", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, routing, "lengthPreference", 0D, 8D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, routing, "tributaries", 0, 4, errors); + + int tileSize = integerValue(routing, "tileSize", 2048); + int sampleSpacing = integerValue(routing, "sampleSpacing", 64); + int maximumRouteLength = integerValue(routing, "maximumRouteLength", 16384); + int minimumSurfaceCourseLength = integerValue(routing, "minimumSurfaceCourseLength", 384); + int minimumUndergroundCourseLength = integerValue(routing, "minimumUndergroundCourseLength", 192); + if (!SAMPLE_SPACINGS.contains(sampleSpacing)) { + errors.add(path + ".sampleSpacing must be one of " + SAMPLE_SPACINGS + "."); + } + if (sampleSpacing > 0 && tileSize % sampleSpacing != 0) { + errors.add(path + ".tileSize must be divisible by sampleSpacing."); + } + if (minimumSurfaceCourseLength > maximumRouteLength) { + errors.add(path + ".minimumSurfaceCourseLength must not exceed maximumRouteLength."); + } + if (minimumUndergroundCourseLength > maximumRouteLength) { + errors.add(path + ".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, maximumRouteLength, inlandOutlets); + } + + private static SourceBudget validateSurface( + String path, + JSONObject surface, + JSONObject dimension, + 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 banks = nestedObject(surface, "banks", path, errors); + JSONObject flow = nestedObject(surface, "flow", path, errors); + JSONObject mouths = nestedObject(surface, "mouths", path, errors); + JSONObject bed = nestedObject(surface, "bed", path, errors); + + SourceBudget budget = sources == null + ? SourceBudget.defaults() + : validateSources(path + ".sources", sources, true, errors); + if (channel != null) { + validateSurfaceChannel(path + ".channel", channel, errors); + } + if (banks != null) { + validateSurfaceBanks(path + ".banks", banks, errors); + } + if (flow != null) { + validateSurfaceFlow(path + ".flow", flow, errors); + } + if (mouths != null) { + validateMouths(path + ".mouths", mouths, errors); + } + if (bed != null) { + validateSurfaceBed(path + ".bed", bed, 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, 128D, 4D, 8D, errors); + validateRange(path, channel, "depth", 1D, 64D, 2D, 4D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "inset", 0, 3, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "maximumIncision", 1, 32, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, channel, "springWidthRatio", 1D, 4D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "springLength", 4, 96, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, channel, "roughness", 0D, 1D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, channel, "roughnessWavelength", 4, 64, errors); + } + + private static void validateSurfaceBanks(String path, JSONObject banks, List errors) { + PackJsonFieldChecks.validateOptionalIntegerRange(path, banks, "freeboard", 0, 4, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "shoreWidth", 0.5D, 6D, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(path, banks, "blendSlope", 0.5D, 12D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, banks, "minimumBlendWidth", 1, 64, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, banks, "maximumBlendWidth", 1, 64, errors); + PackJsonFieldChecks.validateOptionalBoolean(path, banks, "exposeCutStrata", errors); + if (integerValue(banks, "minimumBlendWidth", 4) > integerValue(banks, "maximumBlendWidth", 32)) { + errors.add(path + ".minimumBlendWidth must not exceed maximumBlendWidth."); + } + } + + private static void validateSurfaceFlow(String path, JSONObject flow, List errors) { + PackJsonFieldChecks.validateOptionalIntegerRange(path, flow, "cascadeRun", 1, 8, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, flow, "waterfallMinimumDrop", 2, 32, errors); + } + + private static void validateSurfaceBed(String path, JSONObject bed, List errors) { + PackJsonFieldChecks.validateOptionalBoolean(path, bed, "allowGravityBlocks", errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, bed, "padding", 0, 8, errors); + if (!bed.has("paddingPalette")) { + return; + } + Object rawPalette = bed.opt("paddingPalette"); + if (!(rawPalette instanceof JSONObject palette)) { + errors.add(path + ".paddingPalette must be an object."); + return; + } + Object rawEntries = palette.opt("palette"); + if (!(rawEntries instanceof JSONArray entries) || entries.length() == 0) { + errors.add(path + ".paddingPalette.palette must contain at least one solid block."); + return; + } + for (int index = 0; index < entries.length(); index++) { + String entryPath = path + ".paddingPalette.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 solid block."); + } else if (!definitelyNotFluid(block)) { + errors.add(entryPath + ".block must not be a fluid."); + } + PackJsonFieldChecks.validateOptionalIntegerRange(entryPath, entry, "weight", 1, 64, errors); + } + } + + private static void validateMouths(String path, JSONObject mouths, List errors) { + PackJsonFieldChecks.validateOptionalDoubleRange(path, mouths, "flareRatio", 1D, 4D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(path, mouths, "maximumOceanApron", 0, 32, errors); + } + + 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); + PackJsonFieldChecks.validateOptionalIntegerRange( + path, + underground, + "mouthLevelingDistance", + 16, + 512, + 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 Set validateSurfacePools( + String path, + JSONObject hydrology, + Set riverProfileIds, + Map biomes, + List errors + ) { + if (!hydrology.has("surfacePools")) { + return Set.of(); + } + Object rawPools = hydrology.opt("surfacePools"); + if (!(rawPools instanceof JSONArray pools)) { + errors.add(path + ".surfacePools must be an array."); + return Set.of(); + } + if (pools.length() > MAX_DEEP_FLUIDS) { + errors.add(path + ".surfacePools must contain at most " + MAX_DEEP_FLUIDS + " entries."); + } + LinkedHashSet ids = new LinkedHashSet<>(); + for (int index = 0; index < pools.length(); index++) { + String poolPath = path + ".surfacePools[" + index + "]"; + JSONObject pool = pools.optJSONObject(index); + if (pool == null) { + errors.add(poolPath + " must be an object."); + continue; + } + String id = requiredId(poolPath, pool, errors); + if (id != null) { + if (HydrologyFeatureQuery.isReservedKeyword(id)) { + errors.add(poolPath + ".id '" + id + "' is a reserved hydrology feature selector."); + } else if (riverProfileIds.contains(id)) { + errors.add(poolPath + ".id '" + id + "' collides with a river profile."); + } else if (!ids.add(id)) { + errors.add(poolPath + ".id must be unique inside hydrology.surfacePools."); + } + } + validateFluidPalette(poolPath, pool, true, errors); + PackJsonFieldChecks.validateOptionalDoubleRange(poolPath, pool, "density", 0D, 64D, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(poolPath, pool, "spacing", 32, 8192, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(poolPath, pool, "minimumRadius", 2, 16, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(poolPath, pool, "maximumRadius", 2, 16, errors); + PackJsonFieldChecks.validateOptionalIntegerRange(poolPath, pool, "depth", 1, 8, errors); + if (integerValue(pool, "minimumRadius", 4) > integerValue(pool, "maximumRadius", 7)) { + errors.add(poolPath + ".minimumRadius must not exceed maximumRadius."); + } + Object rawBiome = pool.opt("biome"); + if (rawBiome instanceof String biome && !biome.isBlank() && !biomes.containsKey(biome)) { + errors.add(poolPath + ".biome references unknown biome '" + biome + "'."); + } + } + return Set.copyOf(ids); + } + + private static void validatePolicyPoolsInDimension( + String path, + JSONObject policy, + String dimensionKey, + Set pools, + List errors + ) { + if (policy == null) { + return; + } + JSONArray references = policy.optJSONArray("surfacePools"); + if (references == null) { + return; + } + for (int index = 0; index < references.length(); index++) { + String pool = references.optString(index, null); + if (pool != null && ID_PATTERN.matcher(pool).matches() && !pools.contains(pool)) { + addDistinct(errors, path + ".surfacePools[" + index + "] references unknown surface pool '" + + pool + "' in Dimension '" + dimensionKey + "'."); + } + } + } + + private static void validatePolicyPools(String path, JSONObject policy, List errors) { + if (!policy.has("surfacePools") || policy.opt("surfacePools") == JSONObject.NULL) { + return; + } + Object rawPools = policy.opt("surfacePools"); + if (!(rawPools instanceof JSONArray pools)) { + errors.add(path + ".surfacePools must be an array or null."); + return; + } + if (pools.length() > MAX_POLICY_REFERENCES) { + errors.add(path + ".surfacePools must contain at most " + MAX_POLICY_REFERENCES + " entries."); + } + Set seen = new HashSet<>(); + for (int index = 0; index < pools.length(); index++) { + Object raw = pools.opt(index); + String referencePath = path + ".surfacePools[" + index + "]"; + if (!(raw instanceof String id) || !ID_PATTERN.matcher(id).matches()) { + errors.add(referencePath + " must name a surface pool id."); + continue; + } + if (!seen.add(id)) { + errors.add(referencePath + " duplicates surface pool '" + id + "'."); + } + } + } + + private static boolean validateDeepFluids( + String path, + JSONObject hydrology, + 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) { + 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(), CORRIDOR_SAMPLE_SPACING) + 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); + validatePolicyPools(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); + validateNullableDouble(path, policy, "bankMultiplier", 0D, 4D, errors); + return policy; + } + + private static void validateReachablePolicies( + String dimensionKey, + JSONObject dimension, + JSONObject dimensionPolicy, + Set profiles, + Set pools, + Map regions, + Map biomes, + Map imageMaps, + List errors + ) { + String dimensionPath = "Dimension '" + dimensionKey + "'"; + validatePolicyProfilesInDimension( + dimensionPath + ".riverPolicy", + dimensionPolicy, + dimensionKey, + profiles, + errors + ); + validatePolicyPoolsInDimension( + dimensionPath + ".riverPolicy", + dimensionPolicy, + dimensionKey, + pools, + 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 + ); + validatePolicyPoolsInDimension( + "Region '" + regionKey + "'.riverPolicy", + policy, + dimensionKey, + pools, + 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 + ); + validatePolicyPoolsInDimension( + "Biome '" + biomeKey + "'.riverPolicy", + policy, + dimensionKey, + pools, + 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, + Set surfacePoolIds + ) { + private static HydrologyValidation inactive() { + return new HydrologyValidation(false, false, Set.of(), Set.of()); + } + + private boolean active() { + return riversEnabled || deepFluidsActive; + } + } + + private record RoutingValues( + int tileSize, + int sampleSpacing, + int maximumRouteLength, + Set inlandOutlets + ) { + private static RoutingValues defaults() { + return new RoutingValues(2048, 64, 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/PackValidator.java b/core/src/main/java/art/arcane/iris/core/pack/PackValidator.java index 81ef731ac..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,6 +84,10 @@ public final class PackValidator { packFolder, dimensionFiles, validateLiveRegistries); addDistinct(blockingErrors, imageMaps.errors()); addDistinct(warnings, imageMaps.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 7a29aaa33..9885f746d 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisComplex.java @@ -22,14 +22,27 @@ 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; import art.arcane.iris.engine.object.IrisDecorator; 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.IrisDeepFluidConfig; +import art.arcane.iris.engine.object.IrisSurfacePoolConfig; +import art.arcane.iris.engine.object.IrisHydrology; +import art.arcane.iris.engine.object.IrisRiverHydrology; +import art.arcane.iris.engine.object.IrisRiverProfile; import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisLogging; @@ -58,6 +71,7 @@ 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; @@ -66,8 +80,8 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiFunction; @Data -@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "imageMapRuntime"}) -@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "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); @@ -109,14 +123,20 @@ public class IrisComplex implements DataProvider { private ProceduralStream shoreBiomeStream; private ProceduralStream baseBiomeStream; private ProceduralStream baseBiomeIDStream; + private ProceduralStream naturalTrueBiomeStream; private ProceduralStream trueBiomeStream; private ProceduralStream trueBiomeDerivativeStream; + private ProceduralStream naturalHeightStream; private ProceduralStream heightStream; private ProceduralStream roundedHeighteightStream; private ProceduralStream maxHeightStream; private ProceduralStream overlayStream; private ProceduralStream heightFluidStream; private ProceduralStream slopeStream; + private ProceduralStream riverDistanceStream; + private ProceduralStream riverFlowStream; + private ProceduralStream riverCarveWeightStream; + private ProceduralStream riverWaterSurfaceStream; private ProceduralStream topSurfaceStream; private ProceduralStream terrainSurfaceDecoration; private ProceduralStream terrainCeilingDecoration; @@ -127,10 +147,12 @@ public class IrisComplex implements DataProvider { private ProceduralStream shoreSurfaceDecoration; private ProceduralStream rockStream; private ProceduralStream fluidStream; + private Map> hydrologyFluidStreams; private IrisBiome focusBiome; private IrisRegion focusRegion; private Map> generatorBounds; private Set generatorBiomes; + 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 @@ -166,7 +188,7 @@ public class IrisComplex implements DataProvider { //@builder if (focusRegion != null) { prepareInferredBiomes(focusRegion, preparedRegions); - focusRegion.getAllBiomes(this).forEach(this::registerGenerators); + focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators); } else { engine.getDimension().getRegions().forEach(regionKey -> { IrisRegion region = data.getRegionLoader().load(regionKey); @@ -174,11 +196,11 @@ public class IrisComplex implements DataProvider { return; } prepareInferredBiomes(region, preparedRegions); - region.getAllBiomes(this).forEach(this::registerGenerators); + region.getNaturalBiomes(this).forEach(this::registerGenerators); }); for (IrisRegion region : imageMapRuntime.getMappedRegions()) { prepareInferredBiomes(region, preparedRegions); - region.getAllBiomes(this).forEach(this::registerGenerators); + region.getNaturalBiomes(this).forEach(this::registerGenerators); } imageMapRuntime.getMappedBiomes().forEach(this::registerGenerators); } @@ -211,6 +233,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)); + 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); @@ -263,28 +286,59 @@ public class IrisComplex implements DataProvider { return mapped == null ? biome : mapped; }) .cache2D("imageMappedBaseBiomeStream", engine, cacheSize); - heightStream = ProceduralStream.of((x, z) -> { - double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight()); - return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight); - }, Interpolated.DOUBLE).cache2DDouble("heightStream", engine, cacheSize); - slopeStream = heightStream.slope(3) - .cache2DDouble("slopeStream", engine, cacheSize); - trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, + 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("trueBiomeStream-focus", engine, cacheSize) : heightStream + .cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream .convertAware2D((h, x, z) -> { IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); return mapped == null ? fixBiomeType(h, baseBiomeStream.get(x, z), regionStream.get(x, z), x, z, fluidHeight) : mapped; }) - .cache2D("trueBiomeStream", engine, cacheSize); + .cache2D("naturalTrueBiomeStream", engine, cacheSize); + IrisHydrology configuredHydrology = engine.getDimension().getHydrology(); + if (hydrologyActive(configuredHydrology)) { + hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext( + engine.getSeedManager().getBodies(), + engine.getHeight(), + engine.getDimension(), + data, + (x, z, naturalHeight) -> sampleHydrologyNatural(engine, x, z, naturalHeight), + (x, z) -> naturalHeightStream.getDouble(x, z), + this::sampleNaturalOcean, + footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint) + )); + } + 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(); + slopeStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) + .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) : 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); - heightFluidStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) - .max(fluidHeight).cache2DDouble("heightFluidStream", engine, cacheSize); + riverDistanceStream = ProceduralStream.ofDouble(this::resolveHydrologyDistance) + .cache2DDouble("riverDistanceStream", engine, cacheSize); + riverFlowStream = ProceduralStream.ofDouble(this::resolveHydrologyFlow) + .cache2DDouble("riverFlowStream", engine, cacheSize); + riverCarveWeightStream = ProceduralStream.ofDouble(this::resolveHydrologyCarveWeight) + .cache2DDouble("riverCarveWeightStream", engine, cacheSize); + riverWaterSurfaceStream = ProceduralStream.ofDouble(this::resolveHydrologyFluidSurface) + .cache2DDouble("riverWaterSurfaceStream", engine, cacheSize); + heightFluidStream = ProceduralStream.ofDouble((x, z) -> Math.max( + heightStream.get(x, z), + riverWaterSurfaceStream.get(x, z) + )) + .cache2DDouble("heightFluidStream", engine, cacheSize); maxHeightStream = ProceduralStream.ofDouble((x, z) -> height); terrainSurfaceDecoration = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) .convertAware2D((b, xx, zz) -> decorateFor(b, xx, zz, IrisDecorationPart.NONE)).cache2D("terrainSurfaceDecoration", engine, cacheSize); @@ -310,6 +364,320 @@ public class IrisComplex implements DataProvider { //@done } + static InferredType resolveNaturalInferredType( + ProceduralStream bridgeStream, + IrisBiome focusBiome, + double x, + double z + ) { + if (focusBiome != null) { + return focusBiome.getInferredType(); + } + 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); + } + } + for (IrisSurfacePoolConfig pool : hydrology.getSurfacePools()) { + if (pool == null) { + throw new IllegalArgumentException("hydrology.surfacePools cannot contain null entries"); + } + String profileKey = requireHydrologyKey(pool.getId(), "hydrology.surfacePools[].id"); + ProceduralStream stream = configuredFluidStream( + pool.getFluidPalette(), + rng.nextParallelRNG(9900 + streamIndex++), + "hydrology surface pool " + profileKey + ); + if (streams.putIfAbsent(profileKey, stream) != null) { + throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey); + } + } + return Collections.unmodifiableMap(streams); + } + + 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( + IrisMaterialPalette palette, + RNG fluidRng, + String configurationName + ) { + Objects.requireNonNull(palette, configurationName + " fluidPalette must be configured"); + KList blocks = palette.getBlockData(data); + if (blocks.isEmpty()) { + throw new IllegalArgumentException( + configurationName + " fluidPalette must resolve at least one fluid block"); + } + for (PlatformBlockState block : blocks) { + if (block == null || !block.isFluid()) { + throw new IllegalArgumentException( + configurationName + " fluidPalette may contain only fluid blocks"); + } + } + return palette.getLayerGenerator(fluidRng, data).stream().select(blocks); + } + + 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) { + 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.75D : 0.5D; + } + + 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 boolean hasHydrologyChannelOrShore(int x, int z) { + HydrologyColumnLayer layer = surfaceLayer(x, z); + return layer != null && (layer.channel() || layer.shore()); + } + public ProceduralStream getBiomeStream(InferredType type) { switch (type) { case CAVE: @@ -370,6 +738,47 @@ public class IrisComplex implements DataProvider { return null; } + 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 && layer == null) { + return mapped; + } + 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); + } + if (layer.biomeKey() == null) { + return implode(biome.withInferredType(InferredType.LAND), x, z); + } + InferredType inferredType = layer.shore() + ? InferredType.SHORE + : layer.connectedFluid() ? InferredType.SEA : InferredType.LAND; + return implode(biome.withInferredType(inferredType), x, z); + } + return fixBiomeType( + terrainHeight, + baseBiomeStream.get(x, z), + regionStream.get(x, z), + x, + z, + fluidHeight + ); + } + + static String hydrologySurfaceBiomeKey(HydrologyColumnSample sample) { + Objects.requireNonNull(sample, "sample"); + HydrologyColumnLayer layer = sample.primarySurfaceLayer().orElse(null); + if (layer == null) { + return sample.parentBiomeKey(); + } + 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) { IrisBiome resolved = resolveSurfaceBiome( height, @@ -528,6 +937,38 @@ public class IrisComplex implements DataProvider { return h; } + private NoiseBounds naturalHeightBounds( + Engine engine, + KList overlayNoise, + double x, + double z + ) { + double minimum = fluidHeight; + double maximum = fluidHeight; + for (int interpolatorIndex = 0; interpolatorIndex < frozenInterpolators.length; interpolatorIndex++) { + NoiseBounds bounds = gridSampleBounds( + engine, + frozenInterpolators[interpolatorIndex], + interpolatorIndex, + frozenGenerators[interpolatorIndex], + x, + z + ); + minimum += Math.min(bounds.min(), bounds.max()); + maximum += Math.max(bounds.min(), bounds.max()); + } + for (IrisShapedGeneratorStyle style : overlayNoise) { + minimum += Math.min(style.getMin(), style.getMax()); + maximum += Math.max(style.getMin(), style.getMax()); + } + minimum = imageMapRuntime.sampleTerrainHeight(x, z, minimum); + maximum = imageMapRuntime.sampleTerrainHeight(x, z, maximum); + return new NoiseBounds( + Math.max(0D, Math.min(engine.getHeight(), Math.min(minimum, maximum))), + Math.max(0D, Math.min(engine.getHeight(), Math.max(minimum, maximum))) + ); + } + 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); } @@ -540,46 +981,6 @@ public class IrisComplex implements DataProvider { preparedRegions.add(region); } - 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)); - }; - } - static Map>> compileInferredBiomeStreams( Iterable regions, BiFunction> compiler @@ -1072,6 +1473,8 @@ public class IrisComplex implements DataProvider { } public void 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 794d758d2..fca4c2fae 100644 --- a/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java +++ b/core/src/main/java/art/arcane/iris/engine/IrisEngineMantle.java @@ -29,7 +29,9 @@ 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.MantleHydrologyComponent; import art.arcane.iris.engine.mantle.components.IrisStructureComponent; +import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; @@ -72,6 +74,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 final Engine engine; private final Mantle mantle; @Getter(AccessLevel.NONE) @@ -86,6 +89,7 @@ public class IrisEngineMantle implements EngineMantle { this.mantle = createMantle(engine); components = new KMap<>(); registerComponent(new MantleCarvingComponent(this)); + registerComponent(new MantleHydrologyComponent(this)); object = new MantleObjectComponent(this); registerComponent(object); registerComponent(new MantleFloatingObjectComponent(this)); @@ -121,7 +125,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() @@ -174,10 +178,18 @@ public class IrisEngineMantle implements EngineMantle { if (!getDimension().isCarvingEnabled()) { disabled.addIfMissing(ReservedFlag.CARVED); } + if (disabled.contains(ReservedFlag.CARVED) + || !isHydrologyEnabled(getDimension())) { + disabled.addIfMissing(ReservedFlag.RIVER_HYDROLOGY); + } return Set.copyOf(disabled); }); } + static boolean isHydrologyEnabled(IrisDimension dimension) { + return MantleHydrologyComponent.isEnabledFor(dimension); + } + @Override public MantleObjectComponent getObjectComponent() { return object; @@ -185,7 +197,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 f19a54c99..231c6112f 100644 --- a/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java +++ b/core/src/main/java/art/arcane/iris/engine/UpperDimensionContext.java @@ -66,8 +66,8 @@ public class UpperDimensionContext implements DataProvider { engine.getDimension(), engine.getData(), chunkHeight, - complex.getHeightStream(), - complex.getTrueBiomeStream(), + complex.getNaturalHeightStream(), + complex.getNaturalTrueBiomeStream(), complex.getRegionStream(), complex.getRockStream(), complex.getImageMapRuntime(), @@ -99,12 +99,12 @@ public class UpperDimensionContext implements DataProvider { upperDim.getRegions().forEach(regionKey -> { IrisRegion region = upperData.getRegionLoader().load(regionKey); if (region != null) { - region.getAllBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( + region.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( biome, dataProvider, allBiomes, generators)); } }); for (IrisRegion mappedRegion : imageMapRuntime.getMappedRegions()) { - mappedRegion.getAllBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( + mappedRegion.getNaturalBiomes(dataProvider).forEach(biome -> registerBiomeGenerators( biome, dataProvider, allBiomes, generators)); } for (IrisBiome mappedBiome : imageMapRuntime.getMappedBiomes()) { 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 15b3ed91b..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,6 +26,9 @@ 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.util.common.data.B; import art.arcane.iris.util.project.context.ChunkContext; @@ -50,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 @Override public void onActuate(int x, int z, Hunk output, boolean multicore, ChunkContext context) { @@ -72,7 +153,6 @@ public class IrisDecorantActuator extends EngineAssignedActuator biomeCache = context.getBiome(); ChunkedDataCache regionCache = context.getRegion(); - ChunkedDataCache fluidCache = context.getFluid(); ChunkedDataCache rockCache = context.getRock(); int realX = xf + x; UpperDimensionContext upperContext = getEngine().getUpperContext(); @@ -104,19 +105,47 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator dimensionUndergroundOres = hideOres ? null : dimension.getUndergroundOreGenerators(); IrisOreGeneratorBounds dimensionSurfaceOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getSurfaceOreGeneratorBounds(); IrisOreGeneratorBounds dimensionUndergroundOreBounds = hideOres ? IrisOreGeneratorBounds.EMPTY : dimension.getUndergroundOreGeneratorBounds(); + boolean exposeCutStrata = dimension.getHydrology() != null + && dimension.getHydrology().getRivers().getSurface().getBanks().isExposeCutStrata(); + IrisSurfaceRiverBedConfig riverBed = dimension.getHydrology() == null + ? null + : dimension.getHydrology().getRivers().getSurface().getBed(); + boolean padRiverBed = riverBed != null && !riverBed.isAllowGravityBlocks(); for (int zf = 0; zf < chunkDepth; zf++) { int realZ = zf + z; IrisBiome biome = biomeCache.get(xf, zf); IrisRegion region = regionCache.get(xf, zf); int he = Math.min(chunkHeight, context.getRoundedHeight(xf, zf)); - int hf = Math.max(clampedFluidHeight, he); + int surfaceFluidHeight = Math.min( + chunkHeight, + (int) Math.round(complex.getRiverWaterSurfaceStream().get(realX, realZ)) + ); + int hf = Math.max(surfaceFluidHeight, he); if (hf < 0) { continue; } int topY = Math.min(hf, chunkHeight - 1); - PlatformBlockState fluid = fluidCache.get(xf, zf); + HydrologyColumnSample hydrology = complex.getHydrologyRuntime() == null + ? null + : complex.getHydrologyRuntime().sample(realX, realZ).orElse(null); + HydrologyColumnLayer hydrologyFluid = hydrology == null + ? null + : hydrology.primarySurfaceFluidLayer().orElse(null); + HydrologyColumnLayer hydrologyTerrain = hydrology == null + ? null + : hydrology.primarySurfaceLayer().orElse(null); + int cut = exposeCutStrata + && hydrologyTerrain != null + && hydrologyTerrain.terrainOwned() + && !hydrologyTerrain.channel() + ? Math.max(0, hydrology.naturalHeight() - he) + : 0; + boolean riverOwned = padRiverBed && hydrologyTerrain != null && hydrologyTerrain.terrainOwned(); + PlatformBlockState fluid = hydrologyFluid == null + ? complex.resolveSurfaceFluid(realX, realZ) + : complex.resolveHydrologyFluid(hydrologyFluid.profileKey(), realX, realZ); PlatformBlockState rock = rockCache.get(xf, zf); PlatformBlockState mappedSurfaceBlock = complex.getImageMapRuntime().sampleSurfaceBlock(realX, realZ); KList biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators(); @@ -158,15 +187,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; } @@ -177,11 +206,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator data, IrisBiome biome, int height, int max) { - if (height != getDimension().getFluidHeight()) { + double localFluidHeight = getComplex().getRiverWaterSurfaceStream().get(realX, realZ); + if (height != Math.round(localFluidHeight)) { return; } - double complexFluidHeight = getComplex().getFluidHeight(); ProceduralStream heightStream = getComplex().getHeightStream(); - if (Math.round(heightStream.get(realX1, realZ)) >= complexFluidHeight - && Math.round(heightStream.get(realX_1, realZ)) >= complexFluidHeight - && Math.round(heightStream.get(realX, realZ1)) >= complexFluidHeight - && Math.round(heightStream.get(realX, realZ_1)) >= complexFluidHeight) { + ProceduralStream fluidStream = getComplex().getRiverWaterSurfaceStream(); + if (Math.round(heightStream.get(realX1, realZ)) >= Math.round(fluidStream.get(realX1, realZ)) + && Math.round(heightStream.get(realX_1, realZ)) >= Math.round(fluidStream.get(realX_1, realZ)) + && Math.round(heightStream.get(realX, realZ1)) >= Math.round(fluidStream.get(realX, realZ1)) + && Math.round(heightStream.get(realX, realZ_1)) >= Math.round(fluidStream.get(realX, realZ_1))) { 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); @@ -70,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() @@ -79,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; } @@ -99,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; } @@ -122,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 225567af6..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; @@ -58,12 +60,15 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator { @BlockCoordinates public void decorate(int x, int z, int realX, int realX1, int realX_1, int realZ, int realZ1, int realZ_1, Hunk data, IrisBiome biome, InferredType inferredType, int height, int max) { - int fluidHeight = getDimension().getFluidHeight(); + int fluidHeight = (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(realX, realZ)); if (inferredType == InferredType.SHORE && height < fluidHeight) { return; } 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 6f2adc84f..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,6 +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.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; @@ -244,6 +246,14 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer, @BlockCoordinates default IrisBiome getCaveOrMantleBiome(int x, int y, int z) { + HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent( + getMantle().getMantle(), x, y, z); + if (hydrology != null && !hydrology.floodedBiomeKey().isEmpty()) { + IrisBiome biome = getData().getBiomeLoader().load(hydrology.floodedBiomeKey()); + if (biome != null) { + return biome; + } + } MatterCavern m = getMantle().getMantle().get(x, y, z, MatterCavern.class); if (m != null && m.getCustomBiome() != null && !m.getCustomBiome().isEmpty()) { @@ -445,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; } @@ -552,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/NativeStructurePlacementPlanner.java b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java index 153da99d4..ed44a5490 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlanner.java @@ -157,7 +157,10 @@ public final class NativeStructurePlacementPlanner { } static boolean isSubmerged(Engine engine, int blockX, int blockZ) { - return engine.getHeight(blockX, blockZ, true) < engine.getDimension().getFluidHeight(); + int localFluidHeight = engine.getComplex() == null + ? engine.getDimension().getFluidHeight() + : (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ)); + return engine.getHeight(blockX, blockZ, true) < localFluidHeight; } private static int comparePlacementPriority(IrisStructurePlacement left, IrisStructurePlacement right) { 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 2c120b114..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,6 +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.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; @@ -286,10 +288,12 @@ public final class StructureCaveAnchorResolver { int mantleY, int blockZ ) { + HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ); return acceptsAnchorFluid( placement.isUnderwater(), cavern, + hydrology, mantleY, engine.getDimension().getCaveLavaHeight()); } @@ -300,6 +304,19 @@ public final class StructureCaveAnchorResolver { int mantleY, int defaultLavaHeight ) { + return acceptsAnchorFluid(underwater, cavern, null, mantleY, defaultLavaHeight); + } + + static boolean acceptsAnchorFluid( + boolean underwater, + MatterCavern cavern, + HydrologyCaveCell hydrology, + int mantleY, + int defaultLavaHeight + ) { + if (hydrology != null && hydrology.protectsPlacement()) { + return false; + } if (cavern == null || !cavern.isCavern()) { return false; } @@ -313,7 +330,15 @@ public final class StructureCaveAnchorResolver { } private static MatterCavern cavernAt(Engine engine, int blockX, int mantleY, int blockZ) { - return engine.getMantle().getMantle().get(blockX, mantleY, blockZ, MatterCavern.class); + MatterCavern baseline = engine.getMantle().getMantle() + .get(blockX, mantleY, blockZ, MatterCavern.class); + HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); + return hydrology == null ? baseline : hydrology.asCavern(); + } + + private static HydrologyCaveCell hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) { + return HydrologyCaveStorage.getIfPresent( + engine.getMantle().getMantle(), blockX, mantleY, blockZ); } static int toMantleY(int worldY, int worldMinHeight) { diff --git a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java index 0d706e139..f49a0a676 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/placer/WorldObjectPlacer.java @@ -21,7 +21,6 @@ import art.arcane.iris.util.common.scheduling.J; import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.util.common.data.IrisCustomData; import art.arcane.volmlib.util.math.RNG; -import art.arcane.volmlib.util.matter.MatterCavern; import lombok.EqualsAndHashCode; import lombok.Getter; import org.bukkit.Bukkit; @@ -112,7 +111,7 @@ public class WorldObjectPlacer implements IObjectPlacer { @Override public boolean isCarved(int x, int y, int z) { - return mantle.getMantle().get(x, y, z, MatterCavern.class) != null; + return mantle.isCarved(x, y, z); } @Override 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 346626c5c..1ddd68c40 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,6 +20,13 @@ 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; @@ -29,9 +36,13 @@ 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; @@ -40,6 +51,24 @@ 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_WATERFALL = new Color(190, 236, 255).getRGB(); + private static final int CONFIRMED_DEEP_LAVA = new Color(194, 50, 22).getRGB(); + private static final int STANDING_POOL = new Color(232, 120, 40).getRGB(); + private static final int PROJECTED_SOURCE = new Color(225, 75, 178).getRGB(); + private static final int PROJECTED_OUTLET = new Color(235, 145, 54).getRGB(); + private static final int PROJECTED_DEEP_FLUID = new Color(175, 70, 118).getRGB(); + 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(); private static final int LOWLAND = new Color(78, 128, 76).getRGB(); @@ -105,9 +134,17 @@ public final class IrisRenderer { renderHeightAtlas(pixels, resolution, sx, sz, step, renderer, cancelled); return image; } - PixelShader shader = shader(currentType, studio); + PixelShader shader = shader(currentType, step, studio); + if (studio && currentType == RenderType.RIVER) { + Arrays.fill(pixels, NO_RIVER); + renderRiverAtlas(pixels, resolution, sx, sz, step, renderer.getComplex(), cancelled, false); + return image; + } if (studio && adaptiveStudioType(currentType)) { renderAdaptiveAtlas(pixels, resolution, sx, sz, step, shader, cancelled); + if (currentType == RenderType.BIOME) { + renderRiverAtlas(pixels, resolution, sx, sz, step, renderer.getComplex(), cancelled, true); + } return image; } int groupSize = sampleGroup(step, resolution); @@ -133,6 +170,57 @@ public final class IrisRenderer { return image; } + 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 DEEP_POOL, DEEP_CHANNEL -> CONFIRMED_DEEP_LAVA; + case STANDING_POOL -> STANDING_POOL; + }; + } + + 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, POOL -> PROJECTED_DEEP_FLUID; + }; + } + public static int heightColor(double height, double maximumHeight, double fluidHeight) { double boundedMaximum = Math.max(1D, maximumHeight); double boundedHeight = clamp(height, 0D, boundedMaximum); @@ -160,7 +248,7 @@ public final class IrisRenderer { return Math.max(1, Math.min(resolution, (int) Math.floor(16D / absoluteStep))); } - private PixelShader shader(RenderType currentType, boolean studio) { + private PixelShader shader(RenderType currentType, double step, boolean studio) { IrisComplex complex = renderer.getComplex(); return switch (currentType) { case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD -> biomeShader( @@ -169,7 +257,8 @@ public final class IrisRenderer { case BIOME_SEA -> biomeShader(complex.getSeaBiomeStream(), currentType); case REGION -> regionShader(complex, currentType); case CAVE_LAND -> biomeShader(complex.getCaveBiomeStream(), currentType); - case HEIGHT -> heightShader(complex.getHeightStream()); + case HEIGHT -> heightShader(studio ? complex.getNaturalHeightStream() : complex.getHeightStream()); + case RIVER -> (double x, double z) -> riverColor(complex, x, z, step); case CONTINENT -> studio ? continentShader(complex.getBaseBiomeStream()) : this::continentColor; @@ -180,7 +269,7 @@ public final class IrisRenderer { return switch (type) { case BIOME, DECORATOR_LOAD, OBJECT_LOAD, LAYER_LOAD, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, CONTINENT -> true; - case HEIGHT -> false; + case HEIGHT, RIVER -> false; }; } @@ -224,7 +313,7 @@ public final class IrisRenderer { startX, startZ, step, - complex.getHeightStream(), + complex.getNaturalHeightStream(), engine.getHeight(), fluidHeight, cancelled @@ -237,6 +326,212 @@ public final class IrisRenderer { } } + private static void renderRiverAtlas( + int[] pixels, + int resolution, + double startX, + double startZ, + double step, + IrisComplex complex, + BooleanSupplier cancelled, + boolean composite + ) { + 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) { + int height = Math.min(blockPixels, resolution - pixelZ); + for (int pixelX = 0; pixelX < resolution; pixelX += blockPixels) { + renderRiverBlock( + pixels, + resolution, + startX, + startZ, + step, + runtime, + cancelled, + composite, + !composite, + headwaters, + pixelX, + pixelZ, + Math.min(blockPixels, resolution - pixelX), + height + ); + } + } + renderHeadwaterDirections(pixels, resolution, startX, startZ, step, headwaters); + } + + private static void renderRiverBlock( + int[] pixels, + int resolution, + double startX, + double startZ, + double step, + IrisHydrologyRuntime runtime, + BooleanSupplier cancelled, + boolean composite, + boolean diagnostics, + Map headwaters, + int pixelX, + int pixelZ, + int width, + int height + ) { + checkCancelled(cancelled); + HydrologyRenderSample sample = runtime.sampleRenderFootprint( + startX + pixelX * step, + startZ + pixelZ * step, + startX + (pixelX + width) * step, + startZ + (pixelZ + height) * step + ); + 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 = 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, diagnostics, + headwaters, + pixelX, pixelZ, leftWidth, topHeight); + if (rightWidth > 0) { + 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, diagnostics, + headwaters, + pixelX, pixelZ + topHeight, leftWidth, bottomHeight); + if (rightWidth > 0) { + renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics, + headwaters, + pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight); + } + } + } + + 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) { IdentityHashMap colors = new IdentityHashMap<>(); return (double x, double z) -> { @@ -271,6 +566,50 @@ public final class IrisRenderer { return (double x, double z) -> heightColor(stream.getDouble(x, z), maximumHeight, fluidHeight); } + private int riverColor(IrisComplex complex, double x, double z, double step) { + IrisHydrologyRuntime runtime = complex.getHydrologyRuntime(); + if (runtime == null) { + return NO_RIVER; + } + double endX = x + step; + double endZ = z + step; + HydrologyRenderSample sample = runtime.sampleRenderFootprint( + StrictMath.min(x, endX), + StrictMath.min(z, endZ), + StrictMath.max(x, endX), + StrictMath.max(z, endZ) + ); + 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) { IrisBiome biome = renderer.getBiome( (int) Math.round(x), diff --git a/core/src/main/java/art/arcane/iris/engine/framework/render/RenderType.java b/core/src/main/java/art/arcane/iris/engine/framework/render/RenderType.java index 2ec4a17d1..50b79ce47 100644 --- a/core/src/main/java/art/arcane/iris/engine/framework/render/RenderType.java +++ b/core/src/main/java/art/arcane/iris/engine/framework/render/RenderType.java @@ -19,5 +19,5 @@ package art.arcane.iris.engine.framework.render; public enum RenderType { - BIOME, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, HEIGHT, OBJECT_LOAD, DECORATOR_LOAD, CONTINENT, LAYER_LOAD + BIOME, BIOME_LAND, BIOME_SEA, REGION, CAVE_LAND, RIVER, HEIGHT, OBJECT_LOAD, DECORATOR_LOAD, CONTINENT, LAYER_LOAD } 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..e6dd9330f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateKind.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.hydrology; + +public enum HydrologyCandidateKind { + SOURCE, + OUTLET, + DEEP_FLUID, + POOL +} 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..2cf36b17a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyCandidateRejection.java @@ -0,0 +1,24 @@ +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, + OUTLET_LEVEL, + CAVE_CONTAINMENT, + VOLUME_LIMIT, + SURFACE_HEAD_RANGE, + SURFACE_HEAD_DISTRIBUTION, + SURFACE_EXPOSURE, + SURFACE_DROP_UNSUPPORTED, + SURFACE_CORRIDOR_UNSUPPORTED, + SURFACE_TRANSITION_UNSUPPORTED, + SURFACE_SEGMENT_UNSUPPORTED, + SURFACE_SINKHOLE_CLEARANCE, + SURFACE_SHAPE_UNSUPPORTED, + POLICY_EXCLUDED; +} 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..0e2802747 --- /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, 0 + )); + } + + 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, 0 + ); + } + + 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..f4ea58aaf --- /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 bankBiomeKey, + String floodedCaveBiomeKey +) { + public HydrologyColumnLayer { + Objects.requireNonNull(feature, "feature"); + profileKey = requireKey(profileKey, "profileKey"); + surfaceBiomeKey = requireKey(surfaceBiomeKey, "surfaceBiomeKey"); + mouthBiomeKey = requireKey(mouthBiomeKey, "mouthBiomeKey"); + shoreBiomeKey = requireKey(shoreBiomeKey, "shoreBiomeKey"); + bankBiomeKey = requireKey(bankBiomeKey, "bankBiomeKey"); + 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 bankBiomeKey; + } + 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; + } + + /** Every owned channel water column is published so steps between stations receive fluid updates. */ + public boolean publishesSurfaceFluid() { + return feature.type().isSurface() + && !oceanApron + && channel + && fluidOwned + && (terrainOwned || fallingFluid) + && 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..b1e8e7296 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyColumnSample.java @@ -0,0 +1,193 @@ +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." + ); + } + } + } + } + + 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..e6ff475e8 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyDiagnosticCandidate.java @@ -0,0 +1,19 @@ +package art.arcane.iris.engine.hydrology; + +import java.util.Objects; + +public record HydrologyDiagnosticCandidate( + long id, + HydrologyCandidateKind kind, + HydrologyFeatureType projectedType, + HydrologyPoint point, + HydrologyCandidateRejection rejection, + int detail +) { + 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..cdb5bded3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureQuery.java @@ -0,0 +1,100 @@ +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.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 Set PROFILED = Set.of( + HydrologyFeatureType.DEEP_POOL, + HydrologyFeatureType.DEEP_CHANNEL, + HydrologyFeatureType.STANDING_POOL + ); + 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(PROFILED, 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("deep", DEEP); + builtIns.put("pool", Set.of(HydrologyFeatureType.STANDING_POOL)); + 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..c285d2f62 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFeatureType.java @@ -0,0 +1,67 @@ +package art.arcane.iris.engine.hydrology; + +public enum HydrologyFeatureType { + SURFACE_POOL, + RIFFLE, + CASCADE, + WATERFALL, + // No planner produces a ridge bore any more. The constant stays because its ordinal seeds the + // segment, outlet and feature ids of every constant after it on the cave and deep-fluid paths. + RIDGE_BORE, + UNDERGROUND_POOL, + UNDERGROUND_DROP, + SINKHOLE, + COASTAL_GROTTO, + INLAND_GROTTO, + MOUTH, + DEEP_POOL, + DEEP_CHANNEL, + // A standing surface pool (lava or any fluid) with no course: a bowl cut into the ground with a lip. + STANDING_POOL; + + public boolean isSurface() { + return switch (this) { + case SURFACE_POOL, RIFFLE, CASCADE, WATERFALL, MOUTH, STANDING_POOL -> 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; + case STANDING_POOL -> 13; + }; + } +} 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..d4534035b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompiler.java @@ -0,0 +1,2233 @@ +package art.arcane.iris.engine.hydrology; + +import art.arcane.iris.engine.hydrology.surface.SurfaceFootprint; +import art.arcane.iris.engine.hydrology.surface.SurfaceFootprintCompiler; +import art.arcane.iris.engine.hydrology.surface.SurfaceLayerColumn; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +final class HydrologyFootprintCompiler { + 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[][] HORIZONTAL_NEIGHBORS = { + {1, 0}, {-1, 0}, {0, 1}, {0, -1} + }; + 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 final SurfaceFootprintCompiler surfaceCompiler; + private final LinkedHashMap surfaceFootprints; + private int fullMaterializationCount; + + HydrologyFootprintCompiler( + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler + ) { + this(settings, sampler, HydrologyGeometrySampler.deterministic(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.surfaceCompiler = new SurfaceFootprintCompiler(settings, this::sampleTerrainBasis, this.geometrySampler); + this.surfaceFootprints = new LinkedHashMap<>(COURSE_FOOTPRINT_CACHE_SIZE, 1F, true); + this.fullMaterializationCount = 0; + } + + SurfaceFootprint surfaceFootprint(RiverCourse course) { + CourseRasterKey rasterKey = new CourseRasterKey( + course.id(), + course.type(), + course.profileKey(), + course.segments() + ); + SurfaceFootprint cached = surfaceFootprints.get(rasterKey); + if (cached != null) { + return cached; + } + SurfaceFootprint compiled = surfaceCompiler.compile(course); + surfaceFootprints.put(rasterKey, compiled); + if (surfaceFootprints.size() > COURSE_FOOTPRINT_CACHE_SIZE) { + surfaceFootprints.remove(surfaceFootprints.sequencedKeySet().getFirst()); + } + return compiled; + } + + 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); + } + } + 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 surfaces = new ArrayList<>(); + Long2ObjectOpenHashMap firstSamples = new Long2ObjectOpenHashMap<>(); + for (RiverCourse course : courses) { + ValidationCourseRaster raster = compileValidationCourse(course); + courseRasters.add(raster); + surfaces.add(raster.surface()); + 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(surfaces, firstSamples) + ); + } + + int fullMaterializationCount() { + return fullMaterializationCount; + } + + 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<>(); + SurfaceFootprint surface = surfaceFootprint(course); + for (SurfaceLayerColumn column : surface.columns()) { + addLayer(columns, column.x(), column.z(), column.terrain(), column.layer()); + } + SurfaceRasterIndex courseSurface = new SurfaceRasterIndex(List.of(surface), new Long2ObjectOpenHashMap<>()); + 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), + surface + ); + 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<>(); + SurfaceFootprint surface = surfaceFootprint(course); + for (SurfaceLayerColumn column : surface.columns()) { + addLayer(columns, column.x(), column.z(), column.terrain(), column.layer()); + } + SurfaceRasterIndex courseSurface = new SurfaceRasterIndex(List.of(surface), new Long2ObjectOpenHashMap<>()); + for (int segmentIndex = 0; segmentIndex < course.segments().size(); segmentIndex++) { + HydraulicSegment segment = course.segments().get(segmentIndex); + if (SurfaceFootprintCompiler.exposedSegment(segment)) { + 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 double smoothStep(double progress) { + return progress * progress * (3D - 2D * progress); + } + + 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.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().banks().minimumBlendWidth(), + settings.surface().banks().maximumBlendWidth() + ); + if (type == HydrologyFeatureType.WATERFALL) { + shoreWidth = Math.min(2.5D, shoreWidth); + gradingWidth = Math.min(gradingWidth, Math.max(8D, channelRadius * 4D)); + } + } + 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().banks().maximumBlendWidth(), 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.bankBiomeKey(), + 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.bankBiomeKey(), + terrain.floodedCaveBiomeKey() + ); + } + + 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; + } + builder.add(deltaX, deltaZ, distance); + } + } + 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.bankBiomeKey(), + 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.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) { + 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 CenterlineProjection( + int pointIndex, + double x, + double z, + double distance, + int flowX, + int flowZ + ) { + } + + private record ValidationCourseRaster( + long courseId, + List columns, + SurfaceFootprint surface + ) { + } + + 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 + ) { + private int size() { + return deltaXs.length; + } + } + + private static final class RasterStencilBuilder { + private int[] deltaXs; + private int[] deltaZs; + private double[] distances; + private int size; + + private RasterStencilBuilder() { + this.deltaXs = new int[64]; + this.deltaZs = new int[64]; + this.distances = new double[64]; + } + + private void add(int deltaX, int deltaZ, double distance) { + 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); + } + deltaXs[size] = deltaX; + deltaZs[size] = deltaZ; + distances[size] = distance; + size++; + } + + private RasterStencil build() { + return new RasterStencil( + Arrays.copyOf(deltaXs, size), + Arrays.copyOf(deltaZs, size), + Arrays.copyOf(distances, 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 surfaceColumns; + private final Long2IntOpenHashMap validationNaturalHeights; + + private SurfaceRasterIndex( + List footprints, + Long2ObjectOpenHashMap validationSamples + ) { + Long2ObjectLinkedOpenHashMap merged = new Long2ObjectLinkedOpenHashMap<>(); + for (SurfaceFootprint footprint : footprints) { + for (SurfaceLayerColumn column : footprint.columns()) { + addLayer(merged, column.x(), column.z(), column.terrain(), column.layer()); + } + } + this.surfaceColumns = new Long2ObjectOpenHashMap<>(merged.size()); + for (Long2ObjectMap.Entry entry : merged.long2ObjectEntrySet()) { + surfaceColumns.put(entry.getLongKey(), entry.getValue().build()); + } + this.validationNaturalHeights = new Long2IntOpenHashMap(validationSamples.size()); + for (HydrologyColumnSample sample : validationSamples.values()) { + validationNaturalHeights.put( + RiverFootprint.pack(sample.x(), sample.z()), + sample.naturalHeight() + ); + } + } + + @Override + public int resolve(int x, int z, int naturalHeight) { + long packed = RiverFootprint.pack(x, z); + HydrologyColumnSample sample = surfaceColumns.get(packed); + if (sample != null) { + return sample.terrainHeight(); + } + if (validationNaturalHeights.containsKey(packed)) { + return validationNaturalHeights.get(packed); + } + return naturalHeight; + } + + private HydrologyColumnSample surfaceColumnAt(int x, int z, int naturalHeight) { + HydrologyColumnSample sample = surfaceColumns.get(RiverFootprint.pack(x, z)); + if (sample != null && sample.naturalHeight() != naturalHeight) { + throw new IllegalStateException("Hydrology surface and cave rasters disagree on natural terrain at " + + x + "," + z + "."); + } + return sample; + } + + private boolean ownsSurfaceChannelAt(int x, int z, long courseId) { + HydrologyColumnSample sample = surfaceColumns.get(RiverFootprint.pack(x, z)); + if (sample == null) { + return false; + } + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().courseId() == courseId + && layer.feature().type().isSurface() + && layer.channel() + && layer.terrainOwned()) { + return true; + } + } + return false; + } + } + + 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 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.bankBiomeKey(), + 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..d0c758ca5 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyGeometrySampler.java @@ -0,0 +1,104 @@ +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(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.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 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_BLEND_WIDTH, + 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..8000a676f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyHash.java @@ -0,0 +1,43 @@ +package art.arcane.iris.engine.hydrology; + +public final class HydrologyHash { + private HydrologyHash() { + } + + public static long mix(long seed, long... values) { + long mixed = avalanche(seed ^ 0x9e3779b97f4a7c15L); + for (long value : values) { + mixed = avalanche(mixed ^ avalanche(value + 0x9e3779b97f4a7c15L)); + } + return mixed; + } + + public 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); + } + + public static double unit(long value) { + return (avalanche(value) >>> 11) * 0x1.0p-53; + } + + public 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..ea0aa21cd --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -0,0 +1,7777 @@ +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.engine.hydrology.surface.ChannelProfile; +import art.arcane.iris.engine.hydrology.surface.ValleyProfile; +import art.arcane.iris.engine.hydrology.surface.ValleyProfileSolver; +import art.arcane.iris.engine.hydrology.surface.SurfaceCenterline; +import art.arcane.iris.engine.hydrology.surface.SurfaceCourseBuilder; +import art.arcane.iris.engine.hydrology.surface.SurfaceCourseResult; +import art.arcane.iris.engine.hydrology.surface.SurfaceTerminal; +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.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.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 double CROSS_DROP_WEIGHT = 3D; + 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 SURFACE_POOL_SALT = 0x504f4f4cL; + private static final long DEEP_FLUID_X_OFFSET_SALT = 0x44454550584fL; + private static final long DEEP_FLUID_Z_OFFSET_SALT = 0x444545505a4fL; + private static final long DEEP_CHANNEL_HEADING_SALT = 0x44454550484447L; + 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 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_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; + private final SurfaceCourseBuilder surfaceCourseBuilder; + + public HydrologyPlanner(long worldSeed, HydrologyPlannerSettings settings, HydrologyTerrainSampler sampler) { + this( + worldSeed, + settings, + sampler, + HydrologyGeometrySampler.deterministic(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(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(sampler), + Integer.MIN_VALUE, + footprint -> caveView + ); + } + + public HydrologyPlanner( + long worldSeed, + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler, + int minimumY, + HydrologyCaveVoxelViewFactory caveViewFactory + ) { + this( + worldSeed, + settings, + sampler, + HydrologyGeometrySampler.deterministic(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)); + this.surfaceCourseBuilder = new SurfaceCourseBuilder( + settings.surface(), + this::sampleBasis, + geometrySampler, + settings.seaLevel(), + settings.routing().minimumSurfaceCourseLength() + ); + } + + 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++); + RoutingPlan fallbackRouting = requireOrganicSurface(buildRouting(grid, List.of(fallbackOutlet), true)); + 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 (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()); + 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 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, diagnostics); + List normalizedOutletCourses = normalizeOutletContinuations(normalizedTrunkCourses); + courses.clear(); + courses.addAll(normalizedOutletCourses); + if (includeDeepFluids) { + compileDeepFluidCourses(key, courses, diagnostics); + } + compileSurfacePools(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 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, List diagnostics) { + 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(); + if (course.type() == RiverCourseType.SURFACE) { + RiverCourse owner = edgeOwners.get(new CourseEdgeKey(course.type(), course.drainageEdges().get(ownedEdgeCount).id())); + RiverCourse tributary = surfaceTributary(course, owner, boundary, ownedEdgeCount, diagnostics); + if (tributary != null) { + normalized.add(tributary); + } + continue; + } + 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); + } + + /** + * Turns a surface course that lost its shared drainage to a longer trunk into a tributary: it is cut + * where its own centerline first comes within a channel width of the stem and joined to the nearest + * stem station with a short connector. The tributary must still be a real reach and must arrive at + * or above the stem's water, so it steps down into the river instead of pooling below it. + */ + private RiverCourse surfaceTributary( + RiverCourse course, + RiverCourse owner, + HydrologyPoint boundary, + int ownedEdgeCount, + List diagnostics + ) { + ArrayList stemStations = new ArrayList<>(); + ArrayList stemWidths = new ArrayList<>(); + for (HydraulicSegment segment : owner.segments()) { + for (HydrologyPoint point : segment.centerline()) { + stemStations.add(point); + stemWidths.add(segment.width()); + } + } + ArrayList retained = new ArrayList<>(); + int stations = 0; + HydrologyPoint joinStation = null; + for (HydraulicSegment segment : course.segments()) { + List centerline = segment.centerline(); + int joinIndex = -1; + for (int index = 0; index < centerline.size() && joinIndex < 0; index++) { + HydrologyPoint point = centerline.get(index); + for (int stemIndex = 0; stemIndex < stemStations.size(); stemIndex++) { + HydrologyPoint station = stemStations.get(stemIndex); + double reach = (segment.width() + stemWidths.get(stemIndex)) / 2D + 1D; + if (point.distanceSquared2D(station) <= reach * reach) { + joinIndex = index; + joinStation = station; + break; + } + } + } + if (joinIndex < 0) { + retained.add(segment); + stations += centerline.size(); + continue; + } + if (joinIndex > 0 || retained.isEmpty()) { + List prefix = centerline.subList(0, joinIndex + 1); + int downstreamHead = prefix.getLast().y(); + int drop = segment.upstreamHeadY() - downstreamHead; + HydrologyFeatureType type = drop == 0 && segment.type().isDrop() + ? HydrologyFeatureType.SURFACE_POOL + : segment.type(); + retained.add(new HydraulicSegment( + segment.id(), + segment.courseId(), + type, + segment.upstreamHeadY(), + downstreamHead, + segment.width(), + segment.depth(), + false, + false, + prefix + )); + stations += prefix.size(); + } + break; + } + if (joinStation == null) { + addTributaryDiagnostic(course, HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0, diagnostics); + return null; + } + if (retained.isEmpty() || stations < settings.routing().minimumSurfaceCourseLength() / 2) { + addTributaryDiagnostic(course, HydrologyCandidateRejection.COURSE_TOO_SHORT, stations, diagnostics); + return null; + } + HydraulicSegment last = retained.getLast(); + HydrologyPoint end = last.end(); + int stemHead = joinStation.y(); + if (last.downstreamHeadY() < stemHead) { + addTributaryDiagnostic(course, HydrologyCandidateRejection.SURFACE_HEAD_RANGE, + stemHead - last.downstreamHeadY(), diagnostics); + return null; + } + if (end.x() != joinStation.x() || end.z() != joinStation.z()) { + int drop = last.downstreamHeadY() - stemHead; + HydrologyFeatureType type = drop == 0 + ? HydrologyFeatureType.SURFACE_POOL + : drop >= settings.hydraulics().waterfallMinimumDrop() + ? HydrologyFeatureType.WATERFALL + : drop == 1 ? HydrologyFeatureType.RIFFLE : HydrologyFeatureType.CASCADE; + retained.add(new HydraulicSegment( + HydrologyHash.mix(course.id(), SEGMENT_SALT, 0x4a4f494eL), + course.id(), + type, + last.downstreamHeadY(), + stemHead, + last.width(), + last.depth(), + false, + false, + List.of(end, new HydrologyPoint(joinStation.x(), stemHead, joinStation.z())) + )); + } + return new RiverCourse( + course.id(), + course.type(), + course.sourceNodeId(), + course.outletId(), + course.profileKey(), + course.discharge(), + course.drainageEdges().subList(0, ownedEdgeCount), + retained + ); + } + + private void addTributaryDiagnostic( + RiverCourse course, + HydrologyCandidateRejection rejection, + int detail, + List diagnostics + ) { + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(course.id(), DIAGNOSTIC_SALT, rejection.ordinal(), 0x5452494255544152L), + HydrologyCandidateKind.SOURCE, + HydrologyFeatureType.SURFACE_POOL, + course.segments().getFirst().start(), + rejection, + detail + )); + } + + 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, surface); + ArrayList inlandCandidates = inlandOutletCandidates(grid, oceanReachable); + sortInlandOutletCandidates(grid, inlandCandidates); + int inlandMaximum = oceanCandidates.isEmpty() + ? maximum + : Math.min(Math.max(1, maximum / 4), maximum - 1); + List selectedInland = styledInlandOutlets( + grid, + limitOutlets(inlandCandidates, inlandMaximum), + 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().banks().inset() <= 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 surface) { + int maximumRise = surface ? maximumSurfaceEdgeRise() : Integer.MAX_VALUE; + 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() + || node.terrain().naturalHeight() - neighbor.terrain().naturalHeight() > maximumRise) { + 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().banks().inset() - 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().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, 0 + )); + } + } + } + + private RoutingPlan buildRouting( + SampledGrid grid, + List outlets, + boolean surface + ) { + // A surface river never climbs: any rise along its route becomes a cut of at least that rise, + // so lattice edges that rise more than a small saddle are not drainage at all. + int maximumRise = surface ? maximumSurfaceEdgeRise() : Integer.MAX_VALUE; + 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)); + } + 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; + } + if (downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight() > maximumRise) { + 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() + )); + } + } + return new RoutingPlan( + potential, + parent, + outletIndex, + routeLengths(grid, parent), + List.copyOf(outlets), + false + ); + } + + /** + * Largest rise a surface drainage edge may take. Heads only fall, so a rise along a route becomes a cut + * of at least that rise; the lattice may climb no more than the cut the valley solver still accepts. + */ + private int maximumSurfaceEdgeRise() { + int permitted = settings.surface().maximumIncision() - settings.surface().banks().inset() - settings.surface().minimumDepth(); + return Math.max(1, permitted); + } + + private RoutingPlan requireOrganicSurface(RoutingPlan routing) { + return new RoutingPlan( + routing.potential(), + routing.parent(), + routing.outletIndex(), + routing.routeLengths(), + routing.outlets(), + true + ); + } + + 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 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().minimumCourseLength(surface)) { + addSourceDiagnostic(node, surface, stable, HydrologyCandidateRejection.COURSE_TOO_SHORT, diagnostics); + continue; + } + // Elevation and route length share the same scale: 1000 route blocks count as 100 blocks of height. + double score = (required ? 1.0E15D : 0D) + + weight * 1.0E9D + + (terrain.naturalHeight() + routeLength * 0.1D * settings.routing().lengthPreference()) * 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); + int coursesPerOutlet = surface ? 1 + settings.routing().tributaries() : Integer.MAX_VALUE; + target = effectiveSourceTarget(surface && !enforceGlobalSpacing, target, routing.outlets().size(), coursesPerOutlet); + Comparator candidateOrder = Comparator + .comparing(SourceCandidate::required) + .reversed(); + candidates.sort(candidateOrder + .thenComparing(Comparator.comparingDouble(SourceCandidate::score).reversed()) + .thenComparingLong(SourceCandidate::stableId)); + prioritizeInlandSource(candidates, routing); + int guaranteed = Math.min(target, requiredMinimum); + int maximumCoursesPerOutlet = coursesPerOutlet; + 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) { + return effectiveSourceTarget(outletBounded, requestedTarget, outletCount, 1); + } + + static int effectiveSourceTarget(boolean outletBounded, int requestedTarget, int outletCount, int coursesPerOutlet) { + if (requestedTarget < 0 || outletCount < 0 || coursesPerOutlet < 1) { + throw new IllegalArgumentException("Source target bounds cannot be negative."); + } + long bound = (long) outletCount * coursesPerOutlet; + return outletBounded ? (int) Math.min(requestedTarget, bound) : 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, true); + List undergroundOutlets = settings.underground().enabled() + ? resolveOutlets(key, grid, false, diagnostics) + : surfaceOutlets; + RoutingPlan undergroundRouting = settings.underground().enabled() + ? buildRouting(grid, undergroundOutlets, false) + : 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().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, 0 + )); + } + + 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 + ); + centerline = refinedEdgeCache.get( + refinedEdgeKey, + ignored -> refineEdge( + upstream.id(), + downstream.id(), + upstreamAnchor, + downstreamAnchor, + continuation, + transverseCandidates + ) + ); + 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 + ); + centerline = refinedEdgeCache.get( + refinedEdgeKey, + ignored -> refineEdge( + upstream.id(), + downstream.id(), + upstreamAnchor, + downstreamAnchor, + continuation, + ROUTE_FALLBACK_TRANSVERSE_CANDIDATES + ) + ); + refinedEdges.put(edgeId, centerline); + } + if (centerline.isEmpty()) { + 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 + ) { + int refinement = 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 ? 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 ? 0D : 1D + ); + List candidates = routeCandidates( + upstreamId, + downstreamId, + nominal, + progress, + refinement, + elevationTransition ? 0 : effectiveTransverseCandidates + ); + if (candidates.isEmpty()) { + return List.of(); + } + layers.add(candidates); + } + return solveTerrainRoute(layers, distance / steps); + } + + 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 + ) { + 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()) { + 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()) { + 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) { + if (candidate.terrainResolved()) { + return candidate; + } + HydrologyPoint point = candidate.point(); + HydrologyTerrainSample terrain = sampleLandBasis(point.x(), point.z()); + if (terrain == null || !terrain.transitAllowed()) { + return new RouteCandidate( + point, + candidate.offset(), + Double.POSITIVE_INFINITY, + Double.POSITIVE_INFINITY, + candidate.tangent(), + true + ); + } + double terrainScore = routeTerrainScore(terrain) + crossDropPenalty(point, candidate.tangent(), terrain); + double exactLocalScore = candidate.localScore() - candidate.terrainScore() + terrainScore; + return new RouteCandidate( + new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()), + candidate.offset(), + exactLocalScore, + terrainScore, + candidate.tangent(), + true + ); + } + + /** + * Ground that falls away beside a candidate forces the valley solver to cut the channel down to the + * lower bank; costing that drop keeps refined routes on valley floors and contour lines instead of + * traversing hillsides that the incision cap would later reject. + */ + private double crossDropPenalty(HydrologyPoint point, Direction tangent, HydrologyTerrainSample center) { + double length = StrictMath.hypot(tangent.x(), tangent.z()); + if (length <= 0D) { + return 0D; + } + double normalX = -tangent.z() / length; + double normalZ = tangent.x() / length; + double reach = settings.surface().maximumWidth() / 2D + settings.surface().shoreWidth() + 2D; + int lowest = center.naturalHeight(); + for (double distance = reach / 2D; distance <= reach; distance += reach / 2D) { + for (double direction = -1D; direction <= 1D; direction += 2D) { + HydrologyTerrainSample side = sampleLandBasis( + (int) StrictMath.round(point.x() + normalX * distance * direction), + (int) StrictMath.round(point.z() + normalZ * distance * direction)); + if (side != null) { + lowest = Math.min(lowest, side.naturalHeight()); + } + } + } + double drop = center.naturalHeight() - lowest; + return drop * settings.routing().valleyPreference() * CROSS_DROP_WEIGHT; + } + + private double routeTerrainScore(HydrologyTerrainSample terrain) { + return terrain.naturalHeight() * settings.routing().valleyPreference() + + terrain.slope() * settings.routing().slopePenalty() + + terrain.routingCost() * terrain.routingMultiplier(); + } + + private List solveTerrainRoute( + List> layers, + double targetStepLength + ) { + ArrayList> availableLayers = new ArrayList<>(layers); + HashSet rejectedTransitions = new HashSet<>(); + while (true) { + RouteCandidate[] route = selectTerrainRoute( + availableLayers, + targetStepLength, + rejectedTransitions + ); + if (route.length == 0) { + return List.of(); + } + 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 smoothTerrainRoute(points); + } + } + + private RouteCandidate[] selectTerrainRoute( + List> layers, + double targetStepLength, + Set rejectedTransitions + ) { + 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[] selectCurvatureAwareTerrainRoute( + List> layers, + double targetStepLength, + double maximumTurn, + double turnCost + ) { + int layerCount = layers.size(); + if (layerCount < 2) { + return new RouteCandidate[0]; + } + double[][] localPenalties = new double[layerCount][]; + double[][][] transitionCosts = new double[layerCount][][]; + for (int layerIndex = 0; layerIndex < layerCount; layerIndex++) { + int layerSize = layers.get(layerIndex).size(); + localPenalties[layerIndex] = new double[layerSize]; + if (layerIndex > 0 && layerIndex < layerCount - 1) { + Arrays.fill(localPenalties[layerIndex], Double.NaN); + } + if (layerIndex == 0) { + continue; + } + int previousSize = layers.get(layerIndex - 1).size(); + transitionCosts[layerIndex] = new double[previousSize][layerSize]; + for (double[] row : transitionCosts[layerIndex]) { + Arrays.fill(row, Double.NaN); + } + } + while (true) { + CurvatureRouteSelection selection = selectCurvatureAwareTerrainRoute( + layers, + targetStepLength, + maximumTurn, + turnCost, + localPenalties, + transitionCosts + ); + if (selection.route().length == 0) { + return selection.route(); + } + boolean evaluated = evaluateSelectedSurfaceCosts( + selection, + layers, + 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, + double[][] localPenalties, + double[][][] transitionCosts, + double targetStepLength + ) { + boolean evaluated = false; + RouteCandidate[] route = selection.route(); + int[] indices = selection.indices(); + for (int layerIndex = 1; layerIndex < route.length - 1; layerIndex++) { + RouteCandidate candidate = route[layerIndex]; + if (candidate.terrainResolved()) { + continue; + } + RouteCandidate resolved = resolveRouteCandidate(candidate); + ArrayList updatedLayer = new ArrayList<>(layers.get(layerIndex)); + updatedLayer.set(indices[layerIndex], resolved); + layers.set(layerIndex, List.copyOf(updatedLayer)); + route[layerIndex] = resolved; + evaluated = true; + } + for (int layerIndex = 1; layerIndex < route.length - 1; layerIndex++) { + int candidateIndex = indices[layerIndex]; + if (!Double.isNaN(localPenalties[layerIndex][candidateIndex])) { + continue; + } + if (!Double.isFinite(route[layerIndex].localScore())) { + localPenalties[layerIndex][candidateIndex] = 0D; + continue; + } + localPenalties[layerIndex][candidateIndex] = 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); + if (Double.isFinite(transition)) { + transition += surfaceRouteTransitionPenalty(previous, current); + } + 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 + ) { + int minimumBankDistance = surfaceBankDistance(settings.surface().minimumWidth()); + int maximumBankDistance = surfaceBankDistance(settings.surface().maximumWidth()); + int maximumBankRise = Math.addExact( + settings.surface().maximumIncision(), + (int) StrictMath.ceil(settings.surface().banks().minimumBlendWidth() * 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().banks().inset() + ); + 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().banks().inset() + ); + 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 = routeTurnScore(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 = routeTurnScore(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 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 + ) { + if (!traversableTerrainTransition(previous.point(), current.point())) { + 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 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().getFirst(); + 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<>(); + int tributaries = 0; + for (SurfaceCourseDraft draft : outletDrafts) { + if (mainCourse != null && tributaries >= settings.routing().tributaries()) { + break; + } + SurfaceCourseBuild candidate = buildSurfaceCourse( + draft.courseId(), + draft.profileKey(), + draft.source(), + draft.path() + ); + if (candidate.course() == null) { + if (mainCourse == null) { + mainRejections.put(draft.courseId(), candidate); + } + continue; + } + if (mainCourse == null) { + mainCourse = candidate.course(); + } else { + // A later draft to the same outlet shares the stem's tail; shared-trunk + // normalisation cuts it at the confluence into a tributary. + tributaries++; + } + courses.add(candidate.course()); + } + if (mainCourse == null) { + for (SurfaceCourseDraft draft : outletDrafts) { + SurfaceCourseBuild rejected = mainRejections.get(draft.courseId()); + addCompiledSourceDiagnostic( + draft.source(), + true, + draft.courseId(), + rejected.rejection(), + rejected.rejectionDetail(), + 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 + ) { + if (path.points().size() < 2) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, 0); + } + RiverOutlet outlet = path.outlet(); + boolean surfaceSinkhole = path.reachesOutlet() && surfaceSinkhole(outlet); + SurfaceTerminal terminal; + if (!path.reachesOutlet() || outlet.type() == HydrologyFeatureType.INLAND_GROTTO) { + terminal = SurfaceTerminal.SINKHOLE; + } else if (outlet.type() == HydrologyFeatureType.COASTAL_GROTTO) { + terminal = SurfaceTerminal.COASTAL_GROTTO; + } else { + terminal = SurfaceTerminal.OCEAN_MOUTH; + } + int outletHead = outletHead(outlet); + int terminalHead = surfaceSinkhole ? Math.addExact(outletHead, 1) : outletHead; + SurfaceCourseResult result = surfaceCourseBuilder.build( + worldSeed, + courseId, + profileKey, + path.points(), + terminal, + terminalHead + ); + if (!result.accepted()) { + return SurfaceCourseBuild.rejected(result.rejection(), result.rejectionDetail()); + } + ArrayList segments = new ArrayList<>(result.segments()); + if (path.reachesOutlet()) { + CoursePath terminalPath = new CoursePath( + List.of(result.pathEnd()), + path.pairEdges(), + path.edges(), + outlet, + true, + path.organicSurfaceRequired() + ); + appendOutletSegments( + RiverCourseType.SURFACE, + courseId, + terminalPath, + result.lastHead(), + new int[] {result.lastWidth()}, + new int[] {result.lastDepth()}, + segments + ); + } + if (path.organicSurfaceRequired()) { + int shapeRejection = surfaceShapeRejection(path.points()); + if (shapeRejection != 0) { + return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, shapeRejection); + } + } + int discharge = maximumSurfaceDischarge(path.edges()); + return SurfaceCourseBuild.accepted(new RiverCourse( + courseId, + RiverCourseType.SURFACE, + OptionalLong.of(source.id()), + OptionalLong.of(outlet.id()), + profileKey, + discharge, + path.edges(), + segments + )); + } + + /** 0 when the shape is organic; otherwise 1 too short, 2 too sinuous, 3 grid-locked, 4 sharp turns. */ + private int surfaceShapeRejection(List routedCenterline) { + List centerline = withoutDuplicateRoutePoints(routedCenterline); + if (centerline.size() < 3) { + return 1; + } + 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 2; + } + List sampled = resampleRouteCenterline( + 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 3; + } + 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 4; + } + turnAngles.add(turn); + } + if (turnAngles.isEmpty()) { + return 1; + } + 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 4; + } + } + 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 4; + } + return 0; + } + + 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 resampleRouteCenterline( + 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 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 + ) { + addCompiledSourceDiagnostic(source, surface, courseId, rejection, 0, diagnostics); + } + + private void addCompiledSourceDiagnostic( + GridNode source, + boolean surface, + long courseId, + HydrologyCandidateRejection rejection, + int detail, + 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, + detail + )); + } + + 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 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 + ); + 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.organicSurfaceRequired() + ); + } + + private List surfaceCourseCenterline( + SampledGrid grid, + List guide, + long geometryId + ) { + 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 = 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 = unresolvedSurfaceRouteCandidates( + grid, + geometryId, + candidateId, + position, + progress, + refinement, + transverseCandidates + ); + if (candidates.isEmpty()) { + return List.of(); + } + layers.add(candidates); + } + ArrayList> availableLayers = new ArrayList<>(layers); + double maximumTurn = Math.min( + SURFACE_COURSE_MAXIMUM_TURN_DEGREES, + settings.geometry().meanders().maximumTurnDegrees() + ); + while (true) { + RouteCandidate[] route = selectCurvatureAwareTerrainRoute( + availableLayers, + refinement, + maximumTurn, + SURFACE_COURSE_TURN_COST + ); + if (route.length == 0) { + if (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(); + } + 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 double routeTurnScore(List route) { + List sampled = resampleRouteCenterline( + 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 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().banks().minimumBlendWidth() * 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().banks().inset() + ); + 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 + ) + : 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 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 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 + )); + } + + /** + * Standing surface pools: a jittered lattice of candidate sites per pool profile, admitted where the + * policy lists the pool, the ground is open land clear of every accepted course, and the bowl fits + * the incision cap. Each accepted pool is an independent course with one STANDING_POOL segment. + */ + private void compileSurfacePools( + HydrologyTileKey key, + List courses, + List diagnostics + ) { + int tileSize = settings.routing().tileSize(); + for (HydrologyPlannerSettings.SurfacePool pool : settings.surfacePools()) { + if (!pool.enabled() || pool.maximumPerTile() == 0) { + continue; + } + long profileSeed = HydrologyHash.mix(worldSeed, SURFACE_POOL_SALT, HydrologyHash.text(pool.id())); + int target = Math.min(pool.maximumPerTile(), expectedCount(pool.density(), HydrologyHash.mix( + profileSeed, key.tileX(), key.tileZ()))); + int accepted = 0; + for (DeepSite site : poolSites(key, pool, profileSeed, tileSize)) { + if (accepted >= target) { + addPoolDiagnostic(site, HydrologyCandidateRejection.SOURCE_QUOTA, diagnostics); + continue; + } + HydrologyCandidateRejection rejection = poolAdmission(pool, site, courses); + if (rejection != null) { + addPoolDiagnostic(site, rejection, diagnostics); + continue; + } + RiverCourse course = buildSurfacePoolCourse(pool, site); + if (course == null) { + addPoolDiagnostic(site, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, diagnostics); + continue; + } + courses.add(course); + accepted++; + } + } + } + + private ArrayList poolSites( + HydrologyTileKey key, + HydrologyPlannerSettings.SurfacePool pool, + long profileSeed, + int tileSize + ) { + int minimumX = key.minimumBlockX(tileSize); + int minimumZ = key.minimumBlockZ(tileSize); + int spacing = pool.spacing(); + int xOffset = HydrologyHash.between(HydrologyHash.mix(profileSeed, DEEP_FLUID_X_OFFSET_SALT), 0, spacing - 1); + int zOffset = HydrologyHash.between(HydrologyHash.mix(profileSeed, DEEP_FLUID_Z_OFFSET_SALT), 0, spacing - 1); + long firstCellX = ceilDiv((long) minimumX - xOffset, spacing); + long firstCellZ = ceilDiv((long) minimumZ - zOffset, spacing); + long lastCellX = Math.floorDiv((long) minimumX + tileSize - 1L - xOffset, spacing); + long lastCellZ = Math.floorDiv((long) minimumZ + tileSize - 1L - zOffset, spacing); + ArrayList sites = new ArrayList<>(); + for (long cellZ = firstCellZ; cellZ <= lastCellZ; cellZ++) { + for (long cellX = firstCellX; cellX <= lastCellX; cellX++) { + long stable = HydrologyHash.mix(profileSeed, cellX, cellZ); + // Jitter inside the cell keeps pools off a visible grid. + int jitter = Math.max(1, spacing / 3); + int x = Math.toIntExact(xOffset + cellX * spacing + HydrologyHash.between(HydrologyHash.mix(stable, 11), -jitter, jitter)); + int z = Math.toIntExact(zOffset + cellZ * spacing + HydrologyHash.between(HydrologyHash.mix(stable, 12), -jitter, jitter)); + HydrologyTerrainSample terrain = sampleLandBasis(x, z); + sites.add(new DeepSite(x, z, terrain == null ? 0 : terrain.naturalHeight(), stable)); + } + } + sites.sort(Comparator.comparingLong(DeepSite::stableId)); + return sites; + } + + private HydrologyCandidateRejection poolAdmission( + HydrologyPlannerSettings.SurfacePool pool, + DeepSite site, + List courses + ) { + HydrologyTerrainSample terrain = sampleLandBasis(site.x(), site.z()); + if (terrain == null || terrain.ocean() || terrain.naturalHeight() <= settings.seaLevel() + 1) { + return HydrologyCandidateRejection.SURFACE_EXPOSURE; + } + if (!terrain.transitAllowed() || !terrain.surfacePoolKeys().contains(pool.id())) { + return HydrologyCandidateRejection.POLICY_EXCLUDED; + } + long clearance = pool.maximumRadius() + (long) StrictMath.ceil(settings.surface().shoreWidth()) + + settings.surface().banks().maximumBlendWidth() + settings.surface().maximumWidth(); + long clearanceSquared = clearance * clearance; + for (RiverCourse course : courses) { + for (HydraulicSegment segment : course.segments()) { + for (HydrologyPoint point : segment.centerline()) { + long deltaX = (long) point.x() - site.x(); + long deltaZ = (long) point.z() - site.z(); + if (deltaX * deltaX + deltaZ * deltaZ < clearanceSquared) { + return HydrologyCandidateRejection.SOURCE_SPACING; + } + } + } + } + return null; + } + + private RiverCourse buildSurfacePoolCourse(HydrologyPlannerSettings.SurfacePool pool, DeepSite site) { + int radius = HydrologyHash.between(HydrologyHash.mix(site.stableId(), 3), pool.minimumRadius(), pool.maximumRadius()); + HydrologyTerrainSample terrain = sampleLandBasis(site.x(), site.z()); + List points = List.of( + new HydrologyPoint(site.x() - 1, site.head(), site.z()), + new HydrologyPoint(site.x(), site.head(), site.z()), + new HydrologyPoint(site.x() + 1, site.head(), site.z()) + ); + SurfaceCenterline centerline = SurfaceCenterline.densify(points); + int count = centerline.size(); + double[] halfWidth = new double[count]; + double[] depth = new double[count]; + double[] bank = new double[count]; + Arrays.fill(halfWidth, radius); + Arrays.fill(depth, pool.depth()); + Arrays.fill(bank, terrain == null ? 1D : terrain.bankMultiplier()); + ChannelProfile profile = new ChannelProfile(halfWidth, depth, bank); + ValleyProfile valley = new ValleyProfileSolver(settings.surface(), this::sampleBasis, settings.seaLevel(), 0) + .solve(centerline, profile, SurfaceTerminal.SINKHOLE, Integer.MIN_VALUE); + if (!valley.accepted()) { + return null; + } + int center = Math.min(1, valley.exposedStations() - 1); + int head = valley.head()[center]; + // A pool belongs in a hollow or on level ground: the whole rim must sit within a few blocks of the + // water, or the bowl becomes a scar dug into a slope. + int rimAllowance = pool.depth() + settings.surface().banks().inset() + 2; + for (int station = 0; station < valley.exposedStations(); station++) { + if (valley.crossMax()[station] - valley.head()[station] > rimAllowance) { + return null; + } + } + long courseId = HydrologyHash.mix(worldSeed, SURFACE_POOL_SALT, HydrologyHash.text(pool.id()), site.stableId()); + long segmentId = HydrologyHash.mix(courseId, SEGMENT_SALT, HydrologyFeatureType.STANDING_POOL.ordinal()); + ArrayList centerlinePoints = new ArrayList<>(points.size()); + for (HydrologyPoint point : points) { + centerlinePoints.add(new HydrologyPoint(point.x(), head, point.z())); + } + HydraulicSegment segment = new HydraulicSegment( + segmentId, + courseId, + HydrologyFeatureType.STANDING_POOL, + head, + head, + radius * 2, + pool.depth(), + false, + false, + centerlinePoints + ); + return new RiverCourse( + courseId, + RiverCourseType.SURFACE_POOL, + OptionalLong.empty(), + OptionalLong.empty(), + pool.id(), + 1, + List.of(), + List.of(segment) + ); + } + + private void addPoolDiagnostic( + DeepSite site, + HydrologyCandidateRejection rejection, + List diagnostics + ) { + diagnostics.add(new HydrologyDiagnosticCandidate( + HydrologyHash.mix(site.stableId(), DIAGNOSTIC_SALT, rejection.ordinal()), + HydrologyCandidateKind.POOL, + HydrologyFeatureType.STANDING_POOL, + new HydrologyPoint(site.x(), site.head(), site.z()), + rejection, + 0 + )); + } + + private void compileDeepFluidCourses( + HydrologyTileKey key, + List 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, 0 + )); + } + + 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 + ) { + } + + 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 + ) + implements Comparable { + @Override + public int compareTo(RoutingQueueEntry other) { + 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 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, 0 + ); + } + + 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 organicSurfaceRequired + ) { + } + + private record SurfaceCourseDraft( + GridNode source, + long courseId, + String profileKey, + CoursePath path + ) { + } + + private record SurfaceCourseBuild( + RiverCourse course, + HydrologyCandidateRejection rejection, + int rejectionDetail + ) { + 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, 0); + } + + private static SurfaceCourseBuild rejected(HydrologyCandidateRejection rejection, int detail) { + return new SurfaceCourseBuild(null, Objects.requireNonNull(rejection, "rejection"), detail); + } + } + + 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..3c7612cdc --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettings.java @@ -0,0 +1,586 @@ +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, + List surfacePools +) { + private static final long PLAN_FORMAT_REVISION = 6L; + 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); + surfacePools = surfacePools == null ? List.of() : List.copyOf(surfacePools); + int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools); + if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) { + throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period."); + } + } + + 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, 8192, 8192, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, 0), + new Surface(true, surfaceSources, 4, 8, 2, 4, 10, 1.5D, Banks.defaults()), + new Hydraulics(8), + new Underground(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true), + new 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(), + 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())); + } + for (SurfacePool pool : surfacePools) { + hash = HydrologyHash.mix(hash, pool.hashCode(), HydrologyHash.text(pool.id())); + } + return hash; + } + + public int publicationRadius() { + return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids, surfacePools); + } + + int crossTileColorPeriod() { + return crossTileColorPeriod(publicationRadius(), routing.tileSize()); + } + + private static int publicationRadius( + Routing routing, + Surface surface, + Underground underground, + Outlets outlets, + Geometry geometry, + List deepFluids, + List surfacePools + ) { + 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 blendWidth = surface.banks().maximumBlendWidth(); + int surfaceRadius = (int) StrictMath.ceil( + surface.maximumWidth() / 2D + surface.shoreWidth() + blendWidth + ); + surfaceRadius = Math.max( + surfaceRadius, + (int) StrictMath.ceil(geometry.drops().basinWidth( + geometry.drops().flowWidth(surface.maximumWidth()) + ) / 2D + surface.shoreWidth() + blendWidth) + ); + 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)); + } + for (SurfacePool pool : surfacePools) { + if (!pool.enabled()) { + continue; + } + radius = Math.max(radius, pool.maximumRadius() + (int) StrictMath.ceil(surface.shoreWidth()) + + surface.banks().maximumBlendWidth()); + } + 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 maximumRouteNodes, + int maximumRouteLength, + int minimumSurfaceCourseLength, + int minimumUndergroundCourseLength, + double valleyPreference, + double uphillPenalty, + double slopePenalty, + double confluenceAttraction, + double lengthPreference, + int tributaries + ) { + public Routing { + if (tileSize < 32 || sampleSpacing < 4 || tileSize % sampleSpacing != 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 (minimumSurfaceCourseLength < 0 || minimumSurfaceCourseLength > 32_768 + || minimumUndergroundCourseLength < 0 || minimumUndergroundCourseLength > 32_768) { + throw new IllegalArgumentException("Hydrology course lengths are invalid."); + } + if (minimumSurfaceCourseLength > maximumRouteLength + || 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"); + requireFiniteNonNegative(lengthPreference, "lengthPreference"); + if (tributaries < 0) { + throw new IllegalArgumentException("tributaries must not be negative."); + } + } + + // Route refinement is derived from the lattice, never authored. + public static int refinementSpacing(int sampleSpacing) { + if (sampleSpacing % 4 == 0) { + return 4; + } + return sampleSpacing % 2 == 0 ? 2 : 1; + } + + public int refinementSpacing() { + return refinementSpacing(sampleSpacing); + } + + 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 maximumIncision, + double shoreWidth, + Banks banks + ) { + public Surface { + if (sources == null || banks == null) { + throw new IllegalArgumentException("Surface source and bank settings are required."); + } + requireRange(minimumWidth, maximumWidth, 1, 256, "surface width"); + requireRange(minimumDepth, maximumDepth, 1, 64, "surface depth"); + if (maximumIncision < 0 || !Double.isFinite(shoreWidth) || shoreWidth < 0D || shoreWidth > 32D) { + throw new IllegalArgumentException("Surface incision and shore width are invalid."); + } + } + } + + public record Banks( + int inset, + int freeboard, + double blendSlope, + int minimumBlendWidth, + int maximumBlendWidth, + double roughness, + int roughnessWavelength, + int cascadeRun, + int waterfallMinimumDrop, + double mouthFlareRatio, + double springWidthRatio, + int springLength, + boolean exposeCutStrata + ) { + // Structural invariants only; authoring bounds live in the pack validator. + public Banks { + if (inset < 0 || freeboard < 0 + || !Double.isFinite(blendSlope) || blendSlope <= 0D + || minimumBlendWidth < 1 || maximumBlendWidth < minimumBlendWidth + || !Double.isFinite(roughness) || roughness < 0D || roughness > 1D + || roughnessWavelength < 1 + || cascadeRun < 1 + || waterfallMinimumDrop < 1 + || !Double.isFinite(mouthFlareRatio) || mouthFlareRatio < 1D + || !Double.isFinite(springWidthRatio) || springWidthRatio < 1D + || springLength < 1) { + throw new IllegalArgumentException("Surface bank settings are invalid."); + } + } + + public static Banks defaults() { + return new Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true); + } + } + + /** A standing surface pool profile: a bowl of the given radius and depth, filled with its own fluid. */ + public record SurfacePool( + String id, + boolean enabled, + double density, + int spacing, + int minimumRadius, + int maximumRadius, + int depth, + int maximumPerTile, + String biomeKey + ) { + public SurfacePool { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("Surface pool id must not be blank."); + } + id = id.trim(); + if (!Double.isFinite(density) || density < 0D || spacing < 1 + || minimumRadius < 1 || maximumRadius < minimumRadius || depth < 1 || maximumPerTile < 0) { + throw new IllegalArgumentException("Surface pool settings are invalid."); + } + biomeKey = biomeKey == null || biomeKey.isBlank() ? null : biomeKey.trim(); + } + } + + // The routing gates read one hydraulic threshold: the drop that makes a reach a waterfall. + public record Hydraulics(int waterfallMinimumDrop) { + public Hydraulics { + if (waterfallMinimumDrop < 1) { + throw new IllegalArgumentException("waterfallMinimumDrop must be at least one block."); + } + } + } + + 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..f01cd668b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSample.java @@ -0,0 +1,235 @@ +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, + double bankMultiplier, + String parentBiomeKey, + String surfaceBiomeKey, + String mouthBiomeKey, + String shoreBiomeKey, + String bankBiomeKey, + String floodedCaveBiomeKey, + List preferredProfileKeys, + List surfacePoolKeys +) { + 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"); + requireFinitePositive(bankMultiplier, "bankMultiplier"); + 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); + bankBiomeKey = normalizeKey(bankBiomeKey, parentBiomeKey); + floodedCaveBiomeKey = normalizeKey(floodedCaveBiomeKey, surfaceBiomeKey); + preferredProfileKeys = normalizeProfiles(preferredProfileKeys); + surfacePoolKeys = surfacePoolKeys == null ? List.of() : List.copyOf(surfacePoolKeys); + } + + public static HydrologyTerrainSample openLand(int naturalHeight, double slope, String parentBiomeKey) { + return new HydrologyTerrainSample( + naturalHeight, + slope, + false, + false, + naturalHeight - 32, + naturalHeight - 30, + true, + true, + true, + false, + false, + false, + 0D, + 1D, + 1D, + 1D, + 1D, + 1D, + 1D, + 1D, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + List.of("default"), + List.of() + ); + } + + 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, + 1D, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + parentBiomeKey, + List.of("default"), + List.of() + ); + } + + public HydrologyTerrainSample withSurfacePoolKeys(List replacementPoolKeys) { + return new HydrologyTerrainSample( + naturalHeight, + slope, + ocean, + caveAvailable, + caveFloorY, + caveFluidY, + transitAllowed, + outletAllowed, + surfaceSourceAllowed, + surfaceSourceRequired, + undergroundSourceAllowed, + undergroundSourceRequired, + routingCost, + surfaceSourceWeight, + undergroundSourceWeight, + widthMultiplier, + depthMultiplier, + incisionMultiplier, + routingMultiplier, + bankMultiplier, + parentBiomeKey, + surfaceBiomeKey, + mouthBiomeKey, + shoreBiomeKey, + bankBiomeKey, + floodedCaveBiomeKey, + preferredProfileKeys, + replacementPoolKeys + ); + } + + 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, + bankMultiplier, + parentBiomeKey, + surfaceBiomeKey, + mouthBiomeKey, + shoreBiomeKey, + bankBiomeKey, + floodedCaveBiomeKey, + preferredProfileKeys, + surfacePoolKeys + ); + } + + 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..2c3191273 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourse.java @@ -0,0 +1,108 @@ +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); + boolean independent = type == RiverCourseType.DEEP_FLUID || type == RiverCourseType.SURFACE_POOL; + if (independent && (sourceNodeId.isPresent() || outletId.isPresent())) { + throw new IllegalArgumentException("Independent deep-fluid and pool courses cannot reference drainage nodes or river outlets."); + } + if (!independent && (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() + || 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..269726e7b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverCourseType.java @@ -0,0 +1,8 @@ +package art.arcane.iris.engine.hydrology; + +public enum RiverCourseType { + SURFACE, + UNDERGROUND, + DEEP_FLUID, + SURFACE_POOL +} 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/hydrology/cave/CaveVoxelPrecondition.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelPrecondition.java new file mode 100644 index 000000000..368f4834e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/CaveVoxelPrecondition.java @@ -0,0 +1,9 @@ +package art.arcane.iris.engine.hydrology.cave; + +import java.util.Objects; + +public record CaveVoxelPrecondition(CaveVoxel voxel, boolean openToSurface) { + public CaveVoxelPrecondition { + Objects.requireNonNull(voxel); + } +} 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/hydrology/cave/HydrologyCaveGrottoShape.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveGrottoShape.java new file mode 100644 index 000000000..6d621455d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveGrottoShape.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.hydrology.cave; + +@FunctionalInterface +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)) + + ((double) dy * dy / (verticalRadius * verticalRadius)) + + ((double) dz * dz / (horizontalRadius * horizontalRadius)); + return normalized <= 1D; + }; + + boolean contains( + HydrologyCaveSource source, + HydrologyCavePlannerSettings settings, + int offsetX, + int offsetY, + int offsetZ + ); +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveMode.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveMode.java new file mode 100644 index 000000000..319abeb02 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveMode.java @@ -0,0 +1,9 @@ +package art.arcane.iris.engine.hydrology.cave; + +public enum HydrologyCaveMode { + CLOSED_COMPONENT, + GENERATED_GROTTO, + GROTTO_OR_CLOSED_COMPONENT, + WATERFALL_POOL, + DEEP_POOL +} 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/hydrology/cave/HydrologyCavePlannerSettings.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlannerSettings.java new file mode 100644 index 000000000..b7ac33797 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCavePlannerSettings.java @@ -0,0 +1,106 @@ +package art.arcane.iris.engine.hydrology.cave; + +import java.util.Objects; + +public record HydrologyCavePlannerSettings( + int maxHorizontalRadius, + int maxDepth, + int maxFloodVolume, + int maxThroatLength, + int throatRadius, + int grottoHorizontalRadius, + int grottoVerticalRadius, + int dryHeadroom, + HydrologyCaveFluidPolicy existingFluidPolicy, + HydrologyCaveGrottoShape grottoShape, + int maxClosedComponentHorizontalRadius, + int maxClosedComponentDepth +) { + public static final int MAXIMUM_PLANNED_MUTATIONS = 262_144; + + public HydrologyCavePlannerSettings( + int maxHorizontalRadius, + int maxDepth, + int maxFloodVolume, + int maxThroatLength, + int throatRadius, + int grottoHorizontalRadius, + int grottoVerticalRadius, + int dryHeadroom, + HydrologyCaveFluidPolicy existingFluidPolicy, + HydrologyCaveGrottoShape grottoShape + ) { + this( + maxHorizontalRadius, + maxDepth, + maxFloodVolume, + maxThroatLength, + throatRadius, + grottoHorizontalRadius, + grottoVerticalRadius, + dryHeadroom, + existingFluidPolicy, + grottoShape, + maxHorizontalRadius, + maxDepth + ); + } + + public HydrologyCavePlannerSettings( + int maxHorizontalRadius, + int maxDepth, + int maxFloodVolume, + int maxThroatLength, + int grottoHorizontalRadius, + int grottoVerticalRadius, + HydrologyCaveFluidPolicy existingFluidPolicy + ) { + this( + maxHorizontalRadius, + maxDepth, + maxFloodVolume, + maxThroatLength, + 1, + grottoHorizontalRadius, + grottoVerticalRadius, + 0, + existingFluidPolicy, + HydrologyCaveGrottoShape.ELLIPSOID + ); + } + + public HydrologyCavePlannerSettings { + Objects.requireNonNull(existingFluidPolicy); + Objects.requireNonNull(grottoShape); + if (maxHorizontalRadius < 1) { + throw new IllegalArgumentException("maxHorizontalRadius must be positive"); + } + if (maxDepth < 1) { + throw new IllegalArgumentException("maxDepth must be positive"); + } + if (maxFloodVolume < 1) { + throw new IllegalArgumentException("maxFloodVolume must be positive"); + } + if (maxThroatLength < 1) { + throw new IllegalArgumentException("maxThroatLength must be positive"); + } + if (throatRadius < 1) { + throw new IllegalArgumentException("throatRadius must be positive"); + } + if (grottoHorizontalRadius < 1) { + throw new IllegalArgumentException("grottoHorizontalRadius must be positive"); + } + if (grottoVerticalRadius < 1) { + throw new IllegalArgumentException("grottoVerticalRadius must be positive"); + } + if (dryHeadroom < 0) { + throw new IllegalArgumentException("dryHeadroom cannot be negative"); + } + if (maxClosedComponentHorizontalRadius < 1) { + throw new IllegalArgumentException("maxClosedComponentHorizontalRadius must be positive"); + } + if (maxClosedComponentDepth < 1) { + throw new IllegalArgumentException("maxClosedComponentDepth must be positive"); + } + } +} 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/hydrology/cave/HydrologyCaveRejection.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveRejection.java new file mode 100644 index 000000000..a0b1baec3 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveRejection.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.hydrology.cave; + +public enum HydrologyCaveRejection { + NONE, + INVALID_SOURCE, + NO_CAVE_TARGET, + THROAT_LIMIT, + RADIUS_LIMIT, + DEPTH_LIMIT, + VOLUME_LIMIT, + WORLD_BOUNDARY, + OPEN_SURFACE, + LAVA_CONTACT, + EXISTING_FLUID, + INCOMPATIBLE_FLUID, + GROTTO_INTERSECTION, + GROTTO_SHELL_OPEN, + DRY_HEADROOM_LIMIT, + WATERFALL_SHAFT_OPEN, + OVERLAPPING_SOURCE +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveSource.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveSource.java new file mode 100644 index 000000000..ceb21285b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveSource.java @@ -0,0 +1,17 @@ +package art.arcane.iris.engine.hydrology.cave; + +import java.util.Objects; + +public record HydrologyCaveSource( + long sourceId, + CavePosition entry, + CavePosition target, + int waterHeadY, + HydrologyCaveMode mode +) { + public HydrologyCaveSource { + Objects.requireNonNull(entry); + Objects.requireNonNull(target); + Objects.requireNonNull(mode); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorage.java b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorage.java new file mode 100644 index 000000000..df5b4346f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorage.java @@ -0,0 +1,41 @@ +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 HydrologyCaveStorage { + private HydrologyCaveStorage() { + } + + public static HydrologyCaveCell getIfPresent(Mantle mantle, int x, int y, int z) { + if (y < 0 || y >= mantle.getWorldHeight()) { + return null; + } + int chunkX = x >> 4; + int chunkZ = z >> 4; + 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); + return getIfPresent(chunk, x, y, z); + } + + public static HydrologyCaveCell getIfPresent(MantleChunk chunk, int x, int y, int z) { + if (chunk == null || y < 0) { + return null; + } + int section = y >> 4; + if (!chunk.exists(section)) { + return null; + } + Matter matter = chunk.get(section); + if (matter == null || !matter.hasSlice(HydrologyCaveCell.class)) { + return null; + } + 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..647b7b51f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/EffectiveRiverPolicy.java @@ -0,0 +1,61 @@ +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 bankBiomes, + List floodedCaveBiomes, + List surfacePools, + double widthMultiplier, + double depthMultiplier, + double incisionMultiplier, + double routingMultiplier, + double bankMultiplier +) { + 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); + bankBiomes = List.copyOf(bankBiomes); + floodedCaveBiomes = List.copyOf(floodedCaveBiomes); + surfacePools = List.copyOf(surfacePools); + } + + 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..c521f949e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolver.java @@ -0,0 +1,124 @@ +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 bankBiomes = List.of(); + private List floodedCaveBiomes = List.of(); + private List surfacePools = List.of(); + private double widthMultiplier = 1D; + private double depthMultiplier = 1D; + private double incisionMultiplier = 1D; + private double routingMultiplier = 1D; + private double bankMultiplier = 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.getBankBiomes() != null) { + bankBiomes = List.copyOf(policy.getBankBiomes()); + } + if (policy.getFloodedCaveBiomes() != null) { + floodedCaveBiomes = List.copyOf(policy.getFloodedCaveBiomes()); + } + if (policy.getSurfacePools() != null) { + surfacePools = List.copyOf(policy.getSurfacePools()); + } + if (policy.getWidthMultiplier() != null) { + widthMultiplier = policy.getWidthMultiplier(); + } + if (policy.getDepthMultiplier() != null) { + depthMultiplier = policy.getDepthMultiplier(); + } + if (policy.getIncisionMultiplier() != null) { + incisionMultiplier = policy.getIncisionMultiplier(); + } + if (policy.getRoutingMultiplier() != null) { + routingMultiplier = policy.getRoutingMultiplier(); + } + if (policy.getBankMultiplier() != null) { + bankMultiplier = policy.getBankMultiplier(); + } + } + + private EffectiveRiverPolicy build() { + return new EffectiveRiverPolicy( + placement, + routing, + outletAdmission, + profiles, + surfaceBiomes, + mouthBiomes, + shoreBiomes, + bankBiomes, + floodedCaveBiomes, + surfacePools, + widthMultiplier, + depthMultiplier, + incisionMultiplier, + routingMultiplier, + bankMultiplier + ); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearch.java b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearch.java new file mode 100644 index 000000000..0989b0521 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearch.java @@ -0,0 +1,57 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.engine.hydrology.HydrologyTileKey; + +import java.util.ArrayList; +import java.util.List; + +/** + * Ring order for feature searches: tiles are visited outward from the origin tile so a search can + * stop as soon as no unvisited tile could hold a nearer feature, instead of planning the whole box. + */ +final class HydrologyFeatureSearch { + private HydrologyFeatureSearch() { + } + + /** Tiles at exactly Chebyshev distance {@code radius} from the origin tile, in row-major order. */ + static List ring(int originTileX, int originTileZ, int radius) { + if (radius < 0) { + throw new IllegalArgumentException("radius must not be negative."); + } + if (radius == 0) { + return List.of(new HydrologyTileKey(originTileX, originTileZ)); + } + ArrayList keys = new ArrayList<>(radius * 8); + for (int tileZ = originTileZ - radius; tileZ <= originTileZ + radius; tileZ++) { + boolean edgeRow = tileZ == originTileZ - radius || tileZ == originTileZ + radius; + for (int tileX = originTileX - radius; tileX <= originTileX + radius; tileX++) { + boolean edgeColumn = tileX == originTileX - radius || tileX == originTileX + radius; + if (edgeRow || edgeColumn) { + keys.add(new HydrologyTileKey(tileX, tileZ)); + } + } + } + return List.copyOf(keys); + } + + /** Number of rings needed so every tile that can publish within {@code maximumDistance} is visited. */ + static int ringLimit(int maximumDistance, int tileSize, int publicationRadius) { + if (maximumDistance < 0 || tileSize <= 0 || publicationRadius < 0) { + throw new IllegalArgumentException("Hydrology feature search bounds are invalid."); + } + long reach = (long) maximumDistance + publicationRadius; + return Math.toIntExact((reach + tileSize - 1) / tileSize); + } + + /** + * Smallest block distance a feature published by a ring-{@code radius} tile can have from the + * origin: the ring starts {@code radius - 1} tiles away and a tile's footprint reaches + * {@code publicationRadius} beyond its bounds. + */ + static long lowerBound(int radius, int tileSize, int publicationRadius) { + if (radius <= 0) { + return 0L; + } + return Math.max(0L, (long) (radius - 1) * tileSize - publicationRadius); + } +} 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..7ed8c87b9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSampler.java @@ -0,0 +1,381 @@ +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) { + // Point samples carry the forward slope; the aligned grid derives its own from neighbouring bases. + TerrainBasis basis = basis(blockX, blockZ); + return basis.terrain().withSlope(localSlope(blockX, blockZ, basis.naturalHeight())); + } + + 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..ca3be7725 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntime.java @@ -0,0 +1,958 @@ +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.IrisSurfacePoolConfig; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisHydrology; +import art.arcane.iris.engine.object.IrisInlandRiverGrottoConfig; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisRiverGrottoConfig; +import art.arcane.iris.engine.object.IrisRiverChannelShapeConfig; +import art.arcane.iris.engine.object.IrisRiverDropShapeConfig; +import art.arcane.iris.engine.object.IrisRiverGeometryConfig; +import art.arcane.iris.engine.object.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.IrisSurfaceRiverBankConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverChannelConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverFlowConfig; +import art.arcane.iris.engine.object.IrisSurfaceRiverConfig; +import art.arcane.iris.engine.object.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; +import java.util.function.IntConsumer; + +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_BLEND_SALT = 0x53555246424c454eL; + 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( + this::createTerrainBasis, + (int x, int z) -> context.naturalHeightProvider().sample(x, z), + (int x, int z) -> 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 + ) { + return nearestFeature(types, profileKey, x, z, maximumDistance, (int visited) -> { }); + } + + /** + * Walks tiles outward from the origin, planning each on demand, and stops as soon as no unvisited + * ring can hold a nearer feature. {@code progress} receives the running tile count after every ring. + */ + public Optional nearestFeature( + Set types, + String profileKey, + int x, + int z, + int maximumDistance, + IntConsumer progress + ) { + Objects.requireNonNull(types); + Objects.requireNonNull(progress); + if (types.isEmpty() || maximumDistance < 0) { + return Optional.empty(); + } + int tileSize = settings.routing().tileSize(); + int publicationRadius = settings.publicationRadius(); + 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."); + } + int originTileX = tileCoordinate(x, tileSize); + int originTileZ = tileCoordinate(z, tileSize); + int ringLimit = HydrologyFeatureSearch.ringLimit(maximumDistance, tileSize, publicationRadius); + HydrologyFeatureRef nearest = null; + long nearestDistanceSquared = Long.MAX_VALUE; + int visited = 0; + for (int ring = 0; ring <= ringLimit; ring++) { + long lowerBound = HydrologyFeatureSearch.lowerBound(ring, tileSize, publicationRadius); + if (nearest != null && lowerBound * lowerBound > nearestDistanceSquared) { + break; + } + for (HydrologyTileKey key : HydrologyFeatureSearch.ring(originTileX, originTileZ, ring)) { + HydrologyTile tile = cache.get(key); + visited++; + HydrologyFeatureRef feature = tile.nearestFeature( + types, profileKey, x, z, maximumDistance).orElse(null); + if (feature == null) { + continue; + } + 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; + } + } + progress.accept(visited); + } + 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 profiledIds = new ArrayList<>(settings.deepFluids().size() + settings.surfacePools().size()); + for (HydrologyPlannerSettings.DeepFluid deepFluid : settings.deepFluids()) { + profiledIds.add(deepFluid.id()); + } + for (HydrologyPlannerSettings.SurfacePool pool : settings.surfacePools()) { + profiledIds.add(pool.id()); + } + return HydrologyFeatureQuery.suggestions(profiledIds); + } + + @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 = 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) { + return routingTerrainSampler.sampleBasis(x, z); + } + + 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, + policy.bankMultiplier(), + parentBiomeKey, + selectKey(policy.surfaceBiomes(), parentBiomeKey, biomePatchNoise, 1), + selectKey(policy.mouthBiomes(), parentBiomeKey, biomePatchNoise, 2), + selectKey(policy.shoreBiomes(), parentBiomeKey, biomePatchNoise, 3), + selectKey(policy.bankBiomes(), parentBiomeKey, biomePatchNoise, 4), + selectKey(policy.floodedCaveBiomes(), parentBiomeKey, biomePatchNoise, 5), + profiles, + policy.surfacePools() + ); + 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(); + IrisSurfaceRiverBankConfig banks = surface.getBanks(); + IrisUndergroundRiverConfig underground = rivers.getUnderground(); + ProceduralStream surfaceWidth = styledStream(context, channel.getWidth(), SURFACE_WIDTH_SALT); + ProceduralStream surfaceDepth = styledStream(context, channel.getDepth(), SURFACE_DEPTH_SALT); + ProceduralStream surfaceBlend = ProceduralStream.ofDouble((x, z) -> (double) banks.getMaximumBlendWidth()); + 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_BLEND_WIDTH -> surfaceBlend; + 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(); + IrisSurfaceRiverBankConfig banks = surface.getBanks(); + IrisSurfaceRiverFlowConfig flow = surface.getFlow(); + 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(), + routeNodes, + routing.getMaximumRouteLength(), + routing.getMinimumSurfaceCourseLength(), + routing.getMinimumUndergroundCourseLength(), + routing.getValleyPreference(), + routing.getUphillPenalty(), + routing.getSlopePenalty(), + routing.getConfluenceAttraction(), + routing.getLengthPreference(), + routing.getTributaries() + ); + 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()), + channel.getMaximumIncision(), + banks.getShoreWidth(), + new HydrologyPlannerSettings.Banks( + channel.getInset(), + banks.getFreeboard(), + banks.getBlendSlope(), + banks.getMinimumBlendWidth(), + banks.getMaximumBlendWidth(), + channel.getRoughness(), + channel.getRoughnessWavelength(), + flow.getCascadeRun(), + flow.getWaterfallMinimumDrop(), + surface.getMouths().getFlareRatio(), + channel.getSpringWidthRatio(), + channel.getSpringLength(), + banks.isExposeCutStrata() + ) + ); + HydrologyPlannerSettings.Hydraulics plannerHydraulics = + new HydrologyPlannerSettings.Hydraulics(flow.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()), + underground.getMouthLevelingDistance(), + surface.getMouths().getMaximumOceanApron(), + routing.getMaximumOutletsPerTile() + ); + HydrologyPlannerSettings.Geometry plannerGeometry = geometry( + rivers.getGeometry(), + new HydrologyPlannerSettings.ChannelShape( + 2D, + channel.getRoughness(), + channel.getRoughness(), + channel.getRoughnessWavelength() + ) + ); + return new HydrologyPlannerSettings( + dimension.getFluidHeight(), + plannerRouting, + plannerSurface, + plannerHydraulics, + plannerUnderground, + plannerOutlets, + plannerGeometry, + deepFluids(hydrology.getDeepFluids(), minimumWorldY, routing, plannerRouting.refinementSpacing()), + surfacePools(hydrology.getSurfacePools()) + ); + } + + private static List surfacePools(List configurations) { + ArrayList pools = new ArrayList<>(); + for (IrisSurfacePoolConfig configuration : configurations) { + pools.add(new HydrologyPlannerSettings.SurfacePool( + configuration.getId(), + configuration.getDensity() > 0D, + configuration.getDensity(), + configuration.getSpacing(), + configuration.getMinimumRadius(), + configuration.getMaximumRadius(), + configuration.getDepth(), + Math.min(64, maximumSources(configuration.getDensity(), 0)), + configuration.getBiome() + )); + } + return List.copyOf(pools); + } + + private static HydrologyPlannerSettings.Geometry geometry( + IrisRiverGeometryConfig geometry, + HydrologyPlannerSettings.ChannelShape surfaceShape + ) { + 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() + ), + surfaceShape, + 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, + int refinementSpacing + ) { + 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(refinementSpacing, 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( + HydrologyPlannerSettings.Routing.refinementSpacing(routing.getSampleSpacing()), + 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/hydrology/surface/ChannelProfile.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfile.java new file mode 100644 index 000000000..2f03c1e44 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfile.java @@ -0,0 +1,11 @@ +package art.arcane.iris.engine.hydrology.surface; + +public record ChannelProfile(double[] halfWidth, double[] depth, double[] bankMultiplier) { + public int size() { + return halfWidth.length; + } + + public double collar(int station, double roughness) { + return halfWidth[station] * (1D + roughness) + 2D; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java new file mode 100644 index 000000000..31dbf3b26 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilder.java @@ -0,0 +1,140 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; + +import java.util.Objects; + +public final class ChannelProfileBuilder { + private static final int SMOOTHING_RADIUS = 16; + private static final int MOUTH_FLARE = 32; + + private final HydrologyPlannerSettings.Surface surface; + private final HydrologyTerrainSampler sampler; + private final HydrologyGeometrySampler geometry; + + public ChannelProfileBuilder( + HydrologyPlannerSettings.Surface surface, + HydrologyTerrainSampler sampler, + HydrologyGeometrySampler geometry + ) { + this.surface = Objects.requireNonNull(surface, "surface"); + this.sampler = Objects.requireNonNull(sampler, "sampler"); + this.geometry = Objects.requireNonNull(geometry, "geometry"); + } + + public ChannelProfile build( + SurfaceCenterline centerline, + String profileKey, + boolean directOcean + ) { + int count = centerline.size(); + double[] width = new double[count]; + double[] depth = new double[count]; + double[] bank = new double[count]; + for (int station = 0; station < count; station++) { + int stationX = centerline.x()[station]; + int stationZ = centerline.z()[station]; + int sampledWidth = geometry.sample( + HydrologyGeometrySampler.Field.SURFACE_WIDTH, + profileKey, + stationX, + stationZ, + 0L, + surface.minimumWidth(), + surface.maximumWidth() + ); + int sampledDepth = geometry.sample( + HydrologyGeometrySampler.Field.SURFACE_DEPTH, + profileKey, + stationX, + stationZ, + 0L, + surface.minimumDepth(), + surface.maximumDepth() + ); + HydrologyTerrainSample terrain = sampler.sample(stationX, stationZ); + double widthMultiplier = terrain == null ? 1D : terrain.widthMultiplier(); + double depthMultiplier = terrain == null ? 1D : terrain.depthMultiplier(); + bank[station] = terrain == null ? 1D : terrain.bankMultiplier(); + width[station] = clamp(sampledWidth * widthMultiplier, surface.minimumWidth(), surface.maximumWidth() * 2D); + depth[station] = clamp(sampledDepth * depthMultiplier, 1D, surface.maximumDepth() * 2D); + } + double[] smoothWidth = smooth(width); + double[] smoothDepth = smooth(depth); + // The headwater opens as a spring pool: wider and one block deeper, narrowing to the cruise width. + // Ground that falls away across the pool shrinks it so it never demands a cut the valley solver rejects. + int spring = Math.min(surface.banks().springLength(), count / 2); + double springRatio = surface.banks().springWidthRatio(); + for (int station = 0; station < spring; station++) { + double remaining = 1D - SurfaceNoise.smoothStep(station / (double) spring); + double localRatio = 1D + (springRatio - 1D) * springRoom(centerline, station, smoothWidth[station] * springRatio / 2D); + smoothWidth[station] *= 1D + (localRatio - 1D) * remaining; + smoothDepth[station] += remaining; + } + if (directOcean) { + int flare = Math.min(MOUTH_FLARE, count / 3); + int flareStart = count - flare; + for (int station = Math.max(0, flareStart); station < count; station++) { + double progress = SurfaceNoise.smoothStep((station - flareStart) / (double) Math.max(1, flare - 1)); + smoothWidth[station] *= 1D + (surface.banks().mouthFlareRatio() - 1D) * progress; + } + } + double[] halfWidth = new double[count]; + for (int station = 0; station < count; station++) { + halfWidth[station] = Math.max(0.5D, smoothWidth[station] / 2D); + smoothDepth[station] = Math.max(1D, smoothDepth[station]); + } + return new ChannelProfile(halfWidth, smoothDepth, bank); + } + + /** Fraction of the spring pool the ground allows: 1 on level ground, 0 where the pool ring would sit half the permitted cut lower. */ + private double springRoom(SurfaceCenterline centerline, int station, double reach) { + int centerX = centerline.x()[station]; + int centerZ = centerline.z()[station]; + HydrologyTerrainSample center = sampler.sample(centerX, centerZ); + if (center == null) { + return 1D; + } + double normalX = centerline.normalX(station); + double normalZ = centerline.normalZ(station); + int lowest = center.naturalHeight(); + for (double direction = -1D; direction <= 1D; direction += 2D) { + HydrologyTerrainSample side = sampler.sample( + (int) StrictMath.round(centerX + normalX * reach * direction), + (int) StrictMath.round(centerZ + normalZ * reach * direction)); + if (side != null) { + lowest = Math.min(lowest, side.naturalHeight()); + } + } + double drop = center.naturalHeight() - lowest; + double allowance = Math.max(1D, surface.maximumIncision() / 2D); + return Math.max(0D, Math.min(1D, 1D - drop / allowance)); + } + + static double[] smooth(double[] values) { + int count = values.length; + double[] smoothed = new double[count]; + for (int station = 0; station < count; station++) { + double total = 0D; + double weight = 0D; + for (int offset = -SMOOTHING_RADIUS; offset <= SMOOTHING_RADIUS; offset++) { + int index = station + offset; + if (index < 0 || index >= count) { + continue; + } + double factor = SMOOTHING_RADIUS + 1 - Math.abs(offset); + total += values[index] * factor; + weight += factor; + } + smoothed[station] = total / weight; + } + return smoothed; + } + + private static double clamp(double value, double minimum, double maximum) { + return Math.max(minimum, Math.min(maximum, value)); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionField.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionField.java new file mode 100644 index 000000000..ffc450caf --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionField.java @@ -0,0 +1,32 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.RiverFootprint; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; + +import java.util.Objects; + +public final class ErosionField { + private final Long2ObjectOpenHashMap columns; + private final int uncontainedWetCells; + + ErosionField(Long2ObjectOpenHashMap columns, int uncontainedWetCells) { + this.columns = Objects.requireNonNull(columns, "columns"); + this.uncontainedWetCells = uncontainedWetCells; + } + + public Long2ObjectOpenHashMap columns() { + return columns; + } + + public SurfaceColumn column(int x, int z) { + return columns.get(RiverFootprint.pack(x, z)); + } + + public int uncontainedWetCells() { + return uncontainedWetCells; + } + + public int size() { + return columns.size(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java new file mode 100644 index 000000000..08f8dd08e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompiler.java @@ -0,0 +1,309 @@ +package art.arcane.iris.engine.hydrology.surface; + +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.RiverFootprint; +import it.unimi.dsi.fastutil.longs.Long2DoubleOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap; +import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.longs.LongArrayList; + +import java.util.Objects; + +public final class ErosionFieldCompiler { + private static final int BLEND_SMOOTHING_RADIUS = 12; + private static final double THALWEG_FRACTION = 0.45D; + private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + private static final int[][] DIAGONALS = {{1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; + private static final long OUTLINE_SALT = 0x4f55544c494e45L; + private static final long BED_SALT = 0x424544L; + private static final double EPSILON = 1.0E-9D; + + private final HydrologyPlannerSettings.Surface surface; + private final HydrologyTerrainSampler sampler; + private final int seaLevel; + + public ErosionFieldCompiler( + HydrologyPlannerSettings.Surface surface, + HydrologyTerrainSampler sampler, + int seaLevel + ) { + this.surface = Objects.requireNonNull(surface, "surface"); + this.sampler = Objects.requireNonNull(sampler, "sampler"); + this.seaLevel = seaLevel; + } + + public ErosionField compile( + long courseSeed, + SurfaceCenterline centerline, + ChannelProfile channel, + ValleyProfile valley, + SurfaceTerminal terminal, + int apronLimit + ) { + HydrologyPlannerSettings.Banks banks = surface.banks(); + int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations(); + double roughness = banks.roughness(); + double shoreWidth = surface.shoreWidth(); + int freeboard = banks.freeboard(); + boolean[] basin = basins(valley, channel, count); + double[][] blendWidth = blendWidths(centerline, channel, valley, count); + Long2IntOpenHashMap nearest = new Long2IntOpenHashMap(); + nearest.defaultReturnValue(-1); + Long2DoubleOpenHashMap distance = new Long2DoubleOpenHashMap(); + for (int station = 0; station < count; station++) { + double widest = Math.max(blendWidth[station][0], blendWidth[station][1]); + int radius = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + roughness) + shoreWidth + widest) + 1; + int stationX = centerline.x()[station]; + int stationZ = centerline.z()[station]; + for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) { + for (int deltaX = -radius; deltaX <= radius; deltaX++) { + int cellX = stationX + deltaX; + int cellZ = stationZ + deltaZ; + double cellDistance = centerline.distanceToSegment(station, cellX, cellZ); + if (cellDistance > radius) { + continue; + } + long key = RiverFootprint.pack(cellX, cellZ); + int existing = nearest.get(key); + // A cell on a station's cross-section row ties between the two segments meeting + // there; the later station owns it so the row uses the head its own ring bounded. + if (existing < 0 + || cellDistance < distance.get(key) - EPSILON + || Math.abs(cellDistance - distance.get(key)) <= EPSILON && station > existing) { + nearest.put(key, station); + distance.put(key, cellDistance); + } + } + } + } + int oceanStart = oceanStart(centerline, valley, terminal); + Long2ObjectOpenHashMap columns = new Long2ObjectOpenHashMap<>(nearest.size()); + LongArrayList wetKeys = new LongArrayList(); + for (long key : nearest.keySet()) { + int station = nearest.get(key); + double cellDistance = distance.get(key); + int cellX = RiverFootprint.unpackX(key); + int cellZ = RiverFootprint.unpackZ(key); + HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); + int head = valley.head()[station]; + double halfWidth = channel.halfWidth()[station]; + double outline = halfWidth * (1D + roughness * SurfaceNoise.signed( + courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength())); + outline = Math.max(Math.max(0.5D, 0.6D * halfWidth), Math.min(1.4D * halfWidth, outline)); + boolean wet = cellDistance <= outline + 0.25D; + if (!SurfaceCellAdmission.writable(terrain, seaLevel)) { + if (terrain != null && wet && terminal == SurfaceTerminal.OCEAN_MOUTH + && station >= valley.exposedStations() - 1 + && station - oceanStart < apronLimit + && cellDistance <= apronLimit + 0.25D) { + columns.put(key, new SurfaceColumn( + cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, terrain.naturalHeight(), seaLevel, true)); + } + continue; + } + int natural = terrain.naturalHeight(); + if (wet) { + double normalized = Math.min(1D, cellDistance / Math.max(0.5D, outline)); + double depth = channel.depth()[station]; + double local = 1D + (depth - 1D) * bowl(normalized) + + 0.5D * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength()) + + (basin[station] ? 1D : 0D); + int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local))); + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false)); + wetKeys.add(key); + continue; + } + int bankTop = head + freeboard; + if (cellDistance <= outline + shoreWidth + 0.25D) { + int height = Math.min(natural, bankTop); + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false)); + continue; + } + int cut = natural - bankTop; + if (cut <= 0) { + continue; + } + double side = side(centerline, station, cellX, cellZ); + double width = blendWidth[station][side < 0D ? 0 : 1]; + double progress = (cellDistance - outline - shoreWidth) / width; + if (progress >= 1D) { + continue; + } + int height = Math.min(natural, (int) StrictMath.round(bankTop + cut * SurfaceNoise.smoothStep(progress))); + columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.BANK, height, height, false)); + } + int uncontained = contain(columns, wetKeys, freeboard); + connectSteps(columns, wetKeys); + return new ErosionField(columns, uncontained); + } + + /** + * Where the head steps down between neighbouring wet cells, the upper bed is lowered so its water + * column reaches the lower head; the water stays one connected body across every step. + */ + private static void connectSteps(Long2ObjectOpenHashMap columns, LongArrayList wetKeys) { + for (int index = 0; index < wetKeys.size(); index++) { + long key = wetKeys.getLong(index); + SurfaceColumn wet = columns.get(key); + int bed = wet.height(); + for (int[] offset : CARDINALS) { + SurfaceColumn neighbour = columns.get(RiverFootprint.pack(wet.x() + offset[0], wet.z() + offset[1])); + if (neighbour == null || neighbour.role() != SurfaceRole.CHANNEL || neighbour.apron()) { + continue; + } + if (neighbour.headY() < wet.headY()) { + bed = Math.min(bed, neighbour.headY() - 1); + } + } + if (bed < wet.height()) { + columns.put(key, wet.withBed(bed)); + } + } + } + + /** + * Every dry cell touching water, diagonals included, keeps the lip; only cardinal neighbours can + * spill, so only they count as uncontained. + */ + private int contain(Long2ObjectOpenHashMap columns, LongArrayList wetKeys, int freeboard) { + int uncontained = 0; + for (int index = 0; index < wetKeys.size(); index++) { + SurfaceColumn wet = columns.get(wetKeys.getLong(index)); + uncontained += lip(columns, wet, CARDINALS, freeboard, true); + lip(columns, wet, DIAGONALS, freeboard, false); + } + return uncontained; + } + + private int lip( + Long2ObjectOpenHashMap columns, + SurfaceColumn wet, + int[][] offsets, + int freeboard, + boolean countSpills + ) { + int uncontained = 0; + for (int[] offset : offsets) { + long key = RiverFootprint.pack(wet.x() + offset[0], wet.z() + offset[1]); + SurfaceColumn neighbour = columns.get(key); + if (neighbour == null) { + if (countSpills && wet.headY() != seaLevel) { + uncontained++; + } + continue; + } + if (neighbour.role() == SurfaceRole.CHANNEL) { + continue; + } + int required = wet.headY() + freeboard; + if (neighbour.height() < required) { + int raised = Math.min(neighbour.terrain().naturalHeight(), required); + neighbour = neighbour.withHeight(raised); + columns.put(key, neighbour); + } + if (countSpills && neighbour.height() < wet.headY()) { + uncontained++; + } + } + return uncontained; + } + + /** First mouth station whose center is ocean; the apron is measured from there, not from the mouth segment start. */ + private int oceanStart(SurfaceCenterline centerline, ValleyProfile valley, SurfaceTerminal terminal) { + int count = centerline.size(); + if (terminal != SurfaceTerminal.OCEAN_MOUTH) { + return count; + } + for (int station = Math.max(0, valley.exposedStations() - 1); station < count; station++) { + HydrologyTerrainSample center = sampler.sample(centerline.x()[station], centerline.z()[station]); + if (!SurfaceCellAdmission.writable(center, seaLevel)) { + return station; + } + } + return count; + } + + private boolean[] basins(ValleyProfile valley, ChannelProfile channel, int count) { + boolean[] basin = new boolean[count]; + for (int station = 1; station < count; station++) { + int drop = valley.head()[station - 1] - valley.head()[station]; + if (drop < 2) { + continue; + } + int length = Math.max(2, (int) StrictMath.round(2D * channel.halfWidth()[station])); + for (int following = station; following < Math.min(count, station + length); following++) { + basin[following] = true; + } + } + return basin; + } + + private double[][] blendWidths(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley, int count) { + HydrologyPlannerSettings.Banks banks = surface.banks(); + double[][] widths = new double[count][2]; + for (int station = 0; station < count; station++) { + double halfWidth = channel.halfWidth()[station]; + double probe = halfWidth + surface.shoreWidth() + Math.max(2D, halfWidth); + int bankTop = valley.head()[station] + banks.freeboard(); + for (int side = 0; side < 2; side++) { + double direction = side == 0 ? -1D : 1D; + int probeX = (int) StrictMath.round(centerline.x()[station] + centerline.normalX(station) * probe * direction); + int probeZ = (int) StrictMath.round(centerline.z()[station] + centerline.normalZ(station) * probe * direction); + HydrologyTerrainSample terrain = sampler.sample(probeX, probeZ); + double cut = terrain == null ? 0D : Math.max(0D, terrain.naturalHeight() - bankTop); + double width = cut * banks.blendSlope() * channel.bankMultiplier()[station]; + widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width)); + } + } + return smoothWidths(widths, BLEND_SMOOTHING_RADIUS); + } + + /** + * Moving average along the course so the valley outline widens and narrows gradually instead of + * stepping between neighbouring stations. + */ + static double[][] smoothWidths(double[][] widths, int radius) { + int count = widths.length; + double[][] smoothed = new double[count][2]; + for (int side = 0; side < 2; side++) { + double sum = 0D; + int window = 0; + int head = -1; + int tail = 0; + for (int station = 0; station < count; station++) { + while (head + 1 < count && head + 1 <= station + radius) { + head++; + sum += widths[head][side]; + window++; + } + while (tail < station - radius) { + sum -= widths[tail][side]; + tail++; + window--; + } + smoothed[station][side] = sum / window; + } + } + return smoothed; + } + + /** + * Bed depth factor across the channel: a level thalweg over the inner part of the width that eases + * up to a one-block edge, so the channel reads as a broad bowl rather than a V-shaped trough. + */ + static double bowl(double normalized) { + if (normalized <= THALWEG_FRACTION) { + return 1D; + } + double t = Math.min(1D, (normalized - THALWEG_FRACTION) / (1D - THALWEG_FRACTION)); + return 1D - t * t * (3D - 2D * t); + } + + private static double side(SurfaceCenterline centerline, int station, int cellX, int cellZ) { + double deltaX = cellX - centerline.x()[station]; + double deltaZ = cellZ - centerline.z()[station]; + return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmission.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmission.java new file mode 100644 index 000000000..245863dba --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmission.java @@ -0,0 +1,12 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; + +public final class SurfaceCellAdmission { + private SurfaceCellAdmission() { + } + + public static boolean writable(HydrologyTerrainSample terrain, int seaLevel) { + return terrain != null && !terrain.ocean() && terrain.naturalHeight() > seaLevel; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterline.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterline.java new file mode 100644 index 000000000..1f1db8b4e --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterline.java @@ -0,0 +1,109 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import it.unimi.dsi.fastutil.ints.IntArrayList; + +import java.util.List; +import java.util.Objects; + +public record SurfaceCenterline(int[] x, int[] z, double[] tangentX, double[] tangentZ, int[] pathIndex) { + private static final int TANGENT_WINDOW = 6; + + public static SurfaceCenterline densify(List path) { + Objects.requireNonNull(path, "path"); + if (path.isEmpty()) { + throw new IllegalArgumentException("A surface centerline requires at least one path point."); + } + IntArrayList xs = new IntArrayList(); + IntArrayList zs = new IntArrayList(); + IntArrayList indices = new IntArrayList(); + HydrologyPoint first = path.getFirst(); + xs.add(first.x()); + zs.add(first.z()); + indices.add(0); + for (int pair = 0; pair + 1 < path.size(); pair++) { + HydrologyPoint start = path.get(pair); + HydrologyPoint end = path.get(pair + 1); + int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z())); + for (int step = 1; step <= steps; step++) { + double progress = step / (double) steps; + int cellX = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress); + int cellZ = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress); + int last = xs.size() - 1; + if (xs.getInt(last) == cellX && zs.getInt(last) == cellZ) { + continue; + } + xs.add(cellX); + zs.add(cellZ); + indices.add(pair); + } + } + return fromStations(xs.toIntArray(), zs.toIntArray(), indices.toIntArray()); + } + + private static SurfaceCenterline fromStations(int[] xs, int[] zs, int[] indices) { + int count = xs.length; + double[] tangentX = new double[count]; + double[] tangentZ = new double[count]; + for (int station = 0; station < count; station++) { + int from = Math.max(0, station - TANGENT_WINDOW); + int to = Math.min(count - 1, station + TANGENT_WINDOW); + double vectorX = xs[to] - xs[from]; + double vectorZ = zs[to] - zs[from]; + double length = StrictMath.hypot(vectorX, vectorZ); + if (length <= 0D) { + tangentX[station] = 1D; + tangentZ[station] = 0D; + } else { + tangentX[station] = vectorX / length; + tangentZ[station] = vectorZ / length; + } + } + return new SurfaceCenterline(xs, zs, tangentX, tangentZ, indices); + } + + public int size() { + return x.length; + } + + public double normalX(int station) { + return -tangentZ[station]; + } + + public double normalZ(int station) { + return tangentX[station]; + } + + public double distanceToSegment(int station, double pointX, double pointZ) { + double startX = x[station]; + double startZ = z[station]; + if (station + 1 >= x.length) { + return StrictMath.hypot(pointX - startX, pointZ - startZ); + } + double endX = x[station + 1]; + double endZ = z[station + 1]; + double segmentX = endX - startX; + double segmentZ = endZ - startZ; + double lengthSquared = segmentX * segmentX + segmentZ * segmentZ; + double progress = lengthSquared <= 0D + ? 0D + : ((pointX - startX) * segmentX + (pointZ - startZ) * segmentZ) / lengthSquared; + progress = Math.max(0D, Math.min(1D, progress)); + double closestX = startX + segmentX * progress; + double closestZ = startZ + segmentZ * progress; + return StrictMath.hypot(pointX - closestX, pointZ - closestZ); + } + + public SurfaceCenterline truncate(int stations) { + if (stations < 1 || stations > x.length) { + throw new IllegalArgumentException("Cannot truncate a centerline of " + x.length + " stations to " + stations + "."); + } + int[] xs = new int[stations]; + int[] zs = new int[stations]; + int[] indices = new int[stations]; + System.arraycopy(x, 0, xs, 0, stations); + System.arraycopy(z, 0, zs, 0, stations); + System.arraycopy(pathIndex, 0, indices, 0, stations); + return fromStations(xs, zs, indices); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceColumn.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceColumn.java new file mode 100644 index 000000000..fac10c2c1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceColumn.java @@ -0,0 +1,31 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; + +import java.util.Objects; + +public record SurfaceColumn( + int x, + int z, + HydrologyTerrainSample terrain, + int station, + SurfaceRole role, + int height, + int headY, + boolean apron +) { + public SurfaceColumn { + Objects.requireNonNull(terrain, "terrain"); + Objects.requireNonNull(role, "role"); + } + + /** Dry columns sit at their own height; the head follows it. */ + public SurfaceColumn withHeight(int replacementHeight) { + return new SurfaceColumn(x, z, terrain, station, role, replacementHeight, replacementHeight, apron); + } + + /** Wet columns keep their head; only the bed moves. */ + public SurfaceColumn withBed(int replacementBed) { + return new SurfaceColumn(x, z, terrain, station, role, replacementBed, headY, apron); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java new file mode 100644 index 000000000..0df14d9af --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilder.java @@ -0,0 +1,99 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; + +import java.util.List; +import java.util.Objects; + +public final class SurfaceCourseBuilder { + private final HydrologyPlannerSettings.Surface surface; + private final HydrologyTerrainSampler sampler; + private final HydrologyGeometrySampler geometry; + private final int seaLevel; + private final int minimumCourseLength; + + public SurfaceCourseBuilder( + HydrologyPlannerSettings.Surface surface, + HydrologyTerrainSampler sampler, + HydrologyGeometrySampler geometry, + int seaLevel, + int minimumCourseLength + ) { + this.surface = Objects.requireNonNull(surface, "surface"); + this.sampler = Objects.requireNonNull(sampler, "sampler"); + this.geometry = Objects.requireNonNull(geometry, "geometry"); + this.seaLevel = seaLevel; + this.minimumCourseLength = minimumCourseLength; + } + + public SurfaceCourseResult build( + long worldSeed, + long courseId, + String profileKey, + List path, + SurfaceTerminal terminal, + int terminalHead + ) { + SurfaceCenterline centerline = SurfaceCenterline.densify(path); + boolean directOcean = terminal == SurfaceTerminal.OCEAN_MOUTH; + ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, geometry) + .build(centerline, profileKey, directOcean); + ValleyProfile valley = new ValleyProfileSolver(surface, sampler, seaLevel, minimumCourseLength) + .solve(centerline, channel, terminal, terminalHead); + if (!valley.accepted()) { + return SurfaceCourseResult.rejected(valley.rejection(), valley.rejectionDetail()); + } + int exposed = valley.exposedStations(); + boolean coastalDrop = directOcean && valley.head()[exposed - 1] > seaLevel; + int stations = coastalDrop ? exposed + 1 : exposed; + int[] x = new int[stations]; + int[] z = new int[stations]; + int[] head = new int[stations]; + double[] width = new double[stations]; + double[] depth = new double[stations]; + for (int station = 0; station < exposed; station++) { + x[station] = centerline.x()[station]; + z[station] = centerline.z()[station]; + head[station] = valley.head()[station]; + width[station] = channel.halfWidth()[station] * 2D; + depth[station] = channel.depth()[station]; + } + if (coastalDrop) { + int last = exposed - 1; + if (exposed < centerline.size()) { + x[exposed] = centerline.x()[exposed]; + z[exposed] = centerline.z()[exposed]; + } else { + x[exposed] = x[last] + (int) StrictMath.round(centerline.tangentX()[last]); + z[exposed] = z[last] + (int) StrictMath.round(centerline.tangentZ()[last]); + if (x[exposed] == x[last] && z[exposed] == z[last]) { + x[exposed] = x[last] + 1; + } + } + head[exposed] = seaLevel; + width[exposed] = width[last]; + depth[exposed] = depth[last]; + } + List segments = SurfaceSegmentLabeler.label( + worldSeed, courseId, x, z, head, width, depth, surface.banks()); + if (segments.isEmpty()) { + return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, stations); + } + HydraulicSegment last = segments.getLast(); + int lastStation = stations - 1; + return new SurfaceCourseResult( + segments, + head[lastStation], + last.width(), + last.depth(), + new HydrologyPoint(x[lastStation], head[lastStation], z[lastStation]), + null, + 0 + ); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java new file mode 100644 index 000000000..e7b08acf4 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseResult.java @@ -0,0 +1,30 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.engine.hydrology.HydrologyPoint; + +import java.util.List; +import java.util.Objects; + +public record SurfaceCourseResult( + List segments, + int lastHead, + int lastWidth, + int lastDepth, + HydrologyPoint pathEnd, + HydrologyCandidateRejection rejection, + int rejectionDetail +) { + public SurfaceCourseResult { + segments = List.copyOf(Objects.requireNonNull(segments, "segments")); + } + + public boolean accepted() { + return rejection == null; + } + + public static SurfaceCourseResult rejected(HydrologyCandidateRejection rejection, int detail) { + return new SurfaceCourseResult(List.of(), 0, 0, 0, null, Objects.requireNonNull(rejection, "rejection"), detail); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFeatureRefs.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFeatureRefs.java new file mode 100644 index 000000000..7a2087d35 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFeatureRefs.java @@ -0,0 +1,64 @@ +package art.arcane.iris.engine.hydrology.surface; + +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.HydrologyHash; + +import java.util.HashMap; + +public final class SurfaceFeatureRefs { + private static final int ROLE_CHANNEL = 1; + private static final int ROLE_SHORE = 2; + private static final int ROLE_GRADING = 3; + private static final int ROLE_SOURCE = 6; + + private final long courseId; + private final HashMap features; + + public SurfaceFeatureRefs(long courseId) { + this.courseId = courseId; + this.features = new HashMap<>(); + } + + public HydrologyFeatureRef feature( + HydraulicSegment segment, + SurfaceRole role, + boolean source, + int x, + int y, + int z, + int flowX, + int flowZ + ) { + int roleCode = source ? ROLE_SOURCE : switch (role) { + case CHANNEL -> ROLE_CHANNEL; + case SHORE -> ROLE_SHORE; + case BANK -> ROLE_GRADING; + }; + int featureFlowX = Integer.compare(flowX, 0); + int featureFlowZ = Integer.compare(flowZ, 0); + Key key = new Key(segment.id(), segment.type(), roleCode, featureFlowX, featureFlowZ); + HydrologyFeatureRef existing = features.get(key); + if (existing != null) { + return existing; + } + HydrologyFeatureRef created = new HydrologyFeatureRef( + HydrologyHash.mix(courseId, segment.id(), segment.type().ordinal(), roleCode, featureFlowX, featureFlowZ), + segment.type(), + courseId, + segment.id(), + x, + y, + z, + featureFlowX, + featureFlowZ, + source + ); + features.put(key, created); + return created; + } + + private record Key(long segmentId, HydrologyFeatureType type, int role, int flowX, int flowZ) { + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprint.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprint.java new file mode 100644 index 000000000..9cc8cc298 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprint.java @@ -0,0 +1,18 @@ +package art.arcane.iris.engine.hydrology.surface; + +import java.util.List; +import java.util.Objects; + +public record SurfaceFootprint(List columns, int uncontainedWetCells) { + public SurfaceFootprint { + columns = List.copyOf(Objects.requireNonNull(columns, "columns")); + } + + public static SurfaceFootprint empty() { + return new SurfaceFootprint(List.of(), 0); + } + + public boolean isEmpty() { + return columns.isEmpty(); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java new file mode 100644 index 000000000..a92512ca1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java @@ -0,0 +1,276 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +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.HydrologyHash; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; +import art.arcane.iris.engine.hydrology.RiverCourse; +import art.arcane.iris.engine.hydrology.RiverCourseType; +import art.arcane.iris.engine.hydrology.RiverFootprint; +import it.unimi.dsi.fastutil.ints.IntArrayList; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class SurfaceFootprintCompiler { + private static final long COURSE_SEED_SALT = 0x535552464143455fL; + + private final HydrologyPlannerSettings settings; + private final HydrologyTerrainSampler sampler; + private final HydrologyGeometrySampler geometry; + + public SurfaceFootprintCompiler( + HydrologyPlannerSettings settings, + HydrologyTerrainSampler sampler, + HydrologyGeometrySampler geometry + ) { + this.settings = Objects.requireNonNull(settings, "settings"); + this.sampler = Objects.requireNonNull(sampler, "sampler"); + this.geometry = Objects.requireNonNull(geometry, "geometry"); + } + + public static boolean exposedSegment(HydraulicSegment segment) { + return segment.type().isSurface() && !segment.fallingFluid(); + } + + public SurfaceFootprint compile(RiverCourse course) { + boolean pool = course.type() == RiverCourseType.SURFACE_POOL; + if (course.type() != RiverCourseType.SURFACE && !pool) { + return SurfaceFootprint.empty(); + } + ArrayList exposed = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + if (!exposedSegment(segment)) { + break; + } + exposed.add(segment); + } + if (exposed.isEmpty()) { + return SurfaceFootprint.empty(); + } + Stations stations = stations(exposed); + if (stations.count() < 1) { + return SurfaceFootprint.empty(); + } + SurfaceTerminal terminal = pool ? SurfaceTerminal.SINKHOLE : terminal(course, exposed); + SurfaceCenterline centerline = SurfaceCenterline.densify(stations.points()); + ChannelProfile channel = pool + ? poolProfile(exposed.getFirst(), centerline) + : new ChannelProfileBuilder(settings.surface(), sampler, geometry) + .build(centerline, course.profileKey(), terminal == SurfaceTerminal.OCEAN_MOUTH); + String poolBiome = pool ? poolBiome(course.profileKey()) : null; + ValleyProfile valley = ValleyProfile.fromHeads(stations.head(), stations.exposedStations()); + ErosionField field = new ErosionFieldCompiler(settings.surface(), sampler, settings.seaLevel()).compile( + HydrologyHash.mix(course.id(), COURSE_SEED_SALT), + centerline, + channel, + valley, + terminal, + settings.outlets().maximumOceanApron() + ); + ArrayList ordered = new ArrayList<>(field.columns().values()); + ordered.sort(Comparator + .comparingInt(SurfaceColumn::station) + .thenComparingLong((SurfaceColumn column) -> RiverFootprint.pack(column.x(), column.z()))); + SurfaceFeatureRefs features = new SurfaceFeatureRefs(course.id()); + ArrayList columns = new ArrayList<>(ordered.size()); + for (SurfaceColumn column : ordered) { + HydraulicSegment segment = exposed.get(stations.segmentIndex()[column.station()]); + int flowX = (int) StrictMath.round(centerline.tangentX()[column.station()]); + int flowZ = (int) StrictMath.round(centerline.tangentZ()[column.station()]); + boolean source = column.station() == 0 && column.role() == SurfaceRole.CHANNEL && !column.apron() + && column.x() == centerline.x()[0] && column.z() == centerline.z()[0]; + int y = column.role() == SurfaceRole.CHANNEL ? column.headY() : column.height(); + HydrologyFeatureRef feature = features.feature(segment, column.role(), source, column.x(), y, column.z(), flowX, flowZ); + columns.add(new SurfaceLayerColumn( + column.x(), + column.z(), + column.terrain(), + pool ? poolLayer(feature, column, course.profileKey(), poolBiome) : layer(feature, column, course.profileKey()), + column.role(), + column.apron() + )); + } + return new SurfaceFootprint(columns, field.uncontainedWetCells()); + } + + /** A pool's bowl and rim carry the pool biome when one is configured, otherwise the surrounding biome. */ + private static HydrologyColumnLayer poolLayer( + HydrologyFeatureRef feature, + SurfaceColumn column, + String profileKey, + String poolBiome + ) { + HydrologyTerrainSample terrain = column.terrain(); + String biome = poolBiome == null ? terrain.parentBiomeKey() : poolBiome; + boolean channel = column.role() == SurfaceRole.CHANNEL; + boolean shore = column.role() == SurfaceRole.SHORE; + return new HydrologyColumnLayer( + feature, + column.height(), + column.headY(), + column.headY(), + channel, + shore, + !channel, + channel, + false, + false, + true, + channel, + false, + profileKey, + biome, + biome, + biome, + biome, + terrain.floodedCaveBiomeKey() + ); + } + + private ChannelProfile poolProfile(HydraulicSegment segment, SurfaceCenterline centerline) { + int count = centerline.size(); + double[] halfWidth = new double[count]; + double[] depth = new double[count]; + double[] bank = new double[count]; + for (int station = 0; station < count; station++) { + HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]); + halfWidth[station] = Math.max(0.5D, segment.width() / 2D); + depth[station] = Math.max(1D, segment.depth()); + bank[station] = terrain == null ? 1D : terrain.bankMultiplier(); + } + return new ChannelProfile(halfWidth, depth, bank); + } + + private String poolBiome(String poolId) { + for (HydrologyPlannerSettings.SurfacePool pool : settings.surfacePools()) { + if (pool.id().equals(poolId)) { + return pool.biomeKey(); + } + } + return null; + } + + private static HydrologyColumnLayer layer(HydrologyFeatureRef feature, SurfaceColumn column, String profileKey) { + HydrologyTerrainSample terrain = column.terrain(); + if (column.apron()) { + return new HydrologyColumnLayer( + feature, + column.headY(), + column.headY(), + column.headY(), + true, false, false, true, false, false, false, false, true, + profileKey, + terrain.surfaceBiomeKey(), + terrain.mouthBiomeKey(), + terrain.shoreBiomeKey(), + terrain.bankBiomeKey(), + terrain.floodedCaveBiomeKey() + ); + } + boolean channel = column.role() == SurfaceRole.CHANNEL; + boolean shore = column.role() == SurfaceRole.SHORE; + return new HydrologyColumnLayer( + feature, + column.height(), + column.headY(), + column.headY(), + channel, + shore, + !channel, + channel, + false, + false, + true, + channel, + false, + profileKey, + terrain.surfaceBiomeKey(), + terrain.mouthBiomeKey(), + terrain.shoreBiomeKey(), + terrain.bankBiomeKey(), + terrain.floodedCaveBiomeKey() + ); + } + + private static SurfaceTerminal terminal(RiverCourse course, List exposed) { + if (exposed.getLast().type() == HydrologyFeatureType.MOUTH) { + return SurfaceTerminal.OCEAN_MOUTH; + } + for (HydraulicSegment segment : course.segments()) { + if (segment.type() == HydrologyFeatureType.MOUTH) { + return SurfaceTerminal.OCEAN_MOUTH; + } + if (segment.type() == HydrologyFeatureType.COASTAL_GROTTO) { + return SurfaceTerminal.COASTAL_GROTTO; + } + } + return SurfaceTerminal.SINKHOLE; + } + + private static Stations stations(List exposed) { + ArrayList points = new ArrayList<>(); + IntArrayList segmentIndices = new IntArrayList(); + IntArrayList heads = new IntArrayList(); + int exposedStations = 0; + for (int segmentIndex = 0; segmentIndex < exposed.size(); segmentIndex++) { + HydraulicSegment segment = exposed.get(segmentIndex); + boolean mouth = segment.type() == HydrologyFeatureType.MOUTH; + List centerline = segment.centerline(); + for (int pointIndex = 0; pointIndex < centerline.size(); pointIndex++) { + HydrologyPoint current = centerline.get(pointIndex); + if (pointIndex == 0) { + appendStation(points, segmentIndices, heads, current, segmentIndex); + continue; + } + HydrologyPoint previous = centerline.get(pointIndex - 1); + int steps = Math.max(Math.abs(current.x() - previous.x()), Math.abs(current.z() - previous.z())); + for (int step = 1; step <= steps; step++) { + double progress = step / (double) steps; + HydrologyPoint cell = new HydrologyPoint( + (int) StrictMath.round(previous.x() + (current.x() - previous.x()) * progress), + (int) StrictMath.round(previous.y() + (current.y() - previous.y()) * progress), + (int) StrictMath.round(previous.z() + (current.z() - previous.z()) * progress) + ); + appendStation(points, segmentIndices, heads, cell, segmentIndex); + } + } + if (!mouth) { + exposedStations = points.size(); + } + } + return new Stations(points, segmentIndices.toIntArray(), heads.toIntArray(), exposedStations); + } + + private static void appendStation( + ArrayList points, + IntArrayList segmentIndices, + IntArrayList heads, + HydrologyPoint cell, + int segmentIndex + ) { + if (!points.isEmpty()) { + HydrologyPoint last = points.getLast(); + if (last.x() == cell.x() && last.z() == cell.z()) { + return; + } + } + points.add(cell); + segmentIndices.add(segmentIndex); + heads.add(cell.y()); + } + + private record Stations(List points, int[] segmentIndex, int[] head, int exposedStations) { + private int count() { + return points.size(); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceLayerColumn.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceLayerColumn.java new file mode 100644 index 000000000..d0105f257 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceLayerColumn.java @@ -0,0 +1,21 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; + +import java.util.Objects; + +public record SurfaceLayerColumn( + int x, + int z, + HydrologyTerrainSample terrain, + HydrologyColumnLayer layer, + SurfaceRole role, + boolean apron +) { + public SurfaceLayerColumn { + Objects.requireNonNull(terrain, "terrain"); + Objects.requireNonNull(layer, "layer"); + Objects.requireNonNull(role, "role"); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoise.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoise.java new file mode 100644 index 000000000..8803cd855 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoise.java @@ -0,0 +1,36 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyHash; + +public final class SurfaceNoise { + private SurfaceNoise() { + } + + public static double value(long seed, int x, int z, int wavelength) { + int span = Math.max(1, wavelength); + int cellX = Math.floorDiv(x, span); + int cellZ = Math.floorDiv(z, span); + double localX = smoothStep(Math.floorMod(x, span) / (double) span); + double localZ = smoothStep(Math.floorMod(z, span) / (double) span); + double top = lerp(corner(seed, cellX, cellZ), corner(seed, cellX + 1, cellZ), localX); + double bottom = lerp(corner(seed, cellX, cellZ + 1), corner(seed, cellX + 1, cellZ + 1), localX); + return lerp(top, bottom, localZ); + } + + public static double signed(long seed, int x, int z, int wavelength) { + return value(seed, x, z, wavelength) * 2D - 1D; + } + + public static double smoothStep(double progress) { + double clamped = Math.max(0D, Math.min(1D, progress)); + return clamped * clamped * (3D - 2D * clamped); + } + + private static double corner(long seed, int cellX, int cellZ) { + return HydrologyHash.unit(HydrologyHash.mix(seed, cellX, cellZ)); + } + + private static double lerp(double first, double second, double progress) { + return first + (second - first) * progress; + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRole.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRole.java new file mode 100644 index 000000000..78f6c2ec9 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceRole.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.hydrology.surface; + +public enum SurfaceRole { + CHANNEL, + SHORE, + BANK +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceSegmentLabeler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceSegmentLabeler.java new file mode 100644 index 000000000..8cafe427b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceSegmentLabeler.java @@ -0,0 +1,139 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydrologyHash; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyPoint; + +import java.util.ArrayList; +import java.util.List; + +public final class SurfaceSegmentLabeler { + private static final long SEGMENT_SALT = 0x5345474d454e54L; + + private SurfaceSegmentLabeler() { + } + + public static List label( + long worldSeed, + long courseId, + int[] x, + int[] z, + int[] head, + double[] width, + double[] depth, + HydrologyPlannerSettings.Banks banks + ) { + int pairs = head.length - 1; + if (pairs < 1) { + return List.of(); + } + ArrayList runs = new ArrayList<>(); + for (int pair = 0; pair < pairs; pair++) { + HydrologyFeatureType type = pairType(head[pair] - head[pair + 1], banks.waterfallMinimumDrop()); + if (!runs.isEmpty() && runs.getLast().type == type) { + runs.getLast().end = pair; + } else { + runs.add(new Run(pair, pair, type)); + } + } + for (Run run : runs) { + if (run.type == HydrologyFeatureType.RIFFLE && run.pairs() >= 2) { + run.type = HydrologyFeatureType.CASCADE; + } + } + boolean merged = true; + while (merged) { + merged = false; + for (int index = 1; index + 1 < runs.size(); index++) { + Run previous = runs.get(index - 1); + Run current = runs.get(index); + Run next = runs.get(index + 1); + if (current.type == HydrologyFeatureType.SURFACE_POOL + && current.pairs() < banks.cascadeRun() * 2 + && isGraded(previous.type) + && isGraded(next.type)) { + previous.end = next.end; + previous.type = HydrologyFeatureType.CASCADE; + runs.remove(index + 1); + runs.remove(index); + merged = true; + break; + } + } + if (!merged) { + for (int index = 1; index < runs.size(); index++) { + Run previous = runs.get(index - 1); + Run current = runs.get(index); + if (previous.type == current.type) { + previous.end = current.end; + runs.remove(index); + merged = true; + break; + } + } + } + } + ArrayList segments = new ArrayList<>(runs.size()); + for (Run run : runs) { + int startStation = run.start; + int endStation = run.end + 1; + ArrayList centerline = new ArrayList<>(endStation - startStation + 1); + double totalWidth = 0D; + double totalDepth = 0D; + for (int station = startStation; station <= endStation; station++) { + centerline.add(new HydrologyPoint(x[station], head[station], z[station])); + totalWidth += width[station]; + totalDepth += depth[station]; + } + int stations = endStation - startStation + 1; + int segmentWidth = Math.max(1, (int) StrictMath.round(totalWidth / stations)); + int segmentDepth = Math.max(1, (int) StrictMath.round(totalDepth / stations)); + int index = segments.size(); + segments.add(new HydraulicSegment( + HydrologyHash.mix(worldSeed, SEGMENT_SALT, courseId, index, run.type.ordinal()), + courseId, + run.type, + head[startStation], + head[endStation], + segmentWidth, + segmentDepth, + false, + false, + centerline + )); + } + return List.copyOf(segments); + } + + private static HydrologyFeatureType pairType(int drop, int waterfallMinimumDrop) { + if (drop <= 0) { + return HydrologyFeatureType.SURFACE_POOL; + } + if (drop == 1) { + return HydrologyFeatureType.RIFFLE; + } + return drop >= waterfallMinimumDrop ? HydrologyFeatureType.WATERFALL : HydrologyFeatureType.CASCADE; + } + + private static boolean isGraded(HydrologyFeatureType type) { + return type == HydrologyFeatureType.RIFFLE || type == HydrologyFeatureType.CASCADE; + } + + private static final class Run { + private final int start; + private int end; + private HydrologyFeatureType type; + + private Run(int start, int end, HydrologyFeatureType type) { + this.start = start; + this.end = end; + this.type = type; + } + + private int pairs() { + return end - start + 1; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java new file mode 100644 index 000000000..9ea6c7ed0 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceTerminal.java @@ -0,0 +1,7 @@ +package art.arcane.iris.engine.hydrology.surface; + +public enum SurfaceTerminal { + OCEAN_MOUTH, + SINKHOLE, + COASTAL_GROTTO +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java new file mode 100644 index 000000000..d2a54eae7 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfile.java @@ -0,0 +1,26 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection; + +public record ValleyProfile( + int[] head, + int[] crossMin, + int[] crossMax, + int[] centerNatural, + int exposedStations, + HydrologyCandidateRejection rejection, + int rejectionDetail +) { + public boolean accepted() { + return rejection == null; + } + + public static ValleyProfile fromHeads(int[] head, int exposedStations) { + return new ValleyProfile(head, new int[0], new int[0], new int[0], exposedStations, null, 0); + } + + /** {@code detail} names the measure that failed: exposed stations, required cut, or missing head. */ + public static ValleyProfile rejected(HydrologyCandidateRejection rejection, int detail) { + return new ValleyProfile(new int[0], new int[0], new int[0], new int[0], 0, rejection, detail); + } +} diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java new file mode 100644 index 000000000..c74939e6b --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolver.java @@ -0,0 +1,124 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; + +import java.util.Objects; + +public final class ValleyProfileSolver { + private final HydrologyPlannerSettings.Surface surface; + private final HydrologyTerrainSampler sampler; + private final int seaLevel; + private final int minimumCourseLength; + + public ValleyProfileSolver( + HydrologyPlannerSettings.Surface surface, + HydrologyTerrainSampler sampler, + int seaLevel, + int minimumCourseLength + ) { + this.surface = Objects.requireNonNull(surface, "surface"); + this.sampler = Objects.requireNonNull(sampler, "sampler"); + this.seaLevel = seaLevel; + this.minimumCourseLength = minimumCourseLength; + } + + public ValleyProfile solve( + SurfaceCenterline centerline, + ChannelProfile channel, + SurfaceTerminal terminal, + int terminalHead + ) { + int count = centerline.size(); + int[] crossMin = new int[count]; + int[] crossMax = new int[count]; + int[] centerNatural = new int[count]; + double[] incisionMultiplier = new double[count]; + double roughness = surface.banks().roughness(); + int exposed = count; + for (int station = 0; station < count; station++) { + double outline = channel.halfWidth()[station] * (1D + roughness); + // Every cell from the narrowest possible outline outward can end up beside water, so it joins the minimum. + double innerOutline = channel.halfWidth()[station] * (1D - roughness); + double reach = outline + 2D; + int stationX = centerline.x()[station]; + int stationZ = centerline.z()[station]; + HydrologyTerrainSample center = sampler.sample(stationX, stationZ); + if (!SurfaceCellAdmission.writable(center, seaLevel)) { + exposed = station; + break; + } + centerNatural[station] = center.naturalHeight(); + incisionMultiplier[station] = center.incisionMultiplier(); + double normalX = centerline.normalX(station); + double normalZ = centerline.normalZ(station); + int minimum = Integer.MAX_VALUE; + int maximum = Integer.MIN_VALUE; + boolean blocked = false; + for (double offset = -reach; offset <= reach && !blocked; offset += 0.5D) { + int cellX = (int) StrictMath.round(stationX + normalX * offset); + int cellZ = (int) StrictMath.round(stationZ + normalZ * offset); + HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ); + if (!SurfaceCellAdmission.writable(terrain, seaLevel)) { + blocked = true; + break; + } + if (Math.abs(offset) < innerOutline - 0.75D) { + continue; + } + minimum = Math.min(minimum, terrain.naturalHeight()); + maximum = Math.max(maximum, terrain.naturalHeight()); + } + if (blocked) { + exposed = station; + break; + } + crossMin[station] = minimum; + crossMax[station] = maximum; + } + if (exposed < 2 || exposed < minimumCourseLength) { + return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, exposed); + } + if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH) { + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE, exposed); + } + int inset = surface.banks().inset(); + int[] head = new int[count]; + for (int station = 0; station < exposed; station++) { + int ceiling = crossMin[station] - inset; + head[station] = station == 0 ? ceiling : Math.min(head[station - 1], ceiling); + } + if (terminal == SurfaceTerminal.OCEAN_MOUTH || terminal == SurfaceTerminal.COASTAL_GROTTO) { + for (int station = 0; station < exposed; station++) { + head[station] = Math.max(head[station], seaLevel); + } + } + if (terminal == SurfaceTerminal.OCEAN_MOUTH) { + for (int station = exposed; station < count; station++) { + head[station] = seaLevel; + } + } + if (terminal == SurfaceTerminal.SINKHOLE && head[exposed - 1] < terminalHead) { + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE, terminalHead - head[exposed - 1]); + } + if (terminal == SurfaceTerminal.COASTAL_GROTTO && head[exposed - 1] < terminalHead) { + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE, terminalHead - head[exposed - 1]); + } + int maximumIncision = surface.maximumIncision(); + int deepestCut = 0; + boolean rejected = false; + for (int station = 0; station < exposed; station++) { + int permitted = Math.min(maximumIncision, (int) StrictMath.floor(maximumIncision * incisionMultiplier[station])); + int bed = head[station] - (int) StrictMath.round(channel.depth()[station]); + int cut = centerNatural[station] - bed; + deepestCut = Math.max(deepestCut, cut); + rejected |= cut > permitted; + } + if (rejected) { + return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, deepestCut); + } + return new ValleyProfile(head, crossMin, crossMax, centerNatural, exposed, null, 0); + } +} 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 348ef617e..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,7 +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.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; @@ -47,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 { @@ -102,7 +106,7 @@ public interface EngineMantle extends MatterGenerator { } default int getHighest(int x, int z, IrisData data, boolean ignoreFluid) { - return ignoreFluid ? trueHeight(x, z) : Math.max(trueHeight(x, z), getEngine().getDimension().getFluidHeight()); + return ignoreFluid ? trueHeight(x, z) : Math.max(trueHeight(x, z), getFluidHeight(x, z)); } default int trueHeight(int x, int z) { @@ -110,6 +114,10 @@ public interface EngineMantle extends MatterGenerator { } default boolean isCarved(int x, int h, int z) { + HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent(getMantle(), x, h, z); + if (hydrology != null) { + return hydrology.carves(); + } return getMantle().get(x, h, z, MatterCavern.class) != null; } @@ -125,13 +133,17 @@ public interface EngineMantle extends MatterGenerator { } default boolean isUnderwater(int x, int z) { - return getHighest(x, z, true) <= getFluidHeight(); + return getHighest(x, z, true) < getFluidHeight(x, z); } default int getFluidHeight() { return getEngine().getDimension().getFluidHeight(); } + default int getFluidHeight(int x, int z) { + return (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(x, z)); + } + default boolean isDebugSmartBore() { return getEngine().getDimension().isDebugSmartBore(); } @@ -234,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++) { @@ -249,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() { @@ -294,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 557b9b2d1..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,16 +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.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; @@ -43,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; @@ -63,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); @@ -123,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); @@ -205,12 +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) { - clearDeferredPlacement(matter, x, y, z); + 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); } - 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) { @@ -230,14 +280,28 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { return false; } - Matter matter = chunk.getOrCreate(y >> 4); - 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) { @@ -250,17 +314,30 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { return false; } - Matter matter = chunk.getOrCreate(y >> 4); - if (matter.hasSlice(PlatformBlockState.class)) { - matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); + 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; } - 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) { @@ -272,12 +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 (matter.hasSlice(PlatformBlockState.class)) { - matter.getSlice(PlatformBlockState.class).set(x & 15, y & 15, z & 15, null); + 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); } - 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) { @@ -292,14 +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 (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) { @@ -338,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; @@ -349,11 +594,27 @@ 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); } - 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(HydrologyCaveCell.class)) { + return false; + } + HydrologyCaveCell hydrology = matter.getSlice(HydrologyCaveCell.class) + .get(x & 15, y & 15, z & 15); + return hydrology != null && hydrology.protectsPlacement(); } private static void clearDeferredPlacement(Matter matter, int x, int y, int z) { @@ -362,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); @@ -426,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); @@ -450,6 +813,10 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { @Override public boolean isCarved(int x, int y, int z) { + HydrologyCaveCell hydrology = getDataIfPresent(x, y, z, HydrologyCaveCell.class); + if (hydrology != null) { + return hydrology.carves(); + } return getDataIfPresent(x, y, z, MatterCavern.class) != null; } @@ -474,14 +841,28 @@ public class MantleWriter implements IObjectPlacer, AutoCloseable { } Matter matter = chunk.get(section); - if (matter == null || !matter.hasSlice(MatterCavern.class)) { + if (matter == null) { + continue; + } + + MatterSlice cavernSlice = matter.hasSlice(MatterCavern.class) + ? matter.getSlice(MatterCavern.class) + : null; + MatterSlice hydrologySlice = matter.hasSlice(HydrologyCaveCell.class) + ? matter.getSlice(HydrologyCaveCell.class) + : null; + if (cavernSlice == null && hydrologySlice == null) { continue; } - MatterSlice slice = matter.getSlice(MatterCavern.class); int sectionBaseY = section << 4; int sectionMaxY = Math.min(cappedHeight, sectionBaseY + 16); for (int y = sectionBaseY; y < sectionMaxY; y++) { - if (slice.get(localX, y & 15, localZ) != null) { + HydrologyCaveCell hydrology = hydrologySlice == null + ? null + : hydrologySlice.get(localX, y & 15, localZ); + if (hydrology != null) { + carvedColumn[y] = hydrology.carves() ? (byte) 1 : 0; + } else if (cavernSlice != null && cavernSlice.get(localX, y & 15, localZ) != null) { carvedColumn[y] = 1; } } 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/CarveOrphanSweep.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java index a9e964f2e..e6d8addad 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/CarveOrphanSweep.java @@ -61,15 +61,14 @@ public final class CarveOrphanSweep { int[] surfaceHeights, int maxSurfaceBreakDepth, int worldCeilingY, - int[] surfaceFluidBoundaryStartY, - int fluidHeight + long[] surfaceFluidBoundaries ) { if (chunk == null) { return 0; } return sweep(surfaceHeights, maxSurfaceBreakDepth, 0, worldCeilingY, - new MantleCarveAccess(chunk, surfaceFluidBoundaryStartY, fluidHeight)); + new MantleCarveAccess(chunk, surfaceFluidBoundaries)); } public static int sweep(int[] surfaceHeights, int maxSurfaceBreakDepth, int worldFloorY, int worldCeilingY, CarveAccess access) { @@ -229,15 +228,13 @@ public final class CarveOrphanSweep { private static final class MantleCarveAccess implements CarveAccess { private final MantleChunk chunk; - private final int[] surfaceFluidBoundaryStartY; - private final int fluidHeight; + private final long[] surfaceFluidBoundaries; private MatterSlice cachedSlice; private int cachedSectionIndex = -1; - private MantleCarveAccess(MantleChunk chunk, int[] surfaceFluidBoundaryStartY, int fluidHeight) { + private MantleCarveAccess(MantleChunk chunk, long[] surfaceFluidBoundaries) { this.chunk = chunk; - this.surfaceFluidBoundaryStartY = surfaceFluidBoundaryStartY; - this.fluidHeight = fluidHeight; + this.surfaceFluidBoundaries = surfaceFluidBoundaries; } @Override @@ -261,7 +258,7 @@ public final class CarveOrphanSweep { @Override public boolean isProtected(int localX, int y, int localZ) { int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); - return SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, columnIndex, y, fluidHeight); + return SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, columnIndex, y); } @Override 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 fd838eb6d..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,6 +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.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.common.data.B; import art.arcane.volmlib.util.collection.KList; @@ -71,6 +72,12 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { discard(); return CommitResult.REJECTED_BOUNDS; } + HydrologyCaveCell hydrology = delegate.getData( + mutation.x(), mutation.y(), mutation.z(), HydrologyCaveCell.class); + if (hydrology != null && hydrology.protectsPlacement()) { + discard(); + return CommitResult.REJECTED_HYDROLOGY; + } } for (BufferedMutation mutation : mutations) { @@ -245,7 +252,8 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { enum CommitResult { COMMITTED, EMPTY, - REJECTED_BOUNDS + REJECTED_BOUNDS, + REJECTED_HYDROLOGY } private interface BufferedMutation { diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java index d67e1d452..880c9a432 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3D.java @@ -199,7 +199,7 @@ public class IrisCaveCarver3D { double thresholdPenalty, IrisRange worldYRange, int[] precomputedSurfaceHeights, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, IrisRange overrideVerticalRange, CaveFluidSupportPlan fluidSupportPlan ) { @@ -324,7 +324,7 @@ public class IrisCaveCarver3D { surfaceBreakThresholdBoost, columnMaxY, fluidMaxY, - surfaceFluidBoundaryStartY, + surfaceFluidBoundaries, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, @@ -347,7 +347,7 @@ public class IrisCaveCarver3D { surfaceBreakThresholdBoost, columnMaxY, fluidMaxY, - surfaceFluidBoundaryStartY, + surfaceFluidBoundaries, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, @@ -373,7 +373,7 @@ public class IrisCaveCarver3D { surfaceBreakThresholdBoost, columnMaxY, fluidMaxY, - surfaceFluidBoundaryStartY, + surfaceFluidBoundaries, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, @@ -397,7 +397,7 @@ public class IrisCaveCarver3D { surfaceBreakThresholdBoost, columnMaxY, fluidMaxY, - surfaceFluidBoundaryStartY, + surfaceFluidBoundaries, surfaceBreakFloorY, surfaceBreakColumn, columnThreshold, @@ -428,7 +428,7 @@ public class IrisCaveCarver3D { double surfaceBreakThresholdBoost, int[] columnMaxY, int[] fluidMaxY, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, @@ -484,7 +484,7 @@ public class IrisCaveCarver3D { } int columnIndex = activeColumnIndices[activeIndex]; - if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, columnIndex, y, fluidHeight)) { + if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, columnIndex, y)) { continue; } planeColumnIndices[planeCount] = columnIndex; @@ -560,7 +560,7 @@ public class IrisCaveCarver3D { double surfaceBreakThresholdBoost, int[] columnMaxY, int[] fluidMaxY, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, @@ -622,7 +622,7 @@ public class IrisCaveCarver3D { } int columnIndex = activeColumnIndices[activeIndex]; - if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, columnIndex, y, fluidHeight)) { + if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, columnIndex, y)) { continue; } planeColumnIndices[planeCount] = columnIndex; @@ -720,7 +720,7 @@ public class IrisCaveCarver3D { double surfaceBreakThresholdBoost, int[] columnMaxY, int[] fluidMaxY, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, @@ -822,7 +822,7 @@ public class IrisCaveCarver3D { } int index = tileIndices[columnIndex]; - if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, index, yy, fluidHeight)) { + if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, index, yy)) { continue; } double localThreshold = passThreshold[index]; @@ -870,7 +870,7 @@ public class IrisCaveCarver3D { double surfaceBreakThresholdBoost, int[] columnMaxY, int[] fluidMaxY, - int[] surfaceFluidBoundaryStartY, + long[] surfaceFluidBoundaries, int[] surfaceBreakFloorY, boolean[] surfaceBreakColumn, double[] columnThreshold, @@ -909,7 +909,7 @@ public class IrisCaveCarver3D { double density = sampleDensityOptimized(scratch, x, y, z); int carveMaxY = Math.min(columnTopY, y + sampleStep - 1); for (int yy = y; yy <= carveMaxY; yy++) { - if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaryStartY, index, yy, fluidHeight)) { + if (SurfaceFluidBoundaryPlan.protects(surfaceFluidBoundaries, index, yy)) { continue; } double localThreshold = threshold; diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java index 8764bf127..b497ede03 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/IrisStructureComponent.java @@ -70,7 +70,7 @@ public class IrisStructureComponent extends IrisMantleComponent { private static final MatterCavern CARVE_CAVERN = new MatterCavern(true, "", (byte) 3); public IrisStructureComponent(EngineMantle engineMantle) { - super(engineMantle, ReservedFlag.JIGSAW, 3); + super(engineMantle, ReservedFlag.JIGSAW, 4); } @Override 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 3afd69d85..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 @@ -104,20 +104,19 @@ public class MantleCarvingComponent extends IrisMantleComponent { PrecisionStopwatch resolveStopwatch = PrecisionStopwatch.start(); List weightedProfiles = resolveWeightedProfiles(x, z, complex, resolverState); getEngineMantle().getEngine().getMetrics().getCarveResolve().put(resolveStopwatch.getMilliseconds()); - int fluidHeight = getDimension().getFluidHeight(); - int[] surfaceFluidBoundaryStartY = blendScratch.surfaceFluidBoundaryStartY; + long[] surfaceFluidBoundaries = blendScratch.surfaceFluidBoundaries; SurfaceFluidBoundaryPlan.fill( chunkSurfaceHeights, blendScratch.fieldSurfaceHeights, blendScratch.fieldHasFluid, + blendScratch.fieldFluidHeights, FIELD_SIZE, BLEND_RADIUS, - fluidHeight, - surfaceFluidBoundaryStartY + surfaceFluidBoundaries ); CaveFluidSupportPlan fluidSupportPlan = new CaveFluidSupportPlan(); for (WeightedProfile weightedProfile : weightedProfiles) { - carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights, surfaceFluidBoundaryStartY, fluidSupportPlan); + carveProfile(weightedProfile, writer, x, z, chunkSurfaceHeights, surfaceFluidBoundaries, fluidSupportPlan); } UpperDimensionContext upperCtx = getEngineMantle().getEngine().getUpperContext(); @@ -132,8 +131,7 @@ public class MantleCarvingComponent extends IrisMantleComponent { chunkSurfaceHeights, maxSurfaceBreakDepth(weightedProfiles), writer.getMantle().getWorldHeight() - 1, - surfaceFluidBoundaryStartY, - fluidHeight + surfaceFluidBoundaries ); } } @@ -148,11 +146,11 @@ public class MantleCarvingComponent extends IrisMantleComponent { @ChunkCoordinates private void carveProfile(WeightedProfile weightedProfile, MantleWriter writer, int cx, int cz, - int[] chunkSurfaceHeights, int[] surfaceFluidBoundaryStartY, + int[] chunkSurfaceHeights, long[] surfaceFluidBoundaries, CaveFluidSupportPlan fluidSupportPlan) { IrisCaveCarver3D carver = getCarver(weightedProfile.profile); carver.carve(writer, cx, cz, weightedProfile.columnWeights, MIN_WEIGHT, THRESHOLD_PENALTY, - weightedProfile.worldYRange, chunkSurfaceHeights, surfaceFluidBoundaryStartY, null, fluidSupportPlan); + weightedProfile.worldYRange, chunkSurfaceHeights, surfaceFluidBoundaries, null, fluidSupportPlan); } private void carveUpperTerrain(UpperDimensionContext upperCtx, List normalProfiles, @@ -474,10 +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); - fillFieldFluidPresence(complex.getFluidStream(), startX, startZ, blendScratch.fieldHasFluid); + 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); } @@ -499,11 +499,20 @@ public class MantleCarvingComponent extends IrisMantleComponent { } } - private void fillFieldFluidPresence(ProceduralStream stream, int startX, int startZ, boolean[] target) { + private void fillFieldFluidPresence( + IrisComplex complex, + int startX, + int startZ, + double[] surfaceHeights, + double[] fluidHeights, + boolean[] target + ) { for (int fieldX = 0; fieldX < FIELD_SIZE; fieldX++) { int worldX = startX + fieldX; for (int fieldZ = 0; fieldZ < FIELD_SIZE; fieldZ++) { - target[(fieldX * FIELD_SIZE) + fieldZ] = B.isFluid(stream.get(worldX, startZ + fieldZ)); + int fieldIndex = (fieldX * FIELD_SIZE) + fieldZ; + target[fieldIndex] = B.isFluid(complex.getFluidStream().get(worldX, startZ + fieldZ)) + && Math.round(surfaceHeights[fieldIndex]) < Math.round(fluidHeights[fieldIndex]); } } } @@ -536,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; @@ -697,12 +696,13 @@ public class MantleCarvingComponent extends IrisMantleComponent { private final IdentityHashMap activeProfiles = new IdentityHashMap<>(); private final List profileOrder = new ArrayList<>(); private final double[] fieldSurfaceHeights = new double[FIELD_SIZE * FIELD_SIZE]; + private final double[] fieldFluidHeights = new double[FIELD_SIZE * FIELD_SIZE]; private final boolean[] fieldHasFluid = new boolean[FIELD_SIZE * FIELD_SIZE]; + private final long[] surfaceFluidBoundaries = new long[CHUNK_AREA]; private final IrisRegion[] fieldRegions = new IrisRegion[FIELD_SIZE * FIELD_SIZE]; private final IrisBiome[] fieldSurfaceBiomes = new IrisBiome[FIELD_SIZE * FIELD_SIZE]; private final IrisBiome[] fieldCaveBiomes = new IrisBiome[FIELD_SIZE * FIELD_SIZE]; private final int[] chunkSurfaceHeights = new int[CHUNK_AREA]; - private final int[] surfaceFluidBoundaryStartY = new int[CHUNK_AREA]; private final double[] chunkSurfaceHeightSamples = new double[CHUNK_AREA]; } } diff --git a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java index c351b8928..c1f611cc4 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleFloatingObjectComponent.java @@ -59,7 +59,7 @@ public class MantleFloatingObjectComponent extends IrisMantleComponent { private static final IrisObjectRotation ROTATION_NONE = IrisObjectRotation.of(0, 0, 0); public MantleFloatingObjectComponent(EngineMantle engineMantle) { - super(engineMantle, ReservedFlag.FLOATING_OBJECT, 2); + super(engineMantle, ReservedFlag.FLOATING_OBJECT, 3); } @Override 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..e20548f64 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/MantleHydrologyComponent.java @@ -0,0 +1,847 @@ +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 + "."); + } + PlannedCell candidate = new PlannedCell( + new HydrologyCaveCell(action, layer.profileKey(), layer.floodedCaveBiomeKey()), + 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 9e070482e..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,6 +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.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.IrisLogging; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; @@ -64,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; @@ -82,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, 1); + 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) { @@ -111,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()); @@ -176,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))); @@ -217,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; @@ -379,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()); @@ -392,7 +572,7 @@ public class MantleObjectComponent extends IrisMantleComponent { @ChunkCoordinates private void placeProceduralFrom( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int x, int z, @@ -551,7 +731,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } private CavePlacementAnchor findCavePlacementAnchor( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int minX, int minZ, @@ -576,11 +756,18 @@ public class MantleObjectComponent extends IrisMantleComponent { minDepthBelowSurface, anchorCache ); + HydrologyCaveCell hydrology = candidateY < 0 + ? null + : writer.getDataIfPresent(candidateX, candidateY, candidateZ, HydrologyCaveCell.class); + MatterCavern cavern = candidateY < 0 + ? null + : writer.getDataIfPresent(candidateX, candidateY, candidateZ, MatterCavern.class); if (candidateY < 0 || caveAnchorBiomeConflicts(candidateX, candidateY, candidateZ, expectedCaveBiomeKey) || !acceptsCaveAnchorFluid( underwater, - writer.getDataIfPresent(candidateX, candidateY, candidateZ, MatterCavern.class), + hydrology == null ? cavern : hydrology.asCavern(), + hydrology, candidateY, getDimension().getCaveLavaHeight())) { continue; @@ -591,6 +778,19 @@ public class MantleObjectComponent extends IrisMantleComponent { } static boolean acceptsCaveAnchorFluid(boolean underwater, MatterCavern cavern, int y, int lavaHeight) { + return acceptsCaveAnchorFluid(underwater, cavern, null, y, lavaHeight); + } + + static boolean acceptsCaveAnchorFluid( + boolean underwater, + MatterCavern cavern, + HydrologyCaveCell hydrology, + int y, + int lavaHeight + ) { + if (hydrology != null && hydrology.protectsPlacement()) { + return false; + } if (cavern == null || !cavern.isCavern()) { return false; } @@ -639,7 +839,7 @@ public class MantleObjectComponent extends IrisMantleComponent { @BlockCoordinates private ObjectPlacementResult placeObject( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int x, int z, @@ -767,7 +967,7 @@ public class MantleObjectComponent extends IrisMantleComponent { } private ObjectPlacementResult placeCaveObject( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int chunkX, int chunkZ, @@ -961,7 +1161,7 @@ public class MantleObjectComponent extends IrisMantleComponent { @ChunkCoordinates private void placeUpperObjects( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int chunkX, int chunkZ, @@ -1013,7 +1213,7 @@ public class MantleObjectComponent extends IrisMantleComponent { @ChunkCoordinates private void placeUpperObject( - MantleWriter writer, + ObjectPassPlacer writer, RNG rng, int chunkX, int chunkZ, @@ -1235,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; @@ -1248,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); @@ -1450,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 ) { @@ -1484,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; } @@ -1501,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; } @@ -1510,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; } @@ -1575,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); } @@ -1587,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; @@ -1599,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); } @@ -1622,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) { @@ -1647,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/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/mantle/components/SurfaceFluidBoundaryPlan.java b/core/src/main/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlan.java index ec0cf02e9..d01588e81 100644 --- a/core/src/main/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlan.java +++ b/core/src/main/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlan.java @@ -32,16 +32,17 @@ final class SurfaceFluidBoundaryPlan { int[] chunkSurfaceHeights, double[] fieldSurfaceHeights, boolean[] fieldHasFluid, + double[] fieldFluidHeights, int fieldSize, int padding, - int fluidHeight, - int[] boundaryStartY + long[] boundaries ) { if (chunkSurfaceHeights == null || chunkSurfaceHeights.length < CHUNK_AREA - || boundaryStartY == null || boundaryStartY.length < CHUNK_AREA + || boundaries == null || boundaries.length < CHUNK_AREA || padding < 1 || fieldSize < CHUNK_SIZE + (padding * 2) || fieldSurfaceHeights == null || fieldSurfaceHeights.length < fieldSize * fieldSize - || fieldHasFluid == null || fieldHasFluid.length < fieldSize * fieldSize) { + || fieldHasFluid == null || fieldHasFluid.length < fieldSize * fieldSize + || fieldFluidHeights == null || fieldFluidHeights.length < fieldSize * fieldSize) { throw new IllegalArgumentException("Surface fluid boundary fields do not cover a padded chunk"); } @@ -51,44 +52,67 @@ final class SurfaceFluidBoundaryPlan { int fieldZ = localZ + padding; int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); int boundaryY = NO_BOUNDARY; + int boundaryEndY = Integer.MIN_VALUE; int surfaceY = chunkSurfaceHeights[columnIndex]; int fieldIndex = (fieldX * fieldSize) + fieldZ; + int fluidHeight = roundedHeight(fieldFluidHeights[fieldIndex]); if (fieldHasFluid[fieldIndex] && surfaceY < fluidHeight) { boundaryY = surfaceY; + boundaryEndY = fluidHeight; } - boundaryY = lowerBoundary(boundaryY, fieldSurfaceHeights, fieldHasFluid, - ((fieldX - 1) * fieldSize) + fieldZ, fluidHeight); - boundaryY = lowerBoundary(boundaryY, fieldSurfaceHeights, fieldHasFluid, - ((fieldX + 1) * fieldSize) + fieldZ, fluidHeight); - boundaryY = lowerBoundary(boundaryY, fieldSurfaceHeights, fieldHasFluid, - (fieldX * fieldSize) + fieldZ - 1, fluidHeight); - boundaryY = lowerBoundary(boundaryY, fieldSurfaceHeights, fieldHasFluid, - (fieldX * fieldSize) + fieldZ + 1, fluidHeight); - boundaryStartY[columnIndex] = boundaryY; + long boundary = expandBoundary(boundaryY, boundaryEndY, fieldSurfaceHeights, + fieldHasFluid, fieldFluidHeights, ((fieldX - 1) * fieldSize) + fieldZ); + boundary = expandBoundary(startY(boundary), endY(boundary), fieldSurfaceHeights, + fieldHasFluid, fieldFluidHeights, ((fieldX + 1) * fieldSize) + fieldZ); + boundary = expandBoundary(startY(boundary), endY(boundary), fieldSurfaceHeights, + fieldHasFluid, fieldFluidHeights, (fieldX * fieldSize) + fieldZ - 1); + boundaries[columnIndex] = expandBoundary(startY(boundary), endY(boundary), fieldSurfaceHeights, + fieldHasFluid, fieldFluidHeights, (fieldX * fieldSize) + fieldZ + 1); } } } - static boolean protects(int[] boundaryStartY, int columnIndex, int y, int fluidHeight) { - return boundaryStartY != null - && columnIndex >= 0 - && columnIndex < boundaryStartY.length - && y >= boundaryStartY[columnIndex] - && y <= fluidHeight; + static boolean protects(long[] boundaries, int columnIndex, int y) { + if (boundaries == null || columnIndex < 0 || columnIndex >= boundaries.length) { + return false; + } + long boundary = boundaries[columnIndex]; + return y >= startY(boundary) && y <= endY(boundary); } - private static int lowerBoundary( + static int startY(long boundary) { + return (int) (boundary >> 32); + } + + static int endY(long boundary) { + return (int) boundary; + } + + private static long expandBoundary( int currentBoundaryY, + int currentBoundaryEndY, double[] fieldSurfaceHeights, boolean[] fieldHasFluid, - int fieldIndex, - int fluidHeight + double[] fieldFluidHeights, + int fieldIndex ) { + int fluidHeight = roundedHeight(fieldFluidHeights[fieldIndex]); int neighborSurfaceY = (int) Math.round(fieldSurfaceHeights[fieldIndex]); if (!fieldHasFluid[fieldIndex] || neighborSurfaceY >= fluidHeight) { - return currentBoundaryY; + return boundary(currentBoundaryY, currentBoundaryEndY); } - return Math.min(currentBoundaryY, neighborSurfaceY + 1); + return boundary( + Math.min(currentBoundaryY, neighborSurfaceY + 1), + Math.max(currentBoundaryEndY, fluidHeight) + ); + } + + private static int roundedHeight(double height) { + return Double.isFinite(height) ? (int) Math.round(height) : Integer.MIN_VALUE; + } + + static long boundary(int startY, int endY) { + return ((long) startY << 32) | (endY & 0xffffffffL); } } 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 01c701e8f..814cd6333 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,6 +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.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; @@ -56,6 +58,7 @@ public class IrisCarveModifier extends EngineAssignedModifier { - if (cavern == null) { - return; - } - - if (yy >= worldHeightSpan || yy <= 0) { - return; - } - - int rx = xx & 15; - int rz = zz & 15; - int columnIndex = PowerOfTwoCoordinates.packLocal16(rx, rz); - - if (upperSurfaceHeights != null && yy >= upperSurfaceHeights[columnIndex]) { - return; - } - - PlatformBlockState current = output.getRaw(rx, yy, rz); - boolean explicitCarveIntent = hasExplicitCarveIntent(cavern); - - if (shouldPreserveExistingFluid(cavern, current)) { - return; - } - - columnMasks[columnIndex].add(yy); - - if (!cavern.getCustomBiome().isEmpty()) { - scratch.customCaveBiomePresent = true; - } - - if (current.isAir() && !explicitCarveIntent) { - return; - } - - if (explicitCarveIntent) { - // Only a fluid cavern consumes the fluid sample, and on the maintenance path that - // sample is a full procedural stream evaluation, so never take it per voxel. - PlatformBlockState fluid = isFluidIntent(cavern) ? context.getFluid().get(rx, rz) : null; - output.setRaw(rx, yy, rz, resolveExplicitCarveState(cavern, fluid, LAVA, AIR)); - } else if (usesDefaultLava(caveLavaHeight, yy)) { - output.setRaw(rx, yy, rz, LAVA); - } else { - output.setRaw(rx, yy, rz, AIR); + CarveResolutionContext resolutionContext = new CarveResolutionContext( + output, + context, + scratch, + columnMasks, + upperSurfaceHeights, + worldHeightSpan, + caveLavaHeight, + chunkBlockX, + chunkBlockZ + ); + CarveResolver carveResolver = new CarveResolver(resolutionContext); + mantleChunk.iterate(MatterCavern.class, (xx, yy, zz, cavern) -> carveResolver.apply( + xx, + yy, + zz, + cavern, + dataIfPresent(mantleChunk, xx, yy, zz, HydrologyCaveCell.class) + )); + mantleChunk.iterate(HydrologyCaveCell.class, (xx, yy, zz, hydrology) -> { + if (dataIfPresent(mantleChunk, xx, yy, zz, MatterCavern.class) == null) { + carveResolver.apply(xx, yy, zz, null, hydrology); } }); if (scratch.customCaveBiomePresent) { @@ -161,6 +143,11 @@ public class IrisCarveModifier extends EngineAssignedModifier { + HydrologyCaveCell hydrology = dataIfPresent( + mantleChunk, rx, yy, rz, HydrologyCaveCell.class); + if (hydrology != null && hydrology.protectsPlacement()) { + return; + } int worldX = rx + chunkBlockX; int worldZ = rz + chunkBlockZ; String customBiome = cavern.getCustomBiome(); @@ -179,14 +166,36 @@ public class IrisCarveModifier extends EngineAssignedModifier fluid; + case FALLING_FLUID -> fallingFluidState(fluid); + case DRY_AIR -> air; + case SEAL_GUARD -> normalizeWaterlogging(current, null); + }; + } + + static PlatformBlockState normalizeWaterlogging(PlatformBlockState state, PlatformBlockState resultingFluid) { + if (state == null || B.isFluid(state) || !IrisProceduralBlocks.hasProperty(state, "waterlogged")) { + return state; + } + String target = resultingFluid != null && resultingFluid.isWater() ? "true" : "false"; + if (target.equals(IrisProceduralBlocks.propertyValue(state, "waterlogged"))) { + return state; + } + return state.withProperty("waterlogged", target); + } + + static PlatformBlockState normalizeHydrologyWaterlogging( + PlatformBlockState state, + MatterCavern baseline, + HydrologyCaveCell hydrology, + PlatformBlockState columnFluid + ) { + if (hydrology == null) { + return state; + } + MatterCavern composed = composeCavern(baseline, hydrology); + PlatformBlockState resultingFluid = isFluidIntent(composed) ? columnFluid : null; + return normalizeWaterlogging(state, resultingFluid); + } + + private static PlatformBlockState fallingFluidState(PlatformBlockState fluid) { + if (fluid == null || !IrisProceduralBlocks.hasProperty(fluid, "level")) { + return fluid; + } + if ("8".equals(IrisProceduralBlocks.propertyValue(fluid, "level"))) { + return fluid; + } + return fluid.withProperty("level", "8"); + } + + private final class CarveResolver { + private final CarveResolutionContext context; + + private CarveResolver(CarveResolutionContext context) { + this.context = context; + } + + private void apply( + int x, + int y, + int z, + MatterCavern baseline, + HydrologyCaveCell hydrology + ) { + if (y >= context.worldHeightSpan() || y <= 0) { + return; + } + + int localX = x & 15; + int localZ = z & 15; + int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); + if (context.upperSurfaceHeights() != null && y >= context.upperSurfaceHeights()[columnIndex]) { + return; + } + + PlatformBlockState current = context.output().getRaw(localX, y, localZ); + if (hydrology != null && hydrology.action() == HydrologyCaveAction.SEAL_GUARD) { + PlatformBlockState normalized = resolveHydrologyState(hydrology, current, null, AIR); + if (normalized != current) { + context.output().setRaw(localX, y, localZ, normalized); + } + return; + } + + MatterCavern cavern = composeCavern(baseline, hydrology); + if (cavern == null || shouldPreserveExistingFluid(cavern, current)) { + return; + } + + context.columnMasks()[columnIndex].add(y); + if (!cavern.getCustomBiome().isEmpty()) { + context.scratch().customCaveBiomePresent = true; + } + + boolean explicitCarveIntent = hasExplicitCarveIntent(cavern); + if (shouldSkipEmptyCarve(current, explicitCarveIntent)) { + return; + } + + PlatformBlockState fluid = null; + if (isFluidIntent(cavern)) { + fluid = hydrology == null + ? context.chunkContext().getFluid().get(localX, localZ) + : getComplex().resolveHydrologyFluid( + hydrology.fluidProfileKey(), + context.chunkBlockX() + localX, + context.chunkBlockZ() + localZ + ); + } + if (hydrology != null) { + context.output().setRaw(localX, y, localZ, + resolveHydrologyState(hydrology, current, fluid, AIR)); + return; + } + if (explicitCarveIntent) { + context.output().setRaw(localX, y, localZ, + resolveExplicitCarveState(cavern, fluid, LAVA, AIR)); + } else if (usesDefaultLava(context.caveLavaHeight(), y)) { + context.output().setRaw(localX, y, localZ, LAVA); + } else { + context.output().setRaw(localX, y, localZ, AIR); + } + } + } + + private record CarveResolutionContext( + Hunk output, + ChunkContext chunkContext, + IrisCarveScratch scratch, + CarveColumnMask[] columnMasks, + int[] upperSurfaceHeights, + int worldHeightSpan, + int caveLavaHeight, + int chunkBlockX, + int chunkBlockZ + ) { + } + private void addInternalWallsFromMasks(CarveWallBuffer walls, CarveColumnMask[] columnMasks) { for (int columnIndex = 0; columnIndex < 256; columnIndex++) { CarveColumnMask columnMask = columnMasks[columnIndex]; @@ -291,18 +448,18 @@ public class IrisCarveModifier extends EngineAssignedModifier= 0) { - MatterCavern cavern = mc.get(rx, yy, rz, MatterCavern.class); + MatterCavern cavern = composedCavernAt(mc, rx, yy, rz); if (cavern != null) { - if (rz < 15 && mc.get(rx, yy, rz + 1, MatterCavern.class) == null) { + if (rz < 15 && composedCavernAt(mc, rx, yy, rz + 1) == null) { walls.put(rx, yy, rz + 1, cavern); } - if (rx < 15 && mc.get(rx + 1, yy, rz, MatterCavern.class) == null) { + if (rx < 15 && composedCavernAt(mc, rx + 1, yy, rz) == null) { walls.put(rx + 1, yy, rz, cavern); } - if (rz > 0 && mc.get(rx, yy, rz - 1, MatterCavern.class) == null) { + if (rz > 0 && composedCavernAt(mc, rx, yy, rz - 1) == null) { walls.put(rx, yy, rz - 1, cavern); } - if (rx > 0 && mc.get(rx - 1, yy, rz, MatterCavern.class) == null) { + if (rx > 0 && composedCavernAt(mc, rx - 1, yy, rz) == null) { walls.put(rx - 1, yy, rz, cavern); } } @@ -370,11 +527,11 @@ public class IrisCarveModifier extends EngineAssignedModifier mantleChunk, int x, int y, int z) { + MatterCavern baseline = dataIfPresent(mantleChunk, x, y, z, MatterCavern.class); + HydrologyCaveCell hydrology = dataIfPresent(mantleChunk, x, y, z, HydrologyCaveCell.class); + return composeCavern(baseline, hydrology); + } + + private static T dataIfPresent(MantleChunk mantleChunk, int x, int y, int z, Class type) { + int section = y >> 4; + if (y < 0 || !mantleChunk.exists(section)) { + return null; + } + Matter matter = mantleChunk.get(section); + if (matter == null || !matter.hasSlice(type)) { + return null; + } + return matter.getSlice(type).get(x & 15, y & 15, z & 15); + } + private void processColumnFromMask( Hunk output, MantleChunk mc, @@ -429,7 +604,8 @@ public class IrisCarveModifier extends EngineAssignedModifier output, + MantleChunk mantleChunk, CarveColumnMask boundaryMask, CarveWallBuffer walls, int columnIndex, @@ -473,18 +651,21 @@ public class IrisCarveModifier extends EngineAssignedModifier output, + MantleChunk mantleChunk, CarveWallBuffer walls, int rx, int rz, @@ -507,6 +688,8 @@ public class IrisCarveModifier extends EngineAssignedModifier floorLayers = floorBiome.generateLayers( getDimension(), worldX, worldZ, rng, 3, zoneFloor, getData(), getComplex()); for (int i = 0; i < zoneFloor - 1; i++) { @@ -517,9 +700,19 @@ public class IrisCarveModifier extends EngineAssignedModifier= worldMaxY) { break; } + HydrologyCaveCell hydrology = dataIfPresent( + mantleChunk, rx, ceilingY, rz, HydrologyCaveCell.class); + if (hydrology != null + && hydrology.protectsPlacement() + && hydrology.action() != HydrologyCaveAction.SEAL_GUARD) { + continue; + } PlatformBlockState existing = output.getRaw(rx, ceilingY, rz); if (!B.isSolid(existing)) { continue; } PlatformBlockState layer = ceilingLayers.get(i); + if (!canReplaceHydrologyGuard(hydrology, layer, true)) { + continue; + } if (B.isOre(existing)) { output.setRaw(rx, ceilingY, rz, B.toDeepSlateOre(existing, layer)); continue; @@ -565,7 +768,11 @@ public class IrisCarveModifier extends EngineAssignedModifier output, MantleChunk mc, Mantle mantle, CaveZone zone, int rx, int rz, int xx, int zz, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { + private void processZone(Hunk output, MantleChunk mc, Mantle mantle, + CaveZone zone, int rx, int rz, int xx, int zz, + IrisDimensionCarvingResolver.State resolverState, + Long2ObjectOpenHashMap caveBiomeCache, + Map customBiomeCache) { int maxY = output.getHeight(); if (zone.ceiling + 1 < maxY && B.isDecorant(output.getRaw(rx, zone.ceiling + 1, rz))) { @@ -590,19 +797,31 @@ 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; - PlatformBlockState block = floorBlocks.get(i); + HydrologyCaveCell hydrology = dataIfPresent(mc, rx, y, rz, HydrologyCaveCell.class); + if (hydrology != null + && hydrology.protectsPlacement() + && hydrology.action() != HydrologyCaveAction.SEAL_GUARD) { + continue; + } + PlatformBlockState block = resolveSubmergedCaveFloorLayer( + output, rx, y, rz, floorBlocks.get(i), floorHydrology); PlatformBlockState existing = output.getRaw(rx, y, rz); - if (!B.isSolid(existing) || !canReplaceCaveFloorLayer(output, rx, y, rz, block)) { + if (!B.isSolid(existing) + || !canReplaceHydrologyGuard(hydrology, block, false) + || !canReplaceCaveFloorLayer(output, rx, y, rz, block)) { continue; } if (B.isOre(existing)) { @@ -620,9 +839,15 @@ public class IrisCarveModifier extends EngineAssignedModifier= maxY) { break; } + HydrologyCaveCell hydrology = dataIfPresent(mc, rx, cy, rz, HydrologyCaveCell.class); + if (hydrology != null + && hydrology.protectsPlacement() + && hydrology.action() != HydrologyCaveAction.SEAL_GUARD) { + continue; + } PlatformBlockState block = ceilingBlocks.get(i); PlatformBlockState existing = output.getRaw(rx, cy, rz); - if (!B.isSolid(existing)) { + if (!B.isSolid(existing) || !canReplaceHydrologyGuard(hydrology, block, true)) { continue; } if (B.isOre(existing)) { @@ -646,26 +871,53 @@ public class IrisCarveModifier extends EngineAssignedModifier 0 && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) { decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, ceilingBiome, InferredType.CAVE, zone.getCeiling(), zone.airThickness()); } + + normalizeCaveZoneWaterlogging(output, mc, zone, rx, rz, xx, zz); + } + + private void normalizeCaveZoneWaterlogging( + Hunk output, + MantleChunk mantleChunk, + CaveZone zone, + int localX, + int localZ, + int worldX, + int worldZ + ) { + int minimumY = Math.max(0, zone.floor - 1); + int maximumY = Math.min(output.getHeight() - 1, zone.ceiling + 1); + for (int y = minimumY; y <= maximumY; y++) { + HydrologyCaveCell hydrology = dataIfPresent( + mantleChunk, localX, y, localZ, HydrologyCaveCell.class); + if (hydrology == null) { + continue; + } + MatterCavern baseline = dataIfPresent( + mantleChunk, localX, y, localZ, MatterCavern.class); + PlatformBlockState current = output.getRaw(localX, y, localZ); + PlatformBlockState columnFluid = getComplex().resolveHydrologyFluid( + hydrology.fluidProfileKey(), + worldX, + worldZ + ); + PlatformBlockState normalized = normalizeHydrologyWaterlogging( + current, + baseline, + hydrology, + columnFluid + ); + if (normalized != current) { + output.setRaw(localX, y, localZ, normalized); + } + } } IrisBiome resolveCaveBoundaryBiome(MantleChunk mantleChunk, int x, int y, int z, int worldX, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { - MatterCavern cavern = dataIfPresent(mantleChunk, x, y, z, MatterCavern.class); + MatterCavern cavern = composedCavernAt(mantleChunk, x, y, z); return resolveCaveBoundaryBiome( cavern, worldX, y, worldZ, resolverState, caveBiomeCache, customBiomeCache); } - private static T dataIfPresent(MantleChunk mantleChunk, int x, int y, int z, Class type) { - int section = y >> 4; - if (y < 0 || !mantleChunk.exists(section)) { - return null; - } - Matter matter = mantleChunk.get(section); - if (matter == null || !matter.hasSlice(type)) { - return null; - } - return matter.getSlice(type).get(x & 15, y & 15, z & 15); - } - IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, IrisDimensionCarvingResolver.State resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { if (cavern != null && !cavern.getCustomBiome().isEmpty()) { return resolveCustomBiome(customBiomeCache, cavern.getCustomBiome()); @@ -673,10 +925,57 @@ public class IrisCarveModifier extends EngineAssignedModifier 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; @@ -689,16 +988,7 @@ public class IrisCarveModifier extends EngineAssignedModifier caveBiomeCache, int x, int y, int z, IrisDimensionCarvingResolver.State resolverState) { diff --git a/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java b/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java index 4a8e82851..87fed26e4 100644 --- a/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java +++ b/core/src/main/java/art/arcane/iris/engine/modifier/IrisPostModifier.java @@ -28,6 +28,7 @@ import art.arcane.iris.engine.object.IrisSlopeClip; import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.scheduling.PrecisionStopwatch; import art.arcane.iris.spi.PlatformBlockState; @@ -57,10 +58,9 @@ public class IrisPostModifier extends EngineAssignedModifier IrisDimension dimension = getDimension(); boolean walls = dimension.isPostProcessingWalls(); boolean slabs = dimension.isPostProcessingSlabs(); - int fluidHeight = dimension.getFluidHeight(); for (int i = 0; i < width; i++) { for (int j = 0; j < depth; j++) { - post(i, j, sync, i + x, j + z, context, heights, planeWidth, walls, slabs, fluidHeight); + post(i, j, sync, i + x, j + z, context, heights, planeWidth, walls, slabs); } } @@ -72,6 +72,26 @@ public class IrisPostModifier extends EngineAssignedModifier * resolve the same height stream entry up to five times. Resolve the padded plane once instead. The * four diagonal corners are never read, so they are left unresolved. */ + private boolean riverColumn(int x, int z) { + ProceduralStream carveWeights = getComplex().getRiverCarveWeightStream(); + return skipsHydrologyColumn(new double[] { + carveWeights.get(x, z), + carveWeights.get(x + 1, z), + carveWeights.get(x - 1, z), + carveWeights.get(x, z + 1), + carveWeights.get(x, z - 1) + }); + } + + static boolean skipsHydrologyColumn(double[] carveWeights) { + for (double weight : carveWeights) { + if (weight > 0D) { + return true; + } + } + return false; + } + private int[] heightPlane(int x, int z, int width, int depth, int planeWidth) { int[] heights = new int[planeWidth * (depth + 2)]; EngineMantle mantle = getEngine().getMantle(); @@ -90,7 +110,7 @@ public class IrisPostModifier extends EngineAssignedModifier return heights; } - private void post(int currentPostX, int currentPostZ, Hunk currentData, int x, int z, ChunkContext context, int[] heights, int planeWidth, boolean walls, boolean slabs, int fluidHeight) { + private void post(int currentPostX, int currentPostZ, Hunk currentData, int x, int z, ChunkContext context, int[] heights, int planeWidth, boolean walls, boolean slabs) { // x/z are world coordinates, the hunk is indexed relative to this chunk origin. int originX = x - currentPostX; int originZ = z - currentPostZ; @@ -100,6 +120,8 @@ public class IrisPostModifier extends EngineAssignedModifier int hb = heights[center + planeWidth]; int hc = heights[center - 1]; int hd = heights[center - planeWidth]; + int fluidHeight = (int) Math.round(getComplex().getRiverWaterSurfaceStream().get(x, z)); + boolean river = riverColumn(x, z); // Floating Nibs int g = 0; @@ -113,7 +135,7 @@ public class IrisPostModifier extends EngineAssignedModifier g += hc < h - 1 ? 1 : 0; g += hd < h - 1 ? 1 : 0; - if (g == 4 && isAir(x, h - 1, z, originX, originZ, currentData)) { + if (!river && g == 4 && isAir(x, h - 1, z, originX, originZ, currentData)) { setPostBlock(x, h, z, States.AIR, originX, originZ, currentData); for (int i = h - 1; i > 0; i--) { @@ -131,6 +153,10 @@ public class IrisPostModifier extends EngineAssignedModifier g += hc == h - 1 ? 1 : 0; g += hd == h - 1 ? 1 : 0; + if (river) { + g = -1; + } + if (g >= 4) { PlatformBlockState bcState = getPostBlock(x, h, z, originX, originZ, currentData); PlatformBlockState bState = getPostBlock(x, h + 1, z, originX, originZ, currentData); @@ -141,7 +167,7 @@ public class IrisPostModifier extends EngineAssignedModifier h--; } } - } else { + } else if (!river) { // Potholes g = 0; g += ha == h + 1 ? 1 : 0; @@ -170,7 +196,7 @@ public class IrisPostModifier extends EngineAssignedModifier // Wall Patcher IrisBiome biome = context.getBiome().get(currentPostX, currentPostZ); - if (walls) { + if (walls && !river) { if (!biome.getWall().getPalette().isEmpty()) { if (ha < h - 2 || hb < h - 2 || hc < h - 2 || hd < h - 2) { boolean brokeGround = false; @@ -197,7 +223,7 @@ public class IrisPostModifier extends EngineAssignedModifier } // Slab - if (slabs) { + if (slabs && !river) { //@builder if ((ha == h + 1 && isSolidNonSlab(x + 1, ha, z, originX, originZ, currentData)) || (hb == h + 1 && isSolidNonSlab(x, hb, z + 1, originX, originZ, currentData)) diff --git a/core/src/main/java/art/arcane/iris/engine/object/IObjectPlacer.java b/core/src/main/java/art/arcane/iris/engine/object/IObjectPlacer.java index 305f410d4..44a93cab8 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IObjectPlacer.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IObjectPlacer.java @@ -50,6 +50,17 @@ public interface IObjectPlacer { int getFluidHeight(); + default int getFluidHeight(int x, int z) { + Engine engine = getEngine(); + if (engine == null || engine.getComplex() == null) { + return getFluidHeight(); + } + int coordinateShift = getFluidHeight() - engine.getDimension().getFluidHeight(); + return coordinateShift + (int) Math.round( + engine.getComplex().getRiverWaterSurfaceStream().get(x, z) + ); + } + boolean isDebugSmartBore(); void setTile(int xx, int yy, int zz, TileData tile); 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 ec7001c39..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,6 +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 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/IrisBiomeColorRenderer.java b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeColorRenderer.java index 6ba1918dd..8a09c6374 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeColorRenderer.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisBiomeColorRenderer.java @@ -36,7 +36,7 @@ final class IrisBiomeColorRenderer { static Color getColor(IrisBiome biome, Engine engine, RenderType type) { switch (type) { - case BIOME, HEIGHT, CAVE_LAND, REGION, BIOME_SEA, BIOME_LAND -> { + case BIOME, HEIGHT, CAVE_LAND, REGION, BIOME_SEA, BIOME_LAND, RIVER -> { return biome.getCacheColor().aquire(() -> { if (biome.getColor() == null) { RandomColor randomColor = new RandomColor(biome.getName().hashCode()); 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/IrisDimension.java b/core/src/main/java/art/arcane/iris/engine/object/IrisDimension.java index b7220f0d0..ba967150b 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,6 +165,10 @@ public class IrisDimension extends IrisRegistrant { private KList carving = new KList<>(); @Desc("Profile-driven 3D cave configuration") private IrisCaveProfile caveProfile = new IrisCaveProfile(); + @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) @@ -513,11 +517,27 @@ public class IrisDimension extends IrisRegistrant { } Deque pending = new ArrayDeque<>(); + 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, region.getAllBiomeIds()); + addReachableBiomeKeys(pending, + hydrologyPoliciesActive ? region.getAllBiomeIds() : region.getNaturalBiomeIds()); } for (IrisImageMapBinding binding : getImageMaps()) { if (binding == null || binding.getApplication() != IrisImageMapApplication.BIOME) { @@ -563,6 +583,10 @@ public class IrisDimension extends IrisRegistrant { biomes.put(loadKey, biome); addReachableBiomeKeys(pending, biome.getChildren()); addReachableBiomeKey(pending, biome.getCarvingBiome()); + if (hydrologyPoliciesActive && biome.getRiverPolicy() != null) { + addReachableBiomeKeys(pending, biome.getRiverPolicy().getAllBiomeIds()); + } + KList floatingChildren = biome.getFloatingChildBiomes(); if (floatingChildren == null) { continue; diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStreamType.java b/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStreamType.java index 4bdb39890..c77a9c542 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStreamType.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisEngineStreamType.java @@ -28,6 +28,9 @@ public enum IrisEngineStreamType { @Desc("Represents the given slope at the x, z coordinates") SLOPE((f) -> f.getComplex().getSlopeStream()), + @Desc("Represents terrain height before river incision and river biome replacement.") + NATURAL_HEIGHT((f) -> f.getComplex().getNaturalHeightStream()), + @Desc("Represents the base generator height at the given position. This includes only the biome generators / interpolation and noise features but does not include carving, caves.") HEIGHT((f) -> f.getComplex().getHeightStream()), @@ -41,7 +44,19 @@ public enum IrisEngineStreamType { REGION_STYLE((f) -> f.getComplex().getRegionStyleStream()), @Desc("Represents the identity of regions. Each region has a unique number (very large numbers)") - REGION_IDENTITY((f) -> f.getComplex().getRegionIdentityStream()); + REGION_IDENTITY((f) -> f.getComplex().getRegionIdentityStream()), + + @Desc("Represents block distance from the nearest active river centerline.") + RIVER_DISTANCE((f) -> f.getComplex().getRiverDistanceStream()), + + @Desc("Represents the merged upstream flow carried by the active river reach.") + RIVER_FLOW((f) -> f.getComplex().getRiverFlowStream()), + + @Desc("Represents the normalized river terrain-incision weight.") + RIVER_CARVE_WEIGHT((f) -> f.getComplex().getRiverCarveWeightStream()), + + @Desc("Represents the solved river water-surface height.") + RIVER_WATER_SURFACE((f) -> f.getComplex().getRiverWaterSurfaceStream()); private final Function> getter; 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..bc6e04768 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisHydrology.java @@ -0,0 +1,25 @@ +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<>(); + + @ArrayType(type = IrisSurfacePoolConfig.class, min = 0) + @Desc("Standing surface pools such as lava pools. Each region or biome opts in through riverPolicy.surfacePools.") + private KList surfacePools = 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/IrisObjectPlacementRunner.java b/core/src/main/java/art/arcane/iris/engine/object/IrisObjectPlacementRunner.java index f8a73780e..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. @@ -332,7 +341,8 @@ final class IrisObjectPlacementRunner { return -1; } - if (!config.isForcePlace() && !rawStructurePiece && config.isUnderwater() && y + rty + ty >= placer.getFluidHeight()) { + if (!config.isForcePlace() && !rawStructurePiece && config.isUnderwater() + && y + rty + ty >= placer.getFluidHeight(x, z)) { return -1; } @@ -357,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/IrisProceduralBlocks.java b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java index 845d57556..158aba60b 100644 --- a/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisProceduralBlocks.java @@ -74,6 +74,20 @@ public final class IrisProceduralBlocks { return key.indexOf(',', valueStart) >= 0 || key.indexOf(']', valueStart) >= 0; } + /** Blocks that fall when unsupported: sand, gravel and concrete powder. */ + public static boolean isGravityAffected(PlatformBlockState state) { + if (state == null) { + return false; + } + String key = materialKey(state); + return key.equals("minecraft:sand") + || key.equals("minecraft:red_sand") + || key.equals("minecraft:gravel") + || key.equals("minecraft:suspicious_sand") + || key.equals("minecraft:suspicious_gravel") + || key.endsWith("_concrete_powder"); + } + public static String materialKey(PlatformBlockState state) { String memoized = state.materialKey(); if (memoized != null) { 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 7fd44ba09..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,6 +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 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) @@ -277,18 +279,33 @@ public class IrisRegion extends IrisRegistrant implements IRare { } public KSet getAllBiomeIds() { + KSet names = getNaturalBiomeIds(); + if (riverPolicy != null) { + names.addAll(riverPolicy.getAllBiomeIds()); + } + return names; + } + + public KSet getNaturalBiomeIds() { KSet names = new KSet<>(); names.addAll(landBiomes); names.addAll(caveBiomes); names.addAll(seaBiomes); names.addAll(shoreBiomes); - return names; } public KList getAllBiomes(DataProvider g) { + return resolveBiomes(g, getAllBiomeIds()); + } + + public KList getNaturalBiomes(DataProvider g) { + return resolveBiomes(g, getNaturalBiomeIds()); + } + + private KList resolveBiomes(DataProvider g, KSet biomeIds) { KMap b = new KMap<>(); - KSet names = getAllBiomeIds(); + KSet names = biomeIds.copy(); while (!names.isEmpty()) { for (String i : new KList<>(names)) { 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/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/IrisRiverGeometryConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverGeometryConfig.java new file mode 100644 index 000000000..64d13b11a --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverGeometryConfig.java @@ -0,0 +1,24 @@ +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 and contained cave geometry.") +@Data +public class IrisRiverGeometryConfig { + @Desc("Surface and underground centerline meanders.") + private IrisRiverMeanderConfig meanders = new IrisRiverMeanderConfig(); + + @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("Underground drop, sinkhole and receiving basin 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/IrisRiverHydrology.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverHydrology.java new file mode 100644 index 000000000..e0a05c8e0 --- /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, bank, flow, 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..d7a61ac0b --- /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 how a surface river meets the sea.") +@Data +public class IrisRiverMouthConfig { + @MinNumber(1) + @MaxNumber(4) + @Desc("Width multiplier reached at the coastline over the final stretch of the river.") + private double flareRatio = 1.6D; + + @MinNumber(0) + @MaxNumber(32) + @Desc("Maximum number of blocks the recorded mouth footprint may enter the ocean before terminating.") + private int maximumOceanApron = 8; +} 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..89c088dca --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverPolicy.java @@ -0,0 +1,101 @@ +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 bankBiomes = 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; + + @ArrayType(type = String.class) + @Desc("Standing surface pool ids (hydrology.surfacePools) allowed in this area. An empty list explicitly clears the inherited selection.") + private KList surfacePools = 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; + + @MinNumber(0) + @MaxNumber(4) + @Desc("Multiplier applied to the eroded bank width outside the shore band.") + private Double bankMultiplier = null; + + public KSet getAllBiomeIds() { + KSet biomeIds = new KSet<>(); + addAll(biomeIds, surfaceBiomes); + addAll(biomeIds, mouthBiomes); + addAll(biomeIds, shoreBiomes); + addAll(biomeIds, bankBiomes); + 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/IrisRiverRoutingConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java new file mode 100644 index 000000000..482a35255 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisRiverRoutingConfig.java @@ -0,0 +1,83 @@ +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(64) + @Desc("Spacing in blocks between terrain samples used to build the drainage graph.") + private int sampleSpacing = 64; + + @MinNumber(256) + @MaxNumber(32768) + @Desc("Maximum bounded length in blocks of one accepted river route.") + private int maximumRouteLength = 16384; + + @MinNumber(0) + @MaxNumber(32768) + @Desc("Minimum exposed source-to-outlet length in blocks of a published surface river.") + private int minimumSurfaceCourseLength = 384; + + @MinNumber(0) + @MaxNumber(32768) + @Desc("Minimum source-to-outlet length in blocks of a published underground river.") + private int minimumUndergroundCourseLength = 192; + + @MinNumber(1) + @MaxNumber(256) + @Desc("Maximum drainage roots selected per planning tile.") + 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<>(); + + @MinNumber(0) + @MaxNumber(8) + @Desc("How strongly routes prefer lower ground.") + private double valleyPreference = 1.5D; + + @MinNumber(0) + @MaxNumber(128) + @Desc("Cost per block of rise along a route.") + private double uphillPenalty = 24D; + + @MinNumber(0) + @MaxNumber(16) + @Desc("Cost per unit of terrain slope along a route.") + private double slopePenalty = 2D; + + @MinNumber(0) + @MaxNumber(1) + @Desc("How strongly routes are drawn toward existing drainage.") + private double confluenceAttraction = 0.2D; + + @MinNumber(0) + @MaxNumber(8) + @Desc("How strongly longer source-to-outlet routes are preferred when choosing river sources; 0 ranks by elevation alone.") + private double lengthPreference = 1D; + + @MinNumber(0) + @MaxNumber(4) + @Desc("Extra surface courses an outlet may accept as tributaries joining its main river; 0 keeps one river per outlet.") + private int tributaries = 1; +} 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/IrisSurfacePoolConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfacePoolConfig.java new file mode 100644 index 000000000..569e08548 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfacePoolConfig.java @@ -0,0 +1,54 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.iris.engine.object.annotations.Required; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.Accessors; + +@Accessors(chain = true) +@NoArgsConstructor +@Desc("A standing surface pool: a bowl cut into open ground and filled with a fluid, placed where the river policy allows it.") +@Data +public class IrisSurfacePoolConfig { + @Required + @Desc("Pool identifier. Policies list it under surfacePools and locators accept it as a selector.") + private String id = "lava_pool"; + + @Required + @Desc("Fluid palette filling the pool.") + private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("lava"); + + @MinNumber(0) + @MaxNumber(64) + @Desc("Expected pools per hydrology tile where the policy allows them.") + private double density = 0.75D; + + @MinNumber(32) + @MaxNumber(8192) + @Desc("Nominal spacing in blocks between candidate pool sites.") + private int spacing = 384; + + @MinNumber(2) + @MaxNumber(16) + @Desc("Smallest pool radius in blocks.") + private int minimumRadius = 4; + + @MinNumber(2) + @MaxNumber(16) + @Desc("Largest pool radius in blocks.") + private int maximumRadius = 7; + + @MinNumber(1) + @MaxNumber(8) + @Desc("Fluid depth at the centre of the pool.") + private int depth = 2; + + @RegistryListResource(IrisBiome.class) + @Desc("Biome applied to the pool bed and its rim. Leave empty to keep the surrounding biome.") + private String biome = ""; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBankConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBankConfig.java new file mode 100644 index 000000000..7127627da --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBankConfig.java @@ -0,0 +1,42 @@ +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 lip, shore band and eroded valley around a surface river.") +@Data +public class IrisSurfaceRiverBankConfig { + @MinNumber(0) + @MaxNumber(4) + @Desc("Blocks the bank top sits above the water surface at the channel edge.") + private int freeboard = 1; + + @MinNumber(0.5) + @MaxNumber(6) + @Desc("Width in blocks of the shore band that receives shore biome content.") + private double shoreWidth = 1.5D; + + @MinNumber(0.5) + @MaxNumber(12) + @Desc("Horizontal run in blocks of eroded bank per block of cut depth.") + private double blendSlope = 3D; + + @MinNumber(1) + @MaxNumber(64) + @Desc("Narrowest eroded band outside the shore, in blocks.") + private int minimumBlendWidth = 4; + + @MinNumber(1) + @MaxNumber(64) + @Desc("Widest eroded band outside the shore, in blocks.") + private int maximumBlendWidth = 32; + + @Desc("Show the biome's deeper layers on eroded banks instead of the surface layer.") + private boolean exposeCutStrata = true; +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBedConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBedConfig.java new file mode 100644 index 000000000..2a6ccb6d1 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverBedConfig.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 the material under and beside surface river water.") +@Data +public class IrisSurfaceRiverBedConfig { + @Desc("Allow sand, gravel and other falling blocks in the bed, shore and eroded banks. When false they are replaced by the padding palette.") + private boolean allowGravityBlocks = false; + + @MinNumber(0) + @MaxNumber(8) + @Desc("Blocks below the bed that are also kept free of falling blocks, so nothing under the water can collapse.") + private int padding = 2; + + @Desc("Blocks used in place of falling blocks in the bed, padding, shore and banks.") + private IrisMaterialPalette paddingPalette = new IrisMaterialPalette().qclear().qadd("clay").qadd("dirt"); +} 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..3670d9302 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverChannelConfig.java @@ -0,0 +1,55 @@ +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 wet channel of a surface river.") +@Data +public class IrisSurfaceRiverChannelConfig { + @Desc("Wet channel width in blocks.") + private IrisStyledRange width = range(4D, 8D, 1024D); + + @Desc("Wet bed depth in blocks at the channel center.") + private IrisStyledRange depth = range(2D, 4D, 768D); + + @MinNumber(0) + @MaxNumber(3) + @Desc("Blocks the water surface sits below the lowest natural bank beside the channel.") + private int inset = 1; + + @MinNumber(1) + @MaxNumber(32) + @Desc("Maximum cut below natural terrain at the channel center before a course is rejected.") + private int maximumIncision = 10; + + @MinNumber(0) + @MaxNumber(1) + @Desc("Coherent variation of the wet outline and bed as a fraction of the channel size.") + private double roughness = 0.25D; + + @MinNumber(4) + @MaxNumber(64) + @Desc("Wavelength in blocks of the outline and bed variation.") + private int roughnessWavelength = 16; + + @MinNumber(1) + @MaxNumber(4) + @Desc("Width of the spring pool at the headwater relative to the channel width; 1 starts the river at its normal width.") + private double springWidthRatio = 2.5D; + + @MinNumber(4) + @MaxNumber(96) + @Desc("Blocks over which the spring pool narrows back to the channel width.") + private int springLength = 24; + + private static IrisStyledRange range(double min, double max, double zoom) { + IrisGeneratorStyle style = new IrisGeneratorStyle(NoiseStyle.IRIS).zoomed(zoom); + 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..e97d4e91d --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverConfig.java @@ -0,0 +1,32 @@ +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("Wet channel width, depth, inset and roughness.") + private IrisSurfaceRiverChannelConfig channel = new IrisSurfaceRiverChannelConfig(); + + @Desc("Lip, shore band and eroded valley around the channel.") + private IrisSurfaceRiverBankConfig banks = new IrisSurfaceRiverBankConfig(); + @Desc("Material under and beside the water: falling-block replacement and padding.") + private IrisSurfaceRiverBedConfig bed = new IrisSurfaceRiverBedConfig(); + + @Desc("Cascade and waterfall thresholds.") + private IrisSurfaceRiverFlowConfig flow = new IrisSurfaceRiverFlowConfig(); + + @Desc("Coastal mouth flare and ocean-apron limits.") + private IrisRiverMouthConfig mouths = new IrisRiverMouthConfig(); +} diff --git a/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverFlowConfig.java b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverFlowConfig.java new file mode 100644 index 000000000..1b45bb68c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisSurfaceRiverFlowConfig.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 how a surface river descends.") +@Data +public class IrisSurfaceRiverFlowConfig { + @MinNumber(1) + @MaxNumber(8) + @Desc("Shortest level run in blocks between one-block steps before consecutive steps are labelled a cascade.") + private int cascadeRun = 2; + + @MinNumber(2) + @MaxNumber(32) + @Desc("Smallest natural cliff in blocks that becomes a waterfall instead of a cascade.") + private int waterfallMinimumDrop = 6; +} 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..2364505a9 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.hasHydrologyChannelOrShore(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..a2aca6c52 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/engine/object/IrisUndergroundRiverConfig.java @@ -0,0 +1,44 @@ +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 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; + + @MinNumber(16) + @MaxNumber(512) + @Desc("Distance in blocks over which an ocean-bound underground river levels to sea level before its mouth.") + private int mouthLevelingDistance = 64; + + 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/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/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/hunk/view/ChunkDataHunkHolder.java b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java index c2f4fb608..662c6e5ba 100644 --- a/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java +++ b/core/src/main/java/art/arcane/iris/util/project/hunk/view/ChunkDataHunkHolder.java @@ -76,7 +76,11 @@ public class ChunkDataHunkHolder extends AtomicHunk { } public void apply() { - if (INMS.get().applyChunkDataBlocks(chunk, this)) { + applyTo(chunk); + } + + public void applyTo(ChunkData target) { + if (INMS.get().applyChunkDataBlocks(target, this)) { return; } @@ -95,7 +99,7 @@ public class ChunkDataHunkHolder extends AtomicHunk { block = custom.getBase(); } if (block == null) { - flushRun(x, z, runStart, y, activeBlock); + flushRun(target, x, z, runStart, y, activeBlock); activeBlock = null; runStart = -1; continue; @@ -105,27 +109,27 @@ public class ChunkDataHunkHolder extends AtomicHunk { continue; } - flushRun(x, z, runStart, y, activeBlock); + flushRun(target, x, z, runStart, y, activeBlock); activeBlock = block; runStart = y; } - flushRun(x, z, runStart, height, activeBlock); + flushRun(target, x, z, runStart, height, activeBlock); } } } - private void flushRun(int x, int z, int startY, int endY, BlockData block) { + private void flushRun(ChunkData target, int x, int z, int startY, int endY, BlockData block) { if (block == null || startY < 0 || endY <= startY) { return; } - int minY = chunk.getMinHeight(); + int minY = target.getMinHeight(); if (endY - startY == 1) { - chunk.setBlock(x, startY + minY, z, block); + target.setBlock(x, startY + minY, z, block); return; } - chunk.setRegion(x, startY + minY, z, x + 1, endY + minY, z + 1, block); + target.setRegion(x, startY + minY, z, x + 1, endY + minY, z + 1, block); } } 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 fd07e3554..ae6bb0538 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,6 +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.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; @@ -50,6 +52,8 @@ public final class IrisMatterSupport { IrisMatter.registerSliceType(new IdentifierMatter()); IrisMatter.registerSliceType(new NativeStructureOwnershipMatter()); IrisMatter.registerSliceType(new PlatformBlockMatter()); + 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/resources/languages/de_DE.json b/core/src/main/resources/languages/de_DE.json index 4dff41981..60e762b83 100644 --- a/core/src/main/resources/languages/de_DE.json +++ b/core/src/main/resources/languages/de_DE.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Biom-Meer", "iris.desktop.vision.mode.region": "Region", "iris.desktop.vision.mode.cave_land": "Höhlenland", + "iris.desktop.vision.mode.river": "Flussnetz", "iris.desktop.vision.mode.height": "Höhe", "iris.desktop.vision.mode.object_load": "Objektlast", "iris.desktop.vision.mode.decorator_load": "Dekorlast", diff --git a/core/src/main/resources/languages/es_ES.json b/core/src/main/resources/languages/es_ES.json index e452a2eff..1594a39d9 100644 --- a/core/src/main/resources/languages/es_ES.json +++ b/core/src/main/resources/languages/es_ES.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Mar del bioma", "iris.desktop.vision.mode.region": "Región", "iris.desktop.vision.mode.cave_land": "Tierra de cuevas", + "iris.desktop.vision.mode.river": "Red fluvial", "iris.desktop.vision.mode.height": "Altura", "iris.desktop.vision.mode.object_load": "Carga de objetos", "iris.desktop.vision.mode.decorator_load": "Carga de decoradores", diff --git a/core/src/main/resources/languages/fi_FI.json b/core/src/main/resources/languages/fi_FI.json index bafc58450..b26961ff5 100644 --- a/core/src/main/resources/languages/fi_FI.json +++ b/core/src/main/resources/languages/fi_FI.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Biome-meri", "iris.desktop.vision.mode.region": "Alue", "iris.desktop.vision.mode.cave_land": "Luolamaa", + "iris.desktop.vision.mode.river": "Jokiverkosto", "iris.desktop.vision.mode.height": "Korkeus", "iris.desktop.vision.mode.object_load": "Kohteen kuormitus", "iris.desktop.vision.mode.decorator_load": "Koristuskuorma", diff --git a/core/src/main/resources/languages/fr_FR.json b/core/src/main/resources/languages/fr_FR.json index 169b311d6..b017acdb3 100644 --- a/core/src/main/resources/languages/fr_FR.json +++ b/core/src/main/resources/languages/fr_FR.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Mer du biome", "iris.desktop.vision.mode.region": "Région", "iris.desktop.vision.mode.cave_land": "Terre des grottes", + "iris.desktop.vision.mode.river": "Réseau fluvial", "iris.desktop.vision.mode.height": "Hauteur", "iris.desktop.vision.mode.object_load": "Charge des objets", "iris.desktop.vision.mode.decorator_load": "Charge des décorateurs", diff --git a/core/src/main/resources/languages/he_IL.json b/core/src/main/resources/languages/he_IL.json index 7dbbf8344..cf3124630 100644 --- a/core/src/main/resources/languages/he_IL.json +++ b/core/src/main/resources/languages/he_IL.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "הים Biome", "iris.desktop.vision.mode.region": "אזור", "iris.desktop.vision.mode.cave_land": "ארץ המערה", + "iris.desktop.vision.mode.river": "רשת נהרות", "iris.desktop.vision.mode.height": "גובה", "iris.desktop.vision.mode.object_load": "עומס אובייקטים", "iris.desktop.vision.mode.decorator_load": "עומס דקורטיבי", diff --git a/core/src/main/resources/languages/it_IT.json b/core/src/main/resources/languages/it_IT.json index 1dd31a792..aec0c8fec 100644 --- a/core/src/main/resources/languages/it_IT.json +++ b/core/src/main/resources/languages/it_IT.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Mare dei biomi", "iris.desktop.vision.mode.region": "Regione", "iris.desktop.vision.mode.cave_land": "Terreno della grotta", + "iris.desktop.vision.mode.river": "Rete fluviale", "iris.desktop.vision.mode.height": "Altezza", "iris.desktop.vision.mode.object_load": "Carico oggetto", "iris.desktop.vision.mode.decorator_load": "Carico decorativo", diff --git a/core/src/main/resources/languages/ja-JP.json b/core/src/main/resources/languages/ja-JP.json index 117da8000..75ac9c0cb 100644 --- a/core/src/main/resources/languages/ja-JP.json +++ b/core/src/main/resources/languages/ja-JP.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "海洋バイオーム", "iris.desktop.vision.mode.region": "リージョン", "iris.desktop.vision.mode.cave_land": "洞窟地形", + "iris.desktop.vision.mode.river": "河川網", "iris.desktop.vision.mode.height": "高さ", "iris.desktop.vision.mode.object_load": "オブジェクト負荷", "iris.desktop.vision.mode.decorator_load": "デコレーター負荷", diff --git a/core/src/main/resources/languages/ko_KR.json b/core/src/main/resources/languages/ko_KR.json index caf82fff5..f8601297a 100644 --- a/core/src/main/resources/languages/ko_KR.json +++ b/core/src/main/resources/languages/ko_KR.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Biome 바다", "iris.desktop.vision.mode.region": "주요 특징", "iris.desktop.vision.mode.cave_land": "동굴 땅", + "iris.desktop.vision.mode.river": "하천망", "iris.desktop.vision.mode.height": "고도:", "iris.desktop.vision.mode.object_load": "객체 부하", "iris.desktop.vision.mode.decorator_load": "장식자 짐", diff --git a/core/src/main/resources/languages/lt_LT.json b/core/src/main/resources/languages/lt_LT.json index 1b12afacd..a7940d8bf 100644 --- a/core/src/main/resources/languages/lt_LT.json +++ b/core/src/main/resources/languages/lt_LT.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Biominė jūra", "iris.desktop.vision.mode.region": "Regionas", "iris.desktop.vision.mode.cave_land": "Kavos žemė", + "iris.desktop.vision.mode.river": "Upių tinklas", "iris.desktop.vision.mode.height": "Aukštis", "iris.desktop.vision.mode.object_load": "Objekto apkrova", "iris.desktop.vision.mode.decorator_load": "Decorator apkrova", diff --git a/core/src/main/resources/languages/nl_NL.json b/core/src/main/resources/languages/nl_NL.json index 1c0c9b759..18034346c 100644 --- a/core/src/main/resources/languages/nl_NL.json +++ b/core/src/main/resources/languages/nl_NL.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Biome zee", "iris.desktop.vision.mode.region": "Gebieden", "iris.desktop.vision.mode.cave_land": "Grotland", + "iris.desktop.vision.mode.river": "Riviernetwerk", "iris.desktop.vision.mode.height": "Hoogte", "iris.desktop.vision.mode.object_load": "Objectbelasting", "iris.desktop.vision.mode.decorator_load": "Decoratorbelasting", diff --git a/core/src/main/resources/languages/pl_PL.json b/core/src/main/resources/languages/pl_PL.json index 1a390edd0..8b30423eb 100644 --- a/core/src/main/resources/languages/pl_PL.json +++ b/core/src/main/resources/languages/pl_PL.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Morze Biome", "iris.desktop.vision.mode.region": "Obszar", "iris.desktop.vision.mode.cave_land": "Kraina jaskiń", + "iris.desktop.vision.mode.river": "Sieć rzeczna", "iris.desktop.vision.mode.height": "Wysokość", "iris.desktop.vision.mode.object_load": "Ładunek obiektu", "iris.desktop.vision.mode.decorator_load": "Obciążenie dekoratora", diff --git a/core/src/main/resources/languages/pt_PT.json b/core/src/main/resources/languages/pt_PT.json index 63c53024b..e6cf58cf5 100644 --- a/core/src/main/resources/languages/pt_PT.json +++ b/core/src/main/resources/languages/pt_PT.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Mar de bioma", "iris.desktop.vision.mode.region": "Região", "iris.desktop.vision.mode.cave_land": "Terras das cavernas", + "iris.desktop.vision.mode.river": "Rede fluvial", "iris.desktop.vision.mode.height": "Altura", "iris.desktop.vision.mode.object_load": "Carregamento do objeto", "iris.desktop.vision.mode.decorator_load": "Carga do decorador", diff --git a/core/src/main/resources/languages/ru_RU.json b/core/src/main/resources/languages/ru_RU.json index bffb5e9e8..a56d969a4 100644 --- a/core/src/main/resources/languages/ru_RU.json +++ b/core/src/main/resources/languages/ru_RU.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Биологическое море", "iris.desktop.vision.mode.region": "регион", "iris.desktop.vision.mode.cave_land": "Пещерная земля", + "iris.desktop.vision.mode.river": "Речная сеть", "iris.desktop.vision.mode.height": "высота", "iris.desktop.vision.mode.object_load": "Объектная нагрузка", "iris.desktop.vision.mode.decorator_load": "Загрузка декоратора", diff --git a/core/src/main/resources/languages/tr_TR.json b/core/src/main/resources/languages/tr_TR.json index 666381529..8d264d1e3 100644 --- a/core/src/main/resources/languages/tr_TR.json +++ b/core/src/main/resources/languages/tr_TR.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Biome deniz", "iris.desktop.vision.mode.region": "Bölge Bölgesi", "iris.desktop.vision.mode.cave_land": "Cave land Cave", + "iris.desktop.vision.mode.river": "Nehir ağı", "iris.desktop.vision.mode.height": "Yükseklik", "iris.desktop.vision.mode.object_load": "Object yükü", "iris.desktop.vision.mode.decorator_load": "Decorator yükü", diff --git a/core/src/main/resources/languages/vi_VI.json b/core/src/main/resources/languages/vi_VI.json index 46abf9a0d..dfa29fa2c 100644 --- a/core/src/main/resources/languages/vi_VI.json +++ b/core/src/main/resources/languages/vi_VI.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "Biển lấp lánh", "iris.desktop.vision.mode.region": "Vùng", "iris.desktop.vision.mode.cave_land": "Vùng đất hang động", + "iris.desktop.vision.mode.river": "Mạng lưới sông ngòi", "iris.desktop.vision.mode.height": "Chiều cao", "iris.desktop.vision.mode.object_load": "Trọng tải đối tượng", "iris.desktop.vision.mode.decorator_load": "Tải bộ phân giải", diff --git a/core/src/main/resources/languages/zh_CN.json b/core/src/main/resources/languages/zh_CN.json index b790d94c8..72588fc7c 100644 --- a/core/src/main/resources/languages/zh_CN.json +++ b/core/src/main/resources/languages/zh_CN.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "生物海", "iris.desktop.vision.mode.region": "地区", "iris.desktop.vision.mode.cave_land": "洞穴土地", + "iris.desktop.vision.mode.river": "河流网络", "iris.desktop.vision.mode.height": "高度", "iris.desktop.vision.mode.object_load": "对象负载", "iris.desktop.vision.mode.decorator_load": "加载装饰器", diff --git a/core/src/main/resources/languages/zh_TW.json b/core/src/main/resources/languages/zh_TW.json index 93bbfd0ba..e27df451c 100644 --- a/core/src/main/resources/languages/zh_TW.json +++ b/core/src/main/resources/languages/zh_TW.json @@ -1398,6 +1398,7 @@ "iris.desktop.vision.mode.biome_sea": "生物海", "iris.desktop.vision.mode.region": "地區", "iris.desktop.vision.mode.cave_land": "洞穴土地", + "iris.desktop.vision.mode.river": "河流網絡", "iris.desktop.vision.mode.height": "高度", "iris.desktop.vision.mode.object_load": "物件負載", "iris.desktop.vision.mode.decorator_load": "載入裝飾器", diff --git a/core/src/test/java/art/arcane/iris/core/gui/VisionRenderControllerTest.java b/core/src/test/java/art/arcane/iris/core/gui/VisionRenderControllerTest.java index 1b2a22a73..14e63055e 100644 --- a/core/src/test/java/art/arcane/iris/core/gui/VisionRenderControllerTest.java +++ b/core/src/test/java/art/arcane/iris/core/gui/VisionRenderControllerTest.java @@ -57,7 +57,7 @@ public class VisionRenderControllerTest { VisionRenderController.TileKey baseline = new VisionRenderController.TileKey(4L, RenderType.BIOME, 4D, 8L, 9L); assertNotEquals(baseline, new VisionRenderController.TileKey(5L, RenderType.BIOME, 4D, 8L, 9L)); - assertNotEquals(baseline, new VisionRenderController.TileKey(4L, RenderType.HEIGHT, 4D, 8L, 9L)); + assertNotEquals(baseline, new VisionRenderController.TileKey(4L, RenderType.RIVER, 4D, 8L, 9L)); assertNotEquals(baseline, new VisionRenderController.TileKey(4L, RenderType.BIOME, 4.5D, 8L, 9L)); assertNotEquals(baseline, new VisionRenderController.TileKey(4L, RenderType.BIOME, 4D, 7L, 9L)); } diff --git a/core/src/test/java/art/arcane/iris/core/gui/VisionRenderTypeTest.java b/core/src/test/java/art/arcane/iris/core/gui/VisionRenderTypeTest.java index f3e634030..3c6022c8c 100644 --- a/core/src/test/java/art/arcane/iris/core/gui/VisionRenderTypeTest.java +++ b/core/src/test/java/art/arcane/iris/core/gui/VisionRenderTypeTest.java @@ -7,6 +7,7 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; public class VisionRenderTypeTest { @Test @@ -18,6 +19,12 @@ public class VisionRenderTypeTest { } } + @Test + public void riverRenderTypeUsesTheRiverNetworkLabel() { + assertSame(DesktopUiMessages.VISION_MODE_RIVER, VisionGUI.modeKey(RenderType.RIVER)); + assertEquals("iris.desktop.vision.mode.river", DesktopUiMessages.VISION_MODE_RIVER.id()); + } + @Test public void teleportCoordinatesFloorAcrossTheNegativeOrigin() { assertEquals(-1, VisionGUI.floorWorldCoordinate(-0.01D)); 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/PackDownloaderTest.java b/core/src/test/java/art/arcane/iris/core/pack/PackDownloaderTest.java index 0fed69aaa..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 @@ -651,6 +651,35 @@ public class PackDownloaderTest { assertEquals(0, PackDownloader.downloadLockCount()); } + @Test + public void invalidBuiltInRiverSchemaPreservesExistingTarget() throws Exception { + File packsFolder = temp.newFolder("invalid-river-packs"); + File target = writePack(packsFolder.toPath().resolve("overworld"), "overworld", "old"); + File extracted = writePack(temp.newFolder("invalid-river-source").toPath(), "overworld", "new"); + Files.writeString( + extracted.toPath().resolve("dimensions/overworld.json"), + "{\"name\":\"Overworld\",\"regions\":[\"local\"],\"logicalHeight\":256," + + "\"dimensionHeight\":{\"min\":-64,\"max\":320},\"hydrology\":{\"rivers\":{" + + "\"enabled\":true,\"routing\":{\"tileSize\":255}}}}", + StandardCharsets.UTF_8 + ); + + PackDownloader.PackInstallResult result = PackDownloader.installExtractedPack( + packsFolder, + extracted, + true, + "overworld", + ignored -> { + } + ); + + assertNull(result); + assertEquals("old", Files.readString(target.toPath().resolve("state.txt"), StandardCharsets.UTF_8)); + assertTrue(Files.isRegularFile(target.toPath().resolve("regions/local.json"))); + assertTransactionStateClean(packsFolder); + assertEquals(0, PackDownloader.downloadLockCount()); + } + @Test public void forceOverwriteReplacesEnginelessLoadedPackData() throws Exception { // A registered loader with no engines is a stale catalog registration (startup 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..f5bbbd86c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/pack/PackHydrologyValidatorTest.java @@ -0,0 +1,1371 @@ +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"], + "bankBiomes": ["river_bank"], + "bankMultiplier": 1.25 + } + } + """); + write(pack, "biomes/river_shore.json", + "{\"name\":\"Shore\",\"derivative\":\"minecraft:river\"}"); + write(pack, "biomes/river_bank.json", + "{\"name\":\"Bank\",\"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 acceptsEverySchemaBoundAtItsMinimum() throws Exception { + File pack = pack(riversDimension(""" + { + "enabled": true, + "routing": { + "tileSize": 256, + "sampleSpacing": 8, + "maximumRouteLength": 256, + "minimumSurfaceCourseLength": 0, + "minimumUndergroundCourseLength": 0, + "maximumOutletsPerTile": 1, + "oceanOutlets": true, + "valleyPreference": 0, + "uphillPenalty": 0, + "slopePenalty": 0, + "confluenceAttraction": 0 + }, + "surface": { + "channel": { + "width": {"min": 1, "max": 1}, + "depth": {"min": 1, "max": 1}, + "inset": 0, + "maximumIncision": 1, + "roughness": 0, + "roughnessWavelength": 4 + }, + "banks": { + "freeboard": 0, + "shoreWidth": 0.5, + "blendSlope": 0.5, + "minimumBlendWidth": 1, + "maximumBlendWidth": 1, + "exposeCutStrata": false + }, + "flow": {"cascadeRun": 1, "waterfallMinimumDrop": 2}, + "mouths": {"flareRatio": 1, "maximumOceanApron": 0} + }, + "underground": {"mouthLevelingDistance": 16} + } + """, "\"bankMultiplier\": 0")); + + PackHydrologyValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().isEmpty()); + } + + @Test + public void bedRulesRejectBadPaddingAndFluidPaddingBlocks() throws Exception { + File pack = pack(riversDimension(""" + { + "enabled": true, + "routing": {"tileSize": 1024, "sampleSpacing": 64, "oceanOutlets": true}, + "surface": { + "bed": { + "allowGravityBlocks": false, + "padding": 9, + "paddingPalette": {"palette": [{"block": "minecraft:water"}]} + } + } + } + """, "")); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "padding must be at most 8"); + assertContains(result.errors(), "paddingPalette.palette[0].block must not be a fluid"); + } + + @Test + public void bedRulesAcceptSolidPaddingBlocks() throws Exception { + File pack = pack(riversDimension(""" + { + "enabled": true, + "routing": {"tileSize": 1024, "sampleSpacing": 64, "oceanOutlets": true}, + "surface": { + "bed": { + "allowGravityBlocks": true, + "padding": 0, + "paddingPalette": {"palette": [{"block": "minecraft:clay"}, {"block": "minecraft:dirt", "weight": 2}]} + } + } + } + """, "")); + + PackHydrologyValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().stream().noneMatch((String error) -> error.contains(".bed"))); + } + + @Test + public void surfacePoolRulesRejectBadRangesAndUnknownPolicyReferences() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "rivers": {"enabled": true, "routing": {"tileSize": 1024, "sampleSpacing": 64, "oceanOutlets": true}}, + "surfacePools": [ + { + "id": "lava_pool", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "spacing": 16, + "minimumRadius": 8, + "maximumRadius": 4, + "depth": 9, + "biome": "no/such/biome" + }, + {"id": "pool", "fluidPalette": {"palette": [{"block": "minecraft:water"}]}} + ] + }, + "riverPolicy": {"surfacePools": ["lava_pool", "missing_pool"]} + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "spacing must be at least 32"); + assertContains(result.errors(), "minimumRadius must not exceed maximumRadius"); + assertContains(result.errors(), "depth must be at most 8"); + assertContains(result.errors(), "biome references unknown biome 'no/such/biome'"); + assertContains(result.errors(), "'pool' is a reserved hydrology feature selector"); + assertContains(result.errors(), "references unknown surface pool 'missing_pool'"); + } + + @Test + public void surfacePoolsAcceptAConfiguredPoolAndItsPolicyReference() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "rivers": {"enabled": true, "routing": {"tileSize": 1024, "sampleSpacing": 64, "oceanOutlets": true}}, + "surfacePools": [ + { + "id": "lava_pool", + "fluidPalette": {"palette": [{"block": "minecraft:lava"}]}, + "density": 0.75, + "spacing": 384, + "minimumRadius": 4, + "maximumRadius": 7, + "depth": 2 + } + ] + }, + "riverPolicy": {"surfacePools": ["lava_pool"]} + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().stream().noneMatch((String error) -> error.contains("surfacePool"))); + } + + @Test + public void rejectsMouthLevelingDistanceBeyondTheRouteLength() throws Exception { + File pack = pack(riversDimension(""" + { + "enabled": true, + "routing": {"tileSize": 1024, "sampleSpacing": 64, "maximumRouteLength": 256, "oceanOutlets": true}, + "underground": {"mouthLevelingDistance": 512} + } + """, "")); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "mouthLevelingDistance must not exceed routing.maximumRouteLength"); + } + + @Test + public void acceptsEverySchemaBoundAtItsMaximum() throws Exception { + File pack = pack(riversDimension(""" + { + "enabled": true, + "routing": { + "tileSize": 8192, + "sampleSpacing": 64, + "maximumRouteLength": 32768, + "minimumSurfaceCourseLength": 32768, + "minimumUndergroundCourseLength": 32768, + "maximumOutletsPerTile": 256, + "oceanOutlets": true, + "valleyPreference": 8, + "uphillPenalty": 128, + "slopePenalty": 16, + "confluenceAttraction": 1 + }, + "surface": { + "channel": { + "width": {"min": 128, "max": 128}, + "depth": {"min": 64, "max": 64}, + "inset": 3, + "maximumIncision": 32, + "roughness": 1, + "roughnessWavelength": 64 + }, + "banks": { + "freeboard": 4, + "shoreWidth": 6, + "blendSlope": 12, + "minimumBlendWidth": 64, + "maximumBlendWidth": 64, + "exposeCutStrata": true + }, + "flow": {"cascadeRun": 8, "waterfallMinimumDrop": 32}, + "mouths": {"flareRatio": 4, "maximumOceanApron": 32} + }, + "underground": {"mouthLevelingDistance": 512} + } + """, "\"bankMultiplier\": 4")); + + PackHydrologyValidator.Validation result = validate(pack); + + assertTrue(result.errors().toString(), result.errors().isEmpty()); + } + + @Test + public void rejectsRoutingWeightsSpacingAndCourseLengths() throws Exception { + File above = pack(riversDimension(""" + { + "enabled": true, + "routing": { + "tileSize": 2048, + "sampleSpacing": 24, + "maximumRouteLength": 256, + "minimumSurfaceCourseLength": 512, + "minimumUndergroundCourseLength": 512, + "maximumOutletsPerTile": 0, + "valleyPreference": 8.5, + "uphillPenalty": 128.5, + "slopePenalty": 16.5, + "confluenceAttraction": 1.5, + "oceanOutlets": false, + "inlandOutlets": [] + } + } + """)); + File below = pack(riversDimension(""" + { + "enabled": true, + "routing": { + "tileSize": 128, + "sampleSpacing": 4, + "maximumRouteLength": 128, + "valleyPreference": -0.5, + "uphillPenalty": -0.5, + "slopePenalty": -0.5, + "confluenceAttraction": -0.5 + } + } + """)); + + PackHydrologyValidator.Validation aboveResult = validate(above); + PackHydrologyValidator.Validation belowResult = validate(below); + + assertContains(aboveResult.errors(), ".sampleSpacing must be one of [8, 16, 32, 64]."); + assertContains(aboveResult.errors(), "tileSize must be divisible by sampleSpacing"); + assertContains(aboveResult.errors(), "minimumSurfaceCourseLength must not exceed maximumRouteLength"); + assertContains(aboveResult.errors(), "minimumUndergroundCourseLength must not exceed maximumRouteLength"); + assertContains(aboveResult.errors(), "maximumOutletsPerTile must be at least 1"); + assertContains(aboveResult.errors(), "valleyPreference must be at most 8.0"); + assertContains(aboveResult.errors(), "uphillPenalty must be at most 128.0"); + assertContains(aboveResult.errors(), "slopePenalty must be at most 16.0"); + assertContains(aboveResult.errors(), "confluenceAttraction must be at most 1.0"); + assertContains(aboveResult.errors(), "must enable oceanOutlets or select at least one"); + assertContains(belowResult.errors(), "tileSize must be at least 256"); + assertContains(belowResult.errors(), "sampleSpacing must be at least 8"); + assertContains(belowResult.errors(), "maximumRouteLength must be at least 256"); + assertContains(belowResult.errors(), "valleyPreference must be at least 0.0"); + assertContains(belowResult.errors(), "uphillPenalty must be at least 0.0"); + assertContains(belowResult.errors(), "slopePenalty must be at least 0.0"); + assertContains(belowResult.errors(), "confluenceAttraction must be at least 0.0"); + } + + @Test + public void rejectsSurfaceChannelValuesOutsideTheirBounds() throws Exception { + File above = pack(riversDimension(""" + { + "enabled": true, + "surface": { + "channel": { + "width": {"min": 20, "max": 2}, + "depth": {"min": 2, "max": 65}, + "inset": 4, + "maximumIncision": 33, + "roughness": 1.5, + "roughnessWavelength": 65, + "springWidthRatio": 5, + "springLength": 97 + } + } + } + """)); + File below = pack(riversDimension(""" + { + "enabled": true, + "surface": { + "channel": { + "width": {"min": 0, "max": 8}, + "depth": {"min": 0, "max": 4}, + "inset": -1, + "maximumIncision": 0, + "roughness": -0.1, + "roughnessWavelength": 3, + "springWidthRatio": 0.5, + "springLength": 3 + } + } + } + """)); + + PackHydrologyValidator.Validation aboveResult = validate(above); + PackHydrologyValidator.Validation belowResult = validate(below); + + assertContains(aboveResult.errors(), "width.min must not exceed max"); + assertContains(aboveResult.errors(), "depth.max must be at most 64.0"); + assertContains(aboveResult.errors(), "inset must be at most 3"); + assertContains(aboveResult.errors(), "maximumIncision must be at most 32"); + assertContains(aboveResult.errors(), "roughness must be at most 1.0"); + assertContains(aboveResult.errors(), "roughnessWavelength must be at most 64"); + assertContains(aboveResult.errors(), "springWidthRatio must be at most 4.0"); + assertContains(aboveResult.errors(), "springLength must be at most 96"); + assertContains(belowResult.errors(), "springWidthRatio must be at least 1.0"); + assertContains(belowResult.errors(), "springLength must be at least 4"); + assertContains(belowResult.errors(), "width.min must be at least 1.0"); + assertContains(belowResult.errors(), "depth.min must be at least 1.0"); + assertContains(belowResult.errors(), "inset must be at least 0"); + assertContains(belowResult.errors(), "maximumIncision must be at least 1"); + assertContains(belowResult.errors(), "roughness must be at least 0.0"); + assertContains(belowResult.errors(), "roughnessWavelength must be at least 4"); + } + + @Test + public void rejectsSurfaceBankValuesOutsideTheirBounds() throws Exception { + File above = pack(riversDimension(""" + { + "enabled": true, + "surface": { + "banks": { + "freeboard": 5, + "shoreWidth": 6.5, + "blendSlope": 12.5, + "minimumBlendWidth": 40, + "maximumBlendWidth": 65, + "exposeCutStrata": "yes" + } + } + } + """)); + File below = pack(riversDimension(""" + { + "enabled": true, + "surface": { + "banks": { + "freeboard": -1, + "shoreWidth": 0.4, + "blendSlope": 0.4, + "minimumBlendWidth": 0, + "maximumBlendWidth": 8 + } + } + } + """)); + File unordered = pack(riversDimension(""" + { + "enabled": true, + "surface": { + "banks": {"minimumBlendWidth": 20, "maximumBlendWidth": 10} + } + } + """)); + + PackHydrologyValidator.Validation aboveResult = validate(above); + PackHydrologyValidator.Validation belowResult = validate(below); + PackHydrologyValidator.Validation unorderedResult = validate(unordered); + + assertContains(aboveResult.errors(), "freeboard must be at most 4"); + assertContains(aboveResult.errors(), "shoreWidth must be at most 6.0"); + assertContains(aboveResult.errors(), "blendSlope must be at most 12.0"); + assertContains(aboveResult.errors(), "maximumBlendWidth must be at most 64"); + assertContains(aboveResult.errors(), "exposeCutStrata must be a boolean"); + assertContains(belowResult.errors(), "freeboard must be at least 0"); + assertContains(belowResult.errors(), "shoreWidth must be at least 0.5"); + assertContains(belowResult.errors(), "blendSlope must be at least 0.5"); + assertContains(belowResult.errors(), "minimumBlendWidth must be at least 1"); + assertContains(unorderedResult.errors(), "minimumBlendWidth must not exceed maximumBlendWidth"); + } + + @Test + public void rejectsSurfaceFlowAndMouthValuesOutsideTheirBounds() throws Exception { + File above = pack(riversDimension(""" + { + "enabled": true, + "surface": { + "flow": {"cascadeRun": 9, "waterfallMinimumDrop": 33}, + "mouths": {"flareRatio": 4.5, "maximumOceanApron": 33} + } + } + """)); + File below = pack(riversDimension(""" + { + "enabled": true, + "surface": { + "flow": {"cascadeRun": 0, "waterfallMinimumDrop": 1}, + "mouths": {"flareRatio": 0.5, "maximumOceanApron": -1} + } + } + """)); + + PackHydrologyValidator.Validation aboveResult = validate(above); + PackHydrologyValidator.Validation belowResult = validate(below); + + assertContains(aboveResult.errors(), "cascadeRun must be at most 8"); + assertContains(aboveResult.errors(), "waterfallMinimumDrop must be at most 32"); + assertContains(aboveResult.errors(), "flareRatio must be at most 4.0"); + assertContains(aboveResult.errors(), "maximumOceanApron must be at most 32"); + assertContains(belowResult.errors(), "cascadeRun must be at least 1"); + assertContains(belowResult.errors(), "waterfallMinimumDrop must be at least 2"); + assertContains(belowResult.errors(), "flareRatio must be at least 1.0"); + assertContains(belowResult.errors(), "maximumOceanApron must be at least 0"); + } + + @Test + public void rejectsUndergroundMouthLevelingDistanceOutsideItsBounds() throws Exception { + File above = pack(riversDimension(""" + { + "enabled": true, + "underground": {"mouthLevelingDistance": 513} + } + """)); + File below = pack(riversDimension(""" + { + "enabled": true, + "underground": {"mouthLevelingDistance": 15} + } + """)); + + PackHydrologyValidator.Validation aboveResult = validate(above); + PackHydrologyValidator.Validation belowResult = validate(below); + + assertContains(aboveResult.errors(), "mouthLevelingDistance must be at most 512"); + assertContains(belowResult.errors(), "mouthLevelingDistance must be at least 16"); + } + + @Test + public void rejectsGeometryComplexityAndCapabilityFailures() 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, + "maximumRouteLength": 32768 + }, + "geometry": { + "meanders": { + "primaryWavelength": 7, + "detailWavelength": 129, + "maximumTurnDegrees": 151 + }, + "underground": { + "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} + }, + "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(), "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(), "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(), "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"], + "bankBiomes": ["missing_bank"], + "floodedCaveBiomes": ["../escape"], + "widthMultiplier": 0, + "depthMultiplier": 17, + "incisionMultiplier": -1, + "routingMultiplier": -1, + "bankMultiplier": 4.5 + } + } + """); + + 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(), "bankBiomes[0] references missing biome 'missing_bank'"); + 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"); + assertContains(result.errors(), "bankMultiplier must be at most 4.0"); + } + + @Test + public void rejectsNegativeBankMultiplierAndNonBiomeBankReferences() throws Exception { + File pack = pack(""" + { + "regions": ["region"], + "hydrology": { + "rivers": {"enabled": true} + }, + "riverPolicy": { + "bankBiomes": [17], + "bankMultiplier": -0.5 + } + } + """); + + PackHydrologyValidator.Validation result = validate(pack); + + assertContains(result.errors(), "bankBiomes[0] must name a biome resource"); + assertContains(result.errors(), "bankMultiplier must be at least 0.0"); + } + + @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", + "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": 1000, + "sampleSpacing": 64 + }, + "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 riversDimension(String riversJson) { + return riversDimension(riversJson, null); + } + + private String riversDimension(String riversJson, String policyEntries) { + String policy = policyEntries == null ? "" : ",\n\"riverPolicy\": {" + policyEntries + "}"; + return """ + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "rivers": %s + }%s + } + """.formatted(riversJson, policy); + } + + private String canonicalDimension() { + return """ + { + "regions": ["region"], + "dimensionHeight": {"min": -256, "max": 512}, + "hydrology": { + "rivers": { + "enabled": true, + "routing": { + "tileSize": 2048, + "sampleSpacing": 64, + "maximumRouteLength": 8192, + "minimumSurfaceCourseLength": 384, + "minimumUndergroundCourseLength": 192, + "maximumOutletsPerTile": 4, + "oceanOutlets": true, + "inlandOutlets": ["SINKHOLE_GROTTO"], + "valleyPreference": 1.5, + "uphillPenalty": 24, + "slopePenalty": 2, + "confluenceAttraction": 0.2 + }, + "geometry": { + "meanders": { + "primaryWavelength": 64, + "detailWavelength": 12, + "primaryStrength": 0.34, + "detailStrength": 0.42, + "maximumOffsetRatio": 0.48, + "smoothingPasses": 1, + "maximumTurnDegrees": 82 + }, + "underground": { + "bedRoundness": 2.4, + "bedRoughness": 0.28, + "wallRoughness": 0.24, + "roughnessWavelength": 11 + }, + "grottos": {"bedRoundness": 2.4}, + "drops": { + "cascadeRunPerBlock": 2, + "cascadeExponent": 1.4, + "maximumCascadeStep": 2, + "flowWidthRatio": 0.45, + "maximumFlowDepth": 2, + "basinWidthRatio": 1.8, + "maximumBasinDepth": 8 + } + }, + "surface": { + "enabled": true, + "sources": { + "density": 0.5, + "minimumElevation": 88, + "minimumPerTile": 1, + "minimumSpacing": 384 + }, + "channel": { + "width": {"min": 4, "max": 8, "style": {"style": "STATIC"}}, + "depth": {"min": 2, "max": 4}, + "inset": 1, + "maximumIncision": 10, + "roughness": 0.25, + "roughnessWavelength": 16 + }, + "banks": { + "freeboard": 1, + "shoreWidth": 1.5, + "blendSlope": 3, + "minimumBlendWidth": 4, + "maximumBlendWidth": 32, + "exposeCutStrata": true + }, + "flow": { + "cascadeRun": 2, + "waterfallMinimumDrop": 6 + }, + "mouths": { + "flareRatio": 1.6, + "maximumOceanApron": 8 + } + }, + "underground": { + "enabled": true, + "sources": {"density": 0.5, "minimumPerTile": 1, "minimumSpacing": 512}, + "fluidLevel": {"min": -48, "max": 50}, + "channelWidth": {"min": 3, "max": 8}, + "depth": {"min": 1, "max": 3}, + "headroom": {"min": 6, "max": 14}, + "connectToExistingCaves": true, + "mouthLevelingDistance": 64 + }, + "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"], + "bankBiomes": ["river_bank"], + "floodedCaveBiomes": ["river_bank"], + "widthMultiplier": 1, + "depthMultiplier": 1, + "incisionMultiplier": 1, + "routingMultiplier": 1, + "bankMultiplier": 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/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 new file mode 100644 index 000000000..384c840e8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/IrisRiverSchemaTest.java @@ -0,0 +1,258 @@ +package art.arcane.iris.core.project; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +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.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; +import art.arcane.volmlib.util.json.JSONObject; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformRegistries; +import org.junit.After; +import org.junit.Before; +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.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IrisRiverSchemaTest { + private IrisPlatform previousPlatform; + + @Before + public void bindPlatform() { + previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + if (previousPlatform != null) { + IrisPlatforms.unbind(); + } + IrisPlatform platform = mock(IrisPlatform.class); + PlatformRegistries registries = mock(PlatformRegistries.class); + when(platform.registries()).thenReturn(registries); + when(registries.blockTypeKeys()).thenReturn(List.of()); + IrisPlatforms.bind(platform); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previousPlatform != null) { + IrisPlatforms.bind(previousPlatform); + } + } + + @Test + public void hydrologySchemaExposesTypedPhysicalHierarchy() { + JSONObject schema = new SchemaBuilder(IrisHydrology.class, schemaData()).construct(); + JSONObject definitions = schema.getJSONObject("definitions"); + JSONObject properties = schema.getJSONObject("properties"); + JSONObject rivers = referencedProperties(definitions, properties.getJSONObject("rivers")); + JSONObject routing = referencedProperties(definitions, rivers.getJSONObject("routing")); + 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 banks = referencedProperties(definitions, surface.getJSONObject("banks")); + JSONObject flow = referencedProperties(definitions, surface.getJSONObject("flow")); + 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")); + + assertFalse(routing.has("refinementSpacing")); + assertFalse(routing.has("branching")); + assertFalse(surface.has("hydraulics")); + assertFalse(surface.has("ridgeTunnels")); + assertFalse(channel.has("surfaceInset")); + assertFalse(channel.has("terrainBlendWidth")); + assertFalse(mouths.has("levelingDistance")); + assertFalse(geometry.has("surface")); + + assertEquals(8192, routing.getJSONObject("tileSize").getInt("maximum")); + assertEquals(32768, routing.getJSONObject("maximumRouteLength").getInt("maximum")); + assertEquals(32768, routing.getJSONObject("minimumSurfaceCourseLength").getInt("maximum")); + assertEquals(32768, routing.getJSONObject("minimumUndergroundCourseLength").getInt("maximum")); + assertEquals(8D, routing.getJSONObject("valleyPreference").getDouble("maximum"), 0D); + assertEquals(128D, routing.getJSONObject("uphillPenalty").getDouble("maximum"), 0D); + assertEquals(16D, routing.getJSONObject("slopePenalty").getDouble("maximum"), 0D); + assertEquals(1D, routing.getJSONObject("confluenceAttraction").getDouble("maximum"), 0D); + 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")); + assertEquals(0, channel.getJSONObject("inset").getInt("minimum")); + assertEquals(3, channel.getJSONObject("inset").getInt("maximum")); + assertEquals(32, channel.getJSONObject("maximumIncision").getInt("maximum")); + assertEquals(1D, channel.getJSONObject("roughness").getDouble("maximum"), 0D); + assertEquals(64, channel.getJSONObject("roughnessWavelength").getInt("maximum")); + assertEquals(0, banks.getJSONObject("freeboard").getInt("minimum")); + assertEquals(4, banks.getJSONObject("freeboard").getInt("maximum")); + assertEquals(6D, banks.getJSONObject("shoreWidth").getDouble("maximum"), 0D); + assertEquals(0.5D, banks.getJSONObject("blendSlope").getDouble("minimum"), 0D); + assertEquals(12D, banks.getJSONObject("blendSlope").getDouble("maximum"), 0D); + assertEquals(64, banks.getJSONObject("minimumBlendWidth").getInt("maximum")); + assertEquals(64, banks.getJSONObject("maximumBlendWidth").getInt("maximum")); + assertEquals("boolean", banks.getJSONObject("exposeCutStrata").getString("type")); + assertEquals(1, flow.getJSONObject("cascadeRun").getInt("minimum")); + assertEquals(8, flow.getJSONObject("cascadeRun").getInt("maximum")); + assertEquals(2, flow.getJSONObject("waterfallMinimumDrop").getInt("minimum")); + assertEquals(32, flow.getJSONObject("waterfallMinimumDrop").getInt("maximum")); + assertEquals(1D, mouths.getJSONObject("flareRatio").getDouble("minimum"), 0D); + assertEquals(32, mouths.getJSONObject("maximumOceanApron").getInt("maximum")); + assertEquals(16, underground.getJSONObject("mouthLevelingDistance").getInt("minimum")); + assertEquals(512, underground.getJSONObject("mouthLevelingDistance").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 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(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("bankBiomes").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("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") + private static IrisData schemaData() { + IrisData data = mock(IrisData.class); + ResourceLoader biomeLoader = mock(ResourceLoader.class); + ResourceLoader expressionLoader = mock(ResourceLoader.class); + ResourceLoader blockLoader = mock(ResourceLoader.class); + KMap, ResourceLoader> loaders = new KMap<>(); + loaders.put(IrisBiome.class, biomeLoader); + loaders.put(IrisExpression.class, expressionLoader); + when(data.getBlockLoader()).thenReturn(blockLoader); + when(data.getLoaders()).thenReturn(loaders); + when(data.getPossibleSnippets(anyString())).thenReturn(new KList<>()); + 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]); + when(expressionLoader.getPossibleKeys()).thenReturn(new String[0]); + when(expressionLoader.getFolderName()).thenReturn("expressions"); + when(expressionLoader.getResourceTypeName()).thenReturn("Expression"); + return data; + } + + private static JSONObject referencedProperties(JSONObject definitions, JSONObject reference) { + String key = reference.getString("$ref").substring("#/definitions/".length()); + return definitions.getJSONObject(key).getJSONObject("properties"); + } + + 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; + } + } + 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("enum"); + List names = new ArrayList<>(values.length()); + for (int index = 0; index < values.length(); index++) { + names.add(values.getString(index)); + } + return names; + } +} 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 5142dd313..ac3a99db5 100644 --- a/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/IrisComplexSurfaceBiomeTest.java @@ -1,16 +1,140 @@ package art.arcane.iris.engine; +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.util.project.stream.ProceduralStream; +import art.arcane.iris.util.project.stream.interpolation.Interpolated; import org.junit.Test; +import java.util.List; + +import static org.junit.Assert.assertEquals; 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 gradingFootprintUsesTheBankBiome() { + 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) + ); + + assertEquals("dry", IrisComplex.hydrologySurfaceBiomeKey(sample)); + } + + @Test + public void naturalOceanMaskTracksContinentalIntent() { + assertEquals(InferredType.SEA, IrisComplex.resolveNaturalInferredType( + constantType(InferredType.SEA), + null, + 0D, + 0D + )); + assertEquals(InferredType.LAND, IrisComplex.resolveNaturalInferredType( + constantType(InferredType.LAND), + null, + 0D, + 0D + )); + } + + @Test + public void focusNaturalOceanMaskUsesTheFocusedSurfaceType() { + assertEquals(InferredType.SEA, IrisComplex.resolveNaturalInferredType( + constantType(InferredType.LAND), + 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 naturalSlopeMatchesTheRangeThreeStreamFormula() { + assertEquals(5D, IrisComplex.calculateNaturalSlope(10D, 13D, 14D), 0D); + } + + @Test + public void elevatedRiverBankUsesTheLocalWaterHead() { + IrisBiome base = mock(IrisBiome.class); + IrisBiome sea = mock(IrisBiome.class); + IrisRegion region = mock(IrisRegion.class); + doReturn(false).when(base).isShore(); + doReturn(false).when(base).isAquatic(); + doReturn(3D).when(region).getShoreHeight(12D, 18D); + + IrisBiome resolved = IrisComplex.resolveSurfaceBiome( + 68D, + base, + region, + 12D, + 18D, + 70D, + constant(base), + constant(sea), + constant(base) + ); + + assertSame(sea, resolved); + } + @Test public void shorelineHeightSelectsShoreBiome() { IrisBiome base = mock(IrisBiome.class); @@ -28,7 +152,8 @@ public class IrisComplexSurfaceBiomeTest { 63D, constant(base), constant(base), - constant(shore)); + constant(shore) + ); assertSame(shore, resolved); } @@ -51,7 +176,8 @@ public class IrisComplexSurfaceBiomeTest { 63D, constant(land), constant(aquatic), - constant(aquatic)); + constant(aquatic) + ); assertSame(land, resolved); } @@ -62,4 +188,11 @@ public class IrisComplexSurfaceBiomeTest { doReturn(biome).when(stream).get(anyDouble(), anyDouble()); return stream; } + + private static ProceduralStream constantType(InferredType type) { + return ProceduralStream.of( + (x, z) -> type, + Interpolated.of(value -> 0D, value -> type) + ); + } } 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 new file mode 100644 index 000000000..7e8042b02 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/actuator/IrisDecorantActuatorRiverTest.java @@ -0,0 +1,251 @@ +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.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 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 + ); + return new HydrologyColumnLayer( + feature, + 63, + 65, + type.isUnderground() ? 72 : 65, + channel, + shore, + grading, + connectedFluid, + fallingFluid, + fallingFluid, + true, + fluidOwned, + false, + "water", + "river", + "mouth", + "shore", + "dry", + "cave" + ); + } + + 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 bd2740099..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 @@ -1,6 +1,7 @@ package art.arcane.iris.engine.decorator; import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.SeedManager; import art.arcane.iris.engine.object.InferredType; @@ -10,10 +11,12 @@ import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.util.project.hunk.Hunk; +import art.arcane.iris.util.project.stream.ProceduralStream; 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; @@ -31,6 +34,11 @@ public class IrisDecoratorCaveContextTest { IrisDimension dimension = mock(IrisDimension.class); doReturn(63).when(dimension).getFluidHeight(); doReturn(dimension).when(engine).getDimension(); + IrisComplex complex = mock(IrisComplex.class); + ProceduralStream fluidStream = mock(ProceduralStream.class); + doReturn(complex).when(engine).getComplex(); + doReturn(fluidStream).when(complex).getRiverWaterSurfaceStream(); + doReturn(63D).when(fluidStream).get(anyDouble(), anyDouble()); IrisDecorator decorator = mock(IrisDecorator.class); doReturn(true).when(decorator).passesChanceGate(any(), anyDouble(), anyDouble(), any()); @@ -67,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 578f2eafb..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); @@ -98,6 +180,7 @@ public class IrisShoreLineDecoratorTest { IrisSlopeClip slope = mock(IrisSlopeClip.class); PlatformBlockState decorant = mock(PlatformBlockState.class); ProceduralStream heightStream = mock(ProceduralStream.class); + ProceduralStream fluidStream = mock(ProceduralStream.class); when(engine.getCacheID()).thenReturn(1); when(engine.getSeedManager()).thenReturn(seedManager); @@ -107,7 +190,11 @@ public class IrisShoreLineDecoratorTest { when(engine.getComplex()).thenReturn(complex); when(complex.getFluidHeight()).thenReturn((double) FLUID_HEIGHT); when(complex.getHeightStream()).thenReturn(heightStream); - when(heightStream.get(anyDouble(), anyDouble())).thenReturn((double) FLUID_HEIGHT - 1); + when(complex.getRiverWaterSurfaceStream()).thenReturn(fluidStream); + 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)) .thenReturn(new IrisDecorator[]{decorator}); 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/IrisStructureLocatorContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java index a07d078ab..9ca27f964 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/IrisStructureLocatorContractTest.java @@ -417,7 +417,7 @@ public class IrisStructureLocatorContractTest { assertEquals(IrisStructureLocator.LocateStatus.SEARCH_LIMIT_REACHED, result.status()); assertFalse(result.found()); - verify(engine, times(4_096)).getComplex(); + verify(engine, times(8_192)).getComplex(); } @Test diff --git a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java index 45a201202..0b0c617f7 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/NativeStructurePlacementPlannerTest.java @@ -1,5 +1,6 @@ package art.arcane.iris.engine.framework; +import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.object.IrisNativeStructure; import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisDimension; @@ -9,6 +10,7 @@ import art.arcane.iris.engine.object.IrisStructureTerrain; import art.arcane.iris.engine.object.IrisStructureTerrainMode; import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.engine.object.StructureDistribution; +import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import org.junit.Test; @@ -20,6 +22,7 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class NativeStructurePlacementPlannerTest { @@ -112,6 +115,20 @@ public class NativeStructurePlacementPlannerTest { assertNotNull(NativeStructurePlacementPlanner.planAt(engine, placement, 0, 0)); } + @Test + public void submergedCheckUsesTheColumnRiverHead() { + Engine engine = engine(77L, -64, 384, 66); + IrisComplex complex = mock(IrisComplex.class); + @SuppressWarnings("unchecked") + ProceduralStream riverHead = mock(ProceduralStream.class); + when(engine.getComplex()).thenReturn(complex); + when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead); + when(riverHead.get(8, 8)).thenReturn(68D); + + assertTrue(NativeStructurePlacementPlanner.isSubmerged(engine, 8, 8)); + verify(riverHead).get(8, 8); + } + @Test public void duplicateSelectedStructureUsesOneDeterministicPlan() { Engine engine = engine(77L, -64, 384, 150); 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 7e0456a31..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,16 @@ 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.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.volmlib.util.math.RNG; import art.arcane.volmlib.util.matter.MatterCavern; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import java.util.Arrays; @@ -19,6 +27,7 @@ import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; @@ -27,6 +36,22 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class StructureCaveAnchorResolverTest { + @Before + public void bindPlatform() { + 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); + } + + @After + public void unbindPlatform() { + IrisPlatforms.unbind(); + } + @Test public void floorRequiresSolidBoundaryAndUpwardClearance() { IntPredicate carved = carvedAt(10, 11, 12, 13); @@ -155,6 +180,21 @@ public class StructureCaveAnchorResolverTest { true, new MatterCavern(false, "", (byte) 0), 20, 8)); } + @Test + public void plannedHydrologyCannotBecomeStructureAnchors() { + MatterCavern fluid = new MatterCavern(true, "", (byte) 1); + MatterCavern forcedAir = new MatterCavern(true, "", (byte) 3); + + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( + true, fluid, HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), 20, 8)); + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( + true, fluid, HydrologyCaveCell.of(HydrologyCaveAction.FALLING_FLUID), 20, 8)); + assertFalse(StructureCaveAnchorResolver.acceptsAnchorFluid( + 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) { Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); IrisDimension dimension = mock(IrisDimension.class); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java index f23863fd8..20b9785de 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/StructurePlacementScopeTest.java @@ -66,7 +66,7 @@ public class StructurePlacementScopeTest { } @Test - public void dimensionOnlyPlacementsDoNotResolveBiomeScopes() { + public void dimensionOnlyPlacementsDoNotResolveRiverInclusiveBiomeScopes() { IrisStructurePlacement dimensionPlacement = new IrisStructurePlacement(); Engine engine = mock(Engine.class, RETURNS_DEEP_STUBS); IrisComplex complex = engine.getComplex(); diff --git a/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererPerformanceContractTest.java b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererPerformanceContractTest.java index c1f8e4729..39a4bac1f 100644 --- a/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererPerformanceContractTest.java +++ b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererPerformanceContractTest.java @@ -71,25 +71,33 @@ public class IrisRendererPerformanceContractTest { } @Test - public void studioAndProtocolHeightUseOrdinaryTerrain() { + public void studioHeightUsesNaturalTerrainWhileProtocolRenderRemainsExact() { Engine engine = mock(Engine.class); IrisComplex complex = mock(IrisComplex.class); @SuppressWarnings("unchecked") + ProceduralStream natural = mock(ProceduralStream.class); + @SuppressWarnings("unchecked") ProceduralStream exact = mock(ProceduralStream.class); when(engine.getComplex()).thenReturn(complex); when(engine.getHeight()).thenReturn(320); + when(complex.getNaturalHeightStream()).thenReturn(natural); when(complex.getHeightStream()).thenReturn(exact); + when(natural.getDouble(0D, 0D)).thenReturn(80D); when(exact.getDouble(0D, 0D)).thenReturn(96D); IrisRenderer renderer = new IrisRenderer(engine); renderer.renderStudio(0D, 0D, 1D, 1, RenderType.HEIGHT, () -> false); + + verify(natural).getDouble(0D, 0D); + verifyNoInteractions(exact); + renderer.render(0D, 0D, 1D, 1, RenderType.HEIGHT); - verify(exact, times(2)).getDouble(0D, 0D); + verify(exact).getDouble(0D, 0D); } @Test - public void studioBiomeAndContinentAvoidEngineBiomeLookups() { + public void studioBiomeAndContinentAvoidRiverAdjustedEngineLookups() { Engine engine = mock(Engine.class); IrisComplex complex = mock(IrisComplex.class); IrisBiome biome = mock(IrisBiome.class); @@ -136,12 +144,12 @@ public class IrisRendererPerformanceContractTest { Engine engine = mock(Engine.class); IrisComplex complex = mock(IrisComplex.class); @SuppressWarnings("unchecked") - ProceduralStream exact = mock(ProceduralStream.class); + ProceduralStream natural = mock(ProceduralStream.class); AtomicInteger samples = new AtomicInteger(); when(engine.getComplex()).thenReturn(complex); when(engine.getHeight()).thenReturn(320); - when(complex.getHeightStream()).thenReturn(exact); - when(exact.getDouble(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble())) + when(complex.getNaturalHeightStream()).thenReturn(natural); + when(natural.getDouble(org.mockito.ArgumentMatchers.anyDouble(), org.mockito.ArgumentMatchers.anyDouble())) .thenAnswer(invocation -> { samples.incrementAndGet(); double x = invocation.getArgument(0); 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 new file mode 100644 index 000000000..2d931fcd6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/framework/render/IrisRendererRiverTest.java @@ -0,0 +1,255 @@ +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.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 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()); + expected.put(HydrologyFeatureType.STANDING_POOL, new Color(232, 120, 40).getRGB()); + + assertEquals(HydrologyFeatureType.values().length, expected.size()); + for (HydrologyFeatureType type : HydrologyFeatureType.values()) { + assertEquals(type.name(), expected.get(type).intValue(), IrisRenderer.hydrologyFeatureColor(type)); + } + } + + @Test + 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); + IrisHydrologyRuntime runtime = mock(IrisHydrologyRuntime.class); + when(engine.getComplex()).thenReturn(complex); + 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, + -7D, + 8D, + 1, + RenderType.RIVER, + () -> false + ); + + assertEquals(new Color(48, 112, 190).getRGB(), image.getRGB(0, 0)); + verify(runtime).sampleRenderFootprint(12D, -7D, 20D, 1D); + } + + @Test + public void riverVisionShowsAProjectedCandidateWithoutTreatingItAsAccepted() { + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.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, 0 + ); + 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.getHydrologyRuntime()).thenReturn(runtime); + when(base.get(0D, 0D)).thenReturn(biome); + when(biome.getColor(engine, RenderType.BIOME)).thenReturn(Color.GREEN); + when(runtime.sampleRenderFootprint(0D, 0D, 4D, 4D)) + .thenReturn(sample(HydrologyFeatureType.WATERFALL, false)); + + BufferedImage image = new IrisRenderer(engine).renderStudio( + 0D, + 0D, + 4D, + 1, + RenderType.BIOME, + () -> false + ); + + assertNotEquals(Color.GREEN.getRGB(), image.getRGB(0, 0)); + verify(base).get(0D, 0D); + 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..75031c863 --- /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 rejectedCaveCourseIsAbsentFromCoursesFootprintRendererAndLocator() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 22L, + 11L, + HydrologyFeatureType.UNDERGROUND_POOL, + 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.UNDERGROUND_POOL, 8, 8, 16).isEmpty()); + assertEquals(1, tile.diagnosticCandidates().size()); + assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, + tile.diagnosticCandidates().getFirst().rejection()); + } + + @Test + public void generatedSurfaceConnectedCaveAroundAnUndergroundReachIsSealed() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 42L, + 41L, + HydrologyFeatureType.UNDERGROUND_POOL, + 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.UNDERGROUND_POOL, + 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 undergroundReachAboveFinalTerrainRejectsTheWholeCourse() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 52L, + 51L, + HydrologyFeatureType.UNDERGROUND_POOL, + 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 surfaceExposurePreflightChecksTheFullActionBoundaryBeforeVoxelExpansion() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 152L, + 151L, + HydrologyFeatureType.UNDERGROUND_POOL, + 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 caveSurfaceOpeningExemptsItsCompleteSixNeighborShell() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 162L, + 161L, + HydrologyFeatureType.UNDERGROUND_POOL, + 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.UNDERGROUND_POOL, + 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 acceptedCaveCourseRetainsItsExactPlanOnTheImmutableTile() { + HydrologyTerrainSample terrain = HydrologyTerrainSample.openLand(80, 0D, "parent"); + HydraulicSegment ridge = new HydraulicSegment( + 82L, + 81L, + HydrologyFeatureType.UNDERGROUND_POOL, + 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.UNDERGROUND_POOL, + 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.UNDERGROUND_POOL, + 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..5da3bd5d9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyFootprintCompilerTest.java @@ -0,0 +1,1843 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +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 { + private static final int[][] CARDINALS = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + /** + * Published dry cells cardinally adjacent to owned water whose final terrain sits below the + * water head plus the configured freeboard. + */ + private static int unsupportedBankCells(RiverFootprint footprint, long courseId) { + int freeboard = HydrologyPlannerSettings.defaults().surface().banks().freeboard(); + int unsupported = 0; + for (HydrologyColumnSample sample : footprint.columns().values()) { + HydrologyColumnLayer fluid = sample.primarySurfaceFluidLayer().orElse(null); + if (fluid == null + || fluid.feature().courseId() != courseId + || fluid.oceanApron() + || fluid.fallingFluid()) { + continue; + } + for (int[] offset : CARDINALS) { + HydrologyColumnSample neighbor = footprint + .sample(sample.x() + offset[0], sample.z() + offset[1]) + .orElse(null); + if (neighbor == null || neighbor.primarySurfaceFluidLayer().isPresent()) { + continue; + } + if (neighbor.terrainHeight() < fluid.fluidHeadY() + freeboard) { + unsupported++; + } + } + } + return unsupported; + } + + /** Deepest cut between natural terrain and the published water head over an owned channel. */ + private static int maximumSurfaceCut(RiverFootprint footprint, long courseId) { + int maximum = 0; + for (HydrologyColumnSample sample : footprint.columns().values()) { + HydrologyColumnLayer fluid = sample.primarySurfaceFluidLayer().orElse(null); + if (fluid == null + || fluid.feature().courseId() != courseId + || fluid.feature().type() == HydrologyFeatureType.MOUTH + || fluid.oceanApron() + || fluid.fallingFluid() + || !fluid.channel() + || !fluid.fluidOwned()) { + continue; + } + maximum = Math.max(maximum, sample.naturalHeight() - fluid.fluidHeadY()); + } + return maximum; + } + + @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(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(2, 0) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElseThrow(); + HydrologyColumnLayer secondShared = footprint.sample(3, 0) + .flatMap(HydrologyColumnSample::primarySurfaceFluidLayer) + .orElseThrow(); + assertSame(firstShared.feature(), secondShared.feature()); + } + + @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()); + } + HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults(); + assertTrue(crossSection.size() >= 3); + assertTrue(crossSection.getFirst() < 70); + assertTrue(70 - crossSection.getFirst() <= settings.surface().maximumDepth() + 1); + int thalwegMinimum = crossSection.subList(0, 2).stream().mapToInt(Integer::intValue).min().orElseThrow(); + int thalwegMaximum = crossSection.subList(0, 2).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 surfaceChannelAtTerrainLevelHasNoContainingBank() { + 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)) + ); + RiverCourse course = course(14L, RiverCourseType.SURFACE, segment); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(70, 0D, "parent"); + HydrologyFootprintCompiler compiler = compiler(terrain); + RiverFootprint footprint = compiler.compile(List.of(course)); + + assertTrue(unsupportedBankCells(footprint, 14L) > 0); + for (HydrologyColumnSample sample : footprint.columns().values()) { + assertTrue(sample.terrainHeight() <= sample.naturalHeight()); + } + } + @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), List.of() + ); + 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 overlappingRasterPointsPreserveSortedKeysAndCanonicalAnchors() { + 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)); + + assertTrue(footprint.size() > 0); + 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); + assertTrue(first.size() > 1000); + assertTrue(geometrySamples.get() <= 8); + int outerX = 0; + while (first.sample(outerX + 1, 0).isPresent()) { + outerX++; + } + 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); + assertEquals(baselineCalls.get(), naturalSampler.basisCalls()); + assertEquals(0, detailedCalls.get()); + } + + @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)); + + int channelEdge = 0; + while (layerAt(footprint, channelEdge + 1, 0).channel()) { + channelEdge++; + } + int shoreX = channelEdge + 1; + while (footprint.sample(shoreX, 0).isPresent() && !layerAt(footprint, shoreX, 0).shore()) { + shoreX++; + } + int outerX = shoreX; + while (footprint.sample(outerX + 1, 0).isPresent()) { + outerX++; + } + HydrologyColumnLayer center = layerAt(footprint, 0, 0); + HydrologyColumnLayer wetEdge = layerAt(footprint, channelEdge, 0); + HydrologyColumnLayer shore = layerAt(footprint, shoreX, 0); + HydrologyColumnLayer outerGrade = layerAt(footprint, outerX, 0); + + assertTrue(center.channel()); + assertTrue(wetEdge.channel()); + assertTrue(center.bedY() < center.fluidHeadY()); + assertTrue(center.bedY() <= wetEdge.bedY()); + assertTrue(wetEdge.bedY() < shore.bedY()); + assertTrue(shore.bedY() <= outerGrade.bedY()); + assertTrue(shore.shore()); + assertTrue(outerGrade.grading()); + assertEquals(footprint.sample(outerX, 0).orElseThrow().naturalHeight(), outerGrade.bedY()); + for (HydrologyColumnSample sample : footprint.columns().values()) { + assertTrue(sample.terrainHeight() <= sample.naturalHeight()); + } + } + + @Test + public void surfaceChannelOutlineVariesAlongItsRun() { + HydraulicSegment segment = new HydraulicSegment( + 47L, + 46L, + HydrologyFeatureType.SURFACE_POOL, + 70, + 70, + 12, + 3, + false, + false, + List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(96, 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)); + + HashSet widths = new HashSet<>(); + for (int x = 8; x <= 88; x++) { + widths.add(wetHalfWidth(footprint, x)); + } + + assertTrue(widths.size() > 1); + } + @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 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 oceanClassifiedTerrainPublishesNoSurfaceColumns() { + 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(baseline.isEmpty()); + assertTrue(optimized.isEmpty()); + assertTrue(baselineCalls.get() > 221); + assertTrue(naturalSampler.basisCalls() > 0); + 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 cave = new HydraulicSegment( + 303L, + 301L, + HydrologyFeatureType.UNDERGROUND_POOL, + 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, cave) + ); + 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() + ).anyMatch((HydrologyColumnLayer layer) -> layer.feature().segmentId() == cave.id())); + + 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 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 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.UNDERGROUND_POOL, + 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); + + for (HydrologyColumnSample sample : full.columns().values()) { + assertEquals( + "planned surface at " + sample.x() + "," + sample.z(), + sample.terrainHeight(), + validation.plannedSurface().resolve( + sample.x(), + sample.z(), + terrain.sample(sample.x(), sample.z()).naturalHeight() + ) + ); + } + HashMap> validationLayers = new HashMap<>(); + for (HydrologyColumnSample column : validation.columns()) { + HydrologyColumnSample merged = full.sample(column.x(), column.z()).orElseThrow(); + assertEquals(merged.naturalHeight(), column.naturalHeight()); + assertEquals(merged.ocean(), column.ocean()); + assertEquals(merged.parentBiomeKey(), column.parentBiomeKey()); + assertTrue(merged.layers().containsAll(column.layers())); + validationLayers + .computeIfAbsent(RiverFootprint.pack(column.x(), column.z()), + (Long key) -> new ArrayList<>()) + .addAll(column.layers()); + } + for (HydrologyColumnSample sample : full.columns().values()) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (!layer.feature().type().isUnderground() + && !layer.feature().type().isDeepFluid() + && !layer.oceanApron()) { + continue; + } + assertTrue( + "missing cave layer at " + sample.x() + "," + sample.z(), + validationLayers + .getOrDefault(RiverFootprint.pack(sample.x(), sample.z()), new ArrayList<>()) + .contains(layer) + ); + } + } + 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(surfaceFluidLayer(footprint, 39, -7) == null); + } + + @Test + public void surfaceHeadwaterOpensAsASpringPoolThatNarrowsToTheChannel() { + 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(wetHalfWidth(footprint, 0) > wetHalfWidth(footprint, 32)); + assertTrue(wetHalfWidth(footprint, 0) >= 2 * wetHalfWidth(footprint, 56)); + assertTrue(Math.abs(wetHalfWidth(footprint, 32) - wetHalfWidth(footprint, 56)) <= 1); + for (int x = 1; x <= 24; x++) { + assertTrue(wetHalfWidth(footprint, x) <= wetHalfWidth(footprint, x - 1) + 1); + } + HydrologyColumnLayer source = surfaceFluidLayer(footprint, 0, 0); + HydrologyColumnLayer cruise = surfaceFluidLayer(footprint, 56, 0); + assertTrue(source != null); + assertTrue(cruise != null); + assertTrue(source.feature().source()); + assertFalse(surfaceFluidLayer(footprint, 0, 3).feature().source()); + int sourceDepth = source.fluidHeadY() - source.bedY(); + int cruiseDepth = cruise.fluidHeadY() - cruise.bedY(); + assertTrue(sourceDepth >= cruiseDepth + 1); + } + + @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 unsupportedWetEdgeIsReportedWithoutRaisingItsNaturalBank() { + 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(); + + assertEquals(0, unsupportedBankCells(highFootprint, 528L)); + assertEquals(70, lowBank.naturalHeight()); + assertEquals(70, lowBank.terrainHeight()); + assertTrue(unsupportedBankCells(lowBankFootprint, 528L) > 0); + for (HydrologyColumnSample sample : lowBankFootprint.columns().values()) { + assertTrue(sample.terrainHeight() <= sample.naturalHeight()); + } + } + + @Test + public void publishedSurfaceChannelStaysWithinTheConfiguredIncision() { + 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 compiler = compiler((int x, int z) -> + HydrologyTerrainSample.openLand(76, 0D, "parent")); + + RiverFootprint footprint = compiler.compile(List.of(course)); + + assertTrue( + "center=" + footprint.sample(12, 0), + maximumSurfaceCut(footprint, 528L) + <= HydrologyPlannerSettings.defaults().surface().maximumIncision() + ); + } + + @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()); + } + + @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)); + + 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 apron = footprint.sample(64, 0).orElseThrow(); + assertTrue(apron.ocean()); + assertFalse(apron.layers().isEmpty()); + int apronLimit = HydrologyPlannerSettings.defaults().outlets().maximumOceanApron(); + assertTrue(footprint.sample(64 + apronLimit, 0).isEmpty()); + for (HydrologyColumnSample sample : footprint.columns().values()) { + if (!sample.ocean()) { + assertTrue(sample.naturalHeight() > sample.seaLevel()); + continue; + } + assertTrue(sample.x() - 64 < apronLimit); + for (HydrologyColumnLayer layer : sample.layers()) { + assertTrue(layer.oceanApron()); + assertFalse(layer.terrainOwned()); + assertFalse(layer.fluidOwned()); + assertFalse(layer.shore()); + assertFalse(layer.grading()); + } + } + } + + @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..893d5d091 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java @@ -0,0 +1,720 @@ +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.SURFACE_POOL, + 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 outerGradingUsesTheBankBiome() { + 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)); + + assertEquals("dry", grading.biomeKey()); + 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", "coastal-grotto") + ); + + assertEquals(List.of( + "surface", + "waterfall", + "sinkhole", + "underground", + "grotto", + "coastal_grotto", + "inland_grotto", + "mouth", + "deep", + "pool", + "acid", + "deep_lava" + ), suggestions); + } + + @Test + public void featureQueryReservesEveryBuiltInKeywordAndEquivalentHyphenSpelling() { + List builtInKeywords = List.of( + "surface", + "waterfall", + "sinkhole", + "underground", + "grotto", + "coastal_grotto", + "inland_grotto", + "mouth", + "deep", + "pool" + ); + + 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("ridge_tunnel")); + assertFalse(HydrologyFeatureQuery.isReservedKeyword("ridge-tunnel")); + 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..86fe516f5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerCrossTileCaveAdmissionIntegrationTest.java @@ -0,0 +1,532 @@ +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, 1_089, 256, 16, 8, 0.5D, 12D, 0.5D, 0.2D, 1D, 0), + new HydrologyPlannerSettings.Surface( + true, + surfaceSources, + 4, + 20, + 2, + 5, + 12, + 1.5D, + HydrologyPlannerSettings.Banks.defaults()), + new HydrologyPlannerSettings.Hydraulics(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), List.of() + ); + } + + 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(), + 64, + baseSurface.shoreWidth(), + HydrologyPlannerSettings.Banks.defaults()), + 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(), List.of() + ); + } + + 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water"), List.of() + ); + }; + } + + 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(), + 1D, + sample.parentBiomeKey(), + sample.surfaceBiomeKey(), + sample.mouthBiomeKey(), + sample.shoreBiomeKey(), + sample.bankBiomeKey(), + sample.floodedCaveBiomeKey(), + sample.preferredProfileKeys(), List.of() + ); + }; + } + + 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, + 1D, + "ocean_parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water"), List.of() + ); + } + + 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..5e12fe090 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerGlobalInvariantTest.java @@ -0,0 +1,1375 @@ +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(hasCourseChannelLayer(tile, segment.start(), course.id())); + assertTrue( + segment + " column=" + tile.columnAt(segment.end().x(), segment.end().z()), + hasChannelHead(tile, segment.end(), course.id(), segment.downstreamHeadY()) + ); + 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 plannerSettings( + surfaceSources, + undergroundSources, + surfaceEnabled, + undergroundEnabled, + deepFluids + ); + } + + private HydrologyPlannerSettings plannerSettings( + HydrologyPlannerSettings.Source surfaceSources, + HydrologyPlannerSettings.Source undergroundSources, + boolean surfaceEnabled, + boolean undergroundEnabled, + List deepFluids + ) { + return new HydrologyPlannerSettings( + 63, + new HydrologyPlannerSettings.Routing( + TILE_SIZE, + 16, + 512, + 256, + 16, + 8, + 0.5D, + 12D, + 0.5D, + 0.1D + , 1D, 0), + new HydrologyPlannerSettings.Surface( + surfaceEnabled, + surfaceSources, + 2, + 4, + 1, + 2, + 128, + 1D, + HydrologyPlannerSettings.Banks.defaults()), + new HydrologyPlannerSettings.Hydraulics(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, List.of() + ); + } + + private HydrologyPlannerSettings gradualSettings( + HydrologyPlannerSettings.Source surfaceSources, + HydrologyPlannerSettings.Source undergroundSources, + boolean surfaceEnabled, + boolean undergroundEnabled + ) { + HydrologyPlannerSettings base = plannerSettings( + surfaceSources, + undergroundSources, + surfaceEnabled, + undergroundEnabled, + List.of() + ); + 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.maximumIncision(), + surface.shoreWidth(), + HydrologyPlannerSettings.Banks.defaults()), + 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(), List.of() + ); + } + + 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 hasCourseChannelLayer(HydrologyTile tile, HydrologyPoint point, long courseId) { + HydrologyColumnSample column = tile.columnAt(point.x(), point.z()).orElseThrow(); + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().courseId() == courseId && layer.channel()) { + return true; + } + } + return false; + } + + private boolean hasChannelHead(HydrologyTile tile, HydrologyPoint point, long courseId, int head) { + HydrologyColumnSample column = tile.columnAt(point.x(), point.z()).orElseThrow(); + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().courseId() == courseId && layer.channel() && layer.fluidHeadY() == head) { + 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, + 1D, + ocean ? "ocean" : "land", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default"), List.of() + ); + } + + 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, + 1D, + "land", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default"), List.of() + ); + } + + 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, + 1D, + "land", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default"), List.of() + ); + } + + 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.bankBiomeKey()); + 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.bankBiomeKey()); + 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..0eb41a06b --- /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(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(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(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..d4fda0066 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSettingsTest.java @@ -0,0 +1,262 @@ +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 routingUsesIndependentSurfaceAndUndergroundCourseFloors() { + HydrologyPlannerSettings.Routing routing = HydrologyPlannerSettings.defaults().routing(); + + assertEquals(384, routing.minimumCourseLength(true)); + assertEquals(192, routing.minimumCourseLength(false)); + } + + @Test + public void refinementSpacingIsDerivedFromTheSampleLattice() { + assertEquals(4, HydrologyPlannerSettings.Routing.refinementSpacing(64)); + assertEquals(2, HydrologyPlannerSettings.Routing.refinementSpacing(50)); + assertEquals(1, HydrologyPlannerSettings.Routing.refinementSpacing(45)); + assertEquals(4, HydrologyPlannerSettings.defaults().routing().refinementSpacing()); + } + + @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), List.of() + )); + } + + @Test + public void canonicalBoundaryValuesAreAccepted() { + HydrologyPlannerSettings.Routing routing = new HydrologyPlannerSettings.Routing( + 512, + 512, + 4, + 256, + 0, + 0, + 0D, + 0D, + 0D, + 0D + , 1D, 0); + HydrologyPlannerSettings.Source sources = new HydrologyPlannerSettings.Source(true, 1D, 0, 0, 1, 0); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + true, + sources, + 1, + 1, + 1, + 1, + 0, + 1D, + HydrologyPlannerSettings.Banks.defaults()); + HydrologyPlannerSettings.Hydraulics hydraulics = new HydrologyPlannerSettings.Hydraulics(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 bankSettingsRejectValuesOutsideTheirBounds() { + HydrologyPlannerSettings.Banks banks = HydrologyPlannerSettings.Banks.defaults(); + + assertEquals(1, banks.inset()); + assertEquals(1, banks.freeboard()); + assertEquals(3D, banks.blendSlope(), 0D); + assertEquals(4, banks.minimumBlendWidth()); + assertEquals(32, banks.maximumBlendWidth()); + assertEquals(6, banks.waterfallMinimumDrop()); + assertTrue(banks.exposeCutStrata()); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(-1, 1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, -1, 3D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 0D, 4, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 40, 32, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0.25D, 16, 2, 0, 1.6D, 2.5D, 24, true)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0.25D, 16, 0, 6, 1.6D, 2.5D, 24, true)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 1.5D, 16, 2, 6, 1.6D, 2.5D, 24, true)); + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Surface( + true, + new HydrologyPlannerSettings.Source(true, 1D, 0, 0, 1, 0), + 1, 1, 1, 1, 0, 1D, + null)); + } + + @Test + public void nonPositiveWaterfallThresholdsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new HydrologyPlannerSettings.Hydraulics(0)); + } + + @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/HydrologyPlannerSurfacePoolTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java new file mode 100644 index 000000000..46f6b0657 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerSurfacePoolTest.java @@ -0,0 +1,119 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class HydrologyPlannerSurfacePoolTest { + private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + + @Test + public void poolsAreCarvedWhereThePolicyAllowsThemAndNowhereElse() { + HydrologyPlannerSettings settings = settings(2D, 128, 4, 6, 2); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land") + .withSurfacePoolKeys(x < 512 ? List.of("lava_pool") : List.of()); + + HydrologyTile tile = new HydrologyPlanner(91L, settings, terrain).plan(TILE); + HydrologyTile replay = new HydrologyPlanner(91L, settings, terrain).plan(TILE); + + assertEquals(tile, replay); + List pools = pools(tile); + assertFalse(tile.diagnosticCandidates().toString(), pools.isEmpty()); + for (RiverCourse pool : pools) { + assertEquals("lava_pool", pool.profileKey()); + assertEquals(1, pool.segments().size()); + HydraulicSegment segment = pool.segments().getFirst(); + assertEquals(HydrologyFeatureType.STANDING_POOL, segment.type()); + assertTrue(segment.width() >= 8 && segment.width() <= 12); + assertTrue(segment.start().x() < 512); + assertEquals(79, segment.upstreamHeadY()); + assertEquals(segment.upstreamHeadY(), segment.downstreamHeadY()); + } + } + + @Test + public void aPoolIsABowlWithALipInsideTheFootprint() { + HydrologyPlannerSettings settings = settings(2D, 256, 5, 5, 2); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land") + .withSurfacePoolKeys(List.of("lava_pool")); + + HydrologyTile tile = new HydrologyPlanner(7L, settings, terrain).plan(TILE); + RiverCourse pool = pools(tile).getFirst(); + HydrologyPoint center = pool.segments().getFirst().centerline().get(1); + + int wet = 0; + int shore = 0; + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + HydrologyColumnLayer layer = column.primarySurfaceLayer().orElse(null); + if (layer == null || layer.feature().courseId() != pool.id()) { + continue; + } + assertEquals(HydrologyFeatureType.STANDING_POOL, layer.feature().type()); + assertTrue(column.terrainHeight() <= column.naturalHeight()); + if (layer.channel()) { + wet++; + assertEquals("lava_pool", layer.profileKey()); + assertEquals(79, layer.fluidHeadY()); + assertTrue(layer.bedY() < layer.fluidHeadY()); + assertTrue(center.distanceSquared2D(new HydrologyPoint(column.x(), 0, column.z())) <= 8 * 8); + } else if (layer.shore()) { + shore++; + assertTrue(column.terrainHeight() >= 80); + } + } + assertTrue("wet=" + wet, wet >= 60 && wet <= 140); + assertTrue("shore=" + shore, shore > 0); + } + + @Test + public void poolsKeepClearOfEachOther() { + HydrologyPlannerSettings settings = settings(8D, 64, 6, 6, 2); + HydrologyTerrainSampler terrain = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land") + .withSurfacePoolKeys(List.of("lava_pool")); + + HydrologyTile tile = new HydrologyPlanner(3L, settings, terrain).plan(TILE); + List pools = pools(tile); + + assertTrue(pools.size() >= 2); + for (int first = 0; first < pools.size(); first++) { + for (int second = first + 1; second < pools.size(); second++) { + HydrologyPoint a = pools.get(first).segments().getFirst().centerline().get(1); + HydrologyPoint b = pools.get(second).segments().getFirst().centerline().get(1); + assertTrue(a.distanceSquared2D(b) >= 40L * 40L); + } + } + } + + private static List pools(HydrologyTile tile) { + ArrayList pools = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == RiverCourseType.SURFACE_POOL) { + pools.add(course); + } + } + return pools; + } + + private static HydrologyPlannerSettings settings(double density, int spacing, int minimumRadius, int maximumRadius, int depth) { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Routing routing = new HydrologyPlannerSettings.Routing( + 1024, 64, base.routing().maximumRouteNodes(), 4096, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, 0); + return new HydrologyPlannerSettings( + base.seaLevel(), + routing, + base.surface(), + base.hydraulics(), + base.underground(), + base.outlets(), + base.geometry(), + List.of(), + List.of(new HydrologyPlannerSettings.SurfacePool( + "lava_pool", true, density, spacing, minimumRadius, maximumRadius, depth, 64, null)) + ); + } +} 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..1ac509b8f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTest.java @@ -0,0 +1,2490 @@ +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()); + assertFalse(segment.fallingFluid()); + } + } + } + } + + @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.5D); + 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.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); + assertFalse(segment.fallingFluid()); + assertTrue(segment.centerline().size() >= 2); + 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha"), List.of() + ); + }; + 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)) { + for (HydraulicSegment segment : course.segments()) { + if (segment.drop() < settings.hydraulics().waterfallMinimumDrop()) { + continue; + } + if (segment.type() == HydrologyFeatureType.WATERFALL + || segment.type() == HydrologyFeatureType.UNDERGROUND_DROP) { + aggregateTransition = true; + } + } + } + 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 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.maximumRouteNodes(), + routing.maximumRouteLength(), 96, 0, + routing.valleyPreference(), + routing.uphillPenalty(), + routing.slopePenalty(), + routing.confluenceAttraction(), 1D, 0 + ), + base.surface(), + base.hydraulics(), + base.underground(), + base.outlets(), + base.geometry(), + base.deepFluids(), List.of() + ); + 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(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); + + assertFalse( + "courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(), + tile.footprint().isEmpty() + ); + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + if (!column.ocean() && column.naturalHeight() > column.seaLevel()) { + continue; + } + for (HydrologyColumnLayer layer : column.layers()) { + assertFalse(layer.terrainOwned()); + assertFalse(layer.fluidOwned()); + assertFalse(layer.grading()); + assertFalse(layer.shore()); + assertTrue(layer.fluidHeadY() <= column.seaLevel()); + } + } + } + + @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.bankBiomeKey()); + assertEquals("flooded", layer.floodedCaveBiomeKey()); + if (layer.grading() && !layer.shore() && !layer.channel()) { + sawGrading = true; + assertEquals("parent", column.parentBiomeKey()); + assertEquals(layer.bankBiomeKey(), layer.biomeKey()); + } + } + } + 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(); + assertEquals(layer.bankBiomeKey(), layer.biomeKey()); + 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, + 128, + base.surface().shoreWidth(), + HydrologyPlannerSettings.Banks.defaults()); + HydrologyPlannerSettings shaped = new HydrologyPlannerSettings( + base.seaLevel(), + base.routing(), + deepeningSurface, + base.hydraulics(), + base.underground(), + base.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + base.deepFluids(), List.of() + ); + 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); + assertFalse(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.type() == HydrologyFeatureType.WATERFALL) { + assertTrue( + "waterfall drop=" + drop.drop(), + drop.drop() >= settings.surface().banks().waterfallMinimumDrop() + ); + continue; + } + 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.maximumIncision(), + surface.shoreWidth(), + HydrologyPlannerSettings.Banks.defaults()), + base.hydraulics(), + base.underground(), + base.outlets(), + base.geometry(), + base.deepFluids(), List.of() + ); + 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)); + } + + @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, 512, 256, 0, 0, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), + new HydrologyPlannerSettings.Surface( + surfaceDensity > 0D || surfaceSources.maximumPerTile() > 0, + surfaceSources, + 4, + 18, + 2, + 4, + 10, + 1.5D, + HydrologyPlannerSettings.Banks.defaults()), + new HydrologyPlannerSettings.Hydraulics(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, List.of() + ); + } + + 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, + 1024, + 2048, 64, 32, + 1.5D, + 24D, + 2D, + 0.2D + , 1D, 0), + new HydrologyPlannerSettings.Surface( + true, + new HydrologyPlannerSettings.Source(true, 8D, 80, 0, 8, 128), + surface.minimumWidth(), + surface.maximumWidth(), + surface.minimumDepth(), + surface.maximumDepth(), + 96, + surface.shoreWidth(), + HydrologyPlannerSettings.Banks.defaults()), + new HydrologyPlannerSettings.Hydraulics(5), + base.underground(), + new HydrologyPlannerSettings.Outlets( + true, + outlets.coastalGrotto(), + outlets.inlandGrotto(), + outlets.surfaceSinkholesEnabled(), + outlets.coastalCliffMinimumHeight(), + 48, + outlets.maximumOceanApron(), + 1 + ), + HydrologyPlannerSettings.Geometry.defaults(), + List.of(), 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha"), List.of() + ); + }; + } + + 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, + 1D, + "ocean_parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha", "beta"), List.of() + ); + } + + 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha"), List.of() + ); + } + + 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("beta", "alpha"), List.of() + ); + } + + 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha"), List.of() + ); + } + + 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("alpha", "beta"), List.of() + ); + } + + 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.maximumIncision(), + surface.shoreWidth(), + HydrologyPlannerSettings.Banks.defaults()), + settings.hydraulics(), + settings.underground(), + settings.outlets(), + HydrologyPlannerSettings.Geometry.defaults(), + settings.deepFluids(), List.of() + ); + } + + 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(), List.of() + ); + } + + 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(), List.of() + ); + } + + 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(), List.of() + ); + } + + 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(), List.of() + ); + } + + 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 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/HydrologyPlannerTributaryTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java new file mode 100644 index 000000000..1c3fc554f --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyPlannerTributaryTest.java @@ -0,0 +1,152 @@ +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.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class HydrologyPlannerTributaryTest { + private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0); + + @Test + public void aSecondSourceJoinsTheMainStemAsATributaryWhenAllowed() { + HydrologyTerrainSampler terrain = HydrologyPlannerTributaryTest::valley; + + HydrologyTile single = new HydrologyPlanner(19L, settings(0), terrain).plan(TILE); + HydrologyTile split = new HydrologyPlanner(19L, settings(1), terrain).plan(TILE); + + List singleCourses = surface(single); + List splitCourses = surface(split); + assertEquals(single.diagnosticCandidates().toString(), 1, singleCourses.size()); + assertEquals(split.diagnosticCandidates().toString(), 2, splitCourses.size()); + RiverCourse stem = null; + RiverCourse tributary = null; + for (RiverCourse course : splitCourses) { + if (stem == null || stations(course) > stations(stem)) { + tributary = stem; + stem = course; + } else { + tributary = course; + } + } + assertNotNull(stem); + assertNotNull(tributary); + assertEquals(stem.outletId(), tributary.outletId()); + assertTrue(stem.sourceNodeId().getAsLong() != tributary.sourceNodeId().getAsLong()); + + HydrologyPoint confluence = tributary.segments().getLast().end(); + Integer stemHead = null; + for (HydraulicSegment segment : stem.segments()) { + for (HydrologyPoint point : segment.centerline()) { + if (point.x() == confluence.x() && point.z() == confluence.z()) { + stemHead = point.y(); + } + } + } + assertNotNull("tributary ends on the stem", stemHead); + assertTrue(confluence.y() >= stemHead); + assertTrue(stations(tributary) >= 192); + assertTrue(stations(tributary) < stations(stem)); + for (HydraulicSegment segment : tributary.segments()) { + assertTrue(segment.upstreamHeadY() >= segment.downstreamHeadY()); + } + } + + private static int stations(RiverCourse course) { + int stations = 0; + for (HydraulicSegment segment : course.segments()) { + stations += segment.centerline().size(); + } + return stations; + } + + private static List surface(HydrologyTile tile) { + ArrayList courses = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() == RiverCourseType.SURFACE) { + courses.add(course); + } + } + return courses; + } + + /** A plain sloping east into the sea with a valley along z = 0 that both flanks drain into. */ + private static HydrologyTerrainSample valley(int x, int z) { + if (x >= 900) { + return HydrologyTerrainSample.ocean(50, "sea"); + } + // The valley floor wanders so the stem never runs straight along the lattice, and the eastward + // fall is steeper than the valley flanks so routes drift into the valley at a shallow angle. + int valleyCenter = (int) Math.round(Math.sin(x / 150D) * 40D); + int valley = Math.max(0, 12 - Math.abs(z - valleyCenter) / 20); + int wave = (int) Math.round(Math.sin(x / 29D + z / 43D) * 3D + Math.sin(z / 19D - x / 61D + 1D) * 2D); + int height = 240 - x / 5 - valley + wave; + boolean source = x >= 32 && x <= 96 && (Math.abs(z - 300) <= 40 || Math.abs(z - 150) <= 40); + return new HydrologyTerrainSample( + height, + 0D, + false, + false, + height - 40, + height - 38, + true, + true, + source, + false, + false, + false, + 0D, + source ? 1D : 0D, + 0D, + 1D, + 1D, + 1D, + 1D, + 1D, + "land", + "land", + "land", + "land", + "land", + "land", + List.of("default"), + List.of() + ); + } + + private static HydrologyPlannerSettings settings(int tributaries) { + HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults(); + HydrologyPlannerSettings.Routing routing = new HydrologyPlannerSettings.Routing( + 1024, 64, base.routing().maximumRouteNodes(), 4096, 384, 192, 1.5D, 24D, 2D, 0.2D, 1D, tributaries); + HydrologyPlannerSettings.Source sources = new HydrologyPlannerSettings.Source(true, 4D, 0, 0, 4, 128); + HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface( + true, sources, 4, 8, 2, 4, 40, 1.5D, HydrologyPlannerSettings.Banks.defaults()); + HydrologyPlannerSettings.Underground underground = new HydrologyPlannerSettings.Underground( + false, base.underground().sources(), -48, 72, 3, 8, 1, 3, 6, 14, true); + HydrologyPlannerSettings.Outlets outlets = new HydrologyPlannerSettings.Outlets( + true, + base.outlets().coastalGrotto(), + base.outlets().inlandGrotto(), + false, + base.outlets().coastalCliffMinimumHeight(), + base.outlets().mouthLevelingDistance(), + base.outlets().maximumOceanApron(), + 1 + ); + return new HydrologyPlannerSettings( + base.seaLevel(), + routing, + surface, + base.hydraulics(), + underground, + outlets, + base.geometry(), + List.of(), + List.of() + ); + } +} 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..51cbdead8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyStyledGeometryTest.java @@ -0,0 +1,327 @@ +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.SURFACE_WIDTH, + HydrologyGeometrySampler.Field.SURFACE_DEPTH, + HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL, + HydrologyGeometrySampler.Field.UNDERGROUND_WIDTH, + HydrologyGeometrySampler.Field.UNDERGROUND_DEPTH, + HydrologyGeometrySampler.Field.UNDERGROUND_HEADROOM, + HydrologyGeometrySampler.Field.DEEP_FLUID_HEIGHT + ), + sampledFields + ); + + RiverCourse surface = courses(first, RiverCourseType.SURFACE).getFirst(); + DrainageNode surfaceSource = first.node(surface.sourceNodeId().orElseThrow()).orElseThrow(); + HydraulicSegment surfaceStart = surface.segments().getFirst(); + // The first segment carries the spring pool: wider by the spring ratio and one block deeper. + int maximumSurfaceWidth = (int) Math.ceil(surfaceSettings.surface().maximumWidth() + * surfaceSettings.surface().banks().springWidthRatio()) + + (surfaceStart.receivingPool() ? 2 : 0); + int maximumSurfaceDepth = surfaceSettings.surface().maximumDepth() + 1 + + (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( + "head=" + surfaceStart.upstreamHeadY() + " natural=" + + terrain(surfaceSource.naturalPoint().x(), surfaceSource.naturalPoint().z()).naturalHeight(), + surfaceStart.upstreamHeadY() <= + terrain(surfaceSource.naturalPoint().x(), surfaceSource.naturalPoint().z()).naturalHeight() + - surfaceSettings.surface().banks().inset() + ); + int widestSurfaceWidth = 0; + for (HydraulicSegment segment : surface.segments()) { + widestSurfaceWidth = Math.max(widestSurfaceWidth, segment.width()); + } + assertTrue(widestSurfaceWidth >= surfaceStart.width()); + assertTrue(widestSurfaceWidth <= (int) Math.ceil(surfaceSettings.surface().maximumWidth() + * Math.max(2D, surfaceSettings.surface().banks().springWidthRatio()))); + + 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() + ); + } + } + + 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, 512, 256, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), + new HydrologyPlannerSettings.Surface( + surfaceDensity > 0D, + surfaceSources, + 4, + 18, + 2, + 4, + 10, + 1.5D, + HydrologyPlannerSettings.Banks.defaults()), + new HydrologyPlannerSettings.Hydraulics(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, List.of() + ); + } + + 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, + 1D, + "ocean", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water"), List.of() + ); + } + 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water"), List.of() + ); + } + + 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 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); + } + +} 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..03c42a22c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTerrainSampleTest.java @@ -0,0 +1,83 @@ +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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + profiles, List.of() + ); + } +} 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..5198814ee --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyTileCacheTest.java @@ -0,0 +1,344 @@ +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, 128, 64, 0, 0, 0D, 0D, 0D, 0D, 1D, 0), + new HydrologyPlannerSettings.Surface(false, disabled, 2, 4, 1, 2, 4, 1D, + HydrologyPlannerSettings.Banks.defaults()), + new HydrologyPlannerSettings.Hydraulics(3), + new HydrologyPlannerSettings.Underground(false, disabled, -32, 32, 2, 4, 1, 2, 3, 4, false), + new HydrologyPlannerSettings.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(), 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, 128, 96, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), + new HydrologyPlannerSettings.Surface(true, surfaceSources, 4, 8, 2, 3, 20, 1.5D, tileBoundedBanks()), + new HydrologyPlannerSettings.Hydraulics(3), + new HydrologyPlannerSettings.Underground(false, disabled, -32, 32, 2, 4, 1, 2, 3, 4, false), + new HydrologyPlannerSettings.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(), List.of() + ); + } + + // Small-tile settings need a blend envelope that fits inside the bounded cross-tile admission period. + private static HydrologyPlannerSettings.Banks tileBoundedBanks() { + return new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 4, 0.25D, 16, 2, 6, 1.6D, 2.5D, 24, true); + } + + 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default"), List.of() + ); + } +} 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/hydrology/cave/HydrologyCaveStorageTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorageTest.java new file mode 100644 index 000000000..7bc26050b --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/cave/HydrologyCaveStorageTest.java @@ -0,0 +1,46 @@ +package art.arcane.iris.engine.hydrology.cave; + +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; +import art.arcane.volmlib.util.matter.Matter; +import art.arcane.volmlib.util.matter.MatterSlice; +import org.junit.Test; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +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 HydrologyCaveStorageTest { + @Test + @SuppressWarnings("unchecked") + public void presentReadDoesNotMaterializeASectionOrSlice() { + MantleChunk chunk = mock(MantleChunk.class); + Matter matter = mock(Matter.class); + 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(HydrologyCaveCell.class)).thenReturn(true); + when(matter.getSlice(HydrologyCaveCell.class)).thenReturn(slice); + when(slice.get(3, 1, 5)).thenReturn(expected); + + assertSame(expected, HydrologyCaveStorage.getIfPresent(chunk, 3, 33, 5)); + verify(chunk, never()).getOrCreate(2); + verify(matter, never()).slice(HydrologyCaveCell.class); + } + + @Test + @SuppressWarnings("unchecked") + public void absentSliceReturnsWithoutCreatingIt() { + MantleChunk chunk = mock(MantleChunk.class); + Matter matter = mock(Matter.class); + when(chunk.exists(2)).thenReturn(true); + when(chunk.get(2)).thenReturn(matter); + + 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..c88d3e546 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/policy/RiverPolicyResolverTest.java @@ -0,0 +1,153 @@ +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()); + assertTrue(policy.bankBiomes().isEmpty()); + assertEquals(1D, policy.widthMultiplier(), 0D); + assertEquals(1D, policy.depthMultiplier(), 0D); + assertEquals(1D, policy.incisionMultiplier(), 0D); + assertEquals(1D, policy.routingMultiplier(), 0D); + assertEquals(1D, policy.bankMultiplier(), 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")) + .setBankBiomes(new KList<>("river/bank-dimension")) + .setWidthMultiplier(1.5D) + .setDepthMultiplier(1.25D) + .setBankMultiplier(2D); + IrisRiverPolicy region = new IrisRiverPolicy() + .setRouting(IrisRiverRoutingMode.PREFER) + .setProfiles(new KList<>()) + .setSurfaceBiomes(new KList<>("river/region")) + .setShoreBiomes(new KList<>("river/shore")) + .setBankBiomes(new KList<>()) + .setWidthMultiplier(0.8D) + .setRoutingMultiplier(2D); + IrisRiverPolicy biome = new IrisRiverPolicy() + .setPlacement(IrisRiverPlacementMode.REQUIRED_HEADWATER) + .setRouting(IrisRiverRoutingMode.BLOCK) + .setOutletAdmission(true) + .setSurfaceBiomes(new KList<>()) + .setFloodedCaveBiomes(new KList<>("river/flooded")) + .setDepthMultiplier(0.75D) + .setIncisionMultiplier(0.5D) + .setBankMultiplier(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()); + assertTrue(policy.bankBiomes().isEmpty()); + assertEquals(List.of("river/mouth"), policy.mouthBiomes()); + assertEquals(List.of("river/shore"), policy.shoreBiomes()); + 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); + assertEquals(0.5D, policy.bankMultiplier(), 0D); + assertTrue(policy.allowsSources()); + assertTrue(policy.allowsTransit()); + assertFalse(policy.allowsRouting()); + assertTrue(policy.prefersHeadwaters()); + assertTrue(policy.requiresHeadwaters()); + } + + @Test + public void bankBiomesInheritUntilOverridden() { + IrisRiverPolicy dimension = new IrisRiverPolicy().setBankBiomes(new KList<>("river/bank")); + IrisRiverPolicy region = new IrisRiverPolicy().setRouting(IrisRiverRoutingMode.PREFER); + + EffectiveRiverPolicy policy = RiverPolicyResolver.resolve(dimension, region, null); + + assertEquals(List.of("river/bank"), policy.bankBiomes()); + assertEquals(1D, policy.bankMultiplier(), 0D); + } + + @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/HydrologyFeatureSearchTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchTest.java new file mode 100644 index 000000000..e21c2329d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/HydrologyFeatureSearchTest.java @@ -0,0 +1,47 @@ +package art.arcane.iris.engine.hydrology.runtime; + +import art.arcane.iris.engine.hydrology.HydrologyTileKey; +import org.junit.Test; + +import java.util.HashSet; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class HydrologyFeatureSearchTest { + @Test + public void ringZeroIsTheOriginTile() { + assertEquals(List.of(new HydrologyTileKey(3, -2)), HydrologyFeatureSearch.ring(3, -2, 0)); + } + + @Test + public void ringsCoverEveryTileAtExactlyTheirChebyshevDistance() { + HashSet seen = new HashSet<>(); + for (int radius = 0; radius <= 3; radius++) { + List ring = HydrologyFeatureSearch.ring(1, 1, radius); + assertEquals(radius == 0 ? 1 : radius * 8, ring.size()); + for (HydrologyTileKey key : ring) { + int distance = Math.max(Math.abs(key.tileX() - 1), Math.abs(key.tileZ() - 1)); + assertEquals(radius, distance); + assertTrue(seen.add(key)); + } + } + assertEquals(49, seen.size()); + } + + @Test + public void ringLimitCoversTheRequestedDistancePlusPublication() { + assertEquals(9, HydrologyFeatureSearch.ringLimit(8192, 1024, 358)); + assertEquals(1, HydrologyFeatureSearch.ringLimit(0, 1024, 358)); + assertEquals(0, HydrologyFeatureSearch.ringLimit(0, 1024, 0)); + } + + @Test + public void lowerBoundGrowsWithTheRingAndNeverGoesNegative() { + assertEquals(0L, HydrologyFeatureSearch.lowerBound(0, 1024, 358)); + assertEquals(0L, HydrologyFeatureSearch.lowerBound(1, 1024, 358)); + assertEquals(666L, HydrologyFeatureSearch.lowerBound(2, 1024, 358)); + assertEquals(1690L, HydrologyFeatureSearch.lowerBound(3, 1024, 358)); + } +} 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..3fdbba635 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRoutingTerrainSamplerTest.java @@ -0,0 +1,612 @@ +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 pointBasisSamplesCarryTheForwardSlope() { + IrisHydrologyRoutingTerrainSampler sampler = sampler(64); + int[][] points = {{0, 0}, {-129, -257}, {37, 1024}}; + + for (int[] point : points) { + int x = point[0]; + int z = point[1]; + double expected = IrisHydrologyRoutingTerrainSampler.localSlope( + height(x, z), height(x + 3, z), height(x, z + 3)); + + double actual = sampler.sampleBasis(x, z).slope(); + + assertTrue(expected > 0D); + 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..8adcdcaad --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/runtime/IrisHydrologyRuntimeSettingsTest.java @@ -0,0 +1,182 @@ +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); + + assertEquals(128, IrisHydrologyRuntime.maximumDeepChannelLength(deepFluid, routing)); + } + + @Test + public void disabledDeepChannelsHaveNoPublicationReach() { + IrisDeepFluidConfig deepFluid = new IrisDeepFluidConfig() + .setShortChannels(false) + .setSpacing(8192); + IrisRiverRoutingConfig routing = new IrisRiverRoutingConfig() + .setTileSize(256); + + 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 surfaceBankAndFlowConfigurationMapsIntoPlannerSettings() { + IrisDimension dimension = new IrisDimension(); + IrisRiverHydrology rivers = dimension.getHydrology().getRivers(); + rivers.getSurface().getChannel().setInset(2).setMaximumIncision(12); + rivers.getSurface().getBanks() + .setFreeboard(2) + .setShoreWidth(2.5D) + .setBlendSlope(4D) + .setMinimumBlendWidth(6) + .setMaximumBlendWidth(48) + .setExposeCutStrata(false); + rivers.getSurface().getFlow().setCascadeRun(3).setWaterfallMinimumDrop(8); + rivers.getSurface().getMouths().setFlareRatio(2.2D); + rivers.getUnderground().setMouthLevelingDistance(96); + rivers.getRouting() + .setSampleSpacing(32) + .setMinimumSurfaceCourseLength(500) + .setMinimumUndergroundCourseLength(250) + .setValleyPreference(2.5D) + .setUphillPenalty(30D) + .setSlopePenalty(3D) + .setConfluenceAttraction(0.4D); + + HydrologyPlannerSettings settings = IrisHydrologyRuntime.createSettings(dimension, dimension.getHydrology()); + HydrologyPlannerSettings.Banks banks = settings.surface().banks(); + + assertEquals(2, banks.inset()); + assertEquals(2, banks.freeboard()); + assertEquals(4D, banks.blendSlope(), 0D); + assertEquals(6, banks.minimumBlendWidth()); + assertEquals(48, banks.maximumBlendWidth()); + assertEquals(3, banks.cascadeRun()); + assertEquals(8, banks.waterfallMinimumDrop()); + assertEquals(2.2D, banks.mouthFlareRatio(), 0D); + assertFalse(banks.exposeCutStrata()); + assertEquals(12, settings.surface().maximumIncision()); + assertEquals(2.5D, settings.surface().shoreWidth(), 0D); + assertEquals(8, settings.hydraulics().waterfallMinimumDrop()); + assertEquals(96, settings.outlets().mouthLevelingDistance()); + assertEquals(4, settings.routing().refinementSpacing()); + assertEquals(500, settings.routing().minimumSurfaceCourseLength()); + assertEquals(250, settings.routing().minimumUndergroundCourseLength()); + assertEquals(2.5D, settings.routing().valleyPreference(), 0D); + assertEquals(30D, settings.routing().uphillPenalty(), 0D); + assertEquals(3D, settings.routing().slopePenalty(), 0D); + assertEquals(0.4D, settings.routing().confluenceAttraction(), 0D); + assertEquals(2, HydrologyPlannerSettings.Routing.refinementSpacing(50)); + assertEquals(1, HydrologyPlannerSettings.Routing.refinementSpacing(45)); + } + + @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.getSurface().getChannel() + .setRoughness(0.37D) + .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(0.37D, geometry.surface().bedRoughness(), 0D); + assertEquals(0.37D, 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/hydrology/surface/ChannelProfileBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java new file mode 100644 index 000000000..e1e52f509 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ChannelProfileBuilderTest.java @@ -0,0 +1,119 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; +import org.junit.Test; + +import java.util.List; +import java.util.function.IntBinaryOperator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class ChannelProfileBuilderTest { + private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) { + case SURFACE_WIDTH -> 6; + case SURFACE_DEPTH -> 3; + default -> request.minimum(); + }; + + @Test + public void headwaterOpensAsASpringPoolThatNarrowsToTheCruiseWidth() { + SurfaceCenterline centerline = straight(400); + ChannelProfile profile = builder(1D, 1D).build(centerline, "water", false); + + assertEquals(400, profile.size()); + assertEquals(3D, profile.halfWidth()[200], 1.0E-9D); + assertEquals(3D, profile.halfWidth()[399], 1.0E-9D); + assertEquals(3D, profile.depth()[200], 1.0E-9D); + assertEquals(3D * 2.5D, profile.halfWidth()[0], 1.0E-9D); + assertEquals(4D, profile.depth()[0], 1.0E-9D); + for (int station = 1; station <= 24; station++) { + assertTrue(profile.halfWidth()[station] <= profile.halfWidth()[station - 1]); + assertTrue(profile.depth()[station] <= profile.depth()[station - 1]); + } + assertEquals(3D, profile.halfWidth()[24], 1.0E-9D); + assertEquals(3D, profile.depth()[24], 1.0E-9D); + } + + @Test + public void directOceanCoursesFlareAtTheMouth() { + SurfaceCenterline centerline = straight(400); + ChannelProfile profile = builder(1D, 1D).build(centerline, "water", true); + + assertEquals(3D * 1.6D, profile.halfWidth()[399], 0.05D); + assertEquals(3D, profile.halfWidth()[300], 1.0E-9D); + } + + @Test + public void policyMultipliersScaleWidthDepthAndBank() { + SurfaceCenterline centerline = straight(400); + ChannelProfile profile = builder(2D, 0.5D).build(centerline, "water", false); + + assertEquals(6D, profile.halfWidth()[200], 1.0E-9D); + assertEquals(1.5D, profile.depth()[200], 1.0E-9D); + assertEquals(1.25D, profile.bankMultiplier()[200], 1.0E-9D); + assertEquals(6D * 1.25D + 2D, profile.collar(200, 0.25D), 1.0E-9D); + } + + @Test + public void groundFallingAwayAcrossThePoolShrinksTheSpringPool() { + SurfaceCenterline centerline = straight(400); + ChannelProfile sloped = builder(1D, 1D, (int x, int z) -> 80 - Math.abs(z) / 2).build(centerline, "water", false); + ChannelProfile cliff = builder(1D, 1D, (int x, int z) -> 80 - Math.abs(z) * 8).build(centerline, "water", false); + + assertTrue(sloped.halfWidth()[0] > 3D); + assertTrue(sloped.halfWidth()[0] < 3D * 2.5D); + assertEquals(3D, cliff.halfWidth()[0], 1.0E-9D); + assertEquals(4D, cliff.depth()[0], 1.0E-9D); + assertEquals(3D, sloped.halfWidth()[24], 1.0E-9D); + } + + private static ChannelProfileBuilder builder(double widthMultiplier, double depthMultiplier) { + return builder(widthMultiplier, depthMultiplier, (int x, int z) -> 80); + } + + private static ChannelProfileBuilder builder(double widthMultiplier, double depthMultiplier, IntBinaryOperator height) { + HydrologyTerrainSampler sampler = (int x, int z) -> new HydrologyTerrainSample( + height.applyAsInt(x, z), + 0D, + false, + false, + 48, + 50, + true, + true, + true, + false, + false, + false, + 0D, + 1D, + 1D, + widthMultiplier, + depthMultiplier, + 1D, + 1D, + 1.25D, + "parent", + "parent", + "parent", + "parent", + "parent", + "parent", + List.of("water"), List.of() + ); + return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY); + } + + private static SurfaceCenterline straight(int stations) { + return SurfaceCenterline.densify(path(stations)); + } + + private static List path(int stations) { + return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java new file mode 100644 index 000000000..eddccf880 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ErosionFieldCompilerTest.java @@ -0,0 +1,295 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; +import art.arcane.iris.engine.hydrology.RiverFootprint; +import org.junit.Test; + +import java.util.List; +import java.util.function.IntBinaryOperator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ErosionFieldCompilerTest { + private static final int SEA_LEVEL = 60; + private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) { + case SURFACE_WIDTH -> 6; + case SURFACE_DEPTH -> 3; + default -> request.minimum(); + }; + + @Test + public void flatTerrainProducesAContainedChannelWithLipAndNoWriteAboveNatural() { + Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(0, compiled.field().uncontainedWetCells()); + SurfaceColumn center = compiled.field().column(150, 0); + assertNotNull(center); + assertEquals(SurfaceRole.CHANNEL, center.role()); + assertEquals(79, center.headY()); + assertTrue(center.height() <= 76); + SurfaceColumn edge = compiled.field().column(150, 3); + assertNotNull(edge); + assertEquals(SurfaceRole.CHANNEL, edge.role()); + assertTrue(edge.height() > center.height()); + assertTrue(edge.height() <= 78); + SurfaceColumn shore = compiled.field().column(150, 4); + assertNotNull(shore); + assertEquals(SurfaceRole.SHORE, shore.role()); + assertEquals(80, shore.height()); + assertNoWriteAboveNatural(compiled); + assertChannelContained(compiled); + } + + @Test + public void hillsideCutGetsABankWhoseWidthGrowsWithTheCut() { + Compiled compiled = compile(300, (x, z) -> 90 + Math.max(0, z) / 2, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(0, compiled.field().uncontainedWetCells()); + assertNoWriteAboveNatural(compiled); + assertChannelContained(compiled); + int lastBank = -1; + int previous = Integer.MIN_VALUE; + for (int z = 4; z < 60; z++) { + SurfaceColumn column = compiled.field().column(150, z); + if (column == null) { + break; + } + if (column.role() == SurfaceRole.BANK) { + lastBank = z; + assertTrue(column.height() >= previous); + previous = column.height(); + } + } + assertTrue(lastBank > 12); + int stepOutsideChannel = 0; + for (int z = 6; z <= lastBank; z++) { + stepOutsideChannel = Math.max( + stepOutsideChannel, + Math.abs(compiled.field().column(150, z).height() - compiled.field().column(150, z - 1).height()) + ); + } + assertTrue(stepOutsideChannel <= 1); + } + + @Test + public void slopingTerrainKeepsBanksSmoothAndContained() { + Compiled compiled = compile(400, (x, z) -> 120 - x / 8, SurfaceTerminal.SINKHOLE, 40); + + assertEquals(0, compiled.field().uncontainedWetCells()); + assertNoWriteAboveNatural(compiled); + assertChannelContained(compiled); + for (int x = 20; x < 380; x++) { + for (int z = 5; z < 12; z++) { + SurfaceColumn column = compiled.field().column(x, z); + SurfaceColumn next = compiled.field().column(x + 1, z); + if (column == null || next == null || column.role() == SurfaceRole.CHANNEL || next.role() == SurfaceRole.CHANNEL) { + continue; + } + assertTrue(Math.abs(column.height() - next.height()) <= 1); + } + } + } + + @Test + public void oceanAndSubmergedCellsReceiveNoWrites() { + HydrologyTerrainSampler sampler = (int x, int z) -> x >= 240 || z > 20 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(z <= -20 ? SEA_LEVEL : 100 - x / 6, 0D, "land"); + Compiled compiled = compile(sampler, 300, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertEquals(0, compiled.field().uncontainedWetCells()); + for (SurfaceColumn column : compiled.field().columns().values()) { + HydrologyTerrainSample terrain = sampler.sample(column.x(), column.z()); + if (terrain.ocean() || terrain.naturalHeight() <= SEA_LEVEL) { + assertTrue(column.apron()); + assertEquals(terrain.naturalHeight(), column.height()); + } + } + assertNoWriteAboveNatural(compiled); + } + + @Test + public void bankColumnsBeyondTheBlendAreNotWritten() { + Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); + + assertNull(compiled.field().column(150, 40)); + } + + private static void assertNoWriteAboveNatural(Compiled compiled) { + for (SurfaceColumn column : compiled.field().columns().values()) { + assertTrue(column.height() <= column.terrain().naturalHeight()); + } + } + + private static void assertChannelContained(Compiled compiled) { + for (SurfaceColumn column : compiled.field().columns().values()) { + if (column.role() != SurfaceRole.CHANNEL || column.apron()) { + continue; + } + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + for (int[] offset : offsets) { + SurfaceColumn neighbour = compiled.field().column(column.x() + offset[0], column.z() + offset[1]); + if (neighbour == null) { + assertEquals(SEA_LEVEL, column.headY()); + continue; + } + if (neighbour.role() == SurfaceRole.CHANNEL) { + continue; + } + assertTrue(neighbour.height() >= column.headY()); + } + } + } + + private static Compiled compile(int stations, IntBinaryOperator height, SurfaceTerminal terminal, int terminalHead) { + HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(height.applyAsInt(x, z), 0D, "land"); + return compile(sampler, stations, terminal, terminalHead); + } + + private static Compiled compile(HydrologyTerrainSampler sampler, int stations, SurfaceTerminal terminal, int terminalHead) { + HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(); + List path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0)); + SurfaceCenterline centerline = SurfaceCenterline.densify(path); + ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, CONSTANT_GEOMETRY) + .build(centerline, "water", terminal == SurfaceTerminal.OCEAN_MOUTH); + ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64) + .solve(centerline, channel, terminal, terminalHead); + assertNull(valley.rejection()); + ErosionField field = new ErosionFieldCompiler(surface, sampler, SEA_LEVEL) + .compile(1234L, centerline, channel, valley, terminal, 8); + return new Compiled(field, valley); + } + + private static HydrologyPlannerSettings.Surface zeroRoughnessSurface() { + HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface(); + return new HydrologyPlannerSettings.Surface( + true, + defaults.sources(), + 4, 8, 2, 4, 10, 1.5D, + new HydrologyPlannerSettings.Banks(1, 1, 3D, 4, 32, 0D, 16, 2, 6, 1.6D, 2.5D, 24, true) + ); + } + + private record Compiled(ErosionField field, ValleyProfile valley) { + } + + static long key(int x, int z) { + return RiverFootprint.pack(x, z); + } + + @Test + public void blendWidthSmoothingSpreadsASpikeAcrossItsNeighbours() { + double[][] widths = new double[9][2]; + for (int station = 0; station < widths.length; station++) { + widths[station][0] = 4D; + widths[station][1] = 4D; + } + widths[4][1] = 34D; + + double[][] smoothed = ErosionFieldCompiler.smoothWidths(widths, 2); + + for (int station = 0; station < widths.length; station++) { + assertEquals(4D, smoothed[station][0], 1e-9); + } + assertEquals(4D, smoothed[0][1], 1e-9); + assertEquals(10D, smoothed[2][1], 1e-9); + assertEquals(10D, smoothed[4][1], 1e-9); + assertEquals(10D, smoothed[6][1], 1e-9); + assertEquals(4D, smoothed[8][1], 1e-9); + for (int station = 1; station < widths.length; station++) { + assertTrue(Math.abs(smoothed[station][1] - smoothed[station - 1][1]) <= 6D + 1e-9); + } + } + + @Test + public void bedBowlIsLevelAcrossTheThalwegAndOneBlockAtTheEdge() { + assertEquals(1D, ErosionFieldCompiler.bowl(0D), 1e-9); + assertEquals(1D, ErosionFieldCompiler.bowl(0.45D), 1e-9); + assertTrue(ErosionFieldCompiler.bowl(0.6D) > 0.8D); + assertTrue(ErosionFieldCompiler.bowl(0.8D) > 0.3D && ErosionFieldCompiler.bowl(0.8D) < 0.7D); + assertEquals(0D, ErosionFieldCompiler.bowl(1D), 1e-9); + double previous = 1D; + for (double normalized = 0D; normalized <= 1D; normalized += 0.05D) { + double value = ErosionFieldCompiler.bowl(normalized); + assertTrue(value <= previous + 1e-9); + previous = value; + } + } + + @Test + public void everyCellTouchingWaterKeepsTheLipAcrossHeadSteps() { + Compiled compiled = compile(400, (x, z) -> 120 - x / 8, SurfaceTerminal.SINKHOLE, 40); + int freeboard = zeroRoughnessSurface().banks().freeboard(); + int checked = 0; + + for (SurfaceColumn column : compiled.field().columns().values()) { + if (column.role() != SurfaceRole.CHANNEL) { + continue; + } + for (int deltaX = -1; deltaX <= 1; deltaX++) { + for (int deltaZ = -1; deltaZ <= 1; deltaZ++) { + SurfaceColumn neighbour = compiled.field().column(column.x() + deltaX, column.z() + deltaZ); + if (neighbour == null || neighbour.role() == SurfaceRole.CHANNEL) { + continue; + } + int required = Math.min(neighbour.terrain().naturalHeight(), column.headY() + freeboard); + assertTrue(neighbour.x() + "," + neighbour.z() + " height " + neighbour.height() + " < " + required, + neighbour.height() >= required); + checked++; + } + } + } + assertTrue(checked > 100); + } + + @Test + public void aLowCellBesideTheChannelLowersTheHeadSoTheLipSurvives() { + Compiled compiled = compile(300, (x, z) -> x == 150 && z == 4 ? 79 : 80, SurfaceTerminal.SINKHOLE, 40); + + SurfaceColumn center = compiled.field().column(150, 0); + assertNotNull(center); + assertTrue("head " + center.headY(), center.headY() <= 78); + SurfaceColumn low = compiled.field().column(150, 4); + assertEquals(SurfaceRole.SHORE, low.role()); + assertNotNull(low); + assertTrue(low.height() >= center.headY() + 1); + assertEquals(0, compiled.field().uncontainedWetCells()); + } + + @Test + public void oceanApronStartsAtTheFirstOceanStationAndStopsAtTheLimit() { + HydrologyTerrainSampler sampler = (int x, int z) -> x >= 200 + ? HydrologyTerrainSample.ocean(SEA_LEVEL - 4, "sea") + : HydrologyTerrainSample.openLand(SEA_LEVEL + 3, 0D, "land"); + HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(); + List path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(259, 0, 0)); + SurfaceCenterline centerline = SurfaceCenterline.densify(path); + ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, CONSTANT_GEOMETRY) + .build(centerline, "water", true); + ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64) + .solve(centerline, channel, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + assertNull(valley.rejection()); + + ErosionField field = new ErosionFieldCompiler(surface, sampler, SEA_LEVEL) + .compile(99L, centerline, channel, valley, SurfaceTerminal.OCEAN_MOUTH, 3); + + int firstApron = Integer.MAX_VALUE; + int lastApron = Integer.MIN_VALUE; + for (SurfaceColumn column : field.columns().values()) { + if (column.apron()) { + firstApron = Math.min(firstApron, column.x()); + lastApron = Math.max(lastApron, column.x()); + assertEquals(column.terrain().naturalHeight(), column.height()); + } + } + assertEquals(200, firstApron); + assertEquals(202, lastApron); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmissionTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmissionTest.java new file mode 100644 index 000000000..3bfee702c --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCellAdmissionTest.java @@ -0,0 +1,22 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class SurfaceCellAdmissionTest { + @Test + public void landAboveSeaLevelIsWritable() { + assertTrue(SurfaceCellAdmission.writable(HydrologyTerrainSample.openLand(61, 0D, "land"), 60)); + } + + @Test + public void oceanSubmergedAndMissingSamplesAreNotWritable() { + assertFalse(SurfaceCellAdmission.writable(HydrologyTerrainSample.ocean(90, "ocean"), 60)); + assertFalse(SurfaceCellAdmission.writable(HydrologyTerrainSample.openLand(60, 0D, "land"), 60)); + assertFalse(SurfaceCellAdmission.writable(HydrologyTerrainSample.openLand(40, 0D, "land"), 60)); + assertFalse(SurfaceCellAdmission.writable(null, 60)); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterlineTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterlineTest.java new file mode 100644 index 000000000..4e04cdb72 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCenterlineTest.java @@ -0,0 +1,76 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import org.junit.Test; + +import java.util.HashSet; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class SurfaceCenterlineTest { + @Test + public void straightPathDensifiesToOneBlockStationsWithAxisTangent() { + SurfaceCenterline centerline = SurfaceCenterline.densify(List.of( + new HydrologyPoint(0, 0, 0), + new HydrologyPoint(8, 0, 0) + )); + + assertEquals(9, centerline.size()); + for (int station = 0; station < centerline.size(); station++) { + assertEquals(station, centerline.x()[station]); + assertEquals(0, centerline.z()[station]); + assertEquals(1D, centerline.tangentX()[station], 1.0E-9D); + assertEquals(0D, centerline.tangentZ()[station], 1.0E-9D); + assertEquals(0, centerline.pathIndex()[station]); + } + assertEquals(0D, centerline.normalX(4), 1.0E-9D); + assertEquals(1D, centerline.normalZ(4), 1.0E-9D); + } + + @Test + public void diagonalAndRepeatedPointsProduceNoDuplicateStations() { + SurfaceCenterline centerline = SurfaceCenterline.densify(List.of( + new HydrologyPoint(0, 0, 0), + new HydrologyPoint(4, 0, 4), + new HydrologyPoint(4, 0, 4), + new HydrologyPoint(4, 0, 9) + )); + + HashSet seen = new HashSet<>(); + for (int station = 0; station < centerline.size(); station++) { + long key = ((long) centerline.x()[station] << 32) ^ (centerline.z()[station] & 0xffffffffL); + assertTrue(seen.add(key)); + } + assertEquals(10, centerline.size()); + assertEquals(0, centerline.pathIndex()[0]); + assertEquals(2, centerline.pathIndex()[9]); + } + + @Test + public void distanceToSegmentMeasuresPerpendicularOffsetBesideAStraightRun() { + SurfaceCenterline centerline = SurfaceCenterline.densify(List.of( + new HydrologyPoint(0, 0, 0), + new HydrologyPoint(20, 0, 0) + )); + + assertEquals(3D, centerline.distanceToSegment(7, 7.5D, 3D), 1.0E-9D); + assertEquals(0D, centerline.distanceToSegment(7, 7.5D, 0D), 1.0E-9D); + assertEquals(2D, centerline.distanceToSegment(20, 22D, 0D), 1.0E-9D); + } + + @Test + public void truncateKeepsTheLeadingStations() { + SurfaceCenterline centerline = SurfaceCenterline.densify(List.of( + new HydrologyPoint(0, 0, 0), + new HydrologyPoint(20, 0, 0) + )); + + SurfaceCenterline truncated = centerline.truncate(5); + + assertEquals(5, truncated.size()); + assertEquals(4, truncated.x()[4]); + assertEquals(1D, truncated.tangentX()[4], 1.0E-9D); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java new file mode 100644 index 000000000..85c6192a5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceCourseBuilderTest.java @@ -0,0 +1,141 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class SurfaceCourseBuilderTest { + private static final int SEA_LEVEL = 60; + private static final long COURSE_ID = 4242L; + private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) { + case SURFACE_WIDTH -> 6; + case SURFACE_DEPTH -> 3; + default -> request.minimum(); + }; + + @Test + public void slopedCourseToTheSeaProducesNonRisingLabelledSegmentsEndingAtSeaLevel() { + HydrologyTerrainSampler sampler = (int x, int z) -> x >= 260 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(110 - x / 6, 0D, "land"); + SurfaceCourseResult result = builder(sampler).build( + 7L, + COURSE_ID, + "water", + List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(140, 0, 0), new HydrologyPoint(280, 0, 0)), + SurfaceTerminal.OCEAN_MOUTH, + SEA_LEVEL + ); + + assertNull(result.rejection()); + List segments = result.segments(); + assertTrue(segments.size() > 3); + assertEquals(0, segments.getFirst().start().x()); + assertEquals(SEA_LEVEL, result.lastHead()); + assertEquals(SEA_LEVEL, segments.getLast().downstreamHeadY()); + int previous = Integer.MAX_VALUE; + for (HydraulicSegment segment : segments) { + assertEquals(COURSE_ID, segment.courseId()); + assertTrue(segment.upstreamHeadY() <= previous); + assertTrue(segment.downstreamHeadY() <= segment.upstreamHeadY()); + assertTrue(segment.type().isSurface()); + previous = segment.downstreamHeadY(); + } + for (int index = 1; index < segments.size(); index++) { + HydraulicSegment before = segments.get(index - 1); + HydraulicSegment after = segments.get(index); + assertEquals(before.end().x(), after.start().x()); + assertEquals(before.end().z(), after.start().z()); + assertEquals(before.downstreamHeadY(), after.upstreamHeadY()); + } + assertTrue(result.pathEnd().x() >= 250); + assertTrue(result.lastWidth() >= 6); + } + + @Test + public void flatCourseIsOneSurfacePool() { + HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(80, 0D, "land"); + SurfaceCourseResult result = builder(sampler).build( + 7L, + COURSE_ID, + "water", + List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), + SurfaceTerminal.SINKHOLE, + 40 + ); + + assertNull(result.rejection()); + assertEquals(1, result.segments().size()); + assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().getFirst().type()); + assertEquals(79, result.lastHead()); + assertEquals(201, result.segments().getFirst().centerline().size()); + } + + @Test + public void cliffProducesAWaterfallSegment() { + HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land"); + SurfaceCourseResult result = builder(sampler).build( + 7L, + COURSE_ID, + "water", + List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), + SurfaceTerminal.SINKHOLE, + 40 + ); + + assertNull(result.rejection()); + assertEquals(3, result.segments().size()); + assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(0).type()); + assertEquals(HydrologyFeatureType.WATERFALL, result.segments().get(1).type()); + assertEquals(10, result.segments().get(1).drop()); + assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(2).type()); + } + + @Test + public void gentleSlopeProducesRifflesAndSteepSlopeProducesCascades() { + HydrologyTerrainSampler gentle = (int x, int z) -> HydrologyTerrainSample.openLand(120 - x / 10, 0D, "land"); + SurfaceCourseResult gentleResult = builder(gentle).build(7L, COURSE_ID, "water", + List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40); + HydrologyTerrainSampler steep = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land"); + SurfaceCourseResult steepResult = builder(steep).build(7L, COURSE_ID, "water", + List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40); + + assertNull(gentleResult.rejection()); + assertNull(steepResult.rejection()); + assertTrue(gentleResult.segments().stream().anyMatch(segment -> segment.type() == HydrologyFeatureType.RIFFLE)); + assertTrue(gentleResult.segments().stream().noneMatch(segment -> segment.type() == HydrologyFeatureType.CASCADE)); + assertTrue(steepResult.segments().stream().anyMatch(segment -> segment.type() == HydrologyFeatureType.CASCADE)); + } + + @Test + public void submergedCourseIsRejected() { + HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(50, 0D, "land"); + SurfaceCourseResult result = builder(sampler).build( + 7L, + COURSE_ID, + "water", + List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), + SurfaceTerminal.OCEAN_MOUTH, + SEA_LEVEL + ); + + assertEquals(HydrologyCandidateRejection.COURSE_TOO_SHORT, result.rejection()); + assertTrue(result.segments().isEmpty()); + } + + private static SurfaceCourseBuilder builder(HydrologyTerrainSampler sampler) { + return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL, 64); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java new file mode 100644 index 000000000..b39bad0e1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompilerTest.java @@ -0,0 +1,145 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyFeatureType; +import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; +import art.arcane.iris.engine.hydrology.RiverCourse; +import art.arcane.iris.engine.hydrology.RiverCourseType; +import art.arcane.iris.engine.hydrology.RiverFootprint; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.OptionalLong; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class SurfaceFootprintCompilerTest { + private static final int SEA_LEVEL = 60; + private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) { + case SURFACE_WIDTH -> 6; + case SURFACE_DEPTH -> 3; + default -> request.minimum(); + }; + + @Test + public void compiledLayersDescribeAContainedCarveOnlyChannelWithShoreAndBank() { + HydrologyTerrainSampler sampler = (int x, int z) -> x >= 150 && z > 8 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(80 + Math.max(0, -z) / 2, 0D, "land"); + RiverCourse course = course(List.of( + segment(1L, HydrologyFeatureType.SURFACE_POOL, 79, 79, points(0, 60, 79)), + segment(2L, HydrologyFeatureType.RIFFLE, 79, 78, List.of(new HydrologyPoint(60, 79, 0), new HydrologyPoint(61, 78, 0))), + segment(3L, HydrologyFeatureType.SURFACE_POOL, 78, 78, points(61, 140, 78)) + )); + SurfaceFootprint footprint = compiler(sampler).compile(course); + + assertEquals(0, footprint.uncontainedWetCells()); + assertFalse(footprint.columns().isEmpty()); + HashSet keys = new HashSet<>(); + for (SurfaceLayerColumn column : footprint.columns()) { + assertTrue(keys.add(RiverFootprint.pack(column.x(), column.z()))); + HydrologyColumnLayer layer = column.layer(); + assertTrue(layer.bedY() <= column.terrain().naturalHeight()); + assertEquals(layer.fluidHeadY(), layer.ceilingY()); + assertEquals(1L, layer.feature().courseId()); + assertTrue(layer.feature().type().isSurface()); + if (layer.channel()) { + assertTrue(layer.fluidOwned()); + assertTrue(layer.connectedFluid()); + assertTrue(layer.terrainOwned()); + assertTrue(layer.fluidHeadY() < column.terrain().naturalHeight()); + assertTrue(layer.bedY() < layer.fluidHeadY()); + } else { + assertTrue(layer.grading()); + assertTrue(layer.terrainOwned()); + assertFalse(layer.fluidOwned()); + assertEquals(layer.bedY(), layer.fluidHeadY()); + } + } + SurfaceLayerColumn center = column(footprint, 30, 0); + assertNotNull(center); + assertTrue(center.layer().channel()); + assertEquals(79, center.layer().fluidHeadY()); + assertTrue(center.layer().feature().source() == false); + SurfaceLayerColumn source = column(footprint, 0, 0); + assertNotNull(source); + assertTrue(source.layer().feature().source()); + SurfaceLayerColumn shore = column(footprint, 30, 4); + assertNotNull(shore); + assertTrue(shore.layer().shore()); + assertEquals("land", shore.layer().shoreBiomeKey()); + SurfaceLayerColumn bank = column(footprint, 30, -8); + assertNotNull(bank); + assertTrue(bank.layer().grading()); + assertFalse(bank.layer().shore()); + assertNull(column(footprint, 160, 12)); + } + + @Test + public void mouthOverTheOceanPublishesOnlyANonOwningApron() { + HydrologyTerrainSampler sampler = (int x, int z) -> x >= 100 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(70, 0D, "land"); + RiverCourse course = course(List.of( + segment(1L, HydrologyFeatureType.SURFACE_POOL, 69, 69, points(0, 96, 69)), + segment(2L, HydrologyFeatureType.WATERFALL, 69, SEA_LEVEL, List.of(new HydrologyPoint(96, 69, 0), new HydrologyPoint(97, SEA_LEVEL, 0))), + segment(3L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL, points(97, 112, SEA_LEVEL)) + )); + SurfaceFootprint footprint = compiler(sampler).compile(course); + + int aprons = 0; + for (SurfaceLayerColumn column : footprint.columns()) { + if (column.terrain().ocean()) { + assertTrue(column.layer().oceanApron()); + assertFalse(column.layer().terrainOwned()); + assertFalse(column.layer().fluidOwned()); + assertEquals(HydrologyFeatureType.MOUTH, column.layer().feature().type()); + aprons++; + } else { + assertFalse(column.layer().oceanApron()); + } + } + assertTrue(aprons > 0); + assertNull(column(footprint, 111, 0)); + } + + private static SurfaceLayerColumn column(SurfaceFootprint footprint, int x, int z) { + for (SurfaceLayerColumn column : footprint.columns()) { + if (column.x() == x && column.z() == z) { + return column; + } + } + return null; + } + + private static SurfaceFootprintCompiler compiler(HydrologyTerrainSampler sampler) { + return new SurfaceFootprintCompiler(HydrologyPlannerSettings.defaults(), sampler, CONSTANT_GEOMETRY); + } + + private static RiverCourse course(List segments) { + return new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(9L), OptionalLong.of(8L), "water", 1, List.of(), segments); + } + + private static HydraulicSegment segment(long id, HydrologyFeatureType type, int upstream, int downstream, List centerline) { + return new HydraulicSegment(id, 1L, type, upstream, downstream, 6, 3, false, false, centerline); + } + + private static List points(int fromX, int toX, int y) { + ArrayList points = new ArrayList<>(); + for (int x = fromX; x <= toX; x++) { + points.add(new HydrologyPoint(x, y, 0)); + } + return points; + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java new file mode 100644 index 000000000..d759214a8 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/SurfaceNoiseTest.java @@ -0,0 +1,39 @@ +package art.arcane.iris.engine.hydrology.surface; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class SurfaceNoiseTest { + @Test + public void valuesStayInRangeAndAreDeterministic() { + for (int x = -40; x < 40; x++) { + for (int z = -40; z < 40; z++) { + double value = SurfaceNoise.value(77L, x, z, 16); + assertTrue(value >= 0D && value <= 1D); + assertEquals(value, SurfaceNoise.value(77L, x, z, 16), 0D); + double signed = SurfaceNoise.signed(77L, x, z, 16); + assertTrue(signed >= -1D && signed <= 1D); + } + } + } + + @Test + public void neighbouringCellsAreCoherent() { + double maximumStep = 0D; + for (int x = 0; x < 200; x++) { + maximumStep = Math.max(maximumStep, Math.abs(SurfaceNoise.value(5L, x, 3, 16) - SurfaceNoise.value(5L, x + 1, 3, 16))); + } + assertTrue(maximumStep < 0.2D); + } + + @Test + public void smoothStepEasesBetweenZeroAndOne() { + assertEquals(0D, SurfaceNoise.smoothStep(0D), 0D); + assertEquals(1D, SurfaceNoise.smoothStep(1D), 0D); + assertEquals(0.5D, SurfaceNoise.smoothStep(0.5D), 1.0E-9D); + assertTrue(SurfaceNoise.smoothStep(0.25D) < 0.25D); + assertTrue(SurfaceNoise.smoothStep(0.75D) > 0.75D); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java new file mode 100644 index 000000000..ca12b1b42 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/surface/ValleyProfileSolverTest.java @@ -0,0 +1,175 @@ +package art.arcane.iris.engine.hydrology.surface; + +import art.arcane.iris.engine.hydrology.HydrologyCandidateRejection; +import art.arcane.iris.engine.hydrology.HydrologyGeometrySampler; +import art.arcane.iris.engine.hydrology.HydrologyPlannerSettings; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSample; +import art.arcane.iris.engine.hydrology.HydrologyTerrainSampler; +import org.junit.Test; + +import java.util.List; +import java.util.function.IntBinaryOperator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ValleyProfileSolverTest { + private static final int SEA_LEVEL = 60; + private static final HydrologyGeometrySampler CONSTANT_GEOMETRY = request -> switch (request.field()) { + case SURFACE_WIDTH -> 6; + case SURFACE_DEPTH -> 2; + default -> request.minimum(); + }; + + @Test + public void flatTerrainHoldsTheHeadOneInsetBelowTheBanks() { + ValleyProfile valley = solve(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40); + + assertNull(valley.rejection()); + assertEquals(300, valley.exposedStations()); + for (int station = 0; station < valley.exposedStations(); station++) { + assertEquals(79, valley.head()[station]); + assertEquals(80, valley.crossMin()[station]); + assertEquals(80, valley.centerNatural()[station]); + } + } + + @Test + public void gentleSlopeProducesOneBlockNonRisingSteps() { + ValleyProfile valley = solve(400, (x, z) -> 120 - x / 8, SurfaceTerminal.SINKHOLE, 40); + + assertNull(valley.rejection()); + int steps = 0; + for (int station = 1; station < valley.exposedStations(); station++) { + int drop = valley.head()[station - 1] - valley.head()[station]; + assertTrue(drop >= 0); + assertTrue(drop <= 1); + steps += drop; + } + assertTrue(steps >= 45 && steps <= 51); + assertContained(valley); + } + + @Test + public void hillsideTraverseKeepsTheHeadBelowTheLowestBank() { + ValleyProfile valley = solve(300, (x, z) -> 90 + z, SurfaceTerminal.SINKHOLE, 40); + + assertNull(valley.rejection()); + assertContained(valley); + assertTrue(valley.head()[150] < valley.centerNatural()[150] - 1); + } + + @Test + public void aDipBesideTheChannelLowersEveryDownstreamHead() { + ValleyProfile valley = solve(300, (x, z) -> x == 150 && z == 6 ? 74 : 80, SurfaceTerminal.SINKHOLE, 40); + + assertNull(valley.rejection()); + assertEquals(79, valley.head()[100]); + assertTrue(valley.head()[150] <= 73); + assertTrue(valley.head()[299] <= 73); + assertContained(valley); + } + + @Test + public void oceanTruncatesTheCourseAndPinsTheMouthToSeaLevel() { + HydrologyTerrainSampler sampler = (int x, int z) -> x >= 240 + ? HydrologyTerrainSample.ocean(50, "ocean") + : HydrologyTerrainSample.openLand(100 - x / 6, 0D, "land"); + ValleyProfile valley = solver(sampler).solve( + straight(300), + channel(300, sampler), + SurfaceTerminal.OCEAN_MOUTH, + SEA_LEVEL + ); + + assertNull(valley.rejection()); + assertTrue(valley.exposedStations() > 200); + assertTrue(valley.exposedStations() <= 240); + assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations() - 1]); + assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations()]); + assertEquals(SEA_LEVEL, valley.head()[299]); + for (int station = 0; station < valley.exposedStations(); station++) { + assertTrue(valley.head()[station] >= SEA_LEVEL); + } + assertContained(valley); + } + + @Test + public void landAtSeaLevelAlsoEndsTheExposedCourse() { + ValleyProfile valley = solve(300, (x, z) -> x >= 200 ? SEA_LEVEL : 90, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertNull(valley.rejection()); + assertTrue(valley.exposedStations() <= 200); + assertTrue(valley.exposedStations() >= 190); + assertEquals(89, valley.head()[valley.exposedStations() - 1]); + assertEquals(SEA_LEVEL, valley.head()[valley.exposedStations()]); + } + + @Test + public void aCliffBecomesASingleLargeDrop() { + ValleyProfile valley = solve(300, (x, z) -> x < 150 ? 100 : 90, SurfaceTerminal.SINKHOLE, 40); + + assertNull(valley.rejection()); + int largest = 0; + for (int station = 1; station < valley.exposedStations(); station++) { + largest = Math.max(largest, valley.head()[station - 1] - valley.head()[station]); + } + assertEquals(10, largest); + assertEquals(99, valley.head()[100]); + assertEquals(89, valley.head()[200]); + } + + @Test + public void submergedCourseIsRejectedAsTooShort() { + ValleyProfile valley = solve(300, (x, z) -> 50, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL); + + assertEquals(HydrologyCandidateRejection.COURSE_TOO_SHORT, valley.rejection()); + } + + @Test + public void sinkholeClearanceIsRequiredAtTheTerminal() { + ValleyProfile valley = solve(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 90); + + assertEquals(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE, valley.rejection()); + } + + @Test + public void excessiveIncisionIsRejected() { + ValleyProfile valley = solve(300, (x, z) -> 90 - Math.min(20, Math.abs(z) * 8), SurfaceTerminal.SINKHOLE, 40); + + assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, valley.rejection()); + } + + private static void assertContained(ValleyProfile valley) { + for (int station = 0; station < valley.exposedStations(); station++) { + assertTrue(valley.head()[station] <= valley.crossMin()[station] - 1); + if (station > 0) { + assertTrue(valley.head()[station] <= valley.head()[station - 1]); + } + } + } + + private static ValleyProfile solve(int stations, IntBinaryOperator height, SurfaceTerminal terminal, int terminalHead) { + HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(height.applyAsInt(x, z), 0D, "land"); + return solver(sampler).solve(straight(stations), channel(stations, sampler), terminal, terminalHead); + } + + private static ValleyProfileSolver solver(HydrologyTerrainSampler sampler) { + return new ValleyProfileSolver(HydrologyPlannerSettings.defaults().surface(), sampler, SEA_LEVEL, 64); + } + + private static ChannelProfile channel(int stations, HydrologyTerrainSampler sampler) { + return new ChannelProfileBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, CONSTANT_GEOMETRY) + .build(straight(stations), "water", false); + } + + private static SurfaceCenterline straight(int stations) { + return SurfaceCenterline.densify(path(stations)); + } + + private static List path(int stations) { + return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0)); + } +} 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 cc1c8090f..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,10 +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.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; @@ -17,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; @@ -97,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(); @@ -116,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 -> { @@ -129,19 +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(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 355ee624b..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,6 +1,10 @@ package art.arcane.iris.engine.mantle; 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.object.IrisDimension; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.PlatformBlockState; @@ -15,7 +19,10 @@ import org.junit.Before; 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; @@ -30,9 +37,12 @@ public class MantleWriterOverlayTest { private MantleWriter writer; private Matter matter; + private MantleChunk chunk; private MatterSlice blockSlice; private MatterSlice identifierSlice; private MatterSlice cavernSlice; + private MatterSlice hydrologySlice; + private IrisDimension dimension; @Before @SuppressWarnings("unchecked") @@ -46,23 +56,34 @@ 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); - MantleChunk chunk = mock(MantleChunk.class); + chunk = mock(MantleChunk.class); matter = mock(Matter.class); blockSlice = mock(MatterSlice.class); identifierSlice = mock(MatterSlice.class); cavernSlice = mock(MatterSlice.class); + 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); + when(chunk.exists(0)).thenReturn(true); + when(chunk.get(0)).thenReturn(matter); when(matter.hasSlice(PlatformBlockState.class)).thenReturn(true); when(matter.hasSlice(Identifier.class)).thenReturn(true); + when(matter.hasSlice(MatterCavern.class)).thenReturn(true); when(matter.getSlice(PlatformBlockState.class)).thenReturn(blockSlice); 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(HydrologyCaveCell.class); writer = new MantleWriter(engineMantle, mantle, 0, 0, 0, false); } @@ -105,4 +126,88 @@ public class MantleWriterOverlayTest { verify(identifierSlice).set(X, Y, Z, null); 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(HydrologyCaveCell.class)).thenReturn(true); + when(hydrologySlice.get(X, Y, Z)) + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + + writer.setData(X, Y, Z, replacement); + assertFalse(writer.carveDataIfAbsent(X, Y, Z, cavern)); + writer.setForcedCarve(X, Y, Z, cavern); + + verify(blockSlice, never()).set(X, Y, Z, replacement); + verify(blockSlice, never()).set(X, Y, Z, null); + verify(identifierSlice, never()).set(X, Y, Z, null); + verify(cavernSlice, never()).set(X, Y, Z, cavern); + } + + @Test + public void hydrologyOverridesBaselineCarvedQueriesWithoutChangingIt() { + MatterCavern baseline = new MatterCavern(true, "", (byte) 0); + when(matter.hasSlice(HydrologyCaveCell.class)).thenReturn(true); + when(cavernSlice.get(X, Y, Z)).thenReturn(baseline); + when(hydrologySlice.get(X, Y, Z)) + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD)); + when(hydrologySlice.get(X, Y + 1, Z)) + .thenReturn(HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)); + + assertFalse(writer.isCarved(X, Y, Z)); + assertTrue(writer.isCarved(X, Y + 1, Z)); + + byte[] column = writer.getCarvedColumn(X, Z, Y + 2); + assertEquals(0, column[Y]); + 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 5d7168b28..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() { @@ -74,7 +112,7 @@ public class MatterGeneratorCarvePassRadiusTest { RecordingComponent carving = new RecordingComponent(ReservedFlag.CARVED, 0, 1); RecordingComponent conditional = new RecordingComponent( - ReservedFlag.JIGSAW, + ReservedFlag.RIVER_HYDROLOGY, 1, 0, 160, @@ -111,7 +149,7 @@ public class MatterGeneratorCarvePassRadiusTest { RecordingComponent carving = new RecordingComponent(ReservedFlag.CARVED, 0, 0); RecordingComponent conditional = new RecordingComponent( - ReservedFlag.JIGSAW, + ReservedFlag.RIVER_HYDROLOGY, 1, 0, 0, @@ -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 6766dbe8d..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,6 +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.hydrology.cave.HydrologyCaveAction; +import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell; import art.arcane.iris.spi.PlatformBlockState; import org.junit.Test; @@ -109,6 +111,21 @@ public class CaveObjectPlacementTransactionTest { assertEquals(61, MantleObjectComponent.caveAnchorScanUpperBound(128, 80, 20)); } + @Test + public void dryHydrologyHeadroomRejectsTheWholePlacement() { + IObjectPlacer delegate = createPlacer(128, 80, 20, 90); + 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)); + transaction.setData(5, 30, 7, "object@1"); + + assertEquals(CaveObjectPlacementTransaction.CommitResult.REJECTED_HYDROLOGY, transaction.commit()); + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + verify(delegate, never()).setData(anyInt(), anyInt(), anyInt(), any()); + } + private IObjectPlacer createPlacer(int worldHeight, int surfaceHeight, int caveFloor, int caveCeiling) { Engine engine = mock(Engine.class); when(engine.getHeight()).thenReturn(worldHeight); diff --git a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java index 877e0ee51..86ea66868 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/IrisCaveCarver3DNearParityTest.java @@ -868,10 +868,11 @@ public class IrisCaveCarver3DNearParityTest { Engine engine = createEngine(80, 70); int[] surfaceHeights = filledHeights(70); surfaceHeights[0] = 60; - int[] boundaryStartY = new int[256]; - Arrays.fill(boundaryStartY, SurfaceFluidBoundaryPlan.NO_BOUNDARY); - boundaryStartY[0] = 60; - boundaryStartY[16] = 61; + long[] boundaries = new long[256]; + Arrays.fill(boundaries, SurfaceFluidBoundaryPlan.boundary( + SurfaceFluidBoundaryPlan.NO_BOUNDARY, Integer.MIN_VALUE)); + boundaries[0] = SurfaceFluidBoundaryPlan.boundary(60, 64); + boundaries[16] = SurfaceFluidBoundaryPlan.boundary(61, 64); WriterCapture capture = createWriterCapture(80); CaveFluidSupportPlan supportPlan = new CaveFluidSupportPlan(); @@ -884,7 +885,7 @@ public class IrisCaveCarver3DNearParityTest { 0D, null, surfaceHeights, - boundaryStartY, + boundaries, null, supportPlan ); 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..06aac6374 --- /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() + ); + } + } + assertTrue(transitionCount >= 1); + } + + @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()); + assertTrue( + "column " + column.x() + "," + column.z() + " publishes no surface fluid", + layer.publishesSurfaceFluid() + ); + 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); + HashSet coastChunks = new HashSet<>(); + for (HydrologyColumnSample column : tile.footprint().columns().values()) { + if (column.ocean() || hasCardinalOceanNeighbor( + terrain, + new HydrologyPoint(column.x(), column.naturalHeight(), column.z()) + )) { + coastChunks.add(pack(Math.floorDiv(column.x(), 16), Math.floorDiv(column.z(), 16))); + } + if (!column.ocean()) { + continue; + } + for (HydrologyColumnLayer layer : column.layers()) { + if (layer.feature().courseId() != course.id()) { + continue; + } + 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); + } + } + assertFalse(coastChunks.isEmpty()); + for (long packedChunk : coastChunks) { + 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( + surfacePublicationParityCase(surface, HydrologyCaveAction.WET_SOURCE), + 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("flooded", 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 sameCourseSurfaceFluidOwnsAChangedCaveBoundary() { + long courseId = 33L; + HydrologyColumnLayer ridge = layerForCourse( + HydrologyFeatureType.UNDERGROUND_POOL, + 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.UNDERGROUND_POOL, + 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.UNDERGROUND_POOL, + 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.UNDERGROUND_POOL, + 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.UNDERGROUND_POOL, + 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, 512, 256, 16, 8, 0.5D, 12D, 0.5D, 0.1D, 1D, 0), + new HydrologyPlannerSettings.Surface( + true, + surfaceSources, + 2, + 2, + 2, + 2, + 128, + 0D, + HydrologyPlannerSettings.Banks.defaults()), + new HydrologyPlannerSettings.Hydraulics(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(), 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, + 1D, + ocean ? "ocean" : "land", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("default"), List.of() + ); + } + + 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 3d8205de4..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,6 +1,8 @@ package art.arcane.iris.engine.mantle.components; import art.arcane.iris.engine.object.IrisBiome; +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; @@ -46,6 +48,21 @@ public class MantleObjectComponentCaveAnchorTest { )); } + @Test + public void plannedHydrologyCannotBecomeAnObjectAnchor() { + MatterCavern water = new MatterCavern(true, "", (byte) 1); + MatterCavern forcedAir = new MatterCavern(true, "", (byte) 3); + + assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( + true, water, HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), 20, 8)); + assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( + true, water, HydrologyCaveCell.of(HydrologyCaveAction.FALLING_FLUID), 20, 8)); + assertFalse(MantleObjectComponent.acceptsCaveAnchorFluid( + 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) { IrisBiome biome = new IrisBiome(); biome.setLoadKey(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/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/mantle/components/SurfaceFluidBoundaryPlanTest.java b/core/src/test/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlanTest.java index ef1550777..6f243956d 100644 --- a/core/src/test/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlanTest.java +++ b/core/src/test/java/art/arcane/iris/engine/mantle/components/SurfaceFluidBoundaryPlanTest.java @@ -25,8 +25,8 @@ public class SurfaceFluidBoundaryPlanTest { int boundaryY = fixture.boundary(8, 8); assertEquals(60, boundaryY); - assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, fixture.index(8, 8), 59, FLUID_HEIGHT)); - assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, fixture.index(8, 8), 60, FLUID_HEIGHT)); + assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, fixture.index(8, 8), 59)); + assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, fixture.index(8, 8), 60)); } @Test @@ -38,10 +38,10 @@ public class SurfaceFluidBoundaryPlanTest { int columnIndex = fixture.index(8, 8); assertEquals(61, fixture.boundary(8, 8)); - assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, columnIndex, 60, FLUID_HEIGHT)); - assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, columnIndex, 61, FLUID_HEIGHT)); - assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, columnIndex, 64, FLUID_HEIGHT)); - assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaryStartY, columnIndex, 65, FLUID_HEIGHT)); + assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 60)); + assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 61)); + assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 64)); + assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 65)); } @Test @@ -94,13 +94,28 @@ public class SurfaceFluidBoundaryPlanTest { new int[CHUNK_SIZE * CHUNK_SIZE], new double[FIELD_SIZE * FIELD_SIZE], new boolean[FIELD_SIZE * FIELD_SIZE], + new double[FIELD_SIZE * FIELD_SIZE], FIELD_SIZE, 0, - FLUID_HEIGHT, - new int[CHUNK_SIZE * CHUNK_SIZE] + new long[CHUNK_SIZE * CHUNK_SIZE] )); } + @Test + public void terracedReservoirProtectsOnlyToItsLocalHead() { + Fixture fixture = new Fixture(); + fixture.setFieldSurface(7, 8, 60D); + fixture.setFieldFluidHeight(7, 8, 67D); + + fixture.resolve(); + + int columnIndex = fixture.index(8, 8); + assertEquals(61, fixture.boundary(8, 8)); + assertEquals(67, fixture.boundaryEnd(8, 8)); + assertTrue(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 67)); + assertFalse(SurfaceFluidBoundaryPlan.protects(fixture.boundaries, columnIndex, 68)); + } + private void assertEdgeBoundary(int localX, int localZ, int neighborLocalX, int neighborLocalZ) { Fixture fixture = new Fixture(); fixture.setFieldSurface(neighborLocalX, neighborLocalZ, 60D); @@ -111,12 +126,14 @@ public class SurfaceFluidBoundaryPlanTest { private static final class Fixture { private final int[] chunkSurfaceHeights = new int[CHUNK_SIZE * CHUNK_SIZE]; private final double[] fieldSurfaceHeights = new double[FIELD_SIZE * FIELD_SIZE]; + private final double[] fieldFluidHeights = new double[FIELD_SIZE * FIELD_SIZE]; private final boolean[] fieldHasFluid = new boolean[FIELD_SIZE * FIELD_SIZE]; - private final int[] boundaryStartY = new int[CHUNK_SIZE * CHUNK_SIZE]; + private final long[] boundaries = new long[CHUNK_SIZE * CHUNK_SIZE]; private Fixture() { Arrays.fill(chunkSurfaceHeights, 70); Arrays.fill(fieldSurfaceHeights, 70D); + Arrays.fill(fieldFluidHeights, FLUID_HEIGHT); Arrays.fill(fieldHasFluid, true); } @@ -137,20 +154,30 @@ public class SurfaceFluidBoundaryPlanTest { fieldHasFluid[(fieldX * FIELD_SIZE) + fieldZ] = hasFluid; } + private void setFieldFluidHeight(int localX, int localZ, double fluidHeight) { + int fieldX = localX + PADDING; + int fieldZ = localZ + PADDING; + fieldFluidHeights[(fieldX * FIELD_SIZE) + fieldZ] = fluidHeight; + } + private void resolve() { SurfaceFluidBoundaryPlan.fill( chunkSurfaceHeights, fieldSurfaceHeights, fieldHasFluid, + fieldFluidHeights, FIELD_SIZE, PADDING, - FLUID_HEIGHT, - boundaryStartY + boundaries ); } private int boundary(int localX, int localZ) { - return boundaryStartY[index(localX, localZ)]; + return SurfaceFluidBoundaryPlan.startY(boundaries[index(localX, localZ)]); + } + + private int boundaryEnd(int localX, int localZ) { + return SurfaceFluidBoundaryPlan.endY(boundaries[index(localX, localZ)]); } private int index(int localX, int localZ) { 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 01279a14a..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,6 +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.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; @@ -107,10 +109,48 @@ public class IrisCarveModifierBoundarySupportTest { assertTrue(IrisCarveModifier.hasStableCaveFloorSupport(output, 0, 6, 0)); } + @Test + 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.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 + @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); + + 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) { + return state(key, solid, false); + } + + private PlatformBlockState state(String key, boolean solid, boolean fluid) { PlatformBlockState state = mock(PlatformBlockState.class); doReturn(key).when(state).key(); doReturn(solid).when(state).isSolid(); + doReturn(fluid).when(state).isFluid(); return state; } } diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierHydrologyTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierHydrologyTest.java new file mode 100644 index 000000000..dadc01802 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisCarveModifierHydrologyTest.java @@ -0,0 +1,127 @@ +package art.arcane.iris.engine.modifier; + +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 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, HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD))); + + MatterCavern wet = IrisCarveModifier.composeCavern( + baseline, new HydrologyCaveCell( + HydrologyCaveAction.WET_SOURCE, + "deep_lava", + "iris:flooded" + )); + MatterCavern dry = IrisCarveModifier.composeCavern( + baseline, HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR)); + + assertEquals(1, wet.getLiquid()); + assertEquals("iris:flooded", wet.getCustomBiome()); + assertEquals(3, dry.getLiquid()); + assertEquals(2, baseline.getLiquid()); + assertEquals("baseline", baseline.getCustomBiome()); + } + + @Test + public void hydrologyActionsResolveDistinctFluidStates() { + PlatformBlockState current = mock(PlatformBlockState.class); + PlatformBlockState source = mock(PlatformBlockState.class); + PlatformBlockState falling = mock(PlatformBlockState.class); + PlatformBlockState air = mock(PlatformBlockState.class); + when(source.key()).thenReturn("minecraft:water[level=0]"); + when(source.withProperty("level", "8")).thenReturn(falling); + + assertSame(source, IrisCarveModifier.resolveHydrologyState( + HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), current, source, air)); + assertSame(falling, IrisCarveModifier.resolveHydrologyState( + HydrologyCaveCell.of(HydrologyCaveAction.FALLING_FLUID), current, source, air)); + assertSame(air, IrisCarveModifier.resolveHydrologyState( + HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR), current, source, air)); + } + + @Test + public void waterloggingFollowsOnlyExplicitResultingWater() { + PlatformBlockState waterlogged = mock(PlatformBlockState.class); + PlatformBlockState dry = mock(PlatformBlockState.class); + PlatformBlockState water = mock(PlatformBlockState.class); + PlatformBlockState lava = mock(PlatformBlockState.class); + when(waterlogged.key()).thenReturn("minecraft:seagrass[waterlogged=true]"); + when(waterlogged.withProperty("waterlogged", "false")).thenReturn(dry); + when(water.isWater()).thenReturn(true); + + assertSame(waterlogged, IrisCarveModifier.normalizeWaterlogging(waterlogged, water)); + assertSame(dry, IrisCarveModifier.normalizeWaterlogging(waterlogged, null)); + assertSame(dry, IrisCarveModifier.normalizeWaterlogging(waterlogged, lava)); + assertSame(dry, IrisCarveModifier.resolveHydrologyState( + HydrologyCaveCell.of(HydrologyCaveAction.SEAL_GUARD), waterlogged, water, null)); + } + + @Test + public void noOverlayLeavesBaselineWaterloggingUntouched() { + PlatformBlockState waterlogged = mock(PlatformBlockState.class); + PlatformBlockState water = mock(PlatformBlockState.class); + MatterCavern flooded = new MatterCavern(true, "", (byte) 1); + + assertSame(waterlogged, IrisCarveModifier.normalizeHydrologyWaterlogging( + waterlogged, + flooded, + null, + water + )); + verify(waterlogged, never()).withProperty("waterlogged", "false"); + } + + @Test + public void explicitOverlayUsesComposedFluidIntentForWaterlogging() { + PlatformBlockState waterlogged = mock(PlatformBlockState.class); + PlatformBlockState dry = mock(PlatformBlockState.class); + PlatformBlockState water = mock(PlatformBlockState.class); + MatterCavern flooded = new MatterCavern(true, "", (byte) 1); + when(waterlogged.key()).thenReturn("minecraft:seagrass[waterlogged=true]"); + when(waterlogged.withProperty("waterlogged", "false")).thenReturn(dry); + when(water.isWater()).thenReturn(true); + + assertSame(waterlogged, IrisCarveModifier.normalizeHydrologyWaterlogging( + waterlogged, + flooded, + HydrologyCaveCell.of(HydrologyCaveAction.WET_SOURCE), + water + )); + assertSame(dry, IrisCarveModifier.normalizeHydrologyWaterlogging( + waterlogged, + flooded, + 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 5111ffaa6..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 @@ -15,6 +15,7 @@ import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.iris.util.project.stream.ProceduralStream; +import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.matter.MatterCavern; import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap; import org.junit.AfterClass; @@ -73,6 +74,7 @@ public class IrisCarveModifierInferenceIsolationTest { Method paintBoundaryZone = IrisCarveModifier.class.getDeclaredMethod( "paintBoundaryZone", Hunk.class, + MantleChunk.class, CarveWallBuffer.class, int.class, int.class, @@ -88,6 +90,7 @@ public class IrisCarveModifierInferenceIsolationTest { paintBoundaryZone.invoke( modifier, mock(Hunk.class), + mock(MantleChunk.class), walls, 0, 0, diff --git a/core/src/test/java/art/arcane/iris/engine/modifier/IrisPostModifierHydrologyTest.java b/core/src/test/java/art/arcane/iris/engine/modifier/IrisPostModifierHydrologyTest.java new file mode 100644 index 000000000..e34a36df9 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/modifier/IrisPostModifierHydrologyTest.java @@ -0,0 +1,20 @@ +package art.arcane.iris.engine.modifier; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IrisPostModifierHydrologyTest { + @Test + public void columnsTouchingARiverFootprintSkipThePostPasses() { + assertTrue(IrisPostModifier.skipsHydrologyColumn(new double[] {1D, 0D, 0D, 0D, 0D})); + assertTrue(IrisPostModifier.skipsHydrologyColumn(new double[] {0D, 0D, 0.5D, 0D, 0D})); + assertTrue(IrisPostModifier.skipsHydrologyColumn(new double[] {0D, 0D, 0D, 0D, 0.75D})); + } + + @Test + public void columnsAwayFromRiversKeepThePostPasses() { + assertFalse(IrisPostModifier.skipsHydrologyColumn(new double[] {0D, 0D, 0D, 0D, 0D})); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/object/IObjectPlacerFluidHeightTest.java b/core/src/test/java/art/arcane/iris/engine/object/IObjectPlacerFluidHeightTest.java new file mode 100644 index 000000000..c7248e4a5 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IObjectPlacerFluidHeightTest.java @@ -0,0 +1,71 @@ +package art.arcane.iris.engine.object; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.util.project.stream.ProceduralStream; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class IObjectPlacerFluidHeightTest { + @Test + public void missingEngineOrComplexFallsBackToThePlacersScalarHead() { + IObjectPlacer withoutEngine = mock(IObjectPlacer.class, CALLS_REAL_METHODS); + when(withoutEngine.getFluidHeight()).thenReturn(63); + when(withoutEngine.getEngine()).thenReturn(null); + + assertEquals(63, withoutEngine.getFluidHeight(12, -7)); + + IObjectPlacer withoutComplex = mock(IObjectPlacer.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + when(withoutComplex.getFluidHeight()).thenReturn(63); + when(withoutComplex.getEngine()).thenReturn(engine); + when(engine.getComplex()).thenReturn(null); + + assertEquals(63, withoutComplex.getFluidHeight(12, -7)); + } + + @Test + public void engineLocalPlacersUseTheRoundedPerColumnRiverHead() { + IObjectPlacer placer = placer(127, 127, 130.6D); + + assertEquals(131, placer.getFluidHeight(12, -7)); + + verify(riverHead(placer)).get(12, -7); + } + + @Test + public void worldCoordinatePlacersShiftTheLocalRiverHeadExactlyOnce() { + IObjectPlacer placer = placer(63, 127, 130.6D); + + assertEquals(67, placer.getFluidHeight(12, -7)); + + verify(riverHead(placer)).get(12, -7); + } + + private static IObjectPlacer placer(int placerFluidHeight, int dimensionFluidHeight, double riverFluidHeight) { + IObjectPlacer placer = mock(IObjectPlacer.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + IrisComplex complex = mock(IrisComplex.class); + IrisDimension dimension = mock(IrisDimension.class); + @SuppressWarnings("unchecked") + ProceduralStream riverHead = mock(ProceduralStream.class); + + when(placer.getFluidHeight()).thenReturn(placerFluidHeight); + when(placer.getEngine()).thenReturn(engine); + when(engine.getComplex()).thenReturn(complex); + when(engine.getDimension()).thenReturn(dimension); + when(dimension.getFluidHeight()).thenReturn(dimensionFluidHeight); + when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead); + when(riverHead.get(12, -7)).thenReturn(riverFluidHeight); + return placer; + } + + private static ProceduralStream riverHead(IObjectPlacer placer) { + return placer.getEngine().getComplex().getRiverWaterSurfaceStream(); + } +} 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/IrisDimensionReachableBiomesTest.java b/core/src/test/java/art/arcane/iris/engine/object/IrisDimensionReachableBiomesTest.java index 29dea9d93..b2c573e33 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() + .setBankBiomes(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() + .setBankBiomes(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/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..fa2dca7b5 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.hasHydrologyChannelOrShore(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.hasHydrologyChannelOrShore(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.hasHydrologyChannelOrShore(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 new file mode 100644 index 000000000..9b3b7c057 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/object/IrisRiverConfigurationTest.java @@ -0,0 +1,315 @@ +package art.arcane.iris.engine.object; + +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.Gson; +import org.junit.Test; + +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.assertTrue; + +public class IrisRiverConfigurationTest { + @Test + public void defaultsExposeCanonicalHydrologyHierarchy() { + IrisDimension dimension = new IrisDimension(); + + 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.getHydrology().getDeepFluids().isEmpty()); + + IrisRiverRoutingConfig routing = dimension.getHydrology().getRivers().getRouting(); + assertEquals(2048, routing.getTileSize()); + assertEquals(64, routing.getSampleSpacing()); + assertEquals(16384, routing.getMaximumRouteLength()); + assertEquals(384, routing.getMinimumSurfaceCourseLength()); + assertEquals(192, routing.getMinimumUndergroundCourseLength()); + assertEquals(1.5D, routing.getValleyPreference(), 0D); + assertEquals(24D, routing.getUphillPenalty(), 0D); + assertEquals(2D, routing.getSlopePenalty(), 0D); + assertEquals(0.2D, routing.getConfluenceAttraction(), 0D); + + 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(2D, surface.getChannel().getDepth().getMin(), 0D); + assertEquals(4D, surface.getChannel().getDepth().getMax(), 0D); + assertEquals(1, surface.getChannel().getInset()); + assertEquals(10, surface.getChannel().getMaximumIncision()); + assertEquals(0.25D, surface.getChannel().getRoughness(), 0D); + assertEquals(16, surface.getChannel().getRoughnessWavelength()); + assertEquals(1, surface.getBanks().getFreeboard()); + assertEquals(1.5D, surface.getBanks().getShoreWidth(), 0D); + assertEquals(3D, surface.getBanks().getBlendSlope(), 0D); + assertEquals(4, surface.getBanks().getMinimumBlendWidth()); + assertEquals(32, surface.getBanks().getMaximumBlendWidth()); + assertTrue(surface.getBanks().isExposeCutStrata()); + assertEquals(2, surface.getFlow().getCascadeRun()); + assertEquals(6, surface.getFlow().getWaterfallMinimumDrop()); + assertEquals(1.6D, surface.getMouths().getFlareRatio(), 0D); + assertEquals(8, surface.getMouths().getMaximumOceanApron()); + 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()); + assertEquals(64, underground.getMouthLevelingDistance()); + + 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(dimension.getRiverPolicy().getBankMultiplier()); + assertNull(new IrisRegion().getRiverPolicy()); + assertNull(new IrisBiome().getRiverPolicy()); + } + + @Test + public void deserializesCanonicalDimensionRegionAndBiomeConfiguration() { + Gson gson = new Gson(); + IrisDimension dimension = gson.fromJson(""" + { + "hydrology": { + "rivers": { + "enabled": true, + "routing": { + "tileSize": 4096, + "sampleSpacing": 96, + "maximumRouteLength": 32768, + "minimumSurfaceCourseLength": 640, + "minimumUndergroundCourseLength": 480, + "maximumOutletsPerTile": 3, + "oceanOutlets": true, + "inlandOutlets": ["SINKHOLE_GROTTO"], + "valleyPreference": 2.5, + "uphillPenalty": 30, + "slopePenalty": 3, + "confluenceAttraction": 0.4 + }, + "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}, + "inset": 2, + "maximumIncision": 14, + "roughness": 0.4, + "roughnessWavelength": 24 + }, + "banks": { + "freeboard": 2, + "shoreWidth": 2.5, + "blendSlope": 4, + "minimumBlendWidth": 6, + "maximumBlendWidth": 48, + "exposeCutStrata": false + }, + "flow": {"cascadeRun": 3, "waterfallMinimumDrop": 8}, + "mouths": {"flareRatio": 2.2, "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, + "mouthLevelingDistance": 80 + }, + "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"], + "bankBiomes": ["river/bank"], + "widthMultiplier": 1.5, + "incisionMultiplier": 0.75, + "bankMultiplier": 2 + } + } + """, IrisDimension.class); + IrisRegion region = gson.fromJson(""" + {"riverPolicy": { + "placement": "TRANSIT_ONLY", + "routing": "AVOID", + "mouthBiomes": ["river/mouth"], + "routingMultiplier": 2.5 + }} + """, IrisRegion.class); + IrisBiome biome = gson.fromJson(""" + {"riverPolicy": { + "placement": "REQUIRED_HEADWATER", + "routing": "BLOCK", + "floodedCaveBiomes": [], + "depthMultiplier": 0.8 + }} + """, IrisBiome.class); + + IrisRiverHydrology rivers = dimension.getHydrology().getRivers(); + assertTrue(rivers.isEnabled()); + assertEquals(4096, rivers.getRouting().getTileSize()); + assertEquals(96, rivers.getRouting().getSampleSpacing()); + assertEquals(32768, rivers.getRouting().getMaximumRouteLength()); + assertEquals(3, rivers.getRouting().getMaximumOutletsPerTile()); + assertEquals(640, rivers.getRouting().getMinimumSurfaceCourseLength()); + assertEquals(480, rivers.getRouting().getMinimumUndergroundCourseLength()); + assertEquals(2.5D, rivers.getRouting().getValleyPreference(), 0D); + assertEquals(30D, rivers.getRouting().getUphillPenalty(), 0D); + assertEquals(3D, rivers.getRouting().getSlopePenalty(), 0D); + assertEquals(0.4D, rivers.getRouting().getConfluenceAttraction(), 0D); + assertEquals(new KList<>(IrisRiverInlandOutlet.SINKHOLE_GROTTO), rivers.getRouting().getInlandOutlets()); + assertEquals(24D, rivers.getSurface().getChannel().getWidth().getMax(), 0D); + assertEquals(2, rivers.getSurface().getChannel().getInset()); + assertEquals(14, rivers.getSurface().getChannel().getMaximumIncision()); + assertEquals(0.4D, rivers.getSurface().getChannel().getRoughness(), 0D); + assertEquals(24, rivers.getSurface().getChannel().getRoughnessWavelength()); + assertEquals(2, rivers.getSurface().getBanks().getFreeboard()); + assertEquals(2.5D, rivers.getSurface().getBanks().getShoreWidth(), 0D); + assertEquals(4D, rivers.getSurface().getBanks().getBlendSlope(), 0D); + assertEquals(6, rivers.getSurface().getBanks().getMinimumBlendWidth()); + assertEquals(48, rivers.getSurface().getBanks().getMaximumBlendWidth()); + assertFalse(rivers.getSurface().getBanks().isExposeCutStrata()); + assertEquals(3, rivers.getSurface().getFlow().getCascadeRun()); + assertEquals(8, rivers.getSurface().getFlow().getWaterfallMinimumDrop()); + assertEquals(2.2D, rivers.getSurface().getMouths().getFlareRatio(), 0D); + assertEquals(1, rivers.getSurface().getMouths().getMaximumOceanApron()); + 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(-96D, rivers.getUnderground().getFluidLevel().getMin(), 0D); + assertEquals(896, rivers.getUnderground().getSources().getMinimumSpacing()); + assertFalse(rivers.getUnderground().isConnectToExistingCaves()); + assertEquals(80, rivers.getUnderground().getMouthLevelingDistance()); + assertEquals(40, rivers.getGrottos().getCoastal().getHorizontalRadius()); + assertTrue(rivers.getGrottos().getInland().isEnabled()); + assertTrue(rivers.getGrottos().getInland().isConnectSurfaceRivers()); + assertEquals("underworld", rivers.getProfiles().getFirst().getId()); + + 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(IrisRiverPlacementMode.PREFERRED_HEADWATER, dimension.getRiverPolicy().getPlacement()); + assertEquals(IrisRiverRoutingMode.PREFER, dimension.getRiverPolicy().getRouting()); + assertFalse(dimension.getRiverPolicy().getOutletAdmission()); + assertEquals(2D, dimension.getRiverPolicy().getBankMultiplier(), 0D); + assertEquals(Set.of("river/surface", "river/shore", "river/bank"), 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/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/HydrologyCaveMatterTest.java b/core/src/test/java/art/arcane/iris/util/project/matter/slices/HydrologyCaveMatterTest.java new file mode 100644 index 000000000..d9f95fc3d --- /dev/null +++ b/core/src/test/java/art/arcane/iris/util/project/matter/slices/HydrologyCaveMatterTest.java @@ -0,0 +1,64 @@ +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 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.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class HydrologyCaveMatterTest { + @Test + public void everyActionAndBiomeRoundTrips() throws IOException { + HydrologyCaveMatter matter = new HydrologyCaveMatter(); + for (HydrologyCaveAction action : HydrologyCaveAction.values()) { + for (String profileKey : List.of("default", "underworld", "deep_lava")) { + HydrologyCaveCell expected = new HydrologyCaveCell( + action, + profileKey, + "iris:flooded_grotto" + ); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.writeNode(expected, new DataOutputStream(bytes)); + + HydrologyCaveCell actual = matter.readNode( + new DataInputStream(new ByteArrayInputStream(bytes.toByteArray()))); + + assertEquals(expected, actual); + } + } + } + + @Test + public void emptyBiomeAndInvalidActionRemainUnambiguous() throws IOException { + HydrologyCaveMatter matter = new HydrologyCaveMatter(); + HydrologyCaveCell expected = HydrologyCaveCell.of(HydrologyCaveAction.DRY_AIR); + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + matter.writeNode(expected, new DataOutputStream(bytes)); + + assertEquals(expected, matter.readNode( + new DataInputStream(new ByteArrayInputStream(bytes.toByteArray())))); + assertThrows(IOException.class, () -> matter.readNode( + new DataInputStream(new ByteArrayInputStream(new byte[]{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/probe/build.gradle b/probe/build.gradle index 027271e54..732d28431 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -48,6 +48,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') @@ -56,6 +65,25 @@ 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') +Provider probeTileX = providers.gradleProperty('probeTileX') +Provider probeTileZ = providers.gradleProperty('probeTileZ') +Provider probeOutput = providers.gradleProperty('probeOutput') tasks.register('genProbe', JavaExec) { group = 'verification' @@ -83,6 +111,149 @@ 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()) + } +} + +tasks.register('riverTransectProbe', JavaExec) { + group = 'verification' + description = 'Plans one hydrology tile over a real pack and renders every surface river as a plan, cross-sections and a summary with ocean-write and containment gates.' + mainClass = 'art.arcane.iris.probe.RiverTransectProbe' + classpath = sourceSets.main.runtimeClasspath + jvmArgs('--add-modules', 'jdk.incubator.vector', '-Djava.awt.headless=true') + dependsOn(':core:compileJava') + doFirst { + Map> required = [ + probePack: probePack, + probeDimension: probeDimension, + probeSeed: probeSeed, + probeTileX: probeTileX, + probeTileZ: probeTileZ, + probeOutput: probeOutput + ] + required.each { String name, Provider value -> + if (!value.isPresent() || value.get().isBlank()) { + throw new GradleException("riverTransectProbe requires -P${name}=") + } + } + args( + probePack.get(), + probeDimension.get(), + probeSeed.get(), + probeTileX.get(), + probeTileZ.get(), + probeOutput.get(), + probeStudio.get()) + } +} + +['hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe', 'riverTransectProbe'].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..e4e1a5288 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPackProbe.java @@ -0,0 +1,4186 @@ +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.surface.SurfaceCenterline; +import art.arcane.iris.engine.hydrology.surface.SurfaceFootprintCompiler; +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, + POOL + } + + 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, + 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 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_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 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); + } + + List failures() { + return failures(results()); + } + + static List failures(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]"); + } + return List.copyOf(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 minimumInteriorWidth = Integer.MAX_VALUE; + double maximumWidthTroughDepthRatio = 0D; + for (SeedMorphologyResult result : results()) { + courses += result.courses().size(); + unexpectedLeaves += result.unexpectedLeaves(); + for (CourseMorphologyResult course : result.courses()) { + 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_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<>(); + SurfaceCenterline raster = SurfaceCenterline.densify(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, + 0, + 0D, + 0, + violations + ); + } + double routedLength = 0D; + double lockedLength = 0D; + double longestGridLockedRun = 0D; + int isolatedTurns = 0; + ArrayList turnDegrees = new ArrayList<>(); + for (int[] run : publishedRuns) { + List smoothed = smoothCenterline(stationPoints(input, raster, run[0], run[1]), 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); + InteriorWidthMetrics interiorWidths = interiorWidthMetrics(input, raster, publishedRuns); + List expectedLeaves = publishedEndpoints(input, raster); + int unexpectedLeaves = unexpectedLeaves( + input.ownedCells(), + expectedLeaves, + stationPoints(input, raster, 0, raster.size() - 1), + 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 (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, + 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()); + SurfaceCenterline raster = SurfaceCenterline.densify(input.centerline()); + expectedLeaves.addAll(publishedEndpoints(input, raster)); + expectedCenterlines.addAll(stationPoints(input, raster, 0, raster.size() - 1)); + 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 InteriorWidthMetrics interiorWidthMetrics( + CourseMorphologyInput input, + SurfaceCenterline raster, + 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 (int[] run : publishedRuns) { + int stations = run[1] - run[0] + 1; + int firstOffset = Math.min(stations, endpointMargin); + int lastOffset = Math.max(firstOffset, stations - endpointMargin); + ArrayList widths = new ArrayList<>(); + for (int offset = firstOffset; offset < lastOffset; offset += WIDTH_SAMPLE_SPACING) { + int width = crossSectionWidth( + input.ownedCells(), + raster, + run[0] + offset, + 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, + SurfaceCenterline raster + ) { + List runs = publishedRuns(input, raster); + if (runs.isEmpty()) { + return List.of(input.source(), input.terminal()); + } + ArrayList endpoints = new ArrayList<>(runs.size() * 2); + for (int[] run : runs) { + endpoints.add(stationPoint(input, raster, run[0])); + endpoints.add(stationPoint(input, raster, run[1])); + } + return List.copyOf(endpoints); + } + + private static List publishedRuns( + CourseMorphologyInput input, + SurfaceCenterline raster + ) { + ArrayList runs = new ArrayList<>(); + int start = -1; + for (int station = 0; station < raster.size(); station++) { + if (ownedNear(input.ownedCells(), raster.x()[station], raster.z()[station], 1)) { + if (start < 0) { + start = station; + } + continue; + } + if (start >= 0 && station - start >= 2) { + runs.add(new int[]{start, station - 1}); + } + start = -1; + } + if (start >= 0 && raster.size() - start >= 2) { + runs.add(new int[]{start, raster.size() - 1}); + } + return List.copyOf(runs); + } + + private static List stationPoints( + CourseMorphologyInput input, + SurfaceCenterline raster, + int firstStation, + int lastStation + ) { + ArrayList points = new ArrayList<>(lastStation - firstStation + 1); + for (int station = firstStation; station <= lastStation; station++) { + points.add(stationPoint(input, raster, station)); + } + return List.copyOf(points); + } + + private static HydrologyPoint stationPoint( + CourseMorphologyInput input, + SurfaceCenterline raster, + int station + ) { + return new HydrologyPoint( + raster.x()[station], + input.centerline().get(raster.pathIndex()[station]).y(), + raster.z()[station] + ); + } + + // Transect over published cells; the normal comes from production SurfaceCenterline. + private static int crossSectionWidth( + Set cells, + SurfaceCenterline raster, + int station, + int maximumWidth + ) { + double centerX = raster.x()[station]; + double centerZ = raster.z()[station]; + double normalX = raster.normalX(station); + double normalZ = raster.normalZ(station); + int limit = Math.max(4, maximumWidth * 2); + HashSet sampled = new HashSet<>(); + for (int offset = -limit; offset <= limit; offset++) { + int x = (int) StrictMath.round(centerX + normalX * offset); + int z = (int) StrictMath.round(centerZ + normalZ * 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 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(", \"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 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, + centerline, + ownedCells + ); + } + + private static List surfaceCenterline(RiverCourse course) { + ArrayList points = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + if (!SurfaceFootprintCompiler.exposedSegment(segment)) { + 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 (!SurfaceFootprintCompiler.exposedSegment(segment)) { + break; + } + 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 static final double MINIMUM_MEAN_SURFACE_INCISION = 1D; + private static final int MAXIMUM_GRADED_STEP = 1; + private static final int MINIMUM_LONGEST_COURSE_SEGMENTS = 16; + + 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 fallingWaterfalls; + private int receivingBasins; + 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 shallowIncisionCourses; + private double minimumMeanSurfaceIncision = Double.POSITIVE_INFINITY; + private int oceanApronLandColumns; + private int bankStepViolations; + private int maximumBankStep; + 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 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().minimumSurfaceCourseLength() + ); + HydrologyPlannerSettings.Routing routing = settings.routing(); + int sampleSpacing = routing.sampleSpacing(); + 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); + if (degenerateTurn(previous, current, next)) { + continue; + } + double angle = turnDegrees(previous, current, next); + 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()); + observeRenderedSurfaceCourse(tile, course, routing.refinementSpacing()); + 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(); + BankContinuityMetrics bankContinuity = bankContinuityMetrics(tile.footprint()); + bankStepViolations += bankContinuity.violations(); + maximumBankStep = Math.max(maximumBankStep, bankContinuity.maximumStep()); + observeSurfaceIncision(tile, settings, ownedCompleteSurfaceCourseIds); + for (HydrologyColumnSample sample : tile.footprint().columns().values()) { + observeSurfaceHeadConflicts(sample); + observeFallingTerrainOwnership(sample, ownedCompleteSurfaceCourseIds); + observeOceanIntegrity(sample); + 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 observeSurfaceIncision( + HydrologyTile tile, + HydrologyPlannerSettings settings, + Set scopedCourseIds + ) { + List incisions = channelIncisions( + tile.footprint(), + scopedCourseIds, + settings.surface().banks().inset(), + settings.surface().maximumIncision() + ); + for (ChannelIncision incision : incisions) { + shallowSurfaceIncisionColumns += incision.shallowColumns(); + excessiveSurfaceIncisionColumns += incision.excessiveColumns(); + invalidSurfaceIncisionColumns += incision.excessiveColumns(); + minimumMeanSurfaceIncision = Math.min(minimumMeanSurfaceIncision, incision.meanIncision()); + if (!shallowMeanIncision(incision.meanIncision())) { + continue; + } + shallowIncisionCourses++; + System.out.printf( + Locale.ROOT, + "IRIS_HYDROLOGY_PACK_SURFACE_MEAN_INCISION seed=%d tile_x=%d tile_z=%d " + + "course=%016x channel_columns=%d mean_incision=%.3f minimum=%.3f%n", + tile.worldSeed(), + tile.key().tileX(), + tile.key().tileZ(), + incision.courseId(), + incision.columns(), + incision.meanIncision(), + MINIMUM_MEAN_SURFACE_INCISION + ); + } + } + + static List channelIncisions( + RiverFootprint footprint, + Set scopedCourseIds, + int minimumInset, + int maximumIncision + ) { + TreeMap totals = new TreeMap<>(); + for (HydrologyColumnSample sample : footprint.columns().values()) { + HydrologyColumnLayer layer = sample.primarySurfaceLayer().orElse(null); + if (layer == null || !layer.channel() + || !scopedCourseIds.contains(layer.feature().courseId())) { + continue; + } + int incision = sample.naturalHeight() - sample.terrainHeight(); + long[] entry = totals.computeIfAbsent( + layer.feature().courseId(), + (Long ignored) -> new long[4] + ); + entry[0] += incision; + entry[1]++; + if (incision < minimumInset) { + entry[2]++; + } else if (incision > maximumIncision) { + entry[3]++; + } + } + ArrayList incisions = new ArrayList<>(totals.size()); + for (Map.Entry entry : totals.entrySet()) { + long[] value = entry.getValue(); + incisions.add(new ChannelIncision( + entry.getKey(), + (int) value[1], + value[0] / (double) value[1], + (int) value[2], + (int) value[3] + )); + } + return List.copyOf(incisions); + } + + record ChannelIncision( + long courseId, + int columns, + double meanIncision, + int shallowColumns, + int excessiveColumns + ) { + } + + static boolean shallowMeanIncision(double meanIncision) { + return meanIncision < MINIMUM_MEAN_SURFACE_INCISION; + } + + void requireOrganicSurfaceNetwork() { + List morphologyFailures = publishedMorphology.failures(); + List gates = gates(morphologyFailures); + ArrayList failed = new ArrayList<>(); + for (Gate gate : gates) { + System.out.println("IRIS_HYDROLOGY_PACK_GATE name=" + gate.name() + + " status=" + (gate.passed() ? "PASS" : "FAIL") + " " + gate.counters()); + if (!gate.passed()) { + failed.add(gate.name()); + } + } + if (!failed.isEmpty()) { + throw new IllegalStateException("Real-pack hydrology failed gates " + failed + + ": " + morphologyFailures + " " + summary()); + } + } + + String machineLine() { + return "IRIS_HYDROLOGY_PACK_SHAPE version=15 " + summary() + + " " + publishedMorphology.summary(); + } + + List gates(List morphologyFailures) { + ArrayList gates = new ArrayList<>(); + gates.add(new Gate( + "network_presence", + surfaceEdges > 0 && surfaceNodes > 0 && verticalChanges > 0 + && longestCourse >= MINIMUM_LONGEST_COURSE_SEGMENTS && receivingBasins > 0, + String.format( + Locale.ROOT, + "surface_edges=%d surface_nodes=%d vertical_changes=%d longest_course=%d " + + "minimum_longest_course=%d receiving_basins=%d", + surfaceEdges, + surfaceNodes, + verticalChanges, + longestCourse, + MINIMUM_LONGEST_COURSE_SEGMENTS, + receivingBasins + ) + )); + gates.add(new Gate( + "channel_width", + abruptWidthChanges == 0 && maximumSurfaceWidth <= configuredMaximumSurfaceWidth, + String.format( + Locale.ROOT, + "abrupt_width_changes=%d maximum_surface_width=%d configured_maximum_surface_width=%d", + abruptWidthChanges, + maximumSurfaceWidth, + configuredMaximumSurfaceWidth + ) + )); + gates.add(new Gate( + "outlet_groups", + invalidSurfaceOutletGroups == 0 && nonCompleteSurfaceCourses == 0, + String.format( + Locale.ROOT, + "surface_outlet_groups=%d invalid_surface_outlet_groups=%d " + + "complete_surface_courses=%d non_complete_surface_courses=%d", + surfaceOutletGroups, + invalidSurfaceOutletGroups, + completeSurfaceCourses, + nonCompleteSurfaceCourses + ) + )); + gates.add(new Gate( + "terrain_support", + unsupportedSurfaceTransitions == 0 && unsupportedWaterfalls == 0 + && fallingTerrainOwnershipColumns == 0, + String.format( + Locale.ROOT, + "unsupported_surface_transitions=%d unsupported_waterfalls=%d " + + "falling_terrain_ownership_columns=%d maximum_unsupported_drop=%d", + unsupportedSurfaceTransitions, + unsupportedWaterfalls, + fallingTerrainOwnershipColumns, + maximumUnsupportedDrop + ) + )); + gates.add(new Gate( + "ocean_integrity", + oceanTerrainOwnershipColumns == 0 + && oceanTerrainMutationColumns == 0 + && submergedSurfaceTerrainOwnershipColumns == 0 + && submergedSurfaceTerrainMutationColumns == 0 + && oceanApronLandColumns == 0, + String.format( + Locale.ROOT, + "ocean_terrain_ownership_columns=%d ocean_terrain_mutation_columns=%d " + + "submerged_surface_terrain_ownership_columns=%d " + + "submerged_surface_terrain_mutation_columns=%d " + + "ocean_apron_land_columns=%d", + oceanTerrainOwnershipColumns, + oceanTerrainMutationColumns, + submergedSurfaceTerrainOwnershipColumns, + submergedSurfaceTerrainMutationColumns, + oceanApronLandColumns + ) + )); + gates.add(new Gate( + "bank_containment", + uncontainedSurfaceBankEdges == 0, + String.format( + Locale.ROOT, + "uncontained_surface_bank_edges=%d missing_surface_bank_edges=%d " + + "low_surface_bank_edges=%d", + uncontainedSurfaceBankEdges, + missingSurfaceBankEdges, + lowSurfaceBankEdges + ) + )); + gates.add(new Gate( + "bank_continuity", + bankStepViolations == 0, + String.format( + Locale.ROOT, + "bank_step_violations=%d maximum_bank_step=%d maximum_allowed_step=%d", + bankStepViolations, + maximumBankStep, + MAXIMUM_GRADED_STEP + ) + )); + gates.add(new Gate( + "surface_incision", + invalidSurfaceIncisionColumns == 0 && shallowIncisionCourses == 0, + String.format( + Locale.ROOT, + "shallow_incision_courses=%d minimum_mean_surface_incision=%.3f " + + "minimum_mean_required=%.3f shallow_surface_incision_columns=%d " + + "excessive_surface_incision_columns=%d", + shallowIncisionCourses, + finiteOrZero(minimumMeanSurfaceIncision), + MINIMUM_MEAN_SURFACE_INCISION, + shallowSurfaceIncisionColumns, + excessiveSurfaceIncisionColumns + ) + )); + gates.add(new Gate( + "head_consistency", + conflictingSurfaceHeadColumns == 0, + String.format( + Locale.ROOT, + "conflicting_surface_head_columns=%d wet_surface_columns=%d wet_surface_blocks=%d", + conflictingSurfaceHeadColumns, + wetSurfaceColumns, + wetSurfaceBlocks + ) + )); + gates.add(new Gate( + "published_morphology", + morphologyFailures.isEmpty(), + "morphology_failed_seeds=" + morphologyFailures.size() + + " " + publishedMorphology.summary() + )); + return List.copyOf(gates); + } + + record Gate(String name, boolean passed, String counters) { + } + + 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 " + + "falling_waterfalls=%d receiving_basins=%d " + + "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 " + + "shallow_incision_courses=%d " + + "minimum_mean_surface_incision=%.3f " + + "ocean_apron_land_columns=%d " + + "bank_step_violations=%d " + + "maximum_bank_step=%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, + fallingWaterfalls, + receivingBasins, + unsupportedSurfaceTransitions, + unsupportedWaterfalls, + fallingTerrainOwnershipColumns, + oceanTerrainOwnershipColumns, + oceanTerrainMutationColumns, + submergedSurfaceTerrainOwnershipColumns, + submergedSurfaceTerrainMutationColumns, + uncontainedSurfaceBankEdges, + missingSurfaceBankEdges, + lowSurfaceBankEdges, + invalidSurfaceIncisionColumns, + shallowSurfaceIncisionColumns, + excessiveSurfaceIncisionColumns, + shallowIncisionCourses, + finiteOrZero(minimumMeanSurfaceIncision), + oceanApronLandColumns, + bankStepViolations, + maximumBankStep, + 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) { + boolean ocean = sample.ocean(); + boolean naturallySubmerged = sample.naturalHeight() <= sample.seaLevel(); + if (!ocean && !naturallySubmerged) { + if (carriesOceanApron(sample)) { + oceanApronLandColumns++; + } + return; + } + boolean ownsSurfaceTerrain = ownsSurfaceTerrain(sample); + boolean terrainMutation = mutatesOceanTerrain(sample.naturalHeight(), sample.terrainHeight()); + if (ocean) { + if (ownsSurfaceTerrain) { + oceanTerrainOwnershipColumns++; + } + if (terrainMutation) { + oceanTerrainMutationColumns++; + } + } + if (naturallySubmerged) { + if (ownsSurfaceTerrain) { + submergedSurfaceTerrainOwnershipColumns++; + } + if (terrainMutation) { + submergedSurfaceTerrainMutationColumns++; + } + } + } + + static boolean ownsSurfaceTerrain(HydrologyColumnSample sample) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.feature().type().isSurface() && !layer.oceanApron() && ownsOceanTerrain(layer)) { + return true; + } + } + return false; + } + + static boolean carriesOceanApron(HydrologyColumnSample sample) { + for (HydrologyColumnLayer layer : sample.layers()) { + if (layer.oceanApron()) { + return true; + } + } + return false; + } + + static int bankContinuityViolations(RiverFootprint footprint) { + return bankContinuityMetrics(footprint).violations(); + } + + private static BankContinuityMetrics bankContinuityMetrics(RiverFootprint footprint) { + int violations = 0; + int maximumStep = 0; + int[] offsets = {1, 0, 0, 1}; + for (HydrologyColumnSample sample : footprint.columns().values()) { + if (!graded(sample)) { + continue; + } + int height = sample.terrainHeight(); + for (int offsetIndex = 0; offsetIndex < offsets.length; offsetIndex += 2) { + HydrologyColumnSample neighbor = footprint.sample( + sample.x() + offsets[offsetIndex], + sample.z() + offsets[offsetIndex + 1] + ).orElse(null); + if (neighbor == null || !graded(neighbor)) { + continue; + } + int step = Math.abs(height - neighbor.terrainHeight()); + if (step > MAXIMUM_GRADED_STEP) { + violations++; + maximumStep = Math.max(maximumStep, step); + } + } + } + return new BankContinuityMetrics(violations, maximumStep); + } + + private static boolean graded(HydrologyColumnSample sample) { + HydrologyColumnLayer layer = sample.primarySurfaceLayer().orElse(null); + return layer != null && !layer.channel() && layer.grading(); + } + + private record BankContinuityMetrics(int violations, int maximumStep) { + } + + 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() || layer.fluidOwned() || layer.grading() || layer.shore(); + } + + static boolean mutatesOceanTerrain(int naturalHeight, int terrainHeight) { + return terrainHeight < naturalHeight; + } + + 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 (!SurfaceFootprintCompiler.exposedSegment(segment)) { + 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); + if (degenerateTurn(previous, current, next)) { + continue; + } + double angle = turnDegrees(previous, current, next); + 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 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 + )); + } + + // No public production equivalent: HydrologyPlanner.routeTurnDegrees is 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)))); + } + + static boolean degenerateTurn(HydrologyPoint previous, HydrologyPoint point, HydrologyPoint next) { + return previous.x() == point.x() && previous.z() == point.z() + || next.x() == point.x() && next.z() == point.z(); + } + + private static boolean surfaceBuildRejection(HydrologyCandidateRejection rejection) { + return switch (rejection) { + case SURFACE_HEAD_RANGE, + SURFACE_HEAD_DISTRIBUTION, + 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().bankBiomeKey() + : 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; + case SURFACE_POOL -> CoverageFamily.POOL; + }; + 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..4ba6b8810 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyPlannerProbe.java @@ -0,0 +1,581 @@ +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 long COASTAL_SEED = 7014L; + static final Set REQUIRED_FEATURE_TYPES = + // The synthetic probe settings define no standing pools, so that type is not expected either. + Set.copyOf(EnumSet.complementOf(EnumSet.of(HydrologyFeatureType.RIDGE_BORE, HydrologyFeatureType.STANDING_POOL))); + 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()); + collectAccepted(firstFeatures, coastalGrottoTile()); + 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()); + collectAccepted(coverage, coastalGrottoTile()); + 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.copyOf(REQUIRED_FEATURE_TYPES); + 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( + COASTAL_SEED, + settings(false), + coastalTerrain(false) + ).plan(ORIGIN); + } + + private static HydrologyTile coastalGrottoTile() { + return new HydrologyPlanner( + COASTAL_SEED, + settings(true), + coastalTerrain(true), + solidCaveView() + ).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, + 512, + 256, + 32, + 16, + 0.5D, + 12D, + 0.5D, + 0.1D + , 1D, 0), + new HydrologyPlannerSettings.Surface( + true, + surfaceSources, + 4, + 18, + 2, + 4, + 10, + 1.5D, + HydrologyPlannerSettings.Banks.defaults() + ), + new HydrologyPlannerSettings.Hydraulics(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(), 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water"), List.of() + ); + }; + } + + private static HydrologyTerrainSampler coastalTerrain(boolean cliffCoast) { + return (int x, int z) -> { + if (x >= 208) { + return oceanTerrain(); + } + int height; + double slope; + if (x < 104) { + height = 100 - Math.floorDiv(x, 8); + slope = 1D; + } else if (cliffCoast) { + height = 84 - Math.floorDiv(x - 104, 26); + slope = 1D; + } else { + height = 79 - Math.floorDiv(x - 104, 12); + slope = x < 112 ? 4D : 1D; + } + boolean surfaceSource = x <= 40; + boolean undergroundSource = x <= 64; + return new HydrologyTerrainSample( + height, + slope, + false, + true, + height - 30, + height - 28, + true, + true, + surfaceSource, + surfaceSource, + undergroundSource, + undergroundSource, + 0D, + surfaceSource ? 1D : 0D, + undergroundSource ? 1D : 0D, + 1D, + 1D, + 1D, + 1D, + 1D, + "parent", + "surface", + "mouth", + "shore", + "bank", + "flooded", + List.of("water"), List.of() + ); + }; + } + + 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, + 1_089, + 256, + 32, + 16, + 0.5D, + 12D, + 0.5D, + 0.2D + , 1D, 0), + new HydrologyPlannerSettings.Surface( + true, + surfaceSources, + 4, + 20, + 2, + 5, + 12, + 1.5D, + HydrologyPlannerSettings.Banks.defaults() + ), + new HydrologyPlannerSettings.Hydraulics(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), List.of() + ); + } + + 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, + 1D, + "parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water"), List.of() + ); + }; + } + + 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, + 1D, + "ocean_parent", + "surface", + "mouth", + "shore", + "dry", + "flooded", + List.of("water"), List.of() + ); + } + + 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/RiverTransectProbe.java b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java new file mode 100644 index 000000000..559a8fe74 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java @@ -0,0 +1,695 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.DrainageEdge; +import art.arcane.iris.engine.hydrology.DrainageNode; +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyCandidateKind; +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.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.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.hydrology.surface.SurfaceCenterline; +import art.arcane.iris.engine.hydrology.surface.SurfaceFootprintCompiler; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +/** + * Plans one hydrology tile over a real pack and renders every exposed surface course as a + * top-down plan plus five cross-sections, with a summary of cut depth, bank steps, ocean writes + * and spilling channel cells. Exit code 1 when any course writes the ocean or spills. + */ +public final class RiverTransectProbe { + static final int NO_WATER = Integer.MIN_VALUE; + private static final String PREFIX = "[transect]"; + private static final int MARGIN = 48; + private static final int HALF_SECTION = 40; + private static final int SECTIONS = 5; + private static final int SECTION_SCALE = 4; + private static final int SECTION_ROW_HEIGHT = 120; + private static final int MAXIMUM_PLAN_COLUMNS = 6_000_000; + private static final int MAXIMUM_DETAILS = 16; + private static final int TABLE_HALF_SECTION = 16; + private static final int[] HEADWATER_SECTIONS = {0, 6, 12, 18, 24}; + + private RiverTransectProbe() { + } + + enum Role { + NONE, + CHANNEL, + SHORE, + BANK, + APRON; + + boolean owned() { + return this == CHANNEL || this == SHORE || this == BANK; + } + } + + record ColumnView(int x, int z, int natural, int terrain, int water, Role role) { + int cut() { + return natural - terrain; + } + } + + record CourseSummary( + long id, + int stations, + int ownedColumns, + int minimumCut, + int maximumCut, + int maximumBankStep, + int oceanWrites, + int uncontainedWetCells, + List details + ) { + boolean passes() { + return oceanWrites == 0 && uncontainedWetCells == 0; + } + + String line() { + return String.format(Locale.ROOT, + "%s course=%d %s stations=%d owned=%d cut=%d..%d bankStep=%d oceanWrites=%d uncontained=%d", + PREFIX, id, passes() ? "PASS" : "FAIL", stations, ownedColumns, minimumCut, maximumCut, + maximumBankStep, oceanWrites, uncontainedWetCells); + } + } + + record Configuration(File pack, String dimension, long seed, int tileX, int tileZ, File output, boolean studio) { + static Configuration parse(String[] args) { + if (args.length < 6 || args.length > 7) { + throw new IllegalArgumentException( + "usage: [studio]"); + } + boolean studio = args.length == 7 && RealPackProbeSupport.parseBoolean(args[6], "studio"); + return new Configuration( + new File(args[0]), + args[1], + Long.parseLong(args[2].trim()), + Integer.parseInt(args[3].trim()), + Integer.parseInt(args[4].trim()), + new File(args[5]), + studio + ); + } + } + + private record Bounds(int minimumX, int minimumZ, int maximumX, int maximumZ) { + static Bounds of(SurfaceCenterline centerline, int margin) { + int minimumX = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (int station = 0; station < centerline.size(); station++) { + minimumX = Math.min(minimumX, centerline.x()[station]); + minimumZ = Math.min(minimumZ, centerline.z()[station]); + maximumX = Math.max(maximumX, centerline.x()[station]); + maximumZ = Math.max(maximumZ, centerline.z()[station]); + } + return new Bounds(minimumX - margin, minimumZ - margin, maximumX + margin, maximumZ + margin); + } + + int width() { + return maximumX - minimumX + 1; + } + + int depth() { + return maximumZ - minimumZ + 1; + } + } + + public static void main(String[] args) { + Configuration configuration; + try { + configuration = Configuration.parse(args); + } catch (Throwable failure) { + System.out.println(PREFIX + " FAIL: " + failure.getMessage()); + System.exit(2); + return; + } + int exitCode; + try { + exitCode = run(configuration) ? 0 : 1; + } catch (Throwable failure) { + System.out.println(PREFIX + " FAIL: probe execution failed"); + failure.printStackTrace(System.out); + exitCode = 2; + } + System.exit(exitCode); + } + + static boolean run(Configuration configuration) throws Exception { + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + configuration.pack(), configuration.dimension(), PREFIX); + RealPackProbeSupport.EngineSession session = workspace.openEngine( + configuration.seed(), configuration.studio(), "transect")) { + Engine engine = session.engine(); + IrisComplex complex = engine.getComplex(); + IrisHydrologyRuntime runtime = complex.getHydrologyRuntime(); + if (runtime == null) { + throw new IllegalStateException("Dimension '" + configuration.dimension() + "' has no hydrology runtime."); + } + Files.createDirectories(configuration.output().toPath()); + HydrologyTileKey key = new HydrologyTileKey(configuration.tileX(), configuration.tileZ()); + long planStart = System.nanoTime(); + HydrologyTile tile = runtime.tile(key); + double planMillis = (System.nanoTime() - planStart) / 1_000_000D; + int seaLevel = runtime.settings().seaLevel(); + System.out.println(String.format(Locale.ROOT, + "%s tile=%d,%d seed=%d courses=%d planMs=%.1f", + PREFIX, key.tileX(), key.tileZ(), configuration.seed(), tile.courses().size(), planMillis)); + + for (String line : rejectionLines(tile)) { + System.out.println(PREFIX + " " + line); + } + writeTileDiagnostics(new File(configuration.output(), "tile-diagnostics.png"), tile, complex, runtime, seaLevel); + List summaries = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() != RiverCourseType.SURFACE && course.type() != RiverCourseType.SURFACE_POOL) { + continue; + } + List exposedPath = exposedPath(course); + if (exposedPath.size() < 2) { + continue; + } + SurfaceCenterline centerline = SurfaceCenterline.densify(exposedPath); + Bounds bounds = Bounds.of(centerline, MARGIN); + Map columns = sampleColumns(complex, runtime, bounds); + CourseSummary summary = summarize(course.id(), centerline.size(), seaLevel, columns); + summaries.add(summary); + writePlan(new File(configuration.output(), "course-" + course.id() + ".png"), bounds, columns); + writeSections(new File(configuration.output(), "course-" + course.id() + "-sections.png"), + centerline, complex, runtime); + writeSectionTable(new File(configuration.output(), "course-" + course.id() + "-sections.txt"), + centerline, complex, runtime); + System.out.println(summary.line()); + for (String detail : summary.details()) { + System.out.println(PREFIX + " " + detail); + } + } + writeSummary(new File(configuration.output(), "summary.txt"), configuration, tile, summaries); + boolean pass = summaries.stream().allMatch(CourseSummary::passes); + System.out.println(PREFIX + " " + (pass ? "PASS" : "FAIL") + + " surfaceCourses=" + summaries.size() + + " output=" + configuration.output().getAbsolutePath()); + return pass; + } + } + + static List rejectionLines(HydrologyTile tile) { + TreeMap counts = new TreeMap<>(); + TreeMap> details = new TreeMap<>(); + for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + String key = "rejected " + candidate.kind() + " " + candidate.projectedType() + " " + candidate.rejection(); + counts.merge(key, 1, Integer::sum); + if (candidate.detail() != 0) { + details.computeIfAbsent(key, (String ignored) -> new ArrayList<>()).add(candidate.detail()); + } + } + ArrayList lines = new ArrayList<>(counts.size()); + for (Map.Entry entry : counts.entrySet()) { + ArrayList values = details.get(entry.getKey()); + String suffix = ""; + if (values != null) { + values.sort(null); + suffix = " detail=" + values.subList(0, Math.min(values.size(), 24)); + } + lines.add(entry.getKey() + " x" + entry.getValue() + suffix); + } + return lines; + } + + static int[] sectionStations(int stations) { + int[] selected = new int[SECTIONS]; + for (int section = 0; section < SECTIONS; section++) { + double fraction = 0.1D + 0.2D * section; + selected[section] = Math.max(0, Math.min(stations - 1, (int) Math.floor(stations * fraction))); + } + return selected; + } + + static CourseSummary summarize(long id, int stations, int seaLevel, Map columns) { + int owned = 0; + int minimumCut = Integer.MAX_VALUE; + int maximumCut = Integer.MIN_VALUE; + int maximumBankStep = 0; + int oceanWrites = 0; + int uncontained = 0; + ArrayList details = new ArrayList<>(); + String worstStep = null; + ArrayList ordered = new ArrayList<>(columns.values()); + ordered.sort(Comparator.comparingInt(ColumnView::z).thenComparingInt(ColumnView::x)); + for (ColumnView column : ordered) { + boolean submerged = column.natural() <= seaLevel; + if (submerged && (column.terrain() != column.natural() || column.role().owned())) { + oceanWrites++; + if (details.size() < MAXIMUM_DETAILS) { + details.add("oceanWrite " + column.x() + "," + column.z() + + " natural=" + column.natural() + " terrain=" + column.terrain() + " role=" + column.role()); + } + } + if (!column.role().owned()) { + continue; + } + owned++; + minimumCut = Math.min(minimumCut, column.cut()); + maximumCut = Math.max(maximumCut, column.cut()); + if (column.role() == Role.CHANNEL) { + if (column.water() != NO_WATER && spills(column, columns)) { + uncontained++; + if (details.size() < MAXIMUM_DETAILS) { + details.add("uncontained " + column.x() + "," + column.z() + + " water=" + column.water() + " terrain=" + column.terrain() + + " natural=" + column.natural() + " neighbours=" + neighbourHeights(column, columns)); + } + } + continue; + } + for (int[] offset : new int[][] {{1, 0}, {0, 1}}) { + ColumnView neighbour = columns.get(RiverFootprint.pack(column.x() + offset[0], column.z() + offset[1])); + int step = bankStep(column, neighbour); + if (step > maximumBankStep) { + maximumBankStep = step; + worstStep = "bankStep " + step + " at " + column.x() + "," + column.z() + + " (" + column.role() + " terrain=" + column.terrain() + " natural=" + column.natural() + + ") vs " + neighbour.x() + "," + neighbour.z() + + " (" + neighbour.role() + " terrain=" + neighbour.terrain() + " natural=" + neighbour.natural() + ")"; + } + } + } + if (worstStep != null) { + details.add(worstStep); + } + if (owned == 0) { + minimumCut = 0; + maximumCut = 0; + } + return new CourseSummary(id, stations, owned, minimumCut, maximumCut, maximumBankStep, oceanWrites, uncontained, List.copyOf(details)); + } + + private static String neighbourHeights(ColumnView channel, Map columns) { + StringBuilder text = new StringBuilder(); + for (int[] offset : new int[][] {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}) { + ColumnView neighbour = columns.get(RiverFootprint.pack(channel.x() + offset[0], channel.z() + offset[1])); + if (!text.isEmpty()) { + text.append(' '); + } + text.append(neighbour == null ? "?" : neighbour.role() + ":" + neighbour.terrain()); + } + return text.toString(); + } + + private static boolean spills(ColumnView channel, Map columns) { + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + for (int[] offset : offsets) { + ColumnView neighbour = columns.get(RiverFootprint.pack(channel.x() + offset[0], channel.z() + offset[1])); + if (neighbour == null || neighbour.role() == Role.CHANNEL) { + continue; + } + if (neighbour.terrain() < channel.water()) { + return true; + } + } + return false; + } + + private static int bankStep(ColumnView bank, ColumnView neighbour) { + if (neighbour == null || neighbour.role() == Role.CHANNEL) { + return 0; + } + return Math.abs(bank.terrain() - neighbour.terrain()); + } + + private static List exposedPath(RiverCourse course) { + ArrayList path = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + if (!SurfaceFootprintCompiler.exposedSegment(segment)) { + break; + } + for (HydrologyPoint point : segment.centerline()) { + if (!path.isEmpty()) { + HydrologyPoint last = path.getLast(); + if (last.x() == point.x() && last.z() == point.z()) { + continue; + } + } + path.add(point); + } + } + return path; + } + + private static Map sampleColumns(IrisComplex complex, IrisHydrologyRuntime runtime, Bounds bounds) { + long area = (long) bounds.width() * bounds.depth(); + if (area > MAXIMUM_PLAN_COLUMNS) { + throw new IllegalStateException("Course bounding box too large to render: " + area + " columns."); + } + HashMap columns = new HashMap<>((int) Math.min(Integer.MAX_VALUE, area * 2)); + for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) { + for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) { + columns.put(RiverFootprint.pack(x, z), column(complex, runtime, x, z)); + } + } + return columns; + } + + private static ColumnView column(IrisComplex complex, IrisHydrologyRuntime runtime, int x, int z) { + Optional sample = runtime.sample(x, z); + int natural = sample.map(HydrologyColumnSample::naturalHeight) + .orElseGet(() -> (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z))); + int terrain = (int) Math.round(complex.getHeightStream().getDouble(x, z)); + HydrologyColumnLayer surface = sample.flatMap(HydrologyColumnSample::primarySurfaceLayer).orElse(null); + HydrologyColumnLayer fluid = sample.flatMap(HydrologyColumnSample::primarySurfaceFluidLayer).orElse(null); + int water = fluid == null ? NO_WATER : fluid.fluidHeadY(); + return new ColumnView(x, z, natural, terrain, water, role(surface)); + } + + private static Role role(HydrologyColumnLayer layer) { + if (layer == null) { + return Role.NONE; + } + if (layer.oceanApron()) { + return Role.APRON; + } + if (layer.channel()) { + return Role.CHANNEL; + } + if (layer.shore()) { + return Role.SHORE; + } + return layer.terrainOwned() ? Role.BANK : Role.NONE; + } + + /** + * One pixel per four blocks over the tile: natural height in gray, sea in blue, courses in white, + * diagnostic candidates as dots coloured by their rejection. + */ + private static void writeTileDiagnostics( + File file, + HydrologyTile tile, + IrisComplex complex, + IrisHydrologyRuntime runtime, + int seaLevel + ) throws IOException { + int scale = 4; + int tileSize = tile.tileSize(); + int size = tileSize / scale; + int minimumX = tile.key().minimumBlockX(tileSize); + int minimumZ = tile.key().minimumBlockZ(tileSize); + int[][] natural = new int[size][size]; + int lowest = Integer.MAX_VALUE; + int highest = Integer.MIN_VALUE; + for (int pz = 0; pz < size; pz++) { + for (int px = 0; px < size; px++) { + int height = (int) Math.round(complex.getNaturalHeightStream().getDouble(minimumX + px * scale, minimumZ + pz * scale)); + natural[px][pz] = height; + lowest = Math.min(lowest, height); + highest = Math.max(highest, height); + } + } + double range = Math.max(1, highest - lowest); + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB); + for (int pz = 0; pz < size; pz++) { + for (int px = 0; px < size; px++) { + int height = natural[px][pz]; + int rgb; + if (height <= seaLevel) { + rgb = 0x1E3F8A; + } else { + int gray = 40 + (int) Math.round(190D * (height - lowest) / range); + rgb = (gray << 16) | (gray << 8) | gray; + } + image.setRGB(px, pz, rgb); + } + } + Map nodes = new HashMap<>(); + for (DrainageNode node : tile.nodes()) { + nodes.put(node.id(), node); + plot(image, (node.naturalPoint().x() - minimumX) / scale, (node.naturalPoint().z() - minimumZ) / scale, 0x00E0E0, 0); + } + for (DrainageEdge edge : tile.edges()) { + DrainageNode from = nodes.get(edge.upstreamNodeId()); + DrainageNode to = nodes.get(edge.downstreamNodeId()); + if (from == null || to == null) { + continue; + } + line(image, (from.naturalPoint().x() - minimumX) / scale, (from.naturalPoint().z() - minimumZ) / scale, + (to.naturalPoint().x() - minimumX) / scale, (to.naturalPoint().z() - minimumZ) / scale, 0x20C020); + } + for (RiverCourse course : tile.courses()) { + int rgb = course.type() == RiverCourseType.SURFACE ? 0xFFFFFF : 0xFF00FF; + for (HydraulicSegment segment : course.segments()) { + for (HydrologyPoint point : segment.centerline()) { + plot(image, (point.x() - minimumX) / scale, (point.z() - minimumZ) / scale, rgb, 1); + } + } + } + for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) { + int rgb = switch (candidate.rejection()) { + case NO_DRAINAGE_PATH -> 0xFF2020; + case COURSE_TOO_SHORT -> 0xFFB000; + case SOURCE_SPACING -> 0xFFFF40; + case SURFACE_CORRIDOR_UNSUPPORTED -> 0xFF60FF; + case OUTLET_LIMIT -> 0x20FF20; + default -> 0x00FFFF; + }; + int radius = candidate.kind() == HydrologyCandidateKind.OUTLET ? 1 : 2; + plot(image, (candidate.point().x() - minimumX) / scale, (candidate.point().z() - minimumZ) / scale, rgb, radius); + } + ImageIO.write(image, "png", file); + } + + private static void line(BufferedImage image, int x0, int z0, int x1, int z1, int rgb) { + int steps = Math.max(1, Math.max(Math.abs(x1 - x0), Math.abs(z1 - z0))); + for (int step = 0; step <= steps; step++) { + int x = x0 + (x1 - x0) * step / steps; + int z = z0 + (z1 - z0) * step / steps; + if (x >= 0 && z >= 0 && x < image.getWidth() && z < image.getHeight()) { + image.setRGB(x, z, rgb); + } + } + } + + private static void plot(BufferedImage image, int x, int z, int rgb, int radius) { + for (int dz = -radius; dz <= radius; dz++) { + for (int dx = -radius; dx <= radius; dx++) { + int px = x + dx; + int pz = z + dz; + if (px >= 0 && pz >= 0 && px < image.getWidth() && pz < image.getHeight()) { + image.setRGB(px, pz, rgb); + } + } + } + } + + private static void writePlan(File file, Bounds bounds, Map columns) throws IOException { + int minimumNatural = Integer.MAX_VALUE; + int maximumNatural = Integer.MIN_VALUE; + for (ColumnView column : columns.values()) { + minimumNatural = Math.min(minimumNatural, column.natural()); + maximumNatural = Math.max(maximumNatural, column.natural()); + } + double range = Math.max(1, maximumNatural - minimumNatural); + BufferedImage image = new BufferedImage(bounds.width(), bounds.depth(), BufferedImage.TYPE_INT_RGB); + for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) { + for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) { + ColumnView column = columns.get(RiverFootprint.pack(x, z)); + int gray = 40 + (int) Math.round(190D * (column.natural() - minimumNatural) / range); + int red = gray; + int green = gray; + int blue = gray; + switch (column.role()) { + case CHANNEL -> { + red = 30; + green = 90; + blue = 220; + } + case SHORE -> { + red = 214; + green = 184; + blue = 122; + } + case BANK -> { + int darken = Math.min(120, column.cut() * 12); + red = Math.max(0, gray - darken + 20); + green = Math.max(0, gray - darken); + blue = Math.max(0, gray - darken - 20); + } + case APRON -> { + red = 120; + green = 170; + blue = 230; + } + case NONE -> { + if (column.terrain() != column.natural()) { + red = 220; + green = 40; + blue = 40; + } + } + } + image.setRGB(x - bounds.minimumX(), z - bounds.minimumZ(), (red << 16) | (green << 8) | blue); + } + } + ImageIO.write(image, "png", file); + } + + private static void writeSections( + File file, + SurfaceCenterline centerline, + IrisComplex complex, + IrisHydrologyRuntime runtime + ) throws IOException { + int[] stations = sectionStations(centerline.size()); + int span = HALF_SECTION * 2 + 1; + int width = span * SECTION_SCALE; + BufferedImage image = new BufferedImage(width, SECTION_ROW_HEIGHT * SECTIONS, BufferedImage.TYPE_INT_RGB); + for (int section = 0; section < SECTIONS; section++) { + int station = stations[section]; + double normalX = centerline.normalX(station); + double normalZ = centerline.normalZ(station); + ColumnView[] profile = new ColumnView[span]; + int minimum = Integer.MAX_VALUE; + int maximum = Integer.MIN_VALUE; + for (int offset = -HALF_SECTION; offset <= HALF_SECTION; offset++) { + int x = (int) Math.round(centerline.x()[station] + normalX * offset); + int z = (int) Math.round(centerline.z()[station] + normalZ * offset); + ColumnView column = column(complex, runtime, x, z); + profile[offset + HALF_SECTION] = column; + minimum = Math.min(minimum, Math.min(column.natural(), column.terrain())); + maximum = Math.max(maximum, Math.max(column.natural(), column.terrain())); + if (column.water() != NO_WATER) { + maximum = Math.max(maximum, column.water()); + } + } + int rowTop = section * SECTION_ROW_HEIGHT; + fill(image, 0, rowTop, width, SECTION_ROW_HEIGHT, 0x1E1E1E); + double scale = (SECTION_ROW_HEIGHT - 12D) / Math.max(1, maximum - minimum + 1); + for (int index = 0; index < span; index++) { + ColumnView column = profile[index]; + int left = index * SECTION_SCALE; + int naturalY = rowY(rowTop, column.natural(), minimum, scale); + int terrainY = rowY(rowTop, column.terrain(), minimum, scale); + fill(image, left, naturalY, SECTION_SCALE, rowTop + SECTION_ROW_HEIGHT - naturalY, 0x555555); + fill(image, left, terrainY, SECTION_SCALE, rowTop + SECTION_ROW_HEIGHT - terrainY, 0x9A7B4F); + if (column.water() != NO_WATER && column.water() >= column.terrain()) { + int waterY = rowY(rowTop, column.water(), minimum, scale); + fill(image, left, waterY, SECTION_SCALE, Math.max(1, terrainY - waterY), 0x2E6BE6); + } + if (column.role() == Role.SHORE) { + fill(image, left, terrainY - 2, SECTION_SCALE, 2, 0xD6B87A); + } + } + fill(image, HALF_SECTION * SECTION_SCALE, rowTop, 1, 6, 0xFFFFFF); + } + ImageIO.write(image, "png", file); + } + + private static void writeSectionTable( + File file, + SurfaceCenterline centerline, + IrisComplex complex, + IrisHydrologyRuntime runtime + ) throws IOException { + int[] spaced = sectionStations(centerline.size()); + int[] stations = new int[HEADWATER_SECTIONS.length + spaced.length]; + for (int index = 0; index < HEADWATER_SECTIONS.length; index++) { + stations[index] = Math.min(centerline.size() - 1, HEADWATER_SECTIONS[index]); + } + System.arraycopy(spaced, 0, stations, HEADWATER_SECTIONS.length, spaced.length); + StringBuilder text = new StringBuilder(); + for (int station : stations) { + text.append("station ").append(station) + .append(" at ").append(centerline.x()[station]).append(',').append(centerline.z()[station]) + .append(" head=").append(column(complex, runtime, centerline.x()[station], centerline.z()[station]).water()) + .append('\n'); + text.append("offset natural terrain water role\n"); + double normalX = centerline.normalX(station); + double normalZ = centerline.normalZ(station); + for (int offset = -TABLE_HALF_SECTION; offset <= TABLE_HALF_SECTION; offset++) { + int x = (int) Math.round(centerline.x()[station] + normalX * offset); + int z = (int) Math.round(centerline.z()[station] + normalZ * offset); + ColumnView column = column(complex, runtime, x, z); + int streamNatural = (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z)); + text.append(String.format(Locale.ROOT, "%4d %4d %4d %5s %s%s\n", + offset, column.natural(), column.terrain(), + column.water() == NO_WATER ? "-" : Integer.toString(column.water()), column.role(), + streamNatural == column.natural() ? "" : " stream=" + streamNatural)); + } + } + Files.writeString(file.toPath(), text.toString(), StandardCharsets.UTF_8); + } + + private static int rowY(int rowTop, int height, int minimum, double scale) { + return rowTop + SECTION_ROW_HEIGHT - 6 - (int) Math.round((height - minimum + 1) * scale); + } + + private static void fill(BufferedImage image, int left, int top, int width, int height, int rgb) { + int right = Math.min(image.getWidth(), left + width); + int bottom = Math.min(image.getHeight(), top + height); + for (int y = Math.max(0, top); y < bottom; y++) { + for (int x = Math.max(0, left); x < right; x++) { + image.setRGB(x, y, rgb); + } + } + } + + private static void writeSummary( + File file, + Configuration configuration, + HydrologyTile tile, + List summaries + ) throws IOException { + StringBuilder text = new StringBuilder(); + text.append("tile=").append(tile.key().tileX()).append(',').append(tile.key().tileZ()) + .append(" seed=").append(configuration.seed()) + .append(" dimension=").append(configuration.dimension()) + .append(" courses=").append(tile.courses().size()) + .append(" surfaceCourses=").append(summaries.size()) + .append('\n'); + for (String line : rejectionLines(tile)) { + text.append(line).append('\n'); + } + int maximumCut = 0; + int maximumBankStep = 0; + int oceanWrites = 0; + int uncontained = 0; + for (CourseSummary summary : summaries) { + text.append(summary.line()).append('\n'); + for (String detail : summary.details()) { + text.append(" ").append(detail).append('\n'); + } + maximumCut = Math.max(maximumCut, summary.maximumCut()); + maximumBankStep = Math.max(maximumBankStep, summary.maximumBankStep()); + oceanWrites += summary.oceanWrites(); + uncontained += summary.uncontainedWetCells(); + } + text.append("maximumCut=").append(maximumCut) + .append(" maximumBankStep=").append(maximumBankStep) + .append(" oceanWrites=").append(oceanWrites) + .append(" uncontainedWetCells=").append(uncontained) + .append('\n'); + Files.writeString(file.toPath(), text.toString(), StandardCharsets.UTF_8); + } +} 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..5e6bab082 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyPackProbeTest.java @@ -0,0 +1,1066 @@ +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) + )); + + 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) + )); + + assertTrue(result.isolatedTurns() > 0); + assertTrue(result.p95TurnDegrees() > 25D); + assertTrue(result.violations().contains("ISOLATED_TURN")); + assertTrue(result.violations().contains("P95_TURN")); + } + + @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) + )); + + 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) + )); + + 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 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) + )); + + 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, + centerline, + terminalBasinChannel(centerline, 1, 2, 32, true) + ); + HydrologyPackProbe.CourseMorphologyResult result = + HydrologyPackProbe.PublishedMorphologyMetrics.analyzeCourse(input); + + assertEquals(0, result.minimumInteriorWidth()); + assertEquals(0D, result.maximumWidthTroughDepthRatio(), 0D); + 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)) + ); + + 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)); + assertTrue(HydrologyPackProbe.ShapeMetrics.ownsOceanTerrain(gradedLayer(70))); + assertFalse(HydrologyPackProbe.ShapeMetrics.mutatesOceanTerrain(54, 54)); + assertTrue(HydrologyPackProbe.ShapeMetrics.mutatesOceanTerrain(54, 48)); + assertFalse(HydrologyPackProbe.ShapeMetrics.mutatesOceanTerrain(54, 60)); + } + + @Test + public void oceanIntegrityFlagsOwningSurfaceLayersAndCutTerrain() { + HydrologyColumnSample untouchedOcean = new HydrologyColumnSample( + 0, 0, 48, 63, true, "parent", List.of(surfaceLayer(true, false))); + HydrologyColumnSample cutLand = new HydrologyColumnSample( + 0, 0, 80, 63, false, "parent", List.of(gradedLayer(74))); + + assertFalse(HydrologyPackProbe.ShapeMetrics.ownsSurfaceTerrain(untouchedOcean)); + assertTrue(HydrologyPackProbe.ShapeMetrics.carriesOceanApron(untouchedOcean)); + assertEquals(48, untouchedOcean.terrainHeight()); + assertFalse(HydrologyPackProbe.ShapeMetrics.mutatesOceanTerrain( + untouchedOcean.naturalHeight(), untouchedOcean.terrainHeight())); + assertTrue(HydrologyPackProbe.ShapeMetrics.ownsSurfaceTerrain(cutLand)); + assertTrue(HydrologyPackProbe.ShapeMetrics.mutatesOceanTerrain( + cutLand.naturalHeight(), cutLand.terrainHeight())); + assertThrows(IllegalArgumentException.class, () -> new HydrologyColumnSample( + 0, 0, 60, 63, false, "parent", List.of(gradedLayer(55)))); + } + + @Test + public void landColumnsCarryingAnOceanApronAreDetected() { + HydrologyColumnSample land = new HydrologyColumnSample( + 0, 0, 80, 63, false, "parent", List.of(surfaceLayer(true, false))); + + assertTrue(HydrologyPackProbe.ShapeMetrics.carriesOceanApron(land)); + assertFalse(HydrologyPackProbe.ShapeMetrics.ownsSurfaceTerrain(land)); + } + + @Test + public void bankContinuityAllowsSingleBlockStepsAndRejectsLedges() { + assertEquals(0, HydrologyPackProbe.ShapeMetrics.bankContinuityViolations( + gradedRow(70, 71, 72))); + assertEquals(1, HydrologyPackProbe.ShapeMetrics.bankContinuityViolations( + gradedRow(70, 71, 74))); + } + + @Test + public void bankContinuityIgnoresPairsAcrossTheWaterEdge() { + HydrologyColumnSample west = gradedColumn(-1, 0, 71); + HydrologyColumnSample channel = channelColumn(0, 0, 66, 69); + HydrologyColumnSample east = gradedColumn(1, 0, 78); + + assertEquals(0, HydrologyPackProbe.ShapeMetrics.bankContinuityViolations( + new RiverFootprint(Map.of( + RiverFootprint.pack(west.x(), west.z()), west, + RiverFootprint.pack(channel.x(), channel.z()), channel, + RiverFootprint.pack(east.x(), east.z()), east + )))); + } + + @Test + public void shapeMetricsEmitFormattedMachineAndGateLines() { + HydrologyPackProbe.ShapeMetrics metrics = new HydrologyPackProbe.ShapeMetrics(); + + String machineLine = metrics.machineLine(); + + assertTrue(machineLine.startsWith("IRIS_HYDROLOGY_PACK_SHAPE version=15 ")); + assertTrue(machineLine.contains("shallow_incision_courses=0")); + assertTrue(machineLine.contains("ocean_apron_land_columns=0")); + assertTrue(machineLine.contains("bank_step_violations=0")); + } + + @Test + public void emptyShapeMetricsReportOneLinePerGate() { + HydrologyPackProbe.ShapeMetrics metrics = new HydrologyPackProbe.ShapeMetrics(); + + List gates = metrics.gates(List.of()); + + List names = new ArrayList<>(); + for (HydrologyPackProbe.ShapeMetrics.Gate gate : gates) { + names.add(gate.name()); + } + assertEquals( + List.of( + "network_presence", + "channel_width", + "outlet_groups", + "terrain_support", + "ocean_integrity", + "bank_containment", + "bank_continuity", + "surface_incision", + "head_consistency", + "published_morphology" + ), + names + ); + for (HydrologyPackProbe.ShapeMetrics.Gate gate : gates) { + assertFalse(gate.counters().isBlank()); + } + assertFalse(gate(gates, "network_presence").passed()); + assertTrue(gate(gates, "ocean_integrity").passed()); + assertTrue(gate(gates, "bank_continuity").passed()); + assertTrue(gate(gates, "surface_incision").passed()); + assertTrue(gate(gates, "ocean_integrity").counters().contains("ocean_apron_land_columns=0")); + assertTrue(gate(gates, "bank_continuity").counters().contains("bank_step_violations=0")); + assertTrue(gate(gates, "surface_incision").counters().contains("shallow_incision_courses=0")); + } + + @Test + public void gateFailuresNameEveryFailedGate() { + HydrologyPackProbe.ShapeMetrics metrics = new HydrologyPackProbe.ShapeMetrics(); + + IllegalStateException failure = assertThrows( + IllegalStateException.class, + metrics::requireOrganicSurfaceNetwork + ); + + assertTrue(failure.getMessage().contains("network_presence")); + assertTrue(failure.getMessage().contains("published_morphology")); + } + + private static HydrologyPackProbe.ShapeMetrics.Gate gate( + List gates, + String name + ) { + for (HydrologyPackProbe.ShapeMetrics.Gate gate : gates) { + if (gate.name().equals(name)) { + return gate; + } + } + throw new IllegalStateException("Missing gate " + name + "."); + } + + @Test + public void channelIncisionAveragesNaturalMinusTerrainOverChannelColumns() { + HydrologyColumnSample deep = channelColumn(0, 0, 74, 77); + HydrologyColumnSample shallow = channelColumn(1, 0, 79, 79); + HydrologyColumnSample bank = gradedColumn(2, 0, 79); + RiverFootprint footprint = new RiverFootprint(Map.of( + RiverFootprint.pack(deep.x(), deep.z()), deep, + RiverFootprint.pack(shallow.x(), shallow.z()), shallow, + RiverFootprint.pack(bank.x(), bank.z()), bank + )); + + List incisions = + HydrologyPackProbe.ShapeMetrics.channelIncisions(footprint, Set.of(42L), 2, 10); + + assertEquals(1, incisions.size()); + HydrologyPackProbe.ShapeMetrics.ChannelIncision incision = incisions.getFirst(); + assertEquals(42L, incision.courseId()); + assertEquals(2, incision.columns()); + assertEquals(3.5D, incision.meanIncision(), 0D); + assertEquals(1, incision.shallowColumns()); + assertEquals(0, incision.excessiveColumns()); + assertFalse(HydrologyPackProbe.ShapeMetrics.shallowMeanIncision(incision.meanIncision())); + } + + @Test + public void channelIncisionBelowOneBlockFailsTheShallowGate() { + HydrologyColumnSample flat = channelColumn(0, 0, 80, 80); + RiverFootprint footprint = new RiverFootprint(Map.of( + RiverFootprint.pack(flat.x(), flat.z()), flat + )); + + List incisions = + HydrologyPackProbe.ShapeMetrics.channelIncisions(footprint, Set.of(42L), 2, 10); + + assertEquals(0D, incisions.getFirst().meanIncision(), 0D); + assertTrue(HydrologyPackProbe.ShapeMetrics.shallowMeanIncision(incisions.getFirst().meanIncision())); + } + + @Test + public void channelIncisionIgnoresCoursesOutsideTheScope() { + HydrologyColumnSample deep = channelColumn(0, 0, 74, 77); + RiverFootprint footprint = new RiverFootprint(Map.of( + RiverFootprint.pack(deep.x(), deep.z()), deep + )); + + assertEquals( + List.of(), + HydrologyPackProbe.ShapeMetrics.channelIncisions(footprint, Set.of(7L), 2, 10) + ); + } + + @Test + public void shallowMeanIncisionGateRequiresOneBlockOfCut() { + assertTrue(HydrologyPackProbe.ShapeMetrics.shallowMeanIncision(0.75D)); + assertFalse(HydrologyPackProbe.ShapeMetrics.shallowMeanIncision(1D)); + assertFalse(HydrologyPackProbe.ShapeMetrics.shallowMeanIncision(2.5D)); + } + + @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, 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()); + HydrologyPackProbe.CourseMorphologyResult rejectedCourse = + new HydrologyPackProbe.CourseMorphologyResult( + 52L, 5L, 20, 80D, 80D, 0, 0D, 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")); + + assertEquals( + List.of(), + HydrologyPackProbe.PublishedMorphologyMetrics.failures(List.of(accepted)) + ); + assertTrue(HydrologyPackProbe.PublishedMorphologyMetrics.failures(List.of(accepted, empty)) + .toString().contains("seed=1337")); + assertTrue(HydrologyPackProbe.PublishedMorphologyMetrics.failures(List.of(empty)) + .toString().contains("NO_SURFACE_COURSES")); + assertTrue(HydrologyPackProbe.PublishedMorphologyMetrics.failures(List.of()) + .toString().contains("NO_SURFACE_COURSES")); + assertTrue(HydrologyPackProbe.PublishedMorphologyMetrics + .failures(List.of(accepted, empty, rejected)).toString().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, + 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, 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("\"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 + ) { + return new HydrologyPackProbe.CourseMorphologyInput( + seed, + courseId, + 9, + 4, + 0, + 0D, + true, + 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 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 RiverFootprint gradedRow(int westHeight, int centerHeight, int eastHeight) { + HydrologyColumnSample west = gradedColumn(-1, 0, westHeight); + HydrologyColumnSample center = gradedColumn(0, 0, centerHeight); + HydrologyColumnSample east = gradedColumn(1, 0, eastHeight); + return new RiverFootprint(Map.of( + RiverFootprint.pack(west.x(), west.z()), west, + RiverFootprint.pack(center.x(), center.z()), center, + RiverFootprint.pack(east.x(), east.z()), east + )); + } + + private static HydrologyColumnSample gradedColumn(int x, int z, int height) { + return new HydrologyColumnSample(x, z, 80, 63, false, "parent", List.of(gradedLayer(height))); + } + + private static HydrologyColumnSample channelColumn(int x, int z, int bedY, int headY) { + return new HydrologyColumnSample(x, z, 80, 63, false, "parent", List.of(new HydrologyColumnLayer( + surfaceFeature(), + bedY, + headY, + headY, + true, + false, + false, + true, + false, + false, + true, + true, + false, + "water", + "surface", + "mouth", + "shore", + "bank", + "flooded" + ))); + } + + private static HydrologyColumnLayer gradedLayer(int height) { + return new HydrologyColumnLayer( + surfaceFeature(), + height, + height, + height, + false, + false, + true, + false, + false, + false, + true, + false, + false, + "water", + "surface", + "mouth", + "shore", + "bank", + "flooded" + ); + } + + private static HydrologyFeatureRef surfaceFeature() { + return new HydrologyFeatureRef(41L, HydrologyFeatureType.RIFFLE, 42L, 43L, 0, 70, 0, 1, 0, false); + } + + 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..2b5a8d798 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyPlannerProbeTest.java @@ -0,0 +1,33 @@ +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; +import static org.junit.Assert.assertFalse; + +public final class HydrologyPlannerProbeTest { + @Test + public void deterministicAcceptedCoveragePublishesEveryReachableFeatureType() { + Map coverage = + HydrologyPlannerProbe.deterministicAcceptedCoverage(); + + assertEquals(HydrologyPlannerProbe.REQUIRED_FEATURE_TYPES, coverage.keySet()); + for (Map.Entry entry : coverage.entrySet()) { + assertEquals(entry.getKey(), entry.getValue().type()); + } + } + + @Test + public void requiredCoverageExcludesTheUnproducedRidgeBore() { + assertFalse(HydrologyPlannerProbe.REQUIRED_FEATURE_TYPES.contains(HydrologyFeatureType.RIDGE_BORE)); + assertEquals( + EnumSet.complementOf(EnumSet.of(HydrologyFeatureType.RIDGE_BORE, HydrologyFeatureType.STANDING_POOL)), + HydrologyPlannerProbe.REQUIRED_FEATURE_TYPES + ); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java new file mode 100644 index 000000000..7e3a12c06 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java @@ -0,0 +1,78 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.hydrology.RiverFootprint; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class RiverTransectProbeTest { + @Test + public void sectionStationsSitAtTenThirtyFiftySeventyAndNinetyPercent() { + assertArrayEquals(new int[] {2, 6, 10, 14, 18}, RiverTransectProbe.sectionStations(20)); + assertArrayEquals(new int[] {0, 0, 0, 0, 0}, RiverTransectProbe.sectionStations(1)); + assertArrayEquals(new int[] {0, 0, 1, 2, 2}, RiverTransectProbe.sectionStations(3)); + } + + @Test + public void summaryCountsCutsBankStepsOceanWritesAndSpillingChannels() { + Map columns = new HashMap<>(); + put(columns, 0, 0, 72, 68, 70, RiverTransectProbe.Role.CHANNEL); + put(columns, 1, 0, 72, 71, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.BANK); + put(columns, -1, 0, 72, 69, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.BANK); + put(columns, 0, 1, 72, 70, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.SHORE); + put(columns, 0, -1, 72, 72, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.NONE); + put(columns, 2, 0, 72, 72, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.NONE); + put(columns, 5, 5, 60, 58, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.NONE); + put(columns, 6, 6, 60, 60, 63, RiverTransectProbe.Role.APRON); + + RiverTransectProbe.CourseSummary summary = RiverTransectProbe.summarize(7L, 12, 63, columns); + + assertEquals(7L, summary.id()); + assertEquals(12, summary.stations()); + assertEquals(4, summary.ownedColumns()); + assertEquals(1, summary.minimumCut()); + assertEquals(4, summary.maximumCut()); + assertEquals(1, summary.maximumBankStep()); + assertEquals(1, summary.oceanWrites()); + assertEquals(1, summary.uncontainedWetCells()); + assertFalse(summary.passes()); + assertEquals(3, summary.details().size()); + assertTrue(summary.details().get(0).startsWith("uncontained 0,0")); + assertTrue(summary.details().get(1).startsWith("oceanWrite 5,5")); + assertTrue(summary.details().get(2).startsWith("bankStep 1")); + } + + @Test + public void containedCourseAwayFromTheOceanPasses() { + Map columns = new HashMap<>(); + put(columns, 0, 0, 72, 68, 70, RiverTransectProbe.Role.CHANNEL); + put(columns, 1, 0, 72, 71, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.BANK); + put(columns, -1, 0, 72, 71, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.BANK); + put(columns, 0, 1, 72, 70, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.SHORE); + put(columns, 0, -1, 72, 71, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.SHORE); + + RiverTransectProbe.CourseSummary summary = RiverTransectProbe.summarize(1L, 3, 63, columns); + + assertTrue(summary.passes()); + assertEquals(0, summary.oceanWrites()); + assertEquals(0, summary.uncontainedWetCells()); + } + + private static void put( + Map columns, + int x, + int z, + int natural, + int terrain, + int water, + RiverTransectProbe.Role role + ) { + columns.put(RiverFootprint.pack(x, z), new RiverTransectProbe.ColumnView(x, z, natural, terrain, water, role)); + } +} 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); }