Tunabelly

This commit is contained in:
Brian Neumann-Fopiano
2026-09-22 17:04:56 -04:00
parent 197d2c2461
commit 30c533feaf
114 changed files with 4071 additions and 3994 deletions
@@ -11,7 +11,8 @@ record FootprintLayerShape(
boolean archedChannel,
boolean roundedSurfaceBed,
boolean organicBoundary,
boolean fallingThroat
boolean fallingThroat,
boolean inlet
) {
double totalRadius() {
return channelRadius + shoreWidth + gradingWidth;
@@ -159,7 +159,7 @@ final class FootprintMutableColumn {
first.connectedFluid() || second.connectedFluid(),
first.fallingFluid() || second.fallingFluid(),
first.receivingPool() || second.receivingPool(),
(first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY < naturalHeight),
(first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY != naturalHeight),
first.fluidOwned() || second.fluidOwned(),
first.oceanApron() || second.oceanApron(),
first.profileKey(),
@@ -46,7 +46,7 @@ final class HydrologyCacheBudget {
}
long routingBytes() {
return regionalEnabled ? plannerBytes / 8L : plannerBytes * 3L / 16L;
return plannerBytes * 3L / 16L;
}
long ownerBytes() {
@@ -69,7 +69,4 @@ final class HydrologyCacheBudget {
return regionalEnabled ? plannerBytes / 4L : 0L;
}
long regionalReachBytes() {
return regionalEnabled ? plannerBytes / 16L : 0L;
}
}
@@ -69,7 +69,7 @@ final class HydrologyCacheWeights {
static int regionalCoarse(HydrologyRegionalCoarsePlan plan) {
HydrologyRegionalGraph.Tree tree = plan.tree();
long treeBytes = tree == null ? 0L : 96L + (long) tree.selected().length * 8L
+ (long) tree.accumulation().length * 4L + tree.retainedLabelCount() * 96L;
+ ((long) tree.accumulation().length + tree.heads().length) * 4L + tree.retainedLabelCount() * 96L;
return saturated(ENTRY_BYTES + grid(plan.grid()) + treeBytes + (long) plan.outlets().size() * 224L
+ (long) plan.sources().size() * 32L + (long) plan.envelopes().size() * 64L);
}
@@ -79,7 +79,7 @@ final class HydrologyCacheWeights {
}
private static long grid(HydrologySampledGrid grid) {
return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES);
return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES + 104L);
}
private static long routingPlan(HydrologyRoutingPlan plan) {
@@ -88,7 +88,8 @@ final class HydrologyCacheWeights {
}
return 128L + (long) plan.potential().length * 8L
+ ((long) plan.parent().length + plan.outletIndex().length + plan.routeLengths().length) * 4L
+ (long) plan.outlets().size() * 224L;
+ (long) plan.outlets().size() * 224L
+ (plan.surfaceDrainage() == null ? 0L : plan.surfaceDrainage().maximumRetainedBytes());
}
private static long network(List<DrainageNode> nodes, List<DrainageEdge> edges, List<RiverOutlet> outlets,
@@ -1194,8 +1194,12 @@ final class HydrologyCaveCourseFilter {
HydraulicSegment segment = segments.get(index);
if (segment.type().isSurface() && segment.fallingFluid()) {
int radius = Math.max(1, segment.width() / 2) + 1;
HydraulicSegment receiving = index + 1 < segments.size() ? segments.get(index + 1) : null;
boolean coastal = receiving != null && receiving.type() == HydrologyFeatureType.MOUTH
&& receiving.upstreamHeadY() == segment.downstreamHeadY()
&& receiving.start().equals(segment.end());
openings.add(CaveSurfaceOpening.create(segment, true, (long) radius * radius,
segment.downstreamHeadY() + 2, true));
segment.downstreamHeadY() + (coastal ? 0 : 2), true));
continue;
}
if (!segment.type().isUnderground()) {
@@ -1,9 +1,12 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceNoise;
import art.arcane.iris.generation.hydrology.surface.SurfaceInletRange;
final class HydrologyChannelGeometry {
private final HydrologyFootprintCompiler compiler;
private RiverCourse inletCourse;
private SurfaceInletRange inletRange;
HydrologyChannelGeometry(HydrologyFootprintCompiler compiler) {
this.compiler = compiler;
@@ -141,10 +144,24 @@ final class HydrologyChannelGeometry {
archedChannel,
roundedSurfaceBed,
organicBoundary,
falling
falling,
inletStation(course, segment, point)
);
}
private boolean inletStation(RiverCourse course, HydraulicSegment segment, HydrologyPoint point) {
if (course.type() != RiverCourseType.SURFACE || !segment.type().isSurface() || !segment.fallingFluid()) {
return false;
}
if (inletCourse != course) {
HydrologyTerrainSampler receiving = HydrologyOceanReceiver.forCourse(
compiler.settings, compiler::sampleTerrainBasis, course);
inletRange = SurfaceInletRange.forCourse(compiler.settings, course, receiving);
inletCourse = course;
}
return inletRange.allowsFallingStation(segment.id(), point.x(), point.z());
}
int organicVerticalVariation(
long seed,
int x,
@@ -0,0 +1,280 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicReferenceArray;
final class HydrologyDrainageGeometry {
private final HydrologyPlanner planner;
private final Basin basin;
private final int[] heads;
private final AtomicReferenceArray<HydrologyPoint> anchors;
private final AtomicReferenceArray<CompletableFuture<List<HydrologyPoint>>> edges;
HydrologyDrainageGeometry(HydrologyPlanner planner, Basin basin) {
this.planner = planner;
this.basin = basin;
this.heads = new int[basin.grid().nodes().size()];
this.anchors = new AtomicReferenceArray<>(heads.length);
this.edges = new AtomicReferenceArray<>(heads.length);
Arrays.fill(heads, Integer.MIN_VALUE);
planHeads();
}
int head(int index) {
return heads[index];
}
HydrologyPoint anchor(int index) {
HydrologyPoint existing = anchors.get(index);
if (existing != null) {
return existing;
}
HydrologyGridNode node = basin.grid().node(index);
HydrologyPoint point;
if (basin.parent()[index] < 0 && basin.outletIndex()[index] >= 0) {
HydrologyPoint landward = basin.outlets().get(basin.outletIndex()[index]).outlet().landwardPoint();
point = new HydrologyPoint(landward.x(), heads[index], landward.z());
} else {
point = displacedAnchor(node);
}
anchors.compareAndSet(index, null, point);
return anchors.get(index);
}
List<HydrologyPoint> edge(int index) {
DraftProfile profile = planner.currentDraftProfile();
profile.routeCalls++;
CompletableFuture<List<HydrologyPoint>> future = edges.get(index);
if (future == null) {
CompletableFuture<List<HydrologyPoint>> owned = new CompletableFuture<>();
CompletableFuture<List<HydrologyPoint>> existing = edges.compareAndExchange(index, null, owned);
if (existing == null) {
long started = System.nanoTime();
profile.routeSolves++;
try {
List<HydrologyPoint> points = compile(index);
owned.complete(points);
return points;
} catch (Throwable failure) {
owned.completeExceptionally(failure);
edges.compareAndSet(index, owned, null);
throw HydrologyPlanner.propagateOwnerFailure(failure);
} finally {
profile.routeSolveNanos += System.nanoTime() - started;
}
}
future = existing;
}
try {
return future.join();
} catch (CompletionException failure) {
throw HydrologyPlanner.propagateOwnerFailure(failure.getCause());
}
}
long maximumRetainedBytes() {
int stations = Math.ceilDiv(basin.grid().spacing() * 3, planner.settings.routing().refinementSpacing()) + 2;
return (long) heads.length * (112L + stations * 40L);
}
private void planHeads() {
int[] path = new int[heads.length];
int[] nominal = new int[heads.length];
for (int index = 0; index < heads.length; index++) {
if (heads[index] != Integer.MIN_VALUE || basin.outletIndex()[index] < 0) {
continue;
}
int current = index;
int size = 0;
while (current >= 0 && heads[current] == Integer.MIN_VALUE) {
if (size == path.length) {
throw new IllegalStateException("Surface drainage must be acyclic.");
}
path[size++] = current;
current = basin.parent()[current];
}
int downstream = current < 0 ? Integer.MIN_VALUE : heads[current];
while (size > 0) {
int node = path[--size];
HydrologyTerrainSample terrain = basin.grid().node(node).terrain();
if (basin.parent()[node] < 0) {
HydrologyPoint landward = basin.outlets().get(basin.outletIndex()[node]).outlet().landwardPoint();
HydrologyTerrainSample sampled = planner.sampleLandBasisWithoutSlope(landward.x(), landward.z());
if (sampled != null) {
terrain = sampled;
}
}
int depth = (planner.settings.surface().minimumDepth() + planner.settings.surface().maximumDepth()) / 2;
nominal[node] = terrain.naturalHeight() - planner.settings.surface().banks().sink() - depth;
int required = terrain.naturalHeight() - planner.sourcePlanner.permittedSurfaceIncision(terrain) + depth;
heads[node] = Math.max(Math.max(planner.settings.seaLevel(), required), downstream);
downstream = heads[node];
}
}
int[] required = heads.clone();
int[] ceiling = new int[heads.length];
Arrays.fill(ceiling, Integer.MAX_VALUE);
int[] upstream = new int[heads.length];
for (int index = 0; index < heads.length; index++) {
if (heads[index] == Integer.MIN_VALUE) {
continue;
}
if (planner.sourcePlanner.rawSourceEligible(basin.grid().node(index).terrain(),
planner.settings.surface().sources(), true)) {
ceiling[index] = Math.max(required[index], nominal[index]);
}
int parent = basin.parent()[index];
if (parent >= 0) {
upstream[parent]++;
}
}
int end = 0;
for (int index = 0; index < heads.length; index++) {
if (heads[index] != Integer.MIN_VALUE && upstream[index] == 0) {
path[end++] = index;
}
}
for (int position = 0; position < end; position++) {
int node = path[position];
int parent = basin.parent()[node];
if (parent >= 0) {
ceiling[parent] = Math.min(ceiling[parent], ceiling[node]);
if (--upstream[parent] == 0) {
path[end++] = parent;
}
}
}
for (int position = end - 1; position >= 0; position--) {
int node = path[position];
int parent = basin.parent()[node];
int downstream = parent < 0 ? Integer.MIN_VALUE : heads[parent];
heads[node] = Math.max(Math.max(required[node], downstream), Math.min(nominal[node], ceiling[node]));
}
}
private HydrologyPoint displacedAnchor(HydrologyGridNode node) {
double ratio = Math.min(HydrologyRouteGeometry.ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO,
planner.settings.geometry().meanders().maximumOffsetRatio() * 0.625D);
double wavelength = planner.settings.geometry().meanders().primaryWavelength() * 5D;
int x = node.x() + (int) StrictMath.round(basin.grid().spacing() * ratio
* planner.routeAnchorX.noiseSigned(node.x() / wavelength, node.z() / wavelength));
int z = node.z() + (int) StrictMath.round(basin.grid().spacing() * ratio
* planner.routeAnchorZ.noiseSigned(node.x() / wavelength, node.z() / wavelength));
if (!permittedAnchor(node, x, z)) {
x = node.x();
z = node.z();
}
if (nearCoast(node)) {
int clearance = (int) StrictMath.ceil(planner.settings.surface().maximumWidth()
* planner.settings.surface().banks().springWidthRatio() / 2D) + 1;
if (!landClearance(x, z, clearance)) {
int radius = Math.max(1, (int) StrictMath.ceil(basin.grid().spacing()
* HydrologyRouteGeometry.ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO));
long nearest = Long.MAX_VALUE;
int selectedX = x;
int selectedZ = z;
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
int candidateX = node.x() + offset.x() * radius;
int candidateZ = node.z() + offset.z() * radius;
long dx = (long) candidateX - x;
long dz = (long) candidateZ - z;
long distance = dx * dx + dz * dz;
if (distance < nearest && permittedAnchor(node, candidateX, candidateZ)
&& landClearance(candidateX, candidateZ, clearance)) {
nearest = distance;
selectedX = candidateX;
selectedZ = candidateZ;
}
}
x = selectedX;
z = selectedZ;
}
}
return new HydrologyPoint(x, heads[node.index()], z);
}
private boolean permittedAnchor(HydrologyGridNode node, int x, int z) {
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(x, z);
return terrain != null && !terrain.ocean() && terrain.transitAllowed()
&& node.terrain().drainsInto(terrain) && terrain.drainsInto(node.terrain())
&& HydrologySurfaceProfiles.sharesProfile(node.terrain(), terrain)
&& node.terrain().surfacePolicy().areaKey().equals(terrain.surfacePolicy().areaKey());
}
private boolean nearCoast(HydrologyGridNode node) {
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
HydrologyGridNode neighbor = basin.grid().nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z());
if (neighbor != null && neighbor.terrain().ocean()) {
return true;
}
}
return false;
}
private boolean landClearance(int x, int z, int radius) {
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(x + offset.x() * radius,
z + offset.z() * radius);
if (terrain == null || terrain.ocean()) {
return false;
}
}
return true;
}
private List<HydrologyPoint> compile(int index) {
int downstream = basin.parent()[index];
if (downstream < 0) {
return List.of();
}
HydrologyPoint start = anchor(index);
HydrologyPoint end = anchor(downstream);
int continuation = basin.parent()[downstream];
HydrologyPoint next = continuation < 0 ? basin.outlets().get(basin.outletIndex()[index])
.outlet().connectionPoint() : anchor(continuation);
RouteDirection startTangent = planner.routePaths.direction(start.x(), start.z(), end.x(), end.z());
RouteDirection endTangent = planner.routePaths.direction(end.x(), end.z(), next.x(), next.z());
if (endTangent.x() == 0D && endTangent.z() == 0D) {
endTangent = startTangent;
}
double distance = StrictMath.hypot(end.x() - start.x(), end.z() - start.z());
int steps = Math.max(1, (int) StrictMath.ceil(distance / planner.settings.routing().refinementSpacing()));
ArrayList<HydrologyPoint> points = new ArrayList<>(steps + 1);
for (int step = 0; step <= steps; step++) {
double progress = step / (double) steps;
RoutePosition position = planner.routeGeometry.routePosition(start, end, startTangent, endTangent,
distance * HydrologyRouteGeometry.ROUTE_TANGENT_SCALE, distance, progress, 1D);
HydrologyPoint point = step == 0 ? start : step == steps ? end
: new HydrologyPoint((int) StrictMath.round(position.x()),
(int) StrictMath.round(start.y() + (end.y() - start.y()) * progress),
(int) StrictMath.round(position.z()));
if (points.isEmpty() || points.getLast().distanceSquared2D(point) > 0L) {
points.add(point);
}
}
return permitted(points) ? List.copyOf(points) : List.of();
}
private boolean permitted(List<HydrologyPoint> points) {
HydrologyTerrainSample previous = null;
for (int index = 1; index < points.size(); index++) {
for (HydrologyPoint point : planner.segments.rasterLine(points.get(index - 1), points.get(index))) {
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(point.x(), point.z());
if (terrain == null || terrain.ocean() || !terrain.transitAllowed()
|| previous != null && (!previous.drainsInto(terrain)
|| !HydrologySurfaceProfiles.sharesProfile(previous, terrain))) {
return false;
}
previous = terrain;
}
}
return points.size() >= 2;
}
record Basin(HydrologySampledGrid grid, int[] parent, int[] outletIndex, List<OutletCandidate> outlets) {
}
}
@@ -0,0 +1,50 @@
package art.arcane.iris.generation.hydrology;
final class HydrologyDrainageGraph {
private final HydrologyPlannerSettings settings;
private final int[] upstream;
private final double[] costs;
private final byte[] eligibility;
HydrologyDrainageGraph(HydrologySampledGrid grid, HydrologySourcePlanner planner) {
settings = planner.settings();
int directions = HydrologySourcePlanner.ROUTING_OFFSETS.size();
int size = Math.multiplyExact(grid.nodes().size(), directions);
upstream = new int[size];
costs = new double[size];
eligibility = new byte[size];
for (HydrologyGridNode downstream : grid.nodes()) {
int valleyFloor = planner.minimumNeighborHeight(grid, downstream);
for (int direction = 0; direction < directions; direction++) {
int edge = downstream.index() * directions + direction;
HydrologyGridOffset offset = HydrologySourcePlanner.ROUTING_OFFSETS.get(direction);
HydrologyGridNode source = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z());
upstream[edge] = source == null ? -1 : source.index();
if (source == null || source.terrain().ocean() || !source.terrain().transitAllowed()
|| !source.terrain().drainsInto(downstream.terrain())) {
continue;
}
eligibility[edge] = 1;
if (HydrologySurfaceProfiles.sharesProfile(source.terrain(), downstream.terrain())
&& downstream.terrain().naturalHeight() - source.terrain().naturalHeight()
<= planner.maximumSurfaceEdgeRise(downstream.terrain())) {
eligibility[edge] |= 2;
}
costs[edge] = planner.routeCost(source, downstream, offset, valleyFloor);
}
}
}
HydrologyPlannerSettings settings() {
return settings;
}
int upstream(int downstream, int direction, boolean surface) {
int edge = downstream * HydrologySourcePlanner.ROUTING_OFFSETS.size() + direction;
return (eligibility[edge] & (surface ? 2 : 1)) == 0 ? -1 : upstream[edge];
}
double cost(int downstream, int direction) {
return costs[downstream * HydrologySourcePlanner.ROUTING_OFFSETS.size() + direction];
}
}
@@ -565,6 +565,9 @@ final class HydrologyFootprintCompiler {
boolean surfaceDrop = segment.type().isSurface() && segment.fallingFluid();
if (surfaceDrop && channel && !falling) {
int maximumIncision = terrain.surfacePolicy().maximumIncision(settings.surface().maximumIncision());
if (shape.inlet()) {
maximumIncision = Math.max(maximumIncision, settings.surface().banks().inlet().maximumIncision());
}
int allowedIncision = Math.min(maximumIncision,
(int) StrictMath.floor(maximumIncision * terrain.incisionMultiplier()));
bed = Math.max(bed, Math.min(shape.fluidHead(), terrain.naturalHeight() - allowedIncision));
@@ -17,7 +17,6 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -701,7 +700,6 @@ public final class HydrologyPlanner {
final HashMap<UndergroundSegmentCapKey, Integer> undergroundSegmentCaps;
final HashMap<Long, HydrologyPoint> routeAnchors;
final HashMap<Long, HydrologyPoint> surfaceRouteAnchors;
final IdentityHashMap<HydrologySampledGrid, HashMap<SurfaceRouteKey, List<HydrologyPoint>>> surfaceRoutes;
final HydrologyCaveCourseFilter.CandidateCache caveCandidates;
final HydrologyCaveContainmentPlanner.ValidationCache caveValidations;
@@ -723,7 +721,6 @@ public final class HydrologyPlanner {
this.undergroundSegmentCaps = new HashMap<>();
this.routeAnchors = new HashMap<>();
this.surfaceRouteAnchors = new HashMap<>();
this.surfaceRoutes = new IdentityHashMap<>();
this.caveCandidates = new HydrologyCaveCourseFilter.CandidateCache();
this.caveValidations = new HydrologyCaveContainmentPlanner.ValidationCache();
}
@@ -18,7 +18,7 @@ public record HydrologyPlannerSettings(
SeaCaves seaCaves,
SurfacePolicyBounds surfacePolicyBounds
) {
private static final long PLAN_FORMAT_REVISION = 8L;
private static final long PLAN_FORMAT_REVISION = 9L;
private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4;
public HydrologyPlannerSettings {
@@ -345,8 +345,8 @@ public record HydrologyPlannerSettings(
}
/**
* {@code sink} is how many blocks the water surface sits below the lowest natural ground beside
* the channel; zero keeps the water flush with the bank and the bank top always meets the water.
* {@code sink} is how many blocks the water surface sits below the shaped bank top;
* zero keeps the water flush with the bank.
* {@code channel} shapes the wet outline and {@code flow} the waterfalls and plunge basins.
*/
public record Banks(
@@ -1,6 +1,7 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.BitSet;
import java.util.Comparator;
import java.util.List;
@@ -94,7 +95,42 @@ final class HydrologyRegionalGraph {
accumulation[label.downstream.state.id()] += accumulation[label.state.id()];
}
}
return new Tree(selected, accumulation, List.copyOf(outlets));
int[] heads = new int[labels.size()];
int[] required = new int[labels.size()];
int[] nominal = new int[labels.size()];
int[] ceiling = new int[labels.size()];
Arrays.fill(ceiling, Integer.MAX_VALUE);
for (Label label : labels) {
HydrologyTerrainSample terrain = grid.node(label.state.index()).terrain();
if (label.downstream == null) {
HydrologyPoint landward = outlets.get(label.state.root()).outlet().landwardPoint();
terrain = planner.sampleLandBasisWithoutSlope(landward.x(), landward.z());
}
int index = label.state.id();
int depth = (planner.settings.surface().minimumDepth() + planner.settings.surface().maximumDepth()) / 2;
nominal[index] = terrain.naturalHeight() - planner.settings.surface().banks().sink() - depth;
int minimum = terrain.naturalHeight() - planner.sourcePlanner.permittedSurfaceIncision(terrain) + depth;
required[index] = coastal ? planner.settings.seaLevel() : Math.max(planner.settings.seaLevel(),
Math.max(minimum, label.downstream == null ? Integer.MIN_VALUE : required[label.downstream.state.id()]));
if (selected[label.state.index()] == label && planner.sourcePlanner.rawSourceEligible(
grid.node(label.state.index()).terrain(), planner.settings.surface().sources(), true)) {
ceiling[index] = Math.max(nominal[index], required[index]);
}
}
for (int index = labels.size() - 1; index >= 0; index--) {
Label label = labels.get(index);
if (label.downstream != null) {
int parent = label.downstream.state.id();
ceiling[parent] = Math.min(ceiling[parent], ceiling[index]);
}
}
for (Label label : labels) {
int index = label.state.id();
int downstream = label.downstream == null ? Integer.MIN_VALUE : heads[label.downstream.state.id()];
heads[index] = coastal ? planner.settings.seaLevel()
: Math.max(Math.max(required[index], downstream), Math.min(nominal[index], ceiling[index]));
}
return new Tree(selected, accumulation, List.copyOf(outlets), heads);
}
List<Integer> path(Tree tree, int source) {
@@ -160,7 +196,7 @@ final class HydrologyRegionalGraph {
+ first.routingCost() + second.routingCost()) * policy);
}
record Tree(Label[] selected, int[] accumulation, List<OutletCandidate> outlets) {
record Tree(Label[] selected, int[] accumulation, List<OutletCandidate> outlets, int[] heads) {
int retainedLabelCount() {
BitSet retained = new BitSet(accumulation.length);
for (Label label : selected) {
@@ -187,6 +223,15 @@ final class HydrologyRegionalGraph {
return selected[source] == null ? 0D : selected[source].state.length();
}
List<HydrologyPoint> centerline(HydrologySampledGrid grid, int source) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
for (Label label = selected[source]; label != null; label = label.downstream) {
HydrologyGridNode node = grid.node(label.state.index());
points.add(new HydrologyPoint(node.x(), heads[label.state.id()], node.z()));
}
return List.copyOf(points);
}
int[] contributions(int source) {
int count = 0;
for (Label label = selected[source]; label != null; label = label.downstream) {
@@ -1,31 +1,10 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceBankSupport;
import java.util.Arrays;
final class HydrologyRegionalHydraulics {
private static final int MAXIMUM_WIDTH_CANDIDATES = 8;
private static final int MAXIMUM_ADDITIONAL_SAMPLES = 2048;
private static final int MAXIMUM_BANK_RADIUS = 96;
private final HydrologyPlannerSettings settings;
private final SurfaceBankSupport bankSupport;
private final double minimumHalfWidth;
private final double maximumProbedHalfWidth;
private final double[] candidateHalfWidths;
HydrologyRegionalHydraulics(HydrologyPlannerSettings settings) {
this.settings = settings;
this.bankSupport = new SurfaceBankSupport(settings.surface(), settings.seaLevel());
HydrologyPlannerSettings.Surface surface = settings.surface();
this.minimumHalfWidth = surface.minimumWidth() / 2D;
double authored = surface.maximumWidth() / 2D;
double expanded = surface.maximumWidth();
this.maximumProbedHalfWidth = Math.min(expanded, Math.max(minimumHalfWidth,
(MAXIMUM_BANK_RADIUS - 2D) / (1D + surface.banks().roughness())));
this.candidateHalfWidths = candidates(authored, midpoint(minimumHalfWidth, authored),
maximumProbedHalfWidth, midpoint(minimumHalfWidth, maximumProbedHalfWidth));
}
int minimumHead(HydrologyTerrainSample terrain) {
@@ -41,90 +20,6 @@ final class HydrologyRegionalHydraulics {
return Math.max(settings.seaLevel(), terrain.naturalHeight() - settings.surface().banks().sink());
}
int supportedHead(HeadStation station, HydrologyTerrainSampler sampler) {
int centerHead = maximumHead(station.terrain());
if (centerHead >= station.incomingHead()) {
return station.incomingHead();
}
int supported = support(station, sampler, minimumHalfWidth, centerHead);
if (supported >= station.incomingHead()) {
return station.incomingHead();
}
SampleBudget budget = new SampleBudget(sampler);
double localMinimum = effectiveHalfWidth(settings.surface().minimumWidth(), station.terrain().widthMultiplier());
double localMaximum = effectiveHalfWidth(settings.surface().maximumWidth(), station.terrain().widthMultiplier());
if (additional(localMaximum)) {
supported = support(station, budget, localMaximum, supported);
}
if (supported < station.incomingHead() && localMinimum != localMaximum && additional(localMinimum)) {
supported = support(station, budget, localMinimum, supported);
}
double localMiddle = midpoint(localMinimum, localMaximum);
if (supported < station.incomingHead() && localMiddle != localMinimum && localMiddle != localMaximum && additional(localMiddle)) {
supported = support(station, budget, localMiddle, supported);
}
for (double halfWidth : candidateHalfWidths) {
if (supported >= station.incomingHead() || budget.remaining == 0) {
break;
}
supported = support(station, budget, halfWidth, supported);
}
return Math.min(station.incomingHead(), supported);
}
private int support(HeadStation station, HydrologyTerrainSampler sampler, double halfWidth, int supportedHead) {
SurfaceBankSupport.Station crossSection = new SurfaceBankSupport.Station(station.x(), station.z(),
station.tangentX(), station.tangentZ(), halfWidth);
SurfaceBankSupport.CrossSection support = bankSupport.crossSection(sampler, crossSection, true);
if (support.blocked() || support.minimum() - settings.surface().banks().sink() <= supportedHead) {
return supportedHead;
}
SurfaceBankSupport.Perimeter perimeter = bankSupport.perimeter(sampler, crossSection, support.minimum());
if (!perimeter.complete()) {
return supportedHead;
}
int supported = perimeter.minimum() - settings.surface().banks().sink();
return Math.max(supportedHead, supported);
}
private double effectiveHalfWidth(int width, double multiplier) {
return Math.max(settings.surface().minimumWidth(), Math.min(settings.surface().maximumWidth() * 2D, width * multiplier)) / 2D;
}
private boolean additional(double halfWidth) {
if (halfWidth <= minimumHalfWidth || halfWidth > maximumProbedHalfWidth) {
return false;
}
for (double candidate : candidateHalfWidths) {
if (candidate == halfWidth) {
return false;
}
}
return true;
}
private double[] candidates(double... widths) {
double[] distinct = new double[MAXIMUM_WIDTH_CANDIDATES - 4];
int size = 0;
for (double width : widths) {
if (!Double.isFinite(width) || width <= minimumHalfWidth || width > maximumProbedHalfWidth) {
continue;
}
boolean duplicate = false;
for (int index = 0; index < size; index++) {
duplicate |= distinct[index] == width;
}
if (!duplicate && size < distinct.length) {
distinct[size++] = width;
}
}
return Arrays.copyOf(distinct, size);
}
private static double midpoint(double first, double second) {
return first + (second - first) / 2D;
}
private int minimumHead(HydrologyTerrainSample terrain, int incision) {
int permitted = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()));
int depth = (int) StrictMath.round(Math.max(1D, Math.min(settings.surface().maximumDepth() * 2D,
@@ -132,24 +27,4 @@ final class HydrologyRegionalHydraulics {
return Math.max(settings.seaLevel(), terrain.naturalHeight() - permitted + depth);
}
record HeadStation(int x, int z, double tangentX, double tangentZ, HydrologyTerrainSample terrain, int incomingHead) {
}
private static final class SampleBudget implements HydrologyTerrainSampler {
private final HydrologyTerrainSampler sampler;
private int remaining = MAXIMUM_ADDITIONAL_SAMPLES;
private SampleBudget(HydrologyTerrainSampler sampler) {
this.sampler = sampler;
}
@Override
public HydrologyTerrainSample sample(int blockX, int blockZ) {
if (remaining == 0) {
return null;
}
remaining--;
return sampler.sample(blockX, blockZ);
}
}
}
@@ -475,24 +475,25 @@ final class HydrologyRegionalPlanner {
List<Integer> indices = graph.path(tree, source);
OutletCandidate destination = tree.outlets().get(tree.root(source));
RiverOutlet outlet = destination.outlet();
ArrayList<HydrologyPoint> guide = new ArrayList<>(indices.size() + 1);
ArrayList<HydrologyPoint> guide = new ArrayList<>(tree.centerline(grid, source));
ArrayList<String> profiles = new ArrayList<>(grid.node(source).terrain().preferredProfileKeys());
for (int index : indices) {
HydrologyGridNode node = grid.node(index);
guide.add(node.naturalPoint());
profiles.retainAll(node.terrain().preferredProfileKeys());
}
profiles.retainAll(sample(outlet.connectionPoint().x(), outlet.connectionPoint().z()).preferredProfileKeys());
if (origin != null) {
profiles.retainAll(sample(origin.outlet().connectionPoint().x(), origin.outlet().connectionPoint().z()).preferredProfileKeys());
guide.set(0, origin.outlet().landwardPoint());
HydrologyPoint start = origin.outlet().landwardPoint();
guide.set(0, new HydrologyPoint(start.x(), planner.settings.seaLevel(), start.z()));
}
if (profiles.isEmpty() || guide.size() < 2) {
diagnostic(diagnostics, grid.node(source).id(), grid.node(source).naturalPoint(), origin != null,
HydrologyCandidateRejection.POLICY_EXCLUDED, guide.size());
return HydrologyRegionalNetwork.EMPTY;
}
guide.set(guide.size() - 1, outlet.landwardPoint());
HydrologyPoint end = outlet.landwardPoint();
guide.set(guide.size() - 1, new HydrologyPoint(end.x(), guide.getLast().y(), end.z()));
String profile = profiles.get(HydrologyHash.between(HydrologyHash.mix(basinSeed, source), 0, profiles.size() - 1));
BuildContext context = new BuildContext(grid.node(source), origin, basinSeed, outlet, profile,
new HydrologyRegionalFlow(guide, tree.contributions(source)),
@@ -18,41 +18,32 @@ final class HydrologyRegionalRoute {
private static final int MAXIMUM_CORRIDOR_WORKERS = 8;
private final HydrologyPlanner planner;
private final HydrologyRegionalTerrainRefiner terrainRefiner;
private final HydrologyRegionalHydraulics hydraulics;
private final HydrologyRegionalTerrain terrain;
private final SimplexNoise bends;
private final SimplexNoise details;
HydrologyRegionalRoute(HydrologyPlanner planner) {
this.planner = planner;
this.terrainRefiner = new HydrologyRegionalTerrainRefiner(planner);
this.hydraulics = new HydrologyRegionalHydraulics(planner.settings);
this.terrain = new HydrologyRegionalTerrain(planner);
this.bends = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x52454742454e4453L));
this.details = new SimplexNoise(HydrologyHash.mix(planner.worldSeed, 0x5245474445544149L));
}
void clear() {
terrainRefiner.clear();
terrain.clear();
}
Attempt select(List<HydrologyPoint> guide, String profile, boolean coastal, HydrologyTerrainSampler receiver,
CandidateAdmission admission) {
HydrologyRegionalMorphology morphology = HydrologyRegionalMorphology.sample(guide, terrainRefiner::sample, planner.settings);
HydrologyRegionalMorphology morphology = HydrologyRegionalMorphology.sample(guide, this.terrain::sample, planner.settings);
Refinement topology = validateCorridor(guide, profile, coastal);
OceanEntry resolved = topology.rejection() == null ? topology.oceanEntry() : null;
if (resolved != null) {
guide = topology.points();
receiver = HydrologyOceanReceiver.forConnection(planner.settings, terrainRefiner::sample,
receiver = HydrologyOceanReceiver.forConnection(planner.settings, this.terrain::sample,
resolved.landward(), resolved.receiving());
}
CandidateSelection selection = new CandidateSelection(admission, resolved);
List<HydrologyPoint> repaired = terrainRefiner.refine(guide, profile, coastal, receiver);
if (!repaired.isEmpty() && !repaired.equals(guide)) {
Attempt accepted = selectCurve(repaired, profile, coastal, receiver, selection, morphology);
if (accepted != null) {
return accepted;
}
}
Attempt accepted = selectCurve(guide, profile, coastal, receiver, selection, morphology);
return accepted == null ? selection.failure : accepted;
}
@@ -396,7 +387,7 @@ final class HydrologyRegionalRoute {
terrain.naturalHeight() - planner.settings.seaLevel() + planner.settings.surface().minimumDepth(), null);
}
previousTerrain = terrain;
previousPoint = failure;
previousPoint = new HydrologyPoint(x, (int) StrictMath.round(first.y() + (second.y() - first.y()) * progress), z);
}
length += StrictMath.sqrt(first.distanceSquared2D(second));
}
@@ -419,7 +410,7 @@ final class HydrologyRegionalRoute {
if (++samples > MAXIMUM_RECEIVER_SAMPLES) {
return new Refinement(List.of(), previousPoint, HydrologyCandidateRejection.ROUTE_LIMIT, samples, null);
}
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z);
HydrologyTerrainSample terrain = this.terrain.sample(x, z);
HydrologyPoint point = new HydrologyPoint(x, terrain == null ? first.y() : terrain.naturalHeight(), z);
if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel()) {
return new Refinement(List.of(), point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, 1, null);
@@ -453,7 +444,7 @@ final class HydrologyRegionalRoute {
private boolean wetBridge(int x, int z, HydrologyTerrainSample previous, HydrologyTerrainSample next,
String profile, boolean coastal) {
HydrologyTerrainSample bridge = terrainRefiner.sample(x, z);
HydrologyTerrainSample bridge = this.terrain.sample(x, z);
return bridge != null && bridge.naturalHeight() < planner.settings.seaLevel()
&& bridge.preferredProfileKeys().contains(profile) && previous.drainsInto(bridge) && bridge.drainsInto(next)
&& (!coastal || next.drainsInto(bridge) && bridge.drainsInto(previous));
@@ -465,7 +456,7 @@ final class HydrologyRegionalRoute {
HydrologyPoint landward = new HydrologyPoint(shore.point().x(), seaLevel, shore.point().z());
receiving = new HydrologyPoint(receiving.x(), seaLevel, receiving.z());
HydrologyTerrainSampler proof = HydrologyOceanReceiver.forConnection(planner.settings,
terrainRefiner::sample, landward, receiving);
this.terrain::sample, landward, receiving);
if (!shore.terrain().outletAllowed() || !proof.receivingWater(firstWet.x(), firstWet.z(), seaLevel)
|| !receivingChordAllowed(shore, receiving, profile, coastal)) {
return new Refinement(List.of(), firstWet, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, null);
@@ -489,7 +480,7 @@ final class HydrologyRegionalRoute {
double progress = step / (double) steps;
int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z);
HydrologyTerrainSample terrain = this.terrain.sample(x, z);
if (terrain == null || terrain.naturalHeight() >= planner.settings.seaLevel()
|| !terrain.preferredProfileKeys().contains(profile) || !previous.drainsInto(terrain)
|| coastal && !terrain.drainsInto(previous)) {
@@ -517,30 +508,32 @@ final class HydrologyRegionalRoute {
Math.abs(points.get(index).z() - points.get(index - 1).z()));
}
HydrologyPlannerSettings.Inlet inlet = planner.settings.surface().banks().inlet();
boolean oceanMouth = corridor.oceanEntry() != null || terrainRefiner.receivingTerminal(points.getLast(), profile, false, receiver);
int reach = oceanMouth ? Math.min(inlet.length(), (int) StrictMath.floor(count * inlet.courseFraction())) : 0;
boolean ocean = corridor.oceanEntry() != null || terrain.receivingTerminal(points.getLast(), profile, false, receiver);
int reach = ocean ? Math.min(inlet.length(), (int) StrictMath.floor(count * inlet.courseFraction())) : 0;
int rampStart = count - reach - reach / 2;
int availableHead = Integer.MAX_VALUE;
int station = 0;
HydrologyTerrainSampler bankSampler = terrainRefiner::sample;
for (int index = 0; index + 1 < points.size(); index++) {
HydrologyPoint first = points.get(index);
HydrologyPoint second = points.get(index + 1);
int steps = Math.max(Math.abs(second.x() - first.x()), Math.abs(second.z() - first.z()));
double distance = StrictMath.sqrt(first.distanceSquared2D(second));
double tangentX = distance == 0D ? 1D : (second.x() - first.x()) / distance;
double tangentZ = distance == 0D ? 0D : (second.z() - first.z()) / distance;
for (int step = index == 0 ? 0 : 1; step <= steps; step++) {
double progress = steps == 0 ? 0D : step / (double) steps;
int x = (int) StrictMath.round(first.x() + (second.x() - first.x()) * progress);
int z = (int) StrictMath.round(first.z() + (second.z() - first.z()) * progress);
HydrologyTerrainSample terrain = terrainRefiner.sample(x, z);
availableHead = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation(
x, z, tangentX, tangentZ, terrain, availableHead), bankSampler);
int requiredHead = station >= rampStart ? hydraulics.inletMinimumHead(terrain) : hydraulics.minimumHead(terrain);
if (requiredHead > availableHead) {
return new Refinement(List.of(), new HydrologyPoint(x, terrain.naturalHeight(), z),
HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, requiredHead - availableHead, null);
if (first.y() < second.y()) {
return new Refinement(List.of(), second, HydrologyCandidateRejection.SURFACE_HEAD_RANGE,
second.y() - first.y(), null);
}
List<HydrologyPoint> raster = planner.segments.rasterLine(first, second);
for (int step = index == 0 ? 0 : 1; step < raster.size(); step++) {
HydrologyPoint sampled = raster.get(step);
double progress = raster.size() < 2 ? 0D : step / (double) (raster.size() - 1);
HydrologyPoint point = new HydrologyPoint(sampled.x(),
(int) StrictMath.round(first.y() + (second.y() - first.y()) * progress), sampled.z());
HydrologyTerrainSample terrain = this.terrain.sample(point.x(), point.z());
int incision = terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision());
if (station >= rampStart) {
incision = Math.max(incision, inlet.maximumIncision());
}
int permitted = HydrologySourcePlanner.permittedSurfaceIncision(incision, terrain.incisionMultiplier());
int cut = terrain.naturalHeight() - point.y() + planner.settings.surface().minimumDepth();
if (cut > permitted) {
return new Refinement(List.of(), point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, cut, null);
}
station++;
}
@@ -596,7 +589,7 @@ final class HydrologyRegionalRoute {
private HydrologyTerrainSample next(int segment, int step, int x, int z) {
if (workers == 1) {
return terrainRefiner.sample(x, z);
return HydrologyRegionalRoute.this.terrain.sample(x, z);
}
if (cursor == count) {
fill(segment, step);
@@ -643,7 +636,7 @@ final class HydrologyRegionalRoute {
private void sampleRange(int start, int end) {
for (int index = start; index < end; index++) {
try {
terrain[index] = terrainRefiner.sample(xs[index], zs[index]);
terrain[index] = HydrologyRegionalRoute.this.terrain.sample(xs[index], zs[index]);
} catch (RuntimeException failure) {
failures[index] = failure;
}
@@ -0,0 +1,66 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.volmlib.util.cache.CacheKey;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
final class HydrologyRegionalTerrain implements HydrologyTerrainSampler {
private static final int MAXIMUM_CACHED_SAMPLES = 4194304;
private static final int[][] CARDINAL = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
private final HydrologyPlanner planner;
private final HydrologyCacheBudget budget;
private volatile Cache<Long, Terrain> terrain;
HydrologyRegionalTerrain(HydrologyPlanner planner) {
this.planner = planner;
this.budget = HydrologyCacheBudget.runtime(planner.settings.routing().regional().enabled());
this.terrain = cache();
}
void clear() {
terrain = cache();
}
@Override
public HydrologyTerrainSample sample(int x, int z) {
Cache<Long, Terrain> active = terrain;
long key = CacheKey.mix(RiverFootprint.pack(x, z));
Terrain cached = active.getIfPresent(key);
if (cached != null) {
return cached.sample();
}
Terrain sampled = new Terrain(planner.naturalSampler == null
? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z));
Terrain previous = active.asMap().putIfAbsent(key, sampled);
return (previous == null ? sampled : previous).sample();
}
boolean receivingTerminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
HydrologyTerrainSample land = sample(point.x(), point.z());
if (land == null || land.ocean() || land.naturalHeight() < planner.settings.seaLevel() || !land.outletAllowed()) {
return false;
}
for (int index = 0; index < 4; index++) {
int[] offset = CARDINAL[index];
HydrologyTerrainSample ocean = sample(point.x() + offset[0], point.z() + offset[1]);
if (ocean != null && receiver.receivingWater(point.x() + offset[0], point.z() + offset[1], planner.settings.seaLevel())
&& ocean.preferredProfileKeys().contains(profile) && land.drainsInto(ocean)
&& (!coastal || ocean.drainsInto(land))) {
return true;
}
}
return false;
}
private Cache<Long, Terrain> cache() {
return Caffeine.newBuilder()
.maximumWeight(budget.regionalTerrainBytes())
.weigher((Long key, Terrain value) -> HydrologyCacheWeights.bounded(
HydrologyCacheWeights.terrain(value.sample()), budget.regionalTerrainBytes(),
(int) Math.max(65536L, Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L))))
.build();
}
private record Terrain(HydrologyTerrainSample sample) {
}
}
@@ -1,514 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.volmlib.util.cache.CacheKey;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2IntOpenHashMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;
import java.util.concurrent.ConcurrentHashMap;
final class HydrologyRegionalTerrainRefiner {
private static final int GRID_SPACING = 16;
private static final int MAXIMUM_EXPANSIONS = 4096;
private static final int MAXIMUM_SAMPLES = 65536;
private static final int MAXIMUM_CACHED_SAMPLES = 4194304;
private static final int MAXIMUM_SKIPPED_WAYPOINTS = 2;
private static final int[][] NEIGHBOURS = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}, {1, 1}, {-1, 1}, {-1, -1}, {1, -1}};
private final HydrologyPlanner planner;
private final HydrologyRegionalHydraulics hydraulics;
private final HydrologyCacheBudget cacheBudget;
private volatile CacheState caches;
HydrologyRegionalTerrainRefiner(HydrologyPlanner planner) {
this.planner = planner;
this.hydraulics = new HydrologyRegionalHydraulics(planner.settings);
this.cacheBudget = HydrologyCacheBudget.runtime(planner.settings.routing().regional().enabled());
this.caches = new CacheState(cacheBudget);
}
void clear() {
caches = new CacheState(cacheBudget);
}
HydrologyTerrainSample sample(int x, int z) {
return sample(caches.terrain, x, z);
}
private HydrologyTerrainSample sample(Cache<Long, Terrain> cache, int x, int z) {
long key = CacheKey.mix(RiverFootprint.pack(x, z));
Terrain cached = cache.getIfPresent(key);
if (cached != null) {
return cached.sample();
}
Terrain sampled = new Terrain(planner.naturalSampler == null
? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z));
Terrain existing = cache.asMap().putIfAbsent(key, sampled);
return (existing == null ? sampled : existing).sample();
}
List<HydrologyPoint> refine(List<HydrologyPoint> guide, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
if (guide.size() < 2) {
return List.of();
}
double maximumLength = planner.settings.routing().maximumRouteLength();
double remaining = 0D;
for (int index = 1; index < guide.size(); index++) {
remaining += distance(guide.get(index - 1), guide.get(index));
if (remaining > maximumLength) {
return List.of();
}
}
Terminal terminal = terminal(guide.getLast(), profile, coastal, receiver);
int[] requiredHead = requiredHeads(guide, terminal, MAXIMUM_SKIPPED_WAYPOINTS + 1);
if (requiredHead.length == 0) {
return List.of();
}
int availableHead = Integer.MAX_VALUE;
double length = 0D;
ArrayList<HydrologyPoint> refined = new ArrayList<>();
refined.add(guide.getFirst());
Long2IntOpenHashMap stations = new Long2IntOpenHashMap();
stations.defaultReturnValue(-1);
stations.put(RiverFootprint.pack(guide.getFirst().x(), guide.getFirst().z()), 0);
for (int index = 1; index < guide.size(); index++) {
int start = index - 1;
Reach reach = null;
int selected = index;
for (int candidate = index; candidate < guide.size() && candidate <= index + MAXIMUM_SKIPPED_WAYPOINTS; candidate++) {
Edge edge = new Edge(guide.get(start), guide.get(candidate), profile, coastal,
requiredHead[candidate], availableHead, terminal);
reach = edge(edge);
if (!reach.points().isEmpty()) {
selected = candidate;
break;
}
}
if (reach.points().isEmpty()) {
return List.of();
}
List<HydrologyPoint> points = reach.points();
availableHead = reach.availableHead();
for (int skipped = start; skipped < selected; skipped++) {
remaining -= distance(guide.get(skipped), guide.get(skipped + 1));
}
index = selected;
for (int point = 1; point < points.size(); point++) {
HydrologyPoint next = points.get(point);
long key = RiverFootprint.pack(next.x(), next.z());
int previous = stations.get(key);
if (previous >= 0) {
while (refined.size() > previous + 1) {
HydrologyPoint removed = refined.removeLast();
stations.remove(RiverFootprint.pack(removed.x(), removed.z()));
length -= distance(refined.getLast(), removed);
}
} else {
length += distance(refined.getLast(), next);
stations.put(key, refined.size());
refined.add(next);
}
if (length + Math.max(0D, remaining) > maximumLength) {
return List.of();
}
}
}
return simplify(refined, profile, coastal, terminal);
}
boolean receivingTerminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
HydrologyTerrainSample land = sample(point.x(), point.z());
if (land == null || land.ocean() || land.naturalHeight() < planner.settings.seaLevel() || !land.outletAllowed()) {
return false;
}
for (int index = 0; index < 4; index++) {
int[] offset = NEIGHBOURS[index];
HydrologyTerrainSample ocean = sample(point.x() + offset[0], point.z() + offset[1]);
if (ocean != null && receiver.receivingWater(point.x() + offset[0], point.z() + offset[1], planner.settings.seaLevel())
&& ocean.preferredProfileKeys().contains(profile) && land.drainsInto(ocean)
&& (!coastal || ocean.drainsInto(land))) {
return true;
}
}
return false;
}
private Terminal terminal(HydrologyPoint point, String profile, boolean coastal, HydrologyTerrainSampler receiver) {
int inlet = receivingTerminal(point, profile, coastal, receiver) ? planner.settings.surface().banks().inlet().length() : 0;
return new Terminal(point, inlet + inlet / 2);
}
private int minimumHead(HydrologyTerrainSample terrain, int x, int z, Terminal terminal) {
boolean inlet = terminal.possibleStations() > 0
&& Math.max(Math.abs(x - terminal.landward().x()), Math.abs(z - terminal.landward().z())) < terminal.possibleStations();
return inlet ? hydraulics.inletMinimumHead(terrain) : hydraulics.minimumHead(terrain);
}
private int[] requiredHeads(List<HydrologyPoint> points, Terminal terminal, int maximumAdvance) {
int[] heads = new int[points.size()];
for (int index = points.size() - 1; index >= 0; index--) {
HydrologyPoint point = points.get(index);
HydrologyTerrainSample terrain = sample(point.x(), point.z());
if (terrain == null) {
return new int[0];
}
int required = planner.settings.seaLevel();
if (index + 1 < points.size()) {
required = Integer.MAX_VALUE;
for (int downstream = index + 1; downstream < points.size() && downstream <= index + maximumAdvance; downstream++) {
required = Math.min(required, heads[downstream]);
}
}
heads[index] = Math.max(required, minimumHead(terrain, point.x(), point.z(), terminal));
}
return heads;
}
private List<HydrologyPoint> simplify(List<HydrologyPoint> points, String profile, boolean coastal, Terminal terminal) {
double maximumReach = Math.min(512D, planner.settings.routing().regional().sampleSpacing() * 2D);
Samples samples = new Samples();
ArrayList<HydrologyPoint> simplified = new ArrayList<>();
int current = 0;
int[] requiredHead = requiredHeads(points, terminal, 1);
if (requiredHead.length == 0) {
return List.of();
}
int availableHead = Integer.MAX_VALUE;
simplified.add(points.getFirst());
while (current + 1 < points.size()) {
int farthest = current + 1;
double reach = distance(points.get(current), points.get(farthest));
while (farthest + 1 < points.size()) {
double next = distance(points.get(farthest), points.get(farthest + 1));
if (reach + next > maximumReach) {
break;
}
reach += next;
farthest++;
}
int selected = current + 1;
Traversal selectedTraversal = traverse(points.get(current), points.get(selected),
new Edge(points.get(current), points.get(selected), profile, coastal, requiredHead[selected], availableHead, terminal),
samples, availableHead);
for (int candidate = farthest; candidate > current + 1; candidate--) {
Edge shortcut = new Edge(points.get(current), points.get(candidate), profile, coastal,
requiredHead[candidate], availableHead, terminal);
Traversal traversal = traverse(shortcut.start(), shortcut.end(), shortcut, samples, availableHead);
if (traversal.cost() == 0D) {
selected = candidate;
selectedTraversal = traversal;
break;
}
}
if (!Double.isFinite(selectedTraversal.cost())) {
return List.copyOf(points);
}
availableHead = selectedTraversal.availableHead();
current = selected;
simplified.add(points.get(current));
}
return List.copyOf(simplified);
}
private Reach route(Edge edge) {
Samples samples = new Samples();
Traversal direct = traverse(edge.start(), edge.end(), edge, samples, edge.maximumHead());
if (direct.cost() == 0D) {
return new Reach(List.of(edge.start(), edge.end()), direct.availableHead());
}
int margin = planner.settings.routing().regional().sampleSpacing();
int minimumX = Math.min(edge.start().x(), edge.end().x()) - margin;
int maximumX = Math.max(edge.start().x(), edge.end().x()) + margin;
int minimumZ = Math.min(edge.start().z(), edge.end().z()) - margin;
int maximumZ = Math.max(edge.start().z(), edge.end().z()) + margin;
Long2ObjectOpenHashMap<List<Node>> visited = new Long2ObjectOpenHashMap<>();
PriorityQueue<Pending> pending = new PriorityQueue<>(Comparator.comparingDouble(Pending::estimate)
.thenComparingLong(Pending::key)
.thenComparing(Comparator.comparingInt(Pending::availableHead).reversed()));
long firstKey = RiverFootprint.pack(edge.start().x(), edge.start().z());
long lastKey = RiverFootprint.pack(edge.end().x(), edge.end().z());
Search search = new Search(edge, samples, visited, pending);
Node first = new Node(edge.start(), 0D, edge.maximumHead(), null);
ArrayList<Node> firstLabels = new ArrayList<>(1);
firstLabels.add(first);
visited.put(firstKey, firstLabels);
pending.add(new Pending(firstKey, first, distance(edge.start(), edge.end())));
for (int expanded = 0; !pending.isEmpty() && expanded < MAXIMUM_EXPANSIONS; expanded++) {
Pending next = pending.remove();
Node current = next.node();
if (!retained(visited.get(next.key()), current)) {
continue;
}
if (next.key() == lastKey) {
return new Reach(path(current), current.availableHead());
}
if (distance(current.point(), edge.end()) <= GRID_SPACING * StrictMath.sqrt(2D)) {
offer(search, current, edge.end());
}
for (int[] offset : NEIGHBOURS) {
int x = current.point().x() + offset[0] * GRID_SPACING;
int z = current.point().z() + offset[1] * GRID_SPACING;
if (x < minimumX || x > maximumX || z < minimumZ || z > maximumZ) {
continue;
}
offer(search, current, new HydrologyPoint(x, current.point().y(), z));
}
}
return new Reach(List.of(), edge.maximumHead());
}
private Reach edge(Edge edge) {
CacheState state = caches;
Reach cached = state.edges.getIfPresent(edge);
if (cached != null) {
return cached;
}
HydrologyForkJoin.Task<Reach> task = new HydrologyForkJoin.Task<>(() -> {
try {
Reach present = state.edges.getIfPresent(edge);
Reach result = present == null ? route(edge) : present;
state.edges.put(edge, result);
return result;
} finally {
state.loading.remove(edge);
}
});
HydrologyForkJoin.Task<Reach> existing = state.loading.putIfAbsent(edge, task);
return (existing == null ? task : existing).await();
}
private void offer(Search search, Node current, HydrologyPoint target) {
applyOffer(search, current, evaluateOffer(search, current, target));
}
private Offer evaluateOffer(Search search, Node current, HydrologyPoint target) {
Samples samples = search.samples();
HydrologyTerrainSample terrain = samples.sample(target.x(), target.z());
if (!allowed(terrain, search.edge())) {
return null;
}
HydrologyPoint point = new HydrologyPoint(target.x(), terrain.naturalHeight(), target.z());
double cost = current.cost() + distance(current.point(), point)
+ Math.max(0D, point.y() - current.point().y()) * planner.settings.routing().uphillPenalty()
+ Math.max(0D, terrain.routingCost() * terrain.routingMultiplier());
long key = RiverFootprint.pack(point.x(), point.z());
List<Node> labels = search.visited().get(key);
if (dominated(labels, cost, current.availableHead())) {
return null;
}
Traversal traversal = traverse(current.point(), point, search.edge(), samples, current.availableHead());
cost += traversal.cost();
if (!Double.isFinite(cost) || dominated(labels, cost, traversal.availableHead())) {
return null;
}
cost += clearanceCost(point, search.edge(), samples);
if (dominated(labels, cost, traversal.availableHead())) {
return null;
}
return new Offer(point, cost, traversal.availableHead());
}
private void applyOffer(Search search, Node current, Offer offer) {
if (offer == null) {
return;
}
long key = RiverFootprint.pack(offer.point().x(), offer.point().z());
List<Node> labels = search.visited().get(key);
if (labels == null) {
labels = new ArrayList<>(2);
search.visited().put(key, labels);
}
for (int index = labels.size() - 1; index >= 0; index--) {
Node previous = labels.get(index);
if (offer.cost() <= previous.cost() && offer.availableHead() >= previous.availableHead()) {
labels.remove(index);
}
}
Node retained = new Node(offer.point(), offer.cost(), offer.availableHead(), current);
labels.add(retained);
search.pending().add(new Pending(key, retained, offer.cost() + distance(offer.point(), search.edge().end())));
}
private static boolean dominated(List<Node> labels, double cost, int availableHead) {
if (labels == null) {
return false;
}
for (Node previous : labels) {
if (previous.cost() <= cost && previous.availableHead() >= availableHead) {
return true;
}
}
return false;
}
private static boolean retained(List<Node> labels, Node current) {
for (Node retained : labels) {
if (retained == current) {
return true;
}
}
return false;
}
private Traversal traverse(HydrologyPoint start, HydrologyPoint end, Edge edge,
Samples samples, int availableHead) {
int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z()));
HydrologyTerrainSample previous = null;
double distance = distance(start, end);
double tangentX = distance == 0D ? 1D : (end.x() - start.x()) / distance;
double tangentZ = distance == 0D ? 0D : (end.z() - start.z()) / distance;
double cost = 0D;
for (int step = 0; step <= steps; step++) {
double progress = steps == 0 ? 0D : step / (double) steps;
int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
HydrologyTerrainSample terrain = samples.sample(x, z);
if (!allowed(terrain, edge) || previous != null && (!previous.drainsInto(terrain)
|| edge.coastal() && !terrain.drainsInto(previous))) {
return new Traversal(Double.POSITIVE_INFINITY, availableHead);
}
if (!edge.coastal()) {
availableHead = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation(
x, z, tangentX, tangentZ, terrain, availableHead), samples);
if (availableHead < Math.max(edge.minimumHead(), minimumHead(terrain, x, z, edge.terminal()))) {
return new Traversal(Double.POSITIVE_INFINITY, availableHead);
}
}
cost += ridgeCost(x, z, terrain, edge);
previous = terrain;
}
return new Traversal(cost, availableHead);
}
private double ridgeCost(int x, int z, HydrologyTerrainSample terrain, Edge edge) {
if (edge.coastal()) {
return 0D;
}
double deltaX = edge.end().x() - edge.start().x();
double deltaZ = edge.end().z() - edge.start().z();
double squared = deltaX * deltaX + deltaZ * deltaZ;
double progress = squared == 0D ? 0D : Math.max(0D, Math.min(1D,
((x - edge.start().x()) * deltaX + (z - edge.start().z()) * deltaZ) / squared));
double envelope = edge.start().y() + (edge.end().y() - edge.start().y()) * progress;
int incision = terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision());
double permitted = Math.min(incision, StrictMath.floor(incision * terrain.incisionMultiplier()));
double excess = terrain.naturalHeight() - envelope + planner.settings.surface().minimumDepth()
+ planner.settings.surface().banks().sink() - permitted;
return Math.max(0D, excess) * Math.max(1D, planner.settings.routing().uphillPenalty());
}
private double clearanceCost(HydrologyPoint point, Edge edge, Samples samples) {
double radius = planner.settings.surface().maximumWidth() / 2D
* (1D + planner.settings.surface().banks().roughness()) + 1D;
int blocked = 0;
for (int[] offset : NEIGHBOURS) {
double scale = offset[0] != 0 && offset[1] != 0 ? radius / StrictMath.sqrt(2D) : radius;
int x = point.x() + (int) StrictMath.round(offset[0] * scale);
int z = point.z() + (int) StrictMath.round(offset[1] * scale);
if (!allowed(samples.sample(x, z), edge)) {
blocked++;
}
}
return blocked * GRID_SPACING / 2D;
}
private boolean allowed(HydrologyTerrainSample terrain, Edge edge) {
return terrain != null && !terrain.ocean() && terrain.naturalHeight() >= planner.settings.seaLevel() && terrain.transitAllowed()
&& terrain.preferredProfileKeys().contains(edge.profile())
&& (!edge.coastal() || terrain.naturalHeight() - planner.settings.seaLevel()
+ planner.settings.surface().minimumDepth()
<= planner.settings.routing().regional().maximumCoastalIncision());
}
private static List<HydrologyPoint> path(Node current) {
ArrayList<HydrologyPoint> points = new ArrayList<>();
while (current != null) {
points.add(current.point());
current = current.parent();
}
Collections.reverse(points);
return List.copyOf(points);
}
private static double distance(HydrologyPoint first, HydrologyPoint second) {
return StrictMath.sqrt(first.distanceSquared2D(second));
}
final class Samples implements HydrologyTerrainSampler {
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> values = new Long2ObjectOpenHashMap<>();
private final Cache<Long, Terrain> terrainCache = caches.terrain;
@Override
public HydrologyTerrainSample sample(int x, int z) {
long key = RiverFootprint.pack(x, z);
HydrologyTerrainSample sampled = values.get(key);
if (sampled != null || values.containsKey(key)) {
return sampled;
}
if (values.size() >= MAXIMUM_SAMPLES) {
return null;
}
sampled = HydrologyRegionalTerrainRefiner.this.sample(terrainCache, x, z);
values.put(key, sampled);
return sampled;
}
}
private static final class CacheState {
private final Cache<Edge, Reach> edges;
private final ConcurrentHashMap<Edge, HydrologyForkJoin.Task<Reach>> loading = new ConcurrentHashMap<>();
private final Cache<Long, Terrain> terrain;
private CacheState(HydrologyCacheBudget budget) {
edges = Caffeine.newBuilder()
.maximumWeight(budget.regionalReachBytes())
.weigher((Edge edge, Reach reach) -> HydrologyCacheWeights.bounded(
HydrologyCacheWeights.points(reach.points()), budget.regionalReachBytes(), 128))
.build();
terrain = Caffeine.newBuilder()
.maximumWeight(budget.regionalTerrainBytes())
.weigher((Long key, Terrain terrain) -> HydrologyCacheWeights.bounded(
HydrologyCacheWeights.terrain(terrain.sample()), budget.regionalTerrainBytes(),
(int) Math.max(MAXIMUM_SAMPLES, Math.min(MAXIMUM_CACHED_SAMPLES, Runtime.getRuntime().maxMemory() / 8192L))))
.build();
}
}
private record Offer(HydrologyPoint point, double cost, int availableHead) {
}
private record Edge(HydrologyPoint start, HydrologyPoint end, String profile, boolean coastal,
int minimumHead, int maximumHead, Terminal terminal) {
}
private record Terminal(HydrologyPoint landward, int possibleStations) {
}
private record Reach(List<HydrologyPoint> points, int availableHead) {
}
private record Traversal(double cost, int availableHead) {
}
private record Terrain(HydrologyTerrainSample sample) {
}
private record Search(Edge edge, Samples samples, Long2ObjectOpenHashMap<List<Node>> visited,
PriorityQueue<Pending> pending) {
}
private record Node(HydrologyPoint point, double cost, int availableHead, Node parent) {
}
private record Pending(long key, Node node, double estimate) {
private int availableHead() {
return node.availableHead();
}
}
}
@@ -15,7 +15,6 @@ final class HydrologyRouteGeometry {
this.planner = planner;
}
static final double CROSS_DROP_WEIGHT = 3D;
static final long ROUTE_ANCHOR_X_SALT = 0x52414e434858L;
static final long ROUTE_ANCHOR_Z_SALT = 0x52414e43485aL;
static final long ROUTE_WORM_PRIMARY_SALT = 0x52575052494dL;
@@ -24,22 +23,17 @@ final class HydrologyRouteGeometry {
static final double ROUTE_TANGENT_SCALE = 0.68D;
static final int ROUTE_TRANSVERSE_CANDIDATES = 2;
static final int ROUTE_FALLBACK_TRANSVERSE_CANDIDATES = 4;
static final int SURFACE_COURSE_TRANSVERSE_CANDIDATES = 2;
static final double SURFACE_COURSE_MAXIMUM_TURN_DEGREES = 50D;
static final double SURFACE_COURSE_TURN_COST = 2.5D;
static final int SURFACE_MINIMUM_NATURAL_BANK_RISE = 1;
static final int MAXIMUM_SURFACE_BORE_STEP = 4;
HydrologyPoint edgeContinuation(
HydrologySampledGrid grid,
HydrologyRoutingPlan routing,
HydrologyGridNode downstream,
RiverOutlet outlet,
boolean surface
RiverOutlet outlet
) {
int continuationIndex = routing.parent()[downstream.index()];
if (continuationIndex >= 0) {
return routeAnchor(grid.node(continuationIndex), surface);
return routeAnchor(grid.node(continuationIndex), false);
}
HydrologyPoint landward = outlet.landwardPoint();
if (landward.x() != downstream.x() || landward.z() != downstream.z()) {
@@ -177,17 +171,15 @@ final class HydrologyRouteGeometry {
return score + HydrologyHash.unit(tie) * 1.0E-6D;
}
List<HydrologyPoint> refineEdge(
List<HydrologyPoint> refineUndergroundEdge(
long upstreamId,
long downstreamId,
HydrologyPoint upstream,
HydrologyPoint downstream,
HydrologyPoint continuation,
int transverseCandidates,
boolean surface
int transverseCandidates
) {
int refinement = planner.settings.routing().refinementSpacing();
int effectiveTransverseCandidates = transverseCandidates;
double deltaX = downstream.x() - upstream.x();
double deltaZ = downstream.z() - upstream.z();
double distance = StrictMath.hypot(deltaX, deltaZ);
@@ -240,7 +232,7 @@ final class HydrologyRouteGeometry {
nominal,
progress,
refinement,
elevationTransition && !surface ? 0 : effectiveTransverseCandidates,
elevationTransition ? 0 : transverseCandidates,
upstreamTerrain == null ? null : upstreamTerrain.confinesKey()
);
if (candidates.isEmpty()) {
@@ -531,144 +523,6 @@ final class HydrologyRouteGeometry {
return List.copyOf(candidates.values());
}
List<RouteCandidate> unresolvedSurfaceRouteCandidates(
HydrologySampledGrid grid,
long upstreamId,
long downstreamId,
RoutePosition nominal,
double progress,
int refinement,
int transverseCandidates
) {
double searchEnvelope = StrictMath.sin(StrictMath.PI * progress);
LinkedHashMap<Long, RouteCandidate> candidates = new LinkedHashMap<>();
for (int offsetIndex = -transverseCandidates;
offsetIndex <= transverseCandidates;
offsetIndex++) {
double offset = offsetIndex * refinement * searchEnvelope;
int x = (int) StrictMath.round(nominal.x() - nominal.tangent().z() * offset);
int z = (int) StrictMath.round(nominal.z() + nominal.tangent().x() * offset);
long packed = RiverFootprint.pack(x, z);
RouteTerrainEstimate estimate = routeTerrainEstimate(grid, x, z);
double localScore = estimate.terrainScore()
+ StrictMath.abs(offset) * 0.18D
+ HydrologyHash.unit(HydrologyHash.mix(
planner.worldSeed,
upstreamId,
downstreamId,
x,
z
)) * 1.0E-6D;
RouteCandidate candidate = new RouteCandidate(
new HydrologyPoint(x, estimate.height(), z),
nominal.x() - nominal.tangent().z() * offset,
nominal.z() + nominal.tangent().x() * offset,
offset,
localScore,
estimate.terrainScore(),
nominal.tangent(),
false
);
RouteCandidate current = candidates.get(packed);
if (current == null || candidate.localScore() < current.localScore()) {
candidates.put(packed, candidate);
}
}
return List.copyOf(candidates.values());
}
RouteTerrainEstimate routeTerrainEstimate(HydrologySampledGrid grid, int x, int z) {
double gridX = (x - grid.minimumX()) / (double) grid.spacing();
double gridZ = (z - grid.minimumZ()) / (double) grid.spacing();
int minimumGridX = Math.max(0, Math.min(grid.width() - 1, (int) StrictMath.floor(gridX)));
int minimumGridZ = Math.max(0, Math.min(grid.width() - 1, (int) StrictMath.floor(gridZ)));
int maximumGridX = Math.min(grid.width() - 1, minimumGridX + 1);
int maximumGridZ = Math.min(grid.width() - 1, minimumGridZ + 1);
double progressX = Math.max(0D, Math.min(1D, gridX - minimumGridX));
double progressZ = Math.max(0D, Math.min(1D, gridZ - minimumGridZ));
HydrologyTerrainSample northwest = grid.nodeAt(minimumGridX, minimumGridZ).terrain();
HydrologyTerrainSample northeast = grid.nodeAt(maximumGridX, minimumGridZ).terrain();
HydrologyTerrainSample southwest = grid.nodeAt(minimumGridX, maximumGridZ).terrain();
HydrologyTerrainSample southeast = grid.nodeAt(maximumGridX, maximumGridZ).terrain();
double height = planner.routePaths.bilinear(
northwest.naturalHeight(),
northeast.naturalHeight(),
southwest.naturalHeight(),
southeast.naturalHeight(),
progressX,
progressZ
);
double terrainScore = planner.routePaths.bilinear(
routeTerrainScore(northwest),
routeTerrainScore(northeast),
routeTerrainScore(southwest),
routeTerrainScore(southeast),
progressX,
progressZ
);
return new RouteTerrainEstimate((int) StrictMath.round(height), terrainScore);
}
RouteCandidate resolveRouteCandidate(RouteCandidate candidate) {
if (candidate.terrainResolved()) {
return candidate;
}
HydrologyPoint point = candidate.point();
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
if (terrain == null || !terrain.transitAllowed()) {
return new RouteCandidate(
point,
candidate.continuousX(),
candidate.continuousZ(),
candidate.offset(),
Double.POSITIVE_INFINITY,
Double.POSITIVE_INFINITY,
candidate.tangent(),
true
);
}
double terrainScore = routeTerrainScore(terrain) + crossDropPenalty(point, candidate.tangent(), terrain);
double exactLocalScore = candidate.localScore() - candidate.terrainScore() + terrainScore;
return new RouteCandidate(
new HydrologyPoint(point.x(), terrain.naturalHeight(), point.z()),
candidate.continuousX(),
candidate.continuousZ(),
candidate.offset(),
exactLocalScore,
terrainScore,
candidate.tangent(),
true
);
}
/**
* Ground that falls away beside a candidate forces the valley solver to cut the channel down to the
* lower bank; costing that drop keeps refined routes on valley floors and contour lines instead of
* traversing hillsides that the incision cap would later reject.
*/
double crossDropPenalty(HydrologyPoint point, RouteDirection tangent, HydrologyTerrainSample center) {
double length = StrictMath.hypot(tangent.x(), tangent.z());
if (length <= 0D) {
return 0D;
}
double normalX = -tangent.z() / length;
double normalZ = tangent.x() / length;
double reach = planner.settings.surface().maximumWidth() / 2D + planner.settings.surface().shoreWidth() + 2D;
int lowest = center.naturalHeight();
for (double distance = reach / 2D; distance <= reach; distance += reach / 2D) {
for (double direction = -1D; direction <= 1D; direction += 2D) {
double side = planner.sampleLandHeight(
(int) StrictMath.round(point.x() + normalX * distance * direction),
(int) StrictMath.round(point.z() + normalZ * distance * direction));
if (!Double.isNaN(side)) {
lowest = Math.min(lowest, (int) side);
}
}
}
double drop = center.naturalHeight() - lowest;
return drop * planner.settings.routing().valleyPreference() * CROSS_DROP_WEIGHT;
}
double routeTerrainScore(HydrologyTerrainSample terrain) {
return terrain.naturalHeight() * planner.settings.routing().valleyPreference()
+ terrain.slope() * planner.settings.routing().slopePenalty()
@@ -791,414 +645,6 @@ final class HydrologyRouteGeometry {
return null;
}
RouteCandidate[] selectCurvatureAwareTerrainRoute(
List<List<RouteCandidate>> layers,
double targetStepLength,
double maximumTurn,
double turnCost
) {
int layerCount = layers.size();
if (layerCount < 2) {
return new RouteCandidate[0];
}
double[][] localPenalties = new double[layerCount][];
double[][][] transitionCosts = new double[layerCount][][];
double[][][][] turnCosts = new double[layerCount][][][];
for (int layerIndex = 0; layerIndex < layerCount; layerIndex++) {
int layerSize = layers.get(layerIndex).size();
localPenalties[layerIndex] = new double[layerSize];
if (layerIndex > 0 && layerIndex < layerCount - 1) {
Arrays.fill(localPenalties[layerIndex], Double.NaN);
}
if (layerIndex == 0) {
continue;
}
int previousSize = layers.get(layerIndex - 1).size();
transitionCosts[layerIndex] = new double[previousSize][layerSize];
for (double[] row : transitionCosts[layerIndex]) {
Arrays.fill(row, Double.NaN);
}
}
CurvatureWorkspace workspace = new CurvatureWorkspace(layers);
while (true) {
CurvatureRouteSelection selection = selectCurvatureAwareTerrainRoute(
layers,
targetStepLength,
maximumTurn,
turnCost,
localPenalties,
transitionCosts,
turnCosts,
workspace
);
if (selection.route().length == 0) {
return selection.route();
}
boolean evaluated = evaluateSelectedSurfaceCosts(
selection,
layers,
localPenalties,
transitionCosts,
targetStepLength
);
if (!evaluated) {
return selection.route();
}
}
}
CurvatureRouteSelection selectCurvatureAwareTerrainRoute(
List<List<RouteCandidate>> layers,
double targetStepLength,
double maximumTurn,
double turnCost,
double[][] localPenalties,
double[][][] transitionCosts,
double[][][][] turnCosts,
CurvatureWorkspace workspace
) {
int layerCount = layers.size();
double[][] previousCosts = workspace.firstCosts;
double[][] currentCosts = workspace.secondCosts;
int[][][] predecessors = workspace.predecessors;
List<RouteCandidate> firstLayer = layers.getFirst();
List<RouteCandidate> secondLayer = layers.get(1);
for (int firstIndex = 0; firstIndex < firstLayer.size(); firstIndex++) {
Arrays.fill(previousCosts[firstIndex], Double.POSITIVE_INFINITY);
Arrays.fill(predecessors[1][firstIndex], -1);
for (int secondIndex = 0; secondIndex < secondLayer.size(); secondIndex++) {
RouteCandidate first = firstLayer.get(firstIndex);
RouteCandidate second = secondLayer.get(secondIndex);
double transition = resolvedTransitionCost(
first,
second,
targetStepLength,
transitionCosts[1][firstIndex][secondIndex],
workspace,
1,
firstIndex,
secondIndex
);
if (Double.isFinite(transition)) {
previousCosts[firstIndex][secondIndex] = first.localScore()
+ second.localScore()
+ resolvedLocalPenalty(localPenalties[1][secondIndex])
+ transition;
}
}
}
for (int layerIndex = 2; layerIndex < layerCount; layerIndex++) {
List<RouteCandidate> beforeLayer = layers.get(layerIndex - 2);
List<RouteCandidate> previousLayer = layers.get(layerIndex - 1);
List<RouteCandidate> currentLayer = layers.get(layerIndex);
if (turnCosts[layerIndex] == null) {
turnCosts[layerIndex] = new double[previousLayer.size()][currentLayer.size()][];
}
for (int previousIndex = 0; previousIndex < previousLayer.size(); previousIndex++) {
Arrays.fill(currentCosts[previousIndex], Double.POSITIVE_INFINITY);
Arrays.fill(predecessors[layerIndex][previousIndex], -1);
RouteCandidate previous = previousLayer.get(previousIndex);
for (int currentIndex = 0; currentIndex < currentLayer.size(); currentIndex++) {
RouteCandidate current = currentLayer.get(currentIndex);
double transition = resolvedTransitionCost(
previous,
current,
targetStepLength,
transitionCosts[layerIndex][previousIndex][currentIndex],
workspace,
layerIndex,
previousIndex,
currentIndex
);
if (!Double.isFinite(transition)) {
continue;
}
double localPenalty = resolvedLocalPenalty(localPenalties[layerIndex][currentIndex]);
for (int beforeIndex = 0; beforeIndex < beforeLayer.size(); beforeIndex++) {
double previousCost = previousCosts[beforeIndex][previousIndex];
if (!Double.isFinite(previousCost)) {
continue;
}
double[] candidateTurns = turnCosts[layerIndex][previousIndex][currentIndex];
if (candidateTurns == null) {
candidateTurns = new double[beforeLayer.size()];
Arrays.fill(candidateTurns, Double.NaN);
turnCosts[layerIndex][previousIndex][currentIndex] = candidateTurns;
}
double turnPenalty = candidateTurns[beforeIndex];
if (Double.isNaN(turnPenalty)) {
double turn = continuousTurnDegrees(
beforeLayer.get(beforeIndex),
previous,
current
);
turnPenalty = turn > maximumTurn
? Double.POSITIVE_INFINITY
: curvatureCost(turn, maximumTurn, turnCost);
candidateTurns[beforeIndex] = turnPenalty;
}
if (turnPenalty == Double.POSITIVE_INFINITY) {
continue;
}
double cost = previousCost
+ current.localScore()
+ localPenalty
+ transition
+ turnPenalty;
if (cost < currentCosts[previousIndex][currentIndex]) {
currentCosts[previousIndex][currentIndex] = cost;
predecessors[layerIndex][previousIndex][currentIndex] = beforeIndex;
}
}
}
}
double[][] completedCosts = currentCosts;
currentCosts = previousCosts;
previousCosts = completedCosts;
}
int lastLayerIndex = layerCount - 1;
int selectedPreviousIndex = -1;
int selectedCurrentIndex = -1;
double selectedCost = Double.POSITIVE_INFINITY;
for (int previousIndex = 0; previousIndex < layers.get(lastLayerIndex - 1).size(); previousIndex++) {
for (int currentIndex = 0;
currentIndex < layers.get(lastLayerIndex).size();
currentIndex++) {
if (previousCosts[previousIndex][currentIndex] < selectedCost) {
selectedCost = previousCosts[previousIndex][currentIndex];
selectedPreviousIndex = previousIndex;
selectedCurrentIndex = currentIndex;
}
}
}
if (!Double.isFinite(selectedCost)) {
return CurvatureRouteSelection.empty();
}
RouteCandidate[] route = new RouteCandidate[layerCount];
int[] indices = new int[layerCount];
route[lastLayerIndex] = layers.get(lastLayerIndex).get(selectedCurrentIndex);
route[lastLayerIndex - 1] = layers.get(lastLayerIndex - 1).get(selectedPreviousIndex);
indices[lastLayerIndex] = selectedCurrentIndex;
indices[lastLayerIndex - 1] = selectedPreviousIndex;
for (int layerIndex = lastLayerIndex; layerIndex >= 2; layerIndex--) {
int beforeIndex = predecessors[layerIndex][selectedPreviousIndex][selectedCurrentIndex];
if (beforeIndex < 0) {
return CurvatureRouteSelection.empty();
}
route[layerIndex - 2] = layers.get(layerIndex - 2).get(beforeIndex);
indices[layerIndex - 2] = beforeIndex;
selectedCurrentIndex = selectedPreviousIndex;
selectedPreviousIndex = beforeIndex;
}
return new CurvatureRouteSelection(route, indices);
}
boolean evaluateSelectedSurfaceCosts(
CurvatureRouteSelection selection,
List<List<RouteCandidate>> 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<RouteCandidate> updatedLayer = new ArrayList<>(layers.get(layerIndex));
updatedLayer.set(indices[layerIndex], resolved);
layers.set(layerIndex, List.copyOf(updatedLayer));
route[layerIndex] = resolved;
evaluated = true;
}
for (int layerIndex = 1; layerIndex < route.length - 1; layerIndex++) {
int candidateIndex = indices[layerIndex];
if (!Double.isNaN(localPenalties[layerIndex][candidateIndex])) {
continue;
}
if (!Double.isFinite(route[layerIndex].localScore())) {
localPenalties[layerIndex][candidateIndex] = 0D;
continue;
}
localPenalties[layerIndex][candidateIndex] = planner.surfaceCourses.surfaceRouteCandidateBankPenalty(
route[layerIndex]
);
evaluated = true;
}
for (int layerIndex = 1; layerIndex < route.length; layerIndex++) {
int previousIndex = indices[layerIndex - 1];
int currentIndex = indices[layerIndex];
if (!Double.isNaN(transitionCosts[layerIndex][previousIndex][currentIndex])) {
continue;
}
RouteCandidate previous = route[layerIndex - 1];
RouteCandidate current = route[layerIndex];
if (!Double.isFinite(previous.localScore()) || !Double.isFinite(current.localScore())) {
transitionCosts[layerIndex][previousIndex][currentIndex] = Double.POSITIVE_INFINITY;
continue;
}
double transition = planner.routePaths.routeTransitionCost(previous, current, targetStepLength);
if (Double.isFinite(transition)) {
transition += surfaceRouteTransitionPenalty(previous, current);
}
transitionCosts[layerIndex][previousIndex][currentIndex] = transition;
evaluated = true;
}
return evaluated;
}
double resolvedLocalPenalty(double penalty) {
return Double.isNaN(penalty) ? 0D : penalty;
}
double resolvedTransitionCost(
RouteCandidate previous,
RouteCandidate current,
double targetStepLength,
double exactCost,
CurvatureWorkspace workspace,
int layerIndex,
int previousIndex,
int currentIndex
) {
if (!Double.isNaN(exactCost)) {
return exactCost;
}
long heights = ((long) previous.point().y() << 32) | (current.point().y() & 0xffffffffL);
if (!workspace.geometryKnown[layerIndex][previousIndex][currentIndex]
|| workspace.geometryHeights[layerIndex][previousIndex][currentIndex] != heights) {
workspace.geometryCosts[layerIndex][previousIndex][currentIndex] =
planner.routePaths.routeTransitionGeometryCost(previous, current, targetStepLength);
workspace.geometryHeights[layerIndex][previousIndex][currentIndex] = heights;
workspace.geometryKnown[layerIndex][previousIndex][currentIndex] = true;
}
return workspace.geometryCosts[layerIndex][previousIndex][currentIndex];
}
double curvatureCost(double turn, double maximumTurn, double turnCost) {
double excess = Math.max(0D, turn - 8D);
return excess * excess * turnCost / maximumTurn;
}
double continuousTurnDegrees(RouteCandidate before, RouteCandidate center, RouteCandidate after) {
double incomingX = center.continuousX() - before.continuousX();
double incomingZ = center.continuousZ() - before.continuousZ();
double outgoingX = after.continuousX() - center.continuousX();
double outgoingZ = after.continuousZ() - center.continuousZ();
double length = StrictMath.hypot(incomingX, incomingZ) * StrictMath.hypot(outgoingX, outgoingZ);
if (length <= 0D) {
return 0D;
}
double cosine = (incomingX * outgoingX + incomingZ * outgoingZ) / length;
return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D, cosine))));
}
double surfaceRouteTransitionPenalty(
RouteCandidate start,
RouteCandidate end
) {
int maximumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().maximumWidth());
RouteDirection tangent = planner.routePaths.direction(start.point().x(), start.point().z(), end.point().x(), end.point().z());
int endpointCeiling = Math.max(start.point().y(), end.point().y());
double penalty = 0D;
List<HydrologyPoint> raster = planner.segments.rasterLine(start.point(), end.point());
int sampleStride = Math.max(1, planner.settings.routing().refinementSpacing() / 4);
for (int pointIndex = 0; pointIndex < raster.size(); pointIndex += sampleStride) {
HydrologyPoint point = raster.get(pointIndex);
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
if (terrain == null) {
return 1.0E12D;
}
int permittedIncision = planner.sourcePlanner.permittedSurfaceIncision(terrain);
int ridgeExcess = Math.max(
0,
terrain.naturalHeight() - endpointCeiling - permittedIncision
);
penalty += (double) ridgeExcess * ridgeExcess * 65536D;
if (terrain.slope() >= planner.settings.hydraulics().waterfallMinimumDrop()) {
continue;
}
int head = Math.subtractExact(
terrain.naturalHeight(),
planner.settings.surface().banks().sink()
);
penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty(
point,
tangent,
maximumBankDistance,
head,
planner.surfaceCourses.maximumSurfaceBankRise(terrain)
);
}
if ((raster.size() - 1) % sampleStride != 0) {
HydrologyPoint point = raster.getLast();
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
if (terrain == null) {
return 1.0E12D;
}
int permittedIncision = planner.sourcePlanner.permittedSurfaceIncision(terrain);
int ridgeExcess = Math.max(
0,
terrain.naturalHeight() - endpointCeiling - permittedIncision
);
penalty += (double) ridgeExcess * ridgeExcess * 65536D;
if (terrain.slope() < planner.settings.hydraulics().waterfallMinimumDrop()) {
int head = Math.subtractExact(
terrain.naturalHeight(),
planner.settings.surface().banks().sink()
);
penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty(
point,
tangent,
maximumBankDistance,
head,
planner.surfaceCourses.maximumSurfaceBankRise(terrain)
);
}
}
return penalty;
}
static final class CurvatureWorkspace {
private final double[][] firstCosts;
private final double[][] secondCosts;
private final int[][][] predecessors;
private final double[][][] geometryCosts;
private final long[][][] geometryHeights;
private final boolean[][][] geometryKnown;
CurvatureWorkspace(List<? extends List<?>> layers) {
int maximumCandidates = 0;
for (List<?> layer : layers) {
maximumCandidates = Math.max(maximumCandidates, layer.size());
}
firstCosts = new double[maximumCandidates][maximumCandidates];
secondCosts = new double[maximumCandidates][maximumCandidates];
predecessors = new int[layers.size()][][];
geometryCosts = new double[layers.size()][][];
geometryHeights = new long[layers.size()][][];
geometryKnown = new boolean[layers.size()][][];
for (int layer = 1; layer < layers.size(); layer++) {
int previousSize = layers.get(layer - 1).size();
int currentSize = layers.get(layer).size();
predecessors[layer] = new int[previousSize][currentSize];
geometryCosts[layer] = new double[previousSize][currentSize];
geometryHeights[layer] = new long[previousSize][currentSize];
geometryKnown[layer] = new boolean[previousSize][currentSize];
}
}
}
record CurvatureRouteSelection(RouteCandidate[] route, int[] indices) {
private static CurvatureRouteSelection empty() {
return new CurvatureRouteSelection(new RouteCandidate[0], new int[0]);
}
}
record TerrainTransitionKey(HydrologyPoint start, HydrologyPoint end) {
}
}
@@ -23,19 +23,6 @@ final class HydrologyRoutePath {
return false;
}
double bilinear(
double northwest,
double northeast,
double southwest,
double southeast,
double progressX,
double progressZ
) {
double north = northwest + (northeast - northwest) * progressX;
double south = southwest + (southeast - southwest) * progressX;
return north + (south - north) * progressZ;
}
int removeTerrainPits(List<List<RouteCandidate>> layers, RouteCandidate[] route) {
int removed = 0;
for (int routeIndex = 1; routeIndex < route.length - 1; routeIndex++) {
@@ -8,6 +8,7 @@ record HydrologyRoutingPlan(
int[] outletIndex,
int[] routeLengths,
List<OutletCandidate> outlets,
boolean organicSurfaceRequired
boolean organicSurfaceRequired,
HydrologyDrainageGeometry surfaceDrainage
) {
}
@@ -2,16 +2,77 @@ package art.arcane.iris.generation.hydrology;
import java.util.List;
record HydrologySampledGrid(
int minimumX,
int minimumZ,
int ownerMinimumX,
int ownerMinimumZ,
int ownerSize,
int width,
int spacing,
List<HydrologyGridNode> nodes
) {
final class HydrologySampledGrid {
private final int minimumX;
private final int minimumZ;
private final int ownerMinimumX;
private final int ownerMinimumZ;
private final int ownerSize;
private final int width;
private final int spacing;
private final List<HydrologyGridNode> nodes;
private volatile HydrologyDrainageGraph drainage;
HydrologySampledGrid(int minimumX, int minimumZ, int ownerMinimumX, int ownerMinimumZ,
int ownerSize, int width, int spacing, List<HydrologyGridNode> nodes) {
this.minimumX = minimumX;
this.minimumZ = minimumZ;
this.ownerMinimumX = ownerMinimumX;
this.ownerMinimumZ = ownerMinimumZ;
this.ownerSize = ownerSize;
this.width = width;
this.spacing = spacing;
this.nodes = List.copyOf(nodes);
}
int minimumX() {
return minimumX;
}
int minimumZ() {
return minimumZ;
}
int ownerMinimumX() {
return ownerMinimumX;
}
int ownerMinimumZ() {
return ownerMinimumZ;
}
int ownerSize() {
return ownerSize;
}
int width() {
return width;
}
int spacing() {
return spacing;
}
List<HydrologyGridNode> nodes() {
return nodes;
}
HydrologyDrainageGraph drainage(HydrologySourcePlanner sourcePlanner) {
HydrologyDrainageGraph current = drainage;
if (current != null && current.settings().equals(sourcePlanner.settings())) {
return current;
}
synchronized (this) {
current = drainage;
if (current == null || !current.settings().equals(sourcePlanner.settings())) {
current = new HydrologyDrainageGraph(this, sourcePlanner);
drainage = current;
}
return current;
}
}
HydrologyGridNode node(int index) {
return nodes.get(index);
}
@@ -21,6 +21,10 @@ final class HydrologySourcePlanner {
this.planner = planner;
}
HydrologyPlannerSettings settings() {
return planner.settings;
}
static final long EDGE_SALT = 0x45444745L;
static final long SURFACE_SOURCE_SALT = 0x53555246414345L;
static final long UNDERGROUND_SOURCE_SALT = 0x554e444552L;
@@ -70,10 +74,7 @@ final class HydrologySourcePlanner {
double[] potential = new double[nodeCount];
int[] parent = new int[nodeCount];
int[] outletIndex = new int[nodeCount];
int[] minimumNeighborHeights = new int[nodeCount];
for (HydrologyGridNode node : grid.nodes()) {
minimumNeighborHeights[node.index()] = minimumNeighborHeight(grid, node);
}
HydrologyDrainageGraph drainage = grid.drainage(this);
Arrays.fill(potential, Double.POSITIVE_INFINITY);
Arrays.fill(parent, -1);
Arrays.fill(outletIndex, -1);
@@ -91,25 +92,13 @@ final class HydrologySourcePlanner {
continue;
}
HydrologyGridNode downstream = grid.node(currentEntry.nodeIndex());
for (HydrologyGridOffset offset : ROUTING_OFFSETS) {
HydrologyGridNode upstream = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z());
if (upstream == null || upstream.terrain().ocean() || !upstream.terrain().transitAllowed()
|| !upstream.terrain().drainsInto(downstream.terrain())
|| surface && !HydrologySurfaceProfiles.sharesProfile(upstream.terrain(), downstream.terrain())) {
// Confined ground only drains into its own area, so every course that starts or
// arrives there keeps to that area up to and including its outlet.
for (int direction = 0; direction < ROUTING_OFFSETS.size(); direction++) {
int upstreamIndex = drainage.upstream(downstream.index(), direction, surface);
if (upstreamIndex < 0) {
continue;
}
if (surface && downstream.terrain().naturalHeight() - upstream.terrain().naturalHeight()
> maximumSurfaceEdgeRise(downstream.terrain())) {
continue;
}
double edgeCost = routeCost(
upstream,
downstream,
offset,
minimumNeighborHeights[downstream.index()]
);
HydrologyGridNode upstream = grid.node(upstreamIndex);
double edgeCost = drainage.cost(downstream.index(), direction);
double candidatePotential = currentEntry.potential() + edgeCost;
int existingParent = parent[upstream.index()];
boolean replace = candidatePotential < potential[upstream.index()] - 1.0E-9D;
@@ -135,7 +124,9 @@ final class HydrologySourcePlanner {
outletIndex,
routeLengths(parent, grid.width(), planner.settings.routing()),
List.copyOf(outlets),
false
false,
surface ? new HydrologyDrainageGeometry(planner,
new HydrologyDrainageGeometry.Basin(grid, parent, outletIndex, List.copyOf(outlets))) : null
);
}
@@ -160,7 +151,8 @@ final class HydrologySourcePlanner {
routing.outletIndex(),
routing.routeLengths(),
routing.outlets(),
true
true,
routing.surfaceDrainage()
);
}
@@ -985,75 +977,75 @@ final class HydrologySourcePlanner {
HydrologyGridNode downstream = grid.node(downstreamIndex);
RiverOutlet outlet = routing.outlets().get(routing.outletIndex()[upstream.index()]).outlet();
long edgeId = HydrologyHash.mix(planner.worldSeed, EDGE_SALT, upstream.id(), downstream.id(), outlet.id());
long refinementId = surface ? HydrologyHash.mix(edgeId, SURFACE_SOURCE_SALT) : edgeId;
List<HydrologyPoint> centerline = refinedEdges.get(refinementId);
int transverseCandidates = HydrologyRouteGeometry.ROUTE_TRANSVERSE_CANDIDATES;
if (centerline == null) {
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface);
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface);
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface);
RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey(
upstream.id(),
downstream.id(),
upstreamAnchor.x(),
upstreamAnchor.z(),
downstreamAnchor.x(),
downstreamAnchor.z(),
continuation.x(),
continuation.z(),
transverseCandidates,
surface
);
centerline = planner.refinedEdgeCache.get(
refinedEdgeKey,
ignored -> planner.routeGeometry.refineEdge(
upstream.id(),
downstream.id(),
upstreamAnchor,
downstreamAnchor,
continuation,
transverseCandidates,
surface
)
);
refinedEdges.put(refinementId, centerline);
}
if (centerline.isEmpty()) {
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, surface);
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, surface);
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet, surface);
RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey(
upstream.id(),
downstream.id(),
upstreamAnchor.x(),
upstreamAnchor.z(),
downstreamAnchor.x(),
downstreamAnchor.z(),
continuation.x(),
continuation.z(),
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES,
surface
);
centerline = planner.refinedEdgeCache.get(
refinedEdgeKey,
ignored -> planner.routeGeometry.refineEdge(
upstream.id(),
downstream.id(),
upstreamAnchor,
downstreamAnchor,
continuation,
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES,
surface
)
);
refinedEdges.put(refinementId, centerline);
}
if (centerline.isEmpty()) {
centerline = planner.routeGeometry.constrainedTerrainFallbackEdge(
planner.routeGeometry.routeAnchor(upstream, surface),
planner.routeGeometry.routeAnchor(downstream, surface)
);
refinedEdges.put(refinementId, centerline);
List<HydrologyPoint> centerline;
if (surface) {
centerline = routing.surfaceDrainage().edge(upstream.index());
} else {
centerline = refinedEdges.get(edgeId);
int transverseCandidates = HydrologyRouteGeometry.ROUTE_TRANSVERSE_CANDIDATES;
if (centerline == null) {
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, false);
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, false);
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet);
RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey(
upstream.id(),
downstream.id(),
upstreamAnchor.x(),
upstreamAnchor.z(),
downstreamAnchor.x(),
downstreamAnchor.z(),
continuation.x(),
continuation.z(),
transverseCandidates
);
centerline = planner.refinedEdgeCache.get(
refinedEdgeKey,
ignored -> planner.routeGeometry.refineUndergroundEdge(
upstream.id(),
downstream.id(),
upstreamAnchor,
downstreamAnchor,
continuation,
transverseCandidates
)
);
refinedEdges.put(edgeId, centerline);
}
if (centerline.isEmpty()) {
HydrologyPoint upstreamAnchor = planner.routeGeometry.routeAnchor(upstream, false);
HydrologyPoint downstreamAnchor = planner.routeGeometry.routeAnchor(downstream, false);
HydrologyPoint continuation = planner.routeGeometry.edgeContinuation(grid, routing, downstream, outlet);
RefinedEdgeKey refinedEdgeKey = new RefinedEdgeKey(
upstream.id(),
downstream.id(),
upstreamAnchor.x(),
upstreamAnchor.z(),
downstreamAnchor.x(),
downstreamAnchor.z(),
continuation.x(),
continuation.z(),
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES
);
centerline = planner.refinedEdgeCache.get(
refinedEdgeKey,
ignored -> planner.routeGeometry.refineUndergroundEdge(
upstream.id(),
downstream.id(),
upstreamAnchor,
downstreamAnchor,
continuation,
HydrologyRouteGeometry.ROUTE_FALLBACK_TRANSVERSE_CANDIDATES
)
);
refinedEdges.put(edgeId, centerline);
}
if (centerline.isEmpty()) {
centerline = planner.routeGeometry.constrainedTerrainFallbackEdge(
planner.routeGeometry.routeAnchor(upstream, false),
planner.routeGeometry.routeAnchor(downstream, false)
);
refinedEdges.put(edgeId, centerline);
}
}
if (centerline.isEmpty()) {
continue;
@@ -1081,7 +1073,8 @@ final class HydrologySourcePlanner {
}
int outletIndex = routing.outletIndex()[node.index()];
RiverOutlet outlet = routing.outlets().get(outletIndex).outlet();
HydrologyPoint anchor = planner.routeGeometry.routeAnchor(node, surface);
HydrologyPoint anchor = surface ? routing.surfaceDrainage().anchor(node.index())
: planner.routeGeometry.routeAnchor(node, false);
HydrologyTerrainSample terrain = Objects.requireNonNull(
planner.sampleLandBasis(anchor.x(), anchor.z()),
"Hydrology route anchor left natural land"
@@ -9,8 +9,8 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Objects;
final class HydrologySurfaceCoursePlanner {
private final HydrologyPlanner planner;
@@ -28,7 +28,6 @@ final class HydrologySurfaceCoursePlanner {
static final double SURFACE_GRID_LOCK_TOLERANCE_DEGREES = 5D;
static final double SURFACE_MAXIMUM_GRID_LOCKED_FRACTION = 0.5D;
static final double SURFACE_MAXIMUM_GRID_LOCKED_RUN = 64D;
static final double SURFACE_MAXIMUM_WIDTH_SCALE = 1.18D;
void compileSurfaceCourses(
HydrologyTileKey key,
@@ -407,224 +406,8 @@ final class HydrologySurfaceCoursePlanner {
if (guide.size() < 2 || guideEdges.size() != guide.size() - 1) {
return null;
}
ArrayList<HydrologyPoint> routedGuide = new ArrayList<>(guide);
ArrayList<DrainageEdge> routedGuideEdges = new ArrayList<>(guideEdges);
HydrologyPoint landward = outlet.landwardPoint();
HydrologyPoint guideEnd = routedGuide.getLast();
boolean terminateAtSinkhole = planner.segments.surfaceSinkhole(outlet)
&& (guideEnd.x() != landward.x() || guideEnd.z() != landward.z());
if (terminateAtSinkhole) {
routedGuide.set(routedGuide.size() - 1, landward);
}
List<HydrologyPoint> surfaceGuide = planner.routePaths.withoutDuplicateRoutePoints(routedGuide);
long geometryId = HydrologyHash.mix(
planner.worldSeed,
COURSE_SALT,
HydrologySourcePlanner.SURFACE_SOURCE_SALT,
edges.getFirst().upstreamNodeId(),
outlet.id()
);
Objects.requireNonNull(grid, "Surface course routing grid is required");
HydrologyPlanner.PlanningSamples samples = planner.planningSamples.get();
DraftProfile profile = planner.currentDraftProfile();
profile.routeCalls++;
List<HydrologyPoint> points;
if (samples == null) {
profile.routeSolves++;
long solveStarted = System.nanoTime();
points = surfaceCourseCenterline(grid, surfaceGuide, geometryId);
profile.routeSolveNanos += System.nanoTime() - solveStarted;
} else {
HashMap<SurfaceRouteKey, List<HydrologyPoint>> routes = samples.surfaceRoutes.computeIfAbsent(
grid, ignored -> new HashMap<>()
);
SurfaceRouteKey routeKey = new SurfaceRouteKey(surfaceGuide, geometryId);
points = routes.computeIfAbsent(routeKey, ignored -> {
profile.routeSolves++;
long solveStarted = System.nanoTime();
List<HydrologyPoint> solved = surfaceCourseCenterline(grid, surfaceGuide, geometryId);
profile.routeSolveNanos += System.nanoTime() - solveStarted;
return solved;
});
}
if (points.size() < 2) {
return null;
}
List<DrainageEdge> pairEdges = mapCoursePairs(points, routedGuide, routedGuideEdges);
if (pairEdges.size() != points.size() - 1) {
return null;
}
return new HydrologyCoursePath(
points,
pairEdges,
List.copyOf(edges),
outlet,
true,
routing.organicSurfaceRequired(),
null
);
}
List<HydrologyPoint> surfaceCourseCenterline(
HydrologySampledGrid grid,
List<HydrologyPoint> guide,
long geometryId
) {
if (guide.size() < 2) {
return List.of();
}
int refinement = planner.settings.routing().refinementSpacing();
List<SurfaceCurvePoint> base = smoothSurfaceGuide(resampleSurfaceGuide(guide, refinement));
List<SurfaceCurvePoint> nominal = meanderSurfaceGuide(base, geometryId, refinement);
if (nominal.size() < 2) {
return List.of();
}
ArrayList<List<RouteCandidate>> layers = new ArrayList<>(nominal.size());
int transverseCandidates = HydrologyRouteGeometry.SURFACE_COURSE_TRANSVERSE_CANDIDATES;
for (int pointIndex = 0; pointIndex < nominal.size(); pointIndex++) {
SurfaceCurvePoint point = nominal.get(pointIndex);
RouteDirection tangent = surfaceCurveTangent(nominal, pointIndex);
if (pointIndex == 0 || pointIndex == nominal.size() - 1) {
HydrologyPoint endpoint = pointIndex == 0 ? guide.getFirst() : guide.getLast();
layers.add(List.of(planner.routeGeometry.routeEndpoint(endpoint, tangent)));
continue;
}
double progress = pointIndex / (double) (nominal.size() - 1);
SurfaceCurvePoint basePoint = base.get(pointIndex);
RoutePosition position = new RoutePosition(
point.x(),
point.z(),
basePoint.x(),
basePoint.z(),
tangent
);
long candidateId = HydrologyHash.mix(geometryId, pointIndex);
List<RouteCandidate> candidates = planner.routeGeometry.unresolvedSurfaceRouteCandidates(
grid,
geometryId,
candidateId,
position,
progress,
refinement,
transverseCandidates
);
if (candidates.isEmpty()) {
return List.of();
}
layers.add(candidates);
}
ArrayList<List<RouteCandidate>> availableLayers = new ArrayList<>(layers);
double maximumTurn = Math.min(
HydrologyRouteGeometry.SURFACE_COURSE_MAXIMUM_TURN_DEGREES,
planner.settings.geometry().meanders().maximumTurnDegrees()
);
while (true) {
RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute(
availableLayers,
refinement,
maximumTurn,
HydrologyRouteGeometry.SURFACE_COURSE_TURN_COST
);
if (route.length == 0) {
if (addExhaustedSurfaceRouteFallbacks(
grid,
availableLayers,
nominal,
base,
geometryId
)) {
continue;
}
return List.of();
}
int pitRemoval = planner.routePaths.removeTerrainPits(availableLayers, route);
if (pitRemoval < 0) {
return List.of();
}
if (pitRemoval > 0) {
continue;
}
ArrayList<HydrologyPoint> points = new ArrayList<>(route.length);
for (RouteCandidate candidate : route) {
HydrologyPoint point = candidate.point();
if (points.isEmpty()
|| point.x() != points.getLast().x()
|| point.z() != points.getLast().z()) {
points.add(point);
}
}
if (points.size() < 2 || !planner.routePaths.traversableRoute(points)) {
return List.of();
}
return planner.routePaths.smoothTerrainRoute(points);
}
}
boolean addExhaustedSurfaceRouteFallbacks(
HydrologySampledGrid grid,
List<List<RouteCandidate>> layers,
List<SurfaceCurvePoint> nominal,
List<SurfaceCurvePoint> base,
long geometryId
) {
boolean added = false;
for (int layerIndex = 1; layerIndex < layers.size() - 1; layerIndex++) {
List<RouteCandidate> layer = layers.get(layerIndex);
if (!exhaustedSurfaceRouteLayer(layer)) {
continue;
}
SurfaceCurvePoint nominalPoint = nominal.get(layerIndex);
SurfaceCurvePoint basePoint = base.get(layerIndex);
RouteDirection tangent = surfaceCurveTangent(nominal, layerIndex);
int fallbackX = (int) StrictMath.round(basePoint.x());
int fallbackZ = (int) StrictMath.round(basePoint.z());
long packed = RiverFootprint.pack(fallbackX, fallbackZ);
boolean present = false;
for (RouteCandidate candidate : layer) {
if (RiverFootprint.pack(candidate.point().x(), candidate.point().z()) == packed) {
present = true;
break;
}
}
if (present) {
continue;
}
RouteTerrainEstimate estimate = planner.routeGeometry.routeTerrainEstimate(grid, fallbackX, fallbackZ);
double offset = (basePoint.x() - nominalPoint.x()) * -tangent.z()
+ (basePoint.z() - nominalPoint.z()) * tangent.x();
double localScore = estimate.terrainScore()
+ StrictMath.abs(offset) * 0.18D
+ HydrologyHash.unit(HydrologyHash.mix(
planner.worldSeed,
geometryId,
HydrologyHash.mix(geometryId, layerIndex),
fallbackX,
fallbackZ
)) * 1.0E-6D;
ArrayList<RouteCandidate> expanded = new ArrayList<>(layer);
expanded.add(new RouteCandidate(
new HydrologyPoint(fallbackX, estimate.height(), fallbackZ),
basePoint.x(),
basePoint.z(),
offset,
localScore,
estimate.terrainScore(),
tangent,
false
));
layers.set(layerIndex, List.copyOf(expanded));
added = true;
}
return added;
}
boolean exhaustedSurfaceRouteLayer(List<RouteCandidate> layer) {
for (RouteCandidate candidate : layer) {
if (!candidate.terrainResolved() || Double.isFinite(candidate.localScore())) {
return false;
}
}
return true;
return new HydrologyCoursePath(List.copyOf(guide), List.copyOf(guideEdges), List.copyOf(edges),
outlet, true, routing.organicSurfaceRequired(), null);
}
double routeTurnScore(List<HydrologyPoint> route) {
@@ -652,289 +435,6 @@ final class HydrologySurfaceCoursePlanner {
return percentile + maximum * 0.05D;
}
double surfaceRouteCandidateBankPenalty(RouteCandidate candidate) {
int maximumBankDistance = surfaceBankDistance(planner.settings.surface().maximumWidth());
HydrologyPoint point = candidate.point();
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
if (terrain == null) {
return Double.POSITIVE_INFINITY;
}
if (terrain.slope() >= planner.settings.hydraulics().waterfallMinimumDrop()) {
return 0D;
}
int head = Math.subtractExact(
terrain.naturalHeight(),
planner.settings.surface().banks().sink()
);
return surfaceRouteBankBandPenalty(
point,
candidate.tangent(),
maximumBankDistance,
head,
maximumSurfaceBankRise(terrain)
);
}
int maximumSurfaceBankRise(HydrologyTerrainSample terrain) {
return Math.addExact(
terrain.surfacePolicy().maximumIncision(planner.settings.surface().maximumIncision()),
(int) StrictMath.ceil(planner.settings.surface().banks().minimumBlendWidth() * 0.5D)
);
}
double surfaceRouteBankBandPenalty(
HydrologyPoint point,
RouteDirection tangent,
int maximumDistance,
int head,
int maximumBankRise
) {
int innerDistance = surfaceBankInnerDistance();
double penalty = 0D;
for (int distance = innerDistance; distance <= maximumDistance; distance++) {
penalty += surfaceRouteBankPenalty(point, tangent, distance, head, maximumBankRise);
penalty += surfaceRouteBankPenalty(point, tangent, -distance, head, maximumBankRise);
}
return penalty;
}
double surfaceRouteBankPenalty(
HydrologyPoint point,
RouteDirection tangent,
int signedDistance,
int head,
int maximumBankRise
) {
int bankX = (int) StrictMath.round(point.x() - tangent.z() * signedDistance);
int bankZ = (int) StrictMath.round(point.z() + tangent.x() * signedDistance);
double bankHeight = planner.sampleLandHeight(bankX, bankZ);
if (Double.isNaN(bankHeight)) {
return 1.0E12D;
}
int rise = (int) bankHeight - head;
int shortfall = HydrologyRouteGeometry.SURFACE_MINIMUM_NATURAL_BANK_RISE - rise;
if (shortfall > 0) {
return (double) shortfall * shortfall * 4096D;
}
int excess = Math.max(0, rise - maximumBankRise);
return (double) excess * excess * 128D;
}
/**
* Innermost probed ring, in blocks out from the centerline. It reads {@code surface.maximumWidth()} and
* nothing else - no {@code shoreWidth}, no candidate, no terrain - so raising the shore knob widens the
* probe band instead of sliding it. That is the whole guarantee.
*
* <p>The ring sits one block outside half the nominal maximum width, which is the widest channel the
* width sampler will draw, not the widest channel that gets cut: {@code ChannelProfileBuilder} clamps a
* station to {@code maximumWidth * 2} after the terrain width multiplier, then scales it again by
* {@code banks().springWidthRatio()} over the spring reach and {@code banks().mouthFlareRatio()} through
* the inlet flare. Routing runs before any profile exists, so no ring can promise to clear the channel.
*/
int surfaceBankInnerDistance() {
return (int) StrictMath.ceil(planner.settings.surface().maximumWidth() / 2D) + 1;
}
/**
* Outermost probed ring: where the bank of the widest channel is expected once the shore is cut. It
* gains a ring per block of {@code shoreWidth}, which is what widens the band walked by the penalty.
*/
int surfaceBankDistance(int width) {
int channelRadius = (int) StrictMath.ceil(width / 2D);
return Math.max(
channelRadius + 1,
(int) StrictMath.ceil(
channelRadius * SURFACE_MAXIMUM_WIDTH_SCALE + planner.settings.surface().shoreWidth()
) + 1
);
}
List<SurfaceCurvePoint> resampleSurfaceGuide(List<HydrologyPoint> 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<SurfaceCurvePoint> sampled = new ArrayList<>(capacity);
int segmentIndex = 0;
for (double distance = 0D; distance < totalLength; distance += spacing) {
while (segmentIndex < guide.size() - 2
&& cumulative[segmentIndex + 1] < distance) {
segmentIndex++;
}
HydrologyPoint start = guide.get(segmentIndex);
HydrologyPoint end = guide.get(segmentIndex + 1);
double segmentLength = cumulative[segmentIndex + 1] - cumulative[segmentIndex];
double progress = segmentLength <= 0D
? 0D
: (distance - cumulative[segmentIndex]) / segmentLength;
sampled.add(new SurfaceCurvePoint(
start.x() + (end.x() - start.x()) * progress,
start.z() + (end.z() - start.z()) * progress,
distance
));
}
HydrologyPoint end = guide.getLast();
sampled.add(new SurfaceCurvePoint(end.x(), end.z(), totalLength));
return List.copyOf(sampled);
}
List<SurfaceCurvePoint> smoothSurfaceGuide(List<SurfaceCurvePoint> guide) {
if (guide.size() < 3) {
return guide;
}
List<SurfaceCurvePoint> current = guide;
int passes = Math.max(2, planner.settings.geometry().meanders().smoothingPasses() + 1);
for (int pass = 0; pass < passes; pass++) {
ArrayList<SurfaceCurvePoint> smoothed = new ArrayList<>(current.size());
smoothed.add(current.getFirst());
for (int pointIndex = 1; pointIndex < current.size() - 1; pointIndex++) {
SurfaceCurvePoint previous = current.get(pointIndex - 1);
SurfaceCurvePoint point = current.get(pointIndex);
SurfaceCurvePoint next = current.get(pointIndex + 1);
smoothed.add(new SurfaceCurvePoint(
previous.x() * 0.25D + point.x() * 0.5D + next.x() * 0.25D,
previous.z() * 0.25D + point.z() * 0.5D + next.z() * 0.25D,
point.distance()
));
}
smoothed.add(current.getLast());
current = List.copyOf(smoothed);
}
return current;
}
List<SurfaceCurvePoint> meanderSurfaceGuide(
List<SurfaceCurvePoint> guide,
long geometryId,
int refinement
) {
HydrologyPlannerSettings.Meanders meanders = planner.settings.geometry().meanders();
double detailWeight = meanders.detailStrength() * 0.08D;
double totalWeight = meanders.primaryStrength() + detailWeight;
if (guide.size() < 3 || totalWeight <= 0D || meanders.maximumOffsetRatio() <= 0D) {
return guide;
}
double maximumOffset = planner.settings.routing().sampleSpacing() * Math.min(
0.16D,
meanders.maximumOffsetRatio() * 0.32D
);
double primaryPhase = HydrologyHash.unit(HydrologyHash.mix(geometryId, HydrologyRouteGeometry.ROUTE_WORM_PRIMARY_SALT))
* StrictMath.PI * 2D;
double detailPhase = HydrologyHash.unit(HydrologyHash.mix(geometryId, HydrologyRouteGeometry.ROUTE_WORM_DETAIL_SALT))
* StrictMath.PI * 2D;
double totalLength = guide.getLast().distance();
double taperLength = Math.max(
refinement * 8D,
Math.min(planner.settings.routing().sampleSpacing() * 0.75D, meanders.primaryWavelength() * 0.35D)
);
ArrayList<SurfaceCurvePoint> meandered = new ArrayList<>(guide.size());
for (int pointIndex = 0; pointIndex < guide.size(); pointIndex++) {
SurfaceCurvePoint point = guide.get(pointIndex);
RouteDirection tangent = surfaceCurveTangent(guide, pointIndex);
double endpointDistance = Math.min(point.distance(), totalLength - point.distance());
double taperProgress = Math.min(1D, endpointDistance / taperLength);
double taper = StrictMath.pow(StrictMath.sin(taperProgress * StrictMath.PI * 0.5D), 2D);
double primary = StrictMath.sin(
point.distance() * StrictMath.PI * 2D / meanders.primaryWavelength() + primaryPhase
) * meanders.primaryStrength();
double detail = StrictMath.sin(
point.distance() * StrictMath.PI * 2D / meanders.detailWavelength() + detailPhase
) * detailWeight;
double offset = maximumOffset * taper * (primary + detail) / totalWeight;
meandered.add(new SurfaceCurvePoint(
point.x() - tangent.z() * offset,
point.z() + tangent.x() * offset,
point.distance()
));
}
return List.copyOf(meandered);
}
RouteDirection surfaceCurveTangent(List<SurfaceCurvePoint> points, int pointIndex) {
SurfaceCurvePoint start = points.get(Math.max(0, pointIndex - 2));
SurfaceCurvePoint end = points.get(Math.min(points.size() - 1, pointIndex + 2));
double deltaX = end.x() - start.x();
double deltaZ = end.z() - start.z();
double length = StrictMath.hypot(deltaX, deltaZ);
return length <= 0D
? new RouteDirection(1D, 0D)
: new RouteDirection(deltaX / length, deltaZ / length);
}
List<DrainageEdge> mapCoursePairs(
List<HydrologyPoint> points,
List<HydrologyPoint> guide,
List<DrainageEdge> guideEdges
) {
ArrayList<DrainageEdge> mapped = new ArrayList<>(points.size() - 1);
int minimumGuideIndex = 0;
for (int pointIndex = 0; pointIndex < points.size() - 1; pointIndex++) {
HydrologyPoint first = points.get(pointIndex);
HydrologyPoint second = points.get(pointIndex + 1);
double x = (first.x() + second.x()) * 0.5D;
double z = (first.z() + second.z()) * 0.5D;
int selectedIndex = minimumGuideIndex;
double selectedDistance = Double.POSITIVE_INFINITY;
for (int guideIndex = minimumGuideIndex; guideIndex < guideEdges.size(); guideIndex++) {
double distance = pointToSegmentDistanceSquared(
x,
z,
guide.get(guideIndex),
guide.get(guideIndex + 1)
);
if (distance < selectedDistance) {
selectedIndex = guideIndex;
selectedDistance = distance;
}
}
minimumGuideIndex = selectedIndex;
mapped.add(guideEdges.get(selectedIndex));
}
return List.copyOf(mapped);
}
double pointToSegmentDistanceSquared(
double x,
double z,
HydrologyPoint start,
HydrologyPoint end
) {
double deltaX = end.x() - start.x();
double deltaZ = end.z() - start.z();
double lengthSquared = deltaX * deltaX + deltaZ * deltaZ;
if (lengthSquared == 0D) {
double pointX = x - start.x();
double pointZ = z - start.z();
return pointX * pointX + pointZ * pointZ;
}
double progress = ((x - start.x()) * deltaX + (z - start.z()) * deltaZ) / lengthSquared;
progress = Math.max(0D, Math.min(1D, progress));
double nearestX = start.x() + deltaX * progress;
double nearestZ = start.z() + deltaZ * progress;
double pointX = x - nearestX;
double pointZ = z - nearestZ;
return pointX * pointX + pointZ * pointZ;
}
HydrologyPoint surfaceBankPoint(HydrologyPoint point, RouteDirection tangent, int signedDistance) {
return new HydrologyPoint(
(int) StrictMath.round(point.x() - tangent.z() * signedDistance),
point.y(),
(int) StrictMath.round(point.z() + tangent.x() * signedDistance)
);
}
record SurfaceCurvePoint(double x, double z, double distance) {
}
record SurfaceCourseBuild(
RiverCourse course,
HydrologyCandidateRejection rejection,
@@ -3,8 +3,8 @@ package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -14,16 +14,15 @@ public final class HydrologySurfaceDropRaster {
private static final HydrologySurfaceDropRaster EMPTY = new HydrologySurfaceDropRaster(List.of(), Map.of());
private final List<HydrologyColumnSample> columns;
private final Map<Long, HydrologyColumnSample> indexed;
private final Long2ObjectOpenHashMap<HydrologyColumnSample> indexed;
private final Map<Long, OceanReceiver> oceanReceivers;
private HydrologySurfaceDropRaster(List<HydrologyColumnSample> columns, Map<Long, OceanReceiver> oceanReceivers) {
this.columns = List.copyOf(columns);
Map<Long, HydrologyColumnSample> indexed = new HashMap<>(columns.size());
this.indexed = new Long2ObjectOpenHashMap<>(columns.size());
for (HydrologyColumnSample sample : columns) {
indexed.put(RiverFootprint.pack(sample.x(), sample.z()), sample);
}
this.indexed = Map.copyOf(indexed);
this.oceanReceivers = Map.copyOf(oceanReceivers);
}
@@ -65,9 +65,10 @@ public final class HydrologyTileCache implements AutoCloseable {
private final Object publicationLock = new Object();
private final BooleanSupplier waitingForbidden;
private volatile SharedCacheScope sharedCacheScope;
private volatile StudioHydrologyTileStore persistentStore;
private volatile PreparedHydrologyTileStore persistentStore;
private volatile boolean neighbourPrefetchEnabled;
private volatile Runnable terrainPreparation;
private PregenerationScope pregenerationScope;
private int demandBatches;
public HydrologyTileCache(HydrologyPlanner planner) {
@@ -338,27 +339,31 @@ public final class HydrologyTileCache implements AutoCloseable {
SharedTileKey sharedKey = sharedCacheScope == null ? null : new SharedTileKey(sharedCacheScope, key);
HydrologyTile shared = sharedKey == null ? null : SHARED_TILES.getIfPresent(sharedKey);
if (shared != null && validSharedTile(shared, sharedKey)) {
PreparedHydrologyTileStore store = persistentStore;
if (store != null && store.load(key).isEmpty()) {
persistTile(store, shared);
}
planner.reuseResolvedTile(shared);
IrisLogging.debug("Reused shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ());
IrisLogging.debug("Reused shared prepared hydrology tile %d,%d", key.tileX(), key.tileZ());
return shared;
}
if (shared != null) {
SHARED_TILES.invalidate(sharedKey);
}
StudioHydrologyTileStore store = persistentStore;
PreparedHydrologyTileStore store = persistentStore;
try (HydrologyPlanningAdmission.Permit ignored = HydrologyPlanningAdmission.acquireRoot(closed::get)) {
return planAdmitted(key, planningEpoch, sharedKey, store);
}
}
private HydrologyTile planAdmitted(HydrologyTileKey key, long planningEpoch,
SharedTileKey sharedKey, StudioHydrologyTileStore store) {
if (store != null && persistentStudioKey(key)) {
SharedTileKey sharedKey, PreparedHydrologyTileStore store) {
if (store != null) {
HydrologyTile persisted = store.load(key).orElse(null);
if (persisted != null) {
SHARED_TILES.put(sharedKey, persisted);
planner.reuseResolvedTile(persisted);
IrisLogging.debug("Loaded persisted Studio hydrology tile %d,%d", key.tileX(), key.tileZ());
IrisLogging.debug("Loaded persisted prepared hydrology tile %d,%d", key.tileX(), key.tileZ());
return persisted;
}
}
@@ -369,18 +374,12 @@ public final class HydrologyTileCache implements AutoCloseable {
if (sharedKey != null && !closed.get() && planningEpoch == cacheEpoch.get()) {
HydrologyTile existing = SHARED_TILES.asMap().putIfAbsent(sharedKey, planned);
if (existing == null) {
IrisLogging.debug("Published shared Studio hydrology tile %d,%d", key.tileX(), key.tileZ());
IrisLogging.debug("Published shared prepared hydrology tile %d,%d", key.tileX(), key.tileZ());
}
}
}
if (store != null && persistentStudioKey(key) && !closed.get() && planningEpoch == cacheEpoch.get()) {
try {
store.save(planned);
IrisLogging.debug("Persisted Studio hydrology tile %d,%d", key.tileX(), key.tileZ());
} catch (IOException failure) {
IrisLogging.reportError("Failed to persist Studio hydrology tile "
+ key.tileX() + "," + key.tileZ() + ".", failure);
}
if (store != null && !closed.get() && planningEpoch == cacheEpoch.get()) {
persistTile(store, planned);
}
return planned;
} catch (RuntimeException failure) {
@@ -394,6 +393,15 @@ public final class HydrologyTileCache implements AutoCloseable {
}
}
private void persistTile(PreparedHydrologyTileStore store, HydrologyTile tile) {
try {
store.save(tile);
} catch (IOException failure) {
IrisLogging.reportError("Failed to persist prepared hydrology tile "
+ tile.key().tileX() + "," + tile.key().tileZ() + ".", failure);
}
}
private static boolean interrupted(Throwable failure) {
if (Thread.currentThread().isInterrupted()) {
return true;
@@ -510,6 +518,9 @@ public final class HydrologyTileCache implements AutoCloseable {
public void clear() {
ArrayList<CompletableFuture<ChunkColumns>> cancelledQueries = new ArrayList<>();
synchronized (prefetchQueue) {
if (pregenerationScope != null) {
pregenerationScope.close();
}
synchronized (publicationLock) {
cacheEpoch.incrementAndGet();
planner.clearOwnerDrafts();
@@ -547,20 +558,29 @@ public final class HydrologyTileCache implements AutoCloseable {
neighbourPrefetchEnabled = enabled;
}
public void preparePregeneration(int centerBlockX, int centerBlockZ) {
public PregenerationScope preparePregeneration(PregenerationArea area) {
Objects.requireNonNull(area, "area");
int tileSize = planner.settings().routing().tileSize();
int minimumBlockX = Math.subtractExact(centerBlockX, tileSize);
int minimumBlockZ = Math.subtractExact(centerBlockZ, tileSize);
int maximumBlockX = Math.addExact(centerBlockX, tileSize);
int maximumBlockZ = Math.addExact(centerBlockZ, tileSize);
enqueuePrefetchArea(prefetchAreaKeys(
minimumBlockX,
minimumBlockZ,
maximumBlockX,
maximumBlockZ,
centerBlockX,
centerBlockZ
), true);
int publicationRadius = planner.settings().publicationRadius();
TileBounds bounds = new TileBounds(
tileCoordinate(Math.subtractExact(area.minimumBlockX(), publicationRadius), tileSize),
tileCoordinate(Math.subtractExact(area.minimumBlockZ(), publicationRadius), tileSize),
tileCoordinate(Math.addExact(area.maximumBlockX(), publicationRadius), tileSize),
tileCoordinate(Math.addExact(area.maximumBlockZ(), publicationRadius), tileSize));
List<HydrologyTileKey> keys = prefetchAreaKeys(
Math.max(area.minimumBlockX(), (long) area.centerBlockX() - tileSize),
Math.max(area.minimumBlockZ(), (long) area.centerBlockZ() - tileSize),
Math.min(area.maximumBlockX(), (long) area.centerBlockX() + tileSize),
Math.min(area.maximumBlockZ(), (long) area.centerBlockZ() + tileSize),
area.centerBlockX(), area.centerBlockZ());
PregenerationScope scope = new PregenerationScope(bounds);
try {
enqueuePrefetchArea(keys, scope);
return scope;
} catch (RuntimeException | Error failure) {
scope.close();
throw failure;
}
}
public void enableSharedCache(SharedCacheScope scope, Path persistentRoot) {
@@ -570,16 +590,7 @@ public final class HydrologyTileCache implements AutoCloseable {
sharedCacheScope = Objects.requireNonNull(scope, "scope");
persistentStore = persistentRoot == null
? null
: new StudioHydrologyTileStore(persistentRoot, scope, planner.settings().routing().tileSize());
}
private boolean persistentStudioKey(HydrologyTileKey key) {
int tileSize = planner.settings().routing().tileSize();
int publicationRadius = planner.settings().publicationRadius();
int minimumTile = Math.subtractExact(tileCoordinate(-(long) publicationRadius, tileSize), 1);
int maximumTile = Math.addExact(tileCoordinate(CHUNK_SIZE - 1L + publicationRadius, tileSize), 1);
return key.tileX() >= minimumTile && key.tileX() <= maximumTile
&& key.tileZ() >= minimumTile && key.tileZ() <= maximumTile;
: new PreparedHydrologyTileStore(persistentRoot, scope, planner.settings().routing().tileSize());
}
@Override
@@ -622,6 +633,11 @@ public final class HydrologyTileCache implements AutoCloseable {
.join();
planning.clear();
diagnosing.clear();
synchronized (prefetchQueue) {
if (pregenerationScope != null) {
pregenerationScope.close();
}
}
clear();
}
@@ -833,11 +849,11 @@ public final class HydrologyTileCache implements AutoCloseable {
return;
}
enqueuePrefetchArea(prefetchAreaKeys(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ,
centreBlockX, centreBlockZ), false);
centreBlockX, centreBlockZ), null);
}
private List<HydrologyTileKey> prefetchAreaKeys(int minimumBlockX, int minimumBlockZ, int maximumBlockX,
int maximumBlockZ, int centreBlockX, int centreBlockZ) {
private List<HydrologyTileKey> prefetchAreaKeys(long minimumBlockX, long minimumBlockZ, long maximumBlockX,
long maximumBlockZ, int centreBlockX, int centreBlockZ) {
int tileSize = planner.settings().routing().tileSize();
int publicationRadius = planner.settings().publicationRadius();
int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize);
@@ -858,11 +874,20 @@ public final class HydrologyTileCache implements AutoCloseable {
return keys;
}
private void enqueuePrefetchArea(List<HydrologyTileKey> keys, boolean discardOutsideArea) {
Set<HydrologyTileKey> area = discardOutsideArea ? new HashSet<>(keys) : Set.of();
private void enqueuePrefetchArea(List<HydrologyTileKey> keys, PregenerationScope scope) {
Set<HydrologyTileKey> area = scope != null ? new HashSet<>(keys) : Set.of();
tiles.cleanUp();
synchronized (prefetchQueue) {
if (discardOutsideArea) {
if (closed.get()) {
if (scope != null) {
requireOpen();
}
return;
}
if (scope != null) {
scope.previousNeighbourPrefetch = pregenerationScope == null
? neighbourPrefetchEnabled : pregenerationScope.previousNeighbourPrefetch;
pregenerationScope = scope;
prefetchQueue.removeIf(key -> {
if (!area.contains(key)) {
queuedPrefetches.remove(key);
@@ -903,6 +928,7 @@ public final class HydrologyTileCache implements AutoCloseable {
private boolean enqueuePrefetch(HydrologyTileKey key) {
if (closed.get()
|| pregenerationScope != null && !pregenerationScope.bounds.contains(key)
|| tiles.getIfPresent(key) != null
|| planning.containsKey(new CacheLoadKey<>(cacheEpoch.get(), key))
|| queuedPrefetches.contains(key)
@@ -1056,7 +1082,8 @@ public final class HydrologyTileCache implements AutoCloseable {
if (!queuedPrefetches.remove(key)) {
continue;
}
if (tiles.getIfPresent(key) == null) {
if ((pregenerationScope == null || pregenerationScope.bounds.contains(key))
&& tiles.getIfPresent(key) == null) {
return key;
}
}
@@ -1322,6 +1349,48 @@ public final class HydrologyTileCache implements AutoCloseable {
}
}
public record PregenerationArea(
int centerBlockX, int centerBlockZ,
long minimumBlockX, long minimumBlockZ,
long maximumBlockX, long maximumBlockZ
) {
public PregenerationArea {
if (minimumBlockX > centerBlockX || maximumBlockX < centerBlockX
|| minimumBlockZ > centerBlockZ || maximumBlockZ < centerBlockZ) {
throw new IllegalArgumentException("Pregeneration bounds must contain their center.");
}
}
}
public final class PregenerationScope implements AutoCloseable {
private final TileBounds bounds;
private boolean previousNeighbourPrefetch;
private PregenerationScope(TileBounds bounds) {
this.bounds = bounds;
}
@Override
public void close() {
synchronized (prefetchQueue) {
if (pregenerationScope != this) {
return;
}
pregenerationScope = null;
neighbourPrefetchEnabled = previousNeighbourPrefetch;
prefetchQueue.clear();
queuedPrefetches.clear();
}
}
}
private record TileBounds(int minimumX, int minimumZ, int maximumX, int maximumZ) {
private boolean contains(HydrologyTileKey key) {
return key.tileX() >= minimumX && key.tileX() <= maximumX
&& key.tileZ() >= minimumZ && key.tileZ() <= maximumZ;
}
}
private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) {
private long estimatedBytes() {
long bytes = 64L + 8L * columns.length;
@@ -492,14 +492,6 @@ final class HydrologyTributaryPlanner {
}
}
ArrayList<HydraulicSegment> segments = new ArrayList<>(result.segments());
// Where the two channels touch, the tributary must sit at the lowest stem water it reaches.
double touch = stemWidths.get(stemIndex) + lastWidth(result) + 1D;
for (int index = 0; index < stemStations.size(); index++) {
HydrologyPoint station = stemStations.get(index);
if (station.distanceSquared2D(joinStation) <= touch * touch) {
stemHead = Math.min(stemHead, station.y());
}
}
if (segments.getLast().downstreamHeadY() < stemHead) {
int shortfall = stemHead - segments.getLast().downstreamHeadY();
if (shortfall > TRIBUTARY_BACKWATER_LIMIT) {
@@ -10,16 +10,16 @@ import lombok.experimental.Accessors;
@Data
@NoArgsConstructor
@Accessors(chain = true)
@Description("Bounds terrain removal outside the wet river channel.")
@Description("Bounds terrain shaping outside the wet river channel.")
public class IrisRiverExcavationConfig {
@MinNumber(0)
@MaxNumber(64)
@Description("Maximum blocks removed vertically from a dry bank column. Zero preserves dry bank heights.")
@Description("Maximum blocks cut or filled vertically in a dry bank column. Fill is also limited by the local channel incision policy. Zero preserves dry bank heights.")
private int maximumDepth = 8;
@MinNumber(1)
@MaxNumber(64)
@Description("Maximum horizontal distance from the channel outline affected by shore and bank excavation.")
@Description("Maximum horizontal distance from the channel outline affected by shore and bank shaping.")
private int maximumWidth = 16;
@MinNumber(0)
@@ -20,7 +20,7 @@ public class IrisSurfaceRiverBankConfig {
@MinNumber(0.5)
@MaxNumber(12)
@Description("Horizontal run in blocks of eroded bank per block of cut depth.")
@Description("Horizontal bank blend in blocks per block of terrain cut or fill.")
private double blendSlope = 3D;
@MinNumber(1)
@@ -33,7 +33,7 @@ public class IrisSurfaceRiverBankConfig {
@Description("Widest eroded band outside the shore, in blocks.")
private int maximumBlendWidth = 32;
@Description("Limits dry bank cut depth, width, and excavated volume independently of the wet channel.")
@Description("Limits dry bank cut depth, fill height, width, and excavated volume independently of the wet channel. Fill also respects channel.maximumIncision and local river policy.")
private IrisRiverExcavationConfig excavation = new IrisRiverExcavationConfig();
@Description("Show the biome's deeper layers on eroded banks instead of the surface layer.")
@@ -24,12 +24,12 @@ public class IrisSurfaceRiverChannelConfig {
@MinNumber(0)
@MaxNumber(3)
@Description("Blocks the water surface sinks below the lowest natural ground beside the channel; 0 keeps the water flush with the bank, and the bank always meets the water at its own height.")
@Description("Blocks the water surface sinks below the shaped bank top; 0 keeps the water flush with its banks.")
private int sink = 0;
@MinNumber(1)
@MaxNumber(32)
@Description("Maximum cut below natural terrain at the channel center before a course is rejected.")
@Description("Maximum channel cut below natural terrain. Also caps bank fill, together with banks.excavation.maximumDepth; courses exceeding containment or terrain limits are rejected.")
private int maximumIncision = 10;
@MinNumber(0)
@@ -41,8 +41,8 @@ import java.util.Set;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
final class StudioHydrologyTileStore {
private static final int SCHEMA_VERSION = 5;
final class PreparedHydrologyTileStore {
private static final int SCHEMA_VERSION = 6;
private static final long MAXIMUM_COMPRESSED_BYTES = 128L * 1024L * 1024L;
private static final long MAXIMUM_DECOMPRESSED_BYTES = 512L * 1024L * 1024L;
private static final Gson GSON = new GsonBuilder()
@@ -51,19 +51,21 @@ final class StudioHydrologyTileStore {
.create();
private final Path directory;
private final String scopeIdentity;
private final HydrologyTileCache.SharedCacheScope scope;
private final int expectedTileSize;
StudioHydrologyTileStore(Path root, HydrologyTileCache.SharedCacheScope scope, int expectedTileSize) {
PreparedHydrologyTileStore(Path root, HydrologyTileCache.SharedCacheScope scope, int expectedTileSize) {
this.scope = Objects.requireNonNull(scope, "scope");
if (expectedTileSize < 1) {
throw new IllegalArgumentException("Hydrology tile size must be positive.");
}
this.expectedTileSize = expectedTileSize;
this.scopeIdentity = scopeFingerprint(scope);
this.directory = Objects.requireNonNull(root, "root")
.toAbsolutePath()
.normalize()
.resolve(scopeFingerprint(scope));
.resolve(scopeIdentity);
}
Optional<HydrologyTile> load(HydrologyTileKey key) {
@@ -85,6 +87,7 @@ final class StudioHydrologyTileStore {
PersistedTile persisted = GSON.fromJson(reader, PersistedTile.class);
if (persisted == null
|| persisted.schemaVersion() != SCHEMA_VERSION
|| !scopeIdentity.equals(persisted.scopeIdentity())
|| !key.equals(persisted.key())
|| persisted.worldSeed() != scope.worldSeed()
|| persisted.settingsFingerprint() != scope.settingsFingerprint()) {
@@ -99,7 +102,7 @@ final class StudioHydrologyTileStore {
void save(HydrologyTile tile) throws IOException {
if (!valid(tile, tile.key())) {
throw new IOException("Refused to persist a hydrology tile outside the active Studio cache scope.");
throw new IOException("Refused to persist a hydrology tile outside the active prepared-plan scope.");
}
Files.createDirectories(directory);
Path target = file(tile.key());
@@ -109,10 +112,10 @@ final class StudioHydrologyTileStore {
OutputStream buffered = new BufferedOutputStream(raw);
OutputStream compressed = new GZIPOutputStream(buffered);
Writer writer = new OutputStreamWriter(compressed, StandardCharsets.UTF_8)) {
GSON.toJson(PersistedTile.from(tile), writer);
GSON.toJson(PersistedTile.from(tile, scopeIdentity), writer);
}
if (Files.size(staged) > MAXIMUM_COMPRESSED_BYTES) {
throw new IOException("Studio hydrology tile cache entry exceeds the size limit.");
throw new IOException("Prepared hydrology tile cache entry exceeds the size limit.");
}
try {
Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
@@ -161,6 +164,7 @@ final class StudioHydrologyTileStore {
private record PersistedTile(
int schemaVersion,
String scopeIdentity,
HydrologyTileKey key,
long worldSeed,
long settingsFingerprint,
@@ -174,7 +178,7 @@ final class StudioHydrologyTileStore {
List<HydrologyDiagnosticCandidate> localDiagnosticCandidates,
List<HydrologyColumnSample> columns
) {
private static PersistedTile from(HydrologyTile tile) {
private static PersistedTile from(HydrologyTile tile, String scopeIdentity) {
ArrayList<PersistedCourse> courses = new ArrayList<>(tile.courses().size());
for (RiverCourse course : tile.courses()) {
courses.add(PersistedCourse.from(course));
@@ -187,6 +191,7 @@ final class StudioHydrologyTileStore {
}
return new PersistedTile(
SCHEMA_VERSION,
scopeIdentity,
tile.key(),
tile.worldSeed(),
tile.settingsFingerprint(),
@@ -375,7 +380,7 @@ final class StudioHydrologyTileStore {
private void advance(long amount) throws IOException {
count += amount;
if (count > limit) {
throw new IOException("Studio hydrology tile cache entry exceeds the decompressed size limit.");
throw new IOException("Prepared hydrology tile cache entry exceeds the decompressed size limit.");
}
}
}
@@ -9,7 +9,6 @@ record RefinedEdgeKey(
int downstreamZ,
int continuationX,
int continuationZ,
int transverseCandidates,
boolean surface
int transverseCandidates
) {
}
@@ -1,4 +0,0 @@
package art.arcane.iris.generation.hydrology;
record RouteTerrainEstimate(int height, double terrainScore) {
}
@@ -1,6 +0,0 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
record SurfaceRouteKey(List<HydrologyPoint> guide, long geometryId) {
}
@@ -170,8 +170,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
cache.prefetchArea(minimumBlockX, minimumBlockZ, maximumBlockX, maximumBlockZ, centreBlockX, centreBlockZ);
}
public void preparePregeneration(int centerBlockX, int centerBlockZ) {
cache.preparePregeneration(centerBlockX, centerBlockZ);
public HydrologyTileCache.PregenerationScope preparePregeneration(HydrologyTileCache.PregenerationArea area) {
return cache.preparePregeneration(area);
}
public void setNeighbourPrefetchEnabled(boolean enabled) {
@@ -31,15 +31,17 @@ public final class ErosionFieldCompiler {
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final int seaLevel;
private final double maximumShoreWidth;
public ErosionFieldCompiler(
HydrologyPlannerSettings.Surface surface,
HydrologyTerrainSampler sampler,
int seaLevel
HydrologyPlannerSettings settings,
HydrologyTerrainSampler sampler
) {
this.surface = Objects.requireNonNull(surface, "surface");
Objects.requireNonNull(settings, "settings");
this.surface = settings.surface();
this.sampler = Objects.requireNonNull(sampler, "sampler");
this.seaLevel = seaLevel;
this.seaLevel = settings.seaLevel();
this.maximumShoreWidth = Math.max(surface.shoreWidth(), settings.widestShoreBiomeWidth());
}
public ErosionField compile(
@@ -80,11 +82,12 @@ public final class ErosionFieldCompiler {
SurfaceRasterContext context
) {
return compile(courseSeed, centerline, channel, valley, terminal, apronLimit, ponds, context,
prepare(centerline, channel, valley, terminal));
prepare(centerline, channel, valley, terminal, terminal == SurfaceTerminal.OCEAN_MOUTH
? inletStart(valley.exposedStations()) : centerline.size()));
}
PreparedField prepare(SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley,
SurfaceTerminal terminal) {
SurfaceTerminal terminal, int inletStart) {
int count = terminal == SurfaceTerminal.OCEAN_MOUTH ? centerline.size() : valley.exposedStations();
boolean[] basin = basins(valley, channel, count);
double[] bendOffsets = SurfaceBendProfile.offsets(centerline, channel);
@@ -99,7 +102,7 @@ public final class ErosionFieldCompiler {
radii[station] = (int) StrictMath.ceil(channel.halfWidth()[station] * (1D + surface.banks().roughness())
+ Math.max(stationShore + widest, stationBand)) + 1;
}
return new PreparedField(count, basin, bendOffsets, blendWidth, radii);
return new PreparedField(count, basin, bendOffsets, blendWidth, radii, inletStart);
}
ErosionField compile(long courseSeed, SurfaceCenterline centerline, ChannelProfile channel, ValleyProfile valley,
@@ -157,6 +160,7 @@ public final class ErosionFieldCompiler {
}
}
int oceanStart = oceanStart(centerline, valley, terminal);
int inletStart = prepared.inletStart();
Long2ObjectOpenHashMap<SurfaceColumn> columns = new Long2ObjectOpenHashMap<>(nearest.size());
LongArrayList wetKeys = new LongArrayList();
for (long key : nearest.keySet()) {
@@ -168,7 +172,6 @@ public final class ErosionFieldCompiler {
if (!context.boundary().allows(cellX, cellZ) || context.drops().connects(cellX, cellZ, head)) {
continue;
}
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
double halfWidth = channel.halfWidth()[station];
double outline = halfWidth * (1D + roughness * SurfaceNoise.signed(
courseSeed ^ OUTLINE_SALT, cellX, cellZ, banks.roughnessWavelength()));
@@ -176,6 +179,13 @@ public final class ErosionFieldCompiler {
Math.max(0.5D, channelSettings.outlineMinimumRatio() * halfWidth),
Math.min(channelSettings.outlineMaximumRatio() * halfWidth, outline));
boolean wet = cellDistance <= outline + 0.25D;
double bankDistance = cellDistance - outline;
if (!wet && cellDistance > outline + maximumShoreWidth + 0.25D
&& (bankDistance > erosion.excavation().maximumWidth() || !erosion.enabled()
|| bankDistance - maximumShoreWidth >= blendWidth[station][side(centerline, station, cellX, cellZ) < 0D ? 0 : 1])) {
continue;
}
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
boolean mouthLand = terminal == SurfaceTerminal.OCEAN_MOUTH && head == seaLevel
&& SurfaceCellAdmission.mouthLand(terrain, seaLevel);
if (!SurfaceCellAdmission.writable(terrain, seaLevel) && !mouthLand) {
@@ -198,6 +208,10 @@ public final class ErosionFieldCompiler {
+ erosion.bedNoise() * roughness * SurfaceNoise.signed(courseSeed ^ BED_SALT, cellX, cellZ, banks.roughnessWavelength())
+ (basin[station] ? flow.plungeBasinDepth() : 0D);
int bed = Math.min(natural, head - Math.max(1, (int) StrictMath.round(local)));
int minimumBed = natural - maximumIncision(terrain, station >= inletStart);
if (minimumBed < head) {
bed = Math.max(bed, minimumBed);
}
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false));
wetKeys.add(key);
continue;
@@ -212,7 +226,6 @@ public final class ErosionFieldCompiler {
// geometric shore with the bank biome.
boolean shoreBiome = cellDistance <= outline + terrain.shoreBiomeWidth(shoreWidth) + 0.25D;
SurfaceRole dryRole = shoreBiome ? SurfaceRole.SHORE : SurfaceRole.BANK;
double bankDistance = cellDistance - outline;
if (bankDistance > erosion.excavation().maximumWidth()) {
if (shoreBiome) {
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
@@ -220,14 +233,14 @@ public final class ErosionFieldCompiler {
continue;
}
if (cellDistance <= outline + shore + 0.25D) {
int height = Math.min(natural, bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore)));
height = boundedBankHeight(natural, height, erosion.excavation());
int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(cellDistance, outline, shore));
height = boundedBankHeight(terrain, height, erosion.excavation());
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
continue;
}
int shoreTop = bankTop + (int) StrictMath.round(shoreRise);
int cut = natural - shoreTop;
if (cut <= 0 || !erosion.enabled() || !terrain.erosion()) {
if (cut == 0 || !erosion.enabled() || !terrain.erosion()) {
if (shoreBiome) {
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, natural, natural, false));
}
@@ -243,8 +256,8 @@ public final class ErosionFieldCompiler {
}
continue;
}
int height = Math.min(natural, (int) StrictMath.round(shoreTop + cut * blend(progress, erosion)));
height = boundedBankHeight(natural, height, erosion.excavation());
int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion));
height = boundedBankHeight(terrain, height, erosion.excavation());
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, dryRole, height, height, false));
}
if (count > 0) {
@@ -255,7 +268,7 @@ public final class ErosionFieldCompiler {
}
int uncontained = contain(columns, wetKeys, sink, bounds, context.drops());
connectSteps(columns, wetKeys);
return validate(columns, wetKeys, centerline, terminal, bounds, uncontained);
return validate(columns, wetKeys, centerline, terminal, bounds, uncontained, inletStart);
}
/**
@@ -340,6 +353,10 @@ public final class ErosionFieldCompiler {
if (wasWet) {
bed = Math.min(bed, existing.height());
}
int minimumBed = natural - maximumIncision(terrain, false);
if (minimumBed < head) {
bed = Math.max(bed, minimumBed);
}
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.CHANNEL, bed, head, false));
if (!wasWet) {
wetKeys.add(key);
@@ -353,24 +370,24 @@ public final class ErosionFieldCompiler {
continue;
}
if (distance <= outline + shore + 0.25D) {
int height = Math.min(natural, bankTop + (int) StrictMath.round(shoreRise * benchProgress(distance, outline, shore)));
height = boundedBankHeight(natural, height, erosion.excavation());
int height = bankTop + (int) StrictMath.round(shoreRise * benchProgress(distance, outline, shore));
height = boundedBankHeight(terrain, height, erosion.excavation());
columns.put(key, new SurfaceColumn(cellX, cellZ, terrain, station, SurfaceRole.SHORE, height, height, false));
continue;
}
int cut = natural - shoreTop;
if (cut <= 0 || !erosion.enabled() || !erode) {
if (cut == 0 || !erosion.enabled() || !erode) {
continue;
}
double width = Math.max(banks.minimumBlendWidth(),
Math.min(banks.maximumBlendWidth(), cut * banks.blendSlope() + erosion.blendBaseWidth()));
Math.min(banks.maximumBlendWidth(), Math.abs(cut) * banks.blendSlope() + erosion.blendBaseWidth()));
width = Math.min(width, Math.max(0.25D, erosion.excavation().maximumWidth() - shore));
double progress = (distance - outline - shore) / width;
if (progress >= 1D) {
continue;
}
int height = Math.min(natural, (int) StrictMath.round(shoreTop + cut * blend(progress, erosion)));
height = boundedBankHeight(natural, height, erosion.excavation());
int height = (int) StrictMath.round(shoreTop + cut * blend(progress, erosion));
height = boundedBankHeight(terrain, height, erosion.excavation());
if (existing != null && existing.height() <= height) {
continue;
}
@@ -401,7 +418,8 @@ public final class ErosionFieldCompiler {
int cellX = (int) StrictMath.round(centreX + Math.cos(angle) * ring);
int cellZ = (int) StrictMath.round(centreZ + Math.sin(angle) * ring);
HydrologyTerrainSample terrain = sampler.sample(cellX, cellZ);
if (!SurfaceCellAdmission.writable(terrain, seaLevel) || terrain.naturalHeight() < bankTop) {
if (!SurfaceCellAdmission.writable(terrain, seaLevel)
|| (long) terrain.naturalHeight() + maximumFill(terrain) < bankTop) {
return false;
}
}
@@ -512,20 +530,17 @@ public final class ErosionFieldCompiler {
int uncontained = 0;
for (int index = 0; index < wetKeys.size(); index++) {
SurfaceColumn wet = columns.get(wetKeys.getLong(index));
if (bounds != null && !bounds.contains(wet.x(), wet.z())) {
continue;
}
uncontained += lip(columns, wet, CARDINALS, sink, true, drops);
boolean owned = bounds == null || bounds.contains(wet.x(), wet.z());
uncontained += lip(columns, wet, CARDINALS, sink, owned, drops);
lip(columns, wet, DIAGONALS, sink, false, drops);
}
return uncontained;
}
/**
* The bank beside water must reach the natural ground the head was taken from: the water's own
* block level plus the sink. A neighbour lower than that is raised to it. The solver takes the head
* from the banks it samples at each station. A missed low cell can retain its natural ground,
* but cannot be raised above it. A shortfall below the water head stays uncontained and is counted.
* The bank beside water reaches the planned water level plus the sink. Low neighbours may be
* filled within the local channel and bank terrain limits. Any remaining shortfall below the
* water head stays uncontained and is counted.
*/
private int lip(
Long2ObjectOpenHashMap<SurfaceColumn> columns,
@@ -558,8 +573,7 @@ public final class ErosionFieldCompiler {
}
int required = wet.headY() + sink;
if (neighbour.height() < required) {
int ceiling = neighbour.terrain().naturalHeight();
int raised = Math.min(ceiling, required);
int raised = boundedBankHeight(neighbour.terrain(), required, surface.banks().erosion().excavation());
neighbour = neighbour.withHeight(raised);
columns.put(key, neighbour);
}
@@ -584,18 +598,51 @@ public final class ErosionFieldCompiler {
return count;
}
private static int boundedBankHeight(int natural, int target, HydrologyPlannerSettings.Excavation limits) {
return Math.max(natural - limits.maximumDepth(), Math.min(natural, target));
private int boundedBankHeight(HydrologyTerrainSample terrain, int target, HydrologyPlannerSettings.Excavation limits) {
int natural = terrain.naturalHeight();
return (int) Math.clamp((long) target, (long) natural - limits.maximumDepth(),
(long) natural + maximumFill(terrain));
}
private int maximumFill(HydrologyTerrainSample terrain) {
int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision());
return Math.min(surface.banks().erosion().excavation().maximumDepth(),
Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier())));
}
int inletStart(int exposedStations) {
HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet();
int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposedStations * inlet.courseFraction()));
return Math.max(0, exposedStations - reach - reach / 2);
}
private int maximumIncision(HydrologyTerrainSample terrain, boolean inlet) {
int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision());
if (inlet) {
incision = Math.max(incision, surface.banks().inlet().maximumIncision());
}
return Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()));
}
private ErosionField validate(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys,
SurfaceCenterline centerline, SurfaceTerminal terminal, SurfaceBounds bounds, int uncontained) {
SurfaceCenterline centerline, SurfaceTerminal terminal,
SurfaceBounds bounds, int uncontained, int inletStart) {
long excavation = 0L;
int[] stationVolumes = new int[centerline.size()];
int deepest = 0;
for (SurfaceColumn column : columns.values()) {
if (column.apron() || column.role() == SurfaceRole.CHANNEL
|| bounds != null && !bounds.contains(column.x(), column.z())) {
if (column.apron() || bounds != null && !bounds.contains(column.x(), column.z())) {
continue;
}
if (column.role() == SurfaceRole.CHANNEL) {
HydrologyTerrainSample terrain = column.terrain();
int incision = maximumIncision(terrain, column.station() >= inletStart);
int cut = terrain.naturalHeight() - column.height();
if (cut > incision) {
return new ErosionField(columns, uncontained,
HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
cut, excavation);
}
continue;
}
int cut = Math.max(0, column.terrain().naturalHeight() - column.height());
@@ -613,14 +660,20 @@ public final class ErosionFieldCompiler {
return new ErosionField(columns, uncontained, HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT,
uncontained, excavation);
}
if (bounds == null && terminal == SurfaceTerminal.OCEAN_MOUTH && !connectedToOcean(columns, wetKeys)) {
if (bounds == null && terminal == SurfaceTerminal.OCEAN_MOUTH && !connectedToOcean(columns, wetKeys, centerline)) {
return new ErosionField(columns, 0, HydrologyCandidateRejection.SURFACE_MOUTH_DISCONNECTED, 0, excavation);
}
return new ErosionField(columns, uncontained, null, 0, excavation);
}
private boolean connectedToOcean(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys) {
private boolean connectedToOcean(Long2ObjectOpenHashMap<SurfaceColumn> columns, LongArrayList wetKeys,
SurfaceCenterline centerline) {
if (wetKeys.isEmpty()) {
for (int station = 0; station < centerline.size(); station++) {
if (sampler.receivingWater(centerline.x()[station], centerline.z()[station], seaLevel)) {
return true;
}
}
return false;
}
long firstKey = wetKeys.getLong(0);
@@ -700,7 +753,7 @@ public final class ErosionFieldCompiler {
int probeX = (int) StrictMath.round(centerline.x()[station] + centerline.normalX(station) * probe * direction);
int probeZ = (int) StrictMath.round(centerline.z()[station] + centerline.normalZ(station) * probe * direction);
HydrologyTerrainSample terrain = sampler.sample(probeX, probeZ);
double cut = terrain == null ? 0D : Math.max(0D, terrain.naturalHeight() - bankTop);
double cut = terrain == null ? 0D : Math.abs(terrain.naturalHeight() - (double) bankTop);
double width = cut * banks.blendSlope() * channel.bankMultiplier()[station] + erosion.blendBaseWidth();
widths[station][side] = Math.max(banks.minimumBlendWidth(), Math.min(banks.maximumBlendWidth(), width));
}
@@ -765,6 +818,7 @@ public final class ErosionFieldCompiler {
return deltaX * centerline.normalX(station) + deltaZ * centerline.normalZ(station);
}
record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii) {
record PreparedField(int count, boolean[] basin, double[] bendOffsets, double[][] blendWidth, int[] radii,
int inletStart) {
}
}
@@ -0,0 +1,169 @@
package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
import java.util.List;
import java.util.Objects;
final class RiverProfileSolver {
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final int seaLevel;
RiverProfileSolver(Options options, HydrologyTerrainSampler sampler) {
this.surface = Objects.requireNonNull(options.surface());
this.sampler = Objects.requireNonNull(sampler);
this.seaLevel = options.seaLevel();
}
ValleyProfile solve(List<HydrologyPoint> path, SurfaceCenterline centerline, ChannelProfile channel,
SurfaceTerminal terminal, int terminalHead, int minimumLength) {
int count = centerline.size();
int[] heads = interpolate(path, centerline);
int[] natural = new int[count];
int[] incision = new int[count];
int[] inletIncision = new int[count];
int exposed = count;
double length = 0D;
for (int station = 0; station < count; station++) {
HydrologyTerrainSample terrain = sampler.sample(centerline.x()[station], centerline.z()[station]);
boolean writable = terminal == SurfaceTerminal.OCEAN_MOUTH
? SurfaceCellAdmission.mouthLand(terrain, seaLevel)
: SurfaceCellAdmission.writable(terrain, seaLevel);
if (!writable) {
exposed = station;
break;
}
if (!terrain.transitAllowed()) {
return ValleyProfile.rejected(HydrologyCandidateRejection.POLICY_EXCLUDED, station);
}
natural[station] = terrain.naturalHeight();
int limit = terrain.surfacePolicy().maximumIncision(surface.maximumIncision());
incision[station] = Math.min(limit, (int) StrictMath.floor(limit * terrain.incisionMultiplier()));
int inletLimit = Math.max(limit, surface.banks().inlet().maximumIncision());
inletIncision[station] = Math.min(inletLimit,
(int) StrictMath.floor(inletLimit * terrain.incisionMultiplier()));
if (station > 0) {
length += StrictMath.hypot((double) centerline.x()[station] - centerline.x()[station - 1],
(double) centerline.z()[station] - centerline.z()[station - 1]);
}
}
if (exposed < 2 || length < minimumLength) {
return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, (int) StrictMath.floor(length));
}
if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH
&& terminal != SurfaceTerminal.COASTAL_GROTTO) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE, exposed);
}
int floor = terminal == SurfaceTerminal.OCEAN_MOUTH || terminal == SurfaceTerminal.COASTAL_GROTTO
? Math.max(seaLevel, terminalHead) : terminalHead;
for (int station = 0; station < exposed; station++) {
heads[station] = Math.max(heads[station], floor);
}
if (terminal == SurfaceTerminal.TRIBUTARY) {
gradeTerminal(heads, exposed, terminalHead, surface.banks().cascadeRun());
}
int inletStart = exposed;
if (terminal == SurfaceTerminal.OCEAN_MOUTH) {
inletStart = gradeInlet(heads, natural, inletIncision, channel, exposed);
for (int station = exposed; station < count; station++) {
heads[station] = seaLevel;
}
}
int sourceHead = heads[0];
int downstream = floor;
for (int station = exposed - 1; station >= 0; station--) {
int depth = (int) StrictMath.round(channel.depth()[station]);
int limit = incision[station];
if (station >= inletStart) {
limit = inletIncision[station];
}
int required = Math.max(Math.max(heads[station], downstream), natural[station] - limit + depth);
if (required > sourceHead || terminal == SurfaceTerminal.TRIBUTARY
&& station == exposed - 1 && required > terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
natural[station] - heads[station] + depth);
}
heads[station] = required;
downstream = required;
}
for (int station = 1; station < exposed; station++) {
int drop = heads[station - 1] - heads[station];
if (drop >= surface.banks().waterfallMinimumDrop()) {
continue;
}
int limit = station - 1 >= inletStart ? inletIncision[station - 1] : incision[station - 1];
int connectedHead = Math.max(heads[station - 1] - 1, natural[station - 1] - limit + 1);
if (terminal == SurfaceTerminal.TRIBUTARY && station == exposed - 1 && connectedHead > terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
natural[station - 1] - terminalHead + 1);
}
heads[station] = Math.max(heads[station], Math.min(heads[station - 1], connectedHead));
}
return new ValleyProfile(heads, natural, natural, natural, exposed, null, 0);
}
private static int[] interpolate(List<HydrologyPoint> path, SurfaceCenterline centerline) {
int[] heads = new int[centerline.size()];
for (int station = 0; station < heads.length; station++) {
int index = Math.min(centerline.pathIndex()[station], path.size() - 1);
HydrologyPoint start = path.get(index);
HydrologyPoint end = path.get(Math.min(index + 1, path.size() - 1));
double dx = (double) end.x() - start.x();
double dz = (double) end.z() - start.z();
double squared = dx * dx + dz * dz;
double progress = squared == 0D ? 0D : Math.clamp(
((centerline.x()[station] - (double) start.x()) * dx
+ (centerline.z()[station] - (double) start.z()) * dz) / squared, 0D, 1D);
int head = (int) StrictMath.round(start.y() + ((double) end.y() - start.y()) * progress);
heads[station] = station == 0 ? head : Math.min(heads[station - 1], head);
}
return heads;
}
private static void gradeTerminal(int[] heads, int exposed, int terminalHead, int run) {
for (int station = exposed - 1; station >= 0; station--) {
long target = (long) terminalHead + Math.floorDiv(exposed - 1L - station, Math.max(1, run));
if (heads[station] <= target) {
break;
}
heads[station] = (int) Math.min(Integer.MAX_VALUE, target);
}
}
private int gradeInlet(int[] heads, int[] natural, int[] incision, ChannelProfile channel, int exposed) {
HydrologyPlannerSettings.Inlet inlet = surface.banks().inlet();
int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposed * inlet.courseFraction()));
int start = exposed;
while (start > 0 && exposed - start < reach) {
int index = start - 1;
int cut = natural[index] - seaLevel + (int) StrictMath.round(channel.depth()[index]);
if (cut > incision[index]) {
break;
}
heads[index] = seaLevel;
start--;
}
int rampStart = start;
for (int station = start - 1; station >= Math.max(0, start - reach / 2); station--) {
int target = seaLevel + (int) StrictMath.round((start - station) * inlet.rampSlope());
if (heads[station] <= target) {
break;
}
int cut = natural[station] - target + (int) StrictMath.round(channel.depth()[station]);
if (cut > incision[station]) {
break;
}
heads[station] = target;
rampStart = station;
}
return rampStart;
}
record Options(HydrologyPlannerSettings.Surface surface, int seaLevel) {
}
}
@@ -5,6 +5,7 @@ import art.arcane.iris.generation.hydrology.HydraulicSegment;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.generation.hydrology.HydrologyHash;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
@@ -15,6 +16,8 @@ import java.util.List;
import java.util.Objects;
public final class SurfaceCourseBuilder {
private static final long COASTAL_DROP_SALT = 0x434f41535446414cL;
private final HydrologyPlannerSettings.Surface surface;
private final HydrologyTerrainSampler sampler;
private final HydrologyGeometrySampler geometry;
@@ -45,14 +48,14 @@ public final class SurfaceCourseBuilder {
boolean directOcean = terminal == SurfaceTerminal.OCEAN_MOUTH;
ChannelProfile channel = new ChannelProfileBuilder(surface, sampler, geometry)
.build(centerline, profileKey, directOcean);
ValleyProfile valley = new ValleyProfileSolver(surface, sampler, seaLevel, minimumCourseLength)
.solve(centerline, channel, terminal, terminalHead);
ValleyProfile valley = new RiverProfileSolver(new RiverProfileSolver.Options(surface, seaLevel), sampler)
.solve(path, centerline, channel, terminal, terminalHead, minimumCourseLength);
if (!valley.accepted()) {
return SurfaceCourseResult.rejected(valley.rejection(), valley.rejectionDetail());
}
int exposed = valley.exposedStations();
boolean coastalDrop = directOcean && valley.head()[exposed - 1] > seaLevel;
int stations = coastalDrop ? exposed + 1 : exposed;
int stations = exposed;
int[] x = new int[stations];
int[] z = new int[stations];
int[] head = new int[stations];
@@ -65,40 +68,52 @@ public final class SurfaceCourseBuilder {
width[station] = channel.halfWidth()[station] * 2D;
depth[station] = channel.depth()[station];
}
if (coastalDrop) {
int last = exposed - 1;
if (exposed < centerline.size()) {
x[exposed] = centerline.x()[exposed];
z[exposed] = centerline.z()[exposed];
} else {
x[exposed] = x[last] + (int) StrictMath.round(centerline.tangentX()[last]);
z[exposed] = z[last] + (int) StrictMath.round(centerline.tangentZ()[last]);
if (x[exposed] == x[last] && z[exposed] == z[last]) {
x[exposed] = x[last] + 1;
}
}
head[exposed] = seaLevel;
width[exposed] = width[last];
depth[exposed] = depth[last];
}
List<HydraulicSegment> segments = fallingTransitions(SurfaceSegmentLabeler.label(
worldSeed, courseId, x, z, head, width, depth, surface.banks()));
ArrayList<HydraulicSegment> segments = fallingTransitions(SurfaceSegmentLabeler.label(
worldSeed, courseId, x, z, head, width, depth, surface.banks()), directOcean);
if (segments.isEmpty()) {
return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, stations);
}
if (coastalDrop) {
int last = exposed - 1;
int receiverX;
int receiverZ;
if (exposed < centerline.size()) {
receiverX = centerline.x()[exposed];
receiverZ = centerline.z()[exposed];
} else {
receiverX = x[last] + (int) StrictMath.round(centerline.tangentX()[last]);
receiverZ = z[last] + (int) StrictMath.round(centerline.tangentZ()[last]);
if (receiverX == x[last] && receiverZ == z[last]) {
receiverX = x[last] + 1;
}
}
int drop = head[last] - seaLevel;
HydrologyFeatureType type = drop == 1 ? HydrologyFeatureType.RIFFLE
: drop >= surface.banks().waterfallMinimumDrop()
? HydrologyFeatureType.WATERFALL : HydrologyFeatureType.CASCADE;
boolean receiving = sampler.receivingWater(receiverX, receiverZ, seaLevel);
segments.add(new HydraulicSegment(HydrologyHash.mix(worldSeed, COASTAL_DROP_SALT, courseId),
courseId, type, head[last], seaLevel,
Math.max(1, (int) StrictMath.ceil(width[last])),
Math.max(1, (int) StrictMath.ceil(depth[last])), receiving, receiving,
List.of(new HydrologyPoint(x[last], head[last], z[last]),
new HydrologyPoint(receiverX, seaLevel, receiverZ)),
new HydraulicChannelProfile(new double[]{width[last], width[last]},
new double[]{depth[last], depth[last]})));
}
HydraulicSegment last = segments.getLast();
int lastStation = stations - 1;
return new SurfaceCourseResult(
segments,
head[lastStation],
last.downstreamHeadY(),
Math.max(1, (int) StrictMath.ceil(last.channelProfile().widthAt(last.centerline().size() - 1))),
Math.max(1, (int) StrictMath.ceil(last.channelProfile().depthAt(last.centerline().size() - 1))),
new HydrologyPoint(x[lastStation], head[lastStation], z[lastStation]),
last.end(),
null,
0
);
}
private List<HydraulicSegment> fallingTransitions(List<HydraulicSegment> labelled) {
private ArrayList<HydraulicSegment> fallingTransitions(List<HydraulicSegment> labelled, boolean directOcean) {
ArrayList<HydraulicSegment> segments = new ArrayList<>(labelled);
for (int index = 0; index + 1 < segments.size(); index++) {
HydraulicSegment fall = segments.get(index);
@@ -112,6 +127,9 @@ public final class SurfaceCourseBuilder {
continue;
}
int incision = terrain.surfacePolicy().maximumIncision(surface.maximumIncision());
if (directOcean && fall.downstreamHeadY() == seaLevel) {
incision = Math.max(incision, surface.banks().inlet().maximumIncision());
}
incision = Math.min(incision, (int) StrictMath.floor(incision * terrain.incisionMultiplier()));
if (terrain.naturalHeight() - fall.downstreamHeadY() + fall.depth() > incision) {
continue;
@@ -142,7 +160,7 @@ public final class SurfaceCourseBuilder {
receiver.fallingFluid(), receiver.receivingPool(),
receiver.centerline().subList(1, receiver.centerline().size()), receiverProfile));
}
return List.copyOf(segments);
return segments;
}
}
@@ -69,12 +69,11 @@ public final class SurfaceFootprintCompiler {
}
if (prepared.receivingSampler == null) {
prepared.receivingSampler = HydrologyOceanReceiver.forCourse(settings, sampler, course);
prepared.offsets = stationOffsets(course.segments());
prepared.inlet = SurfaceInletRange.forCourse(settings, course, prepared.receivingSampler);
}
HydrologyTerrainSampler receivingSampler = prepared.receivingSampler;
HydrologySurfaceDropRaster drops = HydrologySurfaceDropRaster.compile(settings, receivingSampler, geometry, course,
bounds == null ? null : bounds.expand(2));
int[] offsets = prepared.offsets;
ArrayList<SurfaceLayerColumn> columns = new ArrayList<>();
int uncontained = 0;
long excavation = 0L;
@@ -90,7 +89,7 @@ public final class SurfaceFootprintCompiler {
while (after < course.segments().size() && exposedSegment(course.segments().get(after))) {
after++;
}
SurfaceFootprint run = compileRun(prepared, first, after, offsets[first], bounds, drops, pool, receivingSampler);
SurfaceFootprint run = compileRun(prepared, first, after, prepared.inlet.offset(first), bounds, drops, pool, receivingSampler);
columns.addAll(run.columns());
uncontained += run.uncontainedWetCells();
excavation += run.bankExcavation();
@@ -123,7 +122,7 @@ public final class SurfaceFootprintCompiler {
}
private PreparedRun prepareRun(RiverCourse course, int first, int after, boolean pool,
HydrologyTerrainSampler receivingSampler) {
HydrologyTerrainSampler receivingSampler, SurfaceInletRange inlet) {
List<HydraulicSegment> exposed = course.segments().subList(first, after);
Stations stations = stations(exposed);
boolean fallingEnd = after < course.segments().size() && course.segments().get(after).fallingFluid();
@@ -141,9 +140,9 @@ public final class SurfaceFootprintCompiler {
: new HydrologyPlannerSettings.Ponds(first == 0 ? settings.surface().banks().ponds().source() : disabled.source(),
fallingEnd ? disabled.terminal() : settings.surface().banks().ponds().terminal());
SurfaceRunBoundary boundary = runBoundary(course, first, after, centerline, channel);
ErosionFieldCompiler erosion = new ErosionFieldCompiler(settings.surface(), receivingSampler, settings.seaLevel());
ErosionFieldCompiler erosion = new ErosionFieldCompiler(settings, receivingSampler);
return new PreparedRun(exposed, stations, coastalChannel, centerline, channel, poolBiome, valley,
terminal, ponds, boundary, erosion, erosion.prepare(centerline, channel, valley, terminal));
terminal, ponds, boundary, erosion, erosion.prepare(centerline, channel, valley, terminal, inlet.firstStation(first)));
}
private SurfaceFootprint compileRun(PreparedCourse prepared, int first, int after, int stationOffset,
@@ -152,7 +151,7 @@ public final class SurfaceFootprintCompiler {
RiverCourse course = prepared.course;
PreparedRun run = prepared.runs[first];
if (run == null) {
run = prepareRun(course, first, after, pool, receivingSampler);
run = prepareRun(course, first, after, pool, receivingSampler, prepared.inlet);
prepared.runs[first] = run;
}
List<HydraulicSegment> exposed = run.exposed();
@@ -210,26 +209,6 @@ public final class SurfaceFootprintCompiler {
+ settings.surface().banks().maximumBlendWidth() + settings.surface().shoreWidth() + 2D;
}
private static int[] stationOffsets(List<HydraulicSegment> segments) {
int[] offsets = new int[segments.size()];
int station = 0;
HydrologyPoint previous = null;
for (int segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++) {
List<HydrologyPoint> points = segments.get(segmentIndex).centerline();
for (int pointIndex = 0; pointIndex < points.size(); pointIndex++) {
HydrologyPoint point = points.get(pointIndex);
if (previous != null) {
station += Math.max(Math.abs(point.x() - previous.x()), Math.abs(point.z() - previous.z()));
}
if (pointIndex == 0) {
offsets[segmentIndex] = station;
}
previous = point;
}
}
return offsets;
}
private ChannelProfile acceptedProfile(Stations stations, SurfaceCenterline centerline) {
int count = centerline.size();
double[] halfWidth = new double[count];
@@ -406,7 +385,7 @@ public final class SurfaceFootprintCompiler {
private final RiverCourse course;
private final PreparedRun[] runs;
private HydrologyTerrainSampler receivingSampler;
private int[] offsets;
private SurfaceInletRange inlet;
private PreparedCourse(SurfaceFootprintCompiler compiler, RiverCourse course) {
this.compiler = compiler;
@@ -0,0 +1,104 @@
package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydraulicSegment;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
import art.arcane.iris.generation.hydrology.RiverCourse;
import art.arcane.iris.generation.hydrology.RiverCourseType;
import art.arcane.iris.generation.hydrology.RiverFootprint;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.longs.LongOpenHashSet;
import java.util.List;
public final class SurfaceInletRange {
private final int[] offsets;
private final int firstStation;
private final Long2ObjectOpenHashMap<LongOpenHashSet> fallingStations;
private SurfaceInletRange(int[] offsets, int firstStation,
Long2ObjectOpenHashMap<LongOpenHashSet> fallingStations) {
this.offsets = offsets;
this.firstStation = firstStation;
this.fallingStations = fallingStations;
}
public static SurfaceInletRange forCourse(HydrologyPlannerSettings settings, RiverCourse course,
HydrologyTerrainSampler receivingSampler) {
List<HydraulicSegment> segments = course.segments();
int[] offsets = offsets(segments);
Long2ObjectOpenHashMap<LongOpenHashSet> fallingStations = new Long2ObjectOpenHashMap<>();
if (course.type() != RiverCourseType.SURFACE || segments.isEmpty()
|| segments.getLast().type() != HydrologyFeatureType.MOUTH) {
return new SurfaceInletRange(offsets, Integer.MAX_VALUE, fallingStations);
}
int exposed = exposedStations(settings.seaLevel(), segments, offsets, receivingSampler);
HydrologyPlannerSettings.Inlet inlet = settings.surface().banks().inlet();
int reach = Math.min(inlet.length(), (int) StrictMath.floor(exposed * inlet.courseFraction()));
int firstStation = Math.max(0, exposed - reach - reach / 2);
for (int index = 0; index < segments.size(); index++) {
HydraulicSegment segment = segments.get(index);
if (!segment.type().isSurface() || !segment.fallingFluid()) {
continue;
}
SurfaceCenterline centerline = SurfaceCenterline.densify(segment.centerline());
int first = Math.max(0, firstStation - offsets[index]);
if (first >= centerline.size()) {
continue;
}
LongOpenHashSet positions = new LongOpenHashSet(centerline.size() - first);
for (int station = first; station < centerline.size(); station++) {
positions.add(RiverFootprint.pack(centerline.x()[station], centerline.z()[station]));
}
fallingStations.put(segment.id(), positions);
}
return new SurfaceInletRange(offsets, firstStation, fallingStations);
}
public int offset(int segmentIndex) {
return offsets[segmentIndex];
}
public int firstStation(int segmentIndex) {
return Math.max(0, firstStation - offsets[segmentIndex]);
}
public boolean allowsFallingStation(long segmentId, int x, int z) {
LongOpenHashSet positions = fallingStations.get(segmentId);
return positions != null && positions.contains(RiverFootprint.pack(x, z));
}
private static int[] offsets(List<HydraulicSegment> segments) {
int[] offsets = new int[segments.size()];
int station = 0;
HydrologyPoint previous = null;
for (int index = 0; index < segments.size(); index++) {
List<HydrologyPoint> points = segments.get(index).centerline();
for (int pointIndex = 0; pointIndex < points.size(); pointIndex++) {
HydrologyPoint point = points.get(pointIndex);
if (previous != null) {
station += Math.max(Math.abs(point.x() - previous.x()), Math.abs(point.z() - previous.z()));
}
if (pointIndex == 0) {
offsets[index] = station;
}
previous = point;
}
}
return offsets;
}
private static int exposedStations(int seaLevel, List<HydraulicSegment> segments, int[] offsets,
HydrologyTerrainSampler receivingSampler) {
int mouth = segments.size() - 1;
SurfaceCenterline centerline = SurfaceCenterline.densify(segments.get(mouth).centerline());
for (int station = centerline.size() - 1; station >= 0; station--) {
if (!receivingSampler.receivingWater(centerline.x()[station], centerline.z()[station], seaLevel)) {
return offsets[mouth] + station + 1;
}
}
return offsets[mouth];
}
}
@@ -121,12 +121,12 @@ public class MantleObjectComponent extends IrisMantleComponent {
@Override
public int getOutputRadius() {
return hasCollisionRules() ? 0 : getRadius();
return 0;
}
@Override
public int getInputRadius() {
return hasCollisionRules() ? calculateInputRadius(getRadius(), true) : 0;
return calculateInputRadius(getRadius(), hasCollisionRules());
}
@Override
@@ -140,10 +140,6 @@ public class MantleObjectComponent extends IrisMantleComponent {
@Override
public void generateLayer(MantleWriter writer, int x, int z, ChunkContext context) {
if (!hasCollisionRules()) {
generateOrigin(writer, x, z, context);
return;
}
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer, x, z);
replaySourceChunks(
x,
@@ -169,12 +165,14 @@ public class MantleObjectComponent extends IrisMantleComponent {
sourceChunkX,
sourceChunkZ
);
replaySourcePredecessors(
sourceChunkX,
sourceChunkZ,
getRadius(),
(predecessorX, predecessorZ) -> generateOrigin(scratch, predecessorX, predecessorZ, context)
);
if (hasCollisionRules()) {
replaySourcePredecessors(
sourceChunkX,
sourceChunkZ,
getRadius(),
(predecessorX, predecessorZ) -> generateOrigin(scratch, predecessorX, predecessorZ, context)
);
}
int checkpoint = scratch.mutationCheckpoint();
generateOrigin(scratch, sourceChunkX, sourceChunkZ, context);
return scratch.sourcePlanSince(checkpoint);
@@ -54,7 +54,9 @@ final class ObjectDestinationTransaction implements ObjectPassPlacer {
void apply(ObjectSourcePlan plan) {
for (Mutation mutation : plan.mutations()) {
mutation.apply(this);
if (isDestination(mutation.x(), mutation.z())) {
mutation.apply(this);
}
}
}
@@ -38,7 +38,6 @@ import art.arcane.iris.generation.mantle.EngineMantle;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.pack.value.IrisColor;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisDimensionCarvingEntry;
import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver;
import art.arcane.iris.structure.object.IrisObject;
import art.arcane.iris.structure.object.IrisObjectPlacement;
@@ -424,15 +423,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
IrisDimensionCarvingResolver.State state
) {
int worldY = y + getWorld().minHeight();
IrisDimensionCarvingEntry rootCarvingEntry = IrisDimensionCarvingResolver.resolveRootEntry(this, worldY, state);
if (rootCarvingEntry != null) {
IrisDimensionCarvingEntry resolvedCarvingEntry = IrisDimensionCarvingResolver.resolveFromRoot(this, rootCarvingEntry, x, z, state);
IrisBiome resolvedCarvingBiome = IrisDimensionCarvingResolver.resolveEntryBiome(this, resolvedCarvingEntry, state);
if (resolvedCarvingBiome != null) {
return resolvedCarvingBiome;
}
}
return null;
return IrisDimensionCarvingResolver.resolveBiome(this, x, worldY, z, state);
}
private IrisBiome resolveDepthCaveBiome(
@@ -79,6 +79,8 @@ final class EngineHotloader {
previousGeneration.mantleStorageDirectory(),
previousGeneration.runtimeKernel(),
previousGeneration.transitionPlan());
PreparedHydrologyCacheIdentity cacheIdentity = PreparedHydrologyCacheIdentity.capture(
assembly.target, assembly.runtimeKernel, assembly.transitionPlan, engine.isStudio());
engine.runtimeAssembly.set(assembly);
EngineRuntime next;
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
@@ -86,6 +88,7 @@ final class EngineHotloader {
assembly.dimensionStackContext = assembly.runtimeKernel.createDimensionStackContext(engine);
assembly.upperContext = assembly.runtimeKernel.createUpperContext(engine);
BiomeMaxes biomeMaxes = engine.runtimeBuilder.computeBiomeMaxes();
EngineRuntimeBuilder.enablePreparedCache(assembly, cacheIdentity, engine.isStudio());
GenerationRuntime nextGeneration = previousGeneration.withComplex(
assembly.cacheId,
assembly.complex,
@@ -57,13 +57,14 @@ final class EngineRuntimeBuilder {
this.engine = engine;
}
EngineRuntime buildRuntime() {
EngineRuntime buildRuntime(PreparedHydrologyCacheIdentity cacheIdentity) {
return buildRuntime(
engine.getTarget(),
engine.getInitialMantleStorageDirectory(),
engine.getInitialKernelVersion(),
engine.getInitialTransitionPlan(),
null);
null,
cacheIdentity);
}
EngineRuntime buildRuntime(EngineTarget runtimeTarget) {
@@ -73,7 +74,9 @@ final class EngineRuntimeBuilder {
active.mantleStorageDirectory(),
active.kernelVersion(),
active.transitionPlan(),
active.mantle());
active.mantle(),
PreparedHydrologyCacheIdentity.capture(runtimeTarget, selectRuntimeKernel(active.kernelVersion()),
active.transitionPlan(), engine.isStudio()));
}
private EngineRuntime buildRuntime(
@@ -81,7 +84,8 @@ final class EngineRuntimeBuilder {
Path mantleStorageDirectory,
GenerationKernelRegistry.Version kernelVersion,
TransitionGenerationPlan transitionPlan,
EngineMantle transferredMantle
EngineMantle transferredMantle,
PreparedHydrologyCacheIdentity cacheIdentity
) {
RuntimeAssembly assembly = new RuntimeAssembly(
RuntimeAssembly.nextRuntimeId(),
@@ -135,6 +139,7 @@ final class EngineRuntimeBuilder {
}
IrisLogging.debug("[IrisEngine timing] IrisWorldManager=" + (M.ms() - started) + "ms");
BiomeMaxes biomeMaxes = computeBiomeMaxes();
enablePreparedCache(assembly, cacheIdentity, engine.isStudio());
return assembly.freezeRuntime(biomeMaxes);
} catch (Throwable e) {
Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e);
@@ -167,6 +172,8 @@ final class EngineRuntimeBuilder {
mantleStorageDirectory,
selectRuntimeKernel(kernelVersion),
transitionPlan);
PreparedHydrologyCacheIdentity cacheIdentity = PreparedHydrologyCacheIdentity.capture(
runtimeTarget, assembly.runtimeKernel, transitionPlan, engine.isStudio());
engine.runtimeAssembly.set(assembly);
try (IrisContext.Scope ignored = IrisContext.open(engine, engine.getGenerationSessions().currentSessionId(), null)) {
IrisLogging.debug("Setup Detached Generation Runtime " + assembly.cacheId);
@@ -184,7 +191,9 @@ final class EngineRuntimeBuilder {
assembly.mode,
assembly.dimensionStackContext
);
return assembly.freezeGeneration(computeBiomeMaxes());
BiomeMaxes biomeMaxes = computeBiomeMaxes();
enablePreparedCache(assembly, cacheIdentity, engine.isStudio());
return assembly.freezeGeneration(biomeMaxes);
} catch (Throwable e) {
Throwable cleanupFailure = engine.shutdownSequence.closeAssembly(assembly, e);
if (cleanupFailure != e) {
@@ -196,6 +205,12 @@ final class EngineRuntimeBuilder {
}
}
static void enablePreparedCache(RuntimeAssembly assembly, PreparedHydrologyCacheIdentity identity, boolean studio) {
if (identity != null) {
identity.enableIfUnchanged(assembly.target, assembly.runtimeKernel, assembly.transitionPlan, assembly.complex, studio);
}
}
void publishRuntime(EngineRuntime next, EngineRuntime previous) {
Throwable retirementFailure = engine.shutdownSequence.closeRuntime(
previous,
@@ -560,7 +560,7 @@ public class IrisComplex implements DataProvider {
hydrologyNoiseCaches = List.of();
}
void enableStudioHydrologyCache(String runtimeIdentity, Path persistentRoot) {
void enablePreparedHydrologyCache(String runtimeIdentity, Path persistentRoot) {
if (hydrologyRuntime != null) {
hydrologyRuntime.enableSharedCache(runtimeIdentity, persistentRoot);
}
@@ -1934,6 +1934,7 @@ public class IrisComplex implements DataProvider {
private Engine engine;
private IrisGenerator[] generators;
private IdentityHashMap<IrisBiome, GeneratorBounds> cachedBounds;
private boolean integerCoordinates;
private boolean inUse;
private boolean isInUse() {
@@ -1945,6 +1946,7 @@ public class IrisComplex implements DataProvider {
this.engine = engine;
this.generators = generators;
this.cachedBounds = cachedBounds;
this.integerCoordinates = complex.baseBiomeStream instanceof CachedStream2D<?>;
this.inUse = true;
if (sampleSource.get() != complex.baseBiomeStream) {
sampleCache.clear();
@@ -1967,10 +1969,12 @@ public class IrisComplex implements DataProvider {
@Override
public NoiseBounds noise(double xx, double zz) {
try {
IrisBiome bx = sampleCache.get(xx, zz);
double sampleX = integerCoordinates ? (int) xx : xx;
double sampleZ = integerCoordinates ? (int) zz : zz;
IrisBiome bx = sampleCache.get(sampleX, sampleZ);
if (bx == null) {
bx = complex.baseBiomeStream.get(xx, zz);
sampleCache.put(xx, zz, bx);
sampleCache.put(sampleX, sampleZ, bx);
}
GeneratorBounds bounds = complex.resolveGeneratorBounds(engine, generators, bx, cachedBounds, localBounds);
@@ -35,7 +35,6 @@ import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisDimensionCarvingResolver;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.spi.IrisLogging;
import com.google.common.util.concurrent.AtomicDouble;
import art.arcane.iris.spi.IrisPlatforms;
@@ -303,7 +302,8 @@ public class IrisEngine implements Engine {
getData().registerEngine(this);
_t0 = M.ms();
long phaseStartedAt = System.nanoTime();
String studioCacheIdentity = currentStudioCacheIdentity();
PreparedHydrologyCacheIdentity cacheIdentity = PreparedHydrologyCacheIdentity.capture(
getTarget(), EngineRuntimeBuilder.selectRuntimeKernel(initialKernelVersion), initialTransitionPlan, studio);
getData().loadPrefetch(this);
IrisLogging.debug("[IrisEngine timing] loadPrefetch=" + (M.ms() - _t0) + "ms");
diagnostics.logStudioInitializationPhase("load_prefetch", phaseStartedAt, false);
@@ -315,8 +315,7 @@ public class IrisEngine implements Engine {
IrisLogging.notice("Engine init: " + requiredTarget.getWorld().name() + "/" + requiredTarget.getDimension().getLoadKey() + " seed=" + getSeedManager().getSeed());
_t0 = M.ms();
phaseStartedAt = System.nanoTime();
EngineRuntime initialRuntime = runtimeBuilder.buildRuntime();
enableStableStudioCache(initialRuntime, studioCacheIdentity);
EngineRuntime initialRuntime = runtimeBuilder.buildRuntime(cacheIdentity);
runtimeBuilder.publishRuntime(initialRuntime, null);
IrisLogging.debug("[IrisEngine timing] setupEngine total=" + (M.ms() - _t0) + "ms");
diagnostics.logPackCompatSummary();
@@ -359,47 +358,6 @@ public class IrisEngine implements Engine {
}
}
private String currentStudioCacheIdentity() {
if (initializationMode != InitializationMode.STUDIO || !IrisPlatforms.isBound()) {
return "";
}
try {
String contentFingerprint = PackValidationCache.contentFingerprint(
IrisPlatforms.get().packsFolderNoCreate());
String contextFingerprint = PackValidationCache.contextFingerprint();
if (contentFingerprint.isBlank() || contextFingerprint.isBlank()) {
return "";
}
return contentFingerprint + contextFingerprint;
} catch (RuntimeException failure) {
IrisLogging.warn("Studio runtime cache fingerprint failed: " + failure.getMessage());
return "";
}
}
private void enableStableStudioCache(EngineRuntime runtime, String initialIdentity) {
if (initialIdentity.isBlank()) {
return;
}
String finalIdentity = currentStudioCacheIdentity();
if (!initialIdentity.equals(finalIdentity)) {
IrisLogging.warn("Studio packs changed during runtime compilation; shared generation caches are disabled.");
return;
}
runtime.generation().complex().enableStudioHydrologyCache(initialIdentity, studioHydrologyCacheRoot());
IrisLogging.debug("Enabled shared Studio hydrology cache identity="
+ initialIdentity.substring(0, Math.min(12, initialIdentity.length())));
}
private Path studioHydrologyCacheRoot() {
Path packsRoot = IrisPlatforms.get().packsFolderNoCreate().toPath().toAbsolutePath().normalize();
Path packRoot = packsRoot.resolve(getDimension().getLoadKey()).normalize();
if (!packRoot.startsWith(packsRoot)) {
throw new IllegalStateException("Studio dimension key resolves outside the Iris packs folder.");
}
return packRoot.resolve(".iris").resolve("studio-hydrology");
}
public void startEntryHydrology(int blockX, int blockZ) {
GenerationRuntimeBinding binding = getActiveGenerationRuntimeBinding();
if (!backgroundTasks.scheduleTrackedTask(() -> prepareEntryHydrology(binding, blockX, blockZ))) {
@@ -0,0 +1,118 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.PackDirectoryResolver;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.world.history.GenerationKernelRegistry;
import art.arcane.iris.world.history.GenerationPackFingerprint;
import art.arcane.iris.world.history.TransitionGenerationPlan;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
record PreparedHydrologyCacheIdentity(String fingerprint) {
static PreparedHydrologyCacheIdentity capture(
EngineTarget target,
GenerationKernelRegistry.RuntimeKernel kernel,
TransitionGenerationPlan transition,
boolean studio
) {
if (!IrisPlatforms.isBound()) {
return null;
}
try {
String context = PackValidationCache.contextFingerprint();
if (context.isBlank()) {
return null;
}
String content = GenerationPackFingerprint.compute(
target.getData().getDataFolder().toPath(), GenerationPackFingerprint.CURRENT_VERSION);
String dependencies = target.getData().hasGenerationRegistryContract() ? "closed"
: dependencyFingerprint(IrisPlatforms.get().packsFolderNoCreate());
if (dependencies.isBlank()) {
return null;
}
int buffetSize = studio && "bukkit".equals(IrisPlatforms.get().platformName())
? target.getDimension().getStudioMode().biomeSizeChunks() : 0;
return fromInputs(content, dependencies, context, kernel, transition == null ? null : transition.specification(),
target.getWorld().minHeight(), target.getWorld().maxHeight(), buffetSize);
} catch (IOException | RuntimeException failure) {
IrisLogging.reportError("Prepared hydrology cache identity could not be captured.", failure);
return null;
}
}
static PreparedHydrologyCacheIdentity fromInputs(
String packContent,
String dependencies,
String platformContext,
GenerationKernelRegistry.RuntimeKernel kernel,
TransitionGenerationPlan.Specification transition,
int minimumHeight,
int maximumHeight,
int biomeBuffetSize
) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
update(digest, packContent);
update(digest, dependencies);
update(digest, platformContext);
update(digest, kernel.version().toString());
update(digest, kernel.implementationFingerprint());
update(digest, Integer.toString(minimumHeight));
update(digest, Integer.toString(maximumHeight));
update(digest, Integer.toString(biomeBuffetSize));
update(digest, Boolean.toString(transition != null));
if (transition != null) {
update(digest, Long.toString(transition.activationId()));
update(digest, transition.oldEpochId());
update(digest, transition.newEpochId());
update(digest, Integer.toString(transition.algorithmVersion()));
update(digest, Integer.toString(transition.widthBlocks()));
update(digest, transition.boundaryIdentity());
update(digest, transition.terrainSignatureIdentity());
}
return new PreparedHydrologyCacheIdentity(HexFormat.of().formatHex(digest.digest()));
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable.", failure);
}
}
void enableIfUnchanged(EngineTarget target, GenerationKernelRegistry.RuntimeKernel kernel,
TransitionGenerationPlan transition, IrisComplex complex, boolean studio) {
if (!equals(capture(target, kernel, transition, studio))) {
IrisLogging.warn("Generation inputs changed during compilation; prepared hydrology caching is disabled.");
return;
}
complex.enablePreparedHydrologyCache(fingerprint,
IrisPlatforms.get().dataFolderNoCreate("cache", "hydrology-plans").toPath());
}
private static void update(MessageDigest digest, String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
digest.update((byte) (bytes.length >>> 24));
digest.update((byte) (bytes.length >>> 16));
digest.update((byte) (bytes.length >>> 8));
digest.update((byte) bytes.length);
digest.update(bytes);
}
private static String dependencyFingerprint(File packsRoot) throws IOException {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (File pack : PackDirectoryResolver.listVisiblePackDirectoriesOrThrow(packsRoot)) {
update(digest, pack.getName());
update(digest, GenerationPackFingerprint.compute(
pack.toPath().toRealPath(), GenerationPackFingerprint.CURRENT_VERSION));
}
return HexFormat.of().formatHex(digest.digest());
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable.", failure);
}
}
}
@@ -151,7 +151,8 @@ public final class HydrologyBankTerrainRuntime {
return new BankColumn(flat, flat);
}
if (base >= natural.topY()) {
return new BankColumn(natural, natural);
Terrain3DColumn filled = Terrain3DColumn.unshaped(base, options.height());
return new BankColumn(filled, filled);
}
double rise = Math.min(amplitude, natural.topY() - base);
IntArrayList boundaries = new IntArrayList(natural.spanCount() * 2 + 8);
@@ -28,6 +28,17 @@ public final class IrisDimensionCarvingResolver {
}
public static IrisBiome resolveBiome(Engine engine, int worldX, int worldY, int worldZ, State state) {
State resolvedState = state == null ? threadState() : state;
IrisData data = resolvedState.bind(engine);
IrisDimensionCarvingEntry root = resolveBoundRootEntry(engine, worldY, data, resolvedState);
if (root == null) {
return null;
}
IrisDimensionCarvingEntry resolved = resolveBoundFromRoot(engine, root, worldX, worldZ, data, resolvedState);
return resolved == null ? null : resolveBoundEntryBiome(data, resolved, resolvedState);
}
public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY) {
return resolveRootEntry(engine, worldY, threadState());
}
@@ -35,6 +46,36 @@ public final class IrisDimensionCarvingResolver {
public static IrisDimensionCarvingEntry resolveRootEntry(Engine engine, int worldY, State state) {
State resolvedState = state == null ? threadState() : state;
IrisData data = resolvedState.bind(engine);
return resolveBoundRootEntry(engine, worldY, data, resolvedState);
}
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) {
return resolveFromRoot(engine, rootEntry, worldX, worldZ, threadState());
}
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) {
State resolvedState = state == null ? threadState() : state;
IrisData data = resolvedState.bind(engine);
return resolveBoundFromRoot(engine, rootEntry, worldX, worldZ, data, resolvedState);
}
public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry) {
return resolveEntryBiome(engine, entry, null);
}
public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry, State state) {
if (entry == null) {
return null;
}
if (state == null) {
return entry.getRealBiome(engine.getData());
}
return resolveBoundEntryBiome(state.bind(engine), entry, state);
}
private static IrisDimensionCarvingEntry resolveBoundRootEntry(Engine engine, int worldY, IrisData data, State resolvedState) {
if (resolvedState.rootEntriesByWorldY.containsKey(worldY)) {
return resolvedState.rootEntriesByWorldY.get(worldY);
}
@@ -59,13 +100,7 @@ public final class IrisDimensionCarvingResolver {
return resolved;
}
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ) {
return resolveFromRoot(engine, rootEntry, worldX, worldZ, threadState());
}
public static IrisDimensionCarvingEntry resolveFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, State state) {
State resolvedState = state == null ? threadState() : state;
IrisData data = resolvedState.bind(engine);
private static IrisDimensionCarvingEntry resolveBoundFromRoot(Engine engine, IrisDimensionCarvingEntry rootEntry, int worldX, int worldZ, IrisData data, State resolvedState) {
if (rootEntry == null) {
return null;
}
@@ -100,22 +135,6 @@ public final class IrisDimensionCarvingResolver {
return current;
}
public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry) {
return resolveEntryBiome(engine, entry, null);
}
public static IrisBiome resolveEntryBiome(Engine engine, IrisDimensionCarvingEntry entry, State state) {
if (entry == null) {
return null;
}
if (state == null) {
return entry.getRealBiome(engine.getData());
}
return resolveBoundEntryBiome(state.bind(engine), entry, state);
}
private static IrisBiome resolveBoundEntryBiome(IrisData data, IrisDimensionCarvingEntry entry, State state) {
if (state.biomeCache.containsKey(entry)) {
return state.biomeCache.get(entry);
@@ -92,9 +92,10 @@ public final class Terrain3DRuntime {
int gridX = Math.floorDiv(x, STEP) * STEP;
int gridZ = Math.floorDiv(z, STEP) * STEP;
Anchor northWest = anchor(gridX, gridZ);
Anchor northEast = anchor(gridX + STEP, gridZ);
Anchor southWest = anchor(gridX, gridZ + STEP);
Anchor southEast = anchor(gridX + STEP, gridZ + STEP);
Anchor northEast = x == gridX ? northWest : anchor(gridX + STEP, gridZ);
Anchor southWest = z == gridZ ? northWest : anchor(gridX, gridZ + STEP);
Anchor southEast = x == gridX ? southWest
: z == gridZ ? northEast : anchor(gridX + STEP, gridZ + STEP);
double dx = (x - gridX) / (double) STEP;
double dz = (z - gridZ) / (double) STEP;
double amplitude = interpolate(northWest.amplitude(), northEast.amplitude(),
@@ -49,14 +49,25 @@ public final class PackValidationRegistry {
if (packRoot == null || result == null) {
return;
}
publish(normalize(packRoot), new RootValidation(result, ""));
publish(normalize(packRoot), new RootValidation(result, "", ""));
}
public static void publish(Path packRoot, PackValidationResult result, String contentFingerprint) {
public static void publish(
Path packRoot,
PackValidationResult result,
String contentFingerprint,
String validatedContextFingerprint
) {
if (packRoot == null || result == null || contentFingerprint == null || contentFingerprint.isBlank()) {
return;
}
publish(normalize(packRoot), new RootValidation(result, contentFingerprint));
if (validatedContextFingerprint == null
|| !validatedContextFingerprint.equals(PackValidationCache.contextFingerprint())) {
publish(packRoot, result);
return;
}
publish(normalize(packRoot), new RootValidation(
result, contentFingerprint, validatedContextFingerprint));
}
public static PackValidationResult publishMatchingCopy(
@@ -118,7 +129,7 @@ public final class PackValidationRegistry {
return new RootState(
current.generation(),
false,
new RootValidation(result, ""));
new RootValidation(result, "", ""));
});
return published.get();
}
@@ -140,6 +151,14 @@ public final class PackValidationRegistry {
: state.validation().result();
}
public static PackValidationResult getMatching(Path packRoot, String contentFingerprint) {
if (packRoot == null || contentFingerprint == null || contentFingerprint.isBlank()) {
return null;
}
RootValidation validation = matchingValidation(packRoot, contentFingerprint);
return validation == null ? null : validation.result();
}
public static PackValidationResult requireLoadable(String packName) {
if (packName == null || packName.isBlank()) {
throw new IllegalArgumentException("Pack name is required for validation");
@@ -250,10 +269,11 @@ public final class PackValidationRegistry {
if (sourceState == null
|| sourceState.mutating()
|| sourceState.validation() == null
|| !copiedContentFingerprint.equals(sourceState.validation().contentFingerprint())) {
|| !copiedContentFingerprint.equals(sourceState.validation().contentFingerprint())
|| !sourceState.validation().contextFingerprint().equals(PackValidationCache.contextFingerprint())) {
return null;
}
return sourceState.validation();
return ROOT_STATES.get(normalizedSource) == sourceState ? sourceState.validation() : null;
}
private static long nextGeneration(RootState current) {
@@ -297,6 +317,7 @@ public final class PackValidationRegistry {
requireOpen();
pendingValidation = new RootValidation(
Objects.requireNonNull(result, "Pack validation result"),
"",
"");
}
@@ -345,7 +366,11 @@ public final class PackValidationRegistry {
}
}
private record RootValidation(PackValidationResult result, String contentFingerprint) {
private record RootValidation(
PackValidationResult result,
String contentFingerprint,
String contextFingerprint
) {
}
private record RootState(long generation, boolean mutating, RootValidation validation) {
@@ -300,6 +300,10 @@ public class IrisData implements ExclusionStrategy, TypeAdapterFactory {
}
}
public boolean hasGenerationRegistryContract() {
return generationRegistryContract != null;
}
public synchronized void bindGenerationRegistryContract(GenerationRegistryContract contract) {
GenerationRegistryContract required = Objects.requireNonNull(contract, "contract");
if (generationRegistryContract != null && !generationRegistryContract.equals(required)) {
@@ -716,6 +716,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
} catch (IOException failure) {
throw new IllegalStateException("Unable to load the active Iris generation transition.", failure);
}
engineTarget.getData().bindGenerationRegistryContract(epoch.registryContract());
IrisEngine createdEngine = new IrisEngine(
engineTarget,
mode,
@@ -1319,7 +1320,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
openGenerationHistoryCoordinateScope(engine, x << 4, z << 4);
GenerationSessionLease lease = engine.acquireGenerationLease("bukkit_terrain_stage");
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
engine.generate(x << 4, z << 4, blocks, biomes, engine.shouldGenerateMulticore());
engine.generate(x << 4, z << 4, blocks, biomes,
initialEntryPending || studioEntryBootstrapActive.get() || engine.shouldGenerateMulticore());
blocks.apply();
if (historyScope != null) {
historyScope.claimGeneratedSemantics();
@@ -362,7 +362,7 @@ public class StudioSVC implements IrisService {
packRoot,
GenerationPackFingerprint.CURRENT_VERSION
);
validatePublishedPack(packRoot);
validateGenerationCandidate(packRoot, fingerprint);
IrisData data = IrisData.openDatapackCompiler(packRoot.toFile());
try {
IrisDimension dimension = data.getDimensionLoader().load(dimensionKey, false);
@@ -566,6 +566,17 @@ public class StudioSVC implements IrisService {
}
}
static PackValidationResult validateGenerationCandidate(Path packRoot, String contentFingerprint) {
PackValidationResult validation = PackValidationRegistry.getMatching(packRoot, contentFingerprint);
if (validation == null) {
return validatePublishedPack(packRoot);
}
if (!validation.isLoadable()) {
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
}
return validation;
}
static PackValidationResult validatePublishedPack(
Path packRoot,
Path validatedSource,
@@ -29,6 +29,7 @@ import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.iris.platform.bukkit.nms.INMSBinding;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.volmlib.util.collection.KSet;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
@@ -69,6 +70,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private static final long MANTLE_CLEANUP_DRAIN_SECONDS = 60L;
private static final long CHUNK_FLUSH_DRAIN_SECONDS = 30L;
private static final long ADMISSION_WAIT_BOUND_MS = 500L;
private static final int HYDROLOGY_PREFETCH_HALO_BLOCKS = 512;
private final World world;
private final IrisRuntimeSchedulerMode runtimeSchedulerMode;
private final IrisPaperLikeBackendMode paperLikeBackendMode;
@@ -117,6 +119,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
private final AtomicBoolean holdsWorkerBoost = new AtomicBoolean();
private volatile Engine metricsEngine;
private volatile Mantle cachedMantle;
private volatile HydrologyTileCache.PregenerationScope hydrologyPrefetchScope;
private final PregenMantleBackpressure backpressure;
public AsyncPregenMethod(World world, int unusedThreads) {
@@ -298,7 +301,18 @@ public class AsyncPregenMethod implements PregeneratorMethod {
if (engine == null || engine.getComplex() == null || engine.getComplex().getHydrologyRuntime() == null) {
return;
}
engine.getComplex().getHydrologyRuntime().preparePregeneration(centerBlockX, centerBlockZ);
HydrologyTileCache.PregenerationArea area = new HydrologyTileCache.PregenerationArea(
centerBlockX, centerBlockZ,
(long) boundsMinRegionX * 512L - HYDROLOGY_PREFETCH_HALO_BLOCKS,
(long) boundsMinRegionZ * 512L - HYDROLOGY_PREFETCH_HALO_BLOCKS,
((long) boundsMaxRegionX + 1L) * 512L - 1L + HYDROLOGY_PREFETCH_HALO_BLOCKS,
((long) boundsMaxRegionZ + 1L) * 512L - 1L + HYDROLOGY_PREFETCH_HALO_BLOCKS);
HydrologyTileCache.PregenerationScope scope = engine.getComplex().getHydrologyRuntime().preparePregeneration(area);
hydrologyPrefetchScope = scope;
if (closing.get()) {
scope.close();
hydrologyPrefetchScope = null;
}
}
private boolean inBounds(int rx, int rz) {
@@ -827,6 +841,11 @@ public class AsyncPregenMethod implements PregeneratorMethod {
releaseWorkerThreadBoost();
}
} finally {
HydrologyTileCache.PregenerationScope scope = hydrologyPrefetchScope;
if (scope != null) {
scope.close();
hydrologyPrefetchScope = null;
}
if (interrupted) {
Thread.currentThread().interrupt();
}
@@ -196,9 +196,15 @@ public class PregenTask {
public void iterateRegions(Spiraled s) {
Bound bound = bounds.region();
new Spiraler(bound.sizeX, bound.sizeZ, ((x, z) -> {
if (bound.check(x, z)) s.on(x, z);
})).setOffset(PowerOfTwoCoordinates.blockToRegionFloor(center.getX()), PowerOfTwoCoordinates.blockToRegionFloor(center.getZ())).drain();
int centerRegionX = PowerOfTwoCoordinates.blockToRegionFloor(center.getX());
int centerRegionZ = PowerOfTwoCoordinates.blockToRegionFloor(center.getZ());
int width = Math.max(centerRegionX - bound.minX(), bound.maxX() - centerRegionX) * 2 + 1;
int depth = Math.max(centerRegionZ - bound.minZ(), bound.maxZ() - centerRegionZ) * 2 + 1;
new Spiraler(width, depth, ((x, z) -> {
if (bound.check(x, z)) {
s.on(x, z);
}
})).setOffset(centerRegionX, centerRegionZ).drain();
}
public void iterateChunks(int rX, int rZ, Spiraled s) {
@@ -291,10 +297,10 @@ public class PregenTask {
PowerOfTwoCoordinates.ceilDivPow2(maxZ, PowerOfTwoCoordinates.CHUNK_BITS)
);
region = new Bound(
PowerOfTwoCoordinates.blockToRegionFloor(minX),
PowerOfTwoCoordinates.blockToRegionFloor(minZ),
PowerOfTwoCoordinates.ceilDivPow2(maxX, PowerOfTwoCoordinates.REGION_BITS),
PowerOfTwoCoordinates.ceilDivPow2(maxZ, PowerOfTwoCoordinates.REGION_BITS)
PowerOfTwoCoordinates.chunkToRegion(chunk.minX()),
PowerOfTwoCoordinates.chunkToRegion(chunk.minZ()),
PowerOfTwoCoordinates.chunkToRegion(chunk.maxX()),
PowerOfTwoCoordinates.chunkToRegion(chunk.maxZ())
);
requireSaneSpan(region);
}
@@ -24,7 +24,7 @@ public class HydrologyCacheBudgetTest {
HydrologyCacheBudget budget = HydrologyCacheBudget.forHeap(heap, regionalEnabled);
long total = budget.tileBytes() + budget.columnBytes() + budget.diagnosticBytes()
+ budget.routingBytes() + budget.ownerBytes() + budget.edgeBytes() + budget.regionalDraftBytes()
+ budget.regionalCoarseBytes() + budget.regionalTerrainBytes() + budget.regionalReachBytes();
+ budget.regionalCoarseBytes() + budget.regionalTerrainBytes();
assertTrue(total <= Math.clamp(heap / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE));
assertTrue(total >= 8L * MEBIBYTE);
assertEquals(budget.tileBytes() / 6L, budget.columnBytes());
@@ -40,7 +40,7 @@ public class HydrologyCacheBudgetTest {
assertEquals(24L * MEBIBYTE, localOnly.routingBytes());
assertEquals(8L * MEBIBYTE, localOnly.edgeBytes());
assertEquals(0L, localOnly.regionalDraftBytes() + localOnly.regionalCoarseBytes()
+ localOnly.regionalTerrainBytes() + localOnly.regionalReachBytes());
+ localOnly.regionalTerrainBytes());
assertThrows(IllegalArgumentException.class, () -> HydrologyCacheBudget.forHeap(0L, false));
}
@@ -112,7 +112,7 @@ public class HydrologyCacheBudgetTest {
HydrologyRegionalGraph.Label second = new HydrologyRegionalGraph.Label(
new HydrologyRegionalGraph.LabelState(3, 2, 63, 1D, 1D, 0), downstream);
HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(
new HydrologyRegionalGraph.Label[]{first, second, null}, new int[4], List.of());
new HydrologyRegionalGraph.Label[]{first, second, null}, new int[4], List.of(), new int[4]);
assertEquals(3, tree.retainedLabelCount());
}
}
@@ -0,0 +1,66 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.OptionalLong;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class HydrologyCoastalFallAdmissionTest {
private static final HydrologyPlannerSettings SETTINGS = HydrologyPlannerSettings.defaults();
private static final HydrologyTerrainSampler TERRAIN = (x, z) -> x >= 80
? HydrologyTerrainSample.ocean(54, "ocean") : HydrologyTerrainSample.openLand(95, 0D, "land");
@Test
public void aConnectedMouthAdmitsTheCoastalFallAtTheNaturalSeaSurface() {
RiverCourse course = course(HydrologyFeatureType.MOUTH, 80);
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
assertTrue(compiler.surfaceFootprint(course).toString(), compiler.surfaceFootprint(course).accepted());
HydrologyCaveCourseFilter.Result result = filter().filter(List.of(), List.of(), List.of(), List.of(course),
compiler.compileValidation(List.of(course)), diagnostics);
assertEquals(diagnostics.toString(), List.of(course), result.courses());
assertEquals(1, result.cavePlans().size());
assertTrue(result.cavePlans().getFirst().accepted());
assertTrue(diagnostics.isEmpty());
}
@Test
public void anInlandTerminalOrDisconnectedMouthCannotOpenTheFallAtSeaLevel() {
for (RiverCourse course : List.of(course(HydrologyFeatureType.SURFACE_POOL, 80),
course(HydrologyFeatureType.MOUTH, 81))) {
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
HydrologyCaveCourseFilter.Result result = filter().filter(List.of(), List.of(), List.of(), List.of(course),
compiler.compileValidation(List.of(course)), diagnostics);
assertTrue(result.courses().isEmpty());
assertFalse(diagnostics.isEmpty());
assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT, diagnostics.getFirst().rejection());
}
}
private static HydrologyCaveCourseFilter filter() {
return new HydrologyCaveCourseFilter(new HydrologyTerrainCaveVoxelView(TERRAIN, SETTINGS.seaLevel(), -64, 256),
new HydrologyCaveCourseFilter.Options(false, 8192, 8192));
}
private static RiverCourse course(HydrologyFeatureType terminal, int receiverX) {
int sea = SETTINGS.seaLevel();
HydraulicSegment approach = new HydraulicSegment(1L, 4L, HydrologyFeatureType.SURFACE_POOL,
93, 93, 4, 2, false, false, List.of(new HydrologyPoint(0, 93, 0), new HydrologyPoint(79, 93, 0)),
HydraulicChannelProfile.uniform(4, 2));
HydraulicSegment fall = new HydraulicSegment(2L, 4L, HydrologyFeatureType.WATERFALL,
93, sea, 4, 2, true, true, List.of(new HydrologyPoint(79, 93, 0), new HydrologyPoint(80, sea, 0)),
HydraulicChannelProfile.uniform(4, 2));
HydraulicSegment receiving = new HydraulicSegment(3L, 4L, terminal, sea, sea, 4, 2, false, false,
List.of(new HydrologyPoint(receiverX, sea, 0)), HydraulicChannelProfile.uniform(4, 2));
return new RiverCourse(4L, RiverCourseType.SURFACE, OptionalLong.of(5L), OptionalLong.of(6L),
"default", 1, List.of(), List.of(approach, fall, receiving));
}
}
@@ -0,0 +1,191 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.doReturn;
public class HydrologyDrainageGeometryTest {
@Test
public void sourceBranchesShareExactlyTheSameDescendingTrunk() {
HydrologyTerrainSampler terrain = (x, z) -> land(120 - x / 8);
Fixture fixture = fixture(terrain);
HydrologyDrainageGeometry geometry = fixture.geometry();
List<HydrologyPoint> first = geometry.edge(0);
List<HydrologyPoint> tributary = geometry.edge(3);
List<HydrologyPoint> trunk = geometry.edge(1);
assertFalse(first.isEmpty());
assertFalse(tributary.isEmpty());
assertFalse(trunk.isEmpty());
assertEquals(first.getLast(), trunk.getFirst());
assertEquals(tributary.getLast(), trunk.getFirst());
for (List<HydrologyPoint> edge : List.of(first, tributary, trunk)) {
for (int index = 1; index < edge.size(); index++) {
assertTrue(edge.get(index - 1).y() >= edge.get(index).y());
}
}
assertEquals(geometry.head(1), trunk.getFirst().y());
}
@Test
public void repeatedAndConcurrentSourcesReuseOneGeometryComputation() {
AtomicInteger sampled = new AtomicInteger();
Fixture fixture = fixture((x, z) -> {
sampled.incrementAndGet();
return land(120 - x / 8);
});
ArrayList<CompletableFuture<List<HydrologyPoint>>> tasks = new ArrayList<>();
for (int index = 0; index < 16; index++) {
tasks.add(CompletableFuture.supplyAsync(() -> fixture.geometry().edge(0)));
}
List<HydrologyPoint> expected = tasks.getFirst().join();
assertFalse(expected.isEmpty());
for (CompletableFuture<List<HydrologyPoint>> task : tasks) {
assertSame(expected, task.join());
}
int samples = sampled.get();
assertSame(expected, fixture.geometry().edge(0));
assertEquals(samples, sampled.get());
}
@Test
public void aFailedRequiredSampleCanBeRetriedWithoutCachingItsFailure() {
AtomicInteger attempts = new AtomicInteger();
IllegalStateException unavailable = new IllegalStateException("Terrain unavailable");
Fixture fixture = fixture((x, z) -> {
if (x == 31 && attempts.incrementAndGet() == 1) {
throw unavailable;
}
return land(120 - x / 8);
});
assertSame(unavailable, assertThrows(IllegalStateException.class, () -> fixture.geometry().edge(0)));
assertFalse(fixture.geometry().edge(0).isEmpty());
}
@Test
public void detailedReliefDoesNotSendTheSharedRiverSearchingForAnotherRoute() {
Fixture flat = fixture((x, z) -> land(120 - x / 8));
Fixture broken = fixture((x, z) -> land(x > 24 && x < 40 ? 90 : 120 - x / 8));
assertEquals(flat.geometry().edge(0), broken.geometry().edge(0));
assertFalse(broken.geometry().edge(0).isEmpty());
}
@Test
public void aThinExcludedPolicyBandStillBlocksAnEdge() {
HydrologyTerrainSample blocked = spy(land(116));
doReturn(false).when(blocked).transitAllowed();
Fixture fixture = fixture((x, z) -> x == 31 ? blocked : land(120 - x / 8));
assertTrue(fixture.geometry().edge(0).isEmpty());
}
@Test
public void coastalRootKeepsItsLandwardPointAndDescendingHeadAboveTheSea() {
Fixture fixture = fixture((x, z) -> land(120 - x / 8));
HydrologyPoint root = fixture.geometry().anchor(2);
assertEquals(128, root.x());
assertEquals(0, root.z());
assertTrue(root.y() >= fixture.planner().settings.seaLevel());
assertEquals(root, fixture.geometry().edge(1).getLast());
}
@Test
public void everyOutletTrialReusesTheSameGridCostsAndPolicyEdges() {
Fixture fixture = fixture((x, z) -> land(120 - x / 8));
HydrologySourcePlanner sources = fixture.planner().sourcePlanner;
HydrologyDrainageGraph graph = fixture.grid().drainage(sources);
sources.buildRouting(fixture.grid(), List.of(fixture.outlet()), true);
sources.buildRouting(fixture.grid(), List.of(fixture.outlet()), false);
assertSame(graph, fixture.grid().drainage(sources));
for (HydrologyGridNode downstream : fixture.grid().nodes()) {
for (int direction = 0; direction < HydrologySourcePlanner.ROUTING_OFFSETS.size(); direction++) {
HydrologyGridOffset offset = HydrologySourcePlanner.ROUTING_OFFSETS.get(direction);
HydrologyGridNode upstream = fixture.grid().nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z());
if (upstream == null) {
assertEquals(-1, graph.upstream(downstream.index(), direction, false));
} else {
assertEquals(upstream.index(), graph.upstream(downstream.index(), direction, false));
assertEquals(sources.routeCost(upstream, downstream, offset,
sources.minimumNeighborHeight(fixture.grid(), downstream)), graph.cost(downstream.index(), direction), 0D);
}
}
}
}
@Test
public void aCuttableRidgeDoesNotLiftTheSharedUpstreamRiverAboveItsSpring() {
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Surface surface = base.surface();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(),
surface.maximumWidth(), surface.minimumDepth(), surface.maximumDepth(), 64,
surface.shoreWidth(), surface.banks()), base.hydraulics(), base.underground(),
base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(),
base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
HydrologyTerrainSampler terrain = (x, z) -> land(x == 64 ? 154 : 120 - x / 8);
Fixture fixture = fixture(terrain, settings, terrain);
HydrologyDrainageGeometry geometry = fixture.geometry();
int depth = (surface.minimumDepth() + surface.maximumDepth()) / 2;
assertEquals(120 - surface.banks().sink() - depth, geometry.head(0));
assertTrue(geometry.head(0) >= geometry.head(1));
assertTrue(geometry.head(1) >= geometry.head(2));
assertTrue(154 - geometry.head(1) + depth <= 64);
assertEquals(geometry.edge(0).getLast(), geometry.edge(1).getFirst());
}
@Test
public void aSourceExcludedDepressionDoesNotLowerTheSharedOutletHead() {
HydrologyTerrainSample excluded = spy(land(90));
doReturn(false).when(excluded).surfaceSourceAllowed();
HydrologyTerrainSampler terrain = (x, z) -> z == 0 ? land(120 - x / 8) : excluded;
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
Fixture fixture = fixture(terrain, settings, terrain);
HydrologyPlannerSettings.Surface surface = settings.surface();
int depth = (surface.minimumDepth() + surface.maximumDepth()) / 2;
assertEquals(104 - surface.banks().sink() - depth, fixture.geometry().head(2));
assertTrue(fixture.geometry().head(0) >= fixture.geometry().head(1));
assertTrue(fixture.geometry().head(1) >= fixture.geometry().head(2));
assertEquals(fixture.geometry().edge(0).getLast(), fixture.geometry().edge(1).getFirst());
}
private Fixture fixture(HydrologyTerrainSampler terrain) {
return fixture(terrain, HydrologyPlannerSettings.defaults(), (x, z) -> land(120 - x / 8));
}
private Fixture fixture(HydrologyTerrainSampler terrain, HydrologyPlannerSettings settings,
HydrologyTerrainSampler coarseTerrain) {
HydrologyPlanner planner = new HydrologyPlanner(91L, settings, terrain);
ArrayList<HydrologyGridNode> nodes = new ArrayList<>();
for (int index = 0; index < 9; index++) {
int x = index % 3 * 64;
int z = index / 3 * 64;
nodes.add(new HydrologyGridNode(index, index % 3, index / 3, x, z, index + 1L, coarseTerrain.sample(x, z)));
}
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 192, 3, 64, nodes);
OutletCandidate outlet = new OutletCandidate(2, -1, new RiverOutlet(10L, HydrologyFeatureType.MOUTH,
3L, new HydrologyPoint(128, 104, 0), new HydrologyPoint(129, 63, 0), 63, true));
int[] parent = {1, 2, -1, 1, 1, 2, 3, 4, 5};
HydrologyDrainageGeometry geometry = new HydrologyDrainageGeometry(planner,
new HydrologyDrainageGeometry.Basin(grid, parent, new int[9], List.of(outlet)));
return new Fixture(planner, grid, outlet, geometry);
}
private static HydrologyTerrainSample land(int height) {
return HydrologyTerrainSample.openLand(height, 0D, "land");
}
private record Fixture(HydrologyPlanner planner, HydrologySampledGrid grid,
OutletCandidate outlet, HydrologyDrainageGeometry geometry) {
}
}
@@ -154,7 +154,7 @@ public class HydrologyFallbackDiagnosticsTest {
}
private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) {
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true);
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true, null);
}
private static OutletCandidate outlet(int index) {
@@ -247,7 +247,7 @@ public class HydrologyFallbackParallelismTest {
}
private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) {
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true);
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true, null);
}
private static OutletCandidate outlet(int index) {
@@ -1,5 +1,6 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
import org.junit.Test;
import java.util.ArrayDeque;
@@ -1508,7 +1509,7 @@ public class HydrologyFootprintCompilerTest {
}
@Test
public void lowBanksKeepTheirNaturalHeightAndReportUncontainedWater() {
public void lowBanksFillWithinTheirPolicyAndReportRemainingUncontainedWater() {
HydraulicSegment channel = new HydraulicSegment(
529L,
528L,
@@ -1539,25 +1540,25 @@ public class HydrologyFootprintCompilerTest {
HydrologyFootprintCompiler lowBankCompiler = compiler(lowBankTerrain);
RiverFootprint lowBankFootprint = lowBankCompiler.compile(List.of(course));
HydrologyColumnSample lowBank = lowBankFootprint.sample(12, lowBankZ).orElseThrow();
HydrologyTerrainSampler deepBankTerrain = (int x, int z) -> HydrologyTerrainSample.openLand(
x == 12 && z == lowBankZ ? 68 : 82,
0D,
"parent"
);
SurfaceRiverPolicy oneBlockFill = new SurfaceRiverPolicy("restricted-bank",
null, null, null, null, null, null, 1);
HydrologyTerrainSampler deepBankTerrain = (int x, int z) -> x == 12 && z == lowBankZ
? HydrologyTerrainSample.openLand(68, 0D, "parent").withSurfacePolicy(oneBlockFill)
: HydrologyTerrainSample.openLand(82, 0D, "parent");
RiverFootprint deepBankFootprint = compiler(deepBankTerrain).compile(List.of(course));
HydrologyColumnSample deepBank = deepBankFootprint.sample(12, lowBankZ).orElseThrow();
assertEquals(0, unsupportedBankCells(highFootprint, 528L));
// A low bank remains below the water and is reported without raising terrain.
assertEquals(69, lowBank.naturalHeight());
assertEquals(69, lowBank.terrainHeight());
assertTrue(unsupportedBankCells(lowBankFootprint, 528L) > 0);
assertEquals(70, lowBank.terrainHeight());
assertEquals(0, unsupportedBankCells(lowBankFootprint, 528L));
for (HydrologyColumnSample sample : lowBankFootprint.columns().values()) {
assertTrue(sample.terrainHeight() <= sample.naturalHeight());
HydrologyColumnLayer layer = sample.primarySurfaceLayerOrNull();
int fillLimit = layer != null && layer.channel() ? 0 : 8;
assertTrue(sample.terrainHeight() - sample.naturalHeight() <= fillLimit);
}
// Two blocks short: the bank keeps its natural height and the edge is reported.
assertEquals(68, deepBank.naturalHeight());
assertEquals(68, deepBank.terrainHeight());
assertEquals(69, deepBank.terrainHeight());
assertTrue(unsupportedBankCells(deepBankFootprint, 528L) > 0);
}
@@ -394,6 +394,30 @@ public class HydrologyModelTest {
assertEquals(72, sample.terrainHeight());
}
@Test
public void overlappingRaisedBanksRetainOwnershipWithoutAccumulatingFill() {
for (boolean sameFeature : List.of(true, false)) {
for (boolean reverse : List.of(true, false)) {
FootprintMutableColumn column = new FootprintMutableColumn(0, 0,
HydrologyTerrainSample.openLand(77, 0D, "parent"), 63);
int[] beds = reverse ? new int[]{81, 80, 77, 75} : new int[]{75, 77, 80, 81};
for (int index = 0; index < beds.length; index++) {
HydrologyFeatureRef feature = feature(HydrologyFeatureType.SURFACE_POOL,
sameFeature ? 30L : 30L + index, 80);
column.add(surfaceLayer(feature, beds[index], beds[index],
false, false, true, beds[index] != 77, false));
}
HydrologyColumnSample merged = column.build();
assertEquals(77, merged.naturalHeight());
assertEquals(81, merged.terrainHeight());
assertEquals(4, merged.terrainHeight() - merged.naturalHeight());
assertTrue(merged.primarySurfaceLayerOrNull().terrainOwned());
assertEquals(sameFeature ? 1 : 4, merged.layers().size());
}
}
}
@Test
public void uncutShoreRetainsItsBiomeWithoutOverridingAnOwnedBank() {
HydrologyColumnLayer shore = surfaceLayer(feature(HydrologyFeatureType.SURFACE_POOL, 30L, 75),
@@ -17,7 +17,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class HydrologyPlanHashTest {
private static final String EXPECTED_PLAN_DIGEST = "9ec4c9cf7dbdad16976041a70853b909816655170cd694058bf359092c2d5a10";
private static final String EXPECTED_PLAN_DIGEST = "e54461c339854842f7fc7b2e85bd700b57942a2c691f5a249d8e498729378296";
private static final List<HydrologyTileKey> TILES = List.of(
new HydrologyTileKey(0, 0),
@@ -17,7 +17,6 @@ import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -35,7 +34,6 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.function.Consumer;
import static org.junit.Assert.assertEquals;
@@ -669,25 +667,18 @@ public class HydrologyPlannerTest {
}
@Test
public void surfaceRouteMemoPreservesCompleteOwnerOutputAcrossPublicationPasses() throws Exception {
public void sharedDrainagePreservesCompleteOwnerOutputAcrossPublicationPasses() {
for (boolean organic : new boolean[]{false, true}) {
HydrologyPlannerSettings settings = organic ? organicShapeSettings()
: standardSettings(4D, 2D, true, false, List.of());
HydrologyTerrainSampler terrain = organic ? organicShapeTerrain() : rollingCoast(112);
SurfaceRouteMemos uncached = new SurfaceRouteMemos(false);
SurfaceRouteMemos cached = new SurfaceRouteMemos(true);
HydrologyTile expected = planWithSurfaceRouteMemos(new HydrologyPlanner(642L, settings, terrain), uncached);
HydrologyTile actual = planWithSurfaceRouteMemos(new HydrologyPlanner(642L, settings, terrain), cached);
HydrologyPlanner planner = new HydrologyPlanner(642L, settings, terrain);
HydrologyTile expected = planner.plan(TILE);
planner.clearOwnerDrafts();
HydrologyTile actual = planner.plan(TILE);
assertEquals(expected, actual);
assertEquals(expected.courses(), actual.courses());
assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates());
assertTrue(cached.computations <= uncached.computations);
if (organic) {
assertTrue("hits=" + cached.hits, cached.hits > 0);
assertTrue("uncached=" + uncached.computations + " cached=" + cached.computations,
cached.computations < uncached.computations);
}
}
}
@@ -717,7 +708,10 @@ public class HydrologyPlannerTest {
for (HydraulicSegment segment : course.segments()) {
if (segment.drop() > 0) {
assertTrue(segment.type().isDrop());
assertFalse(segment.fallingFluid());
if (segment.fallingFluid()) {
assertEquals(HydrologyFeatureType.WATERFALL, segment.type());
assertTrue(tile.cavePlan(course.id()).orElseThrow().accepted());
}
}
}
}
@@ -1124,7 +1118,9 @@ public class HydrologyPlannerTest {
);
for (HydraulicSegment segment : segments(cliff, HydrologyFeatureType.WATERFALL)) {
assertTrue(segment.drop() > 0);
assertFalse(segment.fallingFluid());
assertTrue(segment.fallingFluid());
assertTrue(segment.receivingPool());
assertTrue(cliff.cavePlan(segment.courseId()).orElseThrow().accepted());
assertTrue(segment.centerline().size() >= 2);
assertTrue(segment.width() >= 1);
}
@@ -1139,7 +1135,8 @@ public class HydrologyPlannerTest {
HydraulicSegment receiver = course.segments().get(index + 1);
assertTrue(approach.type().isSurface());
assertTrue(receiver.type().isSurface());
assertEquals(approach.end(), waterfall.start());
assertEquals(approach.downstreamHeadY(), waterfall.upstreamHeadY());
assertTrue(approach.end().distanceSquared2D(waterfall.start()) <= 2L);
assertEquals(waterfall.end(), receiver.start());
sawBlendedWaterfall = true;
}
@@ -1206,6 +1203,13 @@ public class HydrologyPlannerTest {
}
}
assertTrue("courses=" + surfaceCourses(tile), aggregateTransition);
for (HydrologyColumnSample column : tile.footprint().columns().values()) {
for (HydrologyColumnLayer layer : column.layers()) {
if (layer.feature().type().isSurface() && layer.channel() && layer.terrainOwned()) {
assertTrue(column.naturalHeight() - layer.bedY() <= settings.surface().maximumIncision());
}
}
}
}
@Test
@@ -1879,7 +1883,10 @@ public class HydrologyPlannerTest {
}
assertTrue(bed);
assertEquals(drop.fallingFluid(), falling);
assertFalse(drop.fallingFluid());
if (drop.fallingFluid()) {
assertEquals(HydrologyFeatureType.WATERFALL, drop.type());
assertTrue(tile.cavePlan(drop.courseId()).orElseThrow().accepted());
}
if (!receiving) {
HydrologyColumnSample endColumn = tile.columnAt(drop.end().x(), drop.end().z()).orElseThrow();
receiving = endColumn.layers().stream().anyMatch((HydrologyColumnLayer layer) ->
@@ -2210,6 +2217,15 @@ public class HydrologyPlannerTest {
).orElseThrow();
assertEquals(course.id(), located.courseId());
assertEquals(sinkhole.id(), located.segmentId());
for (HydrologyColumnSample column : first.footprint().columns().values()) {
for (HydrologyColumnLayer layer : column.layers()) {
if (layer.feature().courseId() == course.id() && layer.feature().type().isSurface()
&& layer.channel()) {
assertTrue("Surface pond exceeded the channel incision limit at " + column.x() + "," + column.z(),
column.naturalHeight() - layer.bedY() <= settings.surface().maximumIncision());
}
}
}
}
@Test
@@ -3446,57 +3462,6 @@ public class HydrologyPlannerTest {
}
return false;
}
@SuppressWarnings("unchecked")
private HydrologyTile planWithSurfaceRouteMemos(HydrologyPlanner planner, SurfaceRouteMemos memos) throws Exception {
Class<?> samplesClass = Class.forName(HydrologyPlanner.class.getName() + "$PlanningSamples");
Constructor<?> constructor = samplesClass.getDeclaredConstructor();
constructor.setAccessible(true);
Object samples = constructor.newInstance();
Field routes = samplesClass.getDeclaredField("surfaceRoutes");
routes.setAccessible(true);
routes.set(samples, memos);
Field scope = HydrologyPlanner.class.getDeclaredField("planningSamples");
scope.setAccessible(true);
ThreadLocal<Object> local = (ThreadLocal<Object>) scope.get(planner);
local.set(samples);
try {
return planner.plan(TILE);
} finally {
local.remove();
}
}
private static final class SurfaceRouteMemos extends IdentityHashMap<Object, HashMap<Object, List<HydrologyPoint>>> {
private final boolean retain;
private int hits;
private int computations;
private SurfaceRouteMemos(boolean retain) {
this.retain = retain;
}
@Override
public HashMap<Object, List<HydrologyPoint>> computeIfAbsent(Object key,
Function<? super Object, ? extends HashMap<Object, List<HydrologyPoint>>> ignored) {
return super.computeIfAbsent(key, unused -> new HashMap<>() {
@Override
public List<HydrologyPoint> computeIfAbsent(Object route,
Function<? super Object, ? extends List<HydrologyPoint>> computation) {
if (!retain) {
clear();
}
if (containsKey(route)) {
hits++;
}
return super.computeIfAbsent(route, missing -> {
computations++;
return computation.apply(missing);
});
}
});
}
}
private HydrologyPlanner countedPlanner(CountingNaturalSampler sampler) {
return new HydrologyPlanner(91L, standardSettings(4D, 0D, true, false, List.of()),
sampler.delegate, sampler, HydrologyGeometrySampler.deterministic(sampler.delegate),
@@ -31,9 +31,7 @@ public class HydrologyRegionalBankSupportTest {
HydrologyTerrainSampler terrain = depression(false);
List<HydrologyPoint> path = path();
HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain);
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(path, "default", false, terrain);
assertEquals(path, refined);
assertNull(validate(planner, path, terrain).rejection());
SurfaceCourseResult built = build(path, terrain);
assertTrue(built.accepted());
@@ -44,7 +42,7 @@ public class HydrologyRegionalBankSupportTest {
assertEquals(0, footprint.uncontainedWetCells());
assertTrue(course.hydraulicallyNonRising());
for (SurfaceLayerColumn column : footprint.columns()) {
assertTrue(column.layer().bedY() <= column.terrain().naturalHeight());
assertTrue(column.layer().bedY() - column.terrain().naturalHeight() <= SETTINGS.surface().maximumIncision());
}
SurfaceLayerColumn center = footprint.columns().stream()
.filter(column -> column.x() == 132 && column.z() == 0).findFirst().orElseThrow();
@@ -58,67 +56,16 @@ public class HydrologyRegionalBankSupportTest {
HydrologyTerrainSampler terrain = depression(true);
HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain);
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
validate(planner, path(), terrain).rejection());
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(path(), "default", false, terrain);
if (!refined.isEmpty()) {
assertFalse(build(refined, terrain).accepted());
}
assertNull(validate(planner, path(), terrain).rejection());
SurfaceCourseResult built = build(path(), terrain);
assertFalse(built.accepted());
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, built.rejection());
assertEquals(17, built.rejectionDetail());
}
@Test
public void levelCentersNeedNoBankSamplesAndSupportedDipsCannotRaiseIncomingHead() {
AtomicInteger samples = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
assertTrue(Math.abs(x - 132) <= 5 && Math.abs(z) <= 5);
samples.incrementAndGet();
return depression(false).sample(x, z);
};
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(SETTINGS);
assertEquals(79, hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation(
132, 0, 1D, 0D, HydrologyTerrainSample.openLand(80, 0D, "land"), 79), terrain));
assertEquals(0, samples.get());
assertEquals(78, hydraulics.supportedHead(station(78), terrain));
assertTrue(samples.get() > 0 && samples.get() < 128);
}
@Test
public void unavailableCrossSectionOrPerimeterCannotCertifyAContainedDip() {
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(SETTINGS);
HydrologyTerrainSampler crossMissing = (x, z) -> x == 132 && z == 2 ? null : depression(false).sample(x, z);
HydrologyTerrainSampler perimeterMissing = (x, z) -> x == 133 ? null : depression(false).sample(x, z);
assertEquals(65, hydraulics.supportedHead(station(80), crossMissing));
assertEquals(65, hydraulics.supportedHead(station(80), perimeterMissing));
}
@Test
public void bankSupportCannotSamplePastTheExistingSearchBudget() throws Exception {
AtomicInteger requests = new AtomicInteger();
HydrologyTerrainSample ground = HydrologyTerrainSample.openLand(80, 0D, "land");
HydrologyTerrainSampler terrain = (x, z) -> {
requests.incrementAndGet();
return ground;
};
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain));
HydrologyRegionalTerrainRefiner.Samples sampled = refiner.new Samples();
for (int index = 0; index < 65536; index++) {
sampled.sample(index, 100000);
if (built.accepted()) {
RiverCourse course = new RiverCourse(91L, RiverCourseType.SURFACE, OptionalLong.of(1L), OptionalLong.of(2L),
"default", 1, List.of(), built.segments());
SurfaceFootprint footprint = new SurfaceFootprintCompiler(SETTINGS, terrain, GEOMETRY).compile(course);
assertFalse(footprint.accepted());
} else {
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, built.rejection());
}
requests.set(0);
assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(80), sampled));
assertEquals(0, requests.get());
}
private static HydrologyRegionalHydraulics.HeadStation station(int incomingHead) {
return new HydrologyRegionalHydraulics.HeadStation(132, 0, 1D, 0D,
HydrologyTerrainSample.openLand(65, 0D, "land"), incomingHead);
}
private static HydrologyTerrainSampler depression(boolean acrossBanks) {
@@ -14,34 +14,6 @@ import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalCacheTest {
@Test
public void searchBudgetDoesNotLimitSubsequentSearchesOrTerrainResults() {
AtomicInteger reads = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
reads.incrementAndGet();
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner);
HydrologyRegionalTerrainRefiner.Samples first = refiner.new Samples();
for (int x = 0; x < 65536; x++) {
assertNotNull(first.sample(x, 0));
}
assertNull(first.sample(65536, 0));
HydrologyRegionalTerrainRefiner.Samples second = refiner.new Samples();
for (int x = 65536; x < 70000; x++) {
assertNotNull(second.sample(x, 0));
}
assertEquals(70000, reads.get());
for (int x = 0; x < 70000; x++) {
assertEquals(70, refiner.sample(x, 0).naturalHeight());
}
int readsBeforeClear = reads.get();
refiner.clear();
assertEquals(70, refiner.sample(0, 0).naturalHeight());
assertEquals(readsBeforeClear + 1, reads.get());
}
@Test
public void clearingTilesRefreshesChangedRegionalTerrain() throws Exception {
AtomicInteger height = new AtomicInteger(70);
@@ -51,7 +23,7 @@ public class HydrologyRegionalCacheTest {
return HydrologyTerrainSample.openLand(height.get(), 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = refiner(planner);
HydrologyRegionalTerrain refiner = refiner(planner);
try (HydrologyTileCache cache = new HydrologyTileCache(planner)) {
assertEquals(70, refiner.sample(0, 0).naturalHeight());
height.set(85);
@@ -71,7 +43,7 @@ public class HydrologyRegionalCacheTest {
return available.get() ? HydrologyTerrainSample.openLand(70, 0D, "land") : null;
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = refiner(planner);
HydrologyRegionalTerrain refiner = refiner(planner);
try (HydrologyTileCache cache = new HydrologyTileCache(planner)) {
assertNull(refiner.sample(0, 0));
available.set(true);
@@ -82,32 +54,11 @@ public class HydrologyRegionalCacheTest {
}
}
@Test
public void clearingTilesReplansRegionalReachesAgainstCurrentTerrain() throws Exception {
AtomicBoolean flooded = new AtomicBoolean();
HydrologyTerrainSampler terrain = (x, z) -> flooded.get() && x >= 96 && x <= 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = refiner(planner);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
try (HydrologyTileCache cache = new HydrologyTileCache(planner)) {
assertEquals(guide, refiner.refine(guide, "default", false, terrain));
flooded.set(true);
cache.clear();
List<HydrologyPoint> actual = refiner.refine(guide, "default", false, terrain);
List<HydrologyPoint> fresh = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain);
assertFalse(fresh.isEmpty());
assertTrue(fresh.stream().anyMatch(point -> Math.abs(point.z()) >= 32));
assertEquals(fresh, actual);
}
}
private static HydrologyRegionalTerrainRefiner refiner(HydrologyPlanner planner) throws Exception {
private static HydrologyRegionalTerrain refiner(HydrologyPlanner planner) throws Exception {
Field routes = HydrologyRegionalPlanner.class.getDeclaredField("routes");
routes.setAccessible(true);
Field refiner = HydrologyRegionalRoute.class.getDeclaredField("terrainRefiner");
Field refiner = HydrologyRegionalRoute.class.getDeclaredField("terrain");
refiner.setAccessible(true);
return (HydrologyRegionalTerrainRefiner) refiner.get(routes.get(planner.regional));
return (HydrologyRegionalTerrain) refiner.get(routes.get(planner.regional));
}
}
@@ -19,7 +19,7 @@ import static org.junit.Assert.assertTrue;
public class HydrologyRegionalCandidateSelectionTest {
@Test
public void lowerMeanderCandidatePassesTheFullPlannerAfterTheFirstBankCutFails() throws Exception {
public void plannedHeadCrossesAChannelDepressionWithoutLoweringTheWholeCourse() throws Exception {
HydrologyTerrainSampler terrain = coast(true);
HydrologyPlanner planner = planner(terrain, 16, 2);
List<HydrologyPoint> guide = guide(terrain);
@@ -33,9 +33,8 @@ public class HydrologyRegionalCandidateSelectionTest {
});
assertTrue(selected.toString(), selected.accepted());
assertEquals(2, attempted.size());
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, attempted.getFirst().rejection().rejection());
assertEquals(18, attempted.getFirst().rejection().detail());
assertEquals(1, attempted.size());
assertNull(attempted.getFirst().rejection());
assertEquals(1, selected.network().courses().size());
assertEquals(selected, attempted.getLast());
RiverCourse course = selected.network().courses().getFirst();
@@ -124,8 +123,10 @@ public class HydrologyRegionalCandidateSelectionTest {
}
int[] contributions = new int[9];
Arrays.fill(contributions, 1);
int[] heads = new int[9];
Arrays.fill(heads, 80);
HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, contributions,
List.of(new OutletCandidate(8, 8, outlet())));
List.of(new OutletCandidate(8, 8, outlet())), heads);
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
Method build = HydrologyRegionalPlanner.class.getDeclaredMethod("build", HydrologySampledGrid.class,
HydrologyRegionalGraph.Tree.class, int.class, OutletCandidate.class, long.class, List.class);
@@ -51,7 +51,7 @@ public final class HydrologyRegionalFallsTest {
HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(),
SETTINGS.routing().tileSize(), network.nodes(), network.edges(), network.outlets(),
List.of(regional, local), Set.of(regional.id()), clipped.cavePlans(), List.of(), materialized.footprint());
StudioHydrologyTileStore store = new StudioHydrologyTileStore(temporaryFolder.newFolder().toPath(),
PreparedHydrologyTileStore store = new PreparedHydrologyTileStore(temporaryFolder.newFolder().toPath(),
new HydrologyTileCache.SharedCacheScope("regional-ownership", 1L, 128, "overworld", SETTINGS.fingerprint()),
SETTINGS.routing().tileSize());
store.save(original);
@@ -28,8 +28,8 @@ public class HydrologyRegionalMouthRefinementTest {
HydrologyTerrainSampler receiver = HydrologyOceanReceiver.forOutlet(settings, terrain, outlet);
HydrologyPlanner planner = new HydrologyPlanner(1L, settings, terrain);
HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner);
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 65, 0), new HydrologyPoint(767, 65, 0),
new HydrologyPoint(768, 80, 0), landward);
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 63, 0), new HydrologyPoint(767, 63, 0),
new HydrologyPoint(768, 63, 0), landward);
Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validate", List.class, String.class,
boolean.class, HydrologyTerrainSampler.class);
validate.setAccessible(true);
@@ -40,7 +40,7 @@ public class HydrologyRegionalMouthRefinementTest {
assertNull(accepted.toString(), accepted.rejection());
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, unproved.rejection());
assertTrue(new HydrologyRegionalTerrainRefiner(planner).receivingTerminal(landward, "default", false, receiver));
assertTrue(new HydrologyRegionalTerrain(planner).receivingTerminal(landward, "default", false, receiver));
assertFalse(terrain.sample(800, 0).ocean());
assertTrue(receiver.receivingWater(800, 0, 63));
}
@@ -171,8 +171,8 @@ public class HydrologyRegionalMouthRefinementTest {
HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1);
HydrologyTerrainSampler terrain = (x, z) -> x >= 800 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(x >= 768 ? 80 : 65, 0D, "land");
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 65, 0), new HydrologyPoint(767, 65, 0),
new HydrologyPoint(768, 80, 0), new HydrologyPoint(799, 80, 0), new HydrologyPoint(800, 63, 0));
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 63, 0), new HydrologyPoint(767, 63, 0),
new HydrologyPoint(768, 63, 0), new HydrologyPoint(799, 63, 0), new HydrologyPoint(800, 63, 0));
HydrologyGeometrySampler geometry = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 2;
@@ -38,7 +38,7 @@ public class HydrologyRegionalSamplingTest {
RecordingSampler serial = new RecordingSampler(terrain);
SurfaceBankSupport.CrossSection cross = support.crossSection(serial, station, true);
SurfaceBankSupport.Perimeter perimeter = support.perimeter(serial, station, 80);
RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples());
RecordingSampler parallel = new RecordingSampler(refiner(terrain));
pool.submit(() -> {
assertEquals(cross, support.crossSection(parallel, station, true));
assertEquals(perimeter, support.perimeter(parallel, station, 80));
@@ -49,48 +49,6 @@ public class HydrologyRegionalSamplingTest {
}
}
@Test
public void searchLimitCountsNullsAndRetainsAlreadyAdmittedCoordinates() {
AtomicInteger reads = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
reads.incrementAndGet();
return x == 0 ? null : HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = refiner(terrain);
refiner.sample(100000, 0);
HydrologyRegionalTerrainRefiner.Samples samples = refiner.new Samples();
assertNull(samples.sample(0, 0));
for (int x = 1; x < 65536; x++) {
assertEquals(70, samples.sample(x, 0).naturalHeight());
}
assertNull(samples.sample(0, 0));
assertEquals(70, samples.sample(1, 0).naturalHeight());
assertNull(samples.sample(65536, 0));
assertNull(samples.sample(100000, 0));
assertEquals(65537, reads.get());
}
@Test
public void failedRequiredSampleRetainsItsExceptionAndDoesNotConsumeAdmission() {
AtomicInteger failedReads = new AtomicInteger();
IllegalStateException failure = new IllegalStateException("Required terrain unavailable");
HydrologyTerrainSampler terrain = (x, z) -> {
if (x == -1 && failedReads.incrementAndGet() == 1) {
throw failure;
}
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples();
for (int x = 0; x < 65535; x++) {
samples.sample(x, 0);
}
assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(-1, 0)));
assertEquals(70, samples.sample(-1, 0).naturalHeight());
assertEquals(70, samples.sample(0, 0).naturalHeight());
assertNull(samples.sample(-2, 0));
assertEquals(2, failedReads.get());
}
@Test
public void unavailableBankStopsBeforeALaterProviderFailure() throws Exception {
IllegalStateException failure = new IllegalStateException("Later bank sample");
@@ -103,7 +61,7 @@ public class HydrologyRegionalSamplingTest {
SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel());
SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(0, 0, 1D, 0D, 4D);
SurfaceBankSupport.CrossSection expected = support.crossSection(terrain, station, true);
HydrologyRegionalTerrainRefiner.Samples samples = refiner(terrain).new Samples();
HydrologyRegionalTerrain samples = refiner(terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertEquals(expected, pool.submit(() -> support.crossSection(samples, station, true)).get(10, TimeUnit.SECONDS));
}
@@ -111,137 +69,8 @@ public class HydrologyRegionalSamplingTest {
assertSame(failure, assertThrows(IllegalStateException.class, () -> samples.sample(0, 0)));
}
@Test
public void hydraulicBudgetAndLazyWidthsPreserveConsumptionOrder() throws Exception {
HydrologyPlannerSettings.Surface surface = SETTINGS.surface();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(SETTINGS.seaLevel(), SETTINGS.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), 256,
surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()),
SETTINGS.hydraulics(), SETTINGS.underground(), SETTINGS.outlets(), SETTINGS.geometry(), SETTINGS.deepFluids(),
SETTINGS.surfacePools(), SETTINGS.widestShoreBiomeWidth(), SETTINGS.seaCaves(), SETTINGS.surfacePolicyBounds());
HydrologyTerrainSample ground = new HydrologyTerrainSample(65, 0D, false, false, 0, 0,
true, true, true, false, true, false, 0D, 1D, 1D, 0.75D, 1D, 1D, 1D, 1D,
"land", "land", "land", "land", "land", "land", List.of("default"), List.of(),
Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT);
HydrologyTerrainSampler terrain = (x, z) -> x == 133 ? null : x == 132 && z == 0 ? ground
: HydrologyTerrainSample.openLand(80, 0D, "land");
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(settings);
HydrologyRegionalHydraulics.HeadStation station = new HydrologyRegionalHydraulics.HeadStation(
132, 0, 1D, 0D, terrain.sample(132, 0), 80);
RecordingSampler serial = new RecordingSampler(terrain);
int expected = hydraulics.supportedHead(station, serial);
RecordingSampler parallel = new RecordingSampler(refiner(terrain).new Samples());
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertEquals(expected, pool.submit(() -> hydraulics.supportedHead(station, parallel)).get(10, TimeUnit.SECONDS).intValue());
}
assertEquals(serial.requests, parallel.requests);
assertTrue(serial.requests.size() > 2048);
}
@Test
public void routeRefinementMatchesSerialTerrainAndHydraulics() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 95 : 70, 0D, "land");
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> expected = refiner(terrain).refine(guide, "default", false, terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) {
List<HydrologyPoint> actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain))
.get(15, TimeUnit.SECONDS);
assertFalse(expected.isEmpty());
assertEquals(expected, actual);
}
}
@Test
public void coastalCenterlineRetainsNegativeAndFractionalTraversalOrder() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(64, 0D, "land");
List<HydrologyPoint> guide = List.of(new HydrologyPoint(-173, 64, -117), new HydrologyPoint(174, 64, 138));
List<HydrologyPoint> expected = refiner(terrain).refine(guide, "default", true, terrain);
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertFalse(expected.isEmpty());
assertEquals(expected, pool.submit(() -> refiner(terrain).refine(guide, "default", true, terrain))
.get(15, TimeUnit.SECONDS));
}
}
@Test
public void rejectedTraversalDoesNotReadALaterProviderFailure() throws Exception {
IllegalStateException failure = new IllegalStateException("Later centerline sample");
AtomicInteger failures = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
if (x == 32 && z == 0) {
failures.incrementAndGet();
throw failure;
}
return z == 0 && (x == 0 || x == 128)
? HydrologyTerrainSample.openLand(70, 0D, "land") : null;
};
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0));
assertTrue(refiner(terrain).refine(guide, "default", false, terrain).isEmpty());
assertEquals(0, failures.get());
try (ForkJoinPool pool = new ForkJoinPool(4)) {
assertTrue(pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain))
.get(15, TimeUnit.SECONDS).isEmpty());
assertEquals(0, failures.get());
}
}
@Test
public void clearingAnActiveReachCannotPublishIntoTheNewCache() throws Exception {
AtomicBoolean changed = new AtomicBoolean();
AtomicBoolean blocked = new AtomicBoolean();
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
HydrologyTerrainSampler terrain = (x, z) -> {
boolean elevated = changed.get();
if (x == 64 && z == 0 && blocked.compareAndSet(false, true)) {
started.countDown();
await(release);
}
return HydrologyTerrainSample.openLand(elevated && x == 64 && z == 0 ? 100 : 70, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = refiner(terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
Future<List<HydrologyPoint>> previous = executor.submit(() -> refiner.refine(guide, "default", false, terrain));
try {
assertTrue(started.await(5, TimeUnit.SECONDS));
changed.set(true);
refiner.clear();
assertEquals(100, refiner.sample(64, 0).naturalHeight());
} finally {
release.countDown();
}
previous.get(15, TimeUnit.SECONDS);
assertEquals(refiner(terrain).refine(guide, "default", false, terrain),
refiner.refine(guide, "default", false, terrain));
assertEquals(100, refiner.sample(64, 0).naturalHeight());
}
}
@Test
public void signedCoordinateTiesRemainStableInEveryDirection() throws Exception {
try (ForkJoinPool pool = new ForkJoinPool(8)) {
for (int[] direction : new int[][]{{1, 0}, {0, 1}, {-1, 0}, {0, -1}}) {
HydrologyTerrainSampler terrain = (x, z) -> {
int along = x * direction[0] + z * direction[1];
int across = -x * direction[1] + z * direction[0];
return HydrologyTerrainSample.openLand(
along >= 96 && along <= 160 && Math.abs(across) < 32 ? 95 : 70, 0D, "land");
};
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0),
new HydrologyPoint(256 * direction[0], 70, 256 * direction[1]));
List<HydrologyPoint> expected = refiner(terrain).refine(guide, "default", false, terrain);
List<HydrologyPoint> actual = pool.submit(() -> refiner(terrain).refine(guide, "default", false, terrain))
.get(15, TimeUnit.SECONDS);
assertFalse(expected.isEmpty());
assertEquals(expected, actual);
}
}
}
private static HydrologyRegionalTerrainRefiner refiner(HydrologyTerrainSampler terrain) {
return new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain));
private static HydrologyRegionalTerrain refiner(HydrologyTerrainSampler terrain) {
return new HydrologyRegionalTerrain(new HydrologyPlanner(71L, SETTINGS, terrain));
}
private static void await(CountDownLatch latch) {
@@ -1,169 +0,0 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalSkippedWaypointTest {
@Test
public void aSkippableHillDoesNotBlockAnEarlierValleyAcceptedByTheFullPlanner() throws Exception {
Set<Integer> hills = Set.of(4);
HydrologyTerrainSampler terrain = terrain(hills, true);
HydrologyPlanner planner = planner(terrain);
List<HydrologyPoint> guide = guide(terrain, hills);
assertCoarseFeasible(planner, terrain, guide);
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain);
assertFalse(refined.isEmpty());
assertFalse(refined.contains(guide.get(4)));
Object context = context(guide, terrain);
HydrologyRegionalRoute.Attempt selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain,
candidate -> attempt(planner, context, candidate));
assertTrue(selected.toString(), selected.accepted());
assertEquals(1, selected.network().courses().size());
assertTrue(selected.network().courses().getFirst().hydraulicallyNonRising());
assertEquals(16, planner.settings.surface().maximumIncision());
}
@Test
public void theTerminalHeadRemainsMandatory() {
HydrologyTerrainSampler highTerminal = terrain(Set.of(8), false);
HydrologyPlanner planner = planner(highTerminal);
List<HydrologyPoint> guide = guide(highTerminal, Set.of(8));
assertCoarseFeasible(planner, highTerminal, guide);
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, highTerminal).isEmpty());
HydrologyTerrainSampler lowTerminal = terrain(Set.of(), false);
assertFalse(new HydrologyRegionalTerrainRefiner(planner(lowTerminal))
.refine(guide(lowTerminal, Set.of()), "default", false, lowTerminal).isEmpty());
}
@Test
public void threeConsecutiveHighWaypointsCannotBeSkipped() {
Set<Integer> hills = Set.of(4, 5, 6);
HydrologyTerrainSampler terrain = terrain(hills, true);
HydrologyPlanner planner = planner(terrain);
List<HydrologyPoint> guide = guide(terrain, hills);
assertCoarseFeasible(planner, terrain, guide);
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain).isEmpty());
Set<Integer> twoHills = Set.of(4, 5);
HydrologyTerrainSampler skippable = terrain(twoHills, true);
assertFalse(new HydrologyRegionalTerrainRefiner(planner(skippable))
.refine(guide(skippable, twoHills), "default", false, skippable).isEmpty());
}
@Test
public void aSampledRidgeCannotRaiseTheHeadAfterAnEarlierValley() {
HydrologyTerrainSampler base = terrain(Set.of(), true);
HydrologyTerrainSampler ridge = (x, z) -> x >= 200 && x <= 224
? HydrologyTerrainSample.openLand(95, 0D, "land") : base.sample(x, z);
HydrologyPlanner planner = planner(ridge);
List<HydrologyPoint> guide = guide(ridge, Set.of());
assertCoarseFeasible(planner, ridge, guide);
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, ridge).isEmpty());
AtomicInteger admitted = new AtomicInteger();
HydrologyRegionalRoute.Attempt selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, ridge,
candidate -> {
admitted.incrementAndGet();
return new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null);
});
assertFalse(selected.accepted());
assertEquals(0, admitted.get());
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, selected.refinement().rejection());
}
private static void assertCoarseFeasible(HydrologyPlanner planner, HydrologyTerrainSampler terrain, List<HydrologyPoint> guide) {
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings);
int available = Integer.MAX_VALUE;
for (HydrologyPoint point : guide) {
HydrologyTerrainSample sample = terrain.sample(point.x(), point.z());
available = Math.min(available, hydraulics.maximumHead(sample));
assertTrue(point.toString(), available >= hydraulics.minimumHead(sample));
}
}
private static HydrologyRegionalRoute.Attempt attempt(HydrologyPlanner planner, Object context,
HydrologyRegionalRoute.Refinement candidate) {
try {
Method attempt = HydrologyRegionalPlanner.class.getDeclaredMethod("attempt", context.getClass(), HydrologyRegionalRoute.Refinement.class);
attempt.setAccessible(true);
return (HydrologyRegionalRoute.Attempt) attempt.invoke(planner.regional, context, candidate);
} catch (ReflectiveOperationException failure) {
throw new AssertionError(failure);
}
}
private static Object context(List<HydrologyPoint> guide, HydrologyTerrainSampler terrain) throws Exception {
Class<?> type = Class.forName(HydrologyRegionalPlanner.class.getName() + "$BuildContext");
Constructor<?> constructor = type.getDeclaredConstructors()[0];
constructor.setAccessible(true);
int[] contributions = new int[guide.size()];
Arrays.fill(contributions, 1);
RiverOutlet outlet = new RiverOutlet(2L, HydrologyFeatureType.MOUTH, 8L, new HydrologyPoint(1024, 63, 0),
new HydrologyPoint(1025, 63, 0), 63, true);
return constructor.newInstance(new HydrologyGridNode(0, 0, 0, 0, 0, 100L, terrain.sample(0, 0)),
null, 123L, outlet, "default", new HydrologyRegionalFlow(guide, contributions),
HydrologyRegionalMorphology.sample(guide, terrain, HydrologyRegionalPlannerTest.settings(false, 1)));
}
private static List<HydrologyPoint> guide(HydrologyTerrainSampler terrain, Set<Integer> hills) {
ArrayList<HydrologyPoint> guide = new ArrayList<>();
for (int index = 0; index < 9; index++) {
int x = index * 128;
int z = index != 8 && hills.contains(index) ? 128 : 0;
guide.add(new HydrologyPoint(x, terrain.sample(x, z).naturalHeight(), z));
}
return List.copyOf(guide);
}
private static HydrologyTerrainSampler terrain(Set<Integer> hills, boolean coast) {
return (x, z) -> {
if (coast && x >= 1025) {
return HydrologyTerrainSample.ocean(60, "ocean");
}
int height = coast ? 80 - Math.max(0, x - 752) / 16 : 80;
if (x >= 32 && x <= 96) {
height = 75;
}
for (int hill : hills) {
if (Math.abs(x - hill * 128) <= 24 && Math.abs(z - (hill == 8 ? 0 : 128)) <= 24) {
height = 93;
}
}
return HydrologyTerrainSample.openLand(height, 0D, "land");
};
}
private static HydrologyPlanner planner(HydrologyTerrainSampler terrain) {
HydrologyPlannerSettings base = HydrologyRegionalPlannerTest.settings(false, 1);
HydrologyPlannerSettings.Surface surface = base.surface();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), surface.maximumWidth(),
surface.minimumDepth(), surface.maximumDepth(), 16, surface.shoreWidth(), surface.banks()),
base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(),
base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
HydrologyGeometrySampler geometry = request -> switch (request.field()) {
case SURFACE_WIDTH -> 8;
case SURFACE_DEPTH -> 2;
default -> request.minimum();
};
return new HydrologyPlanner(71L, settings, terrain, geometry, -4096,
footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, -4096, 4096));
}
}
@@ -209,7 +209,7 @@ public class HydrologyRegionalSourceParallelismTest {
sources.put(RiverFootprint.pack(node.x(), node.z()), ordinal++);
}
assertEquals(secondCatchmentSize + 4, ordinal);
return new HydrologyRegionalGraph.Tree(labels, contributions, outlets);
return new HydrologyRegionalGraph.Tree(labels, contributions, outlets, new int[contributions.length]);
});
when(graph.path(any(), anyInt())).thenAnswer(invocation -> List.of(invocation.<Integer>getArgument(1), 0));
replace(planner.regional, "graph", graph);
@@ -92,7 +92,7 @@ public class HydrologyRegionalSourceTrialsTest {
new RiverOutlet(100L + root, HydrologyFeatureType.MOUTH, root, point, point, 63, true)));
}
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 16384, sources.size(), 1, nodes);
HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, new int[sources.size()], outlets);
HydrologyRegionalGraph.Tree tree = new HydrologyRegionalGraph.Tree(selected, new int[sources.size()], outlets, new int[sources.size()]);
return new Fixture(grid, tree, indices);
}
@@ -249,7 +249,7 @@ public class HydrologyRegionalSurfacePolicyTest {
candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L,
HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true)));
}
return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false);
return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false, null);
}
private static HydrologyPlannerSettings withSources(HydrologyPlannerSettings.Source sources) {
@@ -1,541 +0,0 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline;
import org.junit.Test;
import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.lang.reflect.Method;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalTerrainRefinerTest {
@Test(timeout = 10000)
public void clearingDuringSamplingCannotPublishOldTerrain() throws Exception {
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
int call = calls.incrementAndGet();
if (call == 1) {
started.countDown();
try {
assertTrue(release.await(5, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
}
return HydrologyTerrainSample.openLand(call == 1 ? 70 : 80, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newSingleThreadExecutor()) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
try {
assertTrue(started.await(3, TimeUnit.SECONDS));
refiner.clear();
assertEquals(80, refiner.sample(0, 0).naturalHeight());
} finally {
release.countDown();
}
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(80, refiner.sample(0, 0).naturalHeight());
assertEquals(2, calls.get());
}
}
@Test(timeout = 10000)
public void independentDiagonalSamplesDoNotWaitForEachOthersTerrain() throws Exception {
CountDownLatch sampling = new CountDownLatch(2);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
sampling.countDown();
try {
assertTrue("Independent terrain samples were serialized", sampling.await(3, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
return HydrologyTerrainSample.openLand(70 + x, 0D, "land");
};
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newFixedThreadPool(2)) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
Future<HydrologyTerrainSample> second = workers.submit(() -> refiner.sample(1, 1));
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(71, second.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(first.get(), refiner.sample(0, 0));
assertEquals(second.get(), refiner.sample(1, 1));
assertEquals(2, calls.get());
}
}
@Test
public void aHigherHeadDetourSurvivesACheaperInfeasibleArrival() {
int[][] heights = {
{86, 94, 77, 91, 75, 85, 82, 88, 74},
{84, 94, 83, 85, 72, 76, 73, 80, 72},
{73, 85, 78, 77, 73, 82, 84, 81, 79},
{83, 77, 92, 81, 84, 80, 93, 94, 96},
{90, 73, 76, 84, 74, 73, 91, 80, 85},
{91, 96, 90, 75, 91, 84, 94, 96, 95},
{84, 76, 80, 92, 72, 73, 96, 75, 86},
{94, 93, 86, 81, 80, 89, 75, 93, 77},
{85, 95, 85, 73, 88, 82, 81, 94, 92}
};
HydrologyTerrainSampler terrain = (x, z) -> {
int gridX = Math.floorDiv(x, 16);
int gridZ = Math.floorDiv(z, 16) + 4;
return gridX < 0 || gridX >= 9 || gridZ < 0 || gridZ >= 9
? HydrologyTerrainSample.ocean(60, "outside")
: HydrologyTerrainSample.openLand(heights[gridZ][gridX], 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(1L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 90, 0), new HydrologyPoint(128, 85, 0));
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner);
List<HydrologyPoint> path = refiner.refine(guide, "default", false, terrain);
assertFalse(path.isEmpty());
assertEquals(guide.getFirst(), path.getFirst());
assertEquals(guide.getLast(), path.getLast());
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings);
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
int available = 90;
for (int station = 0; station < centerline.size(); station++) {
HydrologyTerrainSample sample = terrain.sample(centerline.x()[station], centerline.z()[station]);
assertFalse(sample.ocean());
int supported = hydraulics.supportedHead(new HydrologyRegionalHydraulics.HeadStation(
centerline.x()[station], centerline.z()[station], centerline.tangentX()[station],
centerline.tangentZ()[station], sample, available), terrain);
assertTrue(supported <= available);
available = supported;
assertTrue("station=" + station, available >= hydraulics.minimumHead(sample));
}
assertEquals(path, refiner.refine(guide, "default", false, terrain));
assertEquals(path, new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain));
}
@Test
public void shortRepairedPrefixDoesNotHideALongEnoughGuide() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 406 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(x >= 400 ? 60 : 70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(0, 70, 96),
new HydrologyPoint(128, 70, 96), new HydrologyPoint(256, 70, 96), new HydrologyPoint(256, 70, 0),
new HydrologyPoint(512, 70, 0));
HydrologyRegionalRoute.Refinement selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain,
candidate -> {
double length = 0D;
for (int index = 1; index < candidate.points().size(); index++) {
length += StrictMath.sqrt(candidate.points().get(index - 1).distanceSquared2D(candidate.points().get(index)));
}
assertTrue(length >= planner.settings.routing().regional().minimumLength());
return new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null);
}).refinement();
assertNull(selected.toString(), selected.rejection());
assertEquals(new HydrologyPoint(399, 63, 0), selected.oceanEntry().landward());
assertEquals(new HydrologyPoint(406, 63, 0), selected.oceanEntry().receiving());
}
@Test
public void onlyTheRetainedDryPrefixNeedsTerrainRepair() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 806 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(x >= 800 ? 60 : x >= 96 && x <= 160 && Math.abs(z) < 32 ? 86 : 70,
0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0),
new HydrologyPoint(512, 70, 0), new HydrologyPoint(1024, 70, 0));
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain).isEmpty());
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertEquals(new HydrologyPoint(799, 63, 0), repaired.oceanEntry().landward());
assertEquals(new HydrologyPoint(806, 63, 0), repaired.oceanEntry().receiving());
assertTrue(repaired.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32));
assertTrue(repaired.points().stream().allMatch(point -> point.x() < 800));
}
@Test
public void aCoarseOceanHitBeforeMinimumLengthStillAllowsADryTerrainDetour() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 800 || x >= 96 && x <= 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0),
new HydrologyPoint(512, 70, 0), new HydrologyPoint(799, 70, 0));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertTrue(repaired.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32));
assertEquals(guide.getLast(), repaired.points().getLast());
}
@Test
public void naturallyFloodedLandCannotBeUsedAsAnOwnedDryReach() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 60 : 70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, terrain);
assertFalse(repaired.isEmpty());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
for (int station = 0; station < centerline.size(); station++) {
HydrologyTerrainSample sampled = terrain.sample(centerline.x()[station], centerline.z()[station]);
assertFalse(sampled.ocean());
assertTrue(sampled.naturalHeight() >= planner.settings.seaLevel());
}
}
@Test
public void repeatedCoarseAnchorsCannotCreateRetracedWaterways() {
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(128, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler);
assertEquals(List.of(guide.getFirst(), guide.getLast()), refined);
}
@Test
public void redundantCoarseAnchorHairpinsUseTheClearTerrainShortcut() {
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(160, 70, 32), new HydrologyPoint(144, 70, 48), new HydrologyPoint(160, 70, 48),
new HydrologyPoint(192, 70, 16), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler);
assertEquals(guide.getFirst(), refined.getFirst());
assertEquals(guide.getLast(), refined.getLast());
assertTrue(refined.size() <= 3);
for (int index = 1; index < refined.size(); index++) {
assertTrue(refined.get(index).x() > refined.get(index - 1).x());
}
}
@Test
public void repairsUnsampledOceanPocketsAtPositiveAndNegativeCoordinates() {
for (int origin : new int[]{0, -512}) {
HydrologyTerrainSampler terrain = (x, z) -> x >= origin + 96 && x <= origin + 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
List<HydrologyPoint> guide = List.of(new HydrologyPoint(origin, 70, 0), new HydrologyPoint(origin + 256, 70, 0));
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner);
List<HydrologyPoint> first = refiner.refine(guide, "default", false, planner.sampler);
assertFalse(first.isEmpty());
assertEquals(guide.getFirst(), first.getFirst());
assertEquals(guide.getLast(), first.getLast());
SurfaceCenterline centerline = SurfaceCenterline.densify(first);
for (int station = 0; station < centerline.size(); station++) {
assertFalse(terrain.sample(centerline.x()[station], centerline.z()[station]).ocean());
}
assertEquals(first, refiner.refine(guide, "default", false, planner.sampler));
assertEquals(first, new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler));
}
}
@Test
public void aWaterCourseDetoursAroundALavaOnlyBiome() {
HydrologyTerrainSampler terrain = (x, z) -> land(x >= 96 && x <= 160 && Math.abs(z) < 32 ? "lava" : "water");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "water", false, planner.sampler);
assertFalse(refined.isEmpty());
SurfaceCenterline centerline = SurfaceCenterline.densify(refined);
for (int station = 0; station < centerline.size(); station++) {
assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys());
}
}
@Test
public void repairedTerrainRouteStillPassesOrganicShapeValidation() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 96 && x <= 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
HydrologyRegionalRoute.Refinement refined = new HydrologyRegionalRoute(planner).select(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(refined.toString(), refined.rejection());
assertTrue(refined.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32));
}
@Test
public void anUnavoidableOceanBarrierRejectsWithinTheTerrainSampleBudget() {
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
return x >= 96 && x <= 160 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> refined = new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler);
assertTrue(refined.isEmpty());
assertTrue("terrain samples=" + calls.get(), calls.get() <= 65536);
}
@Test
public void unavailableTerrainIsSampledOnceAndCountsTowardTheBudget() {
Map<Long, Integer> unavailable = new HashMap<>();
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
if (x >= 96 && x <= 160 && Math.abs(z) < 32) {
unavailable.merge(RiverFootprint.pack(x, z), 1, Integer::sum);
return null;
}
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
assertFalse(unavailable.isEmpty());
assertTrue(unavailable.values().stream().allMatch(count -> count == 1));
assertTrue(calls.get() <= 65536);
}
@Test
public void anUnsampledRidgeUsesTheLowerValleyWithoutIncreasingIncision() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 94 : 70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0));
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
for (int station = 0; station < centerline.size(); station++) {
assertEquals(70, terrain.sample(centerline.x()[station], centerline.z()[station]).naturalHeight());
}
}
@Test
public void anImpossibleIntermediateAnchorCanBeSkippedWithinTheSameValley() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x < 48 || x >= 120 && x <= 136 && Math.abs(z) < 8 ? 90 : 65, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 90, 0), new HydrologyPoint(128, 90, 0),
new HydrologyPoint(256, 65, 0));
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(planner);
List<HydrologyPoint> repaired = refiner.refine(guide, "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
assertEquals(guide.getFirst(), repaired.getFirst());
assertEquals(guide.getLast(), repaired.getLast());
assertFalse(repaired.contains(guide.get(1)));
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings);
int head = Integer.MAX_VALUE;
for (int station = 0; station < centerline.size(); station++) {
HydrologyTerrainSample sample = terrain.sample(centerline.x()[station], centerline.z()[station]);
head = Math.min(head, hydraulics.maximumHead(sample));
assertTrue(hydraulics.minimumHead(sample) <= head);
}
assertEquals(repaired, refiner.refine(guide, "default", false, planner.sampler));
}
@Test
public void aLowFineDetourCannotResetTheHeadBeforeTheNextRidge() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 65 : x >= 384 ? 92 : 80, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(256, 80, 0),
new HydrologyPoint(512, 92, 0));
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
assertTrue(repaired.stream().anyMatch(point -> Math.abs(point.z()) >= 32));
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(planner.settings);
int head = Integer.MAX_VALUE;
for (int station = 0; station < centerline.size(); station++) {
HydrologyTerrainSample sample = terrain.sample(centerline.x()[station], centerline.z()[station]);
head = Math.min(head, hydraulics.maximumHead(sample));
assertTrue(hydraulics.minimumHead(sample) <= head);
}
}
@Test
public void ridgeRepairRespectsTheLocalIncisionPolicy() {
SurfaceRiverPolicy limited = new SurfaceRiverPolicy("valley", null, null, null, null, null, null, 4);
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 96 && x <= 160 && Math.abs(z) < 32 ? 76 : 70, 0D, "land").withSurfacePolicy(limited);
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler);
assertFalse(repaired.isEmpty());
assertTrue(repaired.stream().anyMatch(point -> Math.abs(point.z()) >= 32));
}
@Test
public void detourLengthRejectsBeforeDetailedSamplingOfTheRemainingCourse() {
AtomicInteger maximumX = new AtomicInteger(Integer.MIN_VALUE);
HydrologyTerrainSampler terrain = (x, z) -> {
boolean coarseAnchor = Math.floorMod(x + 1, 256) <= 2 && Math.abs(z) <= 1;
if (!coarseAnchor) {
maximumX.accumulateAndGet(x, Math::max);
}
return x >= 96 && x <= 160 && Math.abs(z) < 32 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
ArrayList<HydrologyPoint> guide = new ArrayList<>();
for (int x = 0; x <= planner.settings.routing().maximumRouteLength(); x += 256) {
guide.add(new HydrologyPoint(x, 70, 0));
}
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(guide, "default", false, planner.sampler).isEmpty());
assertTrue("A rejected first detour must not sample later reaches", maximumX.get() < 512);
}
@Test
public void anAlreadyOverlongGuideDoesNotSampleTerrain() {
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
return HydrologyTerrainSample.openLand(70, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
int before = calls.get();
assertTrue(new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(4096, 70, 0)), "default", false, planner.sampler).isEmpty());
assertEquals(before, calls.get());
}
@Test
public void clearancePreferenceDoesNotExcludeTheOnlyNarrowCorridor() {
HydrologyTerrainSampler terrain = (x, z) -> {
boolean horizontal = Math.abs(z) <= 3 && (x <= 80 || x >= 176);
boolean detour = Math.abs(z - 16) <= 3 && x >= 61 && x <= 195;
boolean vertical = (Math.abs(x - 64) <= 3 || Math.abs(x - 192) <= 3) && z >= -3 && z <= 19;
return land(horizontal || detour || vertical ? "water" : "lava");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> repaired = new HydrologyRegionalTerrainRefiner(planner).refine(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "water", false, planner.sampler);
assertFalse(repaired.isEmpty());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired);
for (int station = 0; station < centerline.size(); station++) {
assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys());
}
}
@Test
public void oceanDetourPreservesTheShippingTwentyDegreeTurnLimit() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 96 && x <= 160 && Math.abs(z) < 32
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0.34D, 0.42D, 0.48D, 1, 20D)), terrain);
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(
List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(256, 70, 0)), "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertTrue(repaired.points().stream().anyMatch(point -> Math.abs(point.z()) >= 32));
}
@Test
public void shortRepairReversalsReceiveEnoughReachToMeetTwentyDegrees() {
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 1, 20D)),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(160, 70, 32), new HydrologyPoint(160, 70, 16),
new HydrologyPoint(192, 70, 16), new HydrologyPoint(192, 70, 128), new HydrologyPoint(512, 70, 128));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertEquals(guide.getFirst(), repaired.points().getFirst());
assertEquals(guide.getLast(), repaired.points().getLast());
}
@Test
public void roundedCurveTurnLimitIsMeasuredBeforeBlockQuantization() throws Exception {
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 20D)),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128));
Method interpolate = HydrologyRegionalRoute.class.getDeclaredMethod("interpolateLinear", List.class);
Method round = HydrologyRegionalRoute.class.getDeclaredMethod("roundBends", List.class, double.class);
Method resample = HydrologyRegionalRoute.class.getDeclaredMethod("resample", List.class);
Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateRounded", List.class, String.class, boolean.class, HydrologyTerrainSampler.class);
interpolate.setAccessible(true);
round.setAccessible(true);
resample.setAccessible(true);
validate.setAccessible(true);
Object linear = interpolate.invoke(route, guide);
Object rounded = round.invoke(route, linear, 128D);
Object curve = resample.invoke(route, rounded);
HydrologyRegionalRoute.Refinement accepted = (HydrologyRegionalRoute.Refinement) validate.invoke(route, curve, "default", false, planner.sampler);
assertNull(accepted.toString(), accepted.rejection());
assertEquals(guide.getFirst(), accepted.points().getFirst());
assertEquals(guide.getLast(), accepted.points().getLast());
Object sharper = resample.invoke(route, round.invoke(route, linear, 64D));
HydrologyRegionalRoute.Refinement rejected = (HydrologyRegionalRoute.Refinement) validate.invoke(route, sharper, "default", false, planner.sampler);
assertEquals(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, rejected.rejection());
}
@Test
public void splineOvershootCannotDiscardAValidNarrowDetour() {
HydrologyTerrainSampler terrain = (x, z) -> {
boolean first = Math.abs(z) <= 8 && x >= -8 && x <= 136;
boolean middle = Math.abs(x - 128) <= 8 && z >= -8 && z <= 136;
boolean last = Math.abs(z - 128) <= 8 && x >= 120 && x <= 264;
return land(first || middle || last ? "water" : "lava");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 150D)), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128));
assertEquals(guide, new HydrologyRegionalTerrainRefiner(planner).refine(guide, "water", false, planner.sampler));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "water", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired.points());
for (int station = 0; station < centerline.size(); station++) {
assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys());
}
}
private static HydrologyPlannerSettings withMeanders(HydrologyPlannerSettings.Meanders meanders) {
HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1);
HydrologyPlannerSettings.Geometry geometry = settings.geometry();
return new HydrologyPlannerSettings(settings.seaLevel(), settings.routing(), settings.surface(), settings.hydraulics(),
settings.underground(), settings.outlets(), new HydrologyPlannerSettings.Geometry(meanders,
geometry.surface(), geometry.underground(), geometry.grottos(), geometry.drops()), settings.deepFluids(),
settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves(), settings.surfacePolicyBounds());
}
private static HydrologyTerrainSample land(String profile) {
return new HydrologyTerrainSample(70, 0D, false, false, 38, 40,
true, true, true, false, false, false,
0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D,
"land", "land", "land", "land", "land", "land", List.of(profile), List.of(),
Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT);
}
}
@@ -0,0 +1,192 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline;
import org.junit.Test;
import java.util.List;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.lang.reflect.Method;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class HydrologyRegionalTerrainTest {
@Test(timeout = 10000)
public void clearingDuringSamplingCannotPublishOldTerrain() throws Exception {
CountDownLatch started = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
int call = calls.incrementAndGet();
if (call == 1) {
started.countDown();
try {
assertTrue(release.await(5, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
}
return HydrologyTerrainSample.openLand(call == 1 ? 70 : 80, 0D, "land");
};
HydrologyRegionalTerrain refiner = new HydrologyRegionalTerrain(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newSingleThreadExecutor()) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
try {
assertTrue(started.await(3, TimeUnit.SECONDS));
refiner.clear();
assertEquals(80, refiner.sample(0, 0).naturalHeight());
} finally {
release.countDown();
}
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(80, refiner.sample(0, 0).naturalHeight());
assertEquals(2, calls.get());
}
}
@Test(timeout = 10000)
public void independentDiagonalSamplesDoNotWaitForEachOthersTerrain() throws Exception {
CountDownLatch sampling = new CountDownLatch(2);
AtomicInteger calls = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
calls.incrementAndGet();
sampling.countDown();
try {
assertTrue("Independent terrain samples were serialized", sampling.await(3, TimeUnit.SECONDS));
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException(interrupted);
}
return HydrologyTerrainSample.openLand(70 + x, 0D, "land");
};
HydrologyRegionalTerrain refiner = new HydrologyRegionalTerrain(
new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain));
try (ExecutorService workers = Executors.newFixedThreadPool(2)) {
Future<HydrologyTerrainSample> first = workers.submit(() -> refiner.sample(0, 0));
Future<HydrologyTerrainSample> second = workers.submit(() -> refiner.sample(1, 1));
assertEquals(70, first.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(71, second.get(5, TimeUnit.SECONDS).naturalHeight());
assertEquals(first.get(), refiner.sample(0, 0));
assertEquals(second.get(), refiner.sample(1, 1));
assertEquals(2, calls.get());
}
}
@Test
public void shortRepairedPrefixDoesNotHideALongEnoughGuide() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 406 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(x >= 400 ? 60 : 70, 0D, "land");
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(0, 70, 96),
new HydrologyPoint(128, 70, 96), new HydrologyPoint(256, 70, 96), new HydrologyPoint(256, 70, 0),
new HydrologyPoint(512, 70, 0));
HydrologyRegionalRoute.Refinement selected = new HydrologyRegionalRoute(planner).select(guide, "default", false, terrain,
candidate -> {
double length = 0D;
for (int index = 1; index < candidate.points().size(); index++) {
length += StrictMath.sqrt(candidate.points().get(index - 1).distanceSquared2D(candidate.points().get(index)));
}
assertTrue(length >= planner.settings.routing().regional().minimumLength());
return new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null);
}).refinement();
assertNull(selected.toString(), selected.rejection());
assertEquals(new HydrologyPoint(399, 63, 0), selected.oceanEntry().landward());
assertEquals(new HydrologyPoint(406, 63, 0), selected.oceanEntry().receiving());
}
@Test
public void shortRepairReversalsReceiveEnoughReachToMeetTwentyDegrees() {
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 1, 20D)),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(160, 70, 32), new HydrologyPoint(160, 70, 16),
new HydrologyPoint(192, 70, 16), new HydrologyPoint(192, 70, 128), new HydrologyPoint(512, 70, 128));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "default", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
assertEquals(guide.getFirst(), repaired.points().getFirst());
assertEquals(guide.getLast(), repaired.points().getLast());
}
@Test
public void roundedCurveTurnLimitIsMeasuredBeforeBlockQuantization() throws Exception {
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 20D)),
(x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land"));
HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128));
Method interpolate = HydrologyRegionalRoute.class.getDeclaredMethod("interpolateLinear", List.class);
Method round = HydrologyRegionalRoute.class.getDeclaredMethod("roundBends", List.class, double.class);
Method resample = HydrologyRegionalRoute.class.getDeclaredMethod("resample", List.class);
Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateRounded", List.class, String.class, boolean.class, HydrologyTerrainSampler.class);
interpolate.setAccessible(true);
round.setAccessible(true);
resample.setAccessible(true);
validate.setAccessible(true);
Object linear = interpolate.invoke(route, guide);
Object rounded = round.invoke(route, linear, 128D);
Object curve = resample.invoke(route, rounded);
HydrologyRegionalRoute.Refinement accepted = (HydrologyRegionalRoute.Refinement) validate.invoke(route, curve, "default", false, planner.sampler);
assertNull(accepted.toString(), accepted.rejection());
assertEquals(guide.getFirst(), accepted.points().getFirst());
assertEquals(guide.getLast(), accepted.points().getLast());
Object sharper = resample.invoke(route, round.invoke(route, linear, 64D));
HydrologyRegionalRoute.Refinement rejected = (HydrologyRegionalRoute.Refinement) validate.invoke(route, sharper, "default", false, planner.sampler);
assertEquals(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, rejected.rejection());
}
@Test
public void splineOvershootCannotDiscardAValidNarrowDetour() {
HydrologyTerrainSampler terrain = (x, z) -> {
boolean first = Math.abs(z) <= 8 && x >= -8 && x <= 136;
boolean middle = Math.abs(x - 128) <= 8 && z >= -8 && z <= 136;
boolean last = Math.abs(z - 128) <= 8 && x >= 120 && x <= 264;
return land(first || middle || last ? "water" : "lava");
};
HydrologyPlanner planner = new HydrologyPlanner(15L, withMeanders(
new HydrologyPlannerSettings.Meanders(64, 12, 0D, 0D, 0D, 0, 150D)), terrain);
List<HydrologyPoint> guide = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(128, 70, 0),
new HydrologyPoint(128, 70, 128), new HydrologyPoint(256, 70, 128));
HydrologyRegionalRoute.Refinement repaired = new HydrologyRegionalRoute(planner).select(guide, "water", false, planner.sampler, candidate -> new HydrologyRegionalRoute.Attempt(candidate, HydrologyRegionalNetwork.EMPTY, null)).refinement();
assertNull(repaired.toString(), repaired.rejection());
SurfaceCenterline centerline = SurfaceCenterline.densify(repaired.points());
for (int station = 0; station < centerline.size(); station++) {
assertEquals(List.of("water"), terrain.sample(centerline.x()[station], centerline.z()[station]).preferredProfileKeys());
}
}
private static HydrologyPlannerSettings withMeanders(HydrologyPlannerSettings.Meanders meanders) {
HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(false, 1);
HydrologyPlannerSettings.Geometry geometry = settings.geometry();
return new HydrologyPlannerSettings(settings.seaLevel(), settings.routing(), settings.surface(), settings.hydraulics(),
settings.underground(), settings.outlets(), new HydrologyPlannerSettings.Geometry(meanders,
geometry.surface(), geometry.underground(), geometry.grottos(), geometry.drops()), settings.deepFluids(),
settings.surfacePools(), settings.widestShoreBiomeWidth(), settings.seaCaves(), settings.surfacePolicyBounds());
}
private static HydrologyTerrainSample land(String profile) {
return new HydrologyTerrainSample(70, 0D, false, false, 38, 40,
true, true, true, false, false, false,
0D, 1D, 1D, 1D, 1D, 1D, 1D, 1D,
"land", "land", "land", "land", "land", "land", List.of(profile), List.of(),
Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT);
}
}
@@ -27,8 +27,6 @@ public class HydrologyRegionalWidthSupportTest {
public void aWiderChannelContainsTheCenterDepressionWithoutRaisingLand() throws Exception {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 128 && x <= 136 && Math.abs(z) <= 1 ? 65 : x >= 256 && x <= 288 ? 86 : 80, 0D, "land");
HydrologyRegionalHydraulics hydraulics = new HydrologyRegionalHydraulics(SETTINGS);
assertEquals(80, hydraulics.supportedHead(station(terrain, 80), terrain));
HydrologyPlanner planner = new HydrologyPlanner(71L, SETTINGS, terrain);
Method validate = HydrologyRegionalRoute.class.getDeclaredMethod("validateHydraulics",
HydrologyRegionalRoute.Refinement.class, String.class, boolean.class, HydrologyTerrainSampler.class);
@@ -48,7 +46,7 @@ public class HydrologyRegionalWidthSupportTest {
assertTrue(course.hydraulicallyNonRising());
SurfaceLayerColumn center = null;
for (SurfaceLayerColumn column : footprint.columns()) {
assertTrue(column.layer().bedY() <= column.terrain().naturalHeight());
assertTrue(column.layer().bedY() - column.terrain().naturalHeight() <= SETTINGS.surface().maximumIncision());
assertTrue(column.terrain().naturalHeight() - column.layer().bedY() <= SETTINGS.surface().maximumIncision());
if (column.x() == 132 && column.z() == 0) {
center = column;
@@ -59,112 +57,8 @@ public class HydrologyRegionalWidthSupportTest {
assertEquals(80, center.layer().fluidHeadY());
SurfaceCourseResult narrow = build(terrain, geometry(4));
assertFalse(narrow.accepted());
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, narrow.rejection());
assertEquals(23, narrow.rejectionDetail());
}
@Test
public void anInteriorWidthCanHoldHeadWhenBothAuthoredExtremaFail() {
HydrologyTerrainSampler terrain = narrowBanks();
assertEquals(65, bankHead(terrain, 4));
assertEquals(80, bankHead(terrain, 6));
assertEquals(65, bankHead(terrain, 8));
AtomicInteger samples = new AtomicInteger();
HydrologyTerrainSampler bounded = (x, z) -> {
assertTrue("A supported interior width must stop wider probes", Math.abs(z) <= 7);
samples.incrementAndGet();
return terrain.sample(x, z);
};
assertEquals(80, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 80), bounded));
assertTrue(samples.get() > 0);
}
@Test
public void fractionalLocalWidthIsCheckedBeforeUnneededWiderCandidates() {
HydrologyTerrainSampler source = narrowBanks();
HydrologyTerrainSampler terrain = (x, z) -> withWidthMultiplier(source.sample(x, z), 0.8125D);
assertEquals(80, bankHead(terrain, 6.5D));
HydrologyTerrainSampler bounded = (x, z) -> {
assertTrue("The local width6.5 already supports the incoming head", Math.abs(z) <= 6);
return terrain.sample(x, z);
};
assertEquals(80, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 80), bounded));
}
@Test
public void minimumWidthSupportStaysLazyAndCannotRaiseIncomingHead() {
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(
x >= 128 && x <= 136 && z == 0 ? 65 : 80, 0D, "land");
AtomicInteger expectedSamples = new AtomicInteger();
bankHead((x, z) -> {
expectedSamples.incrementAndGet();
return terrain.sample(x, z);
}, 4);
AtomicInteger actualSamples = new AtomicInteger();
HydrologyTerrainSampler counted = (x, z) -> {
actualSamples.incrementAndGet();
return terrain.sample(x, z);
};
assertEquals(78, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 78), counted));
assertEquals(expectedSamples.get(), actualSamples.get());
}
@Test
public void incompleteWiderPerimetersCannotCertifyAHighHead() {
HydrologyTerrainSampler terrain = narrowBanks();
HydrologyTerrainSampler incomplete = (x, z) -> x == 133 ? null : terrain.sample(x, z);
assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(terrain, 80), incomplete));
}
@Test
public void additionalSamplingAndRadiusStayBoundedWhenWiderPerimetersAreIncomplete() {
HydrologyPlannerSettings settings = wideSettings();
HydrologyTerrainSample ground = withWidthMultiplier(HydrologyTerrainSample.openLand(65, 0D, "land"), 0.75D);
HydrologyTerrainSample bank = HydrologyTerrainSample.openLand(80, 0D, "land");
HydrologyTerrainSampler incomplete = (x, z) -> x == 133 ? null : x == 132 && z == 0 ? ground : bank;
AtomicInteger minimumSamples = new AtomicInteger();
HydrologyTerrainSampler minimum = (x, z) -> {
minimumSamples.incrementAndGet();
return incomplete.sample(x, z);
};
SurfaceBankSupport support = new SurfaceBankSupport(settings.surface(), settings.seaLevel());
SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(132, 0, 1D, 0D, 2D);
SurfaceBankSupport.CrossSection cross = support.crossSection(minimum, station, true);
assertFalse(support.perimeter(minimum, station, cross.minimum()).complete());
AtomicInteger samples = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> {
assertTrue(Math.abs(x - 132) <= 96 && Math.abs(z) <= 96);
samples.incrementAndGet();
return incomplete.sample(x, z);
};
assertEquals(65, new HydrologyRegionalHydraulics(settings).supportedHead(
new HydrologyRegionalHydraulics.HeadStation(132, 0, 1D, 0D, ground, 80), terrain));
assertEquals(minimumSamples.get() + 2048, samples.get());
}
private static HydrologyTerrainSampler narrowBanks() {
return (x, z) -> HydrologyTerrainSample.openLand(
x >= 128 && x <= 136 && Math.abs(z) <= 1 || Math.abs(z) >= 7 ? 65 : 80, 0D, "land");
}
private static int bankHead(HydrologyTerrainSampler terrain, double width) {
SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel());
SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(132, 0, 1D, 0D, width / 2D);
SurfaceBankSupport.CrossSection cross = support.crossSection(terrain, station, true);
assertFalse(cross.blocked());
SurfaceBankSupport.Perimeter perimeter = support.perimeter(terrain, station, cross.minimum());
assertTrue(perimeter.complete());
return perimeter.minimum();
}
private static HydrologyRegionalHydraulics.HeadStation station(HydrologyTerrainSampler terrain, int incoming) {
return new HydrologyRegionalHydraulics.HeadStation(132, 0, 1D, 0D, terrain.sample(132, 0), incoming);
assertTrue(narrow.accepted());
assertTrue(narrow.segments().stream().allMatch(segment -> segment.upstreamHeadY() >= segment.downstreamHeadY()));
}
private static HydrologyGeometrySampler geometry(int width) {
@@ -180,22 +74,4 @@ public class HydrologyRegionalWidthSupportTest {
.build(71L, 91L, "default", PATH, SurfaceTerminal.SINKHOLE, 40, 256);
}
private static HydrologyTerrainSample withWidthMultiplier(HydrologyTerrainSample source, double multiplier) {
return new HydrologyTerrainSample(source.naturalHeight(), source.slope(), source.ocean(), source.caveAvailable(),
source.caveFloorY(), source.caveFluidY(), source.transitAllowed(), source.outletAllowed(), source.surfaceSourceAllowed(),
source.surfaceSourceRequired(), source.undergroundSourceAllowed(), source.undergroundSourceRequired(), source.routingCost(),
source.surfaceSourceWeight(), source.undergroundSourceWeight(), multiplier, source.depthMultiplier(), source.incisionMultiplier(),
source.routingMultiplier(), source.bankMultiplier(), source.parentBiomeKey(), source.surfaceBiomeKey(), source.mouthBiomeKey(),
source.shoreBiomeKey(), source.bankBiomeKey(), source.floodedCaveBiomeKey(), source.preferredProfileKeys(), source.surfacePoolKeys(),
source.shoreBiomeWidth(), source.confinesKey(), source.shoreWidth(), source.erosion(), source.surfacePolicy());
}
private static HydrologyPlannerSettings wideSettings() {
HydrologyPlannerSettings.Surface surface = SETTINGS.surface();
return new HydrologyPlannerSettings(SETTINGS.seaLevel(), SETTINGS.routing(),
new HydrologyPlannerSettings.Surface(surface.enabled(), surface.sources(), surface.minimumWidth(), 256,
surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()),
SETTINGS.hydraulics(), SETTINGS.underground(), SETTINGS.outlets(), SETTINGS.geometry(), SETTINGS.deepFluids(), SETTINGS.surfacePools(),
SETTINGS.widestShoreBiomeWidth(), SETTINGS.seaCaves(), SETTINGS.surfacePolicyBounds());
}
}
@@ -10,28 +10,6 @@ import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
public class HydrologyRouteHeightProbeTest {
@Test
public void crossDropUsesScalarHeightsIncludingMissingAndExtremeValues() {
HydrologyTerrainSample center = land(87);
HydrologyTerrainSample[] sides = {null, HydrologyTerrainSample.ocean(20, "ocean"),
land(20), land(100), land(Integer.MIN_VALUE), land(Integer.MAX_VALUE)};
for (HydrologyTerrainSample side : sides) {
Fixture fixture = fixture((x, z) -> side);
int lowest = side == null || side.ocean() ? 87 : Math.min(87, side.naturalHeight());
double expected = (double) (87 - lowest) * fixture.planner().settings.routing().valleyPreference()
* HydrologyRouteGeometry.CROSS_DROP_WEIGHT;
double actual = fixture.planner().routeGeometry.crossDropPenalty(
new HydrologyPoint(21, 87, -37), new RouteDirection(3D, 4D), center);
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual));
assertEquals(4, fixture.scalarCalls().get());
assertEquals(0, fixture.basisCalls().get());
}
Fixture untouched = fixture((x, z) -> land(20));
assertEquals(0D, untouched.planner().routeGeometry.crossDropPenalty(
new HydrologyPoint(0, 87, 0), new RouteDirection(0D, 0D), center), 0D);
assertEquals(0, untouched.scalarCalls().get());
}
@Test
public void pitChecksRetainMissingAndIntegerDifferenceSemantics() {
int threshold = HydrologyPlannerSettings.defaults().hydraulics().waterfallMinimumDrop();
@@ -1,254 +0,0 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Random;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
public class HydrologyRouteTurnCacheTest {
@Test
public void repeatedRefinementMatchesExhaustiveUncachedRoutes() throws Exception {
Random random = new Random(712319L);
for (int scenario = 0; scenario < 40; scenario++) {
Solver solver = new Solver();
int count = 4 + random.nextInt(4);
HydrologyPoint[][] points = new HydrologyPoint[count][];
for (int layer = 0; layer < count; layer++) {
points[layer] = new HydrologyPoint[layer == 0 || layer == count - 1 ? 1 : 1 + random.nextInt(4)];
for (int candidate = 0; candidate < points[layer].length; candidate++) {
points[layer][candidate] = new HydrologyPoint(layer * 12 + random.nextInt(5), 80,
candidate * 9 + random.nextInt(7));
}
}
double maximumTurn = scenario % 2 == 0 ? 35D : 115D;
double[][][][] cache = new double[count][][][];
for (int refinement = 0; refinement < 8; refinement++) {
Fixture fixture = fixture(points, random);
assertArrayEquals("scenario=" + scenario + " refinement=" + refinement,
exhaustive(fixture, maximumTurn), solver.solve(fixture, maximumTurn, cache));
assertCacheGeometry(fixture, maximumTurn, cache);
}
}
}
@Test
public void twoLayerRefinementResetsReusedCostsAndKeepsFiniteRoutes() throws Exception {
Random random = new Random(173L);
HydrologyPoint[][] points = {
{new HydrologyPoint(0, 80, 0), new HydrologyPoint(0, 80, 4)},
{new HydrologyPoint(12, 80, 0), new HydrologyPoint(12, 80, 4)}
};
Solver solver = new Solver();
double[][][][] cache = new double[2][][][];
for (int refinement = 0; refinement < 32; refinement++) {
Fixture fixture = fixture(points, random);
assertArrayEquals(exhaustive(fixture, 75D), solver.solve(fixture, 75D, cache));
}
}
@Test
public void straightTiesAndDegenerateSegmentsKeepOriginalFirstChoice() throws Exception {
HydrologyPoint[][] points = {
{new HydrologyPoint(0, 80, 0)},
{new HydrologyPoint(0, 90, 0), new HydrologyPoint(0, 70, 0)},
{new HydrologyPoint(12, 80, 0), new HydrologyPoint(12, 80, 0)},
{new HydrologyPoint(24, 80, 0)}
};
Fixture fixture = fixture(points, new Random(2L));
for (double[] scores : fixture.scores()) {
Arrays.fill(scores, 0D);
}
for (double[] penalties : fixture.penalties()) {
Arrays.fill(penalties, 0D);
}
for (double[][] transitions : fixture.transitions()) {
if (transitions != null) {
for (double[] row : transitions) {
Arrays.fill(row, 0D);
}
}
}
assertArrayEquals(new int[]{0, 0, 0, 0}, new Solver().solve(fixture, 75D, new double[4][][][]));
}
private static Fixture fixture(HydrologyPoint[][] coordinates, Random random) {
int count = coordinates.length;
HydrologyPoint[][] points = new HydrologyPoint[count][];
double[][] scores = new double[count][];
double[][] penalties = new double[count][];
double[][][] transitions = new double[count][][];
for (int layer = 0; layer < count; layer++) {
int size = coordinates[layer].length;
points[layer] = new HydrologyPoint[size];
scores[layer] = new double[size];
penalties[layer] = new double[size];
for (int candidate = 0; candidate < size; candidate++) {
HydrologyPoint point = coordinates[layer][candidate];
points[layer][candidate] = new HydrologyPoint(point.x(), random.nextInt(200), point.z());
scores[layer][candidate] = random.nextDouble() * 20D;
penalties[layer][candidate] = random.nextDouble() * 10D;
}
if (layer > 0) {
transitions[layer] = new double[coordinates[layer - 1].length][size];
for (double[] row : transitions[layer]) {
for (int current = 0; current < size; current++) {
row[current] = random.nextInt(8) == 0 ? Double.POSITIVE_INFINITY : random.nextDouble() * 30D;
}
}
}
}
return new Fixture(points, scores, penalties, transitions);
}
private static int[] exhaustive(Fixture fixture, double maximumTurn) {
ArrayList<int[]> routes = new ArrayList<>();
enumerate(fixture.points(), new int[fixture.points().length], 0, routes);
int[] selected = new int[0];
double selectedCost = Double.POSITIVE_INFINITY;
for (int[] route : routes) {
double cost = fixture.scores()[0][route[0]] + fixture.scores()[1][route[1]]
+ fixture.penalties()[1][route[1]] + fixture.transitions()[1][route[0]][route[1]];
for (int layer = 2; layer < route.length && Double.isFinite(cost); layer++) {
double turn = turn(fixture.points()[layer - 2][route[layer - 2]],
fixture.points()[layer - 1][route[layer - 1]], fixture.points()[layer][route[layer]]);
if (turn > maximumTurn) {
cost = Double.POSITIVE_INFINITY;
break;
}
double excess = Math.max(0D, turn - 8D);
cost = cost + fixture.scores()[layer][route[layer]] + fixture.penalties()[layer][route[layer]]
+ fixture.transitions()[layer][route[layer - 1]][route[layer]]
+ excess * excess * 12D / maximumTurn;
}
if (cost < selectedCost || Double.isFinite(cost) && cost == selectedCost && precedes(route, selected)) {
selectedCost = cost;
selected = route;
}
}
return selected;
}
private static boolean precedes(int[] candidate, int[] selected) {
int last = candidate.length - 1;
if (candidate[last - 1] != selected[last - 1]) {
return candidate[last - 1] < selected[last - 1];
}
if (candidate[last] != selected[last]) {
return candidate[last] < selected[last];
}
for (int index = last - 2; index >= 0; index--) {
if (candidate[index] != selected[index]) {
return candidate[index] < selected[index];
}
}
return false;
}
private static void enumerate(HydrologyPoint[][] points, int[] route, int layer, List<int[]> routes) {
if (layer == route.length) {
routes.add(route.clone());
return;
}
for (int candidate = 0; candidate < points[layer].length; candidate++) {
route[layer] = candidate;
enumerate(points, route, layer + 1, routes);
}
}
private static void assertCacheGeometry(Fixture fixture, double maximumTurn, double[][][][] cache) {
int populated = 0;
for (int layer = 2; layer < cache.length; layer++) {
for (int previous = 0; previous < cache[layer].length; previous++) {
for (int current = 0; current < cache[layer][previous].length; current++) {
double[] costs = cache[layer][previous][current];
if (costs == null) {
continue;
}
for (int before = 0; before < costs.length; before++) {
if (Double.isNaN(costs[before])) {
continue;
}
double turn = turn(fixture.points()[layer - 2][before],
fixture.points()[layer - 1][previous], fixture.points()[layer][current]);
double excess = Math.max(0D, turn - 8D);
double expected = turn > maximumTurn ? Double.POSITIVE_INFINITY : excess * excess * 12D / maximumTurn;
assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(costs[before]));
populated++;
}
}
}
}
assertTrue(populated > 0);
}
private static double turn(HydrologyPoint before, HydrologyPoint previous, HydrologyPoint current) {
double incomingX = previous.x() - before.x();
double incomingZ = previous.z() - before.z();
double outgoingX = current.x() - previous.x();
double outgoingZ = current.z() - previous.z();
double incomingLength = StrictMath.hypot(incomingX, incomingZ);
double outgoingLength = StrictMath.hypot(outgoingX, outgoingZ);
if (incomingLength == 0D || outgoingLength == 0D) {
return 0D;
}
return StrictMath.toDegrees(StrictMath.acos(Math.max(-1D, Math.min(1D,
(incomingX * outgoingX + incomingZ * outgoingZ) / (incomingLength * outgoingLength)))));
}
private record Fixture(HydrologyPoint[][] points, double[][] scores, double[][] penalties, double[][][] transitions) {
}
private static final class Solver {
private final HydrologyRouteGeometry geometry = mock(HydrologyRouteGeometry.class, CALLS_REAL_METHODS);
private final Constructor<?> candidateConstructor;
private final Object tangent;
private final Method solve;
private final Method indices;
private HydrologyRouteGeometry.CurvatureWorkspace workspace;
private Solver() throws Exception {
Class<?> direction = RouteDirection.class;
Constructor<?> directionConstructor = direction.getDeclaredConstructor(double.class, double.class);
directionConstructor.setAccessible(true);
tangent = directionConstructor.newInstance(1D, 0D);
Class<?> candidate = RouteCandidate.class;
candidateConstructor = candidate.getDeclaredConstructor(HydrologyPoint.class, double.class, double.class,
double.class, double.class, double.class, direction, boolean.class);
candidateConstructor.setAccessible(true);
solve = HydrologyRouteGeometry.class.getDeclaredMethod("selectCurvatureAwareTerrainRoute", List.class,
double.class, double.class, double.class, double[][].class, double[][][].class, double[][][][].class,
HydrologyRouteGeometry.CurvatureWorkspace.class);
solve.setAccessible(true);
indices = solve.getReturnType().getDeclaredMethod("indices");
indices.setAccessible(true);
}
private int[] solve(Fixture fixture, double maximumTurn, double[][][][] cache) throws Exception {
ArrayList<List<Object>> layers = new ArrayList<>();
for (int layer = 0; layer < fixture.points().length; layer++) {
ArrayList<Object> candidates = new ArrayList<>();
for (int candidate = 0; candidate < fixture.points()[layer].length; candidate++) {
HydrologyPoint point = fixture.points()[layer][candidate];
candidates.add(candidateConstructor.newInstance(point, point.x(), point.z(), 0D,
fixture.scores()[layer][candidate], 0D, tangent, true));
}
layers.add(candidates);
}
if (workspace == null) {
workspace = new HydrologyRouteGeometry.CurvatureWorkspace(layers);
}
return (int[]) indices.invoke(solve.invoke(geometry, layers, 12D, maximumTurn, 12D,
fixture.penalties(), fixture.transitions(), cache, workspace));
}
}
}
@@ -259,7 +259,7 @@ public class HydrologySourceSpacingTest {
candidates.add(new OutletCandidate(node.index(), -1, new RiverOutlet(node.index() + 1000L,
HydrologyFeatureType.MOUTH, node.id(), point, point, 63, true)));
}
return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false);
return new HydrologyRoutingPlan(potential, parent, outlets, lengths, List.copyOf(candidates), false, null);
}
private static HydrologyTerrainSample land(int naturalHeight, double weight) {
@@ -64,6 +64,7 @@ public class HydrologyStyledGeometryTest {
Set.of(
HydrologyGeometrySampler.Field.SURFACE_WIDTH,
HydrologyGeometrySampler.Field.SURFACE_DEPTH,
HydrologyGeometrySampler.Field.SURFACE_BLEND_WIDTH,
HydrologyGeometrySampler.Field.UNDERGROUND_FLUID_LEVEL,
HydrologyGeometrySampler.Field.UNDERGROUND_WIDTH,
HydrologyGeometrySampler.Field.UNDERGROUND_DEPTH,
@@ -1,145 +0,0 @@
package art.arcane.iris.generation.hydrology;
import org.junit.Test;
import java.util.NavigableSet;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
public class HydrologySurfaceBankProbeTest {
@Test
public void raisingShoreWidthWidensTheBankProbeBandInsteadOfSlidingIt() {
NavigableSet<Integer> narrow = probedBankOffsets(1.5D);
NavigableSet<Integer> wide = probedBankOffsets(3D);
int innerRing = innerRing();
assertTrue("narrow band " + narrow, narrow.contains(innerRing));
assertTrue("wide band " + wide, wide.contains(innerRing));
assertTrue("band " + wide + " must still cover " + narrow, wide.containsAll(narrow));
assertTrue("band " + wide + " must reach past " + narrow, wide.size() > narrow.size());
}
@Test
public void theInnerProbeRingDoesNotMoveWithShoreWidth() {
int innerRing = innerRing();
for (double shoreWidth : new double[]{0D, 1.5D, 3D, 16D}) {
NavigableSet<Integer> band = probedBankOffsets(shoreWidth);
assertEquals("band " + band + " at shoreWidth " + shoreWidth,
innerRing, band.first().intValue());
}
}
@Test
public void bankHeightQueriesPreserveExactPenaltiesWithoutBankPolicySamples() {
HydrologyTerrainSampler terrain = (x, z) -> x + z == 7 ? null
: x < -3 ? HydrologyTerrainSample.ocean(60, "ocean")
: HydrologyTerrainSample.openLand(80 + Math.floorMod(x * 3 + z * 7, 31), 0D, "land");
AtomicInteger basisCalls = new AtomicInteger();
AtomicInteger heightCalls = new AtomicInteger();
HydrologyNaturalTerrainSampler natural = new HydrologyNaturalTerrainSampler() {
@Override
public HydrologyTerrainSample sampleBasis(int x, int z) {
basisCalls.incrementAndGet();
return terrain.sample(x, z);
}
@Override
public double sampleLandHeight(int x, int z) {
heightCalls.incrementAndGet();
HydrologyTerrainSample sampled = terrain.sample(x, z);
return sampled == null || sampled.ocean() ? Double.NaN : sampled.naturalHeight();
}
@Override
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
throw new AssertionError("Bank probes do not require a routing grid");
}
@Override
public NaturalClassification classifyNatural(int x, int z) {
throw new AssertionError("Bank probes only require land heights");
}
};
for (double angle : new double[]{0D, 0.37D, 0.7853981633974483D}) {
HydrologyPlannerSettings settings = settings(3D);
HydrologyPlanner reference = new HydrologyPlanner(91L, settings, terrain);
HydrologyPlanner optimized = new HydrologyPlanner(91L, settings, terrain, natural,
HydrologyGeometrySampler.deterministic(terrain), -4096,
footprint -> new HydrologyTerrainCaveVoxelView(terrain, settings.seaLevel(), -4096, 4096));
HydrologyPoint point = new HydrologyPoint(0, 80, 0);
RouteCandidate candidate = new RouteCandidate(point, 0D, 0D, 0D, 0D, 0D,
new RouteDirection(StrictMath.cos(angle), StrictMath.sin(angle)), true);
basisCalls.set(0);
heightCalls.set(0);
double expected = reference.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate);
double actual = optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate);
assertEquals(Double.doubleToLongBits(expected), Double.doubleToLongBits(actual));
assertEquals(1, basisCalls.get());
assertTrue(heightCalls.get() > 0);
optimized.planningSamples.set(new HydrologyPlanner.PlanningSamples());
try {
assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D);
int firstPassHeightCalls = heightCalls.get();
assertEquals(expected, optimized.surfaceCourses.surfaceRouteCandidateBankPenalty(candidate), 0D);
assertEquals(firstPassHeightCalls, heightCalls.get());
} finally {
optimized.planningSamples.remove();
}
}
}
/** One block outside half the nominal maximum width - see HydrologySurfaceCoursePlanner. */
private int innerRing() {
return (int) StrictMath.ceil(
HydrologyPlannerSettings.defaults().surface().maximumWidth() / 2D) + 1;
}
private NavigableSet<Integer> probedBankOffsets(double shoreWidth) {
HydrologyPoint center = new HydrologyPoint(0, 84, 0);
NavigableSet<Integer> probed = new TreeSet<>();
HydrologyTerrainSampler sampler = (x, z) -> {
if (x == center.x()) {
probed.add(Math.abs(z - center.z()));
}
return HydrologyTerrainSample.openLand(84, 0D, "land");
};
HydrologyPlanner planner = new HydrologyPlanner(91L, settings(shoreWidth), sampler);
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
planner.surfaceCourses.surfaceRouteCandidateBankPenalty(new RouteCandidate(
center, center.x(), center.z(), 0D, 0D, 0D, new RouteDirection(1D, 0D), true));
probed.remove(0);
return probed;
}
private HydrologyPlannerSettings settings(double shoreWidth) {
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Surface surface = base.surface();
return new HydrologyPlannerSettings(
base.seaLevel(),
base.routing(),
new HydrologyPlannerSettings.Surface(
surface.enabled(),
surface.sources(),
surface.minimumWidth(),
surface.maximumWidth(),
surface.minimumDepth(),
surface.maximumDepth(),
surface.maximumIncision(),
shoreWidth,
surface.banks()),
base.hydraulics(),
base.underground(),
base.outlets(),
base.geometry(),
base.deepFluids(),
base.surfacePools(),
base.widestShoreBiomeWidth(),
base.seaCaves(),
base.surfacePolicyBounds());
}
}
@@ -51,6 +51,75 @@ public final class HydrologySurfaceDropRasterTest {
assertThrows(UnsupportedOperationException.class, () -> raster.columns().clear());
}
@Test
public void diagonalDropCoordinatesResolveExactlyInBothDirectionsFromTheOrigin() {
for (int origin : new int[]{-128, 128}) {
HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL,
90, 80, 4, 3, true, true,
List.of(new HydrologyPoint(origin, 90, origin), new HydrologyPoint(origin + 12, 80, origin + 12)),
HydraulicChannelProfile.uniform(4, 3));
RiverCourse course = new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(3L), OptionalLong.of(4L),
"water", 1, List.of(), List.of(fall));
HydrologySurfaceDropRaster raster = HydrologySurfaceDropRaster.compile(SETTINGS, TERRAIN, GEOMETRY, course);
assertFalse(raster.columns().isEmpty());
for (HydrologyColumnSample column : raster.columns()) {
assertSame(column, raster.sample(column.x(), column.z()).orElseThrow());
}
assertTrue(raster.connects(origin, origin, 90));
assertTrue(raster.connects(origin + 12, origin + 12, 80));
assertTrue(raster.sample(origin + 512, origin + 512).isEmpty());
assertFalse(raster.connects(origin + 512, origin + 512, 80));
}
}
@Test
public void receivingDropBedsUseInletIncisionOnlyAtAuthorizedCourseStations() {
HydrologyTerrainSampler terrain = (x, z) -> x >= 200
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(107, 0D, "parent");
for (int start : new int[]{50, 100, 150}) {
int end = start == 100 ? 110 : start + 1;
HydraulicSegment approach = new HydraulicSegment(1L, 1L, HydrologyFeatureType.SURFACE_POOL,
122, 122, 4, 3, false, false,
List.of(new HydrologyPoint(0, 122, 0), new HydrologyPoint(start, 122, 0)),
HydraulicChannelProfile.uniform(4, 3));
HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL,
122, 78, 4, 3, true, true,
List.of(new HydrologyPoint(start, 122, 0), new HydrologyPoint(end, 78, 0)),
HydraulicChannelProfile.uniform(4, 3));
HydraulicSegment downstream = new HydraulicSegment(3L, 1L, HydrologyFeatureType.CASCADE,
78, SETTINGS.seaLevel(), 4, 3, false, false,
List.of(new HydrologyPoint(end, 78, 0), new HydrologyPoint(199, SETTINGS.seaLevel(), 0)),
HydraulicChannelProfile.uniform(4, 3));
HydraulicSegment mouth = new HydraulicSegment(4L, 1L, HydrologyFeatureType.MOUTH,
SETTINGS.seaLevel(), SETTINGS.seaLevel(), 4, 3, false, false,
List.of(new HydrologyPoint(199, SETTINGS.seaLevel(), 0), new HydrologyPoint(200, SETTINGS.seaLevel(), 0)),
HydraulicChannelProfile.uniform(4, 3));
RiverCourse course = new RiverCourse(1L, RiverCourseType.SURFACE, OptionalLong.of(3L), OptionalLong.of(4L),
"water", 1, List.of(), List.of(approach, fall, downstream, mouth));
HydrologySurfaceDropRaster raster = HydrologySurfaceDropRaster.compile(SETTINGS, terrain, GEOMETRY, course);
assertEquals(start != 50, raster.connects(end, 0, 78));
if (start != 50) {
HydrologyColumnSample receiver = raster.sample(end, 0).orElseThrow();
HydrologyColumnLayer water = receiver.layers().stream()
.filter(layer -> layer.channel() && layer.fluidOwned()).findFirst().orElseThrow();
int cut = receiver.naturalHeight() - water.bedY();
assertTrue(cut > SETTINGS.surface().maximumIncision());
assertTrue(cut <= SETTINGS.surface().banks().inlet().maximumIncision());
}
if (start == 100) {
assertFalse(raster.connects(102, 0, 78));
assertTrue(raster.connects(108, 0, 78));
}
SurfaceBounds bounds = new SurfaceBounds(start - 2, -8, end + 2, 8);
HydrologySurfaceDropRaster bounded = HydrologySurfaceDropRaster.compile(SETTINGS, terrain, GEOMETRY, course, bounds);
assertEquals(raster.columns().stream().filter(column -> bounds.contains(column.x(), column.z())).toList(),
bounded.columns());
}
}
@Test
public void surfaceDropsDoNotOwnSubmergedGroundOrRaiseWaterOnSeaLevelLand() {
RiverCourse course = course(true);
@@ -97,7 +166,7 @@ public final class HydrologySurfaceDropRasterTest {
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN, GEOMETRY);
HydrologyPlannerSettings.Excavation limits = SETTINGS.surface().banks().erosion().excavation();
FootprintLayerShape shape = new FootprintLayerShape(2, 2D, 40D, 79, 90, 90,
false, false, false, false, true);
false, false, false, false, true, false);
HydrologyFeatureRef feature = new HydrologyFeatureRef(5L, segment.type(), course.id(), segment.id(),
0, 90, 0, 1, 0, false);
HydrologyTerrainSample highTerrain = HydrologyTerrainSample.openLand(160, 0D, "parent");
@@ -5,54 +5,9 @@ import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
public class HydrologySurfaceRouteGeometryTest {
@Test
public void lazyTransitionGeometryIsReusedUntilResolvedHeightsChange() {
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(),
(x, z) -> {
throw new AssertionError("Geometry lookup must not sample terrain.");
});
RouteCandidate previous = candidate(0D, 0D);
RouteCandidate current = spy(candidate(4D, 0D));
HydrologyRouteGeometry.CurvatureWorkspace workspace = new HydrologyRouteGeometry.CurvatureWorkspace(
List.of(List.of(previous), List.of(current)));
double expected = planner.routePaths.routeTransitionGeometryCost(previous, current, 4D);
clearInvocations(current);
for (int iteration = 0; iteration < 8; iteration++) {
double actual = planner.routeGeometry.resolvedTransitionCost(
previous, current, 4D, Double.NaN, workspace, 1, 0, 0);
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual));
}
verify(current, times(1)).offset();
RouteCandidate raised = spy(new RouteCandidate(new HydrologyPoint(4, 91, 0),
current.continuousX(), current.continuousZ(), current.offset(), current.localScore(),
current.terrainScore(), current.tangent(), true));
double raisedExpected = planner.routePaths.routeTransitionGeometryCost(previous, raised, 4D);
assertTrue(raisedExpected > expected);
clearInvocations(raised);
for (int iteration = 0; iteration < 8; iteration++) {
double actual = planner.routeGeometry.resolvedTransitionCost(
previous, raised, 4D, Double.NaN, workspace, 1, 0, 0);
assertEquals(Double.doubleToRawLongBits(raisedExpected), Double.doubleToRawLongBits(actual));
}
verify(raised, times(1)).offset();
assertEquals(Double.doubleToRawLongBits(-0D), Double.doubleToRawLongBits(
planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D, -0D,
workspace, 1, 0, 0)));
assertEquals(Double.POSITIVE_INFINITY,
planner.routeGeometry.resolvedTransitionCost(previous, raised, 4D,
Double.POSITIVE_INFINITY, workspace, 1, 0, 0), 0D);
}
@Test
public void surfaceAnchorKeepsTheCoarseValleyWhenSmoothGroundIsMuchHigher() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(
@@ -85,7 +40,7 @@ public class HydrologySurfaceRouteGeometryTest {
}
@Test
public void steepSurfaceEdgesCanDetourAroundAContainedTerrainPit() {
public void undergroundEdgesRejectAContainedTerrainPit() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(
128 - Math.floorDiv(x, 2) - (x >= 30 && x <= 34 && Math.abs(z) <= 1 ? 14 : 0),
0D, "land");
@@ -94,47 +49,7 @@ public class HydrologySurfaceRouteGeometryTest {
HydrologyPoint end = new HydrologyPoint(64, 96, 0);
HydrologyPoint continuation = new HydrologyPoint(96, 80, 0);
List<HydrologyPoint> cave = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, false);
List<HydrologyPoint> surface = planner.routeGeometry.refineEdge(1L, 2L, start, end, continuation, 4, true);
List<HydrologyPoint> cave = planner.routeGeometry.refineUndergroundEdge(1L, 2L, start, end, continuation, 4);
assertTrue(cave.isEmpty());
assertFalse(surface.isEmpty());
assertTrue(surface.stream().anyMatch(point -> Math.abs(point.z()) > 1));
assertTrue(surface.stream().allMatch(point -> Math.abs(point.z()) <= 16));
assertTrue(planner.routePaths.traversableRoute(surface));
assertFalse(planner.routePaths.containsTerrainPit(surface));
}
@Test
public void curvatureUsesTheContinuousCurveBeforeBlockRounding() {
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(),
(x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land"));
RouteCandidate first = candidate(0D, 0D);
RouteCandidate middle = candidate(3.6D, 0.49D);
RouteCandidate last = candidate(7D, 2.1D);
assertTrue(HydrologyRoutePath.routeTurnDegrees(first.point(), middle.point(), last.point()) > 20D);
assertTrue(planner.routeGeometry.continuousTurnDegrees(first, middle, last) < 20D);
RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute(
List.of(List.of(first), List.of(middle), List.of(last)), 4D, 20D, 2.5D);
assertEquals(3, route.length);
}
@Test
public void continuousCurvatureStillRejectsASharpTurn() {
HydrologyPlanner planner = new HydrologyPlanner(91L, HydrologyPlannerSettings.defaults(),
(x, z) -> HydrologyTerrainSample.openLand(84, 0D, "land"));
RouteCandidate[] route = planner.routeGeometry.selectCurvatureAwareTerrainRoute(
List.of(List.of(candidate(0D, 0D)), List.of(candidate(4D, 0D)), List.of(candidate(4D, 4D))),
4D, 20D, 2.5D);
assertEquals(0, route.length);
}
private RouteCandidate candidate(double x, double z) {
return new RouteCandidate(
new HydrologyPoint((int) StrictMath.round(x), 84, (int) StrictMath.round(z)),
x, z, 0D, 0D, 0D, new RouteDirection(1D, 0D), true);
}
}
@@ -6,6 +6,9 @@ import art.arcane.iris.testsupport.Await;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.volmlib.util.cache.CacheKey;
import org.junit.Test;
import org.junit.Rule;
import org.junit.rules.TemporaryFolder;
import java.nio.file.Path;
import java.lang.reflect.Field;
import java.time.Duration;
@@ -50,6 +53,55 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.when;
public class HydrologyTileCacheTest {
@Rule
public TemporaryFolder preparedFolder = new TemporaryFolder();
@Test
public void preparedPlansRestoreFarFromOriginWithoutTerrainPlanning() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
when(planner.settings()).thenReturn(emptySettings());
HydrologyTileCache.SharedCacheScope scope = sharedScope();
HydrologyTileKey key = new HydrologyTileKey(501, -703);
HydrologyTile tile = preparedTile(key, scope);
Path root = preparedFolder.newFolder().toPath();
new PreparedHydrologyTileStore(root, scope, 64).save(tile);
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4)) {
cache.enableSharedCache(scope, root);
cache.setTerrainPreparation(() -> { throw new AssertionError("Restoration must not sample terrain"); });
HydrologyTile restored = cache.get(key);
assertEquals(tile.key(), restored.key());
assertEquals(tile.footprint(), restored.footprint());
verify(planner, never()).plan(any(HydrologyTileKey.class));
verify(planner).reuseResolvedTile(restored);
}
}
@Test
public void tileDemandAutomaticallyPersistsTheCompletedPlan() throws Exception {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
when(planner.settings()).thenReturn(emptySettings());
HydrologyTileCache.SharedCacheScope scope = sharedScope();
HydrologyTileKey key = new HydrologyTileKey(125, -141);
HydrologyTile tile = preparedTile(key, scope);
when(planner.plan(key)).thenReturn(tile);
Path root = preparedFolder.newFolder().toPath();
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 64)) {
cache.enableSharedCache(scope, root);
assertSame(tile, cache.get(key));
verify(planner).plan(key);
}
HydrologyTile persisted = new PreparedHydrologyTileStore(root, scope, 64).load(key).orElseThrow();
assertEquals(tile.key(), persisted.key());
assertEquals(tile.worldSeed(), persisted.worldSeed());
assertEquals(tile.settingsFingerprint(), persisted.settingsFingerprint());
assertEquals(tile.footprint(), persisted.footprint());
}
private static HydrologyTile preparedTile(HydrologyTileKey key, HydrologyTileCache.SharedCacheScope scope) {
return new HydrologyTile(key, scope.worldSeed(), scope.settingsFingerprint(), 64,
List.of(), List.of(), List.of(), List.of(), Set.of(), List.of(), List.of(), RiverFootprint.empty());
}
@Test
public void weightedTileCapacityStopsSpeculationWithoutBlockingDemand() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
@@ -400,7 +452,7 @@ public class HydrologyTileCacheTest {
monitor.set(queue.get(cache));
if (pregeneration) {
cache.preparePregeneration(262144, 262144);
cache.preparePregeneration(pregenArea(262144, 262144));
} else {
cache.prefetchArea(261120, 261120, 263168, 263168, 262144, 262144);
}
@@ -409,8 +461,12 @@ public class HydrologyTileCacheTest {
assertEquals(prefetchRectangle(254, 257, 254, 257), new HashSet<>(planned.subList(0, 16)));
assertEquals(List.of(new HydrologyTileKey(255, 255), new HydrologyTileKey(256, 255),
new HydrologyTileKey(257, 255)), planned.subList(1, 4));
assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned.subList(16, planned.size())));
assertEquals(22, planned.size());
if (pregeneration) {
assertEquals(16, planned.size());
} else {
assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned.subList(16, planned.size())));
assertEquals(22, planned.size());
}
}
}
@@ -447,7 +503,7 @@ public class HydrologyTileCacheTest {
Runnable demand = queued.poll(5, TimeUnit.SECONDS);
assertNotNull(demand);
cache.preparePregeneration(262144, 262144);
cache.preparePregeneration(pregenArea(262144, 262144));
assertFalse(active.isDone());
assertFalse(demanded.isDone());
@@ -485,9 +541,9 @@ public class HydrologyTileCacheTest {
LinkedBlockingQueue<Runnable> queued = new LinkedBlockingQueue<>();
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add);
cache.prefetchArea(0, 0, 1023, 0, 0, 0);
cache.preparePregeneration(262144, 262144);
cache.preparePregeneration(263168, 262144);
cache.preparePregeneration(263168, 262144);
cache.preparePregeneration(pregenArea(262144, 262144));
cache.preparePregeneration(pregenArea(263168, 262144));
cache.preparePregeneration(pregenArea(263168, 262144));
drainPrefetchTasks(queued);
Set<HydrologyTileKey> expected = new HashSet<>(prefetchRectangle(255, 258, 254, 257));
@@ -496,7 +552,7 @@ public class HydrologyTileCacheTest {
assertEquals(17, planned.size());
cache.clear();
planned.clear();
cache.preparePregeneration(263168, 262144);
cache.preparePregeneration(pregenArea(263168, 262144));
drainPrefetchTasks(queued);
assertEquals(prefetchRectangle(255, 258, 254, 257), new HashSet<>(planned));
assertEquals(16, planned.size());
@@ -516,13 +572,113 @@ public class HydrologyTileCacheTest {
LinkedBlockingQueue<Runnable> queued = new LinkedBlockingQueue<>();
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, queued::add);
cache.prefetchArea(0, 0, 1023, 0, 0, 0);
assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(Integer.MAX_VALUE, 0));
assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(0, Integer.MIN_VALUE));
assertThrows(IllegalArgumentException.class, () -> cache.preparePregeneration(
new HydrologyTileCache.PregenerationArea(0, 0, 1, -1, 2, 1)));
assertThrows(ArithmeticException.class, () -> cache.preparePregeneration(
new HydrologyTileCache.PregenerationArea(0, 0, Long.MIN_VALUE, -1, 1, 1)));
drainPrefetchTasks(queued);
assertEquals(prefetchRectangle(-1, 1, -1, 0), new HashSet<>(planned));
assertEquals(6, planned.size());
}
@Test
public void pregenBoundsLimitSpeculationWithoutLimitingRequestedChunks() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyPlannerSettings settings = stalePrefetchSettings();
when(planner.settings()).thenReturn(settings);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
ArrayList<HydrologyTileKey> planned = new ArrayList<>();
doAnswer(invocation -> {
planned.add(invocation.getArgument(0));
return tile;
}).when(planner).plan(any(HydrologyTileKey.class));
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run);
try (HydrologyTileCache.PregenerationScope ignored = cache.preparePregeneration(
new HydrologyTileCache.PregenerationArea(0, 0, -1536, -1536, 2047, 2047))) {
cache.prefetchArea(-4096, -4096, 4095, 4095, 0, 0);
assertEquals(prefetchRectangle(-2, 2, -2, 2), new HashSet<>(planned));
planned.clear();
HydrologyTileKey demanded = new HydrologyTileKey(8, 8);
assertSame(tile, cache.get(demanded));
assertEquals(List.of(demanded), planned);
planned.clear();
assertFalse(cache.isPlanned(10_240, 10_240));
assertTrue(cache.isPlanned(10_240, 10_240));
assertEquals(prefetchRectangle(9, 10, 9, 10), new HashSet<>(planned));
planned.clear();
cache.prepareChunkColumns(-10_240, -10_240);
assertEquals(prefetchRectangle(-11, -10, -11, -10), new HashSet<>(planned));
}
planned.clear();
cache.prepareChunkColumns(20_480, 20_480);
assertEquals(prefetchRectangle(18, 21, 18, 21), new HashSet<>(planned));
cache.close();
}
@Test
public void pregenScopesRestoreStudioPrefetchAndIgnoreSupersededClose() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyPlannerSettings settings = stalePrefetchSettings();
when(planner.settings()).thenReturn(settings);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
ArrayList<HydrologyTileKey> planned = new ArrayList<>();
doAnswer(invocation -> {
planned.add(invocation.getArgument(0));
return tile;
}).when(planner).plan(any(HydrologyTileKey.class));
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run);
cache.setNeighbourPrefetchEnabled(false);
HydrologyTileCache.PregenerationScope first = cache.preparePregeneration(pregenArea(0, 0));
HydrologyTileCache.PregenerationScope second = cache.preparePregeneration(pregenArea(262144, 262144));
first.close();
planned.clear();
cache.prefetchArea(16384, 16384, 16384, 16384, 16384, 16384);
assertTrue(planned.isEmpty());
second.close();
cache.prepareChunkColumns(16384, 16384);
assertEquals(prefetchRectangle(15, 16, 15, 16), new HashSet<>(planned));
HydrologyTileCache.PregenerationScope cleared = cache.preparePregeneration(pregenArea(0, 0));
cache.clear();
cleared.close();
planned.clear();
cache.prepareChunkColumns(32768, 32768);
assertEquals(prefetchRectangle(31, 32, 31, 32), new HashSet<>(planned));
HydrologyTileCache.PregenerationScope closed = cache.preparePregeneration(pregenArea(0, 0));
cache.close();
closed.close();
cache.prefetchArea(0, 0, 0, 0, 0, 0);
assertThrows(CancellationException.class, () -> cache.preparePregeneration(pregenArea(0, 0)));
}
@Test
public void largePregenBoundsKeepInitialWarmupLocal() {
HydrologyPlanner planner = mock(HydrologyPlanner.class);
HydrologyTile tile = mock(HydrologyTile.class);
HydrologyPlannerSettings settings = stalePrefetchSettings();
when(planner.settings()).thenReturn(settings);
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
ArrayList<HydrologyTileKey> planned = new ArrayList<>();
doAnswer(invocation -> {
planned.add(invocation.getArgument(0));
return tile;
}).when(planner).plan(any(HydrologyTileKey.class));
HydrologyTileCache cache = new HydrologyTileCache(planner, 128, Runnable::run);
try (HydrologyTileCache.PregenerationScope ignored = cache.preparePregeneration(
new HydrologyTileCache.PregenerationArea(0, 0,
Integer.MIN_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MAX_VALUE))) {
assertEquals(prefetchRectangle(-2, 1, -2, 1), new HashSet<>(planned));
assertEquals(16, planned.size());
}
cache.close();
}
private static HydrologyTileCache.PregenerationArea pregenArea(int x, int z) {
return new HydrologyTileCache.PregenerationArea(x, z, (long) x - 1024, (long) z - 1024,
(long) x + 1024, (long) z + 1024);
}
private static HydrologyPlannerSettings stalePrefetchSettings() {
HydrologyPlannerSettings settings = mock(HydrologyPlannerSettings.class);
when(settings.routing()).thenReturn(new HydrologyPlannerSettings.Routing(
@@ -1100,7 +1256,7 @@ public class HydrologyTileCacheTest {
order.clear();
cache.setNeighbourPrefetchEnabled(false);
cache.preparePregeneration(0, 0);
cache.preparePregeneration(pregenArea(0, 0));
assertEquals(new HydrologyTileKey(1, -1), order.getFirst());
assertTrue(order.contains(new HydrologyTileKey(1, 0)));
@@ -35,14 +35,14 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
public class StudioHydrologyTileStoreTest {
public class PreparedHydrologyTileStoreTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void roundTripsValidatedEntryTile() throws Exception {
HydrologyTile original = tile();
StudioHydrologyTileStore store = store();
PreparedHydrologyTileStore store = store();
store.save(original);
HydrologyTile restored = store.load(original.key()).orElseThrow();
@@ -93,7 +93,7 @@ public class StudioHydrologyTileStoreTest {
HydrologyTile original = new HydrologyTile(new HydrologyTileKey(0, 0), 91L, 17L, 64,
List.of(), List.of(), List.of(), List.of(course), Set.of(), List.of(plan), List.of(),
new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column)));
StudioHydrologyTileStore store = store();
PreparedHydrologyTileStore store = store();
store.save(original);
HydrologyTile restored = store.load(original.key()).orElseThrow();
assertEquals(original, restored);
@@ -118,7 +118,7 @@ public class StudioHydrologyTileStoreTest {
@Test
public void entryRequiresRegionalOwnershipMetadata() throws Exception {
HydrologyTile original = tile();
StudioHydrologyTileStore store = store();
PreparedHydrologyTileStore store = store();
store.save(original);
Path file = store.file(original.key());
JsonObject persisted;
@@ -136,7 +136,7 @@ public class StudioHydrologyTileStoreTest {
@Test
public void corruptEntryFallsBackToCacheMiss() throws Exception {
HydrologyTile original = tile();
StudioHydrologyTileStore store = store();
PreparedHydrologyTileStore store = store();
store.save(original);
Path file = store.file(original.key());
Files.writeString(file, "invalid", StandardCharsets.UTF_8);
@@ -149,15 +149,28 @@ public class StudioHydrologyTileStoreTest {
Path root = temporaryFolder.newFolder().toPath();
HydrologyTileCache.SharedCacheScope scope = scope();
HydrologyTile original = tile();
StudioHydrologyTileStore matching = new StudioHydrologyTileStore(root, scope, 64);
PreparedHydrologyTileStore matching = new PreparedHydrologyTileStore(root, scope, 64);
matching.save(original);
StudioHydrologyTileStore mismatched = new StudioHydrologyTileStore(root, scope, 128);
PreparedHydrologyTileStore mismatched = new PreparedHydrologyTileStore(root, scope, 128);
assertTrue(mismatched.load(original.key()).isEmpty());
}
private StudioHydrologyTileStore store() throws Exception {
return new StudioHydrologyTileStore(
@Test
public void copiedTileCannotCrossRuntimeIdentityScopes() throws Exception {
Path root = temporaryFolder.newFolder().toPath();
PreparedHydrologyTileStore source = new PreparedHydrologyTileStore(root, scope(), 64);
PreparedHydrologyTileStore target = new PreparedHydrologyTileStore(root,
new HydrologyTileCache.SharedCacheScope("another-kernel", 91L, 384, "overworld", 17L), 64);
HydrologyTile tile = tile();
source.save(tile);
Files.createDirectories(target.file(tile.key()).getParent());
Files.copy(source.file(tile.key()), target.file(tile.key()));
assertTrue(target.load(tile.key()).isEmpty());
}
private PreparedHydrologyTileStore store() throws Exception {
return new PreparedHydrologyTileStore(
temporaryFolder.newFolder().toPath(),
scope(),
64
@@ -13,9 +13,11 @@ import art.arcane.iris.generation.hydrology.IrisRiverBedProfile;
import art.arcane.iris.generation.hydrology.IrisRiverBlendStyle;
import org.junit.Test;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.IntBinaryOperator;
import static org.junit.Assert.assertEquals;
@@ -31,6 +33,89 @@ public class ErosionFieldCompilerTest {
default -> request.minimum();
};
@Test
public void geometricExclusionAvoidsTerrainSamplesWithoutChangingAsymmetricBanks() {
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface();
AtomicInteger referenceSamples = new AtomicInteger();
AtomicInteger boundedSamples = new AtomicInteger();
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(z > 4 ? 100 : 80, 0D, "land");
ErosionField reference = rasterWithShoreBound(surface, (x, z) -> {
referenceSamples.incrementAndGet();
return terrain.sample(x, z);
}, 128D, null);
ErosionField bounded = rasterWithShoreBound(surface, (x, z) -> {
boundedSamples.incrementAndGet();
return terrain.sample(x, z);
}, surface.shoreWidth(), null);
assertEqualFields(reference, bounded);
assertNull(bounded.rejection());
assertTrue("sample counts " + boundedSamples.get() + "/" + referenceSamples.get(),
boundedSamples.get() < referenceSamples.get() * 0.8D);
assertNotNull(bounded.column(0, 18));
assertNull(bounded.column(0, -18));
}
@Test
public void geometricExclusionRetainsPolicyBandsAtExactAndAdjacentThresholds() {
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface();
for (double width : new double[]{Math.nextDown(1.75D), 1.75D, Math.nextUp(1.75D), 12D}) {
HydrologyTerrainSampler terrain = (x, z) -> land(80, z >= 0 ? width : 0D)
.withShoreWidth(z < 0 ? width : surface.shoreWidth());
double bound = Math.max(surface.shoreWidth(), width);
ErosionField reference = rasterWithShoreBound(surface, terrain, 128D, null);
ErosionField complete = rasterWithShoreBound(surface, terrain, bound, null);
assertEqualFields(reference, complete);
for (SurfaceBounds bounds : List.of(new SurfaceBounds(-16, -16, -1, 15),
new SurfaceBounds(0, -16, 15, 15))) {
ErosionField bounded = rasterWithShoreBound(surface, terrain, bound, bounds);
for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) {
for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) {
assertEquals("policy width " + width + " at " + x + "," + z,
complete.column(x, z), bounded.column(x, z));
}
}
}
if (width >= 1.75D) {
assertEquals(SurfaceRole.SHORE, complete.column(0, 5).role());
}
if (width == 12D) {
assertEquals(SurfaceRole.SHORE, complete.column(0, 15).role());
assertEquals(SurfaceRole.BANK, complete.column(0, -15).role());
}
}
}
private static ErosionField rasterWithShoreBound(HydrologyPlannerSettings.Surface surface,
HydrologyTerrainSampler terrain, double bound,
SurfaceBounds bounds) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(SEA_LEVEL, defaults.routing(), surface,
defaults.hydraulics(), defaults.underground(), defaults.outlets(), defaults.geometry(),
defaults.deepFluids(), defaults.surfacePools(), bound, defaults.seaCaves(), defaults.surfacePolicyBounds());
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(
new HydrologyPoint(-48, 80, 0), new HydrologyPoint(48, 80, 0)));
double[] widths = new double[centerline.size()];
double[] depths = new double[centerline.size()];
double[] multipliers = new double[centerline.size()];
int[] heads = new int[centerline.size()];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(multipliers, 1D);
Arrays.fill(heads, 80);
return new ErosionFieldCompiler(settings, terrain).compile(42L, centerline,
new ChannelProfile(widths, depths, multipliers), ValleyProfile.fromHeads(heads, heads.length),
SurfaceTerminal.SINKHOLE, 0, noPonds(), SurfaceRasterContext.bounded(bounds));
}
private static void assertEqualFields(ErosionField expected, ErosionField actual) {
assertEquals(expected.columns(), actual.columns());
assertEquals(expected.rejection(), actual.rejection());
assertEquals(expected.rejectionDetail(), actual.rejectionDetail());
assertEquals(expected.uncontainedWetCells(), actual.uncontainedWetCells());
assertEquals(expected.bankExcavation(), actual.bankExcavation());
}
@Test
public void mouthCutsDrySeaLevelSillBeforeReachingOceanWater() {
HydrologyTerrainSampler coast = (int x, int z) -> x >= 100
@@ -81,7 +166,7 @@ public class ErosionFieldCompilerTest {
SurfaceCenterline centerline = SurfaceCenterline.densify(List.of(new HydrologyPoint(-150, 0, 0), new HydrologyPoint(150, 0, 0)));
ChannelProfile channel = new ChannelProfileBuilder(surface, hillside, CONSTANT_GEOMETRY).build(centerline, "water", false);
ValleyProfile valley = new ValleyProfileSolver(surface, hillside, SEA_LEVEL, 64).solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40);
ErosionFieldCompiler compiler = new ErosionFieldCompiler(surface, hillside, SEA_LEVEL);
ErosionFieldCompiler compiler = new ErosionFieldCompiler(rasterSettings(surface), hillside);
ErosionField complete = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8);
SurfaceBounds bounds = new SurfaceBounds(-16, -16, 15, 15);
ErosionField bounded = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 8, surface.banks().ponds(), SurfaceRasterContext.bounded(bounds));
@@ -93,6 +178,44 @@ public class ErosionFieldCompilerTest {
}
}
@Test
public void boundedRasterRaisesBanksFromHigherWaterAcrossTheChunkEdge() {
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface();
HydrologyTerrainSampler lowGround = (x, z) -> HydrologyTerrainSample.openLand(77, 0D, "land");
SurfaceCenterline centerline = SurfaceCenterline.densify(
List.of(new HydrologyPoint(-48, 80, 0), new HydrologyPoint(48, 78, 0)));
int count = centerline.size();
double[] widths = new double[count];
double[] depths = new double[count];
double[] multipliers = new double[count];
int[] heads = new int[count];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(multipliers, 1D);
for (int station = 0; station < count; station++) {
heads[station] = centerline.x()[station] < 0 ? 80 : 78;
}
ChannelProfile channel = new ChannelProfile(widths, depths, multipliers);
ValleyProfile valley = ValleyProfile.fromHeads(heads, count);
ErosionFieldCompiler compiler = new ErosionFieldCompiler(rasterSettings(surface), lowGround);
ErosionField complete = compiler.compile(42L, centerline, channel, valley, SurfaceTerminal.SINKHOLE, 0);
assertNull(complete.rejection());
assertEquals(80, complete.column(0, 4).height());
for (SurfaceBounds bounds : List.of(new SurfaceBounds(-16, -16, -1, 15),
new SurfaceBounds(0, -16, 15, 15), new SurfaceBounds(0, 4, 15, 19))) {
ErosionField bounded = compiler.compile(42L, centerline, channel, valley,
SurfaceTerminal.SINKHOLE, 0, noPonds(), SurfaceRasterContext.bounded(bounds));
assertNull(bounded.rejection());
assertEquals(0, bounded.uncontainedWetCells());
for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) {
for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) {
assertEquals("bounded column " + x + "," + z, complete.column(x, z), bounded.column(x, z));
}
}
}
}
@Test
public void flatTerrainHoldsTheWaterFlushWithTheBankAndContainedByDefault() {
Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40);
@@ -320,6 +443,12 @@ public class ErosionFieldCompilerTest {
surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), banks);
}
private static HydrologyPlannerSettings.Surface withMaximumIncision(HydrologyPlannerSettings.Surface surface, int maximumIncision) {
return new HydrologyPlannerSettings.Surface(
surface.enabled(), surface.sources(), surface.minimumWidth(), surface.maximumWidth(),
surface.minimumDepth(), surface.maximumDepth(), maximumIncision, surface.shoreWidth(), surface.banks());
}
@Test
public void bankColumnsBeyondTheBlendAreNotWritten() {
Compiled compiled = compile(300, (x, z) -> 80, SurfaceTerminal.SINKHOLE, 40);
@@ -333,6 +462,14 @@ public class ErosionFieldCompilerTest {
}
}
private static void assertBoundedBankFill(Compiled compiled, int maximumFill) {
for (SurfaceColumn column : compiled.field().columns().values()) {
int fill = column.height() - column.terrain().naturalHeight();
assertTrue(column.x() + "," + column.z() + " fill " + fill,
fill <= (column.role() == SurfaceRole.CHANNEL ? 0 : maximumFill));
}
}
private static void assertChannelContained(Compiled compiled) {
for (SurfaceColumn column : compiled.field().columns().values()) {
if (column.role() != SurfaceRole.CHANNEL || column.apron()) {
@@ -376,7 +513,7 @@ public class ErosionFieldCompilerTest {
ValleyProfile valley = new ValleyProfileSolver(surface, sampler, SEA_LEVEL, 64)
.solve(centerline, channel, terminal, terminalHead);
assertNull(valley.rejection());
ErosionField field = new ErosionFieldCompiler(surface, sampler, SEA_LEVEL)
ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), sampler)
.compile(1234L, centerline, channel, valley, terminal, 8);
return new Compiled(field, valley);
}
@@ -624,7 +761,7 @@ public class ErosionFieldCompilerTest {
.solve(centerline, channel, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL);
assertNull(valley.rejection());
ErosionField field = new ErosionFieldCompiler(surface, sampler, SEA_LEVEL)
ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), sampler)
.compile(99L, centerline, channel, valley, SurfaceTerminal.OCEAN_MOUTH, 3);
int firstApron = Integer.MAX_VALUE;
@@ -754,10 +891,12 @@ public class ErosionFieldCompilerTest {
assertEquals(0, compiled.field().uncontainedWetCells());
assertChannelContained(compiled);
assertNoWriteAboveNatural(compiled);
assertBoundedBankFill(compiled, 8);
assertEquals(80, compiled.field().column(0, 0).headY());
SurfaceColumn nearRim = compiled.field().column(0, 4);
assertEquals(SurfaceRole.CHANNEL, nearRim.role());
assertTrue(compiled.field().columns().values().stream()
.anyMatch(column -> column.height() > column.terrain().naturalHeight()));
for (SurfaceColumn column : compiled.field().columns().values()) {
if (column.role() == SurfaceRole.CHANNEL) {
assertTrue(Math.abs(column.z()) <= 7);
@@ -805,19 +944,21 @@ public class ErosionFieldCompilerTest {
HydrologyTerrainSampler shelf = (int x, int z) ->
HydrologyTerrainSample.openLand(80 + Math.min(16, Math.max(0, Math.abs(z) - 4) * 4), 0D, "land");
Compiled terraced = compile(
zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D,
withMaximumIncision(zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D,
IrisRiverBlendStyle.TERRACED, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D,
new HydrologyPlannerSettings.Excavation(64, 64, 8192))),
new HydrologyPlannerSettings.Excavation(64, 64, 8192))), 32),
shelf, 300, SurfaceTerminal.SINKHOLE, 40);
assertNull(terraced.field().rejection());
Set<Integer> steps = bankHeights(terraced, 150, 9, 40);
assertTrue(steps.toString(), steps.size() >= 2 && steps.size() <= 4);
assertHolds(terraced);
Compiled cliff = compile(
zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D,
withMaximumIncision(zeroRoughnessSurface(0, new HydrologyPlannerSettings.Erosion(true, 12, 0.45D, 1D, 0.5D,
IrisRiverBlendStyle.CLIFF, 4, 0.5D, IrisRiverBedProfile.BOWL, 0D, 0D,
new HydrologyPlannerSettings.Excavation(64, 64, 8192))),
new HydrologyPlannerSettings.Excavation(64, 64, 8192))), 32),
shelf, 300, SurfaceTerminal.SINKHOLE, 40);
assertNull(cliff.field().rejection());
Set<Integer> bench = bankHeights(cliff, 150, 9, 40);
assertEquals(bench.toString(), 2, bench.size());
assertTrue(bench.contains(80));
@@ -873,7 +1014,43 @@ public class ErosionFieldCompilerTest {
assertTrue(column.height() <= hillside.sample(150, z).naturalHeight());
previous = column.height();
}
assertHolds(rising);
assertEquals(0, rising.field().uncontainedWetCells());
assertChannelContained(rising);
assertBoundedBankFill(rising, 8);
assertEquals(92, rising.field().column(150, -7).height());
assertEquals(90, rising.field().column(150, -7).terrain().naturalHeight());
}
@Test
public void zeroBankDepthBudgetLeavesLowBanksUnfilledAndUncontained() {
HydrologyPlannerSettings.Erosion erosion = new HydrologyPlannerSettings.Erosion(
true, 12, 0.45D, 1D, 0.5D, IrisRiverBlendStyle.SMOOTH, 4, 0.5D,
IrisRiverBedProfile.BOWL, 0D, 0D, new HydrologyPlannerSettings.Excavation(0, 16, 256));
HydrologyPlannerSettings.Surface surface = zeroRoughnessSurface(0, erosion);
HydrologyTerrainSampler lowGround = (x, z) -> HydrologyTerrainSample.openLand(77, 0D, "land");
SurfaceCenterline centerline = SurfaceCenterline.densify(
List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(16, 80, 0)));
int count = centerline.size();
double[] widths = new double[count];
double[] depths = new double[count];
double[] multipliers = new double[count];
int[] heads = new int[count];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(multipliers, 1D);
Arrays.fill(heads, 80);
ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), lowGround).compile(42L,
centerline, new ChannelProfile(widths, depths, multipliers), ValleyProfile.fromHeads(heads, count),
SurfaceTerminal.SINKHOLE, 0);
assertEquals(HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, field.rejection());
assertTrue(field.uncontainedWetCells() > 0);
assertEquals(77, field.column(8, 4).height());
for (SurfaceColumn column : field.columns().values()) {
if (column.role() != SurfaceRole.CHANNEL) {
assertEquals(column.terrain().naturalHeight(), column.height());
}
}
}
@Test
@@ -987,4 +1164,11 @@ public class ErosionFieldCompilerTest {
assertEquals(SurfaceRole.SHORE, bare.field().column(150, 4).role());
assertHolds(bare);
}
private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(),
defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), 12D, defaults.seaCaves(), defaults.surfacePolicyBounds());
}
}
@@ -0,0 +1,120 @@
package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
import org.junit.Test;
import java.util.Arrays;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class RiverFirstTerrainTest {
@Test
public void riverBuildsContainedBanksAroundALowNaturalPatch() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(77, 0D, "land");
ErosionField field = compile(sampler, null);
assertNull(field.rejection());
assertEquals(0, field.uncontainedWetCells());
SurfaceColumn bed = field.column(0, 0);
assertNotNull(bed);
assertEquals(bed.terrain().naturalHeight(), bed.height());
assertTrue(bed.height() < bed.headY());
int raisedBanks = 0;
for (SurfaceColumn column : field.columns().values()) {
if (column.role() != SurfaceRole.CHANNEL && column.height() > column.terrain().naturalHeight()) {
raisedBanks++;
assertTrue(column.height() - column.terrain().naturalHeight() <= 8);
}
}
assertTrue(raisedBanks > 0);
}
@Test
public void riverRejectsRequiredFillBeyondTheTerrainBudget() {
ErosionField field = compile((x, z) -> HydrologyTerrainSample.openLand(69, 0D, "land"), null);
assertEquals(HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, field.rejection());
}
@Test
public void unavailableBankCannotCertifyTheWetPerimeter() {
HydrologyTerrainSample ground = HydrologyTerrainSample.openLand(77, 0D, "land");
ErosionField complete = compile((x, z) -> ground, null);
assertNull(complete.rejection());
SurfaceColumn bank = complete.columns().values().stream()
.filter(column -> column.x() == 0 && column.role() != SurfaceRole.CHANNEL
&& complete.column(0, column.z() - 1) != null
&& complete.column(0, column.z() - 1).role() == SurfaceRole.CHANNEL)
.findFirst().orElseThrow();
ErosionField unavailable = compile((x, z) -> x == bank.x() && z == bank.z() ? null : ground, null);
assertEquals(HydrologyCandidateRejection.SURFACE_WATER_CONTAINMENT, unavailable.rejection());
assertTrue(unavailable.uncontainedWetCells() > 0);
}
@Test
public void fineChannelIncisionLimitIsCheckedBeforePublication() {
ErosionField field = compile((x, z) -> HydrologyTerrainSample.openLand(x == 0 && z == 1 ? 110 : 82, 0D, "land"), null);
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, field.rejection());
}
@Test
public void channelEdgesRetainWaterWithinTheLocalIncisionLimit() {
ErosionField field = compile((x, z) -> HydrologyTerrainSample.openLand(x == 0 && z == 1 ? 89 : 82, 0D, "land"), null);
assertNull(field.rejection());
SurfaceColumn column = field.column(0, 1);
assertEquals(SurfaceRole.CHANNEL, column.role());
assertEquals(79, column.height());
assertEquals(80, column.headY());
}
@Test
public void adjacentChunkRastersRetainIdenticalRaisedTerrain() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(77 + Math.floorMod(x + z, 3), 0D, "land");
ErosionField complete = compile(sampler, null);
assertNull(complete.rejection());
for (SurfaceBounds bounds : List.of(new SurfaceBounds(-16, -16, -1, 15),
new SurfaceBounds(0, -16, 15, 15))) {
ErosionField bounded = compile(sampler, bounds);
assertNull(bounded.rejection());
for (SurfaceColumn column : complete.columns().values()) {
if (bounds.contains(column.x(), column.z())) {
assertEquals(column, bounded.column(column.x(), column.z()));
}
}
}
}
private static ErosionField compile(HydrologyTerrainSampler sampler, SurfaceBounds bounds) {
HydrologyPlannerSettings.Surface settings = HydrologyPlannerSettings.defaults().surface();
SurfaceCenterline centerline = SurfaceCenterline.densify(
List.of(new HydrologyPoint(-48, 80, 0), new HydrologyPoint(48, 80, 0)));
int count = centerline.size();
double[] widths = new double[count];
double[] depths = new double[count];
double[] multipliers = new double[count];
int[] heads = new int[count];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(multipliers, 1D);
Arrays.fill(heads, 80);
return new ErosionFieldCompiler(rasterSettings(settings), sampler).compile(17L, centerline,
new ChannelProfile(widths, depths, multipliers), ValleyProfile.fromHeads(heads, count),
SurfaceTerminal.SINKHOLE, 0, HydrologyPlannerSettings.Ponds.none(), SurfaceRasterContext.bounded(bounds));
}
private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(),
defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), surface.shoreWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds());
}
}
@@ -0,0 +1,112 @@
package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydraulicSegment;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyGeometrySampler;
import art.arcane.iris.generation.hydrology.HydrologyPlannerSettings;
import art.arcane.iris.generation.hydrology.HydrologyPoint;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
import art.arcane.iris.generation.hydrology.HydrologyTerrainSampler;
import org.junit.Test;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class RiverProfileSolverTest {
private static final HydrologyGeometrySampler GEOMETRY = request -> switch (request.field()) {
case SURFACE_WIDTH -> 6;
case SURFACE_DEPTH -> 2;
default -> request.minimum();
};
@Test
public void detailedTerrainDoesNotReplacePlannedRiverElevations() {
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 80, 0));
for (int natural : List.of(78, 83, 84)) {
SurfaceCourseResult result = build(path, (x, z) -> HydrologyTerrainSample.openLand(natural, 0D, "land"));
assertNull(result.rejection());
assertEquals(80, result.lastHead());
for (HydraulicSegment segment : result.segments()) {
assertEquals(80, segment.upstreamHeadY());
assertEquals(80, segment.downstreamHeadY());
}
}
}
@Test
public void interpolatedSharedHeadsRemainNonRisingAcrossStations() {
List<HydrologyPoint> path = List.of(new HydrologyPoint(-96, 88, -16),
new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 72, 16));
SurfaceCourseResult result = build(path,
(x, z) -> HydrologyTerrainSample.openLand(82 - Math.floorDiv(x, 12), 0D, "land"));
assertNull(result.rejection());
int previous = Integer.MAX_VALUE;
for (HydraulicSegment segment : result.segments()) {
for (HydrologyPoint point : segment.centerline()) {
assertTrue(point.y() <= previous);
previous = point.y();
}
}
assertEquals(72, result.lastHead());
}
@Test
public void plannedProfileChecksTheCenterWithoutSearchingBankRings() {
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 80, 0));
SurfaceCenterline centerline = SurfaceCenterline.densify(path);
double[] widths = new double[centerline.size()];
double[] depths = new double[centerline.size()];
double[] banks = new double[centerline.size()];
Arrays.fill(widths, 3D);
Arrays.fill(depths, 2D);
Arrays.fill(banks, 1D);
AtomicInteger samples = new AtomicInteger();
HydrologyTerrainSampler sampler = (x, z) -> {
assertEquals(0, z);
samples.incrementAndGet();
return HydrologyTerrainSample.openLand(82, 0D, "land");
};
ValleyProfile profile = new RiverProfileSolver(
new RiverProfileSolver.Options(HydrologyPlannerSettings.defaults().surface(), 60), sampler)
.solve(path, centerline, new ChannelProfile(widths, depths, banks), SurfaceTerminal.SINKHOLE, 40, 32);
assertNull(profile.rejection());
assertEquals(centerline.size(), samples.get());
}
@Test
public void aFineCliffKeepsTheSourceHeadAndRaisesOnlyTheRequiredApproach() {
HydrologyTerrainSampler sampler = (x, z) -> HydrologyTerrainSample.openLand(x < 32 ? 102 : 86, 0D, "land");
SurfaceCourseResult result = build(List.of(new HydrologyPoint(0, 100, 0),
new HydrologyPoint(64, 84, 0)), sampler);
assertNull(result.rejection());
assertEquals(100, result.segments().getFirst().upstreamHeadY());
assertEquals(84, result.lastHead());
int previous = 100;
for (HydraulicSegment segment : result.segments()) {
for (HydrologyPoint point : segment.centerline()) {
assertTrue(point.y() <= previous);
assertTrue(sampler.sample(point.x(), point.z()).naturalHeight() - point.y() + 2
<= HydrologyPlannerSettings.defaults().surface().maximumIncision());
previous = point.y();
}
}
}
@Test
public void anUncuttableFineTerrainRidgeStillRejectsTheCourse() {
SurfaceCourseResult result = build(List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(96, 80, 0)),
(x, z) -> HydrologyTerrainSample.openLand(x == 48 ? 110 : 82, 0D, "land"));
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, result.rejection());
}
private static SurfaceCourseResult build(List<HydrologyPoint> path, HydrologyTerrainSampler sampler) {
return new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler, GEOMETRY, 60)
.build(7L, 19L, "default", path, SurfaceTerminal.SINKHOLE, 40, 32);
}
}
@@ -180,7 +180,7 @@ public class SurfaceBendProfileTest {
ValleyProfile valley = new ValleyProfileSolver(surface, FLAT, 60, 32)
.solve(centerline, channel, SurfaceTerminal.SINKHOLE, 40);
assertTrue(valley.accepted());
return new ErosionFieldCompiler(surface, FLAT, 60).compile(42L, centerline, channel, valley,
return new ErosionFieldCompiler(rasterSettings(surface), FLAT).compile(42L, centerline, channel, valley,
SurfaceTerminal.SINKHOLE, 8, HydrologyPlannerSettings.Ponds.none(), context);
}
@@ -191,4 +191,11 @@ public class SurfaceBendProfileTest {
HydrologyPlannerSettings.Inlet.none(), 2.5D, 24, true,
HydrologyPlannerSettings.Erosion.defaults(), HydrologyPlannerSettings.Ponds.none()));
}
private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(),
defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), surface.shoreWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds());
}
}
@@ -13,6 +13,7 @@ import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@@ -34,7 +35,8 @@ public class SurfaceCourseBuilderTest {
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(140, 0, 0), new HydrologyPoint(280, 0, 0)),
List.of(new HydrologyPoint(0, 110, 0), new HydrologyPoint(140, 87, 0),
new HydrologyPoint(260, 67, 0), new HydrologyPoint(280, SEA_LEVEL, 0)),
SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL,
64
@@ -72,7 +74,7 @@ public class SurfaceCourseBuilderTest {
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
List.of(new HydrologyPoint(0, 80, 0), new HydrologyPoint(200, 80, 0)),
SurfaceTerminal.SINKHOLE,
40,
64
@@ -85,6 +87,41 @@ public class SurfaceCourseBuilderTest {
assertEquals(201, result.segments().getFirst().centerline().size());
}
@Test
public void coastalDropKeepsTheUpstreamGradedReachExposed() {
HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface();
HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(
defaults.enabled(), defaults.sources(), defaults.minimumWidth(), defaults.maximumWidth(),
defaults.minimumDepth(), defaults.maximumDepth(), defaults.maximumIncision(), defaults.shoreWidth(),
defaults.banks().withInlet(HydrologyPlannerSettings.Inlet.none()));
HydrologyTerrainSampler sampler = (x, z) -> x >= 40
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(100 - x, 0D, "land");
SurfaceCourseBuilder builder = new SurfaceCourseBuilder(surface, sampler, CONSTANT_GEOMETRY, SEA_LEVEL);
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(40, SEA_LEVEL, 0));
SurfaceCourseResult result = builder.build(7L, COURSE_ID, "water", path,
SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 16);
assertNull(result.rejection());
assertEquals(2, result.segments().size());
HydraulicSegment approach = result.segments().getFirst();
HydraulicSegment drop = result.segments().getLast();
assertEquals(HydrologyFeatureType.CASCADE, approach.type());
assertFalse(approach.fallingFluid());
assertEquals(100, approach.upstreamHeadY());
assertEquals(61, approach.downstreamHeadY());
assertEquals(new HydrologyPoint(20, 80, 0), approach.centerline().get(20));
assertEquals(HydrologyFeatureType.RIFFLE, drop.type());
assertTrue(drop.fallingFluid());
assertTrue(drop.receivingPool());
assertEquals(List.of(new HydrologyPoint(39, 61, 0), new HydrologyPoint(40, 60, 0)), drop.centerline());
assertEquals(approach.channelProfile().widthAt(39), drop.channelProfile().widthAt(0), 0D);
assertEquals(approach.channelProfile().depthAt(39), drop.channelProfile().depthAt(0), 0D);
assertTrue(approach.id() != drop.id());
assertEquals(result, builder.build(7L, COURSE_ID, "water", path,
SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 16));
}
@Test
public void cliffProducesAWaterfallSegment() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land");
@@ -92,7 +129,8 @@ public class SurfaceCourseBuilderTest {
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 0),
new HydrologyPoint(100, 90, 0), new HydrologyPoint(200, 90, 0)),
SurfaceTerminal.SINKHOLE,
40,
64
@@ -106,14 +144,32 @@ public class SurfaceCourseBuilderTest {
assertEquals(HydrologyFeatureType.SURFACE_POOL, result.segments().get(2).type());
}
@Test
public void cliffIntoTheOceanUsesFallingFluidInsteadOfAnInlandSeaLevelChannel() {
HydrologyTerrainSampler sampler = (x, z) -> x >= 100
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(100, 0D, "land");
SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 97, 0), new HydrologyPoint(99, 97, 0),
new HydrologyPoint(100, SEA_LEVEL, 0)), SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64);
assertNull(result.rejection());
HydraulicSegment fall = result.segments().getLast();
assertEquals(HydrologyFeatureType.WATERFALL, fall.type());
assertTrue(fall.fallingFluid());
assertTrue(fall.receivingPool());
assertEquals(SEA_LEVEL, fall.downstreamHeadY());
assertEquals(100, fall.end().x());
}
@Test
public void gentleSlopeProducesRifflesAndSteepSlopeProducesCascades() {
HydrologyTerrainSampler gentle = (int x, int z) -> HydrologyTerrainSample.openLand(120 - x / 10, 0D, "land");
SurfaceCourseResult gentleResult = builder(gentle).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64);
List.of(new HydrologyPoint(0, 120, 0), new HydrologyPoint(200, 100, 0)), SurfaceTerminal.SINKHOLE, 40, 64);
HydrologyTerrainSampler steep = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land");
SurfaceCourseResult steepResult = builder(steep).build(7L, COURSE_ID, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)), SurfaceTerminal.SINKHOLE, 40, 64);
List.of(new HydrologyPoint(0, 300, 0), new HydrologyPoint(200, 100, 0)), SurfaceTerminal.SINKHOLE, 40, 64);
assertNull(gentleResult.rejection());
assertNull("detail=" + steepResult.rejectionDetail(), steepResult.rejection());
@@ -138,7 +194,7 @@ public class SurfaceCourseBuilderTest {
7L,
COURSE_ID,
"water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
List.of(new HydrologyPoint(0, 50, 0), new HydrologyPoint(200, 50, 0)),
SurfaceTerminal.OCEAN_MOUTH,
SEA_LEVEL,
64
@@ -153,7 +209,8 @@ public class SurfaceCourseBuilderTest {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 240
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(100 - x / 6, 0D, "land");
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(241, 0, 0));
List<HydrologyPoint> path = List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(239, 61, 0),
new HydrologyPoint(240, SEA_LEVEL, 0), new HydrologyPoint(241, SEA_LEVEL, 0));
HydrologyPlannerSettings.Surface defaults = HydrologyPlannerSettings.defaults().surface();
HydrologyPlannerSettings.Inlet inlet = defaults.banks().inlet();
SurfaceCourseResult result = builder(sampler).build(7L, COURSE_ID, "water", path, SurfaceTerminal.OCEAN_MOUTH, SEA_LEVEL, 64);
@@ -2,6 +2,7 @@ package art.arcane.iris.generation.hydrology.surface;
import art.arcane.iris.generation.hydrology.HydraulicChannelProfile;
import art.arcane.iris.generation.hydrology.HydraulicSegment;
import art.arcane.iris.generation.hydrology.HydrologyCandidateRejection;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
@@ -131,6 +132,33 @@ public class SurfaceFootprintCompilerTest {
assertNull(column(footprint, 160, 12));
}
@Test
public void plannedCoastalWaterfallRetainsAConnectedOceanReceiverWithoutCuttingTheCliffToSeaLevel() {
int seaLevel = HydrologyPlannerSettings.defaults().seaLevel();
HydrologyTerrainSampler sampler = (x, z) -> x >= 100
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(100, 0D, "land");
SurfaceCourseResult result = new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(),
sampler, CONSTANT_GEOMETRY, seaLevel).build(7L, 1L, "water",
List.of(new HydrologyPoint(0, 97, 0), new HydrologyPoint(99, 97, 0),
new HydrologyPoint(100, seaLevel, 0)), SurfaceTerminal.OCEAN_MOUTH, seaLevel, 64);
assertNull(result.rejection());
ArrayList<HydraulicSegment> segments = new ArrayList<>(result.segments());
segments.add(segment(77L, HydrologyFeatureType.MOUTH, seaLevel, seaLevel,
List.of(result.pathEnd())));
SurfaceFootprint footprint = compiler(sampler).compile(course(segments));
assertNull(footprint.rejection());
assertEquals(0, footprint.uncontainedWetCells());
assertTrue(footprint.columns().stream().anyMatch(column -> column.layer().channel()
&& column.layer().fluidOwned() && column.layer().fluidHeadY() == 97));
for (SurfaceLayerColumn column : footprint.columns()) {
if (!column.apron() && column.role() == SurfaceRole.CHANNEL) {
assertTrue(column.layer().bedY() >= 90);
}
}
}
@Test
public void mouthOverTheOceanPublishesOnlyANonOwningApron() {
HydrologyTerrainSampler sampler = (int x, int z) -> x >= 100
@@ -212,13 +240,101 @@ public class SurfaceFootprintCompilerTest {
}
}
@Test
public void inletAllowanceSurvivesAFallWithoutReachingOrdinaryUpstreamTerrain() {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
HydrologyPlannerSettings.Banks banks = defaults.surface().banks();
HydrologyPlannerSettings.Erosion erosion = banks.erosion();
HydrologyPlannerSettings.Erosion expandedVolume = new HydrologyPlannerSettings.Erosion(
erosion.enabled(), erosion.smoothingRadius(), erosion.thalwegFraction(), erosion.blendCurve(),
erosion.bedNoise(), erosion.style(), erosion.terraceSteps(), erosion.cliffFraction(), erosion.bedProfile(),
erosion.shoreRise(), erosion.blendBaseWidth(), new HydrologyPlannerSettings.Excavation(8, 16, 512));
HydrologyPlannerSettings.Banks expandedBanks = new HydrologyPlannerSettings.Banks(
banks.sink(), banks.blendSlope(), banks.minimumBlendWidth(), banks.maximumBlendWidth(), banks.roughness(),
banks.roughnessWavelength(), banks.cascadeRun(), banks.waterfallMinimumDrop(), banks.mouthFlareRatio(),
banks.inlet(), banks.springWidthRatio(), banks.springLength(), banks.exposeCutStrata(), expandedVolume,
banks.ponds(), banks.channel(), banks.flow());
HydrologyPlannerSettings.Surface originalSurface = defaults.surface();
HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(
originalSurface.enabled(), originalSurface.sources(), originalSurface.minimumWidth(), originalSurface.maximumWidth(),
originalSurface.minimumDepth(), originalSurface.maximumDepth(), originalSurface.maximumIncision(),
originalSurface.shoreWidth(), expandedBanks);
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(defaults.seaLevel(), defaults.routing(), surface,
defaults.hydraulics(), defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), defaults.widestShoreBiomeWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds());
int seaLevel = settings.seaLevel();
int upstreamHead = seaLevel + 22;
int downstreamHead = seaLevel + 12;
HydrologyTerrainSampler sampler = (x, z) -> x >= 200
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(x >= 120 && x <= 135 ? 100 : x < 151 ? 90 : 80, 0D, "land");
HydraulicSegment fall = new HydraulicSegment(2L, 1L, HydrologyFeatureType.WATERFALL,
upstreamHead, downstreamHead, 6, 3, true, true,
List.of(new HydrologyPoint(150, upstreamHead, 0), new HydrologyPoint(151, downstreamHead, 0)),
HydraulicChannelProfile.uniform(6, 3));
ArrayList<HydrologyPoint> ramp = new ArrayList<>();
for (int x = 180; x <= 192; x++) {
ramp.add(new HydrologyPoint(x, downstreamHead - (x - 180), 0));
}
RiverCourse course = course(List.of(
segment(1L, HydrologyFeatureType.SURFACE_POOL, upstreamHead, upstreamHead, points(0, 149, upstreamHead)),
fall,
segment(3L, HydrologyFeatureType.SURFACE_POOL, downstreamHead, downstreamHead, points(151, 180, downstreamHead)),
segment(4L, HydrologyFeatureType.CASCADE, downstreamHead, seaLevel, ramp),
segment(5L, HydrologyFeatureType.SURFACE_POOL, seaLevel, seaLevel, points(192, 199, seaLevel)),
segment(6L, HydrologyFeatureType.MOUTH, seaLevel, seaLevel, points(199, 201, seaLevel))));
SurfaceFootprintCompiler compiler = new SurfaceFootprintCompiler(settings, sampler, CONSTANT_GEOMETRY);
SurfaceFootprint full = compiler.compile(course);
assertNull("detail=" + full.rejectionDetail(), full.rejection());
assertEquals(0, full.uncontainedWetCells());
for (int x : new int[]{130, 195}) {
SurfaceLayerColumn channel = column(full, x, 0);
assertNotNull(channel);
assertTrue(channel.layer().channel());
assertEquals(x == 130 ? upstreamHead : seaLevel, channel.layer().fluidHeadY());
int cut = channel.terrain().naturalHeight() - channel.layer().bedY();
assertTrue(cut > settings.surface().maximumIncision());
assertTrue(cut <= settings.surface().banks().inlet().maximumIncision());
}
for (SurfaceBounds bounds : List.of(new SurfaceBounds(112, -8, 159, 8), new SurfaceBounds(144, -8, 199, 8))) {
SurfaceFootprint bounded = compiler.compile(course, bounds);
assertNull(bounded.rejection());
assertEquals(full.columns().stream().filter(value -> bounds.contains(value.x(), value.z())).toList(),
bounded.columns());
}
HydrologyTerrainSampler upstreamRidge = (x, z) -> x == 60
? HydrologyTerrainSample.openLand(100, 0D, "land") : sampler.sample(x, z);
SurfaceFootprint rejected = new SurfaceFootprintCompiler(settings, upstreamRidge, CONSTANT_GEOMETRY).compile(course);
assertEquals(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, rejected.rejection());
}
@Test
public void standaloneMouthKeepsItsInletBudgetWithinTheDryCourseFraction() {
int seaLevel = HydrologyPlannerSettings.defaults().seaLevel();
for (int coast : new int[]{8, 100}) {
int ridgeX = Math.max(1, coast / 10);
HydrologyTerrainSampler sampler = (x, z) -> x >= coast
? HydrologyTerrainSample.ocean(50, "ocean")
: HydrologyTerrainSample.openLand(x == ridgeX ? 85 : 70, 0D, "land");
RiverCourse mouth = course(List.of(segment(1L, HydrologyFeatureType.MOUTH,
seaLevel, seaLevel, points(0, coast + 1, seaLevel))));
SurfaceFootprint rejected = compiler(sampler).compile(mouth);
assertEquals("coast=" + coast, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED,
rejected.rejection());
}
}
@Test
public void aContainedFallRetainsBothExposedRunsAndWholeCourseStationIndices() {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(x < 100 ? 100 : 90, 0D, "land");
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL)
.build(7L, 1L, "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.TRIBUTARY, 40, 64);
.build(7L, 1L, "water", List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 0),
new HydrologyPoint(100, 90, 0), new HydrologyPoint(200, 90, 0)),
SurfaceTerminal.TRIBUTARY, 90, 64);
assertNull(built.rejection());
RiverCourse course = course(built.segments());
HydraulicSegment fall = course.segments().stream().filter(HydraulicSegment::fallingFluid).findFirst().orElseThrow();
@@ -246,7 +362,13 @@ public class SurfaceFootprintCompilerTest {
assertEquals(full.columns().stream().filter(value -> bounds.contains(value.x(), value.z())).toList(), bounded.columns());
for (SurfaceLayerColumn value : full.columns()) {
if (value.layer().terrainOwned()) {
assertTrue(value.layer().bedY() <= value.terrain().naturalHeight());
int fill = value.layer().bedY() - value.terrain().naturalHeight();
if (value.layer().channel()) {
assertTrue(fill <= 0);
} else {
assertTrue(fill <= settings.surface().maximumIncision());
assertTrue(fill <= settings.surface().banks().erosion().excavation().maximumDepth());
}
}
}
}
@@ -256,8 +378,8 @@ public class SurfaceFootprintCompilerTest {
HydrologyTerrainSampler sampler = (int x, int z) -> HydrologyTerrainSample.openLand(300 - x, 0D, "land");
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL)
.build(7L, 1L, "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 0)),
SurfaceTerminal.TRIBUTARY, 40, 64);
.build(7L, 1L, "water", List.of(new HydrologyPoint(0, 300, 0), new HydrologyPoint(200, 100, 0)),
SurfaceTerminal.TRIBUTARY, 100, 64);
assertNull("detail=" + built.rejectionDetail(), built.rejection());
SurfaceFootprint footprint = compiler(sampler).compile(course(built.segments()));
assertNull("detail=" + footprint.rejectionDetail(), footprint.rejection());
@@ -276,8 +398,9 @@ public class SurfaceFootprintCompilerTest {
x + z < 200 ? 100 : 86, 0D, "land");
HydrologyPlannerSettings settings = HydrologyPlannerSettings.defaults();
SurfaceCourseResult built = new SurfaceCourseBuilder(settings.surface(), sampler, CONSTANT_GEOMETRY, SEA_LEVEL)
.build(7L, 1L, "water", List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(200, 0, 200)),
SurfaceTerminal.TRIBUTARY, 40, 64);
.build(7L, 1L, "water", List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 99),
new HydrologyPoint(100, 86, 100), new HydrologyPoint(200, 86, 200)),
SurfaceTerminal.TRIBUTARY, 86, 64);
assertNull(built.rejection());
RiverCourse course = course(built.segments());
assertTrue(course.segments().stream().anyMatch(segment -> segment.fallingFluid() && segment.drop() == 14));
@@ -311,8 +434,9 @@ public class SurfaceFootprintCompilerTest {
: HydrologyTerrainSample.openLand((x < 100 ? 100 : 90) + Math.abs(z) / 3, 0D, "land");
SurfaceCourseResult built = new SurfaceCourseBuilder(HydrologyPlannerSettings.defaults().surface(), sampler,
CONSTANT_GEOMETRY, SEA_LEVEL).build(7L, 1L, "water",
List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(180, 0, 0)),
SurfaceTerminal.TRIBUTARY, 40, 64);
List.of(new HydrologyPoint(0, 100, 0), new HydrologyPoint(99, 100, 0),
new HydrologyPoint(100, 90, 0), new HydrologyPoint(180, 90, 0)),
SurfaceTerminal.TRIBUTARY, 90, 64);
assertNull(built.rejection());
RiverCourse falling = course(built.segments());
RiverCourse mouth = course(List.of(segment(1L, HydrologyFeatureType.MOUTH, SEA_LEVEL, SEA_LEVEL,
@@ -186,7 +186,7 @@ public class ValleyProfileSolverTest {
assertNull(valley.rejection());
assertEquals(100, valley.head()[130]);
assertEquals(98, valley.head()[149]);
ErosionField field = new ErosionFieldCompiler(HydrologyPlannerSettings.defaults().surface(), terrain, SEA_LEVEL)
ErosionField field = new ErosionFieldCompiler(rasterSettings(HydrologyPlannerSettings.defaults().surface()), terrain)
.compile(77L, centerline, channel, valley, SurfaceTerminal.TRIBUTARY, 0,
HydrologyPlannerSettings.Ponds.none());
assertEquals(0, field.uncontainedWetCells());
@@ -214,7 +214,7 @@ public class ValleyProfileSolverTest {
assertEquals(120, valley.head()[110]);
assertEquals(118, valley.head()[128]);
assertContained(valley);
ErosionField field = new ErosionFieldCompiler(surface, terrain, SEA_LEVEL)
ErosionField field = new ErosionFieldCompiler(rasterSettings(surface), terrain)
.compile(77L, centerline, channel, valley, SurfaceTerminal.TRIBUTARY, 0,
HydrologyPlannerSettings.Ponds.none());
assertEquals(0, field.uncontainedWetCells());
@@ -231,7 +231,7 @@ public class ValleyProfileSolverTest {
assertNull(valley.rejection());
assertTrue(valley.exposedStations() < 240);
assertEquals(92, valley.head()[valley.exposedStations() - 1]);
ErosionField field = new ErosionFieldCompiler(HydrologyPlannerSettings.defaults().surface(), terrain, SEA_LEVEL)
ErosionField field = new ErosionFieldCompiler(rasterSettings(HydrologyPlannerSettings.defaults().surface()), terrain)
.compile(77L, centerline, channel, valley, SurfaceTerminal.COASTAL_GROTTO, 0,
HydrologyPlannerSettings.Ponds.none());
assertEquals(0, field.uncontainedWetCells());
@@ -492,4 +492,11 @@ public class ValleyProfileSolverTest {
private static List<HydrologyPoint> path(int stations) {
return List.of(new HydrologyPoint(0, 0, 0), new HydrologyPoint(stations - 1, 0, 0));
}
private static HydrologyPlannerSettings rasterSettings(HydrologyPlannerSettings.Surface surface) {
HydrologyPlannerSettings defaults = HydrologyPlannerSettings.defaults();
return new HydrologyPlannerSettings(60, defaults.routing(), surface, defaults.hydraulics(),
defaults.underground(), defaults.outlets(), defaults.geometry(), defaults.deepFluids(),
defaults.surfacePools(), surface.shoreWidth(), defaults.seaCaves(), defaults.surfacePolicyBounds());
}
}
@@ -261,7 +261,7 @@ public class MantleObjectComponentBoundaryRadiusTest {
@Test
@SuppressWarnings("unchecked")
public void translatedTreeSchedulesDistantOwnerChunks() throws Exception {
public void translatedTreeCachesDistantSourcesAndSchedulesOnlyItsDestination() throws Exception {
File objectFile = temporaryFolder.newFile("tree.iob");
try (DataOutputStream output = new DataOutputStream(new FileOutputStream(objectFile))) {
output.writeInt(1);
@@ -293,8 +293,8 @@ public class MantleObjectComponentBoundaryRadiusTest {
MantleObjectComponent component = spy(new MantleObjectComponent(engineMantle));
assertEquals(33, component.getRadius());
assertEquals(33, component.getOutputRadius());
assertEquals(0, component.getInputRadius());
assertEquals(0, component.getOutputRadius());
assertEquals(82, component.getInputRadius());
Mantle<Matter> mantle = mock(Mantle.class);
MantleChunk<Matter> chunk = mock(MantleChunk.class);
@@ -322,9 +322,9 @@ public class MantleObjectComponentBoundaryRadiusTest {
component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class));
assertEquals(1, directOrigins.get());
assertEquals(49, directOrigins.get());
component.generateLayer(directWriter, 0, 0, mock(ChunkContext.class));
assertEquals(2, directOrigins.get());
assertEquals(49, directOrigins.get());
List<String> generatedChunks = new ArrayList<>();
doAnswer(invocation -> {
@@ -342,7 +342,7 @@ public class MantleObjectComponentBoundaryRadiusTest {
));
generator.generateMatter(0, 0, false, mock(ChunkContext.class));
assertEquals(49, generatedChunks.size());
assertEquals(List.of("0,0"), generatedChunks);
List<String> replayedSources = new ArrayList<>();
MantleObjectComponent.replaySourceChunks(
@@ -0,0 +1,207 @@
package art.arcane.iris.generation.mantle;
import art.arcane.iris.generation.context.ChunkContext;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import art.arcane.volmlib.util.matter.Matter;
import org.junit.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class MantleObjectComponentPlacementDeterminismTest {
private static final Position LEFT = new Position(15, 10, 0);
private static final Position RIGHT = new Position(16, 10, 0);
private static final Position SUPPORT = new Position(15, 11, 0);
@Test
public void ordinaryObjectsKeepPrerequisitesAndSameSourceReadsAcrossDestinationOrders() throws Exception {
Fixture forward = new Fixture();
forward.generate(List.of(0, 1));
Fixture reverse = new Fixture();
reverse.generate(List.of(1, 0));
Fixture parallel = new Fixture();
parallel.generateParallel();
Map<Position, String> expected = Map.of(
LEFT, "minecraft:mangrove_wood",
RIGHT, "minecraft:mangrove_wood",
SUPPORT, "minecraft:mangrove_wood"
);
assertEquals(expected, forward.snapshot());
assertEquals(expected, reverse.snapshot());
assertEquals(expected, parallel.snapshot());
assertEquals(12, parallel.sourceCalls.size());
for (Integer calls : parallel.sourceCalls.values()) {
assertEquals(1, calls.intValue());
}
}
@Test
public void ordinaryObjectSourcePlansRemainReusableAfterNeighborPublicationAndHotload() {
Fixture fixture = new Fixture();
fixture.generate(List.of(1, 0));
Map<Position, String> expected = fixture.snapshot();
Map<Chunk, Integer> calls = Map.copyOf(fixture.sourceCalls);
fixture.generate(List.of(0, 1));
assertEquals(expected, fixture.snapshot());
assertEquals(calls, fixture.sourceCalls);
fixture.component.hotload();
fixture.generate(List.of(0, 1));
assertEquals(expected, fixture.snapshot());
for (Map.Entry<Chunk, Integer> entry : fixture.sourceCalls.entrySet()) {
assertEquals(2, entry.getValue().intValue());
}
}
private static final class Fixture {
private final NativeBlockState stone = state("minecraft:stone");
private final NativeBlockState oak = state("minecraft:oak_wood");
private final NativeBlockState mangrove = state("minecraft:mangrove_wood");
private final NativeBlockState wrongRead = state("minecraft:dirt");
private final Map<Position, NativeBlockState> published = new ConcurrentHashMap<>();
private final Map<Chunk, Integer> sourceCalls = new ConcurrentHashMap<>();
private final ThreadLocal<Chunk> destination = new ThreadLocal<>();
private final MantleWriter writer;
private final MantleObjectComponent component;
private final ChunkContext context = mock(ChunkContext.class);
@SuppressWarnings("unchecked")
private Fixture() {
IrisDimension dimension = mock(IrisDimension.class);
when(dimension.getAllRegions(any())).thenReturn(new KList<>());
when(dimension.getReachableBiomes(any())).thenReturn(new KList<>());
Engine engine = mock(Engine.class);
when(engine.getDimension()).thenReturn(dimension);
EngineMantle engineMantle = mock(EngineMantle.class);
when(engineMantle.getEngine()).thenReturn(engine);
Mantle<Matter> mantle = mock(Mantle.class);
when(mantle.getWorldHeight()).thenReturn(64);
writer = mock(MantleWriter.class);
when(writer.getMantle()).thenReturn(mantle);
when(writer.getEngine()).thenReturn(engine);
when(writer.getPrerequisiteBlock(anyInt(), anyInt(), anyInt())).thenReturn(stone);
when(writer.get(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> published.getOrDefault(
new Position(invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)),
stone));
doAnswer(invocation -> {
destination.set(new Chunk(invocation.getArgument(0), invocation.getArgument(1)));
try {
Runnable task = invocation.getArgument(2);
task.run();
} finally {
destination.remove();
}
return null;
}).when(writer).withChunkFence(anyInt(), anyInt(), any(Runnable.class));
doAnswer(invocation -> {
Position position = new Position(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2));
NativeBlockState value = invocation.getArgument(3);
assertEquals(new Chunk(position.x() >> 4, position.z() >> 4), destination.get());
published.put(position, value);
return null;
}).when(writer).setData(anyInt(), anyInt(), anyInt(), any(NativeBlockState.class));
component = new OrdinaryObjects(engineMantle, this);
}
private void generate(List<Integer> destinations) {
for (Integer chunkX : destinations) {
component.generateLayer(writer, chunkX, 0, context);
}
}
private void generateParallel() throws Exception {
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService executor = Executors.newFixedThreadPool(2)) {
Future<?> left = executor.submit(() -> generateAfterBarrier(0, ready, start));
Future<?> right = executor.submit(() -> generateAfterBarrier(1, ready, start));
assertTrue(ready.await(5L, TimeUnit.SECONDS));
start.countDown();
left.get(5L, TimeUnit.SECONDS);
right.get(5L, TimeUnit.SECONDS);
}
}
private void generateAfterBarrier(int chunkX, CountDownLatch ready, CountDownLatch start) {
ready.countDown();
try {
assertTrue(start.await(5L, TimeUnit.SECONDS));
} catch (InterruptedException failure) {
Thread.currentThread().interrupt();
throw new IllegalStateException(failure);
}
component.generateLayer(writer, chunkX, 0, context);
}
private Map<Position, String> snapshot() {
Map<Position, String> result = new HashMap<>();
for (Map.Entry<Position, NativeBlockState> entry : published.entrySet()) {
result.put(entry.getKey(), entry.getValue().key());
}
return result;
}
private static NativeBlockState state(String key) {
NativeBlockState state = mock(NativeBlockState.class);
when(state.key()).thenReturn(key);
return state;
}
}
private static final class OrdinaryObjects extends MantleObjectComponent {
private final Fixture fixture;
private OrdinaryObjects(EngineMantle engineMantle, Fixture fixture) {
super(engineMantle);
this.fixture = fixture;
}
@Override
protected int computeRadius() {
return 16;
}
@Override
void generateOrigin(ObjectPassPlacer placer, int chunkX, int chunkZ, ChunkContext context) {
fixture.sourceCalls.merge(new Chunk(chunkX, chunkZ), 1, Integer::sum);
if (chunkZ != 0 || chunkX < 0 || chunkX > 1) {
return;
}
NativeBlockState prerequisite = placer.get(LEFT.x(), LEFT.y(), LEFT.z());
NativeBlockState wood = chunkX == 0 ? fixture.oak : fixture.mangrove;
placer.set(SUPPORT.x(), SUPPORT.y(), SUPPORT.z(), wood);
boolean sameSourceSupport = placer.get(SUPPORT.x(), SUPPORT.y(), SUPPORT.z()) == wood;
NativeBlockState placed = prerequisite == fixture.stone && sameSourceSupport ? wood : fixture.wrongRead;
placer.set(LEFT.x(), LEFT.y(), LEFT.z(), placed);
placer.set(RIGHT.x(), RIGHT.y(), RIGHT.z(), placed);
}
}
private record Position(int x, int y, int z) {
}
private record Chunk(int x, int z) {
}
}
@@ -93,6 +93,35 @@ public class ObjectDestinationTransactionTest {
verify(writer).setData(1, 4, 0, source);
}
@Test
public void applyingSourcePlanSkipsForeignChunksBeforeOverlayingAndPreservesLocalOrder() {
MantleWriter writer = writer();
Marker foreign = new Marker("foreign");
Marker prerequisite = new Marker("prerequisite");
Marker first = new Marker("first");
Marker second = new Marker("second");
when(writer.getPrerequisiteDataIfPresent(0, 4, 0, Marker.class)).thenReturn(prerequisite);
ObjectDestinationTransaction source = new ObjectDestinationTransaction(writer, 0, 0);
source.setData(-1, 4, 0, first);
source.setData(0, 4, 0, foreign);
source.setData(-1, 4, 0, second);
source.setData(-1, 4, 16, foreign);
ObjectDestinationTransaction destination = new ObjectDestinationTransaction(writer, -1, 0);
destination.apply(source.sourcePlanSince(0));
assertEquals(2, destination.mutationCheckpoint());
assertSame(second, destination.getDataIfPresent(-1, 4, 0, Marker.class));
assertSame(prerequisite, destination.getDataIfPresent(0, 4, 0, Marker.class));
assertNull(destination.getDataIfPresent(-1, 4, 16, Marker.class));
destination.commit();
InOrder order = inOrder(writer);
order.verify(writer).setData(-1, 4, 0, first);
order.verify(writer).setData(-1, 4, 0, second);
verify(writer, never()).setData(0, 4, 0, foreign);
verify(writer, never()).setData(-1, 4, 16, foreign);
}
@Test
public void sourcePlanRejectsAnInvalidCheckpoint() {
ObjectDestinationTransaction transaction = new ObjectDestinationTransaction(writer(), 0, 0);
@@ -3,11 +3,15 @@ package art.arcane.iris.generation.runtime;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.noise.IrisGenerator;
import art.arcane.iris.generation.noise.IrisInterpolator;
import art.arcane.iris.generation.stream.CachedStream2D;
import art.arcane.iris.spi.IrisServices;
import art.arcane.volmlib.util.interpolation.InterpolationMethod;
import art.arcane.volmlib.util.interpolation.NoiseBounds;
import art.arcane.volmlib.util.interpolation.NoiseBoundsProvider;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
@@ -15,6 +19,8 @@ import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.IdentityHashMap;
import java.util.Map;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.assertEquals;
@@ -23,8 +29,69 @@ import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.mockingDetails;
public class IrisComplexBoundsBiomeCacheTest {
@Test
public void cachedBiomeColumnsReuseFractionalSamplesWithSignedParity() throws Exception {
IrisBiome first = new IrisBiome();
IrisBiome second = new IrisBiome();
ProceduralStream<IrisBiome> source = ProceduralStream.of(
(x, z) -> x.intValue() < 0 || z.intValue() < 0 ? first : second,
Interpolated.of(value -> 0D, value -> first));
try (MockedStatic<IrisServices> services = mockStatic(IrisServices.class)) {
services.when(() -> IrisServices.get(PreservationRegistry.class))
.thenReturn(mock(PreservationRegistry.class));
CachedStream2D<IrisBiome> stream = spy(new CachedStream2D<>("bounds", mock(Engine.class), source, 16));
Fixture fixture = new Fixture(stream);
IrisInterpolator interpolator = new IrisInterpolator()
.setFunction(InterpolationMethod.STARCAST_12).setHorizontalScale(1.25D);
fixture.setBounds(interpolator, first, -15.25D, 63.5D);
fixture.setBounds(interpolator, second, 7.75D, 127.25D);
AtomicInteger requestedSamples = new AtomicInteger();
Set<Long> columns = new HashSet<>();
for (int z = -2; z <= 2; z++) {
for (int x = -2; x <= 2; x++) {
NoiseBounds expected = interpolator.interpolateBounds(x, z, (xx, zz) -> {
requestedSamples.incrementAndGet();
columns.add(((long) (int) xx << 32) | ((int) zz & 0xffffffffL));
return (int) xx < 0 || (int) zz < 0
? new NoiseBounds(-15.25D, 63.5D) : new NoiseBounds(7.75D, 127.25D);
});
assertBits(expected, fixture.sample(interpolator, x, z));
}
}
long reads = mockingDetails(stream).getInvocations().stream()
.filter(invocation -> invocation.getMethod().getName().equals("get"))
.count();
assertTrue(reads <= columns.size() * 2L);
assertTrue(reads < requestedSamples.get() / 4L);
}
}
@Test
public void uncachedBiomeStreamsPreserveFractionalSamplesWithinOneColumn() throws Exception {
IrisBiome first = new IrisBiome();
IrisBiome second = new IrisBiome();
ProceduralStream<IrisBiome> source = ProceduralStream.of(
(x, z) -> x < -0.5D || z > 0.5D ? first : second,
Interpolated.of(value -> 0D, value -> first));
Fixture fixture = new Fixture(source);
IrisInterpolator sampling = new IrisInterpolator() {
@Override
public NoiseBounds interpolateBounds(double x, double z, NoiseBoundsProvider provider) {
return provider.noise(x, z);
}
};
fixture.setBounds(sampling, first, -10D, 20D);
fixture.setBounds(sampling, second, 50D, 80D);
assertBits(new NoiseBounds(-10D, 20D), fixture.sample(sampling, -0.75D, 0.25D));
assertBits(new NoiseBounds(50D, 80D), fixture.sample(sampling, -0.25D, 0.25D));
assertBits(new NoiseBounds(-10D, 20D), fixture.sample(sampling, -0.25D, 0.75D));
}
@Test
public void adjacentPassesReuseSamplesWithoutChangingInterpolationBits() throws Exception {
IrisBiome first = new IrisBiome();
@@ -841,7 +841,9 @@ public class IrisEngineGenerationRuntimeScopeTest {
private static IrisBiome carvingBiome(IrisEngine engine, IrisDimensionCarvingResolver.State state) {
IrisDimensionCarvingEntry root = IrisDimensionCarvingResolver.resolveRootEntry(engine, 80, state);
IrisDimensionCarvingEntry child = IrisDimensionCarvingResolver.resolveFromRoot(engine, root, 19, -3, state);
return IrisDimensionCarvingResolver.resolveEntryBiome(engine, child, state);
IrisBiome expected = IrisDimensionCarvingResolver.resolveEntryBiome(engine, child, state);
assertSame(expected, IrisDimensionCarvingResolver.resolveBiome(engine, 19, 80, -3, state));
return expected;
}
private static GenerationHistoryRuntimeRouter historyRouter(IrisEngine engine) {
@@ -0,0 +1,171 @@
package art.arcane.iris.generation.runtime;
import art.arcane.iris.world.history.GenerationKernelRegistry;
import art.arcane.iris.world.history.TransitionGenerationPlan;
import org.junit.Test;
import org.junit.Rule;
import org.junit.ClassRule;
import org.junit.rules.TemporaryFolder;
import org.mockito.MockedStatic;
import art.arcane.iris.pack.PackValidationCache;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.testsupport.PlatformBinding;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.studio.StudioMode;
import static org.junit.Assert.assertNotNull;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.mockito.Mockito.mockStatic;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class PreparedHydrologyCacheIdentityTest {
@ClassRule
public static final PlatformBinding PLATFORM = PlatformBinding.mockPlatform();
@Rule
public TemporaryFolder folder = new TemporaryFolder();
@Test
public void frozenStudioAndProductionCopiesShareUntilPackBytesChange() throws Exception {
Path studio = folder.newFolder("studio").toPath();
Path production = folder.newFolder("production").toPath();
Files.writeString(studio.resolve("terrain.json"), "{\"height\":80}");
Files.copy(studio.resolve("terrain.json"), production.resolve("terrain.json"));
GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation");
try (MockedStatic<PackValidationCache> validation = mockStatic(PackValidationCache.class)) {
validation.when(PackValidationCache::contextFingerprint).thenReturn("platform");
PreparedHydrologyCacheIdentity prepared = PreparedHydrologyCacheIdentity.capture(target(studio), kernel, null, true);
assertEquals(prepared, PreparedHydrologyCacheIdentity.capture(target(production), kernel, null, false));
Files.writeString(production.resolve("terrain.json"), "{\"height\":81}");
assertNotEquals(prepared, PreparedHydrologyCacheIdentity.capture(target(production), kernel, null, false));
}
}
@Test
public void biomeBuffetLayoutsAreIsolatedWhileNormalStudioSharesProduction() throws Exception {
Path pack = folder.newFolder("layout").toPath();
Files.writeString(pack.resolve("terrain.json"), "{}");
EngineTarget target = target(pack);
GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation");
when(IrisPlatforms.get().platformName()).thenReturn("bukkit");
try (MockedStatic<PackValidationCache> validation = mockStatic(PackValidationCache.class)) {
validation.when(PackValidationCache::contextFingerprint).thenReturn("platform");
PreparedHydrologyCacheIdentity normal = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false);
assertNotNull(normal);
assertEquals(normal, PreparedHydrologyCacheIdentity.capture(target, kernel, null, true));
when(target.getDimension().getStudioMode()).thenReturn(StudioMode.BIOME_BUFFET_3x3);
PreparedHydrologyCacheIdentity buffet = PreparedHydrologyCacheIdentity.capture(target, kernel, null, true);
assertNotEquals(normal, buffet);
assertEquals(normal, PreparedHydrologyCacheIdentity.capture(target, kernel, null, false));
when(target.getDimension().getStudioMode()).thenReturn(StudioMode.BIOME_BUFFET_9x9);
assertNotEquals(buffet, PreparedHydrologyCacheIdentity.capture(target, kernel, null, true));
}
}
@Test
public void visibleFallbackPackBytesAndNamesSeparatePlans() throws Exception {
Path targetPack = folder.newFolder("target").toPath();
Path packs = folder.newFolder("packs").toPath();
Path dependency = Files.createDirectory(packs.resolve("shared"));
Path content = dependency.resolve("terrain.json");
Files.writeString(content, "{\"height\":80}");
EngineTarget target = target(targetPack);
when(target.getData().hasGenerationRegistryContract()).thenReturn(false);
when(IrisPlatforms.get().packsFolderNoCreate()).thenReturn(packs.toFile());
GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation");
try (MockedStatic<PackValidationCache> validation = mockStatic(PackValidationCache.class)) {
validation.when(PackValidationCache::contextFingerprint).thenReturn("platform");
PreparedHydrologyCacheIdentity initial = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false);
assertNotNull(initial);
Files.writeString(content, "{\"height\":81}");
PreparedHydrologyCacheIdentity changed = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false);
assertNotNull(changed);
assertNotEquals(initial, changed);
Files.move(dependency, packs.resolve("renamed"));
PreparedHydrologyCacheIdentity renamed = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false);
assertNotNull(renamed);
assertNotEquals(changed, renamed);
Path hidden = Files.createDirectory(packs.resolve(".hidden"));
Files.writeString(hidden.resolve("terrain.json"), "{\"height\":90}");
assertEquals(renamed, PreparedHydrologyCacheIdentity.capture(target, kernel, null, false));
}
}
@Test
public void visiblePackSymlinksUseLogicalNamesAndResolvedContent() throws Exception {
Path targetPack = folder.newFolder("symlink-target").toPath();
Path first = folder.newFolder("first-packs").toPath();
Path second = folder.newFolder("second-packs").toPath();
Path dependency = Files.createDirectory(first.resolve("shared"));
Files.writeString(dependency.resolve("terrain.json"), "{}");
Files.createSymbolicLink(second.resolve("shared"), dependency);
EngineTarget target = target(targetPack);
when(target.getData().hasGenerationRegistryContract()).thenReturn(false);
GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation");
try (MockedStatic<PackValidationCache> validation = mockStatic(PackValidationCache.class)) {
validation.when(PackValidationCache::contextFingerprint).thenReturn("platform");
when(IrisPlatforms.get().packsFolderNoCreate()).thenReturn(first.toFile());
PreparedHydrologyCacheIdentity direct = PreparedHydrologyCacheIdentity.capture(target, kernel, null, false);
assertNotNull(direct);
when(IrisPlatforms.get().packsFolderNoCreate()).thenReturn(second.toFile());
assertEquals(direct, PreparedHydrologyCacheIdentity.capture(target, kernel, null, false));
}
}
private static EngineTarget target(Path path) {
IrisData data = mock(IrisData.class);
when(data.getDataFolder()).thenReturn(path.toFile());
when(data.hasGenerationRegistryContract()).thenReturn(true);
IrisWorld world = mock(IrisWorld.class);
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
EngineTarget target = mock(EngineTarget.class);
IrisDimension dimension = mock(IrisDimension.class);
when(dimension.getStudioMode()).thenReturn(StudioMode.NORMAL);
when(target.getDimension()).thenReturn(dimension);
when(target.getData()).thenReturn(data);
when(target.getWorld()).thenReturn(world);
return target;
}
@Test
public void identicalInputsShareRegardlessOfWorldLocation() {
GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation");
assertEquals(identity("pack", "closed", "platform", kernel, null, -64),
identity("pack", "closed", "platform", kernel, null, -64));
}
@Test
public void everyExternalInputAndTransitionSeparatesPlans() {
GenerationKernelRegistry.RuntimeKernel kernel = kernel("implementation");
PreparedHydrologyCacheIdentity base = identity("pack", "closed", "platform", kernel, null, -64);
assertNotEquals(base, identity("changed", "closed", "platform", kernel, null, -64));
assertNotEquals(base, identity("pack", "dependency-changed", "platform", kernel, null, -64));
assertNotEquals(base, identity("pack", "closed", "platform-changed", kernel, null, -64));
assertNotEquals(base, identity("pack", "closed", "platform", kernel("changed"), null, -64));
assertNotEquals(base, identity("pack", "closed", "platform", kernel, null, 0));
TransitionGenerationPlan.Specification transition = new TransitionGenerationPlan.Specification(
1L, "old", "new", 1, 256, "boundary", "terrain");
assertNotEquals(base, identity("pack", "closed", "platform", kernel, transition, -64));
assertNotEquals(identity("pack", "closed", "platform", kernel, transition, -64),
identity("pack", "closed", "platform", kernel,
new TransitionGenerationPlan.Specification(1L, "old", "new", 1, 256, "other", "terrain"), -64));
}
private static PreparedHydrologyCacheIdentity identity(String pack, String dependencies, String platform,
GenerationKernelRegistry.RuntimeKernel kernel, TransitionGenerationPlan.Specification transition, int minY) {
return PreparedHydrologyCacheIdentity.fromInputs(pack, dependencies, platform, kernel, transition, minY, 320, 0);
}
private static GenerationKernelRegistry.RuntimeKernel kernel(String implementation) {
GenerationKernelRegistry.RuntimeKernel kernel = mock(GenerationKernelRegistry.RuntimeKernel.class);
when(kernel.version()).thenReturn(new GenerationKernelRegistry.Version(1, 1, 1));
when(kernel.implementationFingerprint()).thenReturn(implementation);
return kernel;
}
}

Some files were not shown because too many files have changed in this diff Show More