diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java index 827cbb2c1..238a5186e 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/HydrologyPlanner.java @@ -2646,7 +2646,7 @@ public final class HydrologyPlanner { potential, parent, outletIndex, - routeLengths(grid, parent), + routeLengths(parent, grid.width(), settings.routing()), List.copyOf(outlets), false ); @@ -3222,31 +3222,43 @@ public final class HydrologyPlanner { return count + (HydrologyHash.unit(stable) < remainder ? 1 : 0); } - private int[] routeLengths(SampledGrid grid, int[] parent) { + static int[] routeLengths(int[] parent, int width, HydrologyPlannerSettings.Routing routing) { int[] lengths = new int[parent.length]; + int[] pending = new int[parent.length]; + double[] distances = new double[parent.length]; + Arrays.fill(distances, Double.NaN); + double diagonalDistance = StrictMath.hypot(routing.sampleSpacing(), routing.sampleSpacing()); for (int sourceIndex = 0; sourceIndex < parent.length; sourceIndex++) { - lengths[sourceIndex] = routeLength(sourceIndex, grid, parent); + if (!Double.isNaN(distances[sourceIndex])) { + continue; + } + int current = sourceIndex; + int pendingCount = 0; + while (current >= 0 && Double.isNaN(distances[current])) { + pending[pendingCount++] = current; + distances[current] = -1D; + current = parent[current]; + } + if (current >= 0 && distances[current] < 0D) { + throw new IllegalStateException("Hydrology drainage routing contains a cycle."); + } + double distance = current < 0 ? 0D : distances[current]; + while (pendingCount > 0) { + int node = pending[--pendingCount]; + int next = parent[node]; + if (next >= 0) { + boolean cardinal = node / width == next / width || node % width == next % width; + distance += cardinal ? routing.sampleSpacing() : diagonalDistance; + } + distances[node] = distance; + lengths[node] = distance > routing.maximumRouteLength() + ? Integer.MAX_VALUE + : (int) StrictMath.ceil(distance); + } } 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) { 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 index 3a6827c23..02793feff 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/RiverFootprint.java @@ -3,46 +3,48 @@ package art.arcane.iris.engine.hydrology; import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap; import java.util.ArrayList; +import java.util.Arrays; 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 static final RiverFootprint EMPTY = new RiverFootprint(Map.of()); + + private final Long2ObjectLinkedOpenHashMap indexed; private final Map columns; public RiverFootprint(Map columns) { if (columns == null) { throw new IllegalArgumentException("columns must not be null."); } - TreeMap ordered = new TreeMap<>(); + indexed = new Long2ObjectLinkedOpenHashMap<>(columns.size()); 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); + indexed.put(entry.getKey().longValue(), sample); } - Long2ObjectLinkedOpenHashMap indexed = - new Long2ObjectLinkedOpenHashMap<>(ordered.size()); - for (Map.Entry entry : ordered.entrySet()) { - indexed.put(entry.getKey().longValue(), entry.getValue()); + long[] order = indexed.keySet().toLongArray(); + Arrays.sort(order); + for (long key : order) { + indexed.getAndMoveToLast(key); } - columns = Collections.unmodifiableMap(indexed); - this.columns = columns; + this.columns = Collections.unmodifiableMap(indexed); } public static RiverFootprint empty() { - return new RiverFootprint(Map.of()); + return EMPTY; } public Optional sample(int x, int z) { - return Optional.ofNullable(columns.get(pack(x, z))); + return Optional.ofNullable(indexed.get(pack(x, z))); } public HydrologyRenderSample renderSample(int x, int z) { - HydrologyColumnSample sample = columns.get(pack(x, z)); + HydrologyColumnSample sample = indexed.get(pack(x, z)); return sample == null ? new HydrologyRenderSample(x, z, List.of()) : sample.renderSample(); } @@ -61,7 +63,7 @@ public final class RiverFootprint { 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)); + HydrologyColumnSample sample = indexed.get(pack((int) x, (int) z)); if (sample != null) { selected.add(sample); } 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 index cc986d34f..e1d067d21 100644 --- 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 @@ -9,7 +9,6 @@ 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; @@ -1132,8 +1131,8 @@ public final class HydrologyCaveContainmentPlanner { List throat ) { Queue queue = new ArrayDeque<>(); - Set queued = new HashSet<>(); - Set resolved = new LinkedHashSet<>(); + Set queued = new LinkedHashSet<>(); + int visitedCount = 0; queue.add(source.target()); queued.add(source.target()); @@ -1148,10 +1147,7 @@ public final class HydrologyCaveContainmentPlanner { if (positionRejection != HydrologyCaveRejection.NONE) { return ComponentResult.rejected(positionRejection); } - if (!resolved.add(position)) { - continue; - } - if (resolved.size() > settings.maxFloodVolume()) { + if (++visitedCount > settings.maxFloodVolume()) { return ComponentResult.rejected(HydrologyCaveRejection.VOLUME_LIMIT); } @@ -1171,7 +1167,7 @@ public final class HydrologyCaveContainmentPlanner { } } - return ComponentResult.accepted(resolved); + return ComponentResult.accepted(queued); } private HydrologyCaveRejection addThroatContacts( diff --git a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java index a3b27c8f9..f087f3c4a 100644 --- a/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java +++ b/core/src/main/java/art/arcane/iris/engine/hydrology/surface/SurfaceFootprintCompiler.java @@ -91,11 +91,12 @@ public final class SurfaceFootprintCompiler { && 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); + String biomeOverride = pool ? poolBiome == null ? column.terrain().parentBiomeKey() : poolBiome : null; columns.add(new SurfaceLayerColumn( column.x(), column.z(), column.terrain(), - pool ? poolLayer(feature, column, course.profileKey(), poolBiome) : layer(feature, column, course.profileKey()), + layer(feature, column, course.profileKey(), biomeOverride), column.role(), column.apron() )); @@ -103,40 +104,6 @@ public final class SurfaceFootprintCompiler { return new SurfaceFootprint(columns, field.uncontainedWetCells()); } - /** A pool's bowl and rim carry the pool biome when one is configured, otherwise the surrounding biome. */ - private static HydrologyColumnLayer poolLayer( - HydrologyFeatureRef feature, - SurfaceColumn column, - String profileKey, - String poolBiome - ) { - HydrologyTerrainSample terrain = column.terrain(); - String biome = poolBiome == null ? terrain.parentBiomeKey() : poolBiome; - boolean channel = column.role() == SurfaceRole.CHANNEL; - boolean shore = column.role() == SurfaceRole.SHORE; - return new HydrologyColumnLayer( - feature, - column.height(), - column.headY(), - column.headY(), - channel, - shore, - !channel, - channel, - false, - false, - true, - channel, - false, - profileKey, - biome, - biome, - biome, - biome, - terrain.floodedCaveBiomeKey() - ); - } - private ChannelProfile poolProfile(HydraulicSegment segment, SurfaceCenterline centerline) { int count = centerline.size(); double[] halfWidth = new double[count]; @@ -160,9 +127,15 @@ public final class SurfaceFootprintCompiler { return null; } - private static HydrologyColumnLayer layer(HydrologyFeatureRef feature, SurfaceColumn column, String profileKey) { + /** A pool's bowl and rim carry the pool biome when one is configured, otherwise the surrounding biome. */ + private static HydrologyColumnLayer layer( + HydrologyFeatureRef feature, + SurfaceColumn column, + String profileKey, + String biomeOverride + ) { HydrologyTerrainSample terrain = column.terrain(); - if (column.apron()) { + if (column.apron() && biomeOverride == null) { return new HydrologyColumnLayer( feature, column.headY(), @@ -194,10 +167,10 @@ public final class SurfaceFootprintCompiler { channel, false, profileKey, - terrain.surfaceBiomeKey(), - terrain.mouthBiomeKey(), - terrain.shoreBiomeKey(), - terrain.bankBiomeKey(), + biomeOverride == null ? terrain.surfaceBiomeKey() : biomeOverride, + biomeOverride == null ? terrain.mouthBiomeKey() : biomeOverride, + biomeOverride == null ? terrain.shoreBiomeKey() : biomeOverride, + biomeOverride == null ? terrain.bankBiomeKey() : biomeOverride, terrain.floodedCaveBiomeKey() ); } diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteLengthsTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteLengthsTest.java new file mode 100644 index 000000000..c13096af0 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyRouteLengthsTest.java @@ -0,0 +1,126 @@ +package art.arcane.iris.engine.hydrology; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Random; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +public class HydrologyRouteLengthsTest { + @Test + public void branchingRoutesMatchIndependentDownstreamWalks() { + Random random = new Random(912541L); + for (int width : new int[]{2, 3, 9, 17, 33, 65}) { + for (int spacing : new int[]{4, 16, 64}) { + for (int maximumLength : new int[]{63, 128, 257, 4096}) { + int[] parent = forest(width, random); + HydrologyPlannerSettings.Routing routing = routing(spacing, maximumLength); + assertArrayEquals(walks(parent, width, routing), + HydrologyPlanner.routeLengths(parent, width, routing)); + reverse(parent); + assertArrayEquals(walks(parent, width, routing), + HydrologyPlanner.routeLengths(parent, width, routing)); + } + } + } + } + + @Test + public void exactLengthLimitAndMissingOutletsKeepTheirBounds() { + int[] parent = {1, 2, 3, 4, -1, -1, -1, -1, -1}; + assertArrayEquals(new int[]{Integer.MAX_VALUE, 12, 8, 4, 0, 0, 0, 0, 0}, + HydrologyPlanner.routeLengths(parent, 9, routing(4, 12))); + } + + @Test + public void longRoutesDoNotUseTheCallStack() { + int width = 257; + int[] parent = new int[width * width]; + for (int row = 0; row < width; row++) { + for (int column = 0; column < width; column++) { + int index = row * width + column; + int direction = row % 2 == 0 ? 1 : -1; + int nextColumn = column + direction; + parent[index] = nextColumn >= 0 && nextColumn < width ? index + direction : index + width; + } + } + parent[parent.length - 1] = -1; + int[] lengths = HydrologyPlanner.routeLengths(parent, width, routing(4, Integer.MAX_VALUE)); + assertEquals((parent.length - 1) * 4, lengths[0]); + assertEquals(0, lengths[lengths.length - 1]); + reverse(parent); + lengths = HydrologyPlanner.routeLengths(parent, width, routing(4, Integer.MAX_VALUE)); + assertEquals((parent.length - 1) * 4, lengths[lengths.length - 1]); + assertEquals(0, lengths[0]); + } + + @Test + public void cyclesFailBeforeTheyCanStallPlanning() { + assertThrows(IllegalStateException.class, + () -> HydrologyPlanner.routeLengths(new int[]{1, 0}, 2, routing(4, 128))); + } + + private static int[] forest(int width, Random random) { + int[] parent = new int[width * width]; + Arrays.fill(parent, -1); + int[] candidates = new int[4]; + for (int index = 1; index < parent.length; index++) { + int x = index % width; + int z = index / width; + int count = 0; + if (x > 0) { + candidates[count++] = index - 1; + } + if (z > 0) { + candidates[count++] = index - width; + if (x > 0) { + candidates[count++] = index - width - 1; + } + if (x + 1 < width) { + candidates[count++] = index - width + 1; + } + } + if (random.nextInt(16) != 0) { + parent[index] = candidates[random.nextInt(count)]; + } + } + return parent; + } + + private static void reverse(int[] parent) { + int[] original = parent.clone(); + for (int index = 0; index < parent.length; index++) { + int next = original[parent.length - 1 - index]; + parent[index] = next < 0 ? -1 : parent.length - 1 - next; + } + } + + private static int[] walks(int[] parent, int width, HydrologyPlannerSettings.Routing routing) { + int[] lengths = new int[parent.length]; + for (int source = 0; source < parent.length; source++) { + double distance = 0D; + int steps = 0; + int current = source; + while (parent[current] >= 0) { + int next = parent[current]; + distance += StrictMath.hypot((current % width - next % width) * routing.sampleSpacing(), + (current / width - next / width) * routing.sampleSpacing()); + if (distance > routing.maximumRouteLength() || ++steps > routing.maximumRouteNodes()) { + distance = Integer.MAX_VALUE; + break; + } + current = next; + } + lengths[source] = (int) StrictMath.ceil(distance); + } + return lengths; + } + + private static HydrologyPlannerSettings.Routing routing(int spacing, int maximumLength) { + return new HydrologyPlannerSettings.Routing(spacing * 8, spacing, 1_000_000, maximumLength, + 0, 0, 1D, 1D, 1D, 1D, 1D, 0); + } +} diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/RiverFootprintTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/RiverFootprintTest.java new file mode 100644 index 000000000..4d1495f8a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/RiverFootprintTest.java @@ -0,0 +1,71 @@ +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.Random; +import java.util.TreeMap; + +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 RiverFootprintTest { + @Test + public void primitiveIndexKeepsSignedKeyOrderAndFullCoordinateRange() { + Random random = new Random(836412L); + HashMap input = new HashMap<>(); + for (int index = 0; index < 2_000; index++) { + HydrologyColumnSample column = column(random.nextInt(), random.nextInt()); + input.put(RiverFootprint.pack(column.x(), column.z()), column); + } + for (int x : new int[]{Integer.MIN_VALUE, -1, 0, 1, Integer.MAX_VALUE}) { + for (int z : new int[]{Integer.MIN_VALUE, -1, 0, 1, Integer.MAX_VALUE}) { + input.put(RiverFootprint.pack(x, z), column(x, z)); + } + } + TreeMap expected = new TreeMap<>(input); + RiverFootprint footprint = new RiverFootprint(input); + input.clear(); + assertEquals(new ArrayList<>(expected.keySet()), new ArrayList<>(footprint.columns().keySet())); + assertEquals(expected, footprint.columns()); + for (HydrologyColumnSample column : expected.values()) { + assertSame(column, footprint.sample(column.x(), column.z()).orElseThrow()); + } + } + + @Test + public void exposedEntriesCannotMutateTheLookupIndex() { + HydrologyColumnSample original = column(-5, 9); + RiverFootprint footprint = new RiverFootprint(Map.of(RiverFootprint.pack(-5, 9), original)); + Map.Entry entry = footprint.columns().entrySet().iterator().next(); + assertThrows(UnsupportedOperationException.class, () -> entry.setValue(column(-5, 9))); + assertThrows(UnsupportedOperationException.class, () -> footprint.columns().values().clear()); + assertSame(original, footprint.sample(-5, 9).orElseThrow()); + } + + @Test + public void areaQueriesKeepBothLookupPathsAndExclusiveBounds() { + HashMap input = new HashMap<>(); + for (int x = -8; x < 8; x++) { + for (int z = -8; z < 8; z++) { + input.put(RiverFootprint.pack(x, z), column(x, z)); + } + } + RiverFootprint footprint = new RiverFootprint(input); + assertEquals(List.of(column(-1, -1), column(0, -1), column(-1, 0), column(0, 0)), + footprint.columnsIn(-1, -1, 1, 1)); + assertEquals(new ArrayList<>(new TreeMap<>(input).values()), + footprint.columnsIn(Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE)); + assertTrue(footprint.columnsIn(0, 0, 0, 1).isEmpty()); + assertTrue(footprint.columnsIn(100, 100, 101, 101).isEmpty()); + } + + private static HydrologyColumnSample column(int x, int z) { + return new HydrologyColumnSample(x, z, 90, 63, false, "parent", List.of()); + } +}