mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-12 03:24:14 +00:00
Hydrology Fix, and V+
This commit is contained in:
@@ -640,6 +640,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
generatorDrainCompleted = false;
|
||||
deferredShutdownGenerators.clear();
|
||||
MultiBurst.burst.reopen();
|
||||
MultiBurst.hydrology.reopen();
|
||||
MultiBurst.ioBurst.reopen();
|
||||
IrisLanguage.initialize();
|
||||
debugDump = BukkitDebugDump.create(this, new BukkitDebugDump.Options(
|
||||
@@ -1106,6 +1107,12 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
runtimeTeardownFailed.set(true);
|
||||
Iris.reportError("Failed to close Iris generation workers.", failure);
|
||||
}
|
||||
try {
|
||||
MultiBurst.hydrology.close();
|
||||
} catch (Throwable failure) {
|
||||
runtimeTeardownFailed.set(true);
|
||||
Iris.reportError("Failed to close Iris hydrology workers.", failure);
|
||||
}
|
||||
try {
|
||||
MultiBurst.ioBurst.close();
|
||||
} catch (Throwable failure) {
|
||||
@@ -1273,7 +1280,8 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
private boolean hasActiveShutdownResources() {
|
||||
if (!MultiBurst.burst.isTerminated() || !MultiBurst.ioBurst.isTerminated()) {
|
||||
if (!MultiBurst.burst.isTerminated() || !MultiBurst.hydrology.isTerminated()
|
||||
|| !MultiBurst.ioBurst.isTerminated()) {
|
||||
return true;
|
||||
}
|
||||
return services != null
|
||||
|
||||
+3
-2
@@ -176,15 +176,16 @@ public final class PendingWorldReplacementManager implements Listener {
|
||||
seedSelection
|
||||
);
|
||||
progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PACK);
|
||||
IrisDimension installed = Iris.service(StudioSVC.class).installIntoWorld(
|
||||
StudioSVC.GenerationPublication publication = Iris.service(StudioSVC.class).installIntoWorld(
|
||||
requiredSender,
|
||||
requiredDimension,
|
||||
paths.stage().toFile(),
|
||||
effectiveSeed
|
||||
);
|
||||
if (installed == null) {
|
||||
if (publication == null) {
|
||||
throw new IOException("Iris could not stage the dimension pack.");
|
||||
}
|
||||
IrisDimension installed = publication.dimension();
|
||||
requireCompatibleEnvironment(target.slotKind(), installed.getEnvironment());
|
||||
if (target.slotKind() == SlotKind.VANILLA_OVERWORLD) {
|
||||
progress.stage(RuntimeProgressMessages.WORLD_REPLACE_STAGE_PLAYERS);
|
||||
|
||||
@@ -59,7 +59,7 @@ apply from: file('../../gradle/modded-generation-build-revision.gradle')
|
||||
|
||||
Properties rootProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.0-26.2'))
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.1-26.2'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
|
||||
String fabricLoaderVersion = providers.gradleProperty('fabricLoaderVersion').getOrElse(rootProperties.getProperty('fabricLoaderVersion', '0.19.3'))
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT'))
|
||||
|
||||
@@ -59,7 +59,7 @@ apply from: file('../../gradle/modded-generation-build-revision.gradle')
|
||||
|
||||
Properties rootProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.0-26.2'))
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.1-26.2'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
|
||||
String forgeVersion = providers.gradleProperty('forgeVersion').getOrElse(rootProperties.getProperty('forgeVersion', '26.2-65.1.1'))
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT'))
|
||||
|
||||
+4
-4
@@ -102,6 +102,7 @@ public final class ModdedEngineBootstrap {
|
||||
// closed, MultiBurst falls back to a same-thread executor, so a second world load
|
||||
// without reopen() would silently run every burst inline.
|
||||
MultiBurst.burst.reopen();
|
||||
MultiBurst.hydrology.reopen();
|
||||
MultiBurst.ioBurst.reopen();
|
||||
ModdedScheduler scheduler = schedulerOrNull();
|
||||
if (scheduler != null) {
|
||||
@@ -189,10 +190,9 @@ public final class ModdedEngineBootstrap {
|
||||
failure = runStopStage(failure, "level snapshot", ModdedServerLevels::forget);
|
||||
}
|
||||
failure = runStopStage(failure, "generation pool", IrisModdedChunkGenerator::shutdownGenPool);
|
||||
failure = runStopStage(failure, "burst pools", () -> {
|
||||
MultiBurst.burst.close();
|
||||
MultiBurst.ioBurst.close();
|
||||
});
|
||||
failure = runStopStage(failure, "generation burst pool", MultiBurst.burst::close);
|
||||
failure = runStopStage(failure, "hydrology burst pool", MultiBurst.hydrology::close);
|
||||
failure = runStopStage(failure, "I/O burst pool", MultiBurst.ioBurst::close);
|
||||
failure = runStopStage(failure, "startup state", ModdedStartup::reset);
|
||||
failure = runStopStage(failure, "server state", () -> {
|
||||
currentServer = null;
|
||||
|
||||
@@ -59,7 +59,7 @@ apply from: file('../../gradle/modded-generation-build-revision.gradle')
|
||||
|
||||
Properties rootProperties = new Properties()
|
||||
file('../../gradle.properties').withInputStream { InputStream stream -> rootProperties.load(stream) }
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.0-26.2'))
|
||||
String irisVersion = providers.gradleProperty('irisVersion').getOrElse(rootProperties.getProperty('irisVersion', '4.1.1-26.2'))
|
||||
String minecraftVersion = providers.gradleProperty('minecraftVersion').getOrElse(rootProperties.getProperty('minecraftVersion', '26.2'))
|
||||
String neoForgeVersion = providers.gradleProperty('neoForgeVersion').getOrElse(rootProperties.getProperty('neoForgeVersion', '26.2.0.59'))
|
||||
String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate').getOrElse(rootProperties.getProperty('volmLibCoordinate', 'com.github.VolmitSoftware:VolmLib:master-SNAPSHOT'))
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ interface IrisFileOperations {
|
||||
IrisFileOperations irisFileOperations = objects.newInstance(IrisFileOperations)
|
||||
|
||||
group = 'art.arcane'
|
||||
version = providers.gradleProperty('irisVersion').getOrElse('4.1.0-26.2')
|
||||
version = providers.gradleProperty('irisVersion').getOrElse('4.1.1-26.2')
|
||||
|
||||
project(':core') {
|
||||
group = 'art.arcane'
|
||||
|
||||
@@ -206,6 +206,8 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
|
||||
@ArrayType(type = IrisBiomePaletteLayer.class)
|
||||
@Description("This defines the layers of materials in this biome. Each layer has a palette and min/max height and some other properties. Usually a grassy/sandy layer then a dirt layer then a stone layer. Iris will fill in the remaining blocks below your layers with stone.")
|
||||
private KList<IrisBiomePaletteLayer> layers = new KList<IrisBiomePaletteLayer>().qadd(new IrisBiomePaletteLayer());
|
||||
@Description("What this biome's columns surface with when every layer is rejected, usually because all of them are slope gated and the column is too steep. Omit to inherit the dimension setting.")
|
||||
private IrisSurfaceLayerFallback surfaceLayerFallback = null;
|
||||
@ArrayType(type = IrisBiomePaletteLayer.class)
|
||||
@Description("Layers of materials placed on cave ceilings in this biome, indexed upward from the ceiling surface. Must not have more entries than layers, whose height generators it reuses. Omitting this leaves cave ceilings unchanged.")
|
||||
private KList<IrisBiomePaletteLayer> caveCeilingLayers = new KList<>();
|
||||
|
||||
@@ -56,7 +56,9 @@ final class IrisBiomeLayerGenerator {
|
||||
|
||||
private static KList<PlatformBlockState> generateLayers(IrisBiome biome, IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, IrisComplex complex, ProceduralStream<Double> slopeStream) {
|
||||
if (biome.isLockLayers()) {
|
||||
return generateLockedLayers(biome, wx, wz, random, maxDepth, height, rdata, complex, slopeStream);
|
||||
KList<PlatformBlockState> data = generateLockedLayers(biome, wx, wz, random, maxDepth, height, rdata, complex, slopeStream);
|
||||
appendSurfaceFallback(data, biome, dim, wx, wz, random, Math.min(maxDepth, biome.getLockLayersMax()), rdata);
|
||||
return data;
|
||||
}
|
||||
|
||||
KList<PlatformBlockState> data = new KList<>();
|
||||
@@ -114,9 +116,24 @@ final class IrisBiomeLayerGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
appendSurfaceFallback(data, biome, dim, wx, wz, random, maxDepth, rdata);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static void appendSurfaceFallback(KList<PlatformBlockState> data, IrisBiome biome, IrisDimension dim,
|
||||
double wx, double wz, RNG random, int maxDepth, IrisData rdata) {
|
||||
if (!data.isEmpty() || maxDepth <= 0 || biome.getLayers().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
IrisSurfaceLayerFallback fallback = biome.getSurfaceLayerFallback();
|
||||
if (fallback == null) {
|
||||
fallback = dim.getSurfaceLayerFallback();
|
||||
}
|
||||
if (fallback == IrisSurfaceLayerFallback.TOP_LAYER) {
|
||||
appendLayer(data, biome.getLayers().get(0), 0, 1, wx, wz, random, maxDepth, rdata);
|
||||
}
|
||||
}
|
||||
|
||||
static KList<PlatformBlockState> generateCeilingLayers(IrisBiome biome, IrisDimension dim, double wx, double wz, RNG random, int maxDepth, int height, IrisData rdata, IrisComplex complex) {
|
||||
KList<PlatformBlockState> data = new KList<>();
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Bukkit Servers
|
||||
* Copyright (c) 2022 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.generation.biome;
|
||||
|
||||
import art.arcane.volmlib.util.documentation.Description;
|
||||
|
||||
@Description("What a column surfaces with when every biome layer is rejected, usually because all of them are slope gated and the column is too steep.")
|
||||
public enum IrisSurfaceLayerFallback {
|
||||
@Description("Leave the column without layers so it surfaces with the dimension rock palette. Use this for biomes meant to be bare rock where they get steep.")
|
||||
ROCK,
|
||||
|
||||
@Description("Cap the column with a single block of the first layer's palette, ignoring that layer's slope condition. Keeps steep coastlines and cliff faces from turning into bare stone.")
|
||||
TOP_LAYER
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.ForkJoinWorkerThread;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.IntSupplier;
|
||||
|
||||
@@ -21,7 +22,7 @@ public class MultiBurst extends MultiBurstSupport {
|
||||
* Long-running hydrology tile planning. Kept off {@link #burst} so a handful of twenty-second
|
||||
* plans never pin the workers that chunk generation's short stage and mantle tasks need.
|
||||
*/
|
||||
public static final MultiBurst hydrology = new MultiBurst("Iris Hydrology", () -> Math.max(2, IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism())));
|
||||
public static final MultiBurst hydrology = new HydrologyBurst();
|
||||
private final AtomicInteger parallelismBaseline = new AtomicInteger();
|
||||
|
||||
public MultiBurst() {
|
||||
@@ -136,4 +137,21 @@ public class MultiBurst extends MultiBurstSupport {
|
||||
public static void close(ExecutorService service) {
|
||||
MultiBurstSupport.close(service, M::ms, IrisLogging::info, IrisLogging::warn, IrisLogging::reportError, TIMEOUT);
|
||||
}
|
||||
|
||||
static final class HydrologyBurst extends MultiBurst {
|
||||
HydrologyBurst() {
|
||||
super("Iris Hydrology", () -> Math.max(2,
|
||||
IrisSettings.getThreadCount(IrisSettings.get().getConcurrency().getParallelism())));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ForkJoinPool createPool(
|
||||
int parallelism,
|
||||
ForkJoinPool.ForkJoinWorkerThreadFactory factory,
|
||||
Thread.UncaughtExceptionHandler handler
|
||||
) {
|
||||
return new ForkJoinPool(parallelism, factory, handler, true,
|
||||
0, Integer.MAX_VALUE, parallelism, null, 60L, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ final class HydrologyChannelGeometry {
|
||||
compiler.settings.surface().banks().maximumBlendWidth()
|
||||
);
|
||||
if (type == HydrologyFeatureType.WATERFALL) {
|
||||
// A plunge has no floodplain: the shore bench is cut level with the bank top, so a wide one
|
||||
// terraces the cliff the water falls down. Deliberate saturation, paired with the grading cap
|
||||
// below - shoreWidth stops tracking the knob past 2.5.
|
||||
shoreWidth = Math.min(2.5D, shoreWidth);
|
||||
gradingWidth = Math.min(gradingWidth, Math.max(8D, channelRadius * 4D));
|
||||
}
|
||||
|
||||
+134
-34
@@ -1,5 +1,7 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.generation.hydrology.cave.CavePosition;
|
||||
import art.arcane.iris.generation.hydrology.cave.CaveVoxelView;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate;
|
||||
@@ -17,8 +19,10 @@ import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
final class HydrologyCrossTileResolver {
|
||||
private static final int FALLBACK_TRIAL_PARALLELISM = 16;
|
||||
private final HydrologyPlanner planner;
|
||||
|
||||
HydrologyCrossTileResolver(HydrologyPlanner planner) {
|
||||
@@ -114,7 +118,6 @@ final class HydrologyCrossTileResolver {
|
||||
)
|
||||
);
|
||||
seedRegionalSurface(key, footprintCompiler);
|
||||
diagnostics.addAll(footprintCompiler.regionalNetwork.diagnostics());
|
||||
HashMap<Long, List<HydrologyPoint>> refinedEdges = new HashMap<>();
|
||||
HashMap<SourceCompilationKey, SourceCompilation> sourceCompilations = new HashMap<>();
|
||||
settleSelection(
|
||||
@@ -147,6 +150,8 @@ final class HydrologyCrossTileResolver {
|
||||
? List.of()
|
||||
: planner.outletPlanner.resolveSurfaceFallbackOutlets(grid);
|
||||
int fallbackOutletIndex = 0;
|
||||
int fallbackBatchStart = 0;
|
||||
List<FallbackTrial> fallbackTrials = List.of();
|
||||
while (true) {
|
||||
int maximumPublicationPasses = Math.addExact(
|
||||
1,
|
||||
@@ -225,41 +230,27 @@ final class HydrologyCrossTileResolver {
|
||||
}
|
||||
boolean fallbackSelected = false;
|
||||
while (fallbackOutletIndex < fallbackOutlets.size()) {
|
||||
OutletCandidate fallbackOutlet = fallbackOutlets.get(fallbackOutletIndex++);
|
||||
HydrologyRoutingPlan fallbackRouting = planner.sourcePlanner.requireOrganicSurface(planner.sourcePlanner.buildRouting(grid, List.of(fallbackOutlet), true));
|
||||
HashMap<HydrologyTileKey, SourceRoutingContext> fallbackContexts = new HashMap<>();
|
||||
fallbackContexts.put(key, new SourceRoutingContext(
|
||||
grid,
|
||||
fallbackRouting,
|
||||
undergroundRouting,
|
||||
List.of()
|
||||
));
|
||||
SourceSelection fallbackSelection = planner.sourcePlanner.selectSources(
|
||||
key,
|
||||
grid,
|
||||
fallbackRouting,
|
||||
true,
|
||||
false,
|
||||
diagnostics,
|
||||
fallbackContexts
|
||||
);
|
||||
settleSelection(
|
||||
key,
|
||||
grid,
|
||||
fallbackRouting,
|
||||
undergroundRouting,
|
||||
fallbackSelection,
|
||||
true,
|
||||
footprintCompiler,
|
||||
refinedEdges,
|
||||
sourceCompilations,
|
||||
diagnostics
|
||||
);
|
||||
if (!fallbackSelection.hasAcceptedSelection()) {
|
||||
if (fallbackOutletIndex >= fallbackBatchStart + fallbackTrials.size()) {
|
||||
fallbackBatchStart = fallbackOutletIndex;
|
||||
fallbackTrials = compileFallbackTrials(new FallbackContext(
|
||||
key, grid, undergroundRouting, footprintCompiler, refinedEdges, sourceCompilations
|
||||
), fallbackOutlets, fallbackBatchStart);
|
||||
}
|
||||
FallbackTrial trial = fallbackTrials.get(fallbackOutletIndex++ - fallbackBatchStart);
|
||||
if (trial.failure() != null) {
|
||||
throw HydrologyPlanner.propagateOwnerFailure(trial.failure());
|
||||
}
|
||||
refinedEdges.putAll(trial.refinedEdges());
|
||||
sourceCompilations.putAll(trial.sourceCompilations());
|
||||
if (!trial.selection().hasAcceptedSelection()) {
|
||||
continue;
|
||||
}
|
||||
surfaceRouting = fallbackRouting;
|
||||
surfaceSelection = fallbackSelection;
|
||||
// Every trial re-selects over the whole owned lattice, so a discarded one invents a full set of
|
||||
// per-node rejections for a plan that was never published. Only the adopted trial may report.
|
||||
diagnostics.addAll(trial.diagnostics());
|
||||
surfaceRouting = trial.routing();
|
||||
surfaceSelection = trial.selection();
|
||||
footprintCompiler = trial.footprintCompiler();
|
||||
fallbackSelected = true;
|
||||
break;
|
||||
}
|
||||
@@ -287,6 +278,104 @@ final class HydrologyCrossTileResolver {
|
||||
return draft;
|
||||
}
|
||||
|
||||
private List<FallbackTrial> compileFallbackTrials(
|
||||
FallbackContext context,
|
||||
List<OutletCandidate> outlets,
|
||||
int start
|
||||
) {
|
||||
int end = Math.min(outlets.size(), start + FALLBACK_TRIAL_PARALLELISM);
|
||||
ArrayList<Callable<FallbackTrial>> tasks = new ArrayList<>(end - start);
|
||||
for (int index = start; index < end; index++) {
|
||||
OutletCandidate outlet = outlets.get(index);
|
||||
tasks.add(() -> compileFallbackTrial(context, outlet));
|
||||
}
|
||||
List<FallbackTrial> trials = HydrologyForkJoin.invokeAll(tasks,
|
||||
IrisPlatforms.isBound() ? MultiBurst.hydrology : null);
|
||||
DraftProfile ownerProfile = planner.currentDraftProfile();
|
||||
for (FallbackTrial trial : trials) {
|
||||
DraftProfile trialProfile = trial.profile();
|
||||
ownerProfile.routeCalls += trialProfile.routeCalls;
|
||||
ownerProfile.routeSolves += trialProfile.routeSolves;
|
||||
ownerProfile.routeSolveNanos += trialProfile.routeSolveNanos;
|
||||
ownerProfile.rasterCalls += trialProfile.rasterCalls;
|
||||
ownerProfile.rasterNanos += trialProfile.rasterNanos;
|
||||
ownerProfile.filterCalls += trialProfile.filterCalls;
|
||||
ownerProfile.filterNanos += trialProfile.filterNanos;
|
||||
}
|
||||
return trials;
|
||||
}
|
||||
|
||||
private FallbackTrial compileFallbackTrial(FallbackContext context, OutletCandidate outlet) {
|
||||
HydrologyPlanner.PlanningSamples previousSamples = planner.planningSamples.get();
|
||||
DraftProfile previousProfile = planner.draftProfiles.get();
|
||||
DraftProfile profile = new DraftProfile();
|
||||
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
|
||||
HashMap<Long, List<HydrologyPoint>> refinedEdges = new HashMap<>(context.refinedEdges());
|
||||
HashMap<SourceCompilationKey, SourceCompilation> sourceCompilations = new HashMap<>(context.sourceCompilations());
|
||||
HydrologyFootprintCompiler footprints = null;
|
||||
try {
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
planner.draftProfiles.set(profile);
|
||||
footprints = new HydrologyFootprintCompiler(
|
||||
planner.settings,
|
||||
new HydrologyFootprintCompiler.Sampling(planner.sampler, planner.geometrySampler, planner.naturalSampler)
|
||||
);
|
||||
footprints.regionalNetwork = context.footprintCompiler().regionalNetwork;
|
||||
footprints.regionalSurfaceFootprints.putAll(context.footprintCompiler().regionalSurfaceFootprints);
|
||||
footprints.regionalDropRasters.putAll(context.footprintCompiler().regionalDropRasters);
|
||||
HydrologyRoutingPlan routing = planner.sourcePlanner.requireOrganicSurface(
|
||||
planner.sourcePlanner.buildRouting(context.grid(), List.of(outlet), true)
|
||||
);
|
||||
HashMap<HydrologyTileKey, SourceRoutingContext> routingContexts = new HashMap<>();
|
||||
routingContexts.put(context.key(), new SourceRoutingContext(
|
||||
context.grid(), routing, context.undergroundRouting(), List.of()
|
||||
));
|
||||
SourceSelection selection = planner.sourcePlanner.selectSources(
|
||||
context.key(), context.grid(), routing, true, false, diagnostics, routingContexts
|
||||
);
|
||||
settleSelection(context.key(), context.grid(), routing, context.undergroundRouting(), selection,
|
||||
true, footprints, refinedEdges, sourceCompilations, diagnostics);
|
||||
return new FallbackTrial(routing, selection, List.copyOf(diagnostics), footprints,
|
||||
refinedEdges, sourceCompilations, profile, null);
|
||||
} catch (Throwable failure) {
|
||||
return new FallbackTrial(null, null, List.copyOf(diagnostics), footprints,
|
||||
refinedEdges, sourceCompilations, profile, failure);
|
||||
} finally {
|
||||
if (previousSamples == null) {
|
||||
planner.planningSamples.remove();
|
||||
} else {
|
||||
planner.planningSamples.set(previousSamples);
|
||||
}
|
||||
if (previousProfile == null) {
|
||||
planner.draftProfiles.remove();
|
||||
} else {
|
||||
planner.draftProfiles.set(previousProfile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record FallbackContext(
|
||||
HydrologyTileKey key,
|
||||
HydrologySampledGrid grid,
|
||||
HydrologyRoutingPlan undergroundRouting,
|
||||
HydrologyFootprintCompiler footprintCompiler,
|
||||
Map<Long, List<HydrologyPoint>> refinedEdges,
|
||||
Map<SourceCompilationKey, SourceCompilation> sourceCompilations
|
||||
) {
|
||||
}
|
||||
|
||||
private record FallbackTrial(
|
||||
HydrologyRoutingPlan routing,
|
||||
SourceSelection selection,
|
||||
List<HydrologyDiagnosticCandidate> diagnostics,
|
||||
HydrologyFootprintCompiler footprintCompiler,
|
||||
Map<Long, List<HydrologyPoint>> refinedEdges,
|
||||
Map<SourceCompilationKey, SourceCompilation> sourceCompilations,
|
||||
DraftProfile profile,
|
||||
Throwable failure
|
||||
) {
|
||||
}
|
||||
|
||||
HydrologyTile materializeAcceptedTile(CrossTileResolution resolution) {
|
||||
HydrologyCaveCourseFilter.Result result = resolution.draft().result();
|
||||
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>(resolution.draft().diagnostics());
|
||||
@@ -324,6 +413,16 @@ final class HydrologyCrossTileResolver {
|
||||
if (footprintCompiler.fullMaterializationCount() < 1) {
|
||||
throw new IllegalStateException("Hydrology publication did not materialize its full footprint.");
|
||||
}
|
||||
HashSet<Long> regionalIds = new HashSet<>();
|
||||
for (RiverCourse course : footprintCompiler.regionalNetwork.courses()) {
|
||||
regionalIds.add(course.id());
|
||||
}
|
||||
HashSet<Long> publishedRegionalIds = new HashSet<>();
|
||||
for (RiverCourse course : result.courses()) {
|
||||
if (regionalIds.contains(course.id())) {
|
||||
publishedRegionalIds.add(course.id());
|
||||
}
|
||||
}
|
||||
return new HydrologyTile(
|
||||
resolution.draft().key(),
|
||||
planner.worldSeed,
|
||||
@@ -333,6 +432,7 @@ final class HydrologyCrossTileResolver {
|
||||
result.edges(),
|
||||
result.outlets(),
|
||||
result.courses(),
|
||||
publishedRegionalIds,
|
||||
result.cavePlans(),
|
||||
planner.sourcePlanner.uniqueDiagnostics(diagnostics),
|
||||
footprint
|
||||
|
||||
+69
-11
@@ -1,16 +1,21 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
final class HydrologyOutletPlanner {
|
||||
private static final int REGIONAL_SHORELINE_WINDOW = 4;
|
||||
private final HydrologyPlanner planner;
|
||||
|
||||
HydrologyOutletPlanner(HydrologyPlanner planner) {
|
||||
@@ -133,28 +138,80 @@ final class HydrologyOutletPlanner {
|
||||
ArrayList<OutletCandidate> oceanOutletCandidates(HydrologySampledGrid grid, boolean surface) {
|
||||
ArrayList<OutletCandidate> oceanCandidates = new ArrayList<>();
|
||||
for (HydrologyGridNode land : grid.nodes()) {
|
||||
if (land.terrain().ocean() || !land.terrain().transitAllowed() || !land.terrain().outletAllowed()) {
|
||||
Shoreline shoreline = shoreline(grid, land, surface);
|
||||
if (shoreline == null) {
|
||||
continue;
|
||||
}
|
||||
OutletCandidate candidate = oceanOutletCandidate(shoreline.land(), shoreline.ocean(), surface);
|
||||
if (candidate != null) {
|
||||
oceanCandidates.add(candidate);
|
||||
}
|
||||
}
|
||||
return oceanCandidates;
|
||||
}
|
||||
|
||||
ArrayList<OutletCandidate> regionalOceanOutletCandidates(HydrologySampledGrid grid) {
|
||||
ArrayList<OutletCandidate> oceanCandidates = new ArrayList<>();
|
||||
int index = 0;
|
||||
while (index < grid.nodes().size()) {
|
||||
ArrayList<Callable<OutletCandidate>> tasks = new ArrayList<>(REGIONAL_SHORELINE_WINDOW);
|
||||
while (index < grid.nodes().size() && tasks.size() < REGIONAL_SHORELINE_WINDOW) {
|
||||
Shoreline shoreline = shoreline(grid, grid.nodes().get(index++), true);
|
||||
if (shoreline != null) {
|
||||
tasks.add(() -> regionalOceanOutletCandidate(shoreline));
|
||||
}
|
||||
}
|
||||
for (OutletCandidate candidate : HydrologyForkJoin.invokeAll(tasks,
|
||||
IrisPlatforms.isBound() ? MultiBurst.hydrology : null)) {
|
||||
if (candidate != null) {
|
||||
oceanCandidates.add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
return oceanCandidates;
|
||||
}
|
||||
|
||||
private OutletCandidate regionalOceanOutletCandidate(Shoreline shoreline) {
|
||||
HydrologyPlanner.PlanningSamples previous = planner.planningSamples.get();
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
try {
|
||||
return oceanOutletCandidate(shoreline.land(), shoreline.ocean(), true);
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
planner.planningSamples.remove();
|
||||
} else {
|
||||
planner.planningSamples.set(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Shoreline shoreline(HydrologySampledGrid grid, HydrologyGridNode land, boolean surface) {
|
||||
if (land.terrain().ocean() || !land.terrain().transitAllowed() || !land.terrain().outletAllowed()) {
|
||||
return null;
|
||||
}
|
||||
HydrologyGridNode ocean = firstOceanNeighbor(grid, land, surface);
|
||||
if (ocean == null || !land.terrain().drainsInto(ocean.terrain())) {
|
||||
// A confined shore only reaches a sea that belongs to its own area.
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
return new Shoreline(land, ocean);
|
||||
}
|
||||
|
||||
private OutletCandidate oceanOutletCandidate(HydrologyGridNode land, HydrologyGridNode ocean, boolean surface) {
|
||||
HydrologyOceanBoundaryRefiner.Result boundary = refineOceanBoundary(land, ocean);
|
||||
if (boundary == null) {
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
HydrologyTerrainSample receiving = planner.sampleBasisWithoutSlope(boundary.oceanPoint().x(), boundary.oceanPoint().z());
|
||||
if (receiving == null || !boundary.landwardTerrain().drainsInto(receiving)
|
||||
|| !boundary.landwardTerrain().transitAllowed()
|
||||
|| !boundary.landwardTerrain().outletAllowed()
|
||||
|| surface && !HydrologySurfaceProfiles.sharesProfile(boundary.landwardTerrain(), receiving)) {
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
HydrologyFeatureType type = coastalOutletType(boundary.landwardTerrain());
|
||||
if (type == null) {
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
long outletId = HydrologyHash.mix(planner.worldSeed, OUTLET_SALT, land.id(), ocean.id(), type.ordinal());
|
||||
int seaLevel = planner.settingsSeaLevel(ocean.terrain());
|
||||
@@ -167,9 +224,7 @@ final class HydrologyOutletPlanner {
|
||||
seaLevel,
|
||||
true
|
||||
);
|
||||
oceanCandidates.add(new OutletCandidate(land.index(), ocean.index(), outlet));
|
||||
}
|
||||
return oceanCandidates;
|
||||
return new OutletCandidate(land.index(), ocean.index(), outlet);
|
||||
}
|
||||
|
||||
List<OutletCandidate> resolveSurfaceFallbackOutlets(HydrologySampledGrid grid) {
|
||||
@@ -728,4 +783,7 @@ final class HydrologyOutletPlanner {
|
||||
|
||||
record InlandConnection(HydrologyPoint point, HydrologyTerrainSample terrain) {
|
||||
}
|
||||
|
||||
private record Shoreline(HydrologyGridNode land, HydrologyGridNode ocean) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.cave.CaveVoxelView;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveContainmentPlanner;
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceCourseBuilder;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
@@ -220,6 +221,7 @@ public final class HydrologyPlanner {
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
Set.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
RiverFootprint.empty()
|
||||
@@ -246,6 +248,15 @@ public final class HydrologyPlanner {
|
||||
return tile;
|
||||
}
|
||||
|
||||
public List<HydrologyDiagnosticCandidate> diagnosticCandidates(HydrologyTile tile) {
|
||||
Objects.requireNonNull(tile, "tile");
|
||||
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>(tile.localDiagnosticCandidates());
|
||||
diagnostics.addAll(regional.diagnosticsIn(regional.ownerBounds(tile.key())));
|
||||
ArrayList<HydrologyDiagnosticCandidate> unique = new ArrayList<>(sourcePlanner.uniqueDiagnostics(diagnostics));
|
||||
unique.sort(Comparator.comparingLong(HydrologyDiagnosticCandidate::id));
|
||||
return List.copyOf(unique);
|
||||
}
|
||||
|
||||
void clearOwnerDrafts() {
|
||||
resolvedOwners.invalidateAll();
|
||||
regional.clear();
|
||||
@@ -258,18 +269,29 @@ public final class HydrologyPlanner {
|
||||
|| tile.tileSize() != settings.routing().tileSize()) {
|
||||
throw new IllegalArgumentException("Hydrology tile does not match this planner.");
|
||||
}
|
||||
ArrayList<RiverCourse> localCourses = new ArrayList<>(tile.courses().size());
|
||||
for (RiverCourse course : tile.courses()) {
|
||||
if (!tile.regionalCourseIds().contains(course.id())) {
|
||||
localCourses.add(course);
|
||||
}
|
||||
}
|
||||
ArrayList<HydrologyCavePlan> localPlans = new ArrayList<>(tile.cavePlans().size());
|
||||
for (HydrologyCavePlan plan : tile.cavePlans()) {
|
||||
if (!tile.regionalCourseIds().contains(plan.source().sourceId())) {
|
||||
localPlans.add(plan);
|
||||
}
|
||||
}
|
||||
HydrologyCaveCourseFilter.Result result = new HydrologyCaveCourseFilter.Result(
|
||||
tile.nodes(),
|
||||
tile.edges(),
|
||||
tile.outlets(),
|
||||
tile.courses(),
|
||||
tile.cavePlans()
|
||||
localCourses,
|
||||
localPlans
|
||||
);
|
||||
result = regional.withoutRegional(result, tile.key());
|
||||
HydrologyOwnerDraft draft = new HydrologyOwnerDraft(
|
||||
tile.key(),
|
||||
result,
|
||||
tile.diagnosticCandidates(),
|
||||
tile.localDiagnosticCandidates(),
|
||||
null
|
||||
);
|
||||
resolvedOwners.put(tile.key(), new CrossTileResolvedOwner(draft, List.of()));
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
record HydrologyRegionalCoarsePlan(
|
||||
HydrologySampledGrid grid,
|
||||
List<OutletCandidate> outlets,
|
||||
HydrologyRegionalGraph.Tree tree,
|
||||
List<Integer> sources,
|
||||
List<HydrologyRegionalEnvelope> envelopes,
|
||||
double longest,
|
||||
boolean coastal,
|
||||
int weight
|
||||
) {
|
||||
HydrologyRegionalCoarsePlan {
|
||||
outlets = List.copyOf(outlets);
|
||||
sources = List.copyOf(sources);
|
||||
envelopes = List.copyOf(envelopes);
|
||||
}
|
||||
|
||||
boolean intersects(SurfaceBounds bounds) {
|
||||
if (coastal) {
|
||||
return true;
|
||||
}
|
||||
for (HydrologyRegionalEnvelope envelope : envelopes) {
|
||||
if (envelope.intersects(bounds)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
record HydrologyRegionalEnvelope(long minimumX, long minimumZ, long maximumX, long maximumZ) {
|
||||
private static final HydrologyRegionalEnvelope UNBOUNDED = new HydrologyRegionalEnvelope(
|
||||
Long.MIN_VALUE, Long.MIN_VALUE, Long.MAX_VALUE, Long.MAX_VALUE);
|
||||
|
||||
static HydrologyRegionalEnvelope of(List<HydrologyPoint> guide, HydrologyPlannerSettings settings, int footprintRadius) {
|
||||
long minimumX = Long.MAX_VALUE;
|
||||
long minimumZ = Long.MAX_VALUE;
|
||||
long maximumX = Long.MIN_VALUE;
|
||||
long maximumZ = Long.MIN_VALUE;
|
||||
for (HydrologyPoint point : guide) {
|
||||
minimumX = Math.min(minimumX, point.x());
|
||||
minimumZ = Math.min(minimumZ, point.z());
|
||||
maximumX = Math.max(maximumX, point.x());
|
||||
maximumZ = Math.max(maximumZ, point.z());
|
||||
}
|
||||
if (guide.isEmpty()) {
|
||||
return UNBOUNDED;
|
||||
}
|
||||
int spacing = settings.routing().regional().sampleSpacing();
|
||||
double commonPadding = spacing + spacing * settings.geometry().meanders().maximumOffsetRatio()
|
||||
+ 256D + 2D + Math.max(footprintRadius, (long) settings.publicationRadius() + 1L);
|
||||
double paddingX = StrictMath.ceil(commonPadding + (maximumX - minimumX + 2D * spacing) / 8D);
|
||||
double paddingZ = StrictMath.ceil(commonPadding + (maximumZ - minimumZ + 2D * spacing) / 8D);
|
||||
if (!Double.isFinite(paddingX) || !Double.isFinite(paddingZ)
|
||||
|| minimumX - paddingX < Integer.MIN_VALUE || maximumX + paddingX > Integer.MAX_VALUE
|
||||
|| minimumZ - paddingZ < Integer.MIN_VALUE || maximumZ + paddingZ > Integer.MAX_VALUE) {
|
||||
return UNBOUNDED;
|
||||
}
|
||||
return new HydrologyRegionalEnvelope(minimumX - (long) paddingX, minimumZ - (long) paddingZ,
|
||||
maximumX + (long) paddingX, maximumZ + (long) paddingZ);
|
||||
}
|
||||
|
||||
boolean intersects(SurfaceBounds bounds) {
|
||||
return maximumX >= bounds.minimumX() && minimumX <= bounds.maximumX()
|
||||
&& maximumZ >= bounds.minimumZ() && minimumZ <= bounds.maximumZ();
|
||||
}
|
||||
}
|
||||
+240
-60
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline;
|
||||
@@ -10,11 +11,13 @@ import art.arcane.iris.generation.hydrology.surface.SurfaceFootprint;
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceFootprintCompiler;
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceLayerColumn;
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceTerminal;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -22,12 +25,14 @@ import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
final class HydrologyRegionalPlanner {
|
||||
private static final long BASIN_SALT = 0x524547424153494eL;
|
||||
private static final long COURSE_SALT = 0x524547434f555253L;
|
||||
private static final long NODE_SALT = 0x5245474e4f444553L;
|
||||
private static final int MAXIMUM_SOURCE_TRIALS = 24;
|
||||
private static final int SOURCE_TRIAL_WINDOW = 16;
|
||||
private static final int MAXIMUM_DIAGNOSTICS = 64;
|
||||
|
||||
private final HydrologyPlanner planner;
|
||||
@@ -35,6 +40,11 @@ final class HydrologyRegionalPlanner {
|
||||
private final HydrologyRegionalGraph graph;
|
||||
private final HydrologyRegionalRoute routes;
|
||||
private final Cache<HydrologyTileKey, HydrologyRegionalNetwork> drafts;
|
||||
private final Cache<HydrologyTileKey, HydrologyRegionalCoarsePlan> coarsePlans;
|
||||
private final Map<HydrologyTileKey, HydrologyForkJoin.Task<HydrologyRegionalCoarsePlan>> preparingCoarsePlans = new HashMap<>();
|
||||
private final Map<HydrologyTileKey, HydrologyForkJoin.Task<HydrologyRegionalNetwork>> compilingDrafts = new HashMap<>();
|
||||
private final Object draftPublicationLock = new Object();
|
||||
private long draftEpoch;
|
||||
|
||||
HydrologyRegionalPlanner(HydrologyPlanner planner) {
|
||||
this.planner = planner;
|
||||
@@ -46,6 +56,10 @@ final class HydrologyRegionalPlanner {
|
||||
.maximumWeight(settings.maximumCachedStations())
|
||||
.weigher((HydrologyTileKey key, HydrologyRegionalNetwork network) -> Math.max(minimumWeight, network.stations()))
|
||||
.build();
|
||||
this.coarsePlans = Caffeine.newBuilder()
|
||||
.maximumWeight(settings.maximumCachedStations())
|
||||
.weigher((HydrologyTileKey key, HydrologyRegionalCoarsePlan plan) -> Math.max(minimumWeight, plan.weight()))
|
||||
.build();
|
||||
}
|
||||
|
||||
boolean enabled() {
|
||||
@@ -55,9 +69,15 @@ final class HydrologyRegionalPlanner {
|
||||
}
|
||||
|
||||
void clear() {
|
||||
synchronized (draftPublicationLock) {
|
||||
draftEpoch++;
|
||||
compilingDrafts.clear();
|
||||
preparingCoarsePlans.clear();
|
||||
coarsePlans.invalidateAll();
|
||||
drafts.invalidateAll();
|
||||
routes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyTerrainSample sample(int x, int z) {
|
||||
return planner.naturalSampler == null ? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z);
|
||||
@@ -73,30 +93,56 @@ final class HydrologyRegionalPlanner {
|
||||
int maximumX = Math.floorDiv(bounds.maximumX() + reach, basinSize);
|
||||
int minimumZ = Math.floorDiv(bounds.minimumZ() - reach, basinSize);
|
||||
int maximumZ = Math.floorDiv(bounds.maximumZ() + reach, basinSize);
|
||||
HydrologyTileKey primary = HydrologyTileKey.fromBlock(
|
||||
(int) (((long) bounds.minimumX() + bounds.maximumX()) / 2L),
|
||||
(int) (((long) bounds.minimumZ() + bounds.maximumZ()) / 2L), basinSize);
|
||||
ArrayList<HydrologyRegionalNetwork> networks = new ArrayList<>();
|
||||
ArrayList<HydrologyTileKey> keys = new ArrayList<>();
|
||||
ArrayList<Callable<HydrologyRegionalNetwork>> tasks = new ArrayList<>();
|
||||
for (int z = minimumZ; z <= maximumZ; z++) {
|
||||
for (int x = minimumX; x <= maximumX; x++) {
|
||||
HydrologyTileKey key = new HydrologyTileKey(x, z);
|
||||
HydrologyRegionalNetwork draft = draft(key);
|
||||
keys.add(key);
|
||||
tasks.add(() -> demandedDraft(key, bounds));
|
||||
}
|
||||
}
|
||||
List<HydrologyRegionalNetwork> loaded = HydrologyForkJoin.invokeAll(tasks,
|
||||
IrisPlatforms.isBound() ? MultiBurst.hydrology : null);
|
||||
ArrayList<HydrologyRegionalNetwork> networks = new ArrayList<>(loaded.size());
|
||||
for (int index = 0; index < loaded.size(); index++) {
|
||||
HydrologyTileKey key = keys.get(index);
|
||||
HydrologyRegionalNetwork draft = loaded.get(index);
|
||||
ArrayList<RiverCourse> accepted = new ArrayList<>();
|
||||
for (RiverCourse course : draft.courses()) {
|
||||
if (intersects(course, bounds, footprintRadius()) && admitted(key, course)) {
|
||||
accepted.add(course);
|
||||
}
|
||||
}
|
||||
if (!accepted.isEmpty() || key.equals(primary)) {
|
||||
if (!accepted.isEmpty()) {
|
||||
HydrologyRegionalNetwork selected = select(draft, accepted);
|
||||
networks.add(new HydrologyRegionalNetwork(selected.nodes(), selected.edges(), selected.outlets(),
|
||||
selected.courses(), key.equals(primary) ? draft.diagnostics() : List.of(), selected.cavePlans()));
|
||||
}
|
||||
selected.courses(), List.of(), selected.cavePlans()));
|
||||
}
|
||||
}
|
||||
return merge(networks);
|
||||
}
|
||||
|
||||
List<HydrologyDiagnosticCandidate> diagnosticsIn(SurfaceBounds bounds) {
|
||||
if (!enabled()) {
|
||||
return List.of();
|
||||
}
|
||||
int basinSize = settings.basinSize(planner.settings.routing());
|
||||
HydrologyTileKey primary = HydrologyTileKey.fromBlock(
|
||||
(int) (((long) bounds.minimumX() + bounds.maximumX()) / 2L),
|
||||
(int) (((long) bounds.minimumZ() + bounds.maximumZ()) / 2L), basinSize);
|
||||
return draft(primary).diagnostics();
|
||||
}
|
||||
|
||||
private HydrologyRegionalNetwork demandedDraft(HydrologyTileKey key, SurfaceBounds bounds) {
|
||||
HydrologyRegionalNetwork cached = drafts.getIfPresent(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
PreparedCoarse prepared = prepareCoarse(key);
|
||||
return prepared.plan().intersects(bounds) ? draft(key, prepared) : HydrologyRegionalNetwork.EMPTY;
|
||||
}
|
||||
|
||||
RiverFootprint materialize(HydrologyRegionalNetwork network, SurfaceBounds bounds) {
|
||||
if (network.courses().isEmpty()) {
|
||||
return RiverFootprint.empty();
|
||||
@@ -125,24 +171,142 @@ final class HydrologyRegionalPlanner {
|
||||
}
|
||||
|
||||
HydrologyRegionalNetwork draft(HydrologyTileKey key) {
|
||||
return drafts.get(key, this::compile);
|
||||
HydrologyRegionalNetwork cached = drafts.getIfPresent(key);
|
||||
return cached != null ? cached : draft(key, prepareCoarse(key));
|
||||
}
|
||||
|
||||
private HydrologyRegionalNetwork compile(HydrologyTileKey key) {
|
||||
private HydrologyRegionalNetwork draft(HydrologyTileKey key, PreparedCoarse prepared) {
|
||||
HydrologyForkJoin.Task<HydrologyRegionalNetwork> task;
|
||||
synchronized (draftPublicationLock) {
|
||||
boolean current = prepared.epoch() == draftEpoch;
|
||||
HydrologyRegionalNetwork cached = current ? drafts.getIfPresent(key) : null;
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
task = current ? compilingDrafts.get(key) : null;
|
||||
if (task == null) {
|
||||
task = new HydrologyForkJoin.Task<>(() -> compileDraft(key, prepared));
|
||||
if (current) {
|
||||
compilingDrafts.put(key, task);
|
||||
}
|
||||
}
|
||||
}
|
||||
return task.await();
|
||||
}
|
||||
|
||||
private PreparedCoarse prepareCoarse(HydrologyTileKey key) {
|
||||
long epoch;
|
||||
HydrologyForkJoin.Task<HydrologyRegionalCoarsePlan> task;
|
||||
synchronized (draftPublicationLock) {
|
||||
epoch = draftEpoch;
|
||||
HydrologyRegionalCoarsePlan cached = coarsePlans.getIfPresent(key);
|
||||
if (cached != null) {
|
||||
return new PreparedCoarse(epoch, cached);
|
||||
}
|
||||
task = preparingCoarsePlans.get(key);
|
||||
if (task == null) {
|
||||
task = new HydrologyForkJoin.Task<>(() -> compileCoarse(key, epoch));
|
||||
preparingCoarsePlans.put(key, task);
|
||||
}
|
||||
}
|
||||
return new PreparedCoarse(epoch, task.await());
|
||||
}
|
||||
|
||||
private HydrologyRegionalCoarsePlan compileCoarse(HydrologyTileKey key, long epoch) {
|
||||
try {
|
||||
HydrologyRegionalCoarsePlan plan = coarseSetup(key);
|
||||
synchronized (draftPublicationLock) {
|
||||
if (epoch == draftEpoch) {
|
||||
coarsePlans.put(key, plan);
|
||||
}
|
||||
}
|
||||
return plan;
|
||||
} finally {
|
||||
synchronized (draftPublicationLock) {
|
||||
if (epoch == draftEpoch) {
|
||||
preparingCoarsePlans.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyRegionalNetwork compileDraft(HydrologyTileKey key, PreparedCoarse prepared) {
|
||||
try {
|
||||
HydrologyRegionalNetwork network = compile(key, prepared.plan());
|
||||
synchronized (draftPublicationLock) {
|
||||
if (prepared.epoch() == draftEpoch) {
|
||||
drafts.put(key, network);
|
||||
}
|
||||
}
|
||||
return network;
|
||||
} finally {
|
||||
synchronized (draftPublicationLock) {
|
||||
if (prepared.epoch() == draftEpoch) {
|
||||
compilingDrafts.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyRegionalCoarsePlan coarseSetup(HydrologyTileKey key) {
|
||||
HydrologySampledGrid grid = sample(key);
|
||||
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
|
||||
HydrologyGridNode center = grid.nodes().get(grid.nodes().size() / 2);
|
||||
ArrayList<OutletCandidate> outlets = planner.outletPlanner.oceanOutletCandidates(grid, true);
|
||||
ArrayList<OutletCandidate> outlets = planner.outletPlanner.regionalOceanOutletCandidates(grid);
|
||||
outlets.removeIf(outlet -> outlet.outlet().type() != HydrologyFeatureType.MOUTH);
|
||||
outlets.sort(Comparator.comparingLong(outlet -> HydrologyHash.mix(planner.worldSeed, BASIN_SALT,
|
||||
key.tileX(), key.tileZ(), outlet.outlet().id())));
|
||||
long basinSeed = HydrologyHash.mix(planner.worldSeed, BASIN_SALT, key.tileX(), key.tileZ());
|
||||
boolean coastal = settings.coastalChannels() && HydrologyHash.unit(basinSeed) < settings.coastalChannelChance();
|
||||
if (outlets.isEmpty() || coastal) {
|
||||
return new HydrologyRegionalCoarsePlan(grid, outlets, null, List.of(), List.of(), 0D, coastal, grid.nodes().size());
|
||||
}
|
||||
return routeCoarse(grid, outlets);
|
||||
}
|
||||
|
||||
private HydrologyRegionalCoarsePlan routeCoarse(HydrologySampledGrid grid, List<OutletCandidate> outlets) {
|
||||
ArrayList<OutletCandidate> roots = spacedOutlets(outlets, settings.maximumTrunks() * 2);
|
||||
HydrologyRegionalGraph.Tree tree = graph.route(grid, roots, false);
|
||||
ArrayList<Integer> sources = new ArrayList<>();
|
||||
ArrayList<HydrologyRegionalEnvelope> envelopes = new ArrayList<>();
|
||||
double longest = 0D;
|
||||
for (HydrologyGridNode node : grid.nodes()) {
|
||||
if (!grid.owns(node.x(), node.z()) || tree.root(node.index()) < 0) {
|
||||
continue;
|
||||
}
|
||||
longest = Math.max(longest, tree.length(node.index()));
|
||||
if (tree.length(node.index()) < settings.minimumLength()
|
||||
|| !planner.sourcePlanner.rawSourceEligible(node.terrain(), planner.settings.surface().sources(), true)) {
|
||||
continue;
|
||||
}
|
||||
sources.add(node.index());
|
||||
ArrayList<HydrologyPoint> guide = new ArrayList<>();
|
||||
for (int index : graph.path(tree, node.index())) {
|
||||
guide.add(grid.node(index).naturalPoint());
|
||||
}
|
||||
RiverOutlet outlet = tree.outlets().get(tree.root(node.index())).outlet();
|
||||
guide.add(outlet.landwardPoint());
|
||||
guide.add(outlet.connectionPoint());
|
||||
envelopes.add(HydrologyRegionalEnvelope.of(guide, planner.settings, footprintRadius()));
|
||||
}
|
||||
int weight = Math.addExact(Math.addExact(grid.nodes().size(), tree.selected().length),
|
||||
Math.addExact(Math.ceilDiv(tree.accumulation().length, 16), envelopes.size()));
|
||||
return new HydrologyRegionalCoarsePlan(grid, outlets, tree, sources, envelopes, longest, false, weight);
|
||||
}
|
||||
|
||||
private record PreparedCoarse(long epoch, HydrologyRegionalCoarsePlan plan) {
|
||||
}
|
||||
|
||||
private HydrologyRegionalNetwork compile(HydrologyTileKey key, HydrologyRegionalCoarsePlan coarse) {
|
||||
HydrologySampledGrid grid = coarse.grid();
|
||||
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
|
||||
HydrologyGridNode center = grid.nodes().get(grid.nodes().size() / 2);
|
||||
List<OutletCandidate> outlets = coarse.outlets();
|
||||
if (outlets.isEmpty()) {
|
||||
diagnostic(diagnostics, center.id(), center.naturalPoint(), false, HydrologyCandidateRejection.NO_LEGAL_OUTLET, 0);
|
||||
return new HydrologyRegionalNetwork(List.of(), List.of(), List.of(), List.of(), diagnostics, List.of());
|
||||
}
|
||||
ArrayList<HydrologyRegionalNetwork> accepted = new ArrayList<>();
|
||||
long basinSeed = HydrologyHash.mix(planner.worldSeed, BASIN_SALT, key.tileX(), key.tileZ());
|
||||
if (settings.coastalChannels() && HydrologyHash.unit(basinSeed) < settings.coastalChannelChance()) {
|
||||
if (coarse.coastal()) {
|
||||
ArrayList<HydrologyDiagnosticCandidate> coastalDiagnostics = new ArrayList<>();
|
||||
HydrologyRegionalNetwork coastal = coastalChannel(grid, outlets, basinSeed, coastalDiagnostics);
|
||||
diagnostics.addAll(coastalDiagnostics.subList(0, Math.min(16, coastalDiagnostics.size())));
|
||||
@@ -150,23 +314,11 @@ final class HydrologyRegionalPlanner {
|
||||
accepted.add(coastal);
|
||||
}
|
||||
}
|
||||
ArrayList<OutletCandidate> roots = spacedOutlets(outlets, settings.maximumTrunks() * 2);
|
||||
HydrologyRegionalGraph.Tree tree = graph.route(grid, roots, false);
|
||||
ArrayList<Integer> sources = new ArrayList<>();
|
||||
for (HydrologyGridNode node : grid.nodes()) {
|
||||
if (grid.owns(node.x(), node.z()) && tree.root(node.index()) >= 0
|
||||
&& tree.length(node.index()) >= settings.minimumLength()
|
||||
&& planner.sourcePlanner.rawSourceEligible(node.terrain(), planner.settings.surface().sources(), true)) {
|
||||
sources.add(node.index());
|
||||
}
|
||||
}
|
||||
HydrologyRegionalCoarsePlan routed = coarse.tree() == null ? routeCoarse(grid, outlets) : coarse;
|
||||
HydrologyRegionalGraph.Tree tree = routed.tree();
|
||||
List<Integer> sources = routed.sources();
|
||||
if (sources.isEmpty()) {
|
||||
double longest = 0D;
|
||||
for (HydrologyGridNode node : grid.nodes()) {
|
||||
if (grid.owns(node.x(), node.z()) && tree.root(node.index()) >= 0) {
|
||||
longest = Math.max(longest, tree.length(node.index()));
|
||||
}
|
||||
}
|
||||
double longest = routed.longest();
|
||||
diagnostic(diagnostics, center.id(), center.naturalPoint(), false,
|
||||
longest == 0D ? HydrologyCandidateRejection.NO_DRAINAGE_PATH
|
||||
: longest < settings.minimumLength() ? HydrologyCandidateRejection.COURSE_TOO_SHORT
|
||||
@@ -179,26 +331,70 @@ final class HydrologyRegionalPlanner {
|
||||
}
|
||||
}
|
||||
HydrologyRegionalSourceTrials sourceTrials = new HydrologyRegionalSourceTrials(grid, tree, sources);
|
||||
for (int trials = 0; trials < MAXIMUM_SOURCE_TRIALS; trials++) {
|
||||
if (accepted.size() >= settings.maximumTrunks()) {
|
||||
break;
|
||||
}
|
||||
SourceBuildContext sourceContext = new SourceBuildContext(grid, tree, basinSeed);
|
||||
int trials = 0;
|
||||
while (trials < MAXIMUM_SOURCE_TRIALS && accepted.size() < settings.maximumTrunks()) {
|
||||
int window = Math.min(SOURCE_TRIAL_WINDOW, MAXIMUM_SOURCE_TRIALS - trials);
|
||||
ArrayList<Callable<SourceTrial>> tasks = new ArrayList<>(window);
|
||||
for (int index = 0; index < window; index++) {
|
||||
OptionalInt selected = sourceTrials.next(usedOutlets);
|
||||
if (selected.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
int source = selected.getAsInt();
|
||||
OutletCandidate outlet = tree.outlets().get(tree.root(source));
|
||||
HydrologyRegionalNetwork candidate = build(grid, tree, source, null, basinSeed, diagnostics);
|
||||
tasks.add(() -> buildSourceTrial(sourceContext, source));
|
||||
}
|
||||
if (tasks.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
List<SourceTrial> completed = HydrologyForkJoin.invokeAll(tasks,
|
||||
IrisPlatforms.isBound() ? MultiBurst.hydrology : null);
|
||||
for (SourceTrial trial : completed) {
|
||||
if (accepted.size() >= settings.maximumTrunks()) {
|
||||
break;
|
||||
}
|
||||
if (usedOutlets.contains(trial.outletId())) {
|
||||
continue;
|
||||
}
|
||||
trials++;
|
||||
if (trial.failure() instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (trial.failure() instanceof RuntimeException failure) {
|
||||
throw failure;
|
||||
}
|
||||
int retained = Math.min(MAXIMUM_DIAGNOSTICS - diagnostics.size(), trial.diagnostics().size());
|
||||
diagnostics.addAll(trial.diagnostics().subList(0, retained));
|
||||
HydrologyRegionalNetwork candidate = trial.network();
|
||||
if (!candidate.courses().isEmpty() && !conflicts(candidate.courses().getFirst(), merge(accepted).courses())) {
|
||||
accepted.add(candidate);
|
||||
usedOutlets.add(outlet.outlet().id());
|
||||
usedOutlets.add(trial.outletId());
|
||||
}
|
||||
}
|
||||
}
|
||||
HydrologyRegionalNetwork network = merge(accepted);
|
||||
return new HydrologyRegionalNetwork(network.nodes(), network.edges(), network.outlets(), network.courses(), diagnostics, network.cavePlans());
|
||||
}
|
||||
|
||||
private SourceTrial buildSourceTrial(SourceBuildContext context, int source) {
|
||||
long outletId = context.tree().outlets().get(context.tree().root(source)).outlet().id();
|
||||
ArrayList<HydrologyDiagnosticCandidate> diagnostics = new ArrayList<>();
|
||||
HydrologyPlanner.PlanningSamples previous = planner.planningSamples.get();
|
||||
planner.planningSamples.set(new HydrologyPlanner.PlanningSamples());
|
||||
try {
|
||||
HydrologyRegionalNetwork network = build(context.grid(), context.tree(), source, null, context.basinSeed(), diagnostics);
|
||||
return new SourceTrial(outletId, network, List.copyOf(diagnostics), null);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
return new SourceTrial(outletId, null, List.of(), failure);
|
||||
} finally {
|
||||
if (previous == null) {
|
||||
planner.planningSamples.remove();
|
||||
} else {
|
||||
planner.planningSamples.set(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private HydrologyRegionalNetwork coastalChannel(HydrologySampledGrid grid, List<OutletCandidate> outlets,
|
||||
long basinSeed, List<HydrologyDiagnosticCandidate> diagnostics) {
|
||||
int maximum = Math.min(8, outlets.size());
|
||||
@@ -536,29 +732,6 @@ final class HydrologyRegionalPlanner {
|
||||
combined.courses(), combined.cavePlans());
|
||||
}
|
||||
|
||||
HydrologyCaveCourseFilter.Result withoutRegional(HydrologyCaveCourseFilter.Result result, HydrologyTileKey key) {
|
||||
if (!enabled()) {
|
||||
return result;
|
||||
}
|
||||
Set<Long> regionalIds = new HashSet<>();
|
||||
for (RiverCourse course : coursesIn(ownerBounds(key)).courses()) {
|
||||
regionalIds.add(course.id());
|
||||
}
|
||||
ArrayList<RiverCourse> local = new ArrayList<>();
|
||||
for (RiverCourse course : result.courses()) {
|
||||
if (!regionalIds.contains(course.id())) {
|
||||
local.add(course);
|
||||
}
|
||||
}
|
||||
ArrayList<HydrologyCavePlan> plans = new ArrayList<>();
|
||||
for (HydrologyCavePlan plan : result.cavePlans()) {
|
||||
if (!regionalIds.contains(plan.source().sourceId())) {
|
||||
plans.add(plan);
|
||||
}
|
||||
}
|
||||
return new HydrologyCaveCourseFilter.Result(result.nodes(), result.edges(), result.outlets(), local, plans);
|
||||
}
|
||||
|
||||
private int footprintRadius() {
|
||||
return (int) StrictMath.ceil(planner.settings.surface().maximumWidth() * 2D
|
||||
+ Math.max(planner.settings.surface().shoreWidth(), planner.settings.widestShoreBiomeWidth())
|
||||
@@ -749,6 +922,13 @@ final class HydrologyRegionalPlanner {
|
||||
new ArrayList<>(outlets.values()), new ArrayList<>(courses.values()), new ArrayList<>(diagnostics.values()), new ArrayList<>(plans.values()));
|
||||
}
|
||||
|
||||
private record SourceBuildContext(HydrologySampledGrid grid, HydrologyRegionalGraph.Tree tree, long basinSeed) {
|
||||
}
|
||||
|
||||
private record SourceTrial(long outletId, HydrologyRegionalNetwork network,
|
||||
List<HydrologyDiagnosticCandidate> diagnostics, Throwable failure) {
|
||||
}
|
||||
|
||||
private record BuildContext(HydrologyGridNode source, OutletCandidate origin, long basinSeed, RiverOutlet outlet,
|
||||
String profile, HydrologyRegionalFlow flow, HydrologyRegionalMorphology morphology) {
|
||||
}
|
||||
|
||||
+84
-1
@@ -3,14 +3,19 @@ package art.arcane.iris.generation.hydrology;
|
||||
import art.arcane.volmlib.util.noise.SimplexNoise;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ForkJoinWorkerThread;
|
||||
|
||||
final class HydrologyRegionalRoute {
|
||||
private static final double SAMPLE_SPACING = 4D;
|
||||
private static final double SIMPLEX_COORDINATE_SCALE = 100D;
|
||||
private static final int MAXIMUM_RECEIVER_SAMPLES = 65536;
|
||||
private static final int CORRIDOR_SAMPLE_BATCH = 128;
|
||||
private static final int MAXIMUM_CORRIDOR_WORKERS = 8;
|
||||
|
||||
private final HydrologyPlanner planner;
|
||||
private final HydrologyRegionalTerrainRefiner terrainRefiner;
|
||||
@@ -333,6 +338,7 @@ final class HydrologyRegionalRoute {
|
||||
}
|
||||
|
||||
private Refinement validateCorridor(List<HydrologyPoint> points, String profile, boolean coastal) {
|
||||
CorridorSamples samples = new CorridorSamples(points);
|
||||
Set<Long> visited = new HashSet<>();
|
||||
HydrologyTerrainSample previousTerrain = null;
|
||||
HydrologyPoint previousPoint = null;
|
||||
@@ -345,7 +351,7 @@ final class HydrologyRegionalRoute {
|
||||
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);
|
||||
HydrologyTerrainSample terrain = samples.next(index, step, x, z);
|
||||
HydrologyPoint failure = new HydrologyPoint(x, terrain == null ? first.y() : terrain.naturalHeight(), z);
|
||||
if (!visited.add(RiverFootprint.pack(x, z))) {
|
||||
return new Refinement(List.of(), failure, HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, 2, null);
|
||||
@@ -568,6 +574,83 @@ final class HydrologyRegionalRoute {
|
||||
+ (-before + 3D * start - 3D * end + after) * progress * progress * progress);
|
||||
}
|
||||
|
||||
private final class CorridorSamples {
|
||||
private final List<HydrologyPoint> points;
|
||||
private final int workers;
|
||||
private final int[] xs;
|
||||
private final int[] zs;
|
||||
private final HydrologyTerrainSample[] terrain;
|
||||
private final RuntimeException[] failures;
|
||||
private int count;
|
||||
private int cursor;
|
||||
|
||||
private CorridorSamples(List<HydrologyPoint> points) {
|
||||
this.points = points;
|
||||
workers = Thread.currentThread() instanceof ForkJoinWorkerThread worker
|
||||
? Math.min(MAXIMUM_CORRIDOR_WORKERS, worker.getPool().getParallelism()) : 1;
|
||||
xs = workers > 1 ? new int[CORRIDOR_SAMPLE_BATCH] : null;
|
||||
zs = workers > 1 ? new int[CORRIDOR_SAMPLE_BATCH] : null;
|
||||
terrain = workers > 1 ? new HydrologyTerrainSample[CORRIDOR_SAMPLE_BATCH] : null;
|
||||
failures = workers > 1 ? new RuntimeException[CORRIDOR_SAMPLE_BATCH] : null;
|
||||
}
|
||||
|
||||
private HydrologyTerrainSample next(int segment, int step, int x, int z) {
|
||||
if (workers == 1) {
|
||||
return terrainRefiner.sample(x, z);
|
||||
}
|
||||
if (cursor == count) {
|
||||
fill(segment, step);
|
||||
}
|
||||
int index = cursor++;
|
||||
if (failures[index] != null) {
|
||||
throw failures[index];
|
||||
}
|
||||
return terrain[index];
|
||||
}
|
||||
|
||||
private void fill(int segment, int firstStep) {
|
||||
count = 0;
|
||||
cursor = 0;
|
||||
Arrays.fill(failures, null);
|
||||
for (int index = segment; index + 1 < points.size() && count < CORRIDOR_SAMPLE_BATCH; 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()));
|
||||
for (int step = index == segment ? firstStep : 1; step <= steps && count < CORRIDOR_SAMPLE_BATCH; step++) {
|
||||
double progress = steps == 0 ? 0D : step / (double) steps;
|
||||
xs[count] = (int) StrictMath.round(first.x() + (second.x() - first.x()) * progress);
|
||||
zs[count++] = (int) StrictMath.round(first.z() + (second.z() - first.z()) * progress);
|
||||
}
|
||||
}
|
||||
int taskCount = Math.min(workers, Math.ceilDiv(count, 16));
|
||||
if (taskCount < 2) {
|
||||
sampleRange(0, count);
|
||||
return;
|
||||
}
|
||||
int width = Math.ceilDiv(count, taskCount);
|
||||
ArrayList<Callable<Void>> tasks = new ArrayList<>(taskCount);
|
||||
for (int index = 0; index < taskCount; index++) {
|
||||
int start = index * width;
|
||||
int end = Math.min(count, start + width);
|
||||
tasks.add(() -> {
|
||||
sampleRange(start, end);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
HydrologyForkJoin.invokeAll(tasks, null);
|
||||
}
|
||||
|
||||
private void sampleRange(int start, int end) {
|
||||
for (int index = start; index < end; index++) {
|
||||
try {
|
||||
terrain[index] = terrainRefiner.sample(xs[index], zs[index]);
|
||||
} catch (RuntimeException failure) {
|
||||
failures[index] = failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class CandidateSelection {
|
||||
private final CandidateAdmission admission;
|
||||
private final OceanEntry resolved;
|
||||
|
||||
+108
-44
@@ -1,43 +1,54 @@
|
||||
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.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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 Cache<Edge, Reach> edges;
|
||||
private final HydrologyRegionalHydraulics hydraulics;
|
||||
private final Cache<Long, Terrain> terrain;
|
||||
private volatile CacheState caches;
|
||||
|
||||
HydrologyRegionalTerrainRefiner(HydrologyPlanner planner) {
|
||||
this.planner = planner;
|
||||
this.hydraulics = new HydrologyRegionalHydraulics(planner.settings);
|
||||
this.edges = Caffeine.newBuilder().maximumSize(128).build();
|
||||
this.terrain = Caffeine.newBuilder().maximumSize(MAXIMUM_SAMPLES).build();
|
||||
this.caches = new CacheState();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
edges.invalidateAll();
|
||||
terrain.invalidateAll();
|
||||
caches = new CacheState();
|
||||
}
|
||||
|
||||
HydrologyTerrainSample sample(int x, int z) {
|
||||
return terrain.get(RiverFootprint.pack(x, z), key -> new Terrain(planner.naturalSampler == null
|
||||
? planner.sampler.sample(x, z) : planner.naturalSampler.sampleBasisWithoutSlope(x, z))).sample();
|
||||
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) {
|
||||
@@ -61,7 +72,8 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
double length = 0D;
|
||||
ArrayList<HydrologyPoint> refined = new ArrayList<>();
|
||||
refined.add(guide.getFirst());
|
||||
Map<Long, Integer> stations = new HashMap<>();
|
||||
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;
|
||||
@@ -70,7 +82,7 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
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 = edges.get(edge, this::route);
|
||||
reach = edge(edge);
|
||||
if (!reach.points().isEmpty()) {
|
||||
selected = candidate;
|
||||
break;
|
||||
@@ -88,8 +100,8 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
for (int point = 1; point < points.size(); point++) {
|
||||
HydrologyPoint next = points.get(point);
|
||||
long key = RiverFootprint.pack(next.x(), next.z());
|
||||
Integer previous = stations.get(key);
|
||||
if (previous != null) {
|
||||
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()));
|
||||
@@ -158,7 +170,7 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
|
||||
private List<HydrologyPoint> simplify(List<HydrologyPoint> points, String profile, boolean coastal, Terminal terminal) {
|
||||
double maximumReach = Math.min(512D, planner.settings.routing().regional().sampleSpacing() * 2D);
|
||||
Map<Long, HydrologyTerrainSample> samples = new HashMap<>();
|
||||
Samples samples = new Samples();
|
||||
ArrayList<HydrologyPoint> simplified = new ArrayList<>();
|
||||
int current = 0;
|
||||
int[] requiredHead = requiredHeads(points, terminal, 1);
|
||||
@@ -203,7 +215,7 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
}
|
||||
|
||||
private Reach route(Edge edge) {
|
||||
Map<Long, HydrologyTerrainSample> samples = new HashMap<>();
|
||||
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());
|
||||
@@ -213,7 +225,7 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
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;
|
||||
Map<Long, List<Node>> visited = new HashMap<>();
|
||||
Long2ObjectOpenHashMap<List<Node>> visited = new Long2ObjectOpenHashMap<>();
|
||||
PriorityQueue<Pending> pending = new PriorityQueue<>(Comparator.comparingDouble(Pending::estimate)
|
||||
.thenComparingLong(Pending::key)
|
||||
.thenComparing(Comparator.comparingInt(Pending::availableHead).reversed()));
|
||||
@@ -249,10 +261,35 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
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) {
|
||||
HydrologyTerrainSample terrain = sample(target.x(), target.z(), search.samples());
|
||||
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;
|
||||
return null;
|
||||
}
|
||||
HydrologyPoint point = new HydrologyPoint(target.x(), terrain.naturalHeight(), target.z());
|
||||
double cost = current.cost() + distance(current.point(), point)
|
||||
@@ -261,30 +298,39 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
long key = RiverFootprint.pack(point.x(), point.z());
|
||||
List<Node> labels = search.visited().get(key);
|
||||
if (dominated(labels, cost, current.availableHead())) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
Traversal traversal = traverse(current.point(), point, search.edge(), search.samples(), current.availableHead());
|
||||
Traversal traversal = traverse(current.point(), point, search.edge(), samples, current.availableHead());
|
||||
cost += traversal.cost();
|
||||
if (!Double.isFinite(cost) || dominated(labels, cost, traversal.availableHead())) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
cost += clearanceCost(point, search.edge(), search.samples());
|
||||
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 (cost <= previous.cost() && traversal.availableHead() >= previous.availableHead()) {
|
||||
if (offer.cost() <= previous.cost() && offer.availableHead() >= previous.availableHead()) {
|
||||
labels.remove(index);
|
||||
}
|
||||
}
|
||||
Node retained = new Node(point, cost, traversal.availableHead(), current);
|
||||
Node retained = new Node(offer.point(), offer.cost(), offer.availableHead(), current);
|
||||
labels.add(retained);
|
||||
search.pending().add(new Pending(key, retained, cost + distance(point, search.edge().end())));
|
||||
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) {
|
||||
@@ -309,26 +355,25 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
}
|
||||
|
||||
private Traversal traverse(HydrologyPoint start, HydrologyPoint end, Edge edge,
|
||||
Map<Long, HydrologyTerrainSample> samples, int availableHead) {
|
||||
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;
|
||||
HydrologyTerrainSampler bankSampler = (x, z) -> sample(x, z, samples);
|
||||
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 = sample(x, z, samples);
|
||||
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), bankSampler);
|
||||
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);
|
||||
}
|
||||
@@ -356,7 +401,7 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
return Math.max(0D, excess) * Math.max(1D, planner.settings.routing().uphillPenalty());
|
||||
}
|
||||
|
||||
private double clearanceCost(HydrologyPoint point, Edge edge, Map<Long, HydrologyTerrainSample> samples) {
|
||||
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;
|
||||
@@ -364,7 +409,7 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
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(sample(x, z, samples), edge)) {
|
||||
if (!allowed(samples.sample(x, z), edge)) {
|
||||
blocked++;
|
||||
}
|
||||
}
|
||||
@@ -379,17 +424,6 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
<= planner.settings.routing().regional().maximumCoastalIncision());
|
||||
}
|
||||
|
||||
private HydrologyTerrainSample sample(int x, int z, Map<Long, HydrologyTerrainSample> samples) {
|
||||
long key = RiverFootprint.pack(x, z);
|
||||
HydrologyTerrainSample sampled = samples.get(key);
|
||||
if (sampled != null || samples.containsKey(key) || samples.size() >= MAXIMUM_SAMPLES) {
|
||||
return sampled;
|
||||
}
|
||||
sampled = sample(x, z);
|
||||
samples.put(key, sampled);
|
||||
return sampled;
|
||||
}
|
||||
|
||||
private static List<HydrologyPoint> path(Node current) {
|
||||
ArrayList<HydrologyPoint> points = new ArrayList<>();
|
||||
while (current != null) {
|
||||
@@ -404,6 +438,36 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
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 = Caffeine.newBuilder().maximumSize(128).build();
|
||||
private final ConcurrentHashMap<Edge, HydrologyForkJoin.Task<Reach>> loading = new ConcurrentHashMap<>();
|
||||
private final Cache<Long, Terrain> terrain = Caffeine.newBuilder().maximumSize(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) {
|
||||
}
|
||||
@@ -420,7 +484,7 @@ final class HydrologyRegionalTerrainRefiner {
|
||||
private record Terrain(HydrologyTerrainSample sample) {
|
||||
}
|
||||
|
||||
private record Search(Edge edge, Map<Long, HydrologyTerrainSample> samples, Map<Long, List<Node>> visited,
|
||||
private record Search(Edge edge, Samples samples, Long2ObjectOpenHashMap<List<Node>> visited,
|
||||
PriorityQueue<Pending> pending) {
|
||||
}
|
||||
|
||||
|
||||
@@ -1077,7 +1077,6 @@ final class HydrologyRouteGeometry {
|
||||
RouteCandidate start,
|
||||
RouteCandidate end
|
||||
) {
|
||||
int minimumBankDistance = planner.surfaceCourses.surfaceBankDistance(planner.settings.surface().minimumWidth());
|
||||
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());
|
||||
@@ -1103,10 +1102,9 @@ final class HydrologyRouteGeometry {
|
||||
terrain.naturalHeight(),
|
||||
planner.settings.surface().banks().sink()
|
||||
);
|
||||
penalty += planner.surfaceCourses.surfaceRouteBankPenalty(
|
||||
penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty(
|
||||
point,
|
||||
tangent,
|
||||
minimumBankDistance,
|
||||
maximumBankDistance,
|
||||
head,
|
||||
planner.surfaceCourses.maximumSurfaceBankRise(terrain)
|
||||
@@ -1129,10 +1127,9 @@ final class HydrologyRouteGeometry {
|
||||
terrain.naturalHeight(),
|
||||
planner.settings.surface().banks().sink()
|
||||
);
|
||||
penalty += planner.surfaceCourses.surfaceRouteBankPenalty(
|
||||
penalty += planner.surfaceCourses.surfaceRouteBankBandPenalty(
|
||||
point,
|
||||
tangent,
|
||||
minimumBankDistance,
|
||||
maximumBankDistance,
|
||||
head,
|
||||
planner.surfaceCourses.maximumSurfaceBankRise(terrain)
|
||||
|
||||
+118
-9
@@ -1,5 +1,6 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
@@ -281,8 +282,10 @@ final class HydrologySourcePlanner {
|
||||
candidates.add(new SourceCandidate(node.index(), stable, score, required));
|
||||
hasRequiredCandidate |= required;
|
||||
}
|
||||
HashMap<Long, Boolean> admissions = new HashMap<>();
|
||||
if (surfaceBudgets != null && surfaceBudgets.overridden()) {
|
||||
return selectLocalSurfaceSources(key, grid, routing, candidates, surfaceBudgets, enforceGlobalSpacing, routingContexts);
|
||||
return selectLocalSurfaceSources(key, grid, routing, candidates, surfaceBudgets, enforceGlobalSpacing,
|
||||
routingContexts, admissions);
|
||||
}
|
||||
int target = expectedCount(sourceSettings.density(), HydrologyHash.mix(
|
||||
planner.worldSeed,
|
||||
@@ -330,7 +333,8 @@ final class HydrologySourcePlanner {
|
||||
sourceSettings,
|
||||
sourceSalt,
|
||||
surface,
|
||||
routingContexts
|
||||
routingContexts,
|
||||
admissions
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -358,7 +362,8 @@ final class HydrologySourcePlanner {
|
||||
List<SourceCandidate> candidates,
|
||||
HydrologySurfaceBudgets budgets,
|
||||
boolean enforceGlobalSpacing,
|
||||
Map<HydrologyTileKey, SourceRoutingContext> routingContexts
|
||||
Map<HydrologyTileKey, SourceRoutingContext> routingContexts,
|
||||
Map<Long, Boolean> admissions
|
||||
) {
|
||||
candidates.sort(Comparator.comparing(SourceCandidate::required).reversed()
|
||||
.thenComparing(Comparator.comparingDouble(SourceCandidate::score).reversed())
|
||||
@@ -404,7 +409,7 @@ final class HydrologySourcePlanner {
|
||||
boolean[] evaluated = new boolean[count];
|
||||
IntPredicate admitted = candidateIndex -> !enforceGlobalSpacing || globallyAdmittedSource(
|
||||
grid.node(candidates.get(candidateIndex).nodeIndex()), planner.settings.surface().sources(),
|
||||
SURFACE_SOURCE_SALT, true, routingContexts);
|
||||
SURFACE_SOURCE_SALT, true, routingContexts, admissions);
|
||||
ArrayList<Integer> selectedIndices = new ArrayList<>(Math.min(count, target));
|
||||
int[] selectedByArea = new int[areas.size()];
|
||||
int[] selectedRequiredByArea = new int[areas.size()];
|
||||
@@ -585,7 +590,73 @@ final class HydrologySourcePlanner {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedy min-separation admission over the lattice. A candidate yields its slot only to a higher-priority
|
||||
* neighbour that is itself admitted, so a contender that loses its own contest costs nobody a source; under the
|
||||
* old strict local maximum a doomed contender still suppressed everything beneath it. Contenders outrank the
|
||||
* node they contest, so the walk is acyclic; {@code admissions} memoizes every decision by packed source
|
||||
* coordinate and belongs to one surface or underground selection pass.
|
||||
*/
|
||||
boolean globallyAdmittedSource(
|
||||
HydrologyGridNode candidate,
|
||||
HydrologyPlannerSettings.Source sourceSettings,
|
||||
long sourceSalt,
|
||||
boolean surface,
|
||||
Map<HydrologyTileKey, SourceRoutingContext> routingContexts,
|
||||
Map<Long, Boolean> admissions
|
||||
) {
|
||||
long candidateKey = RiverFootprint.pack(candidate.x(), candidate.z());
|
||||
Boolean decided = admissions.get(candidateKey);
|
||||
if (decided != null) {
|
||||
return decided;
|
||||
}
|
||||
ArrayDeque<SourceContest> contests = new ArrayDeque<>();
|
||||
contests.push(sourceContest(candidate, sourceSettings, sourceSalt, surface, routingContexts));
|
||||
while (!contests.isEmpty()) {
|
||||
SourceContest contest = contests.peek();
|
||||
boolean descended = false;
|
||||
boolean suppressed = false;
|
||||
while (contest.cursor < contest.contenders.size()) {
|
||||
HydrologyGridNode contender = contest.contenders.get(contest.cursor);
|
||||
Boolean contenderAdmitted = admissions.get(RiverFootprint.pack(contender.x(), contender.z()));
|
||||
if (contenderAdmitted == null) {
|
||||
contests.push(sourceContest(contender, sourceSettings, sourceSalt, surface, routingContexts));
|
||||
descended = true;
|
||||
break;
|
||||
}
|
||||
contest.cursor++;
|
||||
if (contenderAdmitted) {
|
||||
suppressed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (descended) {
|
||||
continue;
|
||||
}
|
||||
admissions.put(contest.key, !suppressed);
|
||||
contests.pop();
|
||||
}
|
||||
return admissions.get(candidateKey);
|
||||
}
|
||||
|
||||
private SourceContest sourceContest(
|
||||
HydrologyGridNode node,
|
||||
HydrologyPlannerSettings.Source sourceSettings,
|
||||
long sourceSalt,
|
||||
boolean surface,
|
||||
Map<HydrologyTileKey, SourceRoutingContext> routingContexts
|
||||
) {
|
||||
return new SourceContest(
|
||||
RiverFootprint.pack(node.x(), node.z()),
|
||||
higherPrioritySources(node, sourceSettings, sourceSalt, surface, routingContexts)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every eligible lattice source inside {@code candidate}'s spacing that outranks it, best first. Required heads
|
||||
* and unspaced underground sources contest nothing and so are admitted outright.
|
||||
*/
|
||||
private List<HydrologyGridNode> higherPrioritySources(
|
||||
HydrologyGridNode candidate,
|
||||
HydrologyPlannerSettings.Source sourceSettings,
|
||||
long sourceSalt,
|
||||
@@ -599,12 +670,13 @@ final class HydrologySourcePlanner {
|
||||
? candidate.terrain().surfaceSourceRequired()
|
||||
: candidate.terrain().undergroundSourceRequired();
|
||||
if ((!surface && minimumSpacing <= 0) || required) {
|
||||
return true;
|
||||
return List.of();
|
||||
}
|
||||
SourcePriority candidatePriority = sourcePriority(candidate.x(), candidate.z(), candidate.terrain(), sourceSalt, surface);
|
||||
int sampleSpacing = planner.settings.routing().sampleSpacing();
|
||||
int searchSpacing = surface ? Math.max(minimumSpacing, planner.settings.maximumSurfaceSourceSpacing()) : minimumSpacing;
|
||||
int latticeRadius = (int) StrictMath.ceil(searchSpacing / (double) sampleSpacing);
|
||||
ArrayList<SourceContender> contenders = new ArrayList<>();
|
||||
for (int offsetZ = -latticeRadius; offsetZ <= latticeRadius; offsetZ++) {
|
||||
for (int offsetX = -latticeRadius; offsetX <= latticeRadius; offsetX++) {
|
||||
if (offsetX == 0 && offsetZ == 0) {
|
||||
@@ -624,8 +696,7 @@ final class HydrologySourcePlanner {
|
||||
}
|
||||
int x = (int) neighborX;
|
||||
int z = (int) neighborZ;
|
||||
HydrologyTileKey owner = HydrologyTileKey.fromBlock(x, z, planner.settings.routing().tileSize());
|
||||
SourceRoutingContext context = routingContexts.computeIfAbsent(owner, planner::sourceRoutingContext);
|
||||
SourceRoutingContext context = sourceContext(x, z, routingContexts);
|
||||
HydrologyGridNode neighbor = context.grid().nodeAtWorld(x, z);
|
||||
if (neighbor == null) {
|
||||
throw new IllegalStateException("Source coordinate is absent from its owner routing lattice.");
|
||||
@@ -645,11 +716,28 @@ final class HydrologySourcePlanner {
|
||||
}
|
||||
SourcePriority neighborPriority = sourcePriority(x, z, terrain, sourceSalt, surface);
|
||||
if (compareSourcePriority(neighborPriority, candidatePriority) > 0) {
|
||||
return false;
|
||||
contenders.add(new SourceContender(neighbor, neighborPriority));
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
// Best first: the strongest contender is the likeliest to hold its own slot and end the walk at depth one.
|
||||
contenders.sort((first, second) -> compareSourcePriority(second.priority(), first.priority()));
|
||||
ArrayList<HydrologyGridNode> ordered = new ArrayList<>(contenders.size());
|
||||
for (SourceContender contender : contenders) {
|
||||
ordered.add(contender.node());
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private SourceRoutingContext sourceContext(
|
||||
int x,
|
||||
int z,
|
||||
Map<HydrologyTileKey, SourceRoutingContext> routingContexts
|
||||
) {
|
||||
return routingContexts.computeIfAbsent(
|
||||
HydrologyTileKey.fromBlock(x, z, planner.settings.routing().tileSize()),
|
||||
planner::sourceRoutingContext
|
||||
);
|
||||
}
|
||||
|
||||
SourceRoutingContext compileSourceRoutingContext(HydrologyTileKey key) {
|
||||
@@ -720,6 +808,12 @@ final class HydrologySourcePlanner {
|
||||
return required || weight > 0D;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terrain only, never the routing plan: route length and potential are measured against the outlets of the
|
||||
* tile that owns the node, so scoring the contest with them ranks neighbours either side of a tile seam
|
||||
* against different outlet sets and the winner flips with ownership. Ordering ends in x then z, so distinct
|
||||
* lattice nodes never tie and the contest graph stays acyclic.
|
||||
*/
|
||||
SourcePriority sourcePriority(
|
||||
int x,
|
||||
int z,
|
||||
@@ -1028,4 +1122,19 @@ final class HydrologySourcePlanner {
|
||||
int z
|
||||
) {
|
||||
}
|
||||
|
||||
private record SourceContender(HydrologyGridNode node, SourcePriority priority) {
|
||||
}
|
||||
|
||||
/** One node's unresolved spacing contest: its contenders, and how many of them already answered. */
|
||||
private static final class SourceContest {
|
||||
private final long key;
|
||||
private final List<HydrologyGridNode> contenders;
|
||||
private int cursor;
|
||||
|
||||
private SourceContest(long key, List<HydrologyGridNode> contenders) {
|
||||
this.key = key;
|
||||
this.contenders = contenders;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-6
@@ -653,7 +653,6 @@ final class HydrologySurfaceCoursePlanner {
|
||||
}
|
||||
|
||||
double surfaceRouteCandidateBankPenalty(RouteCandidate candidate) {
|
||||
int minimumBankDistance = surfaceBankDistance(planner.settings.surface().minimumWidth());
|
||||
int maximumBankDistance = surfaceBankDistance(planner.settings.surface().maximumWidth());
|
||||
HydrologyPoint point = candidate.point();
|
||||
HydrologyTerrainSample terrain = planner.sampleLandBasis(point.x(), point.z());
|
||||
@@ -667,10 +666,9 @@ final class HydrologySurfaceCoursePlanner {
|
||||
terrain.naturalHeight(),
|
||||
planner.settings.surface().banks().sink()
|
||||
);
|
||||
return surfaceRouteBankPenalty(
|
||||
return surfaceRouteBankBandPenalty(
|
||||
point,
|
||||
candidate.tangent(),
|
||||
minimumBankDistance,
|
||||
maximumBankDistance,
|
||||
head,
|
||||
maximumSurfaceBankRise(terrain)
|
||||
@@ -684,16 +682,16 @@ final class HydrologySurfaceCoursePlanner {
|
||||
);
|
||||
}
|
||||
|
||||
double surfaceRouteBankPenalty(
|
||||
double surfaceRouteBankBandPenalty(
|
||||
HydrologyPoint point,
|
||||
RouteDirection tangent,
|
||||
int minimumDistance,
|
||||
int maximumDistance,
|
||||
int head,
|
||||
int maximumBankRise
|
||||
) {
|
||||
int innerDistance = surfaceBankInnerDistance();
|
||||
double penalty = 0D;
|
||||
for (int distance = minimumDistance; distance <= maximumDistance; distance++) {
|
||||
for (int distance = innerDistance; distance <= maximumDistance; distance++) {
|
||||
penalty += surfaceRouteBankPenalty(point, tangent, distance, head, maximumBankRise);
|
||||
penalty += surfaceRouteBankPenalty(point, tangent, -distance, head, maximumBankRise);
|
||||
}
|
||||
@@ -720,6 +718,25 @@ final class HydrologySurfaceCoursePlanner {
|
||||
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(
|
||||
|
||||
@@ -26,8 +26,9 @@ public final class HydrologyTile {
|
||||
private final List<DrainageEdge> edges;
|
||||
private final List<RiverOutlet> outlets;
|
||||
private final List<RiverCourse> courses;
|
||||
private final Set<Long> regionalCourseIds;
|
||||
private final List<HydrologyCavePlan> cavePlans;
|
||||
private final List<HydrologyDiagnosticCandidate> diagnosticCandidates;
|
||||
private final List<HydrologyDiagnosticCandidate> localDiagnosticCandidates;
|
||||
private final RiverFootprint footprint;
|
||||
private final Map<Long, DrainageNode> nodesById;
|
||||
private final Map<Long, RiverOutlet> outletsById;
|
||||
@@ -43,8 +44,9 @@ public final class HydrologyTile {
|
||||
List<DrainageEdge> edges,
|
||||
List<RiverOutlet> outlets,
|
||||
List<RiverCourse> courses,
|
||||
Set<Long> regionalCourseIds,
|
||||
List<HydrologyCavePlan> cavePlans,
|
||||
List<HydrologyDiagnosticCandidate> diagnosticCandidates,
|
||||
List<HydrologyDiagnosticCandidate> localDiagnosticCandidates,
|
||||
RiverFootprint footprint
|
||||
) {
|
||||
this.key = Objects.requireNonNull(key, "key");
|
||||
@@ -58,12 +60,13 @@ public final class HydrologyTile {
|
||||
this.edges = sortedCopy(edges, Comparator.comparingLong(DrainageEdge::id));
|
||||
this.outlets = sortedCopy(outlets, Comparator.comparingLong(RiverOutlet::id));
|
||||
this.courses = sortedCopy(courses, Comparator.comparingLong(RiverCourse::id));
|
||||
this.regionalCourseIds = Set.copyOf(Objects.requireNonNull(regionalCourseIds, "regionalCourseIds"));
|
||||
this.cavePlans = sortedCopy(
|
||||
cavePlans,
|
||||
Comparator.comparingLong((HydrologyCavePlan plan) -> plan.source().sourceId())
|
||||
);
|
||||
this.diagnosticCandidates = sortedCopy(
|
||||
diagnosticCandidates,
|
||||
this.localDiagnosticCandidates = sortedCopy(
|
||||
localDiagnosticCandidates,
|
||||
Comparator.comparingLong(HydrologyDiagnosticCandidate::id)
|
||||
);
|
||||
this.footprint = Objects.requireNonNull(footprint, "footprint");
|
||||
@@ -107,12 +110,16 @@ public final class HydrologyTile {
|
||||
return courses;
|
||||
}
|
||||
|
||||
public Set<Long> regionalCourseIds() {
|
||||
return regionalCourseIds;
|
||||
}
|
||||
|
||||
public List<HydrologyCavePlan> cavePlans() {
|
||||
return cavePlans;
|
||||
}
|
||||
|
||||
public List<HydrologyDiagnosticCandidate> diagnosticCandidates() {
|
||||
return diagnosticCandidates;
|
||||
public List<HydrologyDiagnosticCandidate> localDiagnosticCandidates() {
|
||||
return localDiagnosticCandidates;
|
||||
}
|
||||
|
||||
public RiverFootprint footprint() {
|
||||
@@ -139,13 +146,13 @@ public final class HydrologyTile {
|
||||
return footprint.renderSample(x, z);
|
||||
}
|
||||
|
||||
public HydrologyDiagnosticRenderSample diagnosticRenderAt(int x, int z, int maximumDistance) {
|
||||
public HydrologyDiagnosticRenderSample localDiagnosticRenderAt(int x, int z, int maximumDistance) {
|
||||
if (maximumDistance < 0) {
|
||||
throw new IllegalArgumentException("maximumDistance cannot be negative.");
|
||||
}
|
||||
long maximumDistanceSquared = (long) maximumDistance * maximumDistance;
|
||||
ArrayList<HydrologyDiagnosticCandidate> selected = new ArrayList<>();
|
||||
for (HydrologyDiagnosticCandidate candidate : diagnosticCandidates) {
|
||||
for (HydrologyDiagnosticCandidate candidate : localDiagnosticCandidates) {
|
||||
long distance = candidate.point().distanceSquared2D(new HydrologyPoint(x, candidate.point().y(), z));
|
||||
if (distance <= maximumDistanceSquared) {
|
||||
selected.add(candidate);
|
||||
@@ -266,6 +273,12 @@ public final class HydrologyTile {
|
||||
throw new IllegalArgumentException("Every cave-bearing course must have one accepted containment plan.");
|
||||
}
|
||||
}
|
||||
for (long courseId : regionalCourseIds) {
|
||||
RiverCourse course = coursesById.get(courseId);
|
||||
if (course == null || course.type() != RiverCourseType.SURFACE) {
|
||||
throw new IllegalArgumentException("Regional ownership must reference an accepted surface course.");
|
||||
}
|
||||
}
|
||||
validateFootprintCourses(coursesById);
|
||||
}
|
||||
|
||||
@@ -614,8 +627,9 @@ public final class HydrologyTile {
|
||||
&& edges.equals(tile.edges)
|
||||
&& outlets.equals(tile.outlets)
|
||||
&& courses.equals(tile.courses)
|
||||
&& regionalCourseIds.equals(tile.regionalCourseIds)
|
||||
&& cavePlans.equals(tile.cavePlans)
|
||||
&& diagnosticCandidates.equals(tile.diagnosticCandidates)
|
||||
&& localDiagnosticCandidates.equals(tile.localDiagnosticCandidates)
|
||||
&& footprint.equals(tile.footprint);
|
||||
}
|
||||
|
||||
@@ -630,8 +644,9 @@ public final class HydrologyTile {
|
||||
edges,
|
||||
outlets,
|
||||
courses,
|
||||
regionalCourseIds,
|
||||
cavePlans,
|
||||
diagnosticCandidates,
|
||||
localDiagnosticCandidates,
|
||||
footprint
|
||||
);
|
||||
}
|
||||
@@ -639,6 +654,6 @@ public final class HydrologyTile {
|
||||
@Override
|
||||
public String toString() {
|
||||
return "HydrologyTile[key=" + key + ", courses=" + courses.size() + ", candidates="
|
||||
+ diagnosticCandidates.size() + ", columns=" + footprint.size() + "]";
|
||||
+ localDiagnosticCandidates.size() + ", columns=" + footprint.size() + "]";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
private final int maximumEntries;
|
||||
private final Cache<HydrologyTileKey, HydrologyTile> tiles;
|
||||
private final Cache<Long, ChunkColumns> composedChunks;
|
||||
private final Cache<HydrologyTileKey, List<HydrologyDiagnosticCandidate>> diagnostics;
|
||||
private final AtomicLong cacheEpoch;
|
||||
private final AtomicBoolean closed;
|
||||
private final ThreadLocal<LocalChunkColumns> localChunkColumns;
|
||||
@@ -55,11 +56,14 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
private final ConcurrentHashMap<CacheLoadKey<HydrologyTileKey>, CompletableFuture<HydrologyTile>> planning;
|
||||
private final ConcurrentHashMap<CacheLoadKey<HydrologyTileKey>, PendingLoad<HydrologyTile>> loading;
|
||||
private final ConcurrentHashMap<CacheLoadKey<Long>, PendingLoad<ChunkColumns>> composing;
|
||||
private final ConcurrentHashMap<CacheLoadKey<HydrologyTileKey>, PendingLoad<List<HydrologyDiagnosticCandidate>>> diagnosing;
|
||||
private final ConcurrentHashMap<CacheLoadKey<HydrologyTileKey>, PendingLoad<List<HydrologyDiagnosticCandidate>>> loadingDiagnostics;
|
||||
private final Object publicationLock = new Object();
|
||||
private final BooleanSupplier waitingForbidden;
|
||||
private volatile SharedCacheScope sharedCacheScope;
|
||||
private volatile StudioHydrologyTileStore persistentStore;
|
||||
private volatile boolean neighbourPrefetchEnabled;
|
||||
private volatile Runnable terrainPreparation;
|
||||
private int demandBatches;
|
||||
|
||||
public HydrologyTileCache(HydrologyPlanner planner) {
|
||||
@@ -105,6 +109,8 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
this.planning = new ConcurrentHashMap<>();
|
||||
this.loading = new ConcurrentHashMap<>();
|
||||
this.composing = new ConcurrentHashMap<>();
|
||||
this.diagnosing = new ConcurrentHashMap<>();
|
||||
this.loadingDiagnostics = new ConcurrentHashMap<>();
|
||||
if (maximumEntries < 1) {
|
||||
throw new IllegalArgumentException("maximumEntries must be positive.");
|
||||
}
|
||||
@@ -119,6 +125,9 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
this.composedChunks = Caffeine.newBuilder()
|
||||
.maximumSize(MAXIMUM_COMPOSED_CHUNKS)
|
||||
.build();
|
||||
this.diagnostics = Caffeine.newBuilder()
|
||||
.maximumSize(maximumEntries)
|
||||
.build();
|
||||
this.cacheEpoch = new AtomicLong();
|
||||
this.closed = new AtomicBoolean();
|
||||
this.localChunkColumns = new ThreadLocal<>();
|
||||
@@ -142,6 +151,123 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
return awaitPlan(planDemandAsync(key));
|
||||
}
|
||||
|
||||
public List<HydrologyDiagnosticCandidate> diagnosticCandidates(HydrologyTileKey key) {
|
||||
Objects.requireNonNull(key, "key");
|
||||
requireOpen();
|
||||
List<HydrologyDiagnosticCandidate> present = diagnostics.getIfPresent(key);
|
||||
if (present != null) {
|
||||
return present;
|
||||
}
|
||||
if (waitsForbidden()) {
|
||||
throw new IllegalStateException("Cold hydrology diagnostics cannot run on a thread that may not wait.");
|
||||
}
|
||||
long epoch = beginDemandBatch();
|
||||
if (prefetchExecutor == null || plansInline()) {
|
||||
try {
|
||||
return loadDiagnostics(new CacheLoadKey<>(epoch, key));
|
||||
} finally {
|
||||
finishDemandBatch(epoch);
|
||||
}
|
||||
}
|
||||
CompletableFuture<List<HydrologyDiagnosticCandidate>> future;
|
||||
try {
|
||||
future = diagnoseAsync(new CacheLoadKey<>(epoch, key));
|
||||
} catch (Throwable failure) {
|
||||
finishDemandBatch(epoch);
|
||||
throw failure;
|
||||
}
|
||||
future.whenComplete((result, failure) -> finishDemandBatch(epoch));
|
||||
return awaitPlan(future);
|
||||
}
|
||||
|
||||
private List<HydrologyDiagnosticCandidate> loadDiagnostics(CacheLoadKey<HydrologyTileKey> loadKey) {
|
||||
HydrologyTileKey key = loadKey.key();
|
||||
List<HydrologyDiagnosticCandidate> present = diagnostics.getIfPresent(key);
|
||||
if (present != null) {
|
||||
return present;
|
||||
}
|
||||
PendingLoad<List<HydrologyDiagnosticCandidate>> owned =
|
||||
new PendingLoad<>(Thread.currentThread(), new CompletableFuture<>());
|
||||
PendingLoad<List<HydrologyDiagnosticCandidate>> existing;
|
||||
synchronized (publicationLock) {
|
||||
requireOpen();
|
||||
existing = loadingDiagnostics.putIfAbsent(loadKey, owned);
|
||||
}
|
||||
if (existing != null) {
|
||||
return awaitLoad(existing);
|
||||
}
|
||||
try {
|
||||
present = diagnostics.getIfPresent(key);
|
||||
if (present == null) {
|
||||
prepareTerrain();
|
||||
}
|
||||
List<HydrologyDiagnosticCandidate> result = present == null
|
||||
? List.copyOf(planner.diagnosticCandidates(planInline(key, loadKey.epoch()))) : present;
|
||||
synchronized (publicationLock) {
|
||||
if (!closed.get() && cacheEpoch.get() == loadKey.epoch()) {
|
||||
diagnostics.put(key, result);
|
||||
}
|
||||
}
|
||||
owned.future().complete(result);
|
||||
return result;
|
||||
} catch (Throwable failure) {
|
||||
owned.future().completeExceptionally(failure);
|
||||
throw failure;
|
||||
} finally {
|
||||
loadingDiagnostics.remove(loadKey, owned);
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<List<HydrologyDiagnosticCandidate>> diagnoseAsync(CacheLoadKey<HydrologyTileKey> loadKey) {
|
||||
List<HydrologyDiagnosticCandidate> present = diagnostics.getIfPresent(loadKey.key());
|
||||
if (present != null) {
|
||||
return CompletableFuture.completedFuture(present);
|
||||
}
|
||||
PendingLoad<List<HydrologyDiagnosticCandidate>> queued = new PendingLoad<>(null, new CompletableFuture<>());
|
||||
PendingLoad<List<HydrologyDiagnosticCandidate>> existing;
|
||||
synchronized (publicationLock) {
|
||||
if (closed.get()) {
|
||||
return CompletableFuture.failedFuture(new CancellationException("Hydrology tile cache is closed."));
|
||||
}
|
||||
existing = diagnosing.putIfAbsent(loadKey, queued);
|
||||
}
|
||||
if (existing != null) {
|
||||
return existing.future();
|
||||
}
|
||||
try {
|
||||
prefetchExecutor.execute(() -> runDiagnostics(loadKey, queued));
|
||||
} catch (RuntimeException | Error rejected) {
|
||||
diagnosing.remove(loadKey, queued);
|
||||
queued.future().completeExceptionally(rejected);
|
||||
}
|
||||
return queued.future();
|
||||
}
|
||||
|
||||
private void runDiagnostics(CacheLoadKey<HydrologyTileKey> loadKey,
|
||||
PendingLoad<List<HydrologyDiagnosticCandidate>> queued) {
|
||||
PendingLoad<List<HydrologyDiagnosticCandidate>> active =
|
||||
new PendingLoad<>(Thread.currentThread(), queued.future());
|
||||
boolean started;
|
||||
synchronized (publicationLock) {
|
||||
started = !closed.get() && diagnosing.replace(loadKey, queued, active);
|
||||
}
|
||||
if (!started) {
|
||||
queued.future().completeExceptionally(new CancellationException("Hydrology tile cache is closed."));
|
||||
diagnosing.remove(loadKey, queued);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
active.future().complete(loadDiagnostics(loadKey));
|
||||
} catch (Throwable failure) {
|
||||
active.future().completeExceptionally(failure);
|
||||
if (!(failure instanceof CancellationException) || !closed.get()) {
|
||||
IrisLogging.reportError(failure);
|
||||
}
|
||||
} finally {
|
||||
diagnosing.remove(loadKey, active);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean plansInline() {
|
||||
if (prefetchExecutor instanceof MultiBurst burst) {
|
||||
return burst.ownsCurrentThread();
|
||||
@@ -212,6 +338,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
return persisted;
|
||||
}
|
||||
}
|
||||
prepareTerrain();
|
||||
try {
|
||||
HydrologyTile planned = planner.plan(key);
|
||||
synchronized (publicationLock) {
|
||||
@@ -352,6 +479,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
planner.clearOwnerDrafts();
|
||||
tiles.invalidateAll();
|
||||
composedChunks.invalidateAll();
|
||||
diagnostics.invalidateAll();
|
||||
}
|
||||
prefetchQueue.clear();
|
||||
queuedPrefetches.clear();
|
||||
@@ -412,7 +540,7 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
@Override
|
||||
public void close() {
|
||||
ArrayList<CompletableFuture<?>> active = new ArrayList<>();
|
||||
ArrayList<CompletableFuture<HydrologyTile>> queued = new ArrayList<>();
|
||||
ArrayList<CompletableFuture<?>> queued = new ArrayList<>();
|
||||
synchronized (publicationLock) {
|
||||
for (PendingLoad<HydrologyTile> load : loading.values()) {
|
||||
collectActiveLoad(active, load);
|
||||
@@ -420,6 +548,16 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
for (PendingLoad<ChunkColumns> load : composing.values()) {
|
||||
collectActiveLoad(active, load);
|
||||
}
|
||||
for (PendingLoad<List<HydrologyDiagnosticCandidate>> load : loadingDiagnostics.values()) {
|
||||
collectActiveLoad(active, load);
|
||||
}
|
||||
for (PendingLoad<List<HydrologyDiagnosticCandidate>> load : diagnosing.values()) {
|
||||
if (load.owner() == null) {
|
||||
queued.add(load.future());
|
||||
} else {
|
||||
collectActiveLoad(active, load);
|
||||
}
|
||||
}
|
||||
closed.set(true);
|
||||
planning.forEach((key, future) -> {
|
||||
if (!loading.containsKey(key)) {
|
||||
@@ -427,16 +565,28 @@ public final class HydrologyTileCache implements AutoCloseable {
|
||||
}
|
||||
});
|
||||
}
|
||||
for (CompletableFuture<HydrologyTile> future : queued) {
|
||||
for (CompletableFuture<?> future : queued) {
|
||||
future.completeExceptionally(new CancellationException("Hydrology tile cache is closed."));
|
||||
}
|
||||
CompletableFuture.allOf(active.toArray(CompletableFuture<?>[]::new))
|
||||
.handle((ignored, failure) -> null)
|
||||
.join();
|
||||
planning.clear();
|
||||
diagnosing.clear();
|
||||
clear();
|
||||
}
|
||||
|
||||
public void setTerrainPreparation(Runnable preparation) {
|
||||
terrainPreparation = Objects.requireNonNull(preparation, "terrainPreparation");
|
||||
}
|
||||
|
||||
private void prepareTerrain() {
|
||||
Runnable preparation = terrainPreparation;
|
||||
if (preparation != null) {
|
||||
preparation.run();
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectActiveLoad(List<CompletableFuture<?>> active, PendingLoad<?> load) {
|
||||
if (load.owner() == Thread.currentThread()) {
|
||||
throw new IllegalStateException("Hydrology tile cache cannot close from an active load.");
|
||||
|
||||
+12
-5
@@ -37,11 +37,12 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
final class StudioHydrologyTileStore {
|
||||
private static final int SCHEMA_VERSION = 3;
|
||||
private static final int SCHEMA_VERSION = 5;
|
||||
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()
|
||||
@@ -168,8 +169,9 @@ final class StudioHydrologyTileStore {
|
||||
List<DrainageEdge> edges,
|
||||
List<RiverOutlet> outlets,
|
||||
List<PersistedCourse> courses,
|
||||
List<Long> regionalCourseIds,
|
||||
List<PersistedCavePlan> cavePlans,
|
||||
List<HydrologyDiagnosticCandidate> diagnosticCandidates,
|
||||
List<HydrologyDiagnosticCandidate> localDiagnosticCandidates,
|
||||
List<HydrologyColumnSample> columns
|
||||
) {
|
||||
private static PersistedTile from(HydrologyTile tile) {
|
||||
@@ -177,6 +179,8 @@ final class StudioHydrologyTileStore {
|
||||
for (RiverCourse course : tile.courses()) {
|
||||
courses.add(PersistedCourse.from(course));
|
||||
}
|
||||
ArrayList<Long> regionalCourseIds = new ArrayList<>(tile.regionalCourseIds());
|
||||
regionalCourseIds.sort(Long::compareTo);
|
||||
ArrayList<PersistedCavePlan> cavePlans = new ArrayList<>(tile.cavePlans().size());
|
||||
for (HydrologyCavePlan cavePlan : tile.cavePlans()) {
|
||||
cavePlans.add(PersistedCavePlan.from(cavePlan));
|
||||
@@ -191,8 +195,9 @@ final class StudioHydrologyTileStore {
|
||||
tile.edges(),
|
||||
tile.outlets(),
|
||||
courses,
|
||||
regionalCourseIds,
|
||||
cavePlans,
|
||||
tile.diagnosticCandidates(),
|
||||
tile.localDiagnosticCandidates(),
|
||||
new ArrayList<>(tile.footprint().columns().values())
|
||||
);
|
||||
}
|
||||
@@ -202,8 +207,9 @@ final class StudioHydrologyTileStore {
|
||||
requireCollection(edges, "edges");
|
||||
requireCollection(outlets, "outlets");
|
||||
requireCollection(courses, "courses");
|
||||
requireCollection(regionalCourseIds, "regionalCourseIds");
|
||||
requireCollection(cavePlans, "cavePlans");
|
||||
requireCollection(diagnosticCandidates, "diagnosticCandidates");
|
||||
requireCollection(localDiagnosticCandidates, "localDiagnosticCandidates");
|
||||
requireCollection(columns, "columns");
|
||||
ArrayList<RiverCourse> restoredCourses = new ArrayList<>(courses.size());
|
||||
for (PersistedCourse course : courses) {
|
||||
@@ -231,8 +237,9 @@ final class StudioHydrologyTileStore {
|
||||
edges,
|
||||
outlets,
|
||||
restoredCourses,
|
||||
Set.copyOf(regionalCourseIds),
|
||||
restoredCavePlans,
|
||||
diagnosticCandidates,
|
||||
localDiagnosticCandidates,
|
||||
new RiverFootprint(restoredColumns)
|
||||
);
|
||||
}
|
||||
|
||||
+99
-62
@@ -1,6 +1,7 @@
|
||||
package art.arcane.iris.generation.hydrology.runtime;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.volmlib.util.cache.CacheKey;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyNaturalTerrainSampler;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
|
||||
@@ -18,17 +19,15 @@ import java.util.concurrent.Executor;
|
||||
final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrainSampler, AutoCloseable {
|
||||
private static final int MINIMUM_PARALLEL_BASIS_SAMPLES = 64;
|
||||
private static final int COORDINATE_MIX = 0x9E3779B9;
|
||||
private static final int CACHE_STRIPES = 16;
|
||||
private static final int MINIMUM_STRIPE_ENTRIES = 256;
|
||||
|
||||
private final BasisProvider basisProvider;
|
||||
private final IrisHydrologyNaturalHeightProvider heightProvider;
|
||||
private final IrisHydrologyNaturalOceanClassifier oceanClassifier;
|
||||
private final int seaLevel;
|
||||
private final int maximumEntries;
|
||||
private final SamplingOptions samplingOptions;
|
||||
private final Object lock;
|
||||
private final Long2ObjectLinkedOpenHashMap<TerrainBasis> bases;
|
||||
private final Long2DoubleLinkedOpenHashMap naturalHeights;
|
||||
private final Long2ObjectLinkedOpenHashMap<NaturalClassification> oceanClassifications;
|
||||
private final CacheStripe[] stripes;
|
||||
|
||||
IrisHydrologyRoutingTerrainSampler(Sources sources, SamplingOptions samplingOptions) {
|
||||
Objects.requireNonNull(sources, "sources");
|
||||
@@ -38,12 +37,12 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
this.oceanClassifier = Objects.requireNonNull(sources.oceanClassifier(), "oceanClassifier");
|
||||
this.seaLevel = sources.seaLevel();
|
||||
int maximumEntries = samplingOptions.maximumEntries();
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.lock = new Object();
|
||||
this.bases = new Long2ObjectLinkedOpenHashMap<>(maximumEntries);
|
||||
this.naturalHeights = new Long2DoubleLinkedOpenHashMap(maximumEntries);
|
||||
this.naturalHeights.defaultReturnValue(Double.NaN);
|
||||
this.oceanClassifications = new Long2ObjectLinkedOpenHashMap<>(maximumEntries);
|
||||
int stripeCount = Math.min(CACHE_STRIPES, Integer.highestOneBit(Math.max(1, maximumEntries / MINIMUM_STRIPE_ENTRIES)));
|
||||
this.stripes = new CacheStripe[stripeCount];
|
||||
for (int index = 0; index < stripeCount; index++) {
|
||||
int capacity = maximumEntries / stripeCount + (index < maximumEntries % stripeCount ? 1 : 0);
|
||||
stripes[index] = new CacheStripe(capacity);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -78,19 +77,20 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int blockX, int blockZ) {
|
||||
long packed = pack(blockX, blockZ);
|
||||
synchronized (lock) {
|
||||
TerrainBasis basis = bases.getAndMoveToLast(packed);
|
||||
CacheStripe stripe = stripe(packed);
|
||||
synchronized (stripe) {
|
||||
TerrainBasis basis = stripe.bases.getAndMoveToLast(packed);
|
||||
if (basis != null) {
|
||||
return basis.terrain().ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND;
|
||||
}
|
||||
NaturalClassification cached = oceanClassifications.getAndMoveToLast(packed);
|
||||
NaturalClassification cached = stripe.oceanClassifications.getAndMoveToLast(packed);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
NaturalClassification sampled = NaturalClassification.LAND;
|
||||
if (oceanClassifier.isOcean(blockX, blockZ)) {
|
||||
double height = naturalHeight(packed, blockX, blockZ);
|
||||
double height = naturalHeight(stripe, packed, blockX, blockZ);
|
||||
if (!Double.isFinite(height)) {
|
||||
return NaturalClassification.UNAVAILABLE;
|
||||
}
|
||||
@@ -98,17 +98,17 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
sampled = NaturalClassification.OCEAN;
|
||||
}
|
||||
}
|
||||
synchronized (lock) {
|
||||
TerrainBasis basis = bases.getAndMoveToLast(packed);
|
||||
synchronized (stripe) {
|
||||
TerrainBasis basis = stripe.bases.getAndMoveToLast(packed);
|
||||
if (basis != null) {
|
||||
return basis.terrain().ocean() ? NaturalClassification.OCEAN : NaturalClassification.LAND;
|
||||
}
|
||||
NaturalClassification existing = oceanClassifications.getAndMoveToLast(packed);
|
||||
NaturalClassification existing = stripe.oceanClassifications.getAndMoveToLast(packed);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
oceanClassifications.putAndMoveToLast(packed, sampled);
|
||||
evictOldest(oceanClassifications);
|
||||
stripe.oceanClassifications.putAndMoveToLast(packed, sampled);
|
||||
stripe.evictOldest(stripe.oceanClassifications);
|
||||
}
|
||||
return sampled;
|
||||
}
|
||||
@@ -127,55 +127,70 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
|
||||
TerrainBasis basis(int blockX, int blockZ) {
|
||||
long packed = pack(blockX, blockZ);
|
||||
synchronized (lock) {
|
||||
TerrainBasis cached = bases.getAndMoveToLast(packed);
|
||||
CacheStripe stripe = stripe(packed);
|
||||
synchronized (stripe) {
|
||||
TerrainBasis cached = stripe.bases.getAndMoveToLast(packed);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
double naturalHeight = naturalHeight(packed, blockX, blockZ);
|
||||
double naturalHeight = naturalHeight(stripe, packed, blockX, blockZ);
|
||||
TerrainBasis sampled = basisProvider.sample(blockX, blockZ, naturalHeight);
|
||||
if (sampled == null) {
|
||||
throw new NullPointerException(
|
||||
"Hydrology terrain basis provider returned null at " + blockX + "," + blockZ
|
||||
);
|
||||
}
|
||||
synchronized (lock) {
|
||||
TerrainBasis existing = bases.getAndMoveToLast(packed);
|
||||
synchronized (stripe) {
|
||||
TerrainBasis existing = stripe.bases.getAndMoveToLast(packed);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
bases.putAndMoveToLast(packed, sampled);
|
||||
oceanClassifications.remove(packed);
|
||||
evictOldest(bases);
|
||||
stripe.bases.putAndMoveToLast(packed, sampled);
|
||||
stripe.oceanClassifications.remove(packed);
|
||||
stripe.evictOldest(stripe.bases);
|
||||
}
|
||||
return sampled;
|
||||
}
|
||||
|
||||
int basisCacheSize() {
|
||||
synchronized (lock) {
|
||||
return bases.size();
|
||||
int size = 0;
|
||||
for (CacheStripe stripe : stripes) {
|
||||
synchronized (stripe) {
|
||||
size += stripe.bases.size();
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
int naturalHeightCacheSize() {
|
||||
synchronized (lock) {
|
||||
return naturalHeights.size();
|
||||
int size = 0;
|
||||
for (CacheStripe stripe : stripes) {
|
||||
synchronized (stripe) {
|
||||
size += stripe.naturalHeights.size();
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
int oceanClassificationCacheSize() {
|
||||
synchronized (lock) {
|
||||
return oceanClassifications.size();
|
||||
int size = 0;
|
||||
for (CacheStripe stripe : stripes) {
|
||||
synchronized (stripe) {
|
||||
size += stripe.oceanClassifications.size();
|
||||
}
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
synchronized (lock) {
|
||||
bases.clear();
|
||||
naturalHeights.clear();
|
||||
oceanClassifications.clear();
|
||||
for (CacheStripe stripe : stripes) {
|
||||
synchronized (stripe) {
|
||||
stripe.bases.clear();
|
||||
stripe.naturalHeights.clear();
|
||||
stripe.oceanClassifications.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,30 +290,21 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
}
|
||||
|
||||
|
||||
private void evictOldest(Long2ObjectLinkedOpenHashMap<?> cache) {
|
||||
if (cache.size() <= maximumEntries) {
|
||||
return;
|
||||
}
|
||||
cache.removeFirst();
|
||||
}
|
||||
|
||||
private void evictOldest(Long2DoubleLinkedOpenHashMap cache) {
|
||||
if (cache.size() <= maximumEntries) {
|
||||
return;
|
||||
}
|
||||
cache.removeFirstDouble();
|
||||
}
|
||||
|
||||
private double naturalHeight(int blockX, int blockZ) {
|
||||
return naturalHeight(pack(blockX, blockZ), blockX, blockZ);
|
||||
long packed = pack(blockX, blockZ);
|
||||
return naturalHeight(stripe(packed), packed, blockX, blockZ);
|
||||
}
|
||||
|
||||
private double naturalHeight(long packed, int blockX, int blockZ) {
|
||||
synchronized (lock) {
|
||||
double cached = naturalHeights.getAndMoveToLast(packed);
|
||||
if (!Double.isNaN(cached) || naturalHeights.containsKey(packed)) {
|
||||
private double naturalHeight(CacheStripe stripe, long packed, int blockX, int blockZ) {
|
||||
synchronized (stripe) {
|
||||
double cached = stripe.naturalHeights.getAndMoveToLast(packed);
|
||||
if (!Double.isNaN(cached) || stripe.naturalHeights.containsKey(packed)) {
|
||||
return cached;
|
||||
}
|
||||
TerrainBasis cachedBasis = stripe.bases.getAndMoveToLast(packed);
|
||||
if (cachedBasis != null) {
|
||||
return cachedBasis.naturalHeight();
|
||||
}
|
||||
}
|
||||
double sampled = heightProvider.sample(blockX, blockZ);
|
||||
if (!Double.isFinite(sampled)) {
|
||||
@@ -306,22 +312,53 @@ final class IrisHydrologyRoutingTerrainSampler implements HydrologyNaturalTerrai
|
||||
// later sample of the same column gets a fresh chance instead of the cached failure.
|
||||
return sampled;
|
||||
}
|
||||
synchronized (lock) {
|
||||
double existing = naturalHeights.getAndMoveToLast(packed);
|
||||
if (!Double.isNaN(existing) || naturalHeights.containsKey(packed)) {
|
||||
synchronized (stripe) {
|
||||
double existing = stripe.naturalHeights.getAndMoveToLast(packed);
|
||||
if (!Double.isNaN(existing) || stripe.naturalHeights.containsKey(packed)) {
|
||||
return existing;
|
||||
}
|
||||
naturalHeights.putAndMoveToLast(packed, sampled);
|
||||
evictOldest(naturalHeights);
|
||||
stripe.naturalHeights.putAndMoveToLast(packed, sampled);
|
||||
stripe.evictOldest(stripe.naturalHeights);
|
||||
}
|
||||
return sampled;
|
||||
}
|
||||
|
||||
private CacheStripe stripe(long packed) {
|
||||
return stripes[(int) CacheKey.mix(packed) & (stripes.length - 1)];
|
||||
}
|
||||
|
||||
private static long pack(int blockX, int blockZ) {
|
||||
int mixedZ = blockZ ^ Integer.rotateLeft(blockX * COORDINATE_MIX, 16);
|
||||
return ((long) blockX << 32) ^ (mixedZ & 0xffffffffL);
|
||||
}
|
||||
|
||||
private static final class CacheStripe {
|
||||
private final int maximumEntries;
|
||||
private final Long2ObjectLinkedOpenHashMap<TerrainBasis> bases;
|
||||
private final Long2DoubleLinkedOpenHashMap naturalHeights;
|
||||
private final Long2ObjectLinkedOpenHashMap<NaturalClassification> oceanClassifications;
|
||||
|
||||
private CacheStripe(int maximumEntries) {
|
||||
this.maximumEntries = maximumEntries;
|
||||
this.bases = new Long2ObjectLinkedOpenHashMap<>(maximumEntries);
|
||||
this.naturalHeights = new Long2DoubleLinkedOpenHashMap(maximumEntries);
|
||||
this.naturalHeights.defaultReturnValue(Double.NaN);
|
||||
this.oceanClassifications = new Long2ObjectLinkedOpenHashMap<>(maximumEntries);
|
||||
}
|
||||
|
||||
private void evictOldest(Long2ObjectLinkedOpenHashMap<?> cache) {
|
||||
if (cache.size() > maximumEntries) {
|
||||
cache.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private void evictOldest(Long2DoubleLinkedOpenHashMap cache) {
|
||||
if (cache.size() > maximumEntries) {
|
||||
cache.removeFirstDouble();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface BasisProvider {
|
||||
TerrainBasis sample(int blockX, int blockZ, double naturalHeight);
|
||||
|
||||
+7
-2
@@ -119,6 +119,7 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
IrisPlatforms.isBound() ? MultiBurst.hydrology : null,
|
||||
context.waitingForbidden()
|
||||
);
|
||||
this.cache.setTerrainPreparation(context.terrainPreparation());
|
||||
IrisLogging.debug("Hydrology runtime: tileSize=%d publicationRadius=%d planningThreads=%d",
|
||||
settings.routing().tileSize(), settings.publicationRadius(), MultiBurst.hydrology.parallelism());
|
||||
}
|
||||
@@ -131,6 +132,10 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
return cache.get(key);
|
||||
}
|
||||
|
||||
public List<HydrologyDiagnosticCandidate> diagnosticCandidates(HydrologyTileKey key) {
|
||||
return cache.diagnosticCandidates(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the column can be sampled without planning: true when its tiles are cached. Otherwise the
|
||||
* planning pool is asked for them and the caller should answer from natural terrain for now.
|
||||
@@ -319,8 +324,8 @@ public final class IrisHydrologyRuntime implements AutoCloseable {
|
||||
LinkedHashMap<Long, HydrologyDiagnosticCandidate> candidates = new LinkedHashMap<>();
|
||||
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
|
||||
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
|
||||
HydrologyTile tile = cache.get(new HydrologyTileKey(tileX, tileZ));
|
||||
for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) {
|
||||
HydrologyTileKey key = new HydrologyTileKey(tileX, tileZ);
|
||||
for (HydrologyDiagnosticCandidate candidate : diagnosticCandidates(key)) {
|
||||
if (candidate.point().x() < minX || candidate.point().x() >= maxX
|
||||
|| candidate.point().z() < minZ || candidate.point().z() >= maxZ) {
|
||||
continue;
|
||||
|
||||
+3
-1
@@ -17,7 +17,8 @@ public record IrisHydrologyRuntimeContext(
|
||||
IrisHydrologyNaturalHeightDescriber naturalHeightDescriber,
|
||||
IrisHydrologyNaturalOceanClassifier naturalOceanClassifier,
|
||||
HydrologyCaveVoxelViewFactory caveViewFactory,
|
||||
BooleanSupplier waitingForbidden
|
||||
BooleanSupplier waitingForbidden,
|
||||
Runnable terrainPreparation
|
||||
) {
|
||||
/**
|
||||
* {@code waitingForbidden} is true on a thread that must never wait for a cold hydrology plan,
|
||||
@@ -36,5 +37,6 @@ public record IrisHydrologyRuntimeContext(
|
||||
Objects.requireNonNull(naturalOceanClassifier);
|
||||
Objects.requireNonNull(caveViewFactory);
|
||||
Objects.requireNonNull(waitingForbidden);
|
||||
Objects.requireNonNull(terrainPreparation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +324,9 @@ public final class MantleHydrologyComponent extends IrisMantleComponent {
|
||||
CavePosition position = new CavePosition(sample.x(), y, sample.z());
|
||||
HydrologyCaveAction action = plan.actions().get(position);
|
||||
if (action == null || action == HydrologyCaveAction.SEAL_GUARD) {
|
||||
if (!isCaveLayer(layer)) {
|
||||
continue;
|
||||
}
|
||||
throw new IllegalStateException("Accepted cave layer is absent from its containment plan at "
|
||||
+ position + ".");
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.interpolation.IrisInterpolation;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import art.arcane.volmlib.util.noise.CellGenerator;
|
||||
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
|
||||
import java.util.concurrent.atomic.AtomicReferenceArray;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
@@ -262,12 +262,13 @@ public class IrisGenerator extends IrisRegistrant {
|
||||
mixed *= 0xff51afd7ed558ccdL;
|
||||
mixed ^= mixed >>> 33;
|
||||
SurfaceCacheStripe stripe = stripes[(int) mixed & (SURFACE_CACHE_STRIPES - 1)];
|
||||
SurfaceSample cached = stripe.get(key, superSeed);
|
||||
int slot = (int) (mixed >>> 4) & (SURFACE_CACHE_STRIPE_SIZE - 1);
|
||||
SurfaceSample cached = stripe.get(key, superSeed, slot);
|
||||
if (cached != null) {
|
||||
return cached.height();
|
||||
}
|
||||
double height = sampleHeight(x, z, superSeed);
|
||||
stripe.put(key, new SurfaceSample(superSeed, height));
|
||||
stripe.put(slot, new SurfaceSample(key, superSeed, height));
|
||||
return height;
|
||||
}
|
||||
|
||||
@@ -347,22 +348,19 @@ public class IrisGenerator extends IrisRegistrant {
|
||||
private record SurfaceCache(IrisData data, Engine engine, SurfaceCacheStripe[] stripes) {
|
||||
}
|
||||
|
||||
private record SurfaceSample(long seed, double height) {
|
||||
private record SurfaceSample(long key, long seed, double height) {
|
||||
}
|
||||
|
||||
private static final class SurfaceCacheStripe {
|
||||
private final Long2ObjectLinkedOpenHashMap<SurfaceSample> entries = new Long2ObjectLinkedOpenHashMap<>(16);
|
||||
private final AtomicReferenceArray<SurfaceSample> entries = new AtomicReferenceArray<>(SURFACE_CACHE_STRIPE_SIZE);
|
||||
|
||||
private synchronized SurfaceSample get(long key, long seed) {
|
||||
SurfaceSample sample = entries.getAndMoveToLast(key);
|
||||
return sample != null && sample.seed() == seed ? sample : null;
|
||||
private SurfaceSample get(long key, long seed, int slot) {
|
||||
SurfaceSample sample = entries.get(slot);
|
||||
return sample != null && sample.key() == key && sample.seed() == seed ? sample : null;
|
||||
}
|
||||
|
||||
private synchronized void put(long key, SurfaceSample sample) {
|
||||
entries.putAndMoveToLast(key, sample);
|
||||
if (entries.size() > SURFACE_CACHE_STRIPE_SIZE) {
|
||||
entries.removeFirst();
|
||||
}
|
||||
private void put(int slot, SurfaceSample sample) {
|
||||
entries.set(slot, sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +354,8 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
);
|
||||
return mappedTerrainHeight(imageMapRuntime, proceduralHeight, x, z);
|
||||
}, Interpolated.DOUBLE), cachePrefix + "ImageMappedHeightStream", engine, cacheSize);
|
||||
// heightStream is cached and never resolves the surface biome, so the shore band can read its slope.
|
||||
ProceduralStream<Double> shoreSlopeStream = heightStream.slope(3);
|
||||
ProceduralStream<IrisBiome> finalBiomeStream = focusBiome == null
|
||||
? GenerationStreams.cache2D(heightStream.convertAware2D((height, x, z) -> {
|
||||
IrisBiome mappedBiome = imageMapRuntime.sampleBiome(x, z);
|
||||
@@ -370,7 +372,8 @@ public final class DimensionTerrainContext implements DataProvider {
|
||||
fluidHeight,
|
||||
landBiomeStream,
|
||||
seaBiomeStream,
|
||||
shoreBiomeStream
|
||||
shoreBiomeStream,
|
||||
shoreSlopeStream
|
||||
);
|
||||
return resolvedBiome == baseBiome
|
||||
? baseBiome
|
||||
|
||||
@@ -43,7 +43,6 @@ public final class GenerationCacheWarmer {
|
||||
RNG root = new RNG(engine.getSeedManager().getComponent() + 7777L);
|
||||
int[] counter = {0};
|
||||
|
||||
try {
|
||||
KList<IrisBiome> biomes = engine.getAllBiomes();
|
||||
biomes.sort(Comparator.comparing(IrisBiome::getLoadKey));
|
||||
for (IrisBiome biome : biomes) {
|
||||
@@ -62,10 +61,6 @@ public final class GenerationCacheWarmer {
|
||||
}
|
||||
|
||||
warmOres(engine.getDimension().getOres(), root, counter, data);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
IrisLogging.warn("Generation cache warm pass failed: " + e.getMessage());
|
||||
}
|
||||
|
||||
IrisLogging.debug("[IrisEngine timing] cache warm " + counter[0] + " configs=" + (M.ms() - start) + "ms");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
final class HydrologyNoiseCacheBudget {
|
||||
static final int MAXIMUM_CHUNKS = 32_768;
|
||||
static final int BYTES_PER_CHUNK = 4_096;
|
||||
static final HydrologyNoiseCacheBudget SHARED = new HydrologyNoiseCacheBudget(Runtime.getRuntime().maxMemory() / 8L);
|
||||
|
||||
private final long maximumBytes;
|
||||
private long reservedBytes;
|
||||
|
||||
HydrologyNoiseCacheBudget(long maximumBytes) {
|
||||
if (maximumBytes < 0L) {
|
||||
throw new IllegalArgumentException("Cache budget must not be negative.");
|
||||
}
|
||||
this.maximumBytes = maximumBytes;
|
||||
}
|
||||
|
||||
synchronized Reservation reserve(int cacheCount, int requestedAdditionalChunks) {
|
||||
if (cacheCount <= 0 || requestedAdditionalChunks < 0 || requestedAdditionalChunks > MAXIMUM_CHUNKS) {
|
||||
throw new IllegalArgumentException("Invalid hydrology cache reservation.");
|
||||
}
|
||||
long bytesPerIncrement = (long) BYTES_PER_CHUNK * cacheCount;
|
||||
int additionalChunks = (int) Math.min(requestedAdditionalChunks, (maximumBytes - reservedBytes) / bytesPerIncrement);
|
||||
long bytes = additionalChunks * bytesPerIncrement;
|
||||
reservedBytes += bytes;
|
||||
return new Reservation(this, additionalChunks, bytes);
|
||||
}
|
||||
|
||||
synchronized long reservedBytes() {
|
||||
return reservedBytes;
|
||||
}
|
||||
|
||||
private synchronized void release(Reservation reservation) {
|
||||
if (!reservation.released) {
|
||||
reservedBytes -= reservation.bytes;
|
||||
reservation.released = true;
|
||||
}
|
||||
}
|
||||
|
||||
static final class Reservation implements AutoCloseable {
|
||||
private final HydrologyNoiseCacheBudget budget;
|
||||
private final int additionalChunks;
|
||||
private final long bytes;
|
||||
private boolean released;
|
||||
|
||||
private Reservation(HydrologyNoiseCacheBudget budget, int additionalChunks, long bytes) {
|
||||
this.budget = budget;
|
||||
this.additionalChunks = additionalChunks;
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
int additionalChunks() {
|
||||
return additionalChunks;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
budget.release(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
import art.arcane.iris.generation.stream.GenerationStreams;
|
||||
import art.arcane.iris.generation.stream.CachedDoubleStream2D;
|
||||
import art.arcane.iris.generation.stream.CachedStream2D;
|
||||
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
@@ -36,6 +38,7 @@ import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyNaturalSample;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntimeContext;
|
||||
import art.arcane.iris.generation.image.IrisImageMapApplication;
|
||||
import art.arcane.iris.generation.image.IrisImageMapRuntime;
|
||||
import art.arcane.iris.generation.mantle.MantleHydrologyCaveVoxelView;
|
||||
import art.arcane.iris.generation.terrain.InferredType;
|
||||
@@ -78,6 +81,7 @@ import lombok.AccessLevel;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
@@ -90,6 +94,7 @@ import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
@@ -111,6 +116,10 @@ public class IrisComplex implements DataProvider {
|
||||
/** One million corners: about 16 MB, roughly a 4000 by 4000 block area at the 4-block grid. */
|
||||
private static final int SHARED_CORNER_BOUNDS_CAPACITY = 1 << 20;
|
||||
private static final int HEIGHT_BOUNDS_GRID = 4;
|
||||
/** The slope streams measure the rise across a run of this many blocks, so gradient is slope over run. */
|
||||
private static final double SLOPE_RUN = 3D;
|
||||
/** A sheer coast would let shoreMinimumWidth buy unbounded height, so the shore band stops climbing here. */
|
||||
private static final double MAX_SHORE_BAND_GRADIENT = 3D;
|
||||
private static final Comparator<IrisInterpolator> INTERPOLATOR_ORDER = Comparator
|
||||
.comparing((IrisInterpolator interpolator) -> interpolator.getFunction().name())
|
||||
.thenComparingDouble(IrisInterpolator::getHorizontalScale);
|
||||
@@ -134,6 +143,17 @@ public class IrisComplex implements DataProvider {
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient Map<IrisRegion, Map<InferredType, ProceduralStream<IrisBiome>>> inferredBiomeStreams;
|
||||
private final TransitionGenerationPlan transitionGenerationPlan;
|
||||
@Getter(AccessLevel.NONE)
|
||||
private final transient boolean detached;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private transient List<NoiseCacheCapacity> hydrologyNoiseCaches = List.of();
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private transient HydrologyNoiseCacheBudget.Reservation hydrologyNoiseCacheReservation;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private transient boolean hydrologyNoiseCachesClosed;
|
||||
private RNG rng;
|
||||
private double fluidHeight;
|
||||
private IrisData data;
|
||||
@@ -166,12 +186,16 @@ public class IrisComplex implements DataProvider {
|
||||
private final ResolvedTerrainProvider resolvedTerrain;
|
||||
private ProceduralStream<Double> placementHeightStream;
|
||||
private ProceduralStream<Double> heightStream;
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Setter(AccessLevel.NONE)
|
||||
private CachedDoubleStream2D cachedHeightStream;
|
||||
private ProceduralStream<Integer> roundedHeighteightStream;
|
||||
private ProceduralStream<Double> maxHeightStream;
|
||||
private ProceduralStream<Double> overlayStream;
|
||||
private ProceduralStream<Double> heightFluidStream;
|
||||
private ProceduralStream<Double> slopeStream;
|
||||
private ProceduralStream<Double> naturalSlopeStream;
|
||||
private ProceduralStream<Double> shoreSlopeStream;
|
||||
private ProceduralStream<Double> riverDistanceStream;
|
||||
private ProceduralStream<Double> riverFlowStream;
|
||||
private ProceduralStream<Double> riverCarveWeightStream;
|
||||
@@ -216,6 +240,7 @@ public class IrisComplex implements DataProvider {
|
||||
|
||||
IrisComplex(Engine engine, boolean simple, TransitionGenerationPlan transitionGenerationPlan, boolean detached) {
|
||||
terrainEngine = engine;
|
||||
this.detached = detached;
|
||||
this.transitionGenerationPlan = transitionGenerationPlan;
|
||||
this.resolvedTerrain = new ResolvedTerrainProvider(engine);
|
||||
int cacheSize = noiseCacheSize(engine, IrisSettings.get().getPerformance().getNoiseCacheSize(), detached);
|
||||
@@ -317,7 +342,8 @@ public class IrisComplex implements DataProvider {
|
||||
ProceduralStream<IrisRegion> proceduralRegionStream = focusedRegions != null ? focusedRegions
|
||||
: GenerationStreams.cache2D(regionStyleStream
|
||||
.selectRarity(compatRegionPool(engine)), "regionStream", engine, cacheSize);
|
||||
regionStream = focusedRegions != null ? proceduralRegionStream : GenerationStreams.cache2D(proceduralRegionStream
|
||||
regionStream = focusedRegions != null || !imageMapRuntime.has(IrisImageMapApplication.REGION)
|
||||
? proceduralRegionStream : GenerationStreams.cache2D(proceduralRegionStream
|
||||
.convertAware2D((region, x, z) -> {
|
||||
IrisRegion mapped = imageMapRuntime.sampleRegion(x, z);
|
||||
return mapped == null ? region : mapped;
|
||||
@@ -347,7 +373,8 @@ public class IrisComplex implements DataProvider {
|
||||
ProceduralStream<IrisBiome> proceduralBaseBiomeStream = focusedBiomes != null ? focusedBiomes :
|
||||
GenerationStreams.cache2D(bridgeStream.convertAware2D((t, x, z) -> inferredStreams.get(t).get(x, z))
|
||||
.convertAware2D(this::implode), "baseBiomeStream", engine, cacheSize);
|
||||
baseBiomeStream = focusedBiomes != null ? proceduralBaseBiomeStream : GenerationStreams.cache2D(proceduralBaseBiomeStream
|
||||
baseBiomeStream = focusedBiomes != null || !imageMapRuntime.has(IrisImageMapApplication.BIOME)
|
||||
? proceduralBaseBiomeStream : GenerationStreams.cache2D(proceduralBaseBiomeStream
|
||||
.convertAware2D((biome, x, z) -> {
|
||||
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
|
||||
return mapped == null ? biome : mapped;
|
||||
@@ -400,7 +427,8 @@ public class IrisComplex implements DataProvider {
|
||||
(x, z) -> describeNaturalHeight(engine, x, z),
|
||||
this::sampleNaturalOcean,
|
||||
footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint),
|
||||
() -> engine.getPlatformHooks().isMainThread()
|
||||
() -> engine.getPlatformHooks().isMainThread(),
|
||||
this::expandHydrologyNoiseCaches
|
||||
));
|
||||
hydrologyRuntime.setNeighbourPrefetchEnabled(!engine.isStudio());
|
||||
IrisRiverBank3DConfig banks3D = configuredHydrology.getRivers().getGeometry().getBanks3D();
|
||||
@@ -415,7 +443,8 @@ public class IrisComplex implements DataProvider {
|
||||
Math.max(4096, cacheSize), data, banks3D));
|
||||
}
|
||||
}
|
||||
ProceduralStream<Double> cachedHeightStream = GenerationStreams.cache2DDouble(ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z)), "heightStream", engine, cacheSize);
|
||||
cachedHeightStream = new CachedDoubleStream2D("heightStream", engine,
|
||||
ProceduralStream.ofDouble(this::resolveHydrologyTerrainHeight), cacheSize);
|
||||
heightStream = ProceduralStream.ofDouble((x, z) -> terrainEngine.getPlatformHooks().isMainThread()
|
||||
? nonblockingTerrainHeight(x, z) : cachedHeightStream.getDouble(x, z));
|
||||
placementHeightStream = ProceduralStream.ofDouble(this::samplePlacementHeight);
|
||||
@@ -424,6 +453,8 @@ public class IrisComplex implements DataProvider {
|
||||
slopeStream = GenerationStreams.contextInjecting(placementHeightStream, engine, (c, x, z) -> c.getHeight().getDouble(x, z))
|
||||
.slope(3);
|
||||
naturalSlopeStream = GenerationStreams.cache2DDouble(naturalHeightStream.slope(3), "naturalSlopeStream", engine, cacheSize);
|
||||
// Terrain3D column heights resolve the surface biome, so the shore band reads the pre-terrain3D slope or it recurses.
|
||||
shoreSlopeStream = terrain3DEnabled ? baseTerrainHeightStream.slope(3) : naturalSlopeStream;
|
||||
trueBiomeStream = focusedBiomes != null ? GenerationStreams.cache2D(focusedBiomes, "trueBiomeStream-focus", engine, cacheSize) : GenerationStreams.cache2D(heightStream
|
||||
.convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z)), "trueBiomeStream", engine, cacheSize);
|
||||
trueBiomeDerivativeStream = GenerationStreams.cache2D(GenerationStreams.contextInjecting(trueBiomeStream, engine, (c, x, z) -> c.getBiome().get(x, z))
|
||||
@@ -469,6 +500,66 @@ public class IrisComplex implements DataProvider {
|
||||
return engine.isStudio() && !detached ? Math.max(configuredSize, STUDIO_NOISE_CACHE_SIZE) : configuredSize;
|
||||
}
|
||||
|
||||
public synchronized void ensureTerrainNoiseCacheSize(int minimumChunks) {
|
||||
if (minimumChunks <= 0) {
|
||||
throw new IllegalArgumentException("Terrain noise cache size must be positive");
|
||||
}
|
||||
CachedDoubleStream2D naturalCache = (CachedDoubleStream2D) naturalHeightStream;
|
||||
if (naturalCache.getMaxSize() < minimumChunks * 256L) {
|
||||
naturalCache.setMaximumChunks(minimumChunks);
|
||||
}
|
||||
if (cachedHeightStream.getMaxSize() < minimumChunks * 256L) {
|
||||
cachedHeightStream.setMaximumChunks(minimumChunks);
|
||||
}
|
||||
}
|
||||
|
||||
void expandHydrologyNoiseCaches() {
|
||||
expandHydrologyNoiseCaches(HydrologyNoiseCacheBudget.SHARED);
|
||||
}
|
||||
|
||||
synchronized void expandHydrologyNoiseCaches(HydrologyNoiseCacheBudget budget) {
|
||||
if (detached || hydrologyRuntime == null || hydrologyNoiseCachesClosed || hydrologyNoiseCacheReservation != null) {
|
||||
return;
|
||||
}
|
||||
IdentityHashMap<ProceduralStream<?>, Boolean> selected = new IdentityHashMap<>();
|
||||
List<NoiseCacheCapacity> capacities = new ArrayList<>(5);
|
||||
int additionalChunks = HydrologyNoiseCacheBudget.MAXIMUM_CHUNKS;
|
||||
for (ProceduralStream<?> stream : new ProceduralStream<?>[]{
|
||||
baseBiomeStream, baseTerrainHeightStream, unblendedNaturalHeightStream, regionStream, bridgeStream}) {
|
||||
if (!(stream instanceof CachedStream2D<?> || stream instanceof CachedDoubleStream2D)
|
||||
|| selected.put(stream, Boolean.TRUE) != null) {
|
||||
continue;
|
||||
}
|
||||
int originalChunks = Math.toIntExact(((MeteredCache) stream).getMaxSize() / 256L);
|
||||
if (originalChunks >= HydrologyNoiseCacheBudget.MAXIMUM_CHUNKS) {
|
||||
continue;
|
||||
}
|
||||
capacities.add(new NoiseCacheCapacity(stream, originalChunks));
|
||||
additionalChunks = Math.min(additionalChunks, HydrologyNoiseCacheBudget.MAXIMUM_CHUNKS - originalChunks);
|
||||
}
|
||||
if (capacities.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
hydrologyNoiseCaches = List.copyOf(capacities);
|
||||
hydrologyNoiseCacheReservation = budget.reserve(capacities.size(), additionalChunks);
|
||||
for (NoiseCacheCapacity capacity : hydrologyNoiseCaches) {
|
||||
capacity.resize(capacity.originalChunks() + hydrologyNoiseCacheReservation.additionalChunks());
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void releaseHydrologyNoiseCaches() {
|
||||
hydrologyNoiseCachesClosed = true;
|
||||
if (hydrologyNoiseCacheReservation == null) {
|
||||
return;
|
||||
}
|
||||
for (NoiseCacheCapacity capacity : hydrologyNoiseCaches) {
|
||||
capacity.resize(capacity.originalChunks());
|
||||
}
|
||||
hydrologyNoiseCacheReservation.close();
|
||||
hydrologyNoiseCacheReservation = null;
|
||||
hydrologyNoiseCaches = List.of();
|
||||
}
|
||||
|
||||
void enableStudioHydrologyCache(String runtimeIdentity, Path persistentRoot) {
|
||||
if (hydrologyRuntime != null) {
|
||||
hydrologyRuntime.enableSharedCache(runtimeIdentity, persistentRoot);
|
||||
@@ -684,11 +775,11 @@ public class IrisComplex implements DataProvider {
|
||||
if (biome == null || region == null) {
|
||||
return biome;
|
||||
}
|
||||
double shoreHeight = region.getShoreHeight(x, z);
|
||||
if (height >= fluidHeight - 1 && height <= fluidHeight + shoreHeight && !biome.isShore()) {
|
||||
double shoreTop = shoreBandTop(height, region, x, z, fluidHeight, shoreSlopeStream);
|
||||
if (height >= fluidHeight - 1 && height <= shoreTop && !biome.isShore()) {
|
||||
return sampleInferredBiome(region, InferredType.SHORE, x, z);
|
||||
}
|
||||
if (height > fluidHeight + shoreHeight && !biome.isLand()) {
|
||||
if (height > shoreTop && !biome.isLand()) {
|
||||
return sampleInferredBiome(region, InferredType.LAND, x, z);
|
||||
}
|
||||
if (height < fluidHeight && !biome.isAquatic()) {
|
||||
@@ -1264,10 +1355,38 @@ public class IrisComplex implements DataProvider {
|
||||
fluidHeight,
|
||||
landBiomeStream,
|
||||
seaBiomeStream,
|
||||
shoreBiomeStream);
|
||||
shoreBiomeStream,
|
||||
shoreSlopeStream);
|
||||
return resolved == biome ? biome : implode(resolved, x, z);
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest column the shore band still covers. Shore height alone is a vertical window, so a steep
|
||||
* coast gets a beach only a fraction of a block wide; shoreMinimumWidth trades the local gradient back into
|
||||
* height so the beach keeps that width across the ground. Zero width never reads the slope, and neither
|
||||
* does a column that is already inside the vertical window or too high for any widening to reach.
|
||||
*/
|
||||
static double shoreBandTop(
|
||||
double height,
|
||||
IrisRegion region,
|
||||
double x,
|
||||
double z,
|
||||
double fluidHeight,
|
||||
ProceduralStream<Double> shoreSlopes
|
||||
) {
|
||||
double bandTop = fluidHeight + region.getShoreHeight(x, z);
|
||||
double shoreMinimumWidth = region.getShoreMinimumWidth();
|
||||
|
||||
if (shoreMinimumWidth <= 0
|
||||
|| height <= bandTop
|
||||
|| height < fluidHeight - 1
|
||||
|| height > bandTop + shoreMinimumWidth * MAX_SHORE_BAND_GRADIENT) {
|
||||
return bandTop;
|
||||
}
|
||||
|
||||
return bandTop + shoreMinimumWidth * Math.min(shoreSlopes.getDouble(x, z) / SLOPE_RUN, MAX_SHORE_BAND_GRADIENT);
|
||||
}
|
||||
|
||||
static IrisBiome resolveSurfaceBiome(
|
||||
double height,
|
||||
IrisBiome biome,
|
||||
@@ -1277,18 +1396,19 @@ public class IrisComplex implements DataProvider {
|
||||
double fluidHeight,
|
||||
ProceduralStream<IrisBiome> landBiomes,
|
||||
ProceduralStream<IrisBiome> seaBiomes,
|
||||
ProceduralStream<IrisBiome> shoreBiomes
|
||||
ProceduralStream<IrisBiome> shoreBiomes,
|
||||
ProceduralStream<Double> shoreSlopes
|
||||
) {
|
||||
if (biome == null || region == null) {
|
||||
return biome;
|
||||
}
|
||||
double sh = region.getShoreHeight(x, z);
|
||||
double shoreTop = shoreBandTop(height, region, x, z, fluidHeight, shoreSlopes);
|
||||
|
||||
if (height >= fluidHeight - 1 && height <= fluidHeight + sh && !biome.isShore()) {
|
||||
if (height >= fluidHeight - 1 && height <= shoreTop && !biome.isShore()) {
|
||||
return shoreBiomes.get(x, z);
|
||||
}
|
||||
|
||||
if (height > fluidHeight + sh && !biome.isLand()) {
|
||||
if (height > shoreTop && !biome.isLand()) {
|
||||
return landBiomes.get(x, z);
|
||||
}
|
||||
|
||||
@@ -1944,6 +2064,16 @@ public class IrisComplex implements DataProvider {
|
||||
record GeneratorGroup(IrisInterpolator interpolator, IrisGenerator[] generators) {
|
||||
}
|
||||
|
||||
private record NoiseCacheCapacity(ProceduralStream<?> stream, int originalChunks) {
|
||||
private void resize(int chunks) {
|
||||
if (stream instanceof CachedDoubleStream2D doubles) {
|
||||
doubles.setMaximumChunks(chunks);
|
||||
} else if (stream instanceof CachedStream2D<?> objects) {
|
||||
objects.setMaximumChunks(chunks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void close() {
|
||||
if (hydrologyBanks3D != null) {
|
||||
hydrologyBanks3D.clear();
|
||||
@@ -1954,6 +2084,7 @@ public class IrisComplex implements DataProvider {
|
||||
if (hydrologyRuntime != null) {
|
||||
hydrologyRuntime.close();
|
||||
}
|
||||
releaseHydrologyNoiseCaches();
|
||||
resolvedTerrain.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,16 +322,7 @@ public class IrisEngine implements Engine {
|
||||
diagnostics.logPackCompatSummary();
|
||||
diagnostics.logStudioInitializationPhase("build_runtime", phaseStartedAt, false);
|
||||
phaseStartedAt = System.nanoTime();
|
||||
if (requiredMode.warmGenerationCaches()) {
|
||||
if (requiredMode.studio()) {
|
||||
startGenerationCacheWarm(phaseStartedAt);
|
||||
} else {
|
||||
warmGenerationCaches(phaseStartedAt);
|
||||
}
|
||||
} else {
|
||||
generationCacheWarm.complete(null);
|
||||
diagnostics.logStudioInitializationPhase("generation_cache_warm", phaseStartedAt, true);
|
||||
}
|
||||
prepareGenerationCaches(requiredMode, phaseStartedAt);
|
||||
EngineTickRegistry.registerTicking(this);
|
||||
} catch (Throwable e) {
|
||||
shutdownSequence.cleanupFailedConstruction(e);
|
||||
@@ -355,6 +346,19 @@ public class IrisEngine implements Engine {
|
||||
return !generationCacheWarm.isDone();
|
||||
}
|
||||
|
||||
void prepareGenerationCaches(InitializationMode mode, long phaseStartedAt) {
|
||||
if (!mode.warmGenerationCaches()) {
|
||||
generationCacheWarm.complete(null);
|
||||
diagnostics.logStudioInitializationPhase("generation_cache_warm", phaseStartedAt, true);
|
||||
return;
|
||||
}
|
||||
if (mode == InitializationMode.RUNTIME) {
|
||||
warmGenerationCaches(phaseStartedAt);
|
||||
} else {
|
||||
startGenerationCacheWarm(phaseStartedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private String currentStudioCacheIdentity() {
|
||||
if (initializationMode != InitializationMode.STUDIO || !IrisPlatforms.isBound()) {
|
||||
return "";
|
||||
@@ -396,32 +400,32 @@ public class IrisEngine implements Engine {
|
||||
return packRoot.resolve(".iris").resolve("studio-hydrology");
|
||||
}
|
||||
|
||||
public void startStudioEntryHydrology(int blockX, int blockZ) {
|
||||
public void startEntryHydrology(int blockX, int blockZ) {
|
||||
GenerationRuntimeBinding binding = getActiveGenerationRuntimeBinding();
|
||||
if (!backgroundTasks.scheduleTrackedTask(() -> prepareStudioEntryHydrology(binding, blockX, blockZ))) {
|
||||
throw new IllegalStateException("Iris background task admission closed before Studio entry preparation.");
|
||||
if (!backgroundTasks.scheduleTrackedTask(() -> prepareEntryHydrology(binding, blockX, blockZ))) {
|
||||
throw new IllegalStateException("Iris background task admission closed before world entry preparation.");
|
||||
}
|
||||
}
|
||||
|
||||
private void prepareStudioEntryHydrology(GenerationRuntimeBinding binding, int blockX, int blockZ) {
|
||||
private void prepareEntryHydrology(GenerationRuntimeBinding binding, int blockX, int blockZ) {
|
||||
if (closing.get()) {
|
||||
return;
|
||||
}
|
||||
GenerationSessionLease lease;
|
||||
try {
|
||||
lease = acquireGenerationLease("studio_entry_hydrology");
|
||||
lease = acquireGenerationLease("initial_entry_hydrology");
|
||||
} catch (GenerationSessionException failure) {
|
||||
if (closing.get()) {
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("Studio entry hydrology preparation could not acquire its generation session.", failure);
|
||||
throw new IllegalStateException("World entry hydrology preparation could not acquire its generation session.", failure);
|
||||
}
|
||||
try (lease;
|
||||
GenerationRuntimeScope runtimeScope = generationRuntimeScopes.open(binding);
|
||||
IrisContext.Scope context = IrisContext.open(this, lease.sessionId(), null)) {
|
||||
getComplex().getHydrologyRuntime().prepareChunkColumns(blockX, blockZ);
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Studio entry hydrology preparation failed at "
|
||||
throw new IllegalStateException("World entry hydrology preparation failed at "
|
||||
+ blockX + "," + blockZ + ".", failure);
|
||||
}
|
||||
}
|
||||
@@ -1685,6 +1689,7 @@ public class IrisEngine implements Engine {
|
||||
|
||||
public enum InitializationMode {
|
||||
RUNTIME(false, true),
|
||||
WORLD_CREATION(false, true),
|
||||
STUDIO(true, true),
|
||||
OBJECT_STUDIO(true, false),
|
||||
JIGSAW_STUDIO(true, false);
|
||||
|
||||
@@ -27,14 +27,17 @@ import art.arcane.volmlib.util.scheduling.Looper;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public class PreservationSVC implements IrisService, PreservationRegistry {
|
||||
private static final long SHUTDOWN_TIMEOUT_MILLIS = 10_000L;
|
||||
private final List<Thread> threads = new CopyOnWriteArrayList<>();
|
||||
private final List<ExecutorService> services = new CopyOnWriteArrayList<>();
|
||||
private final List<WeakReference<MeteredCache>> caches = new CopyOnWriteArrayList<>();
|
||||
@@ -114,27 +117,64 @@ public class PreservationSVC implements IrisService, PreservationRegistry {
|
||||
}
|
||||
dereference();
|
||||
|
||||
postShutdown(() -> {
|
||||
for (Thread i : threads) {
|
||||
if (i.isAlive()) {
|
||||
try {
|
||||
i.interrupt();
|
||||
IrisLogging.debug("Shutdown Thread " + i.getName());
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
}
|
||||
}
|
||||
postShutdown(() -> shutdownResources(SHUTDOWN_TIMEOUT_MILLIS));
|
||||
}
|
||||
|
||||
for (ExecutorService i : services) {
|
||||
void shutdownResources(long timeoutMillis) {
|
||||
long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
|
||||
List<Thread> ownedThreads = new ArrayList<>(threads);
|
||||
if (dereferencer != null && !ownedThreads.contains(dereferencer)) {
|
||||
ownedThreads.add(dereferencer);
|
||||
}
|
||||
List<ExecutorService> ownedServices = List.copyOf(services);
|
||||
for (Thread thread : ownedThreads) {
|
||||
if (thread.isAlive() && thread != Thread.currentThread()) {
|
||||
try {
|
||||
i.shutdownNow();
|
||||
IrisLogging.debug("Shutdown Executor Service " + i);
|
||||
} catch (Throwable e) {
|
||||
IrisLogging.reportError(e);
|
||||
thread.interrupt();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Failed to interrupt owned thread " + thread.getName(), failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for (ExecutorService executor : ownedServices) {
|
||||
try {
|
||||
executor.shutdownNow();
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Failed to stop owned executor " + executor, failure);
|
||||
}
|
||||
}
|
||||
try {
|
||||
for (Thread thread : ownedThreads) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (thread != Thread.currentThread() && thread.isAlive() && remaining > 0L) {
|
||||
TimeUnit.NANOSECONDS.timedJoin(thread, remaining);
|
||||
}
|
||||
}
|
||||
for (ExecutorService executor : ownedServices) {
|
||||
long remaining = deadline - System.nanoTime();
|
||||
if (!executor.isTerminated() && remaining > 0L) {
|
||||
executor.awaitTermination(remaining, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException("Interrupted while draining Iris background resources", failure);
|
||||
}
|
||||
List<String> activeResources = new ArrayList<>();
|
||||
for (Thread thread : ownedThreads) {
|
||||
if (thread.isAlive() && thread != Thread.currentThread()) {
|
||||
activeResources.add("thread " + thread.getName() + " (" + thread.getState() + ")");
|
||||
}
|
||||
}
|
||||
for (ExecutorService executor : ownedServices) {
|
||||
if (!executor.isTerminated()) {
|
||||
activeResources.add("executor " + executor);
|
||||
}
|
||||
}
|
||||
if (!activeResources.isEmpty()) {
|
||||
throw new IllegalStateException("Iris background resources did not terminate within "
|
||||
+ timeoutMillis + " ms: " + String.join(", ", activeResources));
|
||||
}
|
||||
}
|
||||
|
||||
public void updateCaches() {
|
||||
|
||||
@@ -195,7 +195,9 @@ public class IrisPostModifier extends EngineAssignedModifier<PlatformBlockState>
|
||||
// Wall Patcher
|
||||
IrisBiome biome = context.getBiome().get(currentPostX, currentPostZ);
|
||||
|
||||
if (walls && !river) {
|
||||
// Neighbour heights come from trueHeight, which excludes fluid, so every coastal column reads a
|
||||
// seaward drop and would have its beach surface replaced with the wall palette.
|
||||
if (walls && !river && !biome.isShore()) {
|
||||
if (!biome.getWall().getPalette().isEmpty()) {
|
||||
if (ha < h - 2 || hb < h - 2 || hc < h - 2 || hd < h - 2) {
|
||||
boolean brokeGround = false;
|
||||
|
||||
@@ -246,8 +246,9 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
|
||||
continue;
|
||||
}
|
||||
|
||||
int strataIndex = strataIndex(depth, cut, blocks.size());
|
||||
PlatformBlockState layerBlock = paintHydrologyMaterial(
|
||||
blocks.hasIndex(depth + cut) ? blocks.get(depth + cut) : null,
|
||||
blocks.hasIndex(strataIndex) ? blocks.get(strataIndex) : null,
|
||||
roleMaterial, depth, localRng, realX, i, realZ, data);
|
||||
if (layerBlock != null) {
|
||||
if (riverOwned && depth <= riverBed.getPadding() && IrisProceduralBlocks.isGravityAffected(layerBlock)) {
|
||||
@@ -324,6 +325,16 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The palette index for a column that erosion cut {@code cut} blocks down into. The offset is
|
||||
* clamped to the deepest authored layer so an exposed bank shows soil rather than raw rock,
|
||||
* while a depth past the authored soil still runs off the end and falls through to rock. A
|
||||
* cut of zero collapses to {@code depth}, leaving every uncut column untouched.
|
||||
*/
|
||||
static int strataIndex(int depth, int cut, int paletteSize) {
|
||||
return depth + Math.min(cut, Math.max(0, paletteSize - 1 - depth));
|
||||
}
|
||||
|
||||
/**
|
||||
* The river material that owns this column's top layers, or null when the biome layers stand.
|
||||
* Roles are exclusive: a channel bed, then the shore strip, then the eroded bank.
|
||||
|
||||
@@ -75,6 +75,10 @@ public class CachedStream2D<T> extends BasicStream<T> implements ProceduralStrea
|
||||
return cache.getMaxSize();
|
||||
}
|
||||
|
||||
public void setMaximumChunks(int maximumChunks) {
|
||||
cache.setMaximumChunks(maximumChunks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isClosed() {
|
||||
return engine.isClosed();
|
||||
|
||||
@@ -22,6 +22,7 @@ import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.biome.IrisBiomeCustom;
|
||||
import art.arcane.iris.generation.biome.IrisCustomBiomeAliasResolver;
|
||||
import art.arcane.iris.generation.biome.IrisFloatingChildBiomes;
|
||||
import art.arcane.iris.generation.biome.IrisSurfaceLayerFallback;
|
||||
import art.arcane.iris.generation.cave.IrisCaveProfile;
|
||||
import art.arcane.iris.generation.decoration.IrisDepositGenerator;
|
||||
import art.arcane.iris.generation.decoration.IrisDepositVariant;
|
||||
@@ -323,6 +324,8 @@ public class IrisDimension extends IrisRegistrant {
|
||||
private KList<IrisShapedGeneratorStyle> overlayNoise = new KList<>();
|
||||
@Description("The palette of blocks for 'stone'")
|
||||
private IrisMaterialPalette rockPalette = new IrisMaterialPalette().qclear().qadd("stone");
|
||||
@Description("What a column surfaces with when every biome layer is rejected. Biomes may override this.")
|
||||
private IrisSurfaceLayerFallback surfaceLayerFallback = IrisSurfaceLayerFallback.ROCK;
|
||||
@Description("The dimension fluid block palette used for ocean columns and cave aquifers.")
|
||||
private IrisMaterialPalette fluidPalette = new IrisMaterialPalette().qclear().qadd("water");
|
||||
@Description("Collection of ores to be generated")
|
||||
|
||||
@@ -127,6 +127,10 @@ public class IrisRegion extends IrisRegistrant implements Rarity {
|
||||
@MinNumber(0.0001)
|
||||
@Description("The varience of the shore height")
|
||||
private double shoreHeightZoom = 3.14;
|
||||
@MinNumber(0)
|
||||
@MaxNumber(64)
|
||||
@Description("The minimum width of the shore in blocks. Shore height alone is a vertical window, so a steep coast gets almost no beach; above zero the shore band also climbs with the local slope to stay this wide across the ground.")
|
||||
private double shoreMinimumWidth = 0;
|
||||
@MinNumber(0.0001)
|
||||
@Description("How large land biomes are in this region")
|
||||
private double landBiomeZoom = 1;
|
||||
|
||||
@@ -899,7 +899,7 @@ public class ServerConfigurator {
|
||||
try (Stream<Path> children = Files.list(root)) {
|
||||
for (Path child : children.toList()) {
|
||||
String childName = child.getFileName().toString();
|
||||
if (PackDirectoryResolver.isHiddenName(childName) || isGeneratedPackFile(childName)) {
|
||||
if (PackDirectoryResolver.isHiddenName(childName) || isNonContentPackFile(childName)) {
|
||||
continue;
|
||||
}
|
||||
if (Files.isSymbolicLink(child)) {
|
||||
@@ -953,7 +953,7 @@ public class ServerConfigurator {
|
||||
String fileName = file.getFileName().toString();
|
||||
if ((treeRoot.relativize(file).getNameCount() == 1
|
||||
&& PackDirectoryResolver.isHiddenName(fileName))
|
||||
|| isGeneratedPackFile(fileName)) {
|
||||
|| isNonContentPackFile(fileName)) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
if (attributes.isSymbolicLink() || Files.isSymbolicLink(file)) {
|
||||
@@ -981,8 +981,8 @@ public class ServerConfigurator {
|
||||
});
|
||||
}
|
||||
|
||||
private static boolean isGeneratedPackFile(String name) {
|
||||
return name != null && name.endsWith(CODE_WORKSPACE_SUFFIX);
|
||||
private static boolean isNonContentPackFile(String name) {
|
||||
return name != null && (name.equals(".DS_Store") || name.endsWith(CODE_WORKSPACE_SUFFIX));
|
||||
}
|
||||
|
||||
private record FingerprintEntry(
|
||||
|
||||
@@ -77,6 +77,7 @@ import art.arcane.iris.generation.context.IrisContext;
|
||||
import art.arcane.volmlib.util.io.ReactiveFolder;
|
||||
import art.arcane.volmlib.util.scheduling.ChronoLatch;
|
||||
import art.arcane.iris.world.task.J;
|
||||
import art.arcane.iris.world.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.volmlib.util.scheduling.Looper;
|
||||
import io.papermc.lib.PaperLib;
|
||||
import lombok.Data;
|
||||
@@ -98,6 +99,7 @@ import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -121,6 +123,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
private static final int LOAD_LOCKS = Runtime.getRuntime().availableProcessors() * 4;
|
||||
private static final long HOTLOAD_LOOP_DELAY_MS = 250L;
|
||||
private static final long HOTLOAD_MAINTENANCE_DELAY_MS = 4000L;
|
||||
private static final int PLAYER_ENTRY_MOVE_PADDING = 3;
|
||||
private final GenerationStageGate loadLock;
|
||||
private final IrisWorld world;
|
||||
private final File dataLocation;
|
||||
@@ -153,6 +156,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
private volatile CompletableFuture<Void> startupContentReady = CompletableFuture.completedFuture(null);
|
||||
private volatile CompletableFuture<Void> startupReady = CompletableFuture.completedFuture(null);
|
||||
private volatile boolean closing;
|
||||
private volatile boolean initialEntryPending;
|
||||
private volatile boolean initialPlayerEntryPending;
|
||||
@Setter
|
||||
private volatile StudioGenerator studioGenerator;
|
||||
|
||||
@@ -391,9 +396,9 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
}
|
||||
int spawnX = spawn.getBlockX();
|
||||
int spawnZ = spawn.getBlockZ();
|
||||
if (studio && expectedEngine.getDimension().getStudioMode() == StudioMode.NORMAL
|
||||
if ((initialEntryPending || studio && expectedEngine.getDimension().getStudioMode() == StudioMode.NORMAL)
|
||||
&& expectedEngine instanceof IrisEngine irisEngine) {
|
||||
irisEngine.startStudioEntryHydrology(spawnX, spawnZ);
|
||||
irisEngine.startEntryHydrology(spawnX, spawnZ);
|
||||
return;
|
||||
}
|
||||
int reach = Math.max(16, hydrology.settings().routing().tileSize() / 2);
|
||||
@@ -411,9 +416,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
private void updateSpawnLocation(World world) {
|
||||
try {
|
||||
Location initialSpawn = getInitialSpawnLocation(world);
|
||||
int chunkX = initialSpawn.getBlockX() >> 4;
|
||||
int chunkZ = initialSpawn.getBlockZ() >> 4;
|
||||
CompletableFuture<Chunk> chunkFuture = requestChunkAsync(world, chunkX, chunkZ, true);
|
||||
CompletableFuture<Chunk> chunkFuture = requestInitialSpawnChunks(world, initialSpawn);
|
||||
if (chunkFuture == null) {
|
||||
initialSpawnReady.completeExceptionally(new IllegalStateException(
|
||||
"Initial spawn chunk request returned no completion future for world \""
|
||||
@@ -454,6 +457,72 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
}
|
||||
}
|
||||
|
||||
private CompletableFuture<Chunk> requestInitialSpawnChunks(World world, Location initialSpawn) {
|
||||
int chunkX = initialSpawn.getBlockX() >> 4;
|
||||
int chunkZ = initialSpawn.getBlockZ() >> 4;
|
||||
if (!initialEntryPending) {
|
||||
return requestChunkAsync(world, chunkX, chunkZ, true);
|
||||
}
|
||||
WorldRuntimeControlService runtime = WorldRuntimeControlService.get();
|
||||
CompletableFuture<Chunk> canonical = runtime.requestChunkAsync(world, chunkX, chunkZ, true, true);
|
||||
if (canonical == null || !initialPlayerEntryPending) {
|
||||
return canonical;
|
||||
}
|
||||
List<CompletableFuture<Chunk>> requests = new ArrayList<>(4);
|
||||
requests.add(canonical);
|
||||
int minimumX = (initialSpawn.getBlockX() - PLAYER_ENTRY_MOVE_PADDING) >> 4;
|
||||
int maximumX = (initialSpawn.getBlockX() + PLAYER_ENTRY_MOVE_PADDING) >> 4;
|
||||
int minimumZ = (initialSpawn.getBlockZ() - PLAYER_ENTRY_MOVE_PADDING) >> 4;
|
||||
int maximumZ = (initialSpawn.getBlockZ() + PLAYER_ENTRY_MOVE_PADDING) >> 4;
|
||||
for (int x = minimumX; x <= maximumX; x++) {
|
||||
for (int z = minimumZ; z <= maximumZ; z++) {
|
||||
if (x == chunkX && z == chunkZ) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
CompletableFuture<Chunk> request = runtime.requestChunkAsync(world, x, z, true, true);
|
||||
if (request == null) {
|
||||
throw new IllegalStateException("Initial player entry chunk request returned no completion future for world \""
|
||||
+ world.getName() + "\" at " + x + "," + z + ".");
|
||||
}
|
||||
requests.add(request.thenApply(chunk -> Objects.requireNonNull(chunk,
|
||||
"Initial player entry chunk request completed without a chunk.")));
|
||||
} catch (RuntimeException | Error failure) {
|
||||
requests.add(CompletableFuture.failedFuture(failure));
|
||||
}
|
||||
}
|
||||
}
|
||||
CompletableFuture<Chunk> prepared = new CompletableFuture<>();
|
||||
CompletableFuture.allOf(requests.toArray(CompletableFuture<?>[]::new)).whenComplete((ignored, failure) -> {
|
||||
if (failure == null) {
|
||||
prepared.complete(canonical.getNow(null));
|
||||
return;
|
||||
}
|
||||
Throwable firstFailure = null;
|
||||
IdentityHashMap<Throwable, Boolean> failures = new IdentityHashMap<>();
|
||||
for (CompletableFuture<Chunk> request : requests) {
|
||||
Throwable requestFailure = request.handle((chunk, cause) -> cause).getNow(null);
|
||||
if (requestFailure == null) {
|
||||
continue;
|
||||
}
|
||||
Throwable distinctCause = requestFailure;
|
||||
while (distinctCause instanceof CompletionException && distinctCause.getCause() != null) {
|
||||
distinctCause = distinctCause.getCause();
|
||||
}
|
||||
if (failures.put(distinctCause, Boolean.TRUE) != null) {
|
||||
continue;
|
||||
}
|
||||
if (firstFailure == null) {
|
||||
firstFailure = request == canonical ? requestFailure : distinctCause;
|
||||
} else {
|
||||
firstFailure.addSuppressed(distinctCause);
|
||||
}
|
||||
}
|
||||
prepared.completeExceptionally(firstFailure);
|
||||
});
|
||||
return prepared;
|
||||
}
|
||||
|
||||
private void completeSpawnLocation(World world, Location initialSpawn) {
|
||||
try {
|
||||
applySpawnLocation(world, initialSpawn);
|
||||
@@ -515,8 +584,7 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
lastJigsawStudioRequestId = null;
|
||||
if (generationHistory != null) {
|
||||
try {
|
||||
generationHistory.prepareCurrentGenerator(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks());
|
||||
targetCache.reset();
|
||||
prepareGenerationHistoryTarget(IrisSettings.get().getGenerator().getGenerationTransitionWidthBlocks());
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to prepare saved Iris terrain for the current generator.", failure);
|
||||
}
|
||||
@@ -568,12 +636,41 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
isAuthoringStudio(),
|
||||
studioEntryBootstrapActive.get(),
|
||||
initializationFailure != null));
|
||||
engine = createdEngine;
|
||||
publishInitializedEngine(createdEngine);
|
||||
populators.clear();
|
||||
targetCache.reset();
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void publishInitializedEngine(IrisEngine initializedEngine) {
|
||||
if (initialEntryPending) {
|
||||
setInitialEntryNeighbourPrefetch(initializedEngine, false);
|
||||
}
|
||||
engine = initializedEngine;
|
||||
}
|
||||
|
||||
void prepareGenerationHistoryTarget(int transitionWidthBlocks) throws IOException {
|
||||
generationHistory.prepareCurrentGenerator(transitionWidthBlocks);
|
||||
EngineTarget cachedTarget = targetCache.getIfPresent();
|
||||
if (cachedTarget == null) {
|
||||
return;
|
||||
}
|
||||
Path activePackRoot = generationHistory.activePackRoot();
|
||||
IrisData cachedData = cachedTarget.getData();
|
||||
if (!cachedData.isClosed() && cachedData.getDataFolder().toPath().equals(activePackRoot)) {
|
||||
return;
|
||||
}
|
||||
targetCache.reset();
|
||||
EngineTarget pendingTarget = startupTarget;
|
||||
Engine activeEngine = engine;
|
||||
if (!cachedData.isClosed()
|
||||
&& cachedData.getEngines().isEmpty()
|
||||
&& (pendingTarget == null || pendingTarget.getData() != cachedData)
|
||||
&& (activeEngine == null || activeEngine.getData() != cachedData)) {
|
||||
cachedData.close();
|
||||
}
|
||||
}
|
||||
|
||||
private EngineTarget loadActiveGenerationHistoryTarget() throws IOException {
|
||||
Path packRoot = generationHistory.activePackRoot();
|
||||
IrisData data = IrisData.openRuntime(packRoot.toFile());
|
||||
@@ -604,7 +701,8 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
}
|
||||
|
||||
private IrisEngine createEngine(EngineTarget engineTarget) {
|
||||
IrisEngine.InitializationMode mode = selectInitializationMode(studio, jigsawStudioActive, objectStudioActive);
|
||||
IrisEngine.InitializationMode mode = selectInitializationMode(studio, jigsawStudioActive, objectStudioActive,
|
||||
initialEntryPending);
|
||||
if (generationHistory == null) {
|
||||
return new IrisEngine(engineTarget, mode);
|
||||
}
|
||||
@@ -638,9 +736,10 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
return new IllegalStateException("Unable to attach Iris generation history.", failure);
|
||||
}
|
||||
|
||||
static IrisEngine.InitializationMode selectInitializationMode(boolean studio, boolean jigsawStudioActive, boolean objectStudioActive) {
|
||||
static IrisEngine.InitializationMode selectInitializationMode(boolean studio, boolean jigsawStudioActive,
|
||||
boolean objectStudioActive, boolean initialEntry) {
|
||||
if (!studio) {
|
||||
return IrisEngine.InitializationMode.RUNTIME;
|
||||
return initialEntry ? IrisEngine.InitializationMode.WORLD_CREATION : IrisEngine.InitializationMode.RUNTIME;
|
||||
}
|
||||
return jigsawStudioActive
|
||||
? IrisEngine.InitializationMode.JIGSAW_STUDIO
|
||||
@@ -864,12 +963,14 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
// reject generation for a world that may still be loaded.
|
||||
closing = false;
|
||||
}
|
||||
completeInitialEntry();
|
||||
closeFuture.compareAndSet(future, null);
|
||||
future.completeExceptionally(throwable);
|
||||
return future;
|
||||
}
|
||||
|
||||
operation.whenComplete((ignored, throwable) -> {
|
||||
completeInitialEntry();
|
||||
if (throwable == null) {
|
||||
future.complete(null);
|
||||
} else {
|
||||
@@ -898,6 +999,36 @@ public class BukkitChunkGenerator extends ChunkGenerator implements PlatformChun
|
||||
return studio;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void beginInitialEntry(boolean playerEntry) {
|
||||
if (studio || closing || engine != null) {
|
||||
throw new IllegalStateException("Initial world entry must begin before a normal generator initializes.");
|
||||
}
|
||||
initialEntryPending = true;
|
||||
initialPlayerEntryPending = playerEntry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void completeInitialEntry() {
|
||||
if (!initialEntryPending) {
|
||||
return;
|
||||
}
|
||||
initialEntryPending = false;
|
||||
initialPlayerEntryPending = false;
|
||||
setInitialEntryNeighbourPrefetch(engine, true);
|
||||
}
|
||||
|
||||
private void setInitialEntryNeighbourPrefetch(Engine activeEngine, boolean enabled) {
|
||||
if (activeEngine == null || activeEngine.isClosed()) {
|
||||
return;
|
||||
}
|
||||
IrisComplex complex = activeEngine.getComplex();
|
||||
IrisHydrologyRuntime hydrology = complex == null ? null : complex.getHydrologyRuntime();
|
||||
if (hydrology != null) {
|
||||
hydrology.setNeighbourPrefetchEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
||||
public void endStudioEntryBootstrap() {
|
||||
Engine activeEngine = engine;
|
||||
if (activeEngine instanceof IrisEngine irisEngine) {
|
||||
|
||||
@@ -77,6 +77,10 @@ public interface PlatformChunkGenerator extends Hotloadable, DataProvider {
|
||||
|
||||
CompletableFuture<Integer> getSpawnChunks();
|
||||
|
||||
void beginInitialEntry(boolean playerEntry);
|
||||
|
||||
void completeInitialEntry();
|
||||
|
||||
default CompletableFuture<Void> getInitialSpawnReady() {
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ public class StudioSVC implements IrisService {
|
||||
queueStudioWorldDeletionOnStartup(worldNamesToDelete);
|
||||
}
|
||||
|
||||
public IrisDimension installIntoWorld(
|
||||
public GenerationPublication installIntoWorld(
|
||||
VolmitSender sender,
|
||||
IrisDimension dimension,
|
||||
File dimensionRoot,
|
||||
@@ -220,7 +220,8 @@ public class StudioSVC implements IrisService {
|
||||
File dimensionRoot,
|
||||
long worldSeed
|
||||
) {
|
||||
return publishGenerationHistory(sender, dimension, dimensionRoot, worldSeed, true);
|
||||
GenerationPublication publication = publishGenerationHistory(sender, dimension, dimensionRoot, worldSeed, true);
|
||||
return publication == null ? null : publication.dimension();
|
||||
}
|
||||
|
||||
public IrisDimension installIntoTransientWorld(
|
||||
@@ -235,7 +236,7 @@ public class StudioSVC implements IrisService {
|
||||
return installIntoDirectory(sender, dimension, folder, true);
|
||||
}
|
||||
|
||||
private IrisDimension publishGenerationHistory(
|
||||
private GenerationPublication publishGenerationHistory(
|
||||
VolmitSender sender,
|
||||
IrisDimension dimension,
|
||||
File dimensionRoot,
|
||||
@@ -288,11 +289,11 @@ public class StudioSVC implements IrisService {
|
||||
Path installedPack = stageUpdate && history.pendingActivation().isPresent()
|
||||
? history.packRoot(history.pendingActivation().orElseThrow().activationId())
|
||||
: history.activePackRoot();
|
||||
IrisDimension installed = loadInstalledDimension(installedPack, dimensionKey);
|
||||
IrisDimension installed = loadInstalledDimension(installedPack, source, dimensionKey);
|
||||
if (restartRequired) {
|
||||
ServerConfigurator.restart("An Iris generation epoch update is pending activation.");
|
||||
}
|
||||
return installed;
|
||||
return new GenerationPublication(installed, history);
|
||||
} catch (Throwable failure) {
|
||||
IrisLogging.reportError("Failed to publish generation history for dimension '"
|
||||
+ dimensionKey + "' into " + dimensionRoot.getPath(), failure);
|
||||
@@ -383,8 +384,9 @@ public class StudioSVC implements IrisService {
|
||||
}
|
||||
}
|
||||
|
||||
private static IrisDimension loadInstalledDimension(Path packRoot, String dimensionKey) throws IOException {
|
||||
PackValidationResult validation = validatePublishedPack(packRoot);
|
||||
static IrisDimension loadInstalledDimension(Path packRoot, Path validatedSource, String dimensionKey) throws IOException {
|
||||
String copiedFingerprint = ServerConfigurator.computePackTreeFingerprint(packRoot.toFile());
|
||||
PackValidationResult validation = validatePublishedPack(packRoot, validatedSource, copiedFingerprint);
|
||||
if (!validation.isLoadable()) {
|
||||
throw new BrokenPackException(packRoot.toString(), validation.getBlockingErrors());
|
||||
}
|
||||
@@ -1755,6 +1757,13 @@ public class StudioSVC implements IrisService {
|
||||
RESTART
|
||||
}
|
||||
|
||||
public record GenerationPublication(IrisDimension dimension, GenerationHistory history) {
|
||||
public GenerationPublication {
|
||||
Objects.requireNonNull(dimension, "Published dimension");
|
||||
Objects.requireNonNull(history, "Published generation history");
|
||||
}
|
||||
}
|
||||
|
||||
private record GenerationCandidate(
|
||||
String packFingerprint,
|
||||
GenerationEpoch.DimensionContract dimensionContract,
|
||||
|
||||
@@ -20,6 +20,7 @@ package art.arcane.iris.world;
|
||||
|
||||
import art.arcane.iris.world.runtime.TransientWorldCleanupSupport;
|
||||
import art.arcane.iris.world.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.world.history.GenerationHistory;
|
||||
import com.google.common.util.concurrent.AtomicDouble;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
@@ -125,7 +126,7 @@ public class IrisCreator {
|
||||
private boolean benchmark = false;
|
||||
private BiConsumer<Double, String> studioProgressConsumer;
|
||||
private BiConsumer<String, Long> studioTimingConsumer;
|
||||
private DatapackPreparation datapackPreparation = DatapackPreparation.INSTALL_IF_CHANGED;
|
||||
private DatapackPreparation datapackPreparation = DatapackPreparation.REUSE_LOADED_RUNTIME_IF_READY;
|
||||
|
||||
public static boolean removeFromBukkitYml(NamespacedKey worldKey) throws IOException {
|
||||
return BukkitWorldConfiguration.remove(
|
||||
@@ -258,9 +259,20 @@ public class IrisCreator {
|
||||
|
||||
reportCreationProgress(creationReporter, 0.26D, "prepare_world_pack");
|
||||
StudioSVC studioService = IrisServices.get(StudioSVC.class);
|
||||
IrisDimension installedDimension = benchmark && !studio
|
||||
? studioService.installIntoTransientWorld(sender, resolvedDimension, dimensionRoot)
|
||||
: studioService.installIntoWorld(sender, resolvedDimension, dimensionRoot, seed);
|
||||
long packPreparationStart = System.nanoTime();
|
||||
GenerationHistory publishedHistory = null;
|
||||
IrisDimension installedDimension;
|
||||
if (benchmark && !studio) {
|
||||
installedDimension = studioService.installIntoTransientWorld(sender, resolvedDimension, dimensionRoot);
|
||||
} else {
|
||||
StudioSVC.GenerationPublication publication = studioService.installIntoWorld(
|
||||
sender, resolvedDimension, dimensionRoot, seed);
|
||||
installedDimension = publication == null ? null : publication.dimension();
|
||||
if (publication != null) {
|
||||
publishedHistory = publication.history();
|
||||
}
|
||||
}
|
||||
reportStudioTiming("prepare_world_pack", packPreparationStart);
|
||||
if (installedDimension == null) {
|
||||
throw new IrisException("Failed to install dimension pack for " + dimension());
|
||||
}
|
||||
@@ -276,14 +288,17 @@ public class IrisCreator {
|
||||
AtomicDouble pp = new AtomicDouble(0);
|
||||
AtomicBoolean done = new AtomicBoolean(false);
|
||||
long generatorPrepareStart = System.nanoTime();
|
||||
WorldCreator wc = new IrisWorldCreator()
|
||||
IrisWorldCreator worldCreator = new IrisWorldCreator()
|
||||
.dimension(installedDimension)
|
||||
.studioPackSource(resolvedDimension.getLoader().getDataFolder())
|
||||
.name(name)
|
||||
.seed(seed)
|
||||
.studio(studio)
|
||||
.persistent(!studio && !benchmark)
|
||||
.create();
|
||||
.persistent(!studio && !benchmark);
|
||||
if (publishedHistory != null) {
|
||||
worldCreator.generationHistory(publishedHistory);
|
||||
}
|
||||
WorldCreator wc = worldCreator.create();
|
||||
reportStudioTiming("prepare_studio_generator", generatorPrepareStart);
|
||||
reportStudioProgress(0.40D, "install_datapacks");
|
||||
|
||||
@@ -292,6 +307,9 @@ public class IrisCreator {
|
||||
throw new IrisException("Access is null. Something bad happened.");
|
||||
}
|
||||
stagedGenerator = access;
|
||||
if (!studio && !benchmark) {
|
||||
access.beginInitialEntry(sender != null && sender.isPlayer());
|
||||
}
|
||||
AtomicInteger createProgressTask = startCreateProgressReporter(access, done, creationReporter);
|
||||
|
||||
reportStudioProgress(0.46D, "create_world");
|
||||
@@ -366,6 +384,7 @@ public class IrisCreator {
|
||||
}
|
||||
reportCreationProgress(creationReporter, 0.92D, "teleport_player");
|
||||
awaitSenderTeleport(world);
|
||||
access.completeInitialEntry();
|
||||
|
||||
if (pregen != null) {
|
||||
CompletableFuture<Boolean> ff = new CompletableFuture<>();
|
||||
@@ -401,6 +420,10 @@ public class IrisCreator {
|
||||
throw irisException;
|
||||
}
|
||||
throw new IrisException("Failed to create world \"" + name + "\".", failure);
|
||||
} finally {
|
||||
if (stagedGenerator != null) {
|
||||
stagedGenerator.completeInitialEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.bukkit.generator.ChunkGenerator;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
|
||||
public class IrisWorldCreator {
|
||||
@@ -40,6 +41,7 @@ public class IrisWorldCreator {
|
||||
private long seed = 1337;
|
||||
private boolean persistent;
|
||||
private File studioPackSource;
|
||||
private GenerationHistory generationHistory;
|
||||
|
||||
public IrisWorldCreator() {
|
||||
|
||||
@@ -87,6 +89,11 @@ public class IrisWorldCreator {
|
||||
return this;
|
||||
}
|
||||
|
||||
public IrisWorldCreator generationHistory(GenerationHistory history) {
|
||||
this.generationHistory = Objects.requireNonNull(history, "Generation history");
|
||||
return this;
|
||||
}
|
||||
|
||||
public WorldCreator create() {
|
||||
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
|
||||
NamespacedKey worldKey = IrisWorldStorage.keyFromName(name);
|
||||
@@ -106,42 +113,46 @@ public class IrisWorldCreator {
|
||||
.seed(seed)
|
||||
.worldFolder(worldFolder)
|
||||
.build();
|
||||
GenerationHistory generationHistory = persistent || studio
|
||||
? requireGenerationHistory(w.worldFolder(), seed)
|
||||
: null;
|
||||
GenerationHistory history = resolveGenerationHistory(w.worldFolder());
|
||||
File packRoot = studio
|
||||
? Objects.requireNonNull(studioPackSource, "Studio authoring source")
|
||||
: generationHistory != null
|
||||
? requireActivePack(generationHistory)
|
||||
: history != null
|
||||
? history.paths().packRoot(history.activeEpoch().epochId()).toFile()
|
||||
: new File(w.worldFolder(), "iris/pack");
|
||||
ChunkGenerator g = new BukkitChunkGenerator(
|
||||
w,
|
||||
studio,
|
||||
packRoot,
|
||||
dimensionName,
|
||||
generationHistory);
|
||||
history);
|
||||
|
||||
return creator.environment(environment)
|
||||
.generateStructures(true)
|
||||
.generator(g).seed(seed);
|
||||
}
|
||||
|
||||
private static GenerationHistory requireGenerationHistory(File dimensionRoot, long seed) {
|
||||
GenerationHistory resolveGenerationHistory(File dimensionRoot) {
|
||||
if (!persistent && !studio) {
|
||||
if (generationHistory != null) {
|
||||
throw new IllegalStateException("Generation history requires a persistent or Studio world.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Path root = Objects.requireNonNull(dimensionRoot, "Dimension root").toPath().toAbsolutePath().normalize();
|
||||
try {
|
||||
return GenerationHistory.open(dimensionRoot.toPath(), seed);
|
||||
if (generationHistory == null) {
|
||||
return GenerationHistory.open(root, seed);
|
||||
}
|
||||
if (!generationHistory.paths().dimensionRoot().equals(root)) {
|
||||
throw new IllegalArgumentException("Generation history belongs to a different dimension root.");
|
||||
}
|
||||
generationHistory.requireWorldSeed(seed);
|
||||
return generationHistory;
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Iris generation history is unusable at " + dimensionRoot + ".", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static File requireActivePack(GenerationHistory history) {
|
||||
try {
|
||||
return history.activePackRoot().toFile();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Iris active generation pack is unusable.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private World.Environment findEnvironment() {
|
||||
IrisDimension dim = dimension == null ? IrisData.loadAnyDimension(dimensionName, null) : dimension;
|
||||
return BukkitEnvironment.from(dim == null ? null : dim.getEnvironment());
|
||||
|
||||
@@ -488,7 +488,8 @@ public final class IrisWorldStorage {
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
try {
|
||||
return GenerationHistory.open(root).activePackRoot().toFile();
|
||||
GenerationHistory history = GenerationHistory.open(root);
|
||||
return history.paths().packRoot(history.activeEpoch().epochId()).toFile();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Iris generation history is unusable at " + root + ".", failure);
|
||||
}
|
||||
@@ -500,7 +501,8 @@ public final class IrisWorldStorage {
|
||||
.toAbsolutePath()
|
||||
.normalize();
|
||||
try {
|
||||
return GenerationHistory.open(root, expectedWorldSeed).activePackRoot().toFile();
|
||||
GenerationHistory history = GenerationHistory.open(root, expectedWorldSeed);
|
||||
return history.paths().packRoot(history.activeEpoch().epochId()).toFile();
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Iris generation history is unusable at " + root + ".", failure);
|
||||
}
|
||||
|
||||
@@ -180,19 +180,17 @@ public class IrisWorlds {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops registry entries whose pack snapshot no longer backs them.
|
||||
* Drops registry entries whose canonical world directory no longer exists.
|
||||
* <p>
|
||||
* A world whose storage is present but unusable is kept and reported instead of dropped: the entry is
|
||||
* what lets {@code /iris remove} find the world, and dropping it would not stop the server from loading
|
||||
* the folder anyway. It is excluded from {@link #getDimensions()} so one broken world cannot fail the
|
||||
* whole registry - that failure used to propagate out of the constructor and null out {@link #get()}.
|
||||
* the folder anyway. Pack history and dimension definitions are validated by {@link #getDimensions()}
|
||||
* when callers load them, rather than while reading or saving the identity registry.
|
||||
*/
|
||||
public synchronized void clean() {
|
||||
boolean removed = worlds.entrySet().removeIf(entry -> {
|
||||
try {
|
||||
Optional<File> packRoot = packRoot(entry.getKey());
|
||||
return packRoot.isEmpty()
|
||||
|| !new File(packRoot.get(), "dimensions/" + entry.getValue() + ".json").exists();
|
||||
return dimensionRoot(entry.getKey()).isEmpty();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return true;
|
||||
} catch (IllegalStateException e) {
|
||||
@@ -353,7 +351,7 @@ public class IrisWorlds {
|
||||
return null;
|
||||
}
|
||||
|
||||
private Optional<File> packRoot(String worldIdentity) {
|
||||
private Optional<File> dimensionRoot(String worldIdentity) {
|
||||
NamespacedKey worldKey = WorldIdentity.parse(worldIdentity);
|
||||
Path worldContainer = levelRoot.getParent();
|
||||
if (worldContainer == null) {
|
||||
@@ -363,13 +361,12 @@ public class IrisWorlds {
|
||||
worldKey,
|
||||
levelRoot.getFileName().toString()
|
||||
);
|
||||
Optional<File> dimensionRoot = IrisWorldStorage.frozenDimensionRoot(
|
||||
return IrisWorldStorage.frozenDimensionRoot(
|
||||
worldContainer.toFile(),
|
||||
levelRoot.toFile(),
|
||||
configuredWorldName,
|
||||
worldKey
|
||||
);
|
||||
return dimensionRoot.map(IrisWorldStorage::requireActiveGenerationPackRoot);
|
||||
}
|
||||
|
||||
private static void warnUnusableStorage(String worldIdentity, IllegalStateException failure) {
|
||||
@@ -385,7 +382,9 @@ public class IrisWorlds {
|
||||
private IrisDimension loadDimension(String worldIdentity, String id) {
|
||||
File pack;
|
||||
try {
|
||||
pack = packRoot(worldIdentity).orElse(null);
|
||||
pack = dimensionRoot(worldIdentity)
|
||||
.map(IrisWorldStorage::requireActiveGenerationPackRoot)
|
||||
.orElse(null);
|
||||
} catch (IllegalStateException unusableStorage) {
|
||||
warnUnusableStorage(worldIdentity, unusableStorage);
|
||||
return null;
|
||||
|
||||
@@ -18,7 +18,6 @@ import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -26,7 +25,6 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class GenerationHistory {
|
||||
private static final int MAXIMUM_CACHED_BOUNDARIES = 4;
|
||||
@@ -54,7 +52,8 @@ public final class GenerationHistory {
|
||||
GenerationPackRepository packs,
|
||||
GenerationHistoryStore store,
|
||||
ChunkGenerationOwnership ownership,
|
||||
GenerationKernelRegistry kernels
|
||||
GenerationKernelRegistry kernels,
|
||||
Optional<FreshPublication> publication
|
||||
) throws IOException {
|
||||
this.paths = Objects.requireNonNull(paths, "paths");
|
||||
this.packs = Objects.requireNonNull(packs, "packs");
|
||||
@@ -68,7 +67,7 @@ public final class GenerationHistory {
|
||||
this.admission = new GenerationAdmission(paths.dimensionRoot());
|
||||
this.boundaryCache = boundedCache(MAXIMUM_CACHED_BOUNDARIES);
|
||||
this.terrainSignatureCache = boundedCache(MAXIMUM_CACHED_TERRAIN_SIGNATURES);
|
||||
validateReferencedState();
|
||||
validateReferencedState(publication);
|
||||
}
|
||||
|
||||
public GenerationAdmission.RuntimeLease retainRuntime() {
|
||||
@@ -132,7 +131,7 @@ public final class GenerationHistory {
|
||||
);
|
||||
requireSourceFingerprint(packSource, epoch.packFingerprint(), epoch.packFingerprintVersion());
|
||||
GenerationPackRepository packs = new GenerationPackRepository(paths.dimensionRoot());
|
||||
packs.publish(
|
||||
Path publishedPack = packs.publish(
|
||||
epoch.epochId(),
|
||||
epoch.packFingerprint(),
|
||||
epoch.packFingerprintVersion(),
|
||||
@@ -141,7 +140,8 @@ public final class GenerationHistory {
|
||||
forceEpochPublication(paths, epoch.epochId());
|
||||
GenerationSemanticIndex.initialize(paths.dimensionRoot());
|
||||
GenerationHistoryStore store = GenerationHistoryStore.initialize(paths.generationRoot(), epoch);
|
||||
return new GenerationHistory(paths, packs, store, ownership, kernels);
|
||||
return new GenerationHistory(paths, packs, store, ownership, kernels,
|
||||
Optional.of(new FreshPublication(publishedPack, epoch)));
|
||||
}
|
||||
|
||||
public static GenerationHistory open(Path dimensionRoot) throws IOException {
|
||||
@@ -163,7 +163,7 @@ public final class GenerationHistory {
|
||||
GenerationPackRepository packs = new GenerationPackRepository(paths.dimensionRoot());
|
||||
GenerationHistoryStore store = GenerationHistoryStore.open(paths.generationRoot());
|
||||
ChunkGenerationOwnership ownership = ChunkGenerationOwnership.load(paths.ownershipRoot());
|
||||
return new GenerationHistory(paths, packs, store, ownership, kernels);
|
||||
return new GenerationHistory(paths, packs, store, ownership, kernels, Optional.empty());
|
||||
}
|
||||
|
||||
public static Optional<GenerationHistory> openIfPresent(Path dimensionRoot) throws IOException {
|
||||
@@ -436,7 +436,7 @@ public final class GenerationHistory {
|
||||
BoundarySignatureCapture signatureCapture
|
||||
) throws IOException {
|
||||
WorldChunkInventory inventory = recoverUnstoredClaims();
|
||||
validateReferencedState();
|
||||
validateReferencedState(Optional.empty());
|
||||
Optional<GenerationActivation> pending = store.pendingActivation();
|
||||
if (pending.isEmpty()) {
|
||||
return store.activeActivation();
|
||||
@@ -470,15 +470,15 @@ public final class GenerationHistory {
|
||||
return paths;
|
||||
}
|
||||
|
||||
public synchronized GenerationManifest manifest() {
|
||||
public GenerationManifest manifest() {
|
||||
return store.manifest();
|
||||
}
|
||||
|
||||
public synchronized GenerationActivation activeActivation() {
|
||||
public GenerationActivation activeActivation() {
|
||||
return store.activeActivation();
|
||||
}
|
||||
|
||||
public synchronized GenerationEpoch activeEpoch() {
|
||||
public GenerationEpoch activeEpoch() {
|
||||
return store.activeEpoch();
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@ public final class GenerationHistory {
|
||||
synchronized (this) {
|
||||
if (store.pendingActivation().isEmpty() && usesCurrentGenerator()) {
|
||||
recoverUnstoredClaims();
|
||||
validateReferencedState();
|
||||
validateReferencedState(Optional.empty());
|
||||
} else {
|
||||
if (store.pendingActivation().isEmpty()) {
|
||||
stageCurrentKernel(transitionWidthBlocks);
|
||||
@@ -808,10 +808,14 @@ public final class GenerationHistory {
|
||||
return ownership.explicitChunkCount();
|
||||
}
|
||||
|
||||
private void validateReferencedState() throws IOException {
|
||||
private void validateReferencedState(Optional<FreshPublication> publication) throws IOException {
|
||||
validateRuntimeVersions();
|
||||
GenerationEpoch active = store.activeEpoch();
|
||||
if (publication.isEmpty()
|
||||
|| !publication.get().epoch().equals(active)
|
||||
|| !publication.get().pack().equals(paths.packRoot(active.epochId()))) {
|
||||
packs.requireExactPack(active.epochId(), active.packFingerprint(), active.packFingerprintVersion());
|
||||
}
|
||||
if (store.pendingEpoch().isPresent()) {
|
||||
GenerationEpoch pending = store.pendingEpoch().orElseThrow();
|
||||
packs.requireExactPack(pending.epochId(), pending.packFingerprint(), pending.packFingerprintVersion());
|
||||
@@ -1138,15 +1142,8 @@ public final class GenerationHistory {
|
||||
throw new IOException("Generation epoch is not a safe directory: " + epochRoot);
|
||||
}
|
||||
if (File.separatorChar != '\\') {
|
||||
List<Path> directories;
|
||||
try (Stream<Path> entries = Files.walk(epochRoot)) {
|
||||
directories = entries
|
||||
.filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS))
|
||||
.sorted(Comparator.comparingInt(Path::getNameCount).reversed())
|
||||
.toList();
|
||||
}
|
||||
for (Path directory : directories) {
|
||||
forceDirectory(directory);
|
||||
if (Durability.enabled()) {
|
||||
GenerationPackRepository.forceDirectoryTree(epochRoot, GenerationHistory::forceDirectory);
|
||||
}
|
||||
forceDirectory(paths.epochsRoot());
|
||||
forceDirectory(paths.generationRoot());
|
||||
@@ -1178,6 +1175,9 @@ public final class GenerationHistory {
|
||||
};
|
||||
}
|
||||
|
||||
private record FreshPublication(Path pack, GenerationEpoch epoch) {
|
||||
}
|
||||
|
||||
private static final class PendingSemanticClaim {
|
||||
private final GenerationStage stage;
|
||||
private final GenerationSemanticIndex.Claim claim;
|
||||
|
||||
@@ -18,24 +18,35 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Collections;
|
||||
import java.util.IdentityHashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
public final class GenerationPackFingerprint {
|
||||
public static final int CURRENT_VERSION = 2;
|
||||
private static final int BUFFER_BYTES = 64 * 1_024;
|
||||
private static final int PREFETCH_FILE_BYTES = 256 * 1_024;
|
||||
private static final int PREFETCH_BATCH_FILES = 64;
|
||||
private static final int MAXIMUM_PREFETCH_WORKERS = 4;
|
||||
|
||||
private GenerationPackFingerprint() {
|
||||
}
|
||||
|
||||
public static String compute(Path packRoot, int version) throws IOException {
|
||||
return computeWithInput(packRoot, version, GenerationPackFingerprint::openInput);
|
||||
}
|
||||
|
||||
static String computeWithInput(Path packRoot, int version, InputSource inputSource) throws IOException {
|
||||
requireSupported(version);
|
||||
return switch (version) {
|
||||
case 1 -> computeVersionOne(packRoot);
|
||||
case 2 -> computeTree(packRoot, true);
|
||||
default -> throw new IOException("Unsupported Iris generation pack fingerprint version " + version + ".");
|
||||
};
|
||||
return computeTree(packRoot, version == 2, Objects.requireNonNull(inputSource, "Fingerprint input source"));
|
||||
}
|
||||
|
||||
public static void requireSupported(int version) throws IOException {
|
||||
@@ -44,11 +55,7 @@ public final class GenerationPackFingerprint {
|
||||
}
|
||||
}
|
||||
|
||||
private static String computeVersionOne(Path packRoot) throws IOException {
|
||||
return computeTree(packRoot, false);
|
||||
}
|
||||
|
||||
private static String computeTree(Path packRoot, boolean ignoreFinderMetadata) throws IOException {
|
||||
private static String computeTree(Path packRoot, boolean ignoreFinderMetadata, InputSource inputSource) throws IOException {
|
||||
Path root = Objects.requireNonNull(packRoot, "packRoot").toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) {
|
||||
throw new IOException("Generation pack fingerprint root is missing or unsafe: " + root);
|
||||
@@ -59,14 +66,112 @@ public final class GenerationPackFingerprint {
|
||||
entries.sort(Comparator.comparing(FingerprintEntry::relativePath));
|
||||
MessageDigest digest = sha256();
|
||||
byte[] buffer = new byte[BUFFER_BYTES];
|
||||
int workers = Math.min(MAXIMUM_PREFETCH_WORKERS, Runtime.getRuntime().availableProcessors());
|
||||
if (workers <= 1 || !hasPrefetchBatch(entries)) {
|
||||
for (FingerprintEntry entry : entries) {
|
||||
updateEntry(digest, entry.relativePath(), entry.size());
|
||||
streamEntry(digest, entry, buffer, inputSource);
|
||||
}
|
||||
} else {
|
||||
try (ExecutorService readers = Executors.newFixedThreadPool(workers,
|
||||
Thread.ofPlatform().name("Iris Pack Fingerprint-", 0L).factory())) {
|
||||
for (int start = 0; start < entries.size(); start += PREFETCH_BATCH_FILES) {
|
||||
List<FingerprintEntry> batch = entries.subList(start,
|
||||
Math.min(entries.size(), start + PREFETCH_BATCH_FILES));
|
||||
PrefetchedBatch prefetched = prefetchBatch(batch, inputSource, readers, workers, root);
|
||||
for (int index = 0; index < batch.size(); index++) {
|
||||
FingerprintEntry entry = batch.get(index);
|
||||
try {
|
||||
updateEntry(digest, entry.relativePath(), entry.size());
|
||||
requireSuccessfulRead(prefetched.failures()[index]);
|
||||
byte[] bytes = prefetched.contents()[index];
|
||||
if (bytes == null) {
|
||||
streamEntry(digest, entry, buffer, inputSource);
|
||||
} else {
|
||||
digest.update(bytes);
|
||||
}
|
||||
} catch (IOException | RuntimeException | Error failure) {
|
||||
suppressReadFailures(failure, prefetched.failures());
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private static boolean hasPrefetchBatch(List<FingerprintEntry> entries) {
|
||||
int smallFiles = 0;
|
||||
for (FingerprintEntry entry : entries) {
|
||||
if (entry.size() <= PREFETCH_FILE_BYTES && ++smallFiles >= PREFETCH_BATCH_FILES) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PrefetchedBatch prefetchBatch(List<FingerprintEntry> entries, InputSource inputSource,
|
||||
ExecutorService readers, int workers, Path root) throws IOException {
|
||||
PrefetchedBatch batch = new PrefetchedBatch(new byte[entries.size()][], new Throwable[entries.size()]);
|
||||
List<Callable<Void>> tasks = new ArrayList<>(workers);
|
||||
for (int worker = 0; worker < workers; worker++) {
|
||||
int first = worker;
|
||||
tasks.add(() -> {
|
||||
readBatch(entries, batch, inputSource, first, workers);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
for (Future<Void> task : readers.invokeAll(tasks)) {
|
||||
task.get();
|
||||
}
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("Interrupted while fingerprinting " + root, failure);
|
||||
} catch (ExecutionException failure) {
|
||||
throw new IOException("Unable to prefetch generation pack " + root, failure.getCause());
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
private static void readBatch(List<FingerprintEntry> entries, PrefetchedBatch batch,
|
||||
InputSource inputSource, int first, int workers) {
|
||||
for (int index = first; index < entries.size(); index += workers) {
|
||||
FingerprintEntry entry = entries.get(index);
|
||||
if (entry.size() > PREFETCH_FILE_BYTES) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
batch.contents()[index] = readSmall(entry, inputSource);
|
||||
} catch (Throwable failure) {
|
||||
batch.failures()[index] = failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readSmall(FingerprintEntry entry, InputSource inputSource) throws IOException {
|
||||
byte[] bytes = new byte[(int) entry.size()];
|
||||
int offset = 0;
|
||||
try (InputStream input = inputSource.open(entry.source())) {
|
||||
while (offset < bytes.length) {
|
||||
int read = input.read(bytes, offset, bytes.length - offset);
|
||||
if (read < 0) {
|
||||
throw changed(entry);
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
if (input.read() != -1) {
|
||||
throw changed(entry);
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void streamEntry(MessageDigest digest, FingerprintEntry entry, byte[] buffer,
|
||||
InputSource inputSource) throws IOException {
|
||||
long readBytes = 0L;
|
||||
try (InputStream input = Files.newInputStream(
|
||||
entry.source(),
|
||||
StandardOpenOption.READ,
|
||||
LinkOption.NOFOLLOW_LINKS
|
||||
)) {
|
||||
try (InputStream input = inputSource.open(entry.source())) {
|
||||
int read;
|
||||
while ((read = input.read(buffer)) >= 0) {
|
||||
if (read > 0) {
|
||||
@@ -76,11 +181,43 @@ public final class GenerationPackFingerprint {
|
||||
}
|
||||
}
|
||||
if (readBytes != entry.size()) {
|
||||
throw new IOException("Iris generation pack changed while fingerprinting: "
|
||||
+ entry.source());
|
||||
throw changed(entry);
|
||||
}
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
|
||||
static InputStream openInput(Path source) throws IOException {
|
||||
return Files.newInputStream(source, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS);
|
||||
}
|
||||
|
||||
private static void requireSuccessfulRead(Throwable failure) throws IOException {
|
||||
if (failure == null) {
|
||||
return;
|
||||
}
|
||||
if (failure instanceof IOException ioFailure) {
|
||||
throw ioFailure;
|
||||
}
|
||||
if (failure instanceof RuntimeException runtimeFailure) {
|
||||
throw runtimeFailure;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw new IOException("Unable to read generation pack entry", failure);
|
||||
}
|
||||
|
||||
private static void suppressReadFailures(Throwable primary, Throwable[] failures) {
|
||||
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
|
||||
seen.add(primary);
|
||||
Collections.addAll(seen, primary.getSuppressed());
|
||||
for (Throwable failure : failures) {
|
||||
if (failure != null && seen.add(failure)) {
|
||||
primary.addSuppressed(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IOException changed(FingerprintEntry entry) {
|
||||
return new IOException("Iris generation pack changed while fingerprinting: " + entry.source());
|
||||
}
|
||||
|
||||
private static void collectTree(
|
||||
@@ -150,6 +287,14 @@ public final class GenerationPackFingerprint {
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface InputSource {
|
||||
InputStream open(Path source) throws IOException;
|
||||
}
|
||||
|
||||
private record PrefetchedBatch(byte[][] contents, Throwable[] failures) {
|
||||
}
|
||||
|
||||
private record FingerprintEntry(Path source, String relativePath, long size) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package art.arcane.iris.world.history;
|
||||
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.iris.pack.AtomicDirectoryPublisher;
|
||||
import art.arcane.iris.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
@@ -16,12 +18,23 @@ import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.RecursiveAction;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class GenerationPackRepository {
|
||||
private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}");
|
||||
private static final int MAXIMUM_COPY_WORKERS = 4;
|
||||
|
||||
private final Path dimensionRoot;
|
||||
private final Path generationRoot;
|
||||
@@ -90,7 +103,9 @@ public final class GenerationPackRepository {
|
||||
return requireExactPack(epochId, packFingerprint, packFingerprintVersion);
|
||||
}
|
||||
forceDirectory(epochDirectory);
|
||||
return requireExactPack(epochId, packFingerprint, packFingerprintVersion);
|
||||
validateExistingAncestors(epochId);
|
||||
requireSafeDirectory(target, "Generation epoch pack is missing or unsafe");
|
||||
return target;
|
||||
} finally {
|
||||
if (Files.exists(stage, LinkOption.NOFOLLOW_LINKS)) {
|
||||
AtomicDirectoryPublisher.deleteTree(stage);
|
||||
@@ -182,6 +197,16 @@ public final class GenerationPackRepository {
|
||||
}
|
||||
|
||||
public static void copyPackTree(Path source, Path target) throws IOException {
|
||||
Executor executor = IrisPlatforms.isBound() ? MultiBurst.ioBurst : ForkJoinPool.commonPool();
|
||||
int workers = Math.max(1, Math.min(MAXIMUM_COPY_WORKERS, Runtime.getRuntime().availableProcessors() / 2));
|
||||
copyPackTree(source, target, executor, workers);
|
||||
}
|
||||
|
||||
static void copyPackTree(Path source, Path target, Executor executor, int workers) throws IOException {
|
||||
Objects.requireNonNull(executor, "executor");
|
||||
if (workers < 1 || workers > MAXIMUM_COPY_WORKERS) {
|
||||
throw new IllegalArgumentException("Pack copy workers must be between 1 and " + MAXIMUM_COPY_WORKERS + ".");
|
||||
}
|
||||
Path sourcePath = Objects.requireNonNull(source, "source").toAbsolutePath().normalize();
|
||||
if (Files.isSymbolicLink(sourcePath)) {
|
||||
throw new IOException("Pack source is a symbolic link: " + sourcePath);
|
||||
@@ -199,7 +224,7 @@ public final class GenerationPackRepository {
|
||||
+ normalizedSource + " and " + normalizedTarget);
|
||||
}
|
||||
|
||||
List<Path> copiedFiles = new ArrayList<>();
|
||||
List<Path> sourceFiles = new ArrayList<>();
|
||||
Files.walkFileTree(normalizedSource, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path directory, BasicFileAttributes attributes) throws IOException {
|
||||
@@ -229,9 +254,7 @@ public final class GenerationPackRepository {
|
||||
|| fileName.endsWith(".code-workspace")) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
Path destination = copyDestination(normalizedSource, normalizedTarget, file);
|
||||
Files.copy(file, destination, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
copiedFiles.add(destination);
|
||||
sourceFiles.add(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@@ -240,11 +263,104 @@ public final class GenerationPackRepository {
|
||||
throw new IOException("Unable to copy pack entry: " + file, failure);
|
||||
}
|
||||
});
|
||||
for (Path copiedFile : copiedFiles) {
|
||||
try (FileChannel channel = FileChannel.open(copiedFile, StandardOpenOption.WRITE)) {
|
||||
executeFilePhase(sourceFiles, executor, workers, sourceFile ->
|
||||
Files.copy(sourceFile, copyDestination(normalizedSource, normalizedTarget, sourceFile),
|
||||
StandardCopyOption.COPY_ATTRIBUTES));
|
||||
executeFilePhase(sourceFiles, executor, workers, sourceFile ->
|
||||
forceFile(copyDestination(normalizedSource, normalizedTarget, sourceFile)));
|
||||
}
|
||||
|
||||
static void forceDirectoryTree(Path root, FileAction force) throws IOException {
|
||||
Executor executor = IrisPlatforms.isBound() ? MultiBurst.ioBurst : ForkJoinPool.commonPool();
|
||||
int workers = Math.max(1, Math.min(MAXIMUM_COPY_WORKERS, Runtime.getRuntime().availableProcessors() / 2));
|
||||
forceDirectoryTree(root, executor, workers, force);
|
||||
}
|
||||
|
||||
static void forceDirectoryTree(Path root, Executor executor, int workers, FileAction force) throws IOException {
|
||||
Objects.requireNonNull(executor, "executor");
|
||||
Objects.requireNonNull(force, "force");
|
||||
if (workers < 1 || workers > MAXIMUM_COPY_WORKERS) {
|
||||
throw new IllegalArgumentException("Directory sync workers must be between 1 and " + MAXIMUM_COPY_WORKERS + ".");
|
||||
}
|
||||
List<Path> directories;
|
||||
try (Stream<Path> entries = Files.walk(root)) {
|
||||
directories = entries
|
||||
.filter(path -> Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS))
|
||||
.sorted(Comparator.comparingInt(Path::getNameCount).reversed())
|
||||
.toList();
|
||||
}
|
||||
int start = 0;
|
||||
while (start < directories.size()) {
|
||||
int end = start + 1;
|
||||
int depth = directories.get(start).getNameCount();
|
||||
while (end < directories.size() && directories.get(end).getNameCount() == depth) {
|
||||
end++;
|
||||
}
|
||||
executeFilePhase(directories.subList(start, end), executor, workers, force);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
private static void executeFilePhase(
|
||||
List<Path> files, Executor executor, int workers, FileAction action
|
||||
) throws IOException {
|
||||
FileWork work = new FileWork(files, action, new AtomicInteger(), new AtomicBoolean());
|
||||
int workerCount = Math.min(workers, files.size());
|
||||
List<CompletableFuture<Void>> copies = new ArrayList<>(workerCount);
|
||||
for (int worker = 0; worker < workerCount; worker++) {
|
||||
FileTask task = new FileTask(work);
|
||||
copies.add(task.finished);
|
||||
try {
|
||||
executor.execute(task);
|
||||
} catch (RuntimeException | Error failure) {
|
||||
task.reject(failure);
|
||||
break;
|
||||
}
|
||||
}
|
||||
awaitFileTasks(copies);
|
||||
}
|
||||
|
||||
private static void processFiles(FileWork work) throws IOException {
|
||||
int index;
|
||||
while (!work.failed().get() && (index = work.next().getAndIncrement()) < work.files().size()) {
|
||||
work.action().run(work.files().get(index));
|
||||
}
|
||||
}
|
||||
|
||||
private static void forceFile(Path file) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) {
|
||||
channel.force(true);
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitFileTasks(List<CompletableFuture<Void>> copies) throws IOException {
|
||||
CompletableFuture.allOf(copies.toArray(CompletableFuture[]::new))
|
||||
.handle((ignored, failure) -> null).join();
|
||||
Throwable failure = null;
|
||||
for (CompletableFuture<Void> copy : copies) {
|
||||
try {
|
||||
copy.join();
|
||||
} catch (CompletionException | CancellationException exception) {
|
||||
Throwable cause = exception instanceof CompletionException ? exception.getCause() : exception;
|
||||
if (failure == null) {
|
||||
failure = cause;
|
||||
} else if (failure != cause) {
|
||||
failure.addSuppressed(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure instanceof IOException exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof RuntimeException exception) {
|
||||
throw exception;
|
||||
}
|
||||
if (failure instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
if (failure != null) {
|
||||
throw new IOException("Generation pack file operation failed.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static Path copyDestination(Path source, Path target, Path entry) throws IOException {
|
||||
@@ -274,4 +390,79 @@ public final class GenerationPackRepository {
|
||||
}
|
||||
return requiredValue;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface FileAction {
|
||||
void run(Path file) throws IOException;
|
||||
}
|
||||
|
||||
private record FileWork(List<Path> files, FileAction action, AtomicInteger next, AtomicBoolean failed) {
|
||||
}
|
||||
|
||||
private static final class FileTask extends RecursiveAction implements Runnable {
|
||||
private final FileWork work;
|
||||
private final CompletableFuture<Void> finished = new CompletableFuture<>();
|
||||
private boolean started;
|
||||
private Throwable failure;
|
||||
|
||||
private FileTask(FileWork work) {
|
||||
this.work = work;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
quietlyInvoke();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized boolean cancel(boolean mayInterruptIfRunning) {
|
||||
if (finished.isDone() || !super.cancel(mayInterruptIfRunning)) {
|
||||
return false;
|
||||
}
|
||||
reject(new CancellationException("Generation pack file worker was cancelled."));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void compute() {
|
||||
synchronized (this) {
|
||||
if (started || failure != null) {
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
}
|
||||
Throwable fileFailure = null;
|
||||
try {
|
||||
processFiles(work);
|
||||
} catch (Throwable caught) {
|
||||
fileFailure = caught;
|
||||
} finally {
|
||||
finish(fileFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void reject(Throwable failure) {
|
||||
work.failed().set(true);
|
||||
if (this.failure == null) {
|
||||
this.failure = failure;
|
||||
} else if (this.failure != failure) {
|
||||
this.failure.addSuppressed(failure);
|
||||
}
|
||||
if (!started) {
|
||||
finished.completeExceptionally(this.failure);
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void finish(Throwable fileFailure) {
|
||||
if (fileFailure != null) {
|
||||
reject(fileFailure);
|
||||
}
|
||||
if (failure == null) {
|
||||
finished.complete(null);
|
||||
} else {
|
||||
super.completeExceptionally(failure);
|
||||
finished.completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,8 +554,13 @@ public final class SavedBiomeStore {
|
||||
}
|
||||
|
||||
private WriteStripe regionLock(int chunkX, int chunkZ) {
|
||||
long key = key(chunkX >> 5, chunkZ >> 5);
|
||||
return regionLocks[(int) (key ^ (key >>> 32)) & (regionLocks.length - 1)];
|
||||
long mixed = key(chunkX >> 5, chunkZ >> 5);
|
||||
mixed ^= mixed >>> 33;
|
||||
mixed *= 0xff51afd7ed558ccdL;
|
||||
mixed ^= mixed >>> 33;
|
||||
mixed *= 0xc4ceb9fe1a85ec53L;
|
||||
mixed ^= mixed >>> 33;
|
||||
return regionLocks[(int) (mixed ^ (mixed >>> 32)) & (regionLocks.length - 1)];
|
||||
}
|
||||
|
||||
private static long key(int x, int z) {
|
||||
|
||||
@@ -24,8 +24,6 @@ import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.generation.concurrent.MultiBurst;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.iris.generation.stream.CachedDoubleStream2D;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -76,9 +74,11 @@ public final class PregenPerformanceProfile {
|
||||
}
|
||||
|
||||
public static void apply(Engine engine) {
|
||||
apply();
|
||||
if (engine != null) {
|
||||
prepareNoiseCacheInPlace(engine);
|
||||
}
|
||||
apply();
|
||||
if (engine != null) {
|
||||
IrisLogging.info("Pregen profile applied: noiseCacheSize="
|
||||
+ IrisSettings.get().getPerformance().getNoiseCacheSize());
|
||||
}
|
||||
@@ -94,15 +94,7 @@ public final class PregenPerformanceProfile {
|
||||
|
||||
private static void prepareNoiseCacheInPlace(Engine engine) {
|
||||
IrisComplex complex = Objects.requireNonNull(engine.getComplex(), "Pregeneration requires an active biome complex");
|
||||
int configuredChunks = IrisSettings.get().getPerformance().getNoiseCacheSize();
|
||||
resizeNoiseCache(complex.getNaturalHeightStream(), configuredChunks);
|
||||
resizeNoiseCache(complex.getRawHeightStream(), configuredChunks);
|
||||
}
|
||||
|
||||
private static void resizeNoiseCache(ProceduralStream<Double> stream, int configuredChunks) {
|
||||
if (!(stream instanceof CachedDoubleStream2D cachedStream)) {
|
||||
throw new IllegalStateException("Pregeneration requires a mutable double terrain cache");
|
||||
}
|
||||
cachedStream.setMaximumChunks(configuredChunks);
|
||||
int configuredChunks = Math.max(IrisSettings.get().getPerformance().getNoiseCacheSize(), 4_096);
|
||||
complex.ensureTerrainNoiseCacheSize(configuredChunks);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ final class PaperLikeRuntimeControlBackend implements WorldRuntimeControlBackend
|
||||
}
|
||||
|
||||
TimeAccessStrategy strategy = resolveTimeAccessStrategy(world);
|
||||
if (strategy == null) {
|
||||
if (strategy == null || strategy.handleMethod() == null || strategy.readMethod() == null) {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -44,6 +44,12 @@ public final class IrisBiomeLayerSlopeSourceTest {
|
||||
this.lockLayers = lockLayers;
|
||||
}
|
||||
|
||||
// This test is about which slope source picks the palette, so it pins the fallback to ROCK to
|
||||
// keep a rejected layer list empty instead of capping it with the top layer.
|
||||
private static IrisDimension rockFallbackDimension() {
|
||||
return new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lowerLedgeSlopeControlsSurfacePaletteSelection() {
|
||||
IrisData data = mock(IrisData.class);
|
||||
@@ -65,9 +71,9 @@ public final class IrisBiomeLayerSlopeSourceTest {
|
||||
when(grass.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(block);
|
||||
|
||||
KList<PlatformBlockState> ledge = biome.generateLayers(
|
||||
new IrisDimension(), 12D, -8D, rng, 1, 32, data, complex);
|
||||
rockFallbackDimension(), 12D, -8D, rng, 1, 32, data, complex);
|
||||
KList<PlatformBlockState> cap = biome.generateLayers(
|
||||
new IrisDimension(), 12D, -8D, rng, 1, 96, data, complex);
|
||||
rockFallbackDimension(), 12D, -8D, rng, 1, 96, data, complex);
|
||||
|
||||
assertEquals(1, ledge.size());
|
||||
assertSame(block, ledge.get(0));
|
||||
@@ -105,9 +111,9 @@ public final class IrisBiomeLayerSlopeSourceTest {
|
||||
when(paletteLayer.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(surfaceBlock);
|
||||
|
||||
KList<PlatformBlockState> hostLayers = biome.generateLayers(
|
||||
new IrisDimension(), 12D, -8D, rng, 1, 32, data, hostComplex);
|
||||
rockFallbackDimension(), 12D, -8D, rng, 1, 32, data, hostComplex);
|
||||
KList<PlatformBlockState> sourceLayers = biome.generateLayersWithSlope(
|
||||
new IrisDimension(), 12D, -8D, rng, 1, 32, data, sourceSlope);
|
||||
rockFallbackDimension(), 12D, -8D, rng, 1, 32, data, sourceSlope);
|
||||
|
||||
assertTrue(hostLayers.isEmpty());
|
||||
assertEquals(1, sourceLayers.size());
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package art.arcane.iris.generation.biome;
|
||||
|
||||
import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.generation.terrain.IrisSlopeClip;
|
||||
|
||||
import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.iris.spi.PlatformBlockState;
|
||||
import art.arcane.volmlib.util.noise.CNG;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public final class IrisSurfaceLayerFallbackTest {
|
||||
private final IrisData data = mock(IrisData.class);
|
||||
private final IrisBiomePaletteLayer layer = mock(IrisBiomePaletteLayer.class);
|
||||
private final CNG heightGenerator = mock(CNG.class);
|
||||
private final PlatformBlockState surfaceBlock = mock(PlatformBlockState.class);
|
||||
private final PlatformBlockState secondBlock = mock(PlatformBlockState.class);
|
||||
private final RNG rng = new RNG(19L);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private final ProceduralStream<Double> slopeStream = mock(ProceduralStream.class);
|
||||
|
||||
private IrisBiome biome() {
|
||||
when(layer.getZoom()).thenReturn(1D);
|
||||
when(layer.getMinHeight()).thenReturn(1);
|
||||
when(layer.getMaxHeight()).thenReturn(2);
|
||||
when(layer.getHeightGenerator(any(RNG.class), same(data))).thenReturn(heightGenerator);
|
||||
when(heightGenerator.fit(1, 2, 12D, -8D)).thenReturn(2);
|
||||
when(layer.get(rng, 0, 12D, 0D, -8D, data)).thenReturn(surfaceBlock);
|
||||
when(layer.get(rng, 1, 13D, 1D, -9D, data)).thenReturn(secondBlock);
|
||||
when(slopeStream.getDouble(12D, -8D)).thenReturn(9D);
|
||||
return new IrisBiome().setLayers(new KList<>(layer));
|
||||
}
|
||||
|
||||
private KList<PlatformBlockState> generate(IrisBiome biome, IrisDimension dim) {
|
||||
return biome.generateLayersWithSlope(dim, 12D, -8D, rng, 4, 32, data, slopeStream);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allGatedLayersEmitTopLayerBlockUnderTopLayerFallback() {
|
||||
IrisBiome biome = biome();
|
||||
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
|
||||
|
||||
KList<PlatformBlockState> blocks = generate(biome,
|
||||
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER));
|
||||
|
||||
assertEquals(1, blocks.size());
|
||||
assertSame(surfaceBlock, blocks.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void allGatedLayersStayEmptyUnderRockFallback() {
|
||||
IrisBiome biome = biome();
|
||||
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
|
||||
|
||||
KList<PlatformBlockState> blocks = generate(biome,
|
||||
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK));
|
||||
|
||||
assertTrue(blocks.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ungatedLayersAreIdenticalUnderBothFallbacks() {
|
||||
IrisBiome biome = biome();
|
||||
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip());
|
||||
|
||||
KList<PlatformBlockState> topLayer = generate(biome,
|
||||
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER));
|
||||
KList<PlatformBlockState> rock = generate(biome,
|
||||
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK));
|
||||
|
||||
assertEquals(2, topLayer.size());
|
||||
assertSame(surfaceBlock, topLayer.get(0));
|
||||
assertSame(secondBlock, topLayer.get(1));
|
||||
assertEquals(topLayer, rock);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void biomeFallbackOverridesDimensionFallbackInBothDirections() {
|
||||
IrisBiome pinnedToTopLayer = biome().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER);
|
||||
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
|
||||
|
||||
KList<PlatformBlockState> overRock = generate(pinnedToTopLayer,
|
||||
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK));
|
||||
|
||||
assertEquals(1, overRock.size());
|
||||
assertSame(surfaceBlock, overRock.get(0));
|
||||
|
||||
IrisBiome pinnedToRock = biome().setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK);
|
||||
|
||||
KList<PlatformBlockState> overTopLayer = generate(pinnedToRock,
|
||||
new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER));
|
||||
|
||||
assertTrue(overTopLayer.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockedLayersHonorInheritedAndOverriddenFallbacks() {
|
||||
IrisBiome biome = biome().setLockLayers(true);
|
||||
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
|
||||
IrisDimension dimension = new IrisDimension().setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER);
|
||||
|
||||
KList<PlatformBlockState> blocks = generate(biome, dimension);
|
||||
|
||||
assertEquals(1, blocks.size());
|
||||
assertSame(surfaceBlock, blocks.get(0));
|
||||
assertTrue(generate(biome.setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK), dimension).isEmpty());
|
||||
assertEquals(blocks, generate(biome.setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER),
|
||||
dimension.setSurfaceLayerFallback(IrisSurfaceLayerFallback.ROCK)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lockedFallbackRespectsEmptyLayersAndDepthLimits() {
|
||||
IrisBiome biome = biome().setLockLayers(true).setSurfaceLayerFallback(IrisSurfaceLayerFallback.TOP_LAYER);
|
||||
when(layer.getSlopeCondition()).thenReturn(new IrisSlopeClip(0D, 4D));
|
||||
IrisDimension dimension = new IrisDimension();
|
||||
|
||||
assertTrue(biome.generateLayersWithSlope(dimension, 12D, -8D, rng, 0, 32, data, slopeStream).isEmpty());
|
||||
assertTrue(generate(biome.setLockLayersMax(0), dimension).isEmpty());
|
||||
assertTrue(generate(biome.setLockLayersMax(7).setLayers(new KList<>()), dimension).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dimensionDefaultsToRockFallback() {
|
||||
assertEquals(IrisSurfaceLayerFallback.ROCK, new IrisDimension().getSurfaceLayerFallback());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package art.arcane.iris.generation.concurrent;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.ForkJoinTask;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyBurstTest {
|
||||
@Test(timeout = 15000)
|
||||
public void blockedOwnersLeaveTheFullRunnableTargetAvailableForLeafTasks() throws Exception {
|
||||
int parallelism = 4;
|
||||
MultiBurst.HydrologyBurst burst = new MultiBurst.HydrologyBurst();
|
||||
ForkJoinPool pool = burst.createPool(parallelism, ForkJoinPool.defaultForkJoinWorkerThreadFactory, null);
|
||||
CountDownLatch ownersReady = new CountDownLatch(parallelism);
|
||||
CountDownLatch startOwners = new CountDownLatch(1);
|
||||
CountDownLatch ownersBlocked = new CountDownLatch(parallelism);
|
||||
CountDownLatch releaseOwners = new CountDownLatch(1);
|
||||
CountDownLatch leavesStarted = new CountDownLatch(parallelism);
|
||||
CountDownLatch releaseLeaves = new CountDownLatch(1);
|
||||
List<Future<?>> owners = new ArrayList<>();
|
||||
try {
|
||||
for (int index = 0; index < parallelism; index++) {
|
||||
owners.add(pool.submit(() -> {
|
||||
ownersReady.countDown();
|
||||
await(startOwners);
|
||||
pool.execute(() -> {
|
||||
leavesStarted.countDown();
|
||||
await(releaseLeaves);
|
||||
});
|
||||
ForkJoinPool.managedBlock(new LatchBlocker(ownersBlocked, releaseOwners));
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
assertTrue(ownersReady.await(5, TimeUnit.SECONDS));
|
||||
startOwners.countDown();
|
||||
assertTrue(ownersBlocked.await(5, TimeUnit.SECONDS));
|
||||
assertTrue("Managed waits reduced the runnable leaf capacity", leavesStarted.await(5, TimeUnit.SECONDS));
|
||||
releaseLeaves.countDown();
|
||||
releaseOwners.countDown();
|
||||
for (Future<?> owner : owners) {
|
||||
owner.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
startOwners.countDown();
|
||||
releaseLeaves.countDown();
|
||||
releaseOwners.countDown();
|
||||
MultiBurst.close(pool);
|
||||
}
|
||||
assertTrue(pool.isTerminated());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closedHydrologyWorkersTerminateAndReopenCreatesANewPool() throws Exception {
|
||||
IrisSettings previousSettings = IrisSettings.settings;
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
MultiBurst.HydrologyBurst burst = new MultiBurst.HydrologyBurst();
|
||||
Callable<ForkJoinPool> currentPool = ForkJoinTask::getPool;
|
||||
try {
|
||||
ForkJoinPool first = burst.submit(currentPool).get(5, TimeUnit.SECONDS);
|
||||
burst.close();
|
||||
assertTrue(first.isTerminated());
|
||||
assertTrue(burst.isTerminated());
|
||||
assertSame(Thread.currentThread(), burst.submit(Thread::currentThread).get(5, TimeUnit.SECONDS));
|
||||
|
||||
burst.reopen();
|
||||
ForkJoinPool reopened = burst.submit(currentPool).get(5, TimeUnit.SECONDS);
|
||||
assertNotSame(first, reopened);
|
||||
assertTrue(reopened.getParallelism() > 0);
|
||||
burst.close();
|
||||
assertTrue(reopened.isTerminated());
|
||||
} finally {
|
||||
burst.close();
|
||||
IrisSettings.settings = previousSettings;
|
||||
}
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
if (!latch.await(10, TimeUnit.SECONDS)) {
|
||||
throw new AssertionError("Timed out waiting for pool work");
|
||||
}
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private record LatchBlocker(CountDownLatch blocked, CountDownLatch release) implements ForkJoinPool.ManagedBlocker {
|
||||
@Override
|
||||
public boolean block() throws InterruptedException {
|
||||
blocked.countDown();
|
||||
release.await();
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReleasable() {
|
||||
return release.getCount() == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -73,6 +73,7 @@ public class HydrologyCaveCourseFilterTest {
|
||||
filtered.edges(),
|
||||
filtered.outlets(),
|
||||
filtered.courses(),
|
||||
Set.of(),
|
||||
filtered.cavePlans(),
|
||||
diagnostics,
|
||||
RiverFootprint.empty()
|
||||
@@ -83,9 +84,9 @@ public class HydrologyCaveCourseFilterTest {
|
||||
assertTrue(tile.features().isEmpty());
|
||||
assertTrue(tile.renderAt(8, 8).features().isEmpty());
|
||||
assertTrue(tile.nearestFeature(HydrologyFeatureType.UNDERGROUND_POOL, 8, 8, 16).isEmpty());
|
||||
assertEquals(1, tile.diagnosticCandidates().size());
|
||||
assertEquals(1, tile.localDiagnosticCandidates().size());
|
||||
assertEquals(HydrologyCandidateRejection.CAVE_CONTAINMENT,
|
||||
tile.diagnosticCandidates().getFirst().rejection());
|
||||
tile.localDiagnosticCandidates().getFirst().rejection());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -650,6 +651,7 @@ public class HydrologyCaveCourseFilterTest {
|
||||
filtered.edges(),
|
||||
filtered.outlets(),
|
||||
filtered.courses(),
|
||||
Set.of(),
|
||||
filtered.cavePlans(),
|
||||
diagnostics,
|
||||
footprint
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyDiagnosticCacheTest {
|
||||
@Test
|
||||
public void terrainLoadsLeaveDiagnosticsLazyAndQueriesRetainImmutableResults() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
HydrologyDiagnosticCandidate candidate = mock(HydrologyDiagnosticCandidate.class);
|
||||
ArrayList<HydrologyDiagnosticCandidate> supplied = new ArrayList<>(List.of(candidate));
|
||||
when(planner.plan(key)).thenReturn(tile);
|
||||
when(planner.diagnosticCandidates(tile)).thenReturn(supplied);
|
||||
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4)) {
|
||||
assertSame(tile, cache.get(key));
|
||||
verify(planner, never()).diagnosticCandidates(tile);
|
||||
AtomicInteger preparations = new AtomicInteger();
|
||||
cache.setTerrainPreparation(preparations::incrementAndGet);
|
||||
List<HydrologyDiagnosticCandidate> result = cache.diagnosticCandidates(key);
|
||||
supplied.clear();
|
||||
assertEquals(List.of(candidate), result);
|
||||
assertThrows(UnsupportedOperationException.class, () -> result.add(candidate));
|
||||
assertSame(result, cache.diagnosticCandidates(key));
|
||||
verify(planner, times(1)).plan(key);
|
||||
verify(planner, times(1)).diagnosticCandidates(tile);
|
||||
assertEquals(1, preparations.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void forbiddenThreadsCanReadOnlyCompleteCachedDiagnostics() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
AtomicBoolean forbidden = new AtomicBoolean(true);
|
||||
when(planner.plan(key)).thenReturn(tile);
|
||||
when(planner.diagnosticCandidates(tile)).thenReturn(List.of());
|
||||
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 4, null, forbidden::get)) {
|
||||
assertThrows(IllegalStateException.class, () -> cache.diagnosticCandidates(key));
|
||||
verify(planner, never()).plan(key);
|
||||
forbidden.set(false);
|
||||
List<HydrologyDiagnosticCandidate> complete = cache.diagnosticCandidates(key);
|
||||
forbidden.set(true);
|
||||
assertSame(complete, cache.diagnosticCandidates(key));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void interruptedWaiterLeavesTheSharedDiagnosticLoadRunning() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
List<HydrologyDiagnosticCandidate> expected = List.of(mock(HydrologyDiagnosticCandidate.class));
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
CountDownLatch exited = new CountDownLatch(1);
|
||||
AtomicReference<Thread> waiterThread = new AtomicReference<>();
|
||||
AtomicBoolean interrupted = new AtomicBoolean();
|
||||
when(planner.plan(key)).thenReturn(tile);
|
||||
when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> {
|
||||
started.countDown();
|
||||
assertTrue(release.await(5L, TimeUnit.SECONDS));
|
||||
return expected;
|
||||
});
|
||||
ExecutorService workers = Executors.newSingleThreadExecutor();
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, workers);
|
||||
try {
|
||||
Future<?> waiter = callers.submit(() -> {
|
||||
waiterThread.set(Thread.currentThread());
|
||||
try {
|
||||
assertThrows(CancellationException.class, () -> cache.diagnosticCandidates(key));
|
||||
interrupted.set(Thread.currentThread().isInterrupted());
|
||||
} finally {
|
||||
exited.countDown();
|
||||
}
|
||||
});
|
||||
assertTrue(started.await(5L, TimeUnit.SECONDS));
|
||||
Future<List<HydrologyDiagnosticCandidate>> second = callers.submit(() -> cache.diagnosticCandidates(key));
|
||||
waiterThread.get().interrupt();
|
||||
assertTrue(exited.await(2L, TimeUnit.SECONDS));
|
||||
waiter.get(2L, TimeUnit.SECONDS);
|
||||
assertTrue(interrupted.get());
|
||||
assertFalse(second.isDone());
|
||||
release.countDown();
|
||||
assertEquals(expected, second.get(5L, TimeUnit.SECONDS));
|
||||
verify(planner, times(1)).diagnosticCandidates(tile);
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
callers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearDoesNotPublishAnOlderDiagnosticResultIntoTheNewEpoch() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
List<HydrologyDiagnosticCandidate> old = List.of(mock(HydrologyDiagnosticCandidate.class));
|
||||
List<HydrologyDiagnosticCandidate> fresh = List.of(mock(HydrologyDiagnosticCandidate.class));
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicInteger attempts = new AtomicInteger();
|
||||
when(planner.plan(key)).thenReturn(tile);
|
||||
when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> {
|
||||
if (attempts.getAndIncrement() == 0) {
|
||||
started.countDown();
|
||||
assertTrue(release.await(5L, TimeUnit.SECONDS));
|
||||
return old;
|
||||
}
|
||||
return fresh;
|
||||
});
|
||||
ExecutorService caller = Executors.newSingleThreadExecutor();
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
|
||||
try {
|
||||
Future<List<HydrologyDiagnosticCandidate>> first = caller.submit(() -> cache.diagnosticCandidates(key));
|
||||
assertTrue(started.await(5L, TimeUnit.SECONDS));
|
||||
cache.clear();
|
||||
assertEquals(fresh, cache.diagnosticCandidates(key));
|
||||
release.countDown();
|
||||
assertEquals(old, first.get(5L, TimeUnit.SECONDS));
|
||||
assertEquals(fresh, cache.diagnosticCandidates(key));
|
||||
assertEquals(2, attempts.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
caller.shutdownNow();
|
||||
assertTrue(caller.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
cache.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeCancelsQueuedDiagnosticsAndDrainsActiveDiagnostics() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey firstKey = new HydrologyTileKey(0, 0);
|
||||
HydrologyTileKey secondKey = new HydrologyTileKey(1, 0);
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
CountDownLatch queued = new CountDownLatch(1);
|
||||
AtomicInteger submissions = new AtomicInteger();
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> {
|
||||
started.countDown();
|
||||
assertTrue(release.await(5L, TimeUnit.SECONDS));
|
||||
return List.of();
|
||||
});
|
||||
ExecutorService workers = Executors.newSingleThreadExecutor();
|
||||
ExecutorService callers = Executors.newFixedThreadPool(3);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4, task -> {
|
||||
workers.execute(task);
|
||||
if (submissions.incrementAndGet() == 2) {
|
||||
queued.countDown();
|
||||
}
|
||||
});
|
||||
try {
|
||||
Future<List<HydrologyDiagnosticCandidate>> first = callers.submit(() -> cache.diagnosticCandidates(firstKey));
|
||||
assertTrue(started.await(5L, TimeUnit.SECONDS));
|
||||
Future<?> second = callers.submit(() -> cache.diagnosticCandidates(secondKey));
|
||||
assertTrue(queued.await(5L, TimeUnit.SECONDS));
|
||||
Future<?> closing = callers.submit(cache::close);
|
||||
ExecutionException cancelled = assertThrows(ExecutionException.class, () -> second.get(5L, TimeUnit.SECONDS));
|
||||
assertTrue(cancelled.getCause() instanceof CancellationException);
|
||||
assertThrows(TimeoutException.class, () -> closing.get(100L, TimeUnit.MILLISECONDS));
|
||||
assertThrows(CancellationException.class, () -> cache.diagnosticCandidates(firstKey));
|
||||
release.countDown();
|
||||
assertEquals(List.of(), first.get(5L, TimeUnit.SECONDS));
|
||||
closing.get(5L, TimeUnit.SECONDS);
|
||||
verify(planner, never()).plan(secondKey);
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
callers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
assertTrue(callers.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void aDiagnosticLoadCannotCloseItsOwnCache() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
when(planner.plan(key)).thenReturn(tile);
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
|
||||
when(planner.diagnosticCandidates(tile)).thenAnswer(invocation -> {
|
||||
assertThrows(IllegalStateException.class, cache::close);
|
||||
return List.of();
|
||||
});
|
||||
assertEquals(List.of(), cache.diagnosticCandidates(key));
|
||||
assertSame(tile, cache.get(key));
|
||||
cache.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void diagnosticFailureIsRetriedAndCompletedListsRemainBounded() throws Exception {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(planner.diagnosticCandidates(tile)).thenThrow(new IllegalStateException("expected")).thenReturn(List.of());
|
||||
try (HydrologyTileCache cache = new HydrologyTileCache(planner, 2)) {
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
assertThrows(IllegalStateException.class, () -> cache.diagnosticCandidates(key));
|
||||
assertEquals(List.of(), cache.diagnosticCandidates(key));
|
||||
for (int index = 1; index < 24; index++) {
|
||||
assertEquals(List.of(), cache.diagnosticCandidates(new HydrologyTileKey(index, 0)));
|
||||
}
|
||||
Field field = HydrologyTileCache.class.getDeclaredField("diagnostics");
|
||||
field.setAccessible(true);
|
||||
Cache<?, ?> completed = (Cache<?, ?>) field.get(cache);
|
||||
completed.cleanUp();
|
||||
assertTrue(completed.estimatedSize() <= 2L);
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyFallbackDiagnosticsTest {
|
||||
private static final HydrologyTileKey KEY = new HydrologyTileKey(0, 0);
|
||||
private static final HydrologyCaveCourseFilter.Result EMPTY = new HydrologyCaveCourseFilter.Result(
|
||||
List.of(), List.of(), List.of(), List.of(), List.of());
|
||||
private static final long DISCARDED = 200L;
|
||||
private static final long ADOPTED = 201L;
|
||||
|
||||
/**
|
||||
* A fallback re-routing re-runs source selection over the whole owned lattice, so every discarded trial invents a
|
||||
* full set of per-node rejections. Only the trial the draft actually keeps may report any.
|
||||
*/
|
||||
@Test(timeout = 15000)
|
||||
public void discardedFallbackTrialsDoNotReportTheirRejections() throws Exception {
|
||||
Harness harness = harness();
|
||||
HydrologyOwnerDraft draft = run(harness.resolver());
|
||||
|
||||
assertEquals(List.of(diagnosticId(ADOPTED)),
|
||||
draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
|
||||
assertEquals(List.of(ADOPTED), draft.result().courses().stream().map(RiverCourse::id).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the rejections of a discarded trial are dropped. Its refined edges and source compilations are caches
|
||||
* keyed by routing and source set, so the owner keeps them and the next trial does not recompile them. This is
|
||||
* the one part of a discarded trial that can still reach generation output.
|
||||
*/
|
||||
@Test(timeout = 15000)
|
||||
public void discardedFallbackTrialsStillMergeTheirCompilationCaches() throws Exception {
|
||||
Harness harness = harness();
|
||||
run(harness.resolver());
|
||||
|
||||
assertEquals(Set.of(DISCARDED, ADOPTED), new HashSet<>(harness.refinedEdges().get().keySet()));
|
||||
assertEquals(Set.of(DISCARDED, ADOPTED), harness.sourceCompilations().get().keySet().stream()
|
||||
.map(key -> key.routing().outlets().getFirst().outlet().id())
|
||||
.collect(Collectors.toSet()));
|
||||
}
|
||||
|
||||
private static HydrologyOwnerDraft run(HydrologyCrossTileResolver resolver) throws Exception {
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
return pool.submit(() -> resolver.compileOwnerDraft(KEY, null, false)).get(10, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static Harness harness() throws Exception {
|
||||
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land");
|
||||
HydrologyPlanner planner = new HydrologyPlanner(71L, HydrologyPlannerSettings.defaults(), terrain);
|
||||
HydrologyGridNode source = new HydrologyGridNode(0, 0, 0, 0, 0, 10L, terrain.sample(0, 0));
|
||||
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 2048, 1, 64, List.of(source));
|
||||
HydrologyRoutingPlan primary = routing(List.of());
|
||||
planner.routingContexts.put(KEY, new SourceRoutingContext(grid, primary, primary, List.of()));
|
||||
HydrologySourcePlanner sourcePlanner = mock(HydrologySourcePlanner.class);
|
||||
when(sourcePlanner.hasRoutedSourceSearch()).thenReturn(true);
|
||||
when(sourcePlanner.requireOrganicSurface(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(sourcePlanner.buildRouting(any(), anyList(), anyBoolean()))
|
||||
.thenAnswer(invocation -> routing(invocation.getArgument(1)));
|
||||
when(sourcePlanner.selectSources(any(), any(), any(), anyBoolean(), anyBoolean(), anyList(), anyMap()))
|
||||
.thenAnswer(invocation -> {
|
||||
if (!invocation.<Boolean>getArgument(3)) {
|
||||
return SourceSelection.empty(false);
|
||||
}
|
||||
HydrologyRoutingPlan selected = invocation.getArgument(2);
|
||||
if (selected.outlets().isEmpty()) {
|
||||
return selection(false);
|
||||
}
|
||||
long outlet = selected.outlets().getFirst().outlet().id();
|
||||
invocation.<List<HydrologyDiagnosticCandidate>>getArgument(5).add(diagnostic(diagnosticId(outlet)));
|
||||
return selection(true);
|
||||
});
|
||||
when(sourcePlanner.uniqueDiagnostics(anyList()))
|
||||
.thenAnswer(invocation -> List.copyOf(invocation.<List<HydrologyDiagnosticCandidate>>getArgument(0)));
|
||||
replace(planner, "sourcePlanner", sourcePlanner);
|
||||
HydrologyOutletPlanner outletPlanner = mock(HydrologyOutletPlanner.class);
|
||||
when(outletPlanner.resolveSurfaceFallbackOutlets(any())).thenReturn(List.of(outlet(0), outlet(1)));
|
||||
replace(planner, "outletPlanner", outletPlanner);
|
||||
HydrologyCrossTileResolver resolver = spy(planner.crossTile);
|
||||
AtomicReference<Map<Long, List<HydrologyPoint>>> ownerEdges = new AtomicReference<>();
|
||||
AtomicReference<Map<HydrologyCrossTileResolver.SourceCompilationKey,
|
||||
HydrologyCrossTileResolver.SourceCompilation>> ownerCompilations = new AtomicReference<>();
|
||||
doAnswer(invocation -> {
|
||||
Map<Long, List<HydrologyPoint>> refinedEdges = invocation.getArgument(7);
|
||||
Map<HydrologyCrossTileResolver.SourceCompilationKey,
|
||||
HydrologyCrossTileResolver.SourceCompilation> sourceCompilations = invocation.getArgument(8);
|
||||
if (invocation.<Boolean>getArgument(9)) {
|
||||
// Only the owner draft publishes deep fluids, so these are the maps the trials merge into.
|
||||
ownerEdges.compareAndSet(null, refinedEdges);
|
||||
ownerCompilations.compareAndSet(null, sourceCompilations);
|
||||
}
|
||||
SourceSelection selected = invocation.getArgument(4);
|
||||
if (selected.selectedCandidateIndices.isEmpty()) {
|
||||
return new HydrologyCrossTileResolver.PublicationAttempt(EMPTY, List.of());
|
||||
}
|
||||
HydrologyRoutingPlan selectedRouting = invocation.getArgument(2);
|
||||
long outlet = selectedRouting.outlets().getFirst().outlet().id();
|
||||
refinedEdges.put(outlet, List.of(new HydrologyPoint(0, 70, 0)));
|
||||
sourceCompilations.put(
|
||||
new HydrologyCrossTileResolver.SourceCompilationKey(true, selectedRouting, List.of(0)),
|
||||
new HydrologyCrossTileResolver.SourceCompilation(
|
||||
new CompiledGraph(List.of(), List.of(), List.of(), Map.of()), List.of(), List.of()));
|
||||
return new HydrologyCrossTileResolver.PublicationAttempt(
|
||||
outlet == ADOPTED ? result(outlet, source) : EMPTY, List.of());
|
||||
}).when(resolver).compilePublication(any(), any(), any(), any(), any(), any(), any(), anyMap(), anyMap(), anyBoolean());
|
||||
return new Harness(resolver, ownerEdges, ownerCompilations);
|
||||
}
|
||||
|
||||
private record Harness(
|
||||
HydrologyCrossTileResolver resolver,
|
||||
AtomicReference<Map<Long, List<HydrologyPoint>>> refinedEdges,
|
||||
AtomicReference<Map<HydrologyCrossTileResolver.SourceCompilationKey,
|
||||
HydrologyCrossTileResolver.SourceCompilation>> sourceCompilations
|
||||
) {
|
||||
}
|
||||
|
||||
private static long diagnosticId(long outlet) {
|
||||
return 700L + outlet;
|
||||
}
|
||||
|
||||
private static SourceSelection selection(boolean accepted) {
|
||||
List<SourceCandidate> candidates = accepted ? List.of(new SourceCandidate(0, 10L, 1D, false)) : List.of();
|
||||
int size = candidates.size();
|
||||
SourceAdmissionSelection admission = new SourceAdmissionSelection(1, accepted ? List.of(0) : List.of(),
|
||||
new boolean[size], new boolean[size], new boolean[size], index -> true,
|
||||
SourceAdmissionSelection.Quotas.uniform(new int[size], 1, 1));
|
||||
return new SourceSelection(true, candidates, admission, new int[]{0}, 1);
|
||||
}
|
||||
|
||||
private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) {
|
||||
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true);
|
||||
}
|
||||
|
||||
private static OutletCandidate outlet(int index) {
|
||||
HydrologyPoint point = new HydrologyPoint(128, 63, index * 128);
|
||||
return new OutletCandidate(0, 0, new RiverOutlet(200L + index, HydrologyFeatureType.MOUTH, 10L,
|
||||
point, new HydrologyPoint(point.x() + 1, point.y(), point.z()), 63, true));
|
||||
}
|
||||
|
||||
private static HydrologyCaveCourseFilter.Result result(long id, HydrologyGridNode source) {
|
||||
HydraulicSegment segment = new HydraulicSegment(id, id, HydrologyFeatureType.SURFACE_POOL,
|
||||
70, 70, 4, 2, false, false, List.of(source.naturalPoint(), new HydrologyPoint(128, 70, 0)),
|
||||
new HydraulicChannelProfile(new double[]{4D}, new double[]{2D}));
|
||||
RiverCourse course = new RiverCourse(id, RiverCourseType.SURFACE, OptionalLong.of(source.id()),
|
||||
OptionalLong.of(id), "default", 1, List.of(), List.of(segment));
|
||||
DrainageNode node = new DrainageNode(source.id(), source.x(), source.z(), source.terrain(), 0D, id);
|
||||
return new HydrologyCaveCourseFilter.Result(List.of(node), List.of(),
|
||||
List.of(outlet(Math.toIntExact(id - 200L)).outlet()), List.of(course), List.of());
|
||||
}
|
||||
|
||||
private static HydrologyDiagnosticCandidate diagnostic(long id) {
|
||||
return new HydrologyDiagnosticCandidate(id, HydrologyCandidateKind.SOURCE,
|
||||
HydrologyFeatureType.SURFACE_POOL, new HydrologyPoint(0, 70, 0),
|
||||
HydrologyCandidateRejection.NO_DRAINAGE_PATH, 0);
|
||||
}
|
||||
|
||||
private static void replace(Object target, String name, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicIntegerArray;
|
||||
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyFallbackParallelismTest {
|
||||
private static final HydrologyTileKey KEY = new HydrologyTileKey(0, 0);
|
||||
private static final HydrologyCaveCourseFilter.Result EMPTY = new HydrologyCaveCourseFilter.Result(
|
||||
List.of(), List.of(), List.of(), List.of(), List.of());
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void publicationRejectionConsumesTheNextCompletedOutletWithoutRebuildingIt() throws Exception {
|
||||
AtomicIntegerArray builds = new AtomicIntegerArray(4);
|
||||
Fixture fixture = fixture(outlet -> {
|
||||
builds.incrementAndGet(outlet);
|
||||
if (outlet == 3) {
|
||||
throw new IllegalStateException("Unused fallback provider failed");
|
||||
}
|
||||
});
|
||||
ArrayList<Long> submitted = new ArrayList<>();
|
||||
CrossTileDraftAdmission admission = new CrossTileDraftAdmission() {
|
||||
@Override
|
||||
public void prepare() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result) {
|
||||
if (result.courses().isEmpty()) {
|
||||
return new CrossTilePublicationAdmission(result, List.of(), false);
|
||||
}
|
||||
for (int outlet = 0; outlet < 4; outlet++) {
|
||||
assertEquals("Every trial provider must finish before publication", 1, builds.get(outlet));
|
||||
}
|
||||
long course = result.courses().getFirst().id();
|
||||
submitted.add(course);
|
||||
return course == 200L
|
||||
? new CrossTilePublicationAdmission(EMPTY, List.of(diagnostic(900L)), true)
|
||||
: new CrossTilePublicationAdmission(result, List.of(), false);
|
||||
}
|
||||
};
|
||||
HydrologyOwnerDraft draft = run(fixture, admission);
|
||||
|
||||
assertEquals(List.of(200L, 201L), submitted);
|
||||
assertEquals(List.of(201L), draft.result().courses().stream().map(RiverCourse::id).toList());
|
||||
assertEquals(List.of(200L, 900L, 201L), draft.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
|
||||
for (int outlet = 0; outlet < 4; outlet++) {
|
||||
assertEquals(1, builds.get(outlet));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void requiredFallbackFailureDrainsOtherStartedProviders() throws Exception {
|
||||
CountDownLatch failed = new CountDownLatch(1);
|
||||
CountDownLatch blocked = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicBoolean completed = new AtomicBoolean();
|
||||
IllegalStateException expected = new IllegalStateException("Required fallback provider failed");
|
||||
Fixture fixture = fixture(outlet -> {
|
||||
if (outlet == 0) {
|
||||
failed.countDown();
|
||||
throw expected;
|
||||
}
|
||||
if (outlet == 1) {
|
||||
blocked.countDown();
|
||||
await(release);
|
||||
completed.set(true);
|
||||
}
|
||||
});
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
Future<HydrologyOwnerDraft> pending = pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, null, false));
|
||||
assertTrue(failed.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(blocked.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone());
|
||||
release.countDown();
|
||||
ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS));
|
||||
Throwable cause = failure.getCause();
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
assertSame(expected, cause);
|
||||
assertTrue(completed.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static HydrologyOwnerDraft run(Fixture fixture, CrossTileDraftAdmission admission) throws Exception {
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
return pool.submit(() -> fixture.resolver().compileOwnerDraft(KEY, admission, false)).get(10, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static Fixture fixture(OutletProvider provider) throws Exception {
|
||||
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land");
|
||||
HydrologyPlanner planner = new HydrologyPlanner(71L, HydrologyPlannerSettings.defaults(), terrain);
|
||||
HydrologyGridNode source = new HydrologyGridNode(0, 0, 0, 0, 0, 10L, terrain.sample(0, 0));
|
||||
HydrologySampledGrid grid = new HydrologySampledGrid(0, 0, 0, 0, 2048, 1, 64, List.of(source));
|
||||
HydrologyRoutingPlan primary = routing(List.of());
|
||||
planner.routingContexts.put(KEY, new SourceRoutingContext(grid, primary, primary, List.of()));
|
||||
HydrologySourcePlanner sourcePlanner = mock(HydrologySourcePlanner.class);
|
||||
when(sourcePlanner.hasRoutedSourceSearch()).thenReturn(true);
|
||||
when(sourcePlanner.requireOrganicSurface(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(sourcePlanner.buildRouting(any(), anyList(), anyBoolean())).thenAnswer(invocation -> {
|
||||
List<OutletCandidate> outlets = invocation.getArgument(1);
|
||||
provider.build(Math.toIntExact(outlets.getFirst().outlet().id() - 200L));
|
||||
return routing(outlets);
|
||||
});
|
||||
when(sourcePlanner.selectSources(any(), any(), any(), anyBoolean(), anyBoolean(), anyList(), anyMap()))
|
||||
.thenAnswer(invocation -> {
|
||||
if (!invocation.<Boolean>getArgument(3)) {
|
||||
return SourceSelection.empty(false);
|
||||
}
|
||||
HydrologyRoutingPlan selected = invocation.getArgument(2);
|
||||
return selection(!selected.outlets().isEmpty());
|
||||
});
|
||||
when(sourcePlanner.uniqueDiagnostics(anyList())).thenAnswer(invocation -> List.copyOf(invocation.<List<HydrologyDiagnosticCandidate>>getArgument(0)));
|
||||
replace(planner, "sourcePlanner", sourcePlanner);
|
||||
HydrologyOutletPlanner outletPlanner = mock(HydrologyOutletPlanner.class);
|
||||
when(outletPlanner.resolveSurfaceFallbackOutlets(any())).thenReturn(List.of(outlet(0), outlet(1), outlet(2), outlet(3)));
|
||||
replace(planner, "outletPlanner", outletPlanner);
|
||||
HydrologyCrossTileResolver resolver = spy(planner.crossTile);
|
||||
doAnswer(invocation -> {
|
||||
SourceSelection selected = invocation.getArgument(4);
|
||||
if (selected.selectedCandidateIndices.isEmpty()) {
|
||||
return new HydrologyCrossTileResolver.PublicationAttempt(EMPTY, List.of());
|
||||
}
|
||||
HydrologyRoutingPlan selectedRouting = invocation.getArgument(2);
|
||||
long id = selectedRouting.outlets().getFirst().outlet().id();
|
||||
boolean finalPublication = invocation.getArgument(9);
|
||||
return new HydrologyCrossTileResolver.PublicationAttempt(result(id, source),
|
||||
finalPublication ? List.of() : List.of(diagnostic(id)));
|
||||
}).when(resolver).compilePublication(any(), any(), any(), any(), any(), any(), any(), anyMap(), anyMap(), anyBoolean());
|
||||
return new Fixture(resolver);
|
||||
}
|
||||
|
||||
private static SourceSelection selection(boolean accepted) {
|
||||
List<SourceCandidate> candidates = accepted ? List.of(new SourceCandidate(0, 10L, 1D, false)) : List.of();
|
||||
int size = candidates.size();
|
||||
SourceAdmissionSelection admission = new SourceAdmissionSelection(1, accepted ? List.of(0) : List.of(),
|
||||
new boolean[size], new boolean[size], new boolean[size], index -> true,
|
||||
SourceAdmissionSelection.Quotas.uniform(new int[size], 1, 1));
|
||||
return new SourceSelection(true, candidates, admission, new int[]{0}, 1);
|
||||
}
|
||||
|
||||
private static HydrologyRoutingPlan routing(List<OutletCandidate> outlets) {
|
||||
return new HydrologyRoutingPlan(new double[]{0D}, new int[]{-1}, new int[]{0}, new int[]{0}, outlets, true);
|
||||
}
|
||||
|
||||
private static OutletCandidate outlet(int index) {
|
||||
HydrologyPoint point = new HydrologyPoint(128, 63, index * 128);
|
||||
return new OutletCandidate(0, 0, new RiverOutlet(200L + index, HydrologyFeatureType.MOUTH, 10L,
|
||||
point, new HydrologyPoint(point.x() + 1, point.y(), point.z()), 63, true));
|
||||
}
|
||||
|
||||
private static HydrologyCaveCourseFilter.Result result(long id, HydrologyGridNode source) {
|
||||
HydraulicSegment segment = new HydraulicSegment(id, id, HydrologyFeatureType.SURFACE_POOL,
|
||||
70, 70, 4, 2, false, false, List.of(source.naturalPoint(), new HydrologyPoint(128, 70, 0)),
|
||||
new HydraulicChannelProfile(new double[]{4D}, new double[]{2D}));
|
||||
RiverCourse course = new RiverCourse(id, RiverCourseType.SURFACE, OptionalLong.of(source.id()),
|
||||
OptionalLong.of(id), "default", 1, List.of(), List.of(segment));
|
||||
DrainageNode node = new DrainageNode(source.id(), source.x(), source.z(), source.terrain(), 0D, id);
|
||||
return new HydrologyCaveCourseFilter.Result(List.of(node), List.of(), List.of(outlet(Math.toIntExact(id - 200L)).outlet()),
|
||||
List.of(course), List.of());
|
||||
}
|
||||
|
||||
private static HydrologyDiagnosticCandidate diagnostic(long id) {
|
||||
return new HydrologyDiagnosticCandidate(id, HydrologyCandidateKind.SOURCE,
|
||||
HydrologyFeatureType.SURFACE_POOL, new HydrologyPoint(0, 70, 0), HydrologyCandidateRejection.SOURCE_QUOTA, 0);
|
||||
}
|
||||
|
||||
private static void replace(Object target, String name, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue("Timed out waiting for fallback release", latch.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface OutletProvider {
|
||||
void build(int outlet);
|
||||
}
|
||||
|
||||
private record Fixture(HydrologyCrossTileResolver resolver) {
|
||||
}
|
||||
}
|
||||
@@ -758,6 +758,7 @@ public class HydrologyModelTest {
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(course),
|
||||
Set.of(),
|
||||
List.of(plan),
|
||||
List.of(),
|
||||
new RiverFootprint(Map.of(
|
||||
|
||||
@@ -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 = "20e96d6f310786d09e7182ae6f73e41e5b7d07591b5571a62b1299fe1ab58b9a";
|
||||
private static final String EXPECTED_PLAN_DIGEST = "9ec4c9cf7dbdad16976041a70853b909816655170cd694058bf359092c2d5a10";
|
||||
|
||||
private static final List<HydrologyTileKey> TILES = List.of(
|
||||
new HydrologyTileKey(0, 0),
|
||||
@@ -66,7 +66,7 @@ public class HydrologyPlanHashTest {
|
||||
for (HydrologyCavePlan plan : tile.cavePlans()) {
|
||||
out.append("cave ").append(plan).append('\n');
|
||||
}
|
||||
for (HydrologyDiagnosticCandidate diagnostic : tile.diagnosticCandidates()) {
|
||||
for (HydrologyDiagnosticCandidate diagnostic : tile.localDiagnosticCandidates()) {
|
||||
out.append("diagnostic ").append(diagnostic).append('\n');
|
||||
}
|
||||
for (HydrologyFeatureRef feature : tile.features()) {
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ public class HydrologyPlannerConfinementTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(false, true)).plan(TILE);
|
||||
|
||||
List<RiverCourse> westCourses = westCourses(tile);
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), westCourses.isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), westCourses.isEmpty());
|
||||
assertTrue("no western course crossed into the east: " + describe(westCourses),
|
||||
westCourses.stream().anyMatch(course -> maximumX(course) >= BOUNDARY));
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public class HydrologyPlannerConfinementTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(true, true)).plan(TILE);
|
||||
|
||||
List<RiverCourse> westCourses = westCourses(tile);
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), westCourses.isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), westCourses.isEmpty());
|
||||
for (RiverCourse course : westCourses) {
|
||||
assertTrue("western course left its region: " + describe(List.of(course)), maximumX(course) < BOUNDARY);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ public class HydrologyPlannerConfinementTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(91L, settings(), terrain(true, false)).plan(TILE);
|
||||
|
||||
assertTrue(describe(westCourses(tile)), westCourses(tile).isEmpty());
|
||||
assertTrue("diagnostics=" + tile.diagnosticCandidates(), tile.diagnosticCandidates().stream()
|
||||
assertTrue("diagnostics=" + tile.localDiagnosticCandidates(), tile.localDiagnosticCandidates().stream()
|
||||
.anyMatch(candidate -> candidate.rejection() == HydrologyCandidateRejection.CONFINED_NO_OUTLET));
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -73,8 +73,8 @@ public class HydrologyPlannerCrossTileCaveAdmissionIntegrationTest {
|
||||
assertEquals(forward, reverse);
|
||||
List<HydrologyPoint> firstSources = sourcePoints(forward.get(pair.first()), RiverCourseType.SURFACE);
|
||||
List<HydrologyPoint> secondSources = sourcePoints(forward.get(pair.second()), RiverCourseType.SURFACE);
|
||||
assertEquals(forward.get(pair.first()).diagnosticCandidates().toString(), 1, firstSources.size());
|
||||
assertEquals(forward.get(pair.second()).diagnosticCandidates().toString(), 1, secondSources.size());
|
||||
assertEquals(forward.get(pair.first()).localDiagnosticCandidates().toString(), 1, firstSources.size());
|
||||
assertEquals(forward.get(pair.second()).localDiagnosticCandidates().toString(), 1, secondSources.size());
|
||||
assertPointNear(new HydrologyPoint(32, 121, -272), firstSources.getFirst(), 8);
|
||||
// The second tile keeps the mouth on its own coast, so its heaviest required source no longer
|
||||
// competes with the first tile's network and is accepted ahead of the lighter fallback source.
|
||||
|
||||
+1
-1
@@ -443,7 +443,7 @@ public class HydrologyPlannerGlobalInvariantTest {
|
||||
).plan(new HydrologyTileKey(0, 0));
|
||||
|
||||
assertTrue(courses(tile, RiverCourseType.SURFACE).isEmpty());
|
||||
assertTrue(tile.diagnosticCandidates().stream().anyMatch(
|
||||
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch(
|
||||
(HydrologyDiagnosticCandidate candidate) -> candidate.rejection()
|
||||
== HydrologyCandidateRejection.NO_DRAINAGE_PATH
|
||||
));
|
||||
|
||||
+10
-10
@@ -28,7 +28,7 @@ public class HydrologyPlannerSeaCaveTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast).plan(TILE);
|
||||
List<RiverCourse> caves = courses(tile, RiverCourseType.SEA_CAVE);
|
||||
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), caves.isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), caves.isEmpty());
|
||||
for (RiverCourse cave : caves) {
|
||||
assertEquals(1, cave.segments().size());
|
||||
HydraulicSegment chamber = cave.segments().getFirst();
|
||||
@@ -42,7 +42,7 @@ public class HydrologyPlannerSeaCaveTest {
|
||||
assertTrue("inner=" + inner + " connection=" + connection,
|
||||
inner.distanceSquared2D(connection) >= (long) seaCaves.depth() * seaCaves.depth());
|
||||
HydrologyCavePlan plan = tile.cavePlan(cave.id()).orElse(null);
|
||||
assertTrue("cave plan missing or rejected for " + cave.id() + " diagnostics=" + tile.diagnosticCandidates(),
|
||||
assertTrue("cave plan missing or rejected for " + cave.id() + " diagnostics=" + tile.localDiagnosticCandidates(),
|
||||
plan != null && plan.accepted());
|
||||
|
||||
int chamberColumns = 0;
|
||||
@@ -148,10 +148,10 @@ public class HydrologyPlannerSeaCaveTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(17L, settings, coast).plan(TILE);
|
||||
List<RiverCourse> caves = courses(tile, RiverCourseType.SEA_CAVE);
|
||||
|
||||
assertEquals("diagnostics=" + tile.diagnosticCandidates(), 2, caves.size());
|
||||
assertEquals("diagnostics=" + tile.localDiagnosticCandidates(), 2, caves.size());
|
||||
for (RiverCourse cave : caves) {
|
||||
HydrologyPoint connection = cave.segments().getFirst().end();
|
||||
assertTrue("connection=" + connection + " diagnostics=" + tile.diagnosticCandidates(),
|
||||
assertTrue("connection=" + connection + " diagnostics=" + tile.localDiagnosticCandidates(),
|
||||
connection.z() >= 128 && connection.z() < 256);
|
||||
}
|
||||
HydrologyPoint first = caves.get(0).segments().getFirst().end();
|
||||
@@ -159,7 +159,7 @@ public class HydrologyPlannerSeaCaveTest {
|
||||
long minimum = seaCaves.minimumSpacing() - 2L;
|
||||
assertTrue(first + " vs " + second, first.distanceSquared2D(second) >= minimum * minimum);
|
||||
int lowCoastRejections = 0;
|
||||
for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) {
|
||||
for (HydrologyDiagnosticCandidate candidate : tile.localDiagnosticCandidates()) {
|
||||
if (candidate.kind() != HydrologyCandidateKind.OUTLET
|
||||
|| candidate.projectedType() != HydrologyFeatureType.COASTAL_GROTTO
|
||||
|| candidate.point().z() < 0 || candidate.point().z() >= 128) {
|
||||
@@ -195,8 +195,8 @@ public class HydrologyPlannerSeaCaveTest {
|
||||
}
|
||||
List<RiverCourse> caves = courses(tile, RiverCourseType.SEA_CAVE);
|
||||
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), outletPoints.isEmpty());
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), caves.isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), outletPoints.isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), caves.isEmpty());
|
||||
long clearance = 2L * CHAMBER.horizontalRadius() + 12L;
|
||||
for (RiverCourse cave : caves) {
|
||||
for (HydrologyPoint point : cave.segments().getFirst().centerline()) {
|
||||
@@ -213,7 +213,7 @@ public class HydrologyPlannerSeaCaveTest {
|
||||
cliffCoast(112, 66)
|
||||
).plan(TILE);
|
||||
assertTrue(courses(lowCoast, RiverCourseType.SEA_CAVE).isEmpty());
|
||||
assertTrue(lowCoast.diagnosticCandidates().toString(),
|
||||
assertTrue(lowCoast.localDiagnosticCandidates().toString(),
|
||||
hasDiagnostic(lowCoast, HydrologyCandidateRejection.SURFACE_HEAD_RANGE));
|
||||
}
|
||||
|
||||
@@ -226,12 +226,12 @@ public class HydrologyPlannerSeaCaveTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast(112, 92)).plan(TILE);
|
||||
|
||||
assertTrue(courses(tile, RiverCourseType.SEA_CAVE).isEmpty());
|
||||
assertTrue(tile.diagnosticCandidates().toString(),
|
||||
assertTrue(tile.localDiagnosticCandidates().toString(),
|
||||
hasDiagnostic(tile, HydrologyCandidateRejection.VOLUME_LIMIT));
|
||||
}
|
||||
|
||||
private static boolean hasDiagnostic(HydrologyTile tile, HydrologyCandidateRejection rejection) {
|
||||
for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) {
|
||||
for (HydrologyDiagnosticCandidate candidate : tile.localDiagnosticCandidates()) {
|
||||
if (candidate.kind() == HydrologyCandidateKind.OUTLET
|
||||
&& candidate.projectedType() == HydrologyFeatureType.COASTAL_GROTTO
|
||||
&& candidate.rejection() == rejection) {
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ public class HydrologyPlannerSurfacePoolTest {
|
||||
|
||||
assertEquals(tile, replay);
|
||||
List<RiverCourse> pools = pools(tile);
|
||||
assertFalse(tile.diagnosticCandidates().toString(), pools.isEmpty());
|
||||
assertFalse(tile.localDiagnosticCandidates().toString(), pools.isEmpty());
|
||||
for (RiverCourse pool : pools) {
|
||||
assertEquals("lava_pool", pool.profileKey());
|
||||
assertEquals(1, pool.segments().size());
|
||||
|
||||
@@ -332,7 +332,7 @@ public class HydrologyPlannerTest {
|
||||
observedParallelOwner |= parallelThreads.size() > 1;
|
||||
assertTileContentsEqual(expected, actual);
|
||||
assertEquals(expected.courses(), actual.courses());
|
||||
assertEquals(expected.diagnosticCandidates(), actual.diagnosticCandidates());
|
||||
assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates());
|
||||
}
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
@@ -376,7 +376,7 @@ public class HydrologyPlannerTest {
|
||||
assertEquals("outlets", expected.outlets(), actual.outlets());
|
||||
assertEquals("courses", expected.courses(), actual.courses());
|
||||
assertEquals("cave plans", expected.cavePlans(), actual.cavePlans());
|
||||
assertEquals("diagnostics", expected.diagnosticCandidates(), actual.diagnosticCandidates());
|
||||
assertEquals("diagnostics", expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates());
|
||||
assertEquals("footprint column count", expected.footprint().columns().size(), actual.footprint().columns().size());
|
||||
for (Map.Entry<Long, HydrologyColumnSample> entry : expected.footprint().columns().entrySet()) {
|
||||
assertEquals("footprint column " + entry.getKey(), entry.getValue(), actual.footprint().columns().get(entry.getKey()));
|
||||
@@ -623,7 +623,7 @@ public class HydrologyPlannerTest {
|
||||
|
||||
assertEquals(expected, actual);
|
||||
assertEquals(expected.courses(), actual.courses());
|
||||
assertEquals(expected.diagnosticCandidates(), actual.diagnosticCandidates());
|
||||
assertEquals(expected.localDiagnosticCandidates(), actual.localDiagnosticCandidates());
|
||||
assertTrue(cached.computations <= uncached.computations);
|
||||
if (organic) {
|
||||
assertTrue("hits=" + cached.hits, cached.hits > 0);
|
||||
@@ -645,7 +645,7 @@ public class HydrologyPlannerTest {
|
||||
|
||||
HydrologyTile tile = planner.plan(TILE);
|
||||
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty());
|
||||
assertTrue(tile.outlets().stream().allMatch(RiverOutlet::directOcean));
|
||||
assertTrue(tile.acyclic());
|
||||
assertFalse(surfaceCourses(tile).isEmpty());
|
||||
@@ -674,7 +674,7 @@ public class HydrologyPlannerTest {
|
||||
|
||||
assertEquals(first.courses(), replay.courses());
|
||||
List<RiverCourse> courses = surfaceCourses(first);
|
||||
assertTrue(first.diagnosticCandidates().toString(), courses.size() <= 4);
|
||||
assertTrue(first.localDiagnosticCandidates().toString(), courses.size() <= 4);
|
||||
RiverCourse mainCourse = courses.getFirst();
|
||||
for (RiverCourse course : courses) {
|
||||
if (course.drainageEdges().size() > mainCourse.drainageEdges().size()) {
|
||||
@@ -740,7 +740,7 @@ public class HydrologyPlannerTest {
|
||||
|
||||
HydrologyTile tile = planner(78L, settings, rollingCoast(112)).plan(TILE);
|
||||
|
||||
assertFalse(tile.diagnosticCandidates().toString(), surfaceCourses(tile).isEmpty());
|
||||
assertFalse(tile.localDiagnosticCandidates().toString(), surfaceCourses(tile).isEmpty());
|
||||
assertTrue(surfaceCourses(tile).stream().allMatch(RiverCourse::hydraulicallyNonRising));
|
||||
}
|
||||
|
||||
@@ -835,7 +835,7 @@ public class HydrologyPlannerTest {
|
||||
DrainageNode replacementSource = backfilled.node(replacement.sourceNodeId().orElseThrow()).orElseThrow();
|
||||
assertTrue(initialSource.z() < replacementSource.z());
|
||||
assertFalse(replacement.sourceNodeId().equals(initiallySelected.sourceNodeId()));
|
||||
assertTrue(backfilled.diagnosticCandidates().stream().anyMatch(
|
||||
assertTrue(backfilled.localDiagnosticCandidates().stream().anyMatch(
|
||||
(HydrologyDiagnosticCandidate candidate) ->
|
||||
candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT
|
||||
&& candidate.point().x() == initialSource.x()
|
||||
@@ -973,7 +973,7 @@ public class HydrologyPlannerTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(82L, settings, terrain).plan(TILE);
|
||||
|
||||
assertTrue(surfaceCourses(tile).isEmpty());
|
||||
assertTrue(tile.diagnosticCandidates().toString(), tile.diagnosticCandidates().stream().anyMatch(
|
||||
assertTrue(tile.localDiagnosticCandidates().toString(), tile.localDiagnosticCandidates().stream().anyMatch(
|
||||
candidate -> candidate.rejection() == HydrologyCandidateRejection.NO_DRAINAGE_PATH
|
||||
));
|
||||
}
|
||||
@@ -996,8 +996,8 @@ public class HydrologyPlannerTest {
|
||||
assertTrue(tile.courses().isEmpty());
|
||||
assertTrue(tile.footprint().isEmpty());
|
||||
assertTrue(tile.features().isEmpty());
|
||||
assertFalse(tile.diagnosticCandidates().isEmpty());
|
||||
for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) {
|
||||
assertFalse(tile.localDiagnosticCandidates().isEmpty());
|
||||
for (HydrologyDiagnosticCandidate candidate : tile.localDiagnosticCandidates()) {
|
||||
assertEquals(HydrologyCandidateKind.SOURCE, candidate.kind());
|
||||
assertEquals(HydrologyFeatureType.SURFACE_POOL, candidate.projectedType());
|
||||
assertEquals(HydrologyCandidateRejection.NO_LEGAL_OUTLET, candidate.rejection());
|
||||
@@ -1009,7 +1009,7 @@ public class HydrologyPlannerTest {
|
||||
candidate.point().z(),
|
||||
0
|
||||
).isEmpty());
|
||||
HydrologyDiagnosticRenderSample diagnostic = tile.diagnosticRenderAt(
|
||||
HydrologyDiagnosticRenderSample diagnostic = tile.localDiagnosticRenderAt(
|
||||
candidate.point().x(),
|
||||
candidate.point().z(),
|
||||
0
|
||||
@@ -1034,7 +1034,7 @@ public class HydrologyPlannerTest {
|
||||
HydrologyTile cliff = new HydrologyPlanner(19L, settings, cliffTerrain).plan(TILE);
|
||||
|
||||
assertTrue(
|
||||
"diagnostics=" + rolling.diagnosticCandidates() + " segments=" + rolling.courses().stream()
|
||||
"diagnostics=" + rolling.localDiagnosticCandidates() + " segments=" + rolling.courses().stream()
|
||||
.flatMap((RiverCourse course) -> course.segments().stream())
|
||||
.map((HydraulicSegment segment) -> segment.type() + ":" + segment.drop())
|
||||
.toList(),
|
||||
@@ -1058,7 +1058,7 @@ public class HydrologyPlannerTest {
|
||||
}
|
||||
assertTrue(
|
||||
"features=" + cliff.features().stream().map(HydrologyFeatureRef::type).toList()
|
||||
+ " diagnostics=" + cliff.diagnosticCandidates().stream()
|
||||
+ " diagnostics=" + cliff.localDiagnosticCandidates().stream()
|
||||
.map((HydrologyDiagnosticCandidate candidate) ->
|
||||
candidate.projectedType() + ":" + candidate.rejection())
|
||||
.toList(),
|
||||
@@ -1134,7 +1134,7 @@ public class HydrologyPlannerTest {
|
||||
};
|
||||
HydrologyTile tile = new HydrologyPlanner(812L, settings, terrain, solidCaveView()).plan(TILE);
|
||||
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), surfaceCourses(tile).isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), surfaceCourses(tile).isEmpty());
|
||||
boolean aggregateTransition = false;
|
||||
for (RiverCourse course : surfaceCourses(tile)) {
|
||||
for (HydraulicSegment segment : course.segments()) {
|
||||
@@ -1352,7 +1352,7 @@ public class HydrologyPlannerTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(333L, settings, coast, solidCaveView()).plan(TILE);
|
||||
|
||||
assertTrue(courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
|
||||
assertTrue(tile.diagnosticCandidates().stream().anyMatch(
|
||||
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch(
|
||||
(HydrologyDiagnosticCandidate candidate) ->
|
||||
candidate.rejection() == HydrologyCandidateRejection.OUTLET_LEVEL
|
||||
));
|
||||
@@ -1393,7 +1393,7 @@ public class HydrologyPlannerTest {
|
||||
footprint -> solidCaveView()
|
||||
).plan(TILE);
|
||||
|
||||
assertFalse(tile.diagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
|
||||
assertFalse(tile.localDiagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
|
||||
for (RiverCourse course : courses(tile, RiverCourseType.UNDERGROUND)) {
|
||||
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
|
||||
assertEquals(HydrologyFeatureType.INLAND_GROTTO, outlet.type());
|
||||
@@ -1428,11 +1428,11 @@ public class HydrologyPlannerTest {
|
||||
|
||||
assertTrue(courses(narrowTile, RiverCourseType.UNDERGROUND).isEmpty());
|
||||
assertTrue(courses(wideTile, RiverCourseType.UNDERGROUND).isEmpty());
|
||||
assertTrue(narrowTile.diagnosticCandidates().stream().anyMatch(
|
||||
assertTrue(narrowTile.localDiagnosticCandidates().stream().anyMatch(
|
||||
(HydrologyDiagnosticCandidate candidate) ->
|
||||
candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT
|
||||
));
|
||||
assertTrue(wideTile.diagnosticCandidates().stream().anyMatch(
|
||||
assertTrue(wideTile.localDiagnosticCandidates().stream().anyMatch(
|
||||
(HydrologyDiagnosticCandidate candidate) ->
|
||||
candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT
|
||||
));
|
||||
@@ -1458,12 +1458,12 @@ public class HydrologyPlannerTest {
|
||||
footprint -> solidCaveView()
|
||||
).plan(TILE);
|
||||
|
||||
assertFalse(tile.diagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
|
||||
assertFalse(tile.localDiagnosticCandidates().toString(), courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
|
||||
RiverCourse course = courses(tile, RiverCourseType.UNDERGROUND).getFirst();
|
||||
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
|
||||
assertFalse(
|
||||
"outlet=" + outlet.type() + " source=" + course.sourceNodeId()
|
||||
+ " diagnostics=" + tile.diagnosticCandidates(),
|
||||
+ " diagnostics=" + tile.localDiagnosticCandidates(),
|
||||
outlet.directOcean()
|
||||
);
|
||||
assertEquals(71, outlet.connectionPoint().y());
|
||||
@@ -1597,7 +1597,7 @@ public class HydrologyPlannerTest {
|
||||
HydrologyTile tile = planner.plan(TILE);
|
||||
|
||||
assertFalse(
|
||||
"courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(),
|
||||
"courses=" + tile.courses() + " diagnostics=" + tile.localDiagnosticCandidates(),
|
||||
tile.outlets().isEmpty()
|
||||
);
|
||||
assertTrue(tile.outlets().stream().allMatch(RiverOutlet::directOcean));
|
||||
@@ -1625,7 +1625,7 @@ public class HydrologyPlannerTest {
|
||||
).plan(TILE);
|
||||
|
||||
assertFalse(
|
||||
"courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(),
|
||||
"courses=" + tile.courses() + " diagnostics=" + tile.localDiagnosticCandidates(),
|
||||
tile.footprint().isEmpty()
|
||||
);
|
||||
for (HydrologyColumnSample column : tile.footprint().columns().values()) {
|
||||
@@ -1650,7 +1650,7 @@ public class HydrologyPlannerTest {
|
||||
rollingCoast(112)
|
||||
).plan(TILE);
|
||||
|
||||
assertFalse("courses=" + tile.courses() + " diagnostics=" + tile.diagnosticCandidates(), tile.footprint().isEmpty());
|
||||
assertFalse("courses=" + tile.courses() + " diagnostics=" + tile.localDiagnosticCandidates(), tile.footprint().isEmpty());
|
||||
boolean sawGrading = false;
|
||||
for (HydrologyColumnSample column : tile.footprint().columns().values()) {
|
||||
assertEquals(column.ocean() ? "ocean_parent" : "parent", column.parentBiomeKey());
|
||||
@@ -1870,9 +1870,9 @@ public class HydrologyPlannerTest {
|
||||
};
|
||||
HydrologyTile tile = new HydrologyPlanner(994L, settings, cliffCoast).plan(TILE);
|
||||
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty());
|
||||
for (RiverOutlet outlet : tile.outlets()) {
|
||||
assertEquals("outlet=" + outlet + " diagnostics=" + tile.diagnosticCandidates(),
|
||||
assertEquals("outlet=" + outlet + " diagnostics=" + tile.localDiagnosticCandidates(),
|
||||
HydrologyFeatureType.COASTAL_GROTTO, outlet.type());
|
||||
assertTrue(outlet.directOcean());
|
||||
assertEquals(settings.seaLevel(), outlet.connectionPoint().y());
|
||||
@@ -1937,12 +1937,12 @@ public class HydrologyPlannerTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(70143L, settings, terrain, solidCaveView()).plan(TILE);
|
||||
|
||||
assertEquals(
|
||||
"outlets=" + tile.outlets() + " diagnostics=" + tile.diagnosticCandidates(),
|
||||
"outlets=" + tile.outlets() + " diagnostics=" + tile.localDiagnosticCandidates(),
|
||||
1,
|
||||
tile.outlets().stream().filter(RiverOutlet::directOcean).count()
|
||||
);
|
||||
assertEquals(
|
||||
"outlets=" + tile.outlets() + " diagnostics=" + tile.diagnosticCandidates(),
|
||||
"outlets=" + tile.outlets() + " diagnostics=" + tile.localDiagnosticCandidates(),
|
||||
1,
|
||||
tile.outlets().stream().filter((RiverOutlet outlet) -> !outlet.directOcean()).count()
|
||||
);
|
||||
@@ -1985,7 +1985,7 @@ public class HydrologyPlannerTest {
|
||||
|
||||
HydrologyTile tile = new HydrologyPlanner(883L, settings, twoCoasts).plan(TILE);
|
||||
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), tile.outlets().isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), tile.outlets().isEmpty());
|
||||
assertFalse(surfaceCourses(tile).isEmpty());
|
||||
for (RiverOutlet outlet : tile.outlets()) {
|
||||
assertTrue(
|
||||
@@ -2034,12 +2034,12 @@ public class HydrologyPlannerTest {
|
||||
|
||||
HydrologyTile tile = new HydrologyPlanner(884L, settings, coast, solidCaveView()).plan(TILE);
|
||||
|
||||
List<HydrologyDiagnosticCandidate> level = tile.diagnosticCandidates().stream()
|
||||
List<HydrologyDiagnosticCandidate> level = tile.localDiagnosticCandidates().stream()
|
||||
.filter((HydrologyDiagnosticCandidate candidate) ->
|
||||
candidate.kind() == HydrologyCandidateKind.OUTLET
|
||||
&& candidate.rejection() == HydrologyCandidateRejection.OUTLET_LEVEL)
|
||||
.toList();
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), level.isEmpty());
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), level.isEmpty());
|
||||
for (HydrologyDiagnosticCandidate candidate : level) {
|
||||
assertTrue(
|
||||
candidate.toString(),
|
||||
@@ -2071,8 +2071,8 @@ public class HydrologyPlannerTest {
|
||||
|
||||
HydrologyTile tile = new HydrologyPlanner(885L, settings, island, solidCaveView()).plan(TILE);
|
||||
|
||||
assertFalse("diagnostics=" + tile.diagnosticCandidates(), surfaceCourses(tile).isEmpty());
|
||||
assertTrue(tile.diagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) ->
|
||||
assertFalse("diagnostics=" + tile.localDiagnosticCandidates(), surfaceCourses(tile).isEmpty());
|
||||
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch((HydrologyDiagnosticCandidate candidate) ->
|
||||
candidate.rejection() == HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED));
|
||||
for (RiverCourse course : surfaceCourses(tile)) {
|
||||
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
|
||||
@@ -2107,7 +2107,7 @@ public class HydrologyPlannerTest {
|
||||
HydrologyTile repeated = planner.plan(TILE);
|
||||
|
||||
assertEquals(first, repeated);
|
||||
assertEquals(first.diagnosticCandidates().toString(), 1, surfaceCourses(first).size());
|
||||
assertEquals(first.localDiagnosticCandidates().toString(), 1, surfaceCourses(first).size());
|
||||
RiverCourse course = surfaceCourses(first).getFirst();
|
||||
assertTrue(course.surfaceSinkholeContinuation());
|
||||
RiverOutlet outlet = first.outlet(course.outletId().orElseThrow()).orElseThrow();
|
||||
@@ -2179,7 +2179,7 @@ public class HydrologyPlannerTest {
|
||||
|
||||
assertTrue(surfaceCourses(tile).isEmpty());
|
||||
assertFalse(courses(tile, RiverCourseType.UNDERGROUND).isEmpty());
|
||||
assertTrue(tile.diagnosticCandidates().stream().anyMatch(
|
||||
assertTrue(tile.localDiagnosticCandidates().stream().anyMatch(
|
||||
(HydrologyDiagnosticCandidate candidate) -> candidate.projectedType() == HydrologyFeatureType.SURFACE_POOL
|
||||
&& candidate.rejection() == HydrologyCandidateRejection.NO_LEGAL_OUTLET
|
||||
));
|
||||
@@ -2239,7 +2239,7 @@ public class HydrologyPlannerTest {
|
||||
|
||||
HydrologyTile tile = new HydrologyPlanner(70142L, settings, terrain, solidCaveView()).plan(TILE);
|
||||
|
||||
assertEquals(tile.diagnosticCandidates().toString(), 1, surfaceCourses(tile).size());
|
||||
assertEquals(tile.localDiagnosticCandidates().toString(), 1, surfaceCourses(tile).size());
|
||||
RiverCourse course = surfaceCourses(tile).getFirst();
|
||||
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
|
||||
assertFalse(outlet.directOcean());
|
||||
@@ -2264,7 +2264,7 @@ public class HydrologyPlannerTest {
|
||||
HydrologyTile tile = new HydrologyPlanner(70140L, settings, terrain, solidCaveView()).plan(TILE);
|
||||
|
||||
assertTrue(
|
||||
tile.diagnosticCandidates().toString(),
|
||||
tile.localDiagnosticCandidates().toString(),
|
||||
tile.outlets().stream().anyMatch((RiverOutlet outlet) -> !outlet.directOcean())
|
||||
);
|
||||
RiverCourse course = surfaceCourses(tile).stream()
|
||||
@@ -2300,7 +2300,7 @@ public class HydrologyPlannerTest {
|
||||
RiverOutlet outlet = tile.outlet(course.outletId().orElseThrow()).orElseThrow();
|
||||
assertFalse(
|
||||
"outlet=" + outlet.type() + " source=" + course.sourceNodeId()
|
||||
+ " diagnostics=" + tile.diagnosticCandidates(),
|
||||
+ " diagnostics=" + tile.localDiagnosticCandidates(),
|
||||
outlet.directOcean()
|
||||
);
|
||||
assertTrue(hasAny(tile, HydrologyFeatureType.SINKHOLE, HydrologyFeatureType.INLAND_GROTTO));
|
||||
@@ -2331,7 +2331,7 @@ public class HydrologyPlannerTest {
|
||||
assertTrue(rejected.cavePlans().stream().noneMatch(
|
||||
(HydrologyCavePlan plan) -> plan.actions().containsKey(hazard)
|
||||
));
|
||||
assertTrue(rejected.diagnosticCandidates().stream().anyMatch(
|
||||
assertTrue(rejected.localDiagnosticCandidates().stream().anyMatch(
|
||||
(HydrologyDiagnosticCandidate candidate) ->
|
||||
candidate.rejection() == HydrologyCandidateRejection.CAVE_CONTAINMENT
|
||||
));
|
||||
|
||||
+8
-8
@@ -61,8 +61,8 @@ public class HydrologyPlannerTributaryTest {
|
||||
|
||||
List<RiverCourse> singleCourses = surface(single);
|
||||
List<RiverCourse> splitCourses = surface(split);
|
||||
assertEquals(single.diagnosticCandidates().toString(), 1, singleCourses.size());
|
||||
assertEquals(split.diagnosticCandidates().toString(), 2, splitCourses.size());
|
||||
assertEquals(single.localDiagnosticCandidates().toString(), 1, singleCourses.size());
|
||||
assertEquals(split.localDiagnosticCandidates().toString(), 2, splitCourses.size());
|
||||
RiverCourse stem = null;
|
||||
RiverCourse tributary = null;
|
||||
for (RiverCourse course : splitCourses) {
|
||||
@@ -390,7 +390,7 @@ public class HydrologyPlannerTributaryTest {
|
||||
HydrologyTile split = SPLIT_TILE;
|
||||
|
||||
List<RiverCourse> courses = surface(split);
|
||||
assertEquals(split.diagnosticCandidates().toString(), 2, courses.size());
|
||||
assertEquals(split.localDiagnosticCandidates().toString(), 2, courses.size());
|
||||
RiverCourse stem = stations(courses.get(0)) >= stations(courses.get(1)) ? courses.get(0) : courses.get(1);
|
||||
RiverCourse tributary = stem == courses.get(0) ? courses.get(1) : courses.get(0);
|
||||
java.util.Set<Long> stemEdges = new java.util.HashSet<>();
|
||||
@@ -414,7 +414,7 @@ public class HydrologyPlannerTributaryTest {
|
||||
}
|
||||
assertNotNull("tributary ends on the stem", stemHead);
|
||||
assertEquals(stemHead.intValue(), tributary.segments().getLast().downstreamHeadY());
|
||||
for (HydrologyDiagnosticCandidate candidate : split.diagnosticCandidates()) {
|
||||
for (HydrologyDiagnosticCandidate candidate : split.localDiagnosticCandidates()) {
|
||||
assertTrue(candidate.toString(), candidate.kind() != HydrologyCandidateKind.TRIBUTARY);
|
||||
}
|
||||
}
|
||||
@@ -426,8 +426,8 @@ public class HydrologyPlannerTributaryTest {
|
||||
HydrologyTile single = new HydrologyPlanner(19L, settings(0, 1D, 1), terrain).plan(TILE);
|
||||
HydrologyTile joined = new HydrologyPlanner(19L, settings(1, 1D, 1), terrain).plan(TILE);
|
||||
|
||||
assertEquals(single.diagnosticCandidates().toString(), 1, surface(single).size());
|
||||
assertEquals(joined.diagnosticCandidates().toString(), 2, surface(joined).size());
|
||||
assertEquals(single.localDiagnosticCandidates().toString(), 1, surface(single).size());
|
||||
assertEquals(joined.localDiagnosticCandidates().toString(), 2, surface(joined).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -437,9 +437,9 @@ public class HydrologyPlannerTributaryTest {
|
||||
HydrologyTile single = new HydrologyPlanner(23L, undergroundSettings(0), terrain).plan(TILE);
|
||||
HydrologyTile joined = new HydrologyPlanner(23L, undergroundSettings(1), terrain).plan(TILE);
|
||||
|
||||
assertEquals(single.diagnosticCandidates().toString(), 1, underground(single).size());
|
||||
assertEquals(single.localDiagnosticCandidates().toString(), 1, underground(single).size());
|
||||
List<RiverCourse> courses = underground(joined);
|
||||
assertEquals(joined.diagnosticCandidates().toString(), 2, courses.size());
|
||||
assertEquals(joined.localDiagnosticCandidates().toString(), 2, courses.size());
|
||||
RiverCourse stem = stations(courses.get(0)) >= stations(courses.get(1)) ? courses.get(0) : courses.get(1);
|
||||
RiverCourse tributary = stem == courses.get(0) ? courses.get(1) : courses.get(0);
|
||||
assertEquals(stem.outletId(), tributary.outletId());
|
||||
|
||||
+7
-7
@@ -41,7 +41,7 @@ public class HydrologyPlannerUndergroundKnobsTest {
|
||||
).plan(TILE);
|
||||
List<RiverCourse> planned = courses(fromFactory, RiverCourseType.UNDERGROUND);
|
||||
|
||||
assertFalse("diagnostics=" + fromFactory.diagnosticCandidates(), planned.isEmpty());
|
||||
assertFalse("diagnostics=" + fromFactory.localDiagnosticCandidates(), planned.isEmpty());
|
||||
assertEquals(fromFactory.courses(), explicit.courses());
|
||||
assertCoveredByRock(coast, planned, base.underground().minimumHeadroom(), 1);
|
||||
|
||||
@@ -94,7 +94,7 @@ public class HydrologyPlannerUndergroundKnobsTest {
|
||||
).plan(TILE);
|
||||
|
||||
List<RiverCourse> shallowCourses = courses(shallow, RiverCourseType.UNDERGROUND);
|
||||
assertFalse("diagnostics=" + shallow.diagnosticCandidates(), shallowCourses.isEmpty());
|
||||
assertFalse("diagnostics=" + shallow.localDiagnosticCandidates(), shallowCourses.isEmpty());
|
||||
assertTrue(
|
||||
"heads=" + heads(shallowCourses) + " bound=" + bound,
|
||||
heads(shallowCourses).stream().anyMatch((Integer head) -> head < bound)
|
||||
@@ -166,17 +166,17 @@ public class HydrologyPlannerUndergroundKnobsTest {
|
||||
SOLID_CAVE_VIEW
|
||||
).plan(TILE);
|
||||
|
||||
assertFalse("diagnostics=" + atTheDefault.diagnosticCandidates(), atTheDefault.outlets().isEmpty());
|
||||
assertFalse("diagnostics=" + atTheDefault.localDiagnosticCandidates(), atTheDefault.outlets().isEmpty());
|
||||
for (RiverOutlet outlet : atTheDefault.outlets()) {
|
||||
assertNotEquals(HydrologyFeatureType.COASTAL_GROTTO, outlet.type());
|
||||
}
|
||||
assertFalse("diagnostics=" + sensitive.diagnosticCandidates(), sensitive.outlets().isEmpty());
|
||||
assertFalse("diagnostics=" + sensitive.localDiagnosticCandidates(), sensitive.outlets().isEmpty());
|
||||
boolean grotto = false;
|
||||
for (RiverOutlet outlet : sensitive.outlets()) {
|
||||
grotto |= outlet.type() == HydrologyFeatureType.COASTAL_GROTTO;
|
||||
}
|
||||
assertTrue("outlets=" + sensitive.outlets(), grotto);
|
||||
assertFalse("diagnostics=" + slopeRuleOff.diagnosticCandidates(), slopeRuleOff.outlets().isEmpty());
|
||||
assertFalse("diagnostics=" + slopeRuleOff.localDiagnosticCandidates(), slopeRuleOff.outlets().isEmpty());
|
||||
for (RiverOutlet outlet : slopeRuleOff.outlets()) {
|
||||
assertNotEquals(HydrologyFeatureType.COASTAL_GROTTO, outlet.type());
|
||||
}
|
||||
@@ -205,8 +205,8 @@ public class HydrologyPlannerUndergroundKnobsTest {
|
||||
List<RiverCourse> turnedCaves = courses(turned, RiverCourseType.SEA_CAVE);
|
||||
List<RiverCourse> straightCaves = courses(straight, RiverCourseType.SEA_CAVE);
|
||||
|
||||
assertFalse("diagnostics=" + turned.diagnosticCandidates(), turnedCaves.isEmpty());
|
||||
assertFalse("diagnostics=" + straight.diagnosticCandidates(), straightCaves.isEmpty());
|
||||
assertFalse("diagnostics=" + turned.localDiagnosticCandidates(), turnedCaves.isEmpty());
|
||||
assertFalse("diagnostics=" + straight.localDiagnosticCandidates(), straightCaves.isEmpty());
|
||||
boolean offNormal = false;
|
||||
for (RiverCourse cave : turnedCaves) {
|
||||
HydraulicSegment chamber = cave.segments().getFirst();
|
||||
|
||||
+4
-15
@@ -9,9 +9,7 @@ import art.arcane.iris.generation.hydrology.surface.SurfaceTerminal;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@@ -108,23 +106,14 @@ public class HydrologyRegionalBankSupportTest {
|
||||
return ground;
|
||||
};
|
||||
HydrologyRegionalTerrainRefiner refiner = new HydrologyRegionalTerrainRefiner(new HydrologyPlanner(71L, SETTINGS, terrain));
|
||||
Map<Long, HydrologyTerrainSample> sampled = new HashMap<>();
|
||||
HydrologyRegionalTerrainRefiner.Samples sampled = refiner.new Samples();
|
||||
for (int index = 0; index < 65536; index++) {
|
||||
sampled.put(RiverFootprint.pack(index, 100000), ground);
|
||||
sampled.sample(index, 100000);
|
||||
}
|
||||
Method sample = HydrologyRegionalTerrainRefiner.class.getDeclaredMethod("sample", int.class, int.class, Map.class);
|
||||
sample.setAccessible(true);
|
||||
HydrologyTerrainSampler limited = (x, z) -> {
|
||||
try {
|
||||
return (HydrologyTerrainSample) sample.invoke(refiner, x, z, sampled);
|
||||
} catch (ReflectiveOperationException failure) {
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
};
|
||||
requests.set(0);
|
||||
|
||||
assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(80), limited));
|
||||
assertEquals(65, new HydrologyRegionalHydraulics(SETTINGS).supportedHead(station(80), sampled));
|
||||
assertEquals(0, requests.get());
|
||||
assertEquals(65536, sampled.size());
|
||||
}
|
||||
|
||||
private static HydrologyRegionalHydraulics.HeadStation station(int incomingHead) {
|
||||
|
||||
+28
@@ -14,6 +14,34 @@ import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyRegionalCacheTest {
|
||||
@Test
|
||||
public void sharedTerrainRetentionIsIndependentOfEachSearchBudget() {
|
||||
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 < 100000; x++) {
|
||||
assertNotNull(second.sample(x, 0));
|
||||
}
|
||||
assertEquals(100000, reads.get());
|
||||
for (int x = 0; x < 100000; x++) {
|
||||
assertEquals(70, refiner.sample(x, 0).naturalHeight());
|
||||
}
|
||||
assertEquals(100000, reads.get());
|
||||
refiner.clear();
|
||||
assertEquals(70, refiner.sample(0, 0).naturalHeight());
|
||||
assertEquals(100001, reads.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearingTilesRefreshesChangedRegionalTerrain() throws Exception {
|
||||
AtomicInteger height = new AtomicInteger(70);
|
||||
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyRegionalConcurrencyTest {
|
||||
@Test(timeout = 15000)
|
||||
public void collidingBasinKeysCompileWithoutHoldingCacheLocks() throws Exception {
|
||||
HydrologyTileKey first = new HydrologyTileKey(0, 0);
|
||||
HydrologyTileKey second = new HydrologyTileKey(1, -31);
|
||||
assertEquals(first.hashCode(), second.hashCode());
|
||||
CountDownLatch started = new CountDownLatch(2);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
HydrologyPlanner planner = planner(request -> {
|
||||
started.countDown();
|
||||
await(release);
|
||||
return land(70);
|
||||
});
|
||||
ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<HydrologyRegionalNetwork> firstResult = workers.submit(() -> planner.regional.draft(first));
|
||||
Future<HydrologyRegionalNetwork> secondResult = workers.submit(() -> planner.regional.draft(second));
|
||||
assertTrue("Independent basin compilation must overlap", started.await(5, TimeUnit.SECONDS));
|
||||
release.countDown();
|
||||
assertTrue(firstResult.get(5, TimeUnit.SECONDS).courses().isEmpty());
|
||||
assertTrue(secondResult.get(5, TimeUnit.SECONDS).courses().isEmpty());
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void concurrentRequestsShareOneBasinCompilation() throws Exception {
|
||||
AtomicInteger compilations = new AtomicInteger();
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch callers = new CountDownLatch(2);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
HydrologyPlanner planner = planner(request -> {
|
||||
compilations.incrementAndGet();
|
||||
started.countDown();
|
||||
await(release);
|
||||
return land(70);
|
||||
});
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<HydrologyRegionalNetwork> first = workers.submit(() -> {
|
||||
callers.countDown();
|
||||
return planner.regional.draft(key);
|
||||
});
|
||||
Future<HydrologyRegionalNetwork> second = workers.submit(() -> {
|
||||
callers.countDown();
|
||||
return planner.regional.draft(key);
|
||||
});
|
||||
assertTrue(callers.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
release.countDown();
|
||||
assertSame(first.get(5, TimeUnit.SECONDS), second.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(1, compilations.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
workers.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void clearDuringCompilationDoesNotPublishTheOldDraft() throws Exception {
|
||||
AtomicInteger compilations = new AtomicInteger();
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
HydrologyPlanner planner = planner(request -> {
|
||||
if (compilations.incrementAndGet() == 1) {
|
||||
started.countDown();
|
||||
await(release);
|
||||
return land(70);
|
||||
}
|
||||
return land(85);
|
||||
});
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<HydrologyRegionalNetwork> old = worker.submit(() -> planner.regional.draft(key));
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
planner.regional.clear();
|
||||
HydrologyRegionalNetwork current = planner.regional.draft(key);
|
||||
release.countDown();
|
||||
assertEquals(70, old.get(5, TimeUnit.SECONDS).diagnostics().getFirst().point().y());
|
||||
assertEquals(85, current.diagnostics().getFirst().point().y());
|
||||
assertSame(current, planner.regional.draft(key));
|
||||
assertEquals(2, compilations.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
worker.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedCompilationCanBeRetried() {
|
||||
AtomicInteger compilations = new AtomicInteger();
|
||||
IllegalStateException expected = new IllegalStateException("Terrain is unavailable");
|
||||
HydrologyPlanner planner = planner(request -> {
|
||||
if (compilations.incrementAndGet() == 1) {
|
||||
throw expected;
|
||||
}
|
||||
return land(70);
|
||||
});
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
assertSame(expected, assertThrows(IllegalStateException.class, () -> planner.regional.draft(key)));
|
||||
HydrologyRegionalNetwork current = planner.regional.draft(key);
|
||||
assertSame(current, planner.regional.draft(key));
|
||||
assertEquals(2, compilations.get());
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void aBasinWindowDispatchesIndependentDraftsBeforeWaiting() throws Exception {
|
||||
AtomicInteger compilations = new AtomicInteger();
|
||||
CountDownLatch started = new CountDownLatch(2);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
HydrologyPlanner planner = planner(request -> {
|
||||
compilations.incrementAndGet();
|
||||
started.countDown();
|
||||
await(release);
|
||||
return land(70);
|
||||
});
|
||||
SurfaceBounds bounds = new SurfaceBounds(0, 0, 15, 15);
|
||||
ForkJoinPool pool = new ForkJoinPool(2);
|
||||
try {
|
||||
Future<HydrologyRegionalNetwork> result = pool.submit(() -> planner.regional.coursesIn(bounds));
|
||||
assertTrue("A window must start more than one cold basin", started.await(5, TimeUnit.SECONDS));
|
||||
release.countDown();
|
||||
assertEquals(planner(request -> land(70)).regional.coursesIn(bounds), result.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(4, compilations.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void aBasinWindowCompletesOnASaturatedSingleWorkerPool() throws Exception {
|
||||
HydrologyPlanner planner = planner(request -> land(70));
|
||||
SurfaceBounds bounds = new SurfaceBounds(0, 0, 15, 15);
|
||||
HydrologyRegionalNetwork expected = planner(request -> land(70)).regional.coursesIn(bounds);
|
||||
ForkJoinPool pool = new ForkJoinPool(1, ForkJoinPool.defaultForkJoinWorkerThreadFactory,
|
||||
null, false, 1, 1, 1, null, 60, TimeUnit.SECONDS);
|
||||
try {
|
||||
assertEquals(expected, pool.submit(() -> planner.regional.coursesIn(bounds)).get(5, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
private static HydrologyPlanner planner(
|
||||
Function<HydrologyRoutingTerrainSampler.GridRequest, HydrologyTerrainSample> grids
|
||||
) {
|
||||
HydrologyTerrainSampler terrain = (x, z) -> land(70);
|
||||
return new HydrologyPlanner(71L, HydrologyRegionalPlannerTest.settings(false, 8), terrain,
|
||||
new ControlledRoutingSampler(grids), HydrologyGeometrySampler.deterministic(terrain), 0,
|
||||
footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128));
|
||||
}
|
||||
|
||||
private static HydrologyTerrainSample land(int height) {
|
||||
return HydrologyTerrainSample.openLand(height, 0D, "land");
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
if (!latch.await(5, TimeUnit.SECONDS)) {
|
||||
throw new AssertionError("Timed out waiting for basin compilation");
|
||||
}
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private record ControlledRoutingSampler(
|
||||
Function<GridRequest, HydrologyTerrainSample> grids
|
||||
) implements HydrologyRoutingTerrainSampler {
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()];
|
||||
Arrays.fill(samples, grids.apply(request));
|
||||
return samples;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int blockX, int blockZ) {
|
||||
return NaturalClassification.LAND;
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyRegionalCorridorSamplingTest {
|
||||
@Test
|
||||
public void parallelSamplesPreserveCorridorOrderAcrossSegmentsAndBatches() throws Exception {
|
||||
List<HydrologyPoint> points = List.of(new HydrologyPoint(-137, 70, -67),
|
||||
new HydrologyPoint(-7, 70, 19), new HydrologyPoint(-7, 70, 19),
|
||||
new HydrologyPoint(113, 70, 39), new HydrologyPoint(237, 70, 97));
|
||||
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70 + Math.floorMod(x + z, 9), 0D, "land");
|
||||
HydrologyRegionalRoute.Refinement serial = validate(points, terrain);
|
||||
try (ForkJoinPool pool = new ForkJoinPool(4)) {
|
||||
HydrologyRegionalRoute.Refinement parallel = pool.submit(() -> validate(points, terrain)).get(10, TimeUnit.SECONDS);
|
||||
assertNull(serial.rejection());
|
||||
assertEquals(points, serial.points());
|
||||
assertEquals(serial, parallel);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void independentColumnsRunTogetherWithinOneBoundedBatch() throws Exception {
|
||||
CountDownLatch overlapping = new CountDownLatch(2);
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
HydrologyTerrainSampler terrain = (x, z) -> {
|
||||
samples.incrementAndGet();
|
||||
overlapping.countDown();
|
||||
try {
|
||||
assertTrue(overlapping.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
return x == 0 ? null : HydrologyTerrainSample.openLand(70, 0D, "land");
|
||||
};
|
||||
try (ForkJoinPool pool = new ForkJoinPool(4)) {
|
||||
HydrologyRegionalRoute.Refinement result = pool.submit(() -> validate(straight(), terrain)).get(10, TimeUnit.SECONDS);
|
||||
assertEquals(HydrologyCandidateRejection.POLICY_EXCLUDED, result.rejection());
|
||||
assertEquals(new HydrologyPoint(0, 70, 0), result.failure());
|
||||
assertEquals(128, samples.get());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectionKeepsPrecedenceOverLaterSpeculativeFailure() throws Exception {
|
||||
HydrologyTerrainSampler terrain = (x, z) -> {
|
||||
if (x == 64) {
|
||||
throw new IllegalStateException("Unavailable later sample");
|
||||
}
|
||||
return x == 31 ? null : HydrologyTerrainSample.openLand(70, 0D, "land");
|
||||
};
|
||||
HydrologyRegionalRoute.Refinement serial = validate(straight(), terrain);
|
||||
try (ForkJoinPool pool = new ForkJoinPool(4)) {
|
||||
HydrologyRegionalRoute.Refinement parallel = pool.submit(() -> validate(straight(), terrain)).get(10, TimeUnit.SECONDS);
|
||||
assertEquals(serial, parallel);
|
||||
assertEquals(new HydrologyPoint(31, 70, 0), parallel.failure());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void consumedSampleFailurePropagatesInEncounterOrder() throws Exception {
|
||||
IllegalStateException first = new IllegalStateException("First failed sample");
|
||||
IllegalStateException second = new IllegalStateException("Second failed sample");
|
||||
HydrologyTerrainSampler terrain = (x, z) -> {
|
||||
if (x == 7) {
|
||||
throw first;
|
||||
}
|
||||
if (x == 64) {
|
||||
throw second;
|
||||
}
|
||||
return HydrologyTerrainSample.openLand(70, 0D, "land");
|
||||
};
|
||||
try (ForkJoinPool pool = new ForkJoinPool(4)) {
|
||||
IllegalStateException actual = pool.submit(() -> assertThrows(IllegalStateException.class,
|
||||
() -> validate(straight(), terrain))).get(10, TimeUnit.SECONDS);
|
||||
assertSame(first, actual);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oceanEntryAndRetracingRejectionsMatchSerialValidation() throws Exception {
|
||||
HydrologyTerrainSampler ocean = (x, z) -> x >= 701 ? HydrologyTerrainSample.ocean(60, "ocean")
|
||||
: HydrologyTerrainSample.openLand(70, 0D, "land");
|
||||
List<HydrologyPoint> retracing = List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(193, 70, 0),
|
||||
new HydrologyPoint(0, 70, 0), new HydrologyPoint(900, 70, 0));
|
||||
try (ForkJoinPool pool = new ForkJoinPool(4)) {
|
||||
HydrologyRegionalRoute.Refinement oceanSerial = validate(straight(), ocean);
|
||||
HydrologyRegionalRoute.Refinement oceanParallel = pool.submit(() -> validate(straight(), ocean)).get(10, TimeUnit.SECONDS);
|
||||
assertNull(oceanSerial.rejection());
|
||||
assertEquals(new HydrologyPoint(700, 63, 0), oceanSerial.oceanEntry().landward());
|
||||
assertEquals(oceanSerial, oceanParallel);
|
||||
HydrologyRegionalRoute.Refinement retracingSerial = validate(retracing, ocean);
|
||||
HydrologyRegionalRoute.Refinement retracingParallel = pool.submit(() -> validate(retracing, ocean)).get(10, TimeUnit.SECONDS);
|
||||
assertEquals(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, retracingSerial.rejection());
|
||||
assertEquals(retracingSerial, retracingParallel);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<HydrologyPoint> straight() {
|
||||
return List.of(new HydrologyPoint(0, 70, 0), new HydrologyPoint(900, 70, 0));
|
||||
}
|
||||
|
||||
private static HydrologyRegionalRoute.Refinement validate(List<HydrologyPoint> points,
|
||||
HydrologyTerrainSampler terrain) throws Exception {
|
||||
HydrologyPlanner planner = new HydrologyPlanner(15L, HydrologyRegionalPlannerTest.settings(false, 1), terrain);
|
||||
HydrologyRegionalRoute route = new HydrologyRegionalRoute(planner);
|
||||
Method method = HydrologyRegionalRoute.class.getDeclaredMethod("validateCorridor", List.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (HydrologyRegionalRoute.Refinement) method.invoke(route, points, "default", false);
|
||||
} catch (InvocationTargetException failure) {
|
||||
if (failure.getCause() instanceof RuntimeException runtime) {
|
||||
throw runtime;
|
||||
}
|
||||
if (failure.getCause() instanceof Error error) {
|
||||
throw error;
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
public class HydrologyRegionalDemandTest {
|
||||
private static final HydrologyTileKey ORIGIN = new HydrologyTileKey(0, 0);
|
||||
private static final SurfaceBounds EXCLUDED = new SurfaceBounds(1792, 1792, 1807, 1807);
|
||||
|
||||
@Test(timeout = 120000)
|
||||
public void excludedWindowDefersGeometryUntilDiagnosticsAndPreservesLaterRiver() {
|
||||
HydrologyPlannerSettings settings = localizedSettings();
|
||||
HydrologyTerrainSampler terrain = localizedCoast();
|
||||
AtomicInteger eagerGeometry = new AtomicInteger();
|
||||
HydrologyPlanner eager = planner(settings, terrain, eagerGeometry);
|
||||
HydrologyRegionalNetwork expected = eager.regional.draft(ORIGIN);
|
||||
assertFalse("The excluded basin must contain a successful river", expected.courses().isEmpty());
|
||||
assertTrue(eagerGeometry.get() > 0);
|
||||
AtomicInteger geometry = new AtomicInteger();
|
||||
HydrologyPlanner demand = planner(settings, terrain, geometry);
|
||||
|
||||
assertEquals(HydrologyRegionalNetwork.EMPTY, demand.regional.coursesIn(EXCLUDED));
|
||||
assertEquals("A distant query must not build fine source geometry", 0, geometry.get());
|
||||
assertEquals(expected.diagnostics(), demand.regional.diagnosticsIn(EXCLUDED));
|
||||
assertTrue("Diagnostics must finish the complete source/outlet competition", geometry.get() > 0);
|
||||
assertEquals(expected, demand.regional.draft(ORIGIN));
|
||||
|
||||
SurfaceBounds nearby = window(expected.courses().getFirst().segments().getFirst().start());
|
||||
HydrologyRegionalNetwork expectedWindow = eager.regional.coursesIn(nearby);
|
||||
assertFalse(expectedWindow.courses().isEmpty());
|
||||
HydrologyRegionalNetwork actual = demand.regional.coursesIn(nearby);
|
||||
assertEquals(expectedWindow, actual);
|
||||
assertEquals(eager.regional.materialize(expectedWindow, nearby).columns(),
|
||||
demand.regional.materialize(actual, nearby).columns());
|
||||
|
||||
demand.regional.clear();
|
||||
geometry.set(0);
|
||||
assertEquals(HydrologyRegionalNetwork.EMPTY, demand.regional.coursesIn(EXCLUDED));
|
||||
assertEquals(0, geometry.get());
|
||||
assertEquals(expectedWindow, demand.regional.coursesIn(nearby));
|
||||
}
|
||||
|
||||
@Test(timeout = 120000)
|
||||
public void coldQueriesRetainCurvedRiverExtremesAndTheirFootprints() {
|
||||
HydrologyPlannerSettings settings = localizedSettings();
|
||||
HydrologyTerrainSampler terrain = localizedCoast();
|
||||
HydrologyPlanner eager = planner(settings, terrain, new AtomicInteger());
|
||||
HydrologyRegionalNetwork full = eager.regional.draft(ORIGIN);
|
||||
assertFalse(full.courses().isEmpty());
|
||||
ArrayList<HydrologyPoint> points = new ArrayList<>();
|
||||
for (HydraulicSegment segment : full.courses().getFirst().segments()) {
|
||||
points.addAll(segment.centerline());
|
||||
}
|
||||
HydrologyPoint minimumX = points.getFirst();
|
||||
HydrologyPoint maximumX = minimumX;
|
||||
HydrologyPoint minimumZ = minimumX;
|
||||
HydrologyPoint maximumZ = minimumX;
|
||||
for (HydrologyPoint point : points) {
|
||||
if (point.x() < minimumX.x()) {
|
||||
minimumX = point;
|
||||
}
|
||||
if (point.x() > maximumX.x()) {
|
||||
maximumX = point;
|
||||
}
|
||||
if (point.z() < minimumZ.z()) {
|
||||
minimumZ = point;
|
||||
}
|
||||
if (point.z() > maximumZ.z()) {
|
||||
maximumZ = point;
|
||||
}
|
||||
}
|
||||
assertTrue("The route must contain a lateral bend", maximumZ.z() - minimumZ.z() > 4);
|
||||
for (HydrologyPoint point : List.of(minimumX, maximumX, minimumZ, maximumZ)) {
|
||||
SurfaceBounds bounds = window(point);
|
||||
HydrologyRegionalNetwork expected = eager.regional.coursesIn(bounds);
|
||||
assertFalse(expected.courses().isEmpty());
|
||||
HydrologyPlanner demand = planner(settings, terrain, new AtomicInteger());
|
||||
HydrologyRegionalNetwork actual = demand.regional.coursesIn(bounds);
|
||||
assertEquals(expected, actual);
|
||||
assertEquals(eager.regional.materialize(expected, bounds).columns(),
|
||||
demand.regional.materialize(actual, bounds).columns());
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 120000)
|
||||
public void aLowerRankedNeighborStillRejectsAnOverlappingAcceptedCourse() {
|
||||
HydrologyPlanner planner = planner(localizedSettings(), localizedCoast(), new AtomicInteger());
|
||||
HydrologyRegionalNetwork full = planner.regional.draft(ORIGIN);
|
||||
assertFalse(full.courses().isEmpty());
|
||||
RiverCourse course = full.courses().getFirst();
|
||||
HydrologyTileKey neighbor = new HydrologyTileKey(0, -1);
|
||||
assertTrue(Long.compareUnsigned(HydrologyHash.mix(71L, 0x524547424153494eL, 0, -1),
|
||||
HydrologyHash.mix(71L, 0x524547424153494eL, 0, 0)) < 0);
|
||||
HydrologyRegionalPlanner regional = spy(planner.regional);
|
||||
RiverCourse blocker = copyIdentity(course);
|
||||
assertTrue(regional.conflicts(course, List.of(blocker)));
|
||||
doReturn(new HydrologyRegionalNetwork(List.of(), List.of(), List.of(), List.of(blocker), List.of(), List.of()))
|
||||
.when(regional).draft(neighbor);
|
||||
|
||||
HydrologyRegionalNetwork actual = regional.coursesIn(window(course.segments().getFirst().start()));
|
||||
|
||||
verify(regional).draft(neighbor);
|
||||
assertFalse(actual.courses().stream().anyMatch(candidate -> candidate.id() == course.id()));
|
||||
}
|
||||
|
||||
@Test(timeout = 120000)
|
||||
public void coastalBasinsKeepBothReceivingSeasWhenQueriedAwayFromTheChannel() {
|
||||
HydrologyPlannerSettings settings = HydrologyRegionalPlannerTest.settings(true, 8);
|
||||
HydrologyTerrainSampler terrain = (x, z) -> x < 128 || x >= 1664
|
||||
? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(65, 0D, "land");
|
||||
HydrologyPlanner eager = new HydrologyPlanner(19L, settings, terrain);
|
||||
HydrologyRegionalNetwork expected = eager.regional.draft(ORIGIN);
|
||||
assertEquals(HydrologyFeatureType.MOUTH, expected.courses().getFirst().segments().getFirst().type());
|
||||
assertEquals(2, expected.outlets().size());
|
||||
AtomicInteger geometry = new AtomicInteger();
|
||||
HydrologyGeometrySampler delegate = HydrologyGeometrySampler.deterministic(terrain);
|
||||
HydrologyPlanner demand = new HydrologyPlanner(19L, settings, terrain, request -> {
|
||||
geometry.incrementAndGet();
|
||||
return delegate.sample(request);
|
||||
}, 0, footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128));
|
||||
|
||||
demand.regional.coursesIn(new SurfaceBounds(1536, 1536, 1551, 1551));
|
||||
|
||||
assertTrue("A seeded coastal channel must remain unpruned", geometry.get() > 0);
|
||||
assertEquals(expected, demand.regional.draft(ORIGIN));
|
||||
assertEquals(expected.diagnostics(), demand.regional.diagnosticsIn(EXCLUDED));
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void clearSeparatesConcurrentCoarseAndCompleteDraftPublications() throws Exception {
|
||||
AtomicInteger grids = new AtomicInteger();
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(85, 0D, "land");
|
||||
HydrologyRoutingTerrainSampler routing = new HydrologyRoutingTerrainSampler() {
|
||||
@Override
|
||||
public HydrologyTerrainSample[] sampleGrid(GridRequest request) {
|
||||
int height = grids.incrementAndGet() == 1 ? 70 : 85;
|
||||
if (height == 70) {
|
||||
started.countDown();
|
||||
await(release);
|
||||
}
|
||||
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[request.width() * request.width()];
|
||||
Arrays.fill(samples, HydrologyTerrainSample.openLand(height, 0D, "land"));
|
||||
return samples;
|
||||
}
|
||||
|
||||
@Override
|
||||
public NaturalClassification classifyNatural(int blockX, int blockZ) {
|
||||
return NaturalClassification.LAND;
|
||||
}
|
||||
};
|
||||
HydrologyPlanner planner = new HydrologyPlanner(71L, HydrologyRegionalPlannerTest.settings(false, 8), terrain,
|
||||
routing, HydrologyGeometrySampler.deterministic(terrain), 0,
|
||||
footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128));
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<HydrologyRegionalNetwork> old = worker.submit(() -> planner.regional.coursesIn(new SurfaceBounds(1024, 1024, 1039, 1039)));
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
planner.regional.clear();
|
||||
HydrologyRegionalNetwork current = planner.regional.draft(ORIGIN);
|
||||
assertEquals(85, current.diagnostics().getFirst().point().y());
|
||||
release.countDown();
|
||||
assertEquals(HydrologyRegionalNetwork.EMPTY, old.get(5, TimeUnit.SECONDS));
|
||||
assertSame(current, planner.regional.draft(ORIGIN));
|
||||
assertEquals(current.diagnostics(), planner.regional.diagnosticsIn(new SurfaceBounds(1024, 1024, 1039, 1039)));
|
||||
assertEquals(2, grids.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static RiverCourse copyIdentity(RiverCourse course) {
|
||||
long id = course.id() + 1L;
|
||||
ArrayList<HydraulicSegment> segments = new ArrayList<>();
|
||||
for (HydraulicSegment segment : course.segments()) {
|
||||
segments.add(new HydraulicSegment(segment.id(), id, segment.type(), segment.upstreamHeadY(),
|
||||
segment.downstreamHeadY(), segment.width(), segment.depth(), segment.fallingFluid(),
|
||||
segment.receivingPool(), segment.centerline(), segment.channelProfile()));
|
||||
}
|
||||
return new RiverCourse(id, course.type(), course.sourceNodeId(), OptionalLong.of(course.outletId().orElseThrow() + 1L),
|
||||
course.profileKey(), course.discharge(), course.drainageEdges(), segments);
|
||||
}
|
||||
|
||||
private static SurfaceBounds window(HydrologyPoint point) {
|
||||
return new SurfaceBounds(point.x() - 32, point.z() - 32, point.x() + 32, point.z() + 32);
|
||||
}
|
||||
|
||||
private static HydrologyPlanner planner(HydrologyPlannerSettings settings, HydrologyTerrainSampler terrain, AtomicInteger geometry) {
|
||||
HydrologyGeometrySampler delegate = HydrologyGeometrySampler.deterministic(terrain);
|
||||
return new HydrologyPlanner(71L, settings, terrain, request -> {
|
||||
geometry.incrementAndGet();
|
||||
return delegate.sample(request);
|
||||
}, 0, footprint -> new HydrologyTerrainCaveVoxelView(terrain, 63, 0, 128));
|
||||
}
|
||||
|
||||
private static HydrologyTerrainSampler localizedCoast() {
|
||||
return (x, z) -> x < 128 ? HydrologyTerrainSample.ocean(60, "ocean")
|
||||
: HydrologyTerrainSample.openLand(x >= 768 && x <= 1024 && z >= 0 && z <= 256 ? 70 : 66, 0D, "land");
|
||||
}
|
||||
|
||||
private static HydrologyPlannerSettings localizedSettings() {
|
||||
HydrologyPlannerSettings base = HydrologyRegionalPlannerTest.settings(false, 8);
|
||||
HydrologyPlannerSettings.Surface surface = base.surface();
|
||||
HydrologyPlannerSettings.Source source = new HydrologyPlannerSettings.Source(true, 0D, 70, 0, 0, 128);
|
||||
return new HydrologyPlannerSettings(base.seaLevel(), base.routing(),
|
||||
new HydrologyPlannerSettings.Surface(surface.enabled(), source, surface.minimumWidth(), surface.maximumWidth(),
|
||||
surface.minimumDepth(), surface.maximumDepth(), surface.maximumIncision(), surface.shoreWidth(), surface.banks()),
|
||||
base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(), base.surfacePools(),
|
||||
base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-1
@@ -6,10 +6,13 @@ import art.arcane.iris.generation.hydrology.cave.CaveVoxelView;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
|
||||
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -23,6 +26,52 @@ public final class HydrologyRegionalFallsTest {
|
||||
private static final HydrologyPlannerSettings SETTINGS = HydrologyPlannerSettings.defaults();
|
||||
private static final HydrologyTerrainSampler TERRAIN = (x, z) -> HydrologyTerrainSample.openLand(89, 0D, "parent");
|
||||
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void persistedMixedSurfaceOwnershipRestoresWithoutTerrainPlanning() throws Exception {
|
||||
HydrologyPlanner planner = new HydrologyPlanner(1L, SETTINGS, TERRAIN, solid(null));
|
||||
RiverCourse regional = course();
|
||||
HydrologyRegionalNetwork network = metadata(regional, List.of());
|
||||
List<HydrologyCavePlan> plans = new HydrologyRegionalFalls(planner, regional)
|
||||
.validate(network, new ArrayList<>());
|
||||
network = metadata(regional, plans);
|
||||
SurfaceBounds bounds = new SurfaceBounds(-2, -2, 1, 2);
|
||||
HydrologyFootprintCompiler compiler = new HydrologyFootprintCompiler(SETTINGS, TERRAIN, planner.geometrySampler);
|
||||
compiler.seedRegionalSurface(network, bounds);
|
||||
HydrologyCrossTileResolver.MaterializedHydrology materialized = new HydrologyCrossTileResolver(planner)
|
||||
.materializeFinalHydrology(new HydrologyCaveCourseFilter.Result(network.nodes(), network.edges(),
|
||||
network.outlets(), network.courses(), plans), new ArrayList<>(), compiler, true);
|
||||
HydrologyCaveCourseFilter.Result clipped = HydrologyCrossTileResolver.clipRegionalPlans(materialized.result(), network, bounds);
|
||||
RiverCourse local = new RiverCourse(101L, RiverCourseType.SURFACE, regional.sourceNodeId(), regional.outletId(),
|
||||
"water", 1, List.of(), List.of(new HydraulicSegment(102L, 101L, HydrologyFeatureType.SURFACE_POOL,
|
||||
72, 72, 4, 2, false, false, List.of(new HydrologyPoint(0, 72, 0), new HydrologyPoint(1, 72, 0)),
|
||||
HydraulicChannelProfile.uniform(4, 2))));
|
||||
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(),
|
||||
new HydrologyTileCache.SharedCacheScope("regional-ownership", 1L, 128, "overworld", SETTINGS.fingerprint()),
|
||||
SETTINGS.routing().tileSize());
|
||||
store.save(original);
|
||||
HydrologyTile restored = store.load(original.key()).orElseThrow();
|
||||
assertEquals(original, restored);
|
||||
assertFalse(restored.cavePlans().isEmpty());
|
||||
HydrologyPlanner fresh = new HydrologyPlanner(1L, SETTINGS, (x, z) -> {
|
||||
throw new AssertionError("Restoring final ownership must not sample terrain");
|
||||
});
|
||||
|
||||
fresh.reuseResolvedTile(restored);
|
||||
|
||||
HydrologyCaveCourseFilter.Result owned = fresh.resolvedOwners.getIfPresent(restored.key()).draft().result();
|
||||
assertEquals(List.of(local), owned.courses());
|
||||
assertTrue(owned.cavePlans().isEmpty());
|
||||
assertEquals(restored.nodes(), owned.nodes());
|
||||
assertEquals(restored.edges(), owned.edges());
|
||||
assertEquals(restored.outlets(), owned.outlets());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fullContainmentProofIsCachedAndOnlyFinalTileActionsAreClipped() {
|
||||
AtomicInteger validations = new AtomicInteger();
|
||||
@@ -55,7 +104,7 @@ public final class HydrologyRegionalFallsTest {
|
||||
assertEquals(full.actions().get(entry.getKey()), entry.getValue());
|
||||
}
|
||||
HydrologyTile tile = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, SETTINGS.fingerprint(), 512,
|
||||
clipped.nodes(), clipped.edges(), clipped.outlets(), clipped.courses(), clipped.cavePlans(), List.of(), materialized.footprint());
|
||||
clipped.nodes(), clipped.edges(), clipped.outlets(), clipped.courses(), Set.of(clipped.courses().getFirst().id()), clipped.cavePlans(), List.of(), materialized.footprint());
|
||||
assertEquals(1, tile.courses().size());
|
||||
assertEquals(full, regional.cavePlans().getFirst());
|
||||
}
|
||||
|
||||
+93
-5
@@ -4,19 +4,46 @@ import art.arcane.iris.generation.hydrology.surface.SurfaceBounds;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.HashSet;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyRegionalPlannerTest {
|
||||
@Test(timeout = 120000)
|
||||
public void reusedRegionalTilePreservesAdjacentPlanningWithoutSamplingDuringRestore() {
|
||||
HydrologyPlannerSettings settings = settings(false, 8);
|
||||
HydrologyTerrainSampler terrain = coast();
|
||||
HydrologyPlanner baseline = new HydrologyPlanner(71L, settings, terrain);
|
||||
RiverCourse regional = baseline.regional.draft(new HydrologyTileKey(0, 0)).courses().getFirst();
|
||||
HydrologyTileKey key = crossedTiles(regional, settings.routing().tileSize()).stream()
|
||||
.min(Comparator.comparingInt(HydrologyTileKey::tileX).thenComparingInt(HydrologyTileKey::tileZ))
|
||||
.orElseThrow();
|
||||
HydrologyTile original = baseline.plan(key);
|
||||
assertTrue(original.regionalCourseIds().contains(regional.id()));
|
||||
HydrologyTileKey adjacent = new HydrologyTileKey(key.tileX(), key.tileZ() + 1);
|
||||
HydrologyTile expected = baseline.plan(adjacent);
|
||||
AtomicBoolean planning = new AtomicBoolean();
|
||||
HydrologyPlanner restored = new HydrologyPlanner(71L, settings, (x, z) -> {
|
||||
assertTrue("Restoring final ownership must not sample terrain", planning.get());
|
||||
return terrain.sample(x, z);
|
||||
});
|
||||
|
||||
restored.reuseResolvedTile(original);
|
||||
planning.set(true);
|
||||
|
||||
assertEquals(expected, restored.plan(adjacent));
|
||||
}
|
||||
|
||||
@Test(timeout = 120000)
|
||||
public void oneRegionalCourseCrossesSeveralGenerationTilesWithStableHydraulics() {
|
||||
HydrologyPlanner planner = new HydrologyPlanner(71L, settings(false, 8), coast());
|
||||
@@ -87,29 +114,48 @@ public class HydrologyRegionalPlannerTest {
|
||||
planner.regional.clear();
|
||||
assertEquals(first.diagnostics(), planner.regional.draft(key).diagnostics());
|
||||
HydrologyRegionalNetwork window = planner.regional.coursesIn(new SurfaceBounds(0, 0, 511, 511));
|
||||
assertEquals(first.diagnostics(), window.diagnostics());
|
||||
assertTrue(window.diagnostics().isEmpty());
|
||||
assertEquals(first.diagnostics(), planner.regional.diagnosticsIn(new SurfaceBounds(0, 0, 511, 511)));
|
||||
}
|
||||
|
||||
@Test(timeout = 120000)
|
||||
public void regionalCacheEvictionAndConcurrentRequestsKeepTheSameCourse() throws Exception {
|
||||
HydrologyPlanner planner = new HydrologyPlanner(71L, settings(false, 1), coast());
|
||||
HydrologyPlannerSettings settings = settings(false, 1);
|
||||
HydrologyTerrainSampler terrain = coast();
|
||||
Set<Thread> samplingThreads = ConcurrentHashMap.newKeySet();
|
||||
HydrologyPlanner planner = new HydrologyPlanner(71L, settings, (x, z) -> {
|
||||
samplingThreads.add(Thread.currentThread());
|
||||
return terrain.sample(x, z);
|
||||
});
|
||||
HydrologyTileKey key = new HydrologyTileKey(0, 0);
|
||||
HydrologyRegionalNetwork first = planner.regional.draft(key);
|
||||
assertFalse(first.courses().isEmpty());
|
||||
assertTrue(first.courses().stream().allMatch(course -> course.type() == RiverCourseType.SURFACE));
|
||||
SurfaceBounds bounds = networkBounds(first).expand(settings.publicationRadius());
|
||||
RiverFootprint footprint = planner.regional.materialize(first, bounds);
|
||||
assertFalse(footprint.columns().isEmpty());
|
||||
assertTrue(footprint.columns().values().stream().anyMatch(column -> column.layers().stream().anyMatch(HydrologyColumnLayer::fluidOwned)));
|
||||
planner.regional.draft(new HydrologyTileKey(0, 1));
|
||||
assertEquals(first, planner.regional.draft(key));
|
||||
planner.regional.clear();
|
||||
ExecutorService workers = Executors.newFixedThreadPool(3);
|
||||
samplingThreads.clear();
|
||||
ForkJoinPool workers = new ForkJoinPool(4);
|
||||
try {
|
||||
ArrayList<CompletableFuture<HydrologyRegionalNetwork>> futures = new ArrayList<>();
|
||||
for (int index = 0; index < 3; index++) {
|
||||
futures.add(CompletableFuture.supplyAsync(() -> planner.regional.draft(key), workers));
|
||||
}
|
||||
HydrologyRegionalNetwork parallel = futures.getFirst().get(90, TimeUnit.SECONDS);
|
||||
for (CompletableFuture<HydrologyRegionalNetwork> future : futures) {
|
||||
assertEquals(first, future.get(90, TimeUnit.SECONDS));
|
||||
}
|
||||
assertTrue("A cold successful basin must sample on multiple workers: " + samplingThreads, samplingThreads.size() > 1);
|
||||
assertNetworkRawBits(first, parallel);
|
||||
RiverFootprint actual = workers.submit(() -> planner.regional.materialize(parallel, bounds)).get(30, TimeUnit.SECONDS);
|
||||
assertEquals(footprint.columns(), actual.columns());
|
||||
} finally {
|
||||
workers.shutdownNow();
|
||||
assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +172,48 @@ public class HydrologyRegionalPlannerTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static SurfaceBounds networkBounds(HydrologyRegionalNetwork network) {
|
||||
int minimumX = Integer.MAX_VALUE;
|
||||
int minimumZ = Integer.MAX_VALUE;
|
||||
int maximumX = Integer.MIN_VALUE;
|
||||
int maximumZ = Integer.MIN_VALUE;
|
||||
for (RiverCourse course : network.courses()) {
|
||||
for (HydraulicSegment segment : course.segments()) {
|
||||
for (HydrologyPoint point : segment.centerline()) {
|
||||
minimumX = Math.min(minimumX, point.x());
|
||||
minimumZ = Math.min(minimumZ, point.z());
|
||||
maximumX = Math.max(maximumX, point.x());
|
||||
maximumZ = Math.max(maximumZ, point.z());
|
||||
}
|
||||
}
|
||||
}
|
||||
return new SurfaceBounds(minimumX, minimumZ, maximumX, maximumZ);
|
||||
}
|
||||
|
||||
private static void assertNetworkRawBits(HydrologyRegionalNetwork expected, HydrologyRegionalNetwork actual) {
|
||||
assertEquals(expected, actual);
|
||||
for (int index = 0; index < expected.nodes().size(); index++) {
|
||||
assertEquals(Double.doubleToRawLongBits(expected.nodes().get(index).potential()),
|
||||
Double.doubleToRawLongBits(actual.nodes().get(index).potential()));
|
||||
}
|
||||
for (int index = 0; index < expected.edges().size(); index++) {
|
||||
assertEquals(Double.doubleToRawLongBits(expected.edges().get(index).cost()),
|
||||
Double.doubleToRawLongBits(actual.edges().get(index).cost()));
|
||||
}
|
||||
for (int courseIndex = 0; courseIndex < expected.courses().size(); courseIndex++) {
|
||||
List<HydraulicSegment> expectedSegments = expected.courses().get(courseIndex).segments();
|
||||
List<HydraulicSegment> actualSegments = actual.courses().get(courseIndex).segments();
|
||||
for (int segmentIndex = 0; segmentIndex < expectedSegments.size(); segmentIndex++) {
|
||||
HydraulicChannelProfile first = expectedSegments.get(segmentIndex).channelProfile();
|
||||
HydraulicChannelProfile second = actualSegments.get(segmentIndex).channelProfile();
|
||||
for (int station = 0; station < first.size(); station++) {
|
||||
assertEquals(Double.doubleToRawLongBits(first.widthAt(station)), Double.doubleToRawLongBits(second.widthAt(station)));
|
||||
assertEquals(Double.doubleToRawLongBits(first.depthAt(station)), Double.doubleToRawLongBits(second.depthAt(station)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertGraphEndpoints(HydrologyRegionalNetwork network, RiverCourse course) {
|
||||
DrainageNode source = network.nodes().stream()
|
||||
.filter(node -> node.id() == course.sourceNodeId().orElseThrow()).findFirst().orElseThrow();
|
||||
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.surface.SurfaceBankSupport;
|
||||
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyRegionalSamplingTest {
|
||||
private static final HydrologyPlannerSettings SETTINGS = HydrologyRegionalPlannerTest.settings(false, 1);
|
||||
|
||||
@Test
|
||||
public void bankSamplesPreserveSamplingOrderAndResultsForRotatedWidths() throws Exception {
|
||||
HydrologyTerrainSampler terrain = (x, z) -> x == 17 ? null
|
||||
: HydrologyTerrainSample.openLand(70 + Math.floorMod(x + z, 13), 0D, "land");
|
||||
SurfaceBankSupport support = new SurfaceBankSupport(SETTINGS.surface(), SETTINGS.seaLevel());
|
||||
try (ForkJoinPool pool = new ForkJoinPool(4)) {
|
||||
for (double angle : new double[]{0D, 0.37D, 0.7853981633974483D}) {
|
||||
for (double width : new double[]{2D, 5D, 16D, 48D}) {
|
||||
SurfaceBankSupport.Station station = new SurfaceBankSupport.Station(11, -7,
|
||||
StrictMath.cos(angle), StrictMath.sin(angle), width);
|
||||
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());
|
||||
pool.submit(() -> {
|
||||
assertEquals(cross, support.crossSection(parallel, station, true));
|
||||
assertEquals(perimeter, support.perimeter(parallel, station, 80));
|
||||
}).get(10, TimeUnit.SECONDS);
|
||||
assertEquals(serial.requests, parallel.requests);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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");
|
||||
HydrologyTerrainSampler terrain = (x, z) -> {
|
||||
if (z == 0) {
|
||||
throw failure;
|
||||
}
|
||||
return z == -5 ? null : HydrologyTerrainSample.openLand(70, 0D, "land");
|
||||
};
|
||||
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();
|
||||
try (ForkJoinPool pool = new ForkJoinPool(4)) {
|
||||
assertEquals(expected, pool.submit(() -> support.crossSection(samples, station, true)).get(10, TimeUnit.SECONDS));
|
||||
}
|
||||
assertTrue(expected.blocked());
|
||||
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 void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingSampler implements HydrologyTerrainSampler {
|
||||
private final HydrologyTerrainSampler sampler;
|
||||
private final List<Long> requests = new ArrayList<>();
|
||||
|
||||
private RecordingSampler(HydrologyTerrainSampler sampler) {
|
||||
this.sampler = sampler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HydrologyTerrainSample sample(int x, int z) {
|
||||
requests.add(RiverFootprint.pack(x, z));
|
||||
return sampler.sample(x, z);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
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;
|
||||
|
||||
public class HydrologyRegionalShorelineTest {
|
||||
@Test(timeout = 15000)
|
||||
public void successfulBoundariesMatchSerialCandidatesInGridOrderAcrossWindows() throws Exception {
|
||||
HydrologySampledGrid grid = grid();
|
||||
HydrologyPlanner serial = planner(HydrologyRegionalShorelineTest::terrain);
|
||||
List<OutletCandidate> expected = serial.outletPlanner.oceanOutletCandidates(grid, true);
|
||||
CountDownLatch overlapping = new CountDownLatch(4);
|
||||
Set<Long> threads = ConcurrentHashMap.newKeySet();
|
||||
HydrologyPlanner parallel = planner((x, z) -> {
|
||||
if (x == 1 && z < 64) {
|
||||
threads.add(Thread.currentThread().threadId());
|
||||
overlapping.countDown();
|
||||
await(overlapping);
|
||||
}
|
||||
return terrain(x, z);
|
||||
});
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
List<OutletCandidate> actual = pool.submit(() -> parallel.outletPlanner.regionalOceanOutletCandidates(grid)).get(10, TimeUnit.SECONDS);
|
||||
assertEquals(32, expected.size());
|
||||
assertEquals(expected, actual);
|
||||
assertEquals(4, threads.size());
|
||||
for (int index = 0; index < actual.size(); index++) {
|
||||
assertEquals(index * 32, actual.get(index).landIndex());
|
||||
assertEquals(new HydrologyPoint(9, 63, index * 16), actual.get(index).outlet().landwardPoint());
|
||||
assertEquals(new HydrologyPoint(10, 63, index * 16), actual.get(index).outlet().connectionPoint());
|
||||
}
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void unavailableShorelinesKeepTheSameAcceptedOrder() throws Exception {
|
||||
HydrologyTerrainSampler sampler = (x, z) -> x == 5 && z % 48 == 0 ? null : terrain(x, z);
|
||||
HydrologySampledGrid grid = grid();
|
||||
List<OutletCandidate> expected = planner(sampler).outletPlanner.oceanOutletCandidates(grid, true);
|
||||
HydrologyPlanner parallel = planner(sampler);
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
List<OutletCandidate> actual = pool.submit(() -> parallel.outletPlanner.regionalOceanOutletCandidates(grid)).get(10, TimeUnit.SECONDS);
|
||||
assertEquals(21, expected.size());
|
||||
assertEquals(expected, actual);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void firstFailureDrainsStartedWorkAndDoesNotStartTheNextWindow() throws Exception {
|
||||
CountDownLatch blocked = new CountDownLatch(1);
|
||||
CountDownLatch failed = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicBoolean finished = new AtomicBoolean();
|
||||
AtomicBoolean restored = new AtomicBoolean();
|
||||
AtomicInteger laterWindow = new AtomicInteger();
|
||||
IllegalStateException expected = new IllegalStateException("First shoreline provider failure");
|
||||
IllegalArgumentException laterFailure = new IllegalArgumentException("Later shoreline provider failure");
|
||||
HydrologyPlanner planner = planner((x, z) -> {
|
||||
if (x == 1 && z == 0) {
|
||||
await(blocked);
|
||||
failed.countDown();
|
||||
throw expected;
|
||||
}
|
||||
if (x == 1 && z == 16) {
|
||||
blocked.countDown();
|
||||
await(release);
|
||||
finished.set(true);
|
||||
}
|
||||
if (x == 1 && z == 32) {
|
||||
throw laterFailure;
|
||||
}
|
||||
if (x == 1 && z >= 64) {
|
||||
laterWindow.incrementAndGet();
|
||||
}
|
||||
return terrain(x, z);
|
||||
});
|
||||
HydrologySampledGrid grid = grid();
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
Future<List<OutletCandidate>> pending = pool.submit(() -> {
|
||||
HydrologyPlanner.PlanningSamples previous = new HydrologyPlanner.PlanningSamples();
|
||||
planner.planningSamples.set(previous);
|
||||
try {
|
||||
return planner.outletPlanner.regionalOceanOutletCandidates(grid);
|
||||
} finally {
|
||||
restored.set(planner.planningSamples.get() == previous);
|
||||
planner.planningSamples.remove();
|
||||
}
|
||||
});
|
||||
assertTrue(blocked.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(failed.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone());
|
||||
release.countDown();
|
||||
ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS));
|
||||
Throwable cause = failure.getCause();
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
assertSame(expected, cause);
|
||||
assertEquals(List.of(laterFailure), List.of(expected.getSuppressed()));
|
||||
assertTrue(finished.get());
|
||||
assertTrue(restored.get());
|
||||
assertEquals(0, laterWindow.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static HydrologyPlanner planner(HydrologyTerrainSampler sampler) {
|
||||
return new HydrologyPlanner(71L, HydrologyRegionalPlannerTest.settings(false, 1), sampler);
|
||||
}
|
||||
|
||||
private static HydrologyTerrainSample terrain(int x, int z) {
|
||||
return x >= 10 ? HydrologyTerrainSample.ocean(60, "ocean") : HydrologyTerrainSample.openLand(66, 0D, "land");
|
||||
}
|
||||
|
||||
private static HydrologySampledGrid grid() {
|
||||
ArrayList<HydrologyGridNode> nodes = new ArrayList<>(1024);
|
||||
for (int z = 0; z < 32; z++) {
|
||||
for (int x = 0; x < 32; x++) {
|
||||
int index = z * 32 + x;
|
||||
nodes.add(new HydrologyGridNode(index, x, z, x * 16, z * 16, index + 1L, terrain(x * 16, z * 16)));
|
||||
}
|
||||
}
|
||||
return new HydrologySampledGrid(0, 0, 0, 0, 512, 32, 16, nodes);
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue(latch.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
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.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class HydrologyRegionalSourceParallelismTest {
|
||||
@Test(timeout = 15000)
|
||||
public void acceptsOutletsInSourceOrderWhenTheMiddleOfAWindowSucceeds() throws Exception {
|
||||
Fixture fixture = fixture(4, source -> source == 4 || source == 1 ? accepted(source) : rejected(source));
|
||||
HydrologyRegionalNetwork network = run(fixture);
|
||||
|
||||
assertEquals(List.of(104L, 101L), network.courses().stream().map(RiverCourse::id).toList());
|
||||
assertEquals(List.of(0L), network.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void skipsFailureFromAnOutletAlreadyAcceptedAndDrainsItsStartedWork() throws Exception {
|
||||
CountDownLatch blocked = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicBoolean completed = new AtomicBoolean();
|
||||
Fixture fixture = fixture(4, source -> {
|
||||
if (source == 5) {
|
||||
blocked.countDown();
|
||||
await(release);
|
||||
completed.set(true);
|
||||
throw new IllegalStateException("Unused outlet provider failed");
|
||||
}
|
||||
return source == 4 ? accepted(source) : rejected(source);
|
||||
});
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
Future<HydrologyRegionalNetwork> pending = pool.submit(() -> fixture.planner().regional.draft(new HydrologyTileKey(0, 0)));
|
||||
assertTrue(blocked.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone());
|
||||
release.countDown();
|
||||
HydrologyRegionalNetwork network = pending.get(5, TimeUnit.SECONDS);
|
||||
|
||||
assertTrue(completed.get());
|
||||
assertEquals(List.of(104L), network.courses().stream().map(RiverCourse::id).toList());
|
||||
assertEquals(List.of(0L, 1L, 2L, 3L), network.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).toList());
|
||||
} finally {
|
||||
release.countDown();
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void acceptedOutletSkipsLeaveExactlyTwentyFourActualSourceTrials() throws Exception {
|
||||
AtomicInteger evaluations = new AtomicInteger();
|
||||
Fixture fixture = fixture(32, source -> {
|
||||
evaluations.incrementAndGet();
|
||||
return source == 0 ? accepted(source) : rejected(source);
|
||||
});
|
||||
HydrologyRegionalNetwork network = run(fixture);
|
||||
|
||||
assertEquals(List.of(100L), network.courses().stream().map(RiverCourse::id).toList());
|
||||
assertEquals(23, network.diagnostics().size());
|
||||
assertEquals(23L, network.diagnostics().stream().map(HydrologyDiagnosticCandidate::id).distinct().count());
|
||||
assertTrue(network.diagnostics().stream().allMatch(diagnostic -> diagnostic.id() >= 4L));
|
||||
assertTrue("The fixture must include work skipped after outlet acceptance", evaluations.get() > 24);
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void requiredSourceFailureWaitsForAllStartedTrialProviders() throws Exception {
|
||||
CountDownLatch failed = new CountDownLatch(1);
|
||||
CountDownLatch blocked = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicBoolean completed = new AtomicBoolean();
|
||||
IllegalStateException expected = new IllegalStateException("Required source provider failed");
|
||||
Fixture fixture = fixture(4, source -> {
|
||||
if (source == 1) {
|
||||
failed.countDown();
|
||||
throw expected;
|
||||
}
|
||||
if (source == 5) {
|
||||
blocked.countDown();
|
||||
await(release);
|
||||
completed.set(true);
|
||||
}
|
||||
return rejected(source);
|
||||
});
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
Future<HydrologyRegionalNetwork> pending = pool.submit(() -> fixture.planner().regional.draft(new HydrologyTileKey(0, 0)));
|
||||
assertTrue(failed.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(blocked.await(5, TimeUnit.SECONDS));
|
||||
assertFalse(pending.isDone());
|
||||
release.countDown();
|
||||
ExecutionException failure = assertThrows(ExecutionException.class, () -> pending.get(5, TimeUnit.SECONDS));
|
||||
Throwable cause = failure.getCause();
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
assertSame(expected, cause);
|
||||
assertTrue(completed.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static HydrologyRegionalNetwork run(Fixture fixture) throws Exception {
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
return pool.submit(() -> fixture.planner().regional.draft(new HydrologyTileKey(0, 0))).get(10, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
private static Fixture fixture(int secondCatchmentSize, SourceProvider provider) throws Exception {
|
||||
HydrologyTerrainSampler terrain = (x, z) -> HydrologyTerrainSample.openLand(70, 0D, "land");
|
||||
HydrologyPlannerSettings base = HydrologyRegionalPlannerTest.settings(false, 1);
|
||||
HydrologyPlannerSettings.Routing routing = base.routing();
|
||||
HydrologyPlannerSettings.Regional regional = routing.regional();
|
||||
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(),
|
||||
new HydrologyPlannerSettings.Routing(routing.tileSize(), routing.sampleSpacing(), routing.maximumRouteNodes(),
|
||||
routing.maximumRouteLength(), routing.minimumSurfaceCourseLength(), routing.minimumUndergroundCourseLength(),
|
||||
routing.valleyPreference(), routing.uphillPenalty(), routing.slopePenalty(), routing.confluenceAttraction(),
|
||||
routing.lengthPreference(), routing.tributaries(), new HydrologyPlannerSettings.Regional(true,
|
||||
regional.sampleSpacing(), regional.minimumLength(), 2, regional.maximumCachedBasins(),
|
||||
regional.maximumCachedStations(), false, regional.coastalChannelChance(), regional.maximumCoastalIncision())),
|
||||
base.surface(), base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(),
|
||||
base.surfacePools(), base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
|
||||
HydrologyPlanner planner = new HydrologyPlanner(71L, settings, terrain);
|
||||
ArrayList<OutletCandidate> outlets = new ArrayList<>(List.of(outlet(0), outlet(1)));
|
||||
HydrologyOutletPlanner outletPlanner = mock(HydrologyOutletPlanner.class);
|
||||
when(outletPlanner.regionalOceanOutletCandidates(any())).thenAnswer(invocation -> new ArrayList<>(outlets));
|
||||
replace(planner, "outletPlanner", outletPlanner);
|
||||
Map<Long, Integer> sources = new HashMap<>();
|
||||
HydrologyRegionalGraph graph = mock(HydrologyRegionalGraph.class);
|
||||
when(graph.route(any(), anyList(), anyBoolean())).thenAnswer(invocation -> {
|
||||
HydrologySampledGrid grid = invocation.getArgument(0);
|
||||
HydrologyRegionalGraph.Label[] labels = new HydrologyRegionalGraph.Label[grid.nodes().size()];
|
||||
int[] contributions = new int[grid.nodes().size()];
|
||||
Arrays.fill(contributions, 1);
|
||||
int ordinal = 0;
|
||||
for (HydrologyGridNode node : grid.nodes()) {
|
||||
if (!grid.owns(node.x(), node.z()) || ordinal >= secondCatchmentSize + 4) {
|
||||
continue;
|
||||
}
|
||||
int root = ordinal < 4 ? 0 : 1;
|
||||
int local = ordinal < 4 ? ordinal : ordinal - 4;
|
||||
double length = ordinal < 4 || secondCatchmentSize == 4 ? 10000D - local * 2000D : 10000D - local;
|
||||
HydrologyRegionalGraph.Label terminal = new HydrologyRegionalGraph.Label(
|
||||
new HydrologyRegionalGraph.LabelState(0, 0, 63, 0D, 0D, root), null);
|
||||
labels[node.index()] = new HydrologyRegionalGraph.Label(new HydrologyRegionalGraph.LabelState(
|
||||
node.index(), node.index(), 63, 0D, length, root), terminal);
|
||||
sources.put(RiverFootprint.pack(node.x(), node.z()), ordinal++);
|
||||
}
|
||||
assertEquals(secondCatchmentSize + 4, ordinal);
|
||||
return new HydrologyRegionalGraph.Tree(labels, contributions, outlets);
|
||||
});
|
||||
when(graph.path(any(), anyInt())).thenAnswer(invocation -> List.of(invocation.<Integer>getArgument(1), 0));
|
||||
replace(planner.regional, "graph", graph);
|
||||
HydrologyRegionalRoute routes = mock(HydrologyRegionalRoute.class);
|
||||
when(routes.select(anyList(), anyString(), anyBoolean(), any(), any())).thenAnswer(invocation -> {
|
||||
List<HydrologyPoint> guide = invocation.getArgument(0);
|
||||
HydrologyPoint first = guide.getFirst();
|
||||
return provider.build(sources.get(RiverFootprint.pack(first.x(), first.z())));
|
||||
});
|
||||
replace(planner.regional, "routes", routes);
|
||||
return new Fixture(planner);
|
||||
}
|
||||
|
||||
private static OutletCandidate outlet(int root) {
|
||||
HydrologyPoint point = new HydrologyPoint(2048, 63, root * 1024);
|
||||
return new OutletCandidate(0, 0, new RiverOutlet(1000L + root, HydrologyFeatureType.MOUTH,
|
||||
root, point, new HydrologyPoint(point.x() + 1, point.y(), point.z()), 63, true));
|
||||
}
|
||||
|
||||
private static HydrologyRegionalRoute.Attempt accepted(int source) {
|
||||
int z = source * 4096;
|
||||
HydrologyPoint first = new HydrologyPoint(0, 70, z);
|
||||
HydrologyPoint last = new HydrologyPoint(512, 70, z);
|
||||
HydraulicSegment segment = new HydraulicSegment(100L + source, 100L + source, HydrologyFeatureType.SURFACE_POOL,
|
||||
70, 70, 4, 2, false, false, List.of(first, last),
|
||||
new HydraulicChannelProfile(new double[]{4D}, new double[]{2D}));
|
||||
RiverCourse course = new RiverCourse(100L + source, RiverCourseType.SURFACE, OptionalLong.of(10000L + source),
|
||||
OptionalLong.of(1000L + (source < 4 ? 0 : 1)), "default", 1, List.of(), List.of(segment));
|
||||
HydrologyRegionalNetwork network = new HydrologyRegionalNetwork(List.of(), List.of(), List.of(),
|
||||
List.of(course), List.of(), List.of());
|
||||
return new HydrologyRegionalRoute.Attempt(new HydrologyRegionalRoute.Refinement(List.of(first, last), null, null, 0, null), network, null);
|
||||
}
|
||||
|
||||
private static HydrologyRegionalRoute.Attempt rejected(int source) {
|
||||
HydrologyPoint point = new HydrologyPoint(source, 70, 0);
|
||||
HydrologyDiagnosticCandidate diagnostic = new HydrologyDiagnosticCandidate(source, HydrologyCandidateKind.REGIONAL_SOURCE,
|
||||
HydrologyFeatureType.SURFACE_POOL, point, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, source);
|
||||
return new HydrologyRegionalRoute.Attempt(new HydrologyRegionalRoute.Refinement(List.of(point), null, null, 0, null),
|
||||
HydrologyRegionalNetwork.EMPTY, diagnostic);
|
||||
}
|
||||
|
||||
private static void replace(Object target, String name, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue("Timed out waiting for trial release", latch.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
private interface SourceProvider {
|
||||
HydrologyRegionalRoute.Attempt build(int source);
|
||||
}
|
||||
|
||||
private record Fixture(HydrologyPlanner planner) {
|
||||
}
|
||||
}
|
||||
+68
@@ -8,6 +8,11 @@ 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;
|
||||
|
||||
@@ -17,6 +22,69 @@ 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 = {
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
@@ -43,7 +44,7 @@ public class HydrologyRegionalTributariesTest {
|
||||
HydrologyCaveCourseFilter.Result combined = planner.regional.include(new HydrologyCaveCourseFilter.Result(
|
||||
connected.nodes(), connected.edges(), connected.outlets(), courses, List.of()), fixture.regional());
|
||||
HydrologyTile tile = new HydrologyTile(new HydrologyTileKey(0, 0), 19L, 0L, 512,
|
||||
combined.nodes(), combined.edges(), combined.outlets(), combined.courses(), List.of(), diagnostics,
|
||||
combined.nodes(), combined.edges(), combined.outlets(), combined.courses(), Set.of(fixture.regional().courses().getFirst().id()), List.of(), diagnostics,
|
||||
RiverFootprint.empty());
|
||||
assertTrue(tile.acyclic());
|
||||
DrainageNode source = tile.node(tributary.sourceNodeId().orElseThrow()).orElseThrow();
|
||||
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class HydrologySourceSpacingTest {
|
||||
private static final HydrologyTileKey TILE = new HydrologyTileKey(0, 0);
|
||||
private static final int WIDTH = 11;
|
||||
private static final int SPACING = 64;
|
||||
/** One lattice step (64, or 91 diagonally) is inside the spacing; two steps (128) are outside it. */
|
||||
private static final int MINIMUM_SPACING = 100;
|
||||
private static final int HIGH = 200;
|
||||
private static final int ROUTE = 512;
|
||||
private static final int LONG_ROUTE = 4096;
|
||||
|
||||
@Test
|
||||
public void aRejectedSourceNoLongerCostsItsLowerRankedNeighbourTheSlot() {
|
||||
// A > B > C by weight, A one step from B, B one step from C, A two steps from C.
|
||||
Field withMiddle = field(Map.of(pack(3, 4), 3D, pack(4, 4), 2D, pack(5, 4), 1D));
|
||||
assertTrue(withMiddle.admits(3, 4));
|
||||
assertFalse(withMiddle.admits(4, 4));
|
||||
assertTrue(withMiddle.admits(5, 4));
|
||||
|
||||
Field withoutMiddle = field(Map.of(pack(3, 4), 3D, pack(5, 4), 1D));
|
||||
assertEquals(withoutMiddle.accepted(), withMiddle.accepted());
|
||||
}
|
||||
|
||||
/**
|
||||
* Route length and potential belong to the routing plan of the tile that owns a node, so a contest scored with
|
||||
* them ranks neighbours either side of a tile seam against different outlet sets and hands the slot to whoever
|
||||
* owns the node. The contest is terrain only, and must not move when the plan under it does.
|
||||
*/
|
||||
@Test
|
||||
public void theSpacingContestIgnoresTheOwnerTileRoutingPlan() {
|
||||
int[] pair = hashInvertedPair(planner());
|
||||
long left = pack(pair[0], pair[1]);
|
||||
long right = pack(pair[0] + 1, pair[1]);
|
||||
Map<Long, Double> weights = Map.of(left, 1D, right, 1D);
|
||||
|
||||
Set<Long> terrainOnly = field(weights).accepted();
|
||||
assertEquals(Set.of(left), terrainOnly);
|
||||
assertEquals(terrainOnly, field(weights, Map.of(right, LONG_ROUTE)).accepted());
|
||||
assertEquals(terrainOnly, field(weights, Map.of(left, LONG_ROUTE)).accepted());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void greedyAdmissionIsDeterministicSeparatedAndMaximal() {
|
||||
Set<Long> first = denseField().accepted();
|
||||
Set<Long> second = denseField().accepted();
|
||||
assertEquals(first, second);
|
||||
assertFalse(first.isEmpty());
|
||||
|
||||
for (long accepted : first) {
|
||||
for (long other : first) {
|
||||
if (accepted != other) {
|
||||
assertTrue("accepted sources " + accepted + " and " + other + " sit inside the spacing",
|
||||
separation(accepted, other) >= MINIMUM_SPACING);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) {
|
||||
for (int gridX = 1; gridX < WIDTH - 1; gridX++) {
|
||||
long node = pack(gridX, gridZ);
|
||||
if (first.contains(node)) {
|
||||
continue;
|
||||
}
|
||||
boolean blocked = false;
|
||||
for (long accepted : first) {
|
||||
blocked |= separation(node, accepted) < MINIMUM_SPACING;
|
||||
}
|
||||
assertTrue("rejected source " + node + " is separated from every accepted source", blocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Field denseField() {
|
||||
HashMap<Long, Double> weights = new HashMap<>();
|
||||
for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) {
|
||||
for (int gridX = 1; gridX < WIDTH - 1; gridX++) {
|
||||
weights.put(pack(gridX, gridZ), 1D + (gridX * 7 + gridZ * 13) % 5);
|
||||
}
|
||||
}
|
||||
return field(weights);
|
||||
}
|
||||
|
||||
/** The first adjacent pair whose raw source hashes rank the left node above the right one. */
|
||||
private static int[] hashInvertedPair(HydrologyPlanner planner) {
|
||||
for (int gridZ = 1; gridZ < WIDTH - 1; gridZ++) {
|
||||
for (int gridX = 1; gridX < WIDTH - 2; gridX++) {
|
||||
long left = planner.sourcePlanner.sourceStableId(HydrologySourcePlanner.SURFACE_SOURCE_SALT,
|
||||
gridX * SPACING, gridZ * SPACING);
|
||||
long right = planner.sourcePlanner.sourceStableId(HydrologySourcePlanner.SURFACE_SOURCE_SALT,
|
||||
(gridX + 1) * SPACING, gridZ * SPACING);
|
||||
if (Long.compareUnsigned(left, right) > 0) {
|
||||
return new int[]{gridX, gridZ};
|
||||
}
|
||||
}
|
||||
}
|
||||
fail("No hash-inverted adjacent source pair exists on this lattice");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static double separation(long first, long second) {
|
||||
return StrictMath.hypot((double) (unpackX(first) - unpackX(second)) * SPACING,
|
||||
(double) (unpackZ(first) - unpackZ(second)) * SPACING);
|
||||
}
|
||||
|
||||
private static Field field(Map<Long, Double> weights) {
|
||||
return field(weights, Map.of());
|
||||
}
|
||||
|
||||
private static Field field(Map<Long, Double> weights, Map<Long, Integer> routeLengths) {
|
||||
HydrologyPlanner planner = planner();
|
||||
HydrologySampledGrid grid = grid(weights);
|
||||
HydrologyRoutingPlan routing = routing(grid, routeLengths);
|
||||
HashMap<HydrologyTileKey, SourceRoutingContext> contexts = new HashMap<>();
|
||||
contexts.put(TILE, new SourceRoutingContext(grid, routing, routing, List.of()));
|
||||
return new Field(planner, grid, weights.keySet(), contexts, new HashMap<>());
|
||||
}
|
||||
|
||||
private static HydrologyPlanner planner() {
|
||||
HydrologyPlannerSettings base = HydrologyPlannerSettings.defaults();
|
||||
HydrologyPlannerSettings.Surface surface = new HydrologyPlannerSettings.Surface(true,
|
||||
new HydrologyPlannerSettings.Source(true, 0.5D, 88, 0, 1, MINIMUM_SPACING),
|
||||
4, 8, 2, 4, 10, 1.5D, HydrologyPlannerSettings.Banks.defaults());
|
||||
HydrologyPlannerSettings settings = new HydrologyPlannerSettings(base.seaLevel(), base.routing(), surface,
|
||||
base.hydraulics(), base.underground(), base.outlets(), base.geometry(), base.deepFluids(),
|
||||
base.surfacePools(), base.widestShoreBiomeWidth(), base.seaCaves(), base.surfacePolicyBounds());
|
||||
return new HydrologyPlanner(1337L, settings, (x, z) -> land(HIGH, 0D));
|
||||
}
|
||||
|
||||
private static HydrologySampledGrid grid(Map<Long, Double> weights) {
|
||||
ArrayList<HydrologyGridNode> nodes = new ArrayList<>(WIDTH * WIDTH);
|
||||
for (int gridZ = 0; gridZ < WIDTH; gridZ++) {
|
||||
for (int gridX = 0; gridX < WIDTH; gridX++) {
|
||||
int index = gridZ * WIDTH + gridX;
|
||||
long node = pack(gridX, gridZ);
|
||||
nodes.add(new HydrologyGridNode(index, gridX, gridZ, gridX * SPACING, gridZ * SPACING, index + 1L,
|
||||
land(HIGH, weights.getOrDefault(node, 0D))));
|
||||
}
|
||||
}
|
||||
return new HydrologySampledGrid(0, 0, 0, 0, WIDTH * SPACING, WIDTH, SPACING, List.copyOf(nodes));
|
||||
}
|
||||
|
||||
private static HydrologyRoutingPlan routing(HydrologySampledGrid grid, Map<Long, Integer> routeLengths) {
|
||||
int count = grid.nodes().size();
|
||||
double[] potential = new double[count];
|
||||
int[] parent = new int[count];
|
||||
int[] outlets = new int[count];
|
||||
int[] lengths = new int[count];
|
||||
Arrays.fill(potential, 100D);
|
||||
Arrays.fill(parent, 0);
|
||||
ArrayList<OutletCandidate> candidates = new ArrayList<>(count);
|
||||
for (HydrologyGridNode node : grid.nodes()) {
|
||||
outlets[node.index()] = node.index();
|
||||
lengths[node.index()] = routeLengths.getOrDefault(pack(node.gridX(), node.gridZ()), ROUTE);
|
||||
HydrologyPoint point = new HydrologyPoint(node.x(), 63, node.z());
|
||||
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);
|
||||
}
|
||||
|
||||
private static HydrologyTerrainSample land(int naturalHeight, double weight) {
|
||||
return new HydrologyTerrainSample(naturalHeight, 0D, false, false, naturalHeight - 32, naturalHeight - 30,
|
||||
true, true, true, false, false, false,
|
||||
0D, weight, 0D, 1D, 1D, 1D, 1D, 1D, "land", "land", "land", "land", "land", "land",
|
||||
List.of("default"), List.of(), Double.NaN, null, Double.NaN, true, SurfaceRiverPolicy.INHERIT);
|
||||
}
|
||||
|
||||
private static long pack(int gridX, int gridZ) {
|
||||
return RiverFootprint.pack(gridX, gridZ);
|
||||
}
|
||||
|
||||
private static int unpackX(long packed) {
|
||||
return RiverFootprint.unpackX(packed);
|
||||
}
|
||||
|
||||
private static int unpackZ(long packed) {
|
||||
return RiverFootprint.unpackZ(packed);
|
||||
}
|
||||
|
||||
private record Field(
|
||||
HydrologyPlanner planner,
|
||||
HydrologySampledGrid grid,
|
||||
Set<Long> eligible,
|
||||
Map<HydrologyTileKey, SourceRoutingContext> contexts,
|
||||
Map<Long, Boolean> admissions
|
||||
) {
|
||||
boolean admits(int gridX, int gridZ) {
|
||||
return planner.sourcePlanner.globallyAdmittedSource(
|
||||
grid.nodeAt(gridX, gridZ),
|
||||
planner.settings.surface().sources(),
|
||||
HydrologySourcePlanner.SURFACE_SOURCE_SALT,
|
||||
true,
|
||||
contexts,
|
||||
admissions
|
||||
);
|
||||
}
|
||||
|
||||
Set<Long> accepted() {
|
||||
LinkedHashSet<Long> selected = new LinkedHashSet<>();
|
||||
for (long node : new TreeSet<>(eligible)) {
|
||||
if (admits(unpackX(node), unpackZ(node))) {
|
||||
selected.add(node);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.NavigableSet;
|
||||
import java.util.TreeSet;
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
/** 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());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -19,6 +19,7 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -345,7 +346,7 @@ public final class HydrologySurfaceDropRasterTest {
|
||||
columns.put(RiverFootprint.pack(sample.x(), sample.z()), sample);
|
||||
}
|
||||
HydrologyTile tile = new HydrologyTile(new HydrologyTileKey(0, 0), 1L, 1L, 128,
|
||||
accepted.nodes(), accepted.edges(), accepted.outlets(), accepted.courses(), accepted.cavePlans(), List.of(), new RiverFootprint(columns));
|
||||
accepted.nodes(), accepted.edges(), accepted.outlets(), accepted.courses(), Set.of(), accepted.cavePlans(), List.of(), new RiverFootprint(columns));
|
||||
assertEquals(1, tile.courses().size());
|
||||
for (boolean connectToExistingCaves : new boolean[] {false, true}) {
|
||||
diagnostics.clear();
|
||||
|
||||
+36
-1
@@ -388,6 +388,13 @@ public class HydrologyTileCacheTest {
|
||||
when(tile.tileSize()).thenReturn(64);
|
||||
HydrologyTileCache first = new HydrologyTileCache(firstPlanner, 4, null, null, scope);
|
||||
HydrologyTileCache second = new HydrologyTileCache(secondPlanner, 4, null, null, scope);
|
||||
AtomicInteger terrainPreparations = new AtomicInteger();
|
||||
first.setTerrainPreparation(terrainPreparations::incrementAndGet);
|
||||
second.setTerrainPreparation(() -> {
|
||||
throw new AssertionError("Shared tile reuse must not prepare terrain caches");
|
||||
});
|
||||
|
||||
assertEquals(0, terrainPreparations.get());
|
||||
|
||||
assertSame(tile, first.get(key));
|
||||
assertSame(tile, second.get(key));
|
||||
@@ -395,6 +402,7 @@ public class HydrologyTileCacheTest {
|
||||
verify(firstPlanner, times(1)).plan(key);
|
||||
verify(secondPlanner, never()).plan(key);
|
||||
verify(secondPlanner, times(1)).reuseResolvedTile(tile);
|
||||
assertEquals(1, terrainPreparations.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -801,7 +809,7 @@ public class HydrologyTileCacheTest {
|
||||
public void planningFailureFallsBackToTheEmptyTileAndIsCached() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTileKey key = new HydrologyTileKey(3, -2);
|
||||
HydrologyTile empty = new HydrologyTile(key, 7L, 11L, 1024, List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), RiverFootprint.empty());
|
||||
HydrologyTile empty = new HydrologyTile(key, 7L, 11L, 1024, List.of(), List.of(), List.of(), List.of(), Set.of(), List.of(), List.of(), RiverFootprint.empty());
|
||||
when(planner.settings()).thenReturn(emptySettings());
|
||||
when(planner.plan(key)).thenThrow(new IllegalStateException("Hydrology natural height was not finite at -66,-641"));
|
||||
when(planner.emptyTile(key)).thenReturn(empty);
|
||||
@@ -870,6 +878,33 @@ public class HydrologyTileCacheTest {
|
||||
assertEquals(width * height, cache.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deferredNeighboursAllowFurtherDemandAndResumeAfterEntry() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
HydrologyTile tile = mock(HydrologyTile.class);
|
||||
HydrologyPlannerSettings settings = emptySettings();
|
||||
when(planner.settings()).thenReturn(settings);
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(tile);
|
||||
when(tile.columnAt(anyInt(), anyInt())).thenReturn(Optional.empty());
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 64, Runnable::run);
|
||||
cache.setNeighbourPrefetchEnabled(false);
|
||||
int tileSize = settings.routing().tileSize();
|
||||
|
||||
cache.prepareChunkColumns(0, 0);
|
||||
int requiredTiles = cache.size();
|
||||
HydrologyTileKey distant = new HydrologyTileKey(20, 20);
|
||||
assertSame(tile, cache.get(distant));
|
||||
assertEquals(requiredTiles + 1, cache.size());
|
||||
verify(planner, never()).plan(new HydrologyTileKey(-2, -2));
|
||||
|
||||
cache.setNeighbourPrefetchEnabled(true);
|
||||
cache.prepareChunkColumns(tileSize * 20 + 32, tileSize * 20 + 48);
|
||||
|
||||
verify(planner).plan(new HydrologyTileKey(19, 19));
|
||||
assertTrue(cache.size() > requiredTiles + 1);
|
||||
cache.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pregenPreparationStartsAtTheNearestUncachedForwardTile() {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
|
||||
+30
-1
@@ -1,19 +1,27 @@
|
||||
package art.arcane.iris.generation.hydrology;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.policy.SurfaceRiverPolicy;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Map;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class StudioHydrologyTileStoreTest {
|
||||
@@ -35,9 +43,29 @@ public class StudioHydrologyTileStoreTest {
|
||||
assertEquals(original.edges(), restored.edges());
|
||||
assertEquals(original.outlets(), restored.outlets());
|
||||
assertEquals(original.courses(), restored.courses());
|
||||
assertEquals(original.regionalCourseIds(), restored.regionalCourseIds());
|
||||
assertEquals(original.cavePlans(), restored.cavePlans());
|
||||
assertEquals(original.diagnosticCandidates(), restored.diagnosticCandidates());
|
||||
assertEquals(original.localDiagnosticCandidates(), restored.localDiagnosticCandidates());
|
||||
assertEquals(original.footprint(), restored.footprint());
|
||||
assertThrows(UnsupportedOperationException.class, () -> restored.regionalCourseIds().clear());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void entryRequiresRegionalOwnershipMetadata() throws Exception {
|
||||
HydrologyTile original = tile();
|
||||
StudioHydrologyTileStore store = store();
|
||||
store.save(original);
|
||||
Path file = store.file(original.key());
|
||||
JsonObject persisted;
|
||||
try (InputStream input = new GZIPInputStream(Files.newInputStream(file))) {
|
||||
persisted = JsonParser.parseString(new String(input.readAllBytes(), StandardCharsets.UTF_8)).getAsJsonObject();
|
||||
}
|
||||
persisted.remove("regionalCourseIds");
|
||||
try (OutputStream output = new GZIPOutputStream(Files.newOutputStream(file))) {
|
||||
output.write(persisted.toString().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
assertTrue(store.load(original.key()).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -168,6 +196,7 @@ public class StudioHydrologyTileStoreTest {
|
||||
List.of(),
|
||||
List.of(outlet),
|
||||
List.of(course),
|
||||
Set.of(course.id()),
|
||||
List.of(),
|
||||
List.of(),
|
||||
new RiverFootprint(Map.of(RiverFootprint.pack(0, 0), column))
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ public class IrisHydrologyPhysicalOceanTest {
|
||||
shore.setLoadKey("shore");
|
||||
return new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(17L, 768, dimension, data,
|
||||
(x, z, height) -> new IrisHydrologyNaturalSample(height, oceanIntent.isOcean(x, z), shore, region),
|
||||
heights, (x, z) -> "coast", oceanIntent, footprint -> null, () -> false));
|
||||
heights, (x, z) -> "coast", oceanIntent, footprint -> null, () -> false, () -> {}));
|
||||
}
|
||||
|
||||
private static IrisHydrologyRoutingTerrainSampler sampler(IrisHydrologyRuntime runtime) throws Exception {
|
||||
|
||||
+1
-1
@@ -229,7 +229,7 @@ public class IrisHydrologyPolicyCacheTest {
|
||||
private IrisHydrologyRuntime runtime() {
|
||||
return new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(17L, 768, dimension, data,
|
||||
(x, z, height) -> new IrisHydrologyNaturalSample(height, false, biome.get(), region.get()),
|
||||
(x, z) -> 180D, (x, z) -> "constant", (x, z) -> false, footprint -> null, () -> false));
|
||||
(x, z) -> 180D, (x, z) -> "constant", (x, z) -> false, footprint -> null, () -> false, () -> {}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package art.arcane.iris.generation.hydrology.runtime;
|
||||
|
||||
import art.arcane.iris.generation.hydrology.HydrologyRoutingTerrainSampler.NaturalClassification;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTerrainSample;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class IrisHydrologyRoutingCacheConcurrencyTest {
|
||||
@Test(timeout = 15000)
|
||||
public void publishedBasisSupersedesAnEarlierClassification() throws Exception {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(x, z, height) -> new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height,
|
||||
HydrologyTerrainSample.ocean(60, "ocean")),
|
||||
(x, z) -> 60D,
|
||||
(x, z) -> {
|
||||
started.countDown();
|
||||
await(release);
|
||||
return false;
|
||||
}, 63),
|
||||
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(65536));
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<NaturalClassification> classification = worker.submit(() -> sampler.classifyNatural(-8193, 257));
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
assertTrue(sampler.sampleBasisWithoutSlope(-8193, 257).ocean());
|
||||
release.countDown();
|
||||
assertEquals(NaturalClassification.OCEAN, classification.get(5, TimeUnit.SECONDS));
|
||||
assertEquals(NaturalClassification.OCEAN, sampler.classifyNatural(-8193, 257));
|
||||
assertEquals(0, sampler.oceanClassificationCacheSize());
|
||||
} finally {
|
||||
release.countDown();
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void concurrentSameCoordinateReturnsTheFirstPublishedBasis() throws Exception {
|
||||
CountDownLatch started = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(x, z, height) -> {
|
||||
if (calls.incrementAndGet() == 1) {
|
||||
started.countDown();
|
||||
await(release);
|
||||
}
|
||||
return new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height,
|
||||
HydrologyTerrainSample.openLand(70, 0D, "land"));
|
||||
}, (x, z) -> 70D, (x, z) -> false, 63),
|
||||
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(65536));
|
||||
ExecutorService worker = Executors.newSingleThreadExecutor();
|
||||
try {
|
||||
Future<IrisHydrologyRoutingTerrainSampler.TerrainBasis> first = worker.submit(() -> sampler.basis(4096, -37));
|
||||
assertTrue(started.await(5, TimeUnit.SECONDS));
|
||||
IrisHydrologyRoutingTerrainSampler.TerrainBasis published = sampler.basis(4096, -37);
|
||||
release.countDown();
|
||||
assertSame(published, first.get(5, TimeUnit.SECONDS));
|
||||
assertSame(published, sampler.basis(4096, -37));
|
||||
assertEquals(2, calls.get());
|
||||
} finally {
|
||||
release.countDown();
|
||||
worker.shutdownNow();
|
||||
assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unevenStripeCapacityStaysBoundedAndCloseClearsEveryCache() {
|
||||
int capacity = 4097;
|
||||
AtomicInteger heightCalls = new AtomicInteger();
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(x, z, height) -> new IrisHydrologyRoutingTerrainSampler.TerrainBasis(height,
|
||||
HydrologyTerrainSample.openLand((int) height, 0D, "land")),
|
||||
(x, z) -> {
|
||||
heightCalls.incrementAndGet();
|
||||
return 70D + Math.floorMod(x + z, 11);
|
||||
}, (x, z) -> false, 63),
|
||||
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(capacity));
|
||||
for (int coordinate = -10000; coordinate < 10000; coordinate++) {
|
||||
sampler.sampleBasisWithoutSlope(coordinate, coordinate * 3);
|
||||
sampler.classifyNatural(coordinate + 100000, -coordinate);
|
||||
}
|
||||
assertTrue(sampler.basisCacheSize() <= capacity);
|
||||
assertTrue(sampler.naturalHeightCacheSize() <= capacity);
|
||||
assertTrue(sampler.oceanClassificationCacheSize() <= capacity);
|
||||
assertTrue(sampler.basisCacheSize() > capacity / 2);
|
||||
assertTrue(sampler.oceanClassificationCacheSize() > capacity / 2);
|
||||
HydrologyTerrainSample before = sampler.sampleBasisWithoutSlope(7, 21);
|
||||
int previousCalls = heightCalls.get();
|
||||
sampler.close();
|
||||
assertEquals(0, sampler.basisCacheSize());
|
||||
assertEquals(0, sampler.naturalHeightCacheSize());
|
||||
assertEquals(0, sampler.oceanClassificationCacheSize());
|
||||
assertEquals(before, sampler.sampleBasisWithoutSlope(7, 21));
|
||||
assertEquals(previousCalls + 1, heightCalls.get());
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue("Timed out waiting for provider release", latch.await(5, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new AssertionError(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -268,6 +268,43 @@ public class IrisHydrologyRoutingTerrainSamplerTest {
|
||||
assertEquals(1, calls.get(pack(x + 3, z)).intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void slopeReusesBasisHeightAfterNaturalHeightEvictionUntilClose() {
|
||||
Map<Long, Integer> heightCalls = new HashMap<>();
|
||||
IrisHydrologyRoutingTerrainSampler sampler = new IrisHydrologyRoutingTerrainSampler(
|
||||
new IrisHydrologyRoutingTerrainSampler.Sources(
|
||||
(int x, int z, double naturalHeight) -> basis(x, z, naturalHeight),
|
||||
(int x, int z) -> {
|
||||
heightCalls.merge(pack(x, z), 1, Integer::sum);
|
||||
return height(x, z);
|
||||
},
|
||||
(int x, int z) -> false,
|
||||
128
|
||||
),
|
||||
IrisHydrologyRoutingTerrainSampler.SamplingOptions.serial(2)
|
||||
);
|
||||
|
||||
sampler.sampleBasisWithoutSlope(0, 0);
|
||||
sampler.localSlope(16, 16, height(16, 16));
|
||||
assertEquals(3, heightCalls.size());
|
||||
assertEquals(2, sampler.naturalHeightCacheSize());
|
||||
assertEquals(1, sampler.basisCacheSize());
|
||||
|
||||
double expected = IrisHydrologyRoutingTerrainSampler.localSlope(
|
||||
height(-3, 0), height(0, 0), height(-3, 3));
|
||||
double actual = sampler.localSlope(-3, 0, height(-3, 0));
|
||||
|
||||
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(actual));
|
||||
assertEquals(1, heightCalls.get(pack(0, 0)).intValue());
|
||||
assertEquals(4, heightCalls.size());
|
||||
|
||||
sampler.close();
|
||||
double afterClose = sampler.localSlope(-3, 0, height(-3, 0));
|
||||
|
||||
assertEquals(Double.doubleToRawLongBits(expected), Double.doubleToRawLongBits(afterClose));
|
||||
assertEquals(2, heightCalls.get(pack(0, 0)).intValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void denseDetailedStencilsShareRawNaturalHeights() {
|
||||
AtomicInteger basisCalls = new AtomicInteger();
|
||||
|
||||
+29
-1
@@ -305,7 +305,7 @@ public class MantleHydrologyComponentTest {
|
||||
surfaceCourses.add(course);
|
||||
}
|
||||
}
|
||||
assertEquals(tile.diagnosticCandidates().toString(), 1, surfaceCourses.size());
|
||||
assertEquals(tile.localDiagnosticCandidates().toString(), 1, surfaceCourses.size());
|
||||
RiverCourse course = surfaceCourses.getFirst();
|
||||
|
||||
LinkedHashMap<Long, HydrologyColumnLayer> fluidLayers = new LinkedHashMap<>();
|
||||
@@ -1202,6 +1202,34 @@ public class MantleHydrologyComponentTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partiallyOverlappingSurfaceLayerPublishesOnlyAcceptedCaveCells() {
|
||||
HydrologyColumnLayer surface = layerForCourse(
|
||||
HydrologyFeatureType.SURFACE_POOL, 71L, 1L, 14L, 18, 25, 25, "river"
|
||||
);
|
||||
HydrologyColumnLayer cave = layerForCourse(
|
||||
HydrologyFeatureType.UNDERGROUND_POOL, 72L, 1L, 15L, 20, 23, 24, "river"
|
||||
);
|
||||
HydrologyColumnSample sample = sample(8, 8, false, 80, List.of(surface, cave));
|
||||
|
||||
MantleHydrologyComponent.Publication publication = compile(
|
||||
samples(sample), List.of(acceptedPlan(sample, cave)), new TestCaveVoxelView()
|
||||
);
|
||||
|
||||
for (int y = 19; y <= 25; y++) {
|
||||
CavePosition position = new CavePosition(8, y, 8);
|
||||
if (y >= 21 && y <= 24) {
|
||||
assertEquals(HydrologyCaveAction.WET_SOURCE, publication.caveCells().get(position).action());
|
||||
assertFalse(publication.surfaceWrites().containsKey(position));
|
||||
} else {
|
||||
assertEquals(HydrologyCaveAction.WET_SOURCE, publication.surfaceWrites().get(position).action());
|
||||
assertFalse(publication.caveCells().containsKey(position));
|
||||
}
|
||||
}
|
||||
assertEquals(HydrologyCaveAction.SEAL_GUARD,
|
||||
publication.caveCells().get(new CavePosition(9, 22, 8)).action());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void matchingSurfaceFluidComposesOverAcceptedCaveClearance() {
|
||||
HydrologyColumnLayer surface = layer(
|
||||
|
||||
+103
@@ -5,6 +5,11 @@ import art.arcane.iris.pack.loading.IrisData;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ForkJoinPool;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.ToDoubleFunction;
|
||||
@@ -91,6 +96,104 @@ public class IrisGeneratorSurfaceDetailTest {
|
||||
assertEquals(91, samples.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedCellSamplingMatchesColdCacheRawBits() {
|
||||
Sampler source = (seed, x, z) -> Math.sin(x * 0.017D) + Math.cos(z * 0.031D);
|
||||
IrisGenerator warm = generator(source).setSurfaceDetail(0.5D);
|
||||
IrisGenerator cold = generator(source).setSurfaceDetail(0.5D);
|
||||
double[][] points = {
|
||||
{-6D, -6D}, {-5.75D, -5.5D}, {-4.25D, -4.5D}, {-3D, -3D},
|
||||
{-0.25D, -0.125D}, {0D, 0D}, {1.25D, 1.75D}, {5.5D, 5.75D},
|
||||
{6D, 6D}, {6.25D, 6.5D}, {8191.25D, -4096.75D}
|
||||
};
|
||||
|
||||
for (double[] point : points) {
|
||||
cold.rescale(1D);
|
||||
long expected = Double.doubleToRawLongBits(cold.getHeight(point[0], point[1], 27L));
|
||||
for (int repeat = 0; repeat < 4; repeat++) {
|
||||
assertEquals(expected, Double.doubleToRawLongBits(warm.getHeight(point[0], point[1], 27L)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void repeatedLatticeSamplesRefreshSeedChanges() {
|
||||
AtomicInteger samples = new AtomicInteger();
|
||||
IrisGenerator generator = generator((seed, x, z) -> {
|
||||
samples.incrementAndGet();
|
||||
return seed * 0.000001D;
|
||||
}).setSurfaceDetail(0.5D);
|
||||
|
||||
double first = generator.getHeight(0, 0, 27L);
|
||||
assertEquals(Double.doubleToRawLongBits(first), Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
|
||||
assertEquals(1, samples.get());
|
||||
|
||||
double second = generator.getHeight(0, 0, 28L);
|
||||
assertEquals(0.000001D, second - first, 1e-12D);
|
||||
assertEquals(Double.doubleToRawLongBits(second), Double.doubleToRawLongBits(generator.getHeight(0, 0, 28L)));
|
||||
assertEquals(2, samples.get());
|
||||
|
||||
assertEquals(Double.doubleToRawLongBits(first), Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
|
||||
assertEquals(3, samples.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collidingLatticeSamplesRecomputeWithoutReturningAnotherCoordinate() {
|
||||
AtomicInteger originSamples = new AtomicInteger();
|
||||
IrisGenerator generator = generator((seed, x, z) -> {
|
||||
if (x == 0D && z == 0D) {
|
||||
originSamples.incrementAndGet();
|
||||
}
|
||||
return x * 0.00001D;
|
||||
}).setSurfaceDetail(0.5D);
|
||||
|
||||
assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
|
||||
assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
|
||||
assertEquals(1, originSamples.get());
|
||||
assertEquals(Double.doubleToRawLongBits(71754D * 0.00001D),
|
||||
Double.doubleToRawLongBits(generator.getHeight(71754, 0, 27L)));
|
||||
assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
|
||||
assertEquals(2, originSamples.get());
|
||||
assertEquals(0L, Double.doubleToRawLongBits(generator.getHeight(0, 0, 27L)));
|
||||
assertEquals(2, originSamples.get());
|
||||
}
|
||||
|
||||
@Test(timeout = 15000)
|
||||
public void concurrentCollisionsAndSeedChangesPreserveRawHeightBits() throws Exception {
|
||||
Sampler source = (seed, x, z) -> seed * 0.000001D + x * 0.00001D - z * 0.00003D;
|
||||
IrisGenerator generator = generator(source).setSurfaceDetail(0.5D);
|
||||
IrisGenerator reference = generator(source);
|
||||
double[] xs = {0D, 71754D};
|
||||
long[] seeds = {27L, 28L};
|
||||
long[][] expected = new long[xs.length][seeds.length];
|
||||
for (int coordinate = 0; coordinate < xs.length; coordinate++) {
|
||||
for (int seed = 0; seed < seeds.length; seed++) {
|
||||
expected[coordinate][seed] = Double.doubleToRawLongBits(reference.getHeight(xs[coordinate], 0D, seeds[seed]));
|
||||
}
|
||||
}
|
||||
ForkJoinPool pool = new ForkJoinPool(4);
|
||||
try {
|
||||
List<Future<?>> tasks = new ArrayList<>();
|
||||
for (int task = 0; task < 16; task++) {
|
||||
int offset = task;
|
||||
tasks.add(pool.submit(() -> {
|
||||
for (int sample = 0; sample < 64; sample++) {
|
||||
int coordinate = (sample + offset) & 1;
|
||||
int seed = (sample / 2 + offset) & 1;
|
||||
assertEquals(expected[coordinate][seed],
|
||||
Double.doubleToRawLongBits(generator.getHeight(xs[coordinate], 0D, seeds[seed])));
|
||||
}
|
||||
}));
|
||||
}
|
||||
for (Future<?> task : tasks) {
|
||||
task.get(5, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
assertTrue(pool.awaitTermination(5, TimeUnit.SECONDS));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cachedLatticeHeightsRemainIsolatedBySeed() {
|
||||
IrisGenerator generator = generator((seed, x, z) -> seed * 0.000001D);
|
||||
|
||||
@@ -69,6 +69,9 @@ public class EngineShutdownDrainTest {
|
||||
@ClassRule
|
||||
public static final DurabilityMode DURABILITY = DurabilityMode.relaxed();
|
||||
|
||||
@ClassRule
|
||||
public static final TemporaryFolder PLATFORM_FILES = new TemporaryFolder();
|
||||
|
||||
private static IrisPlatform previousPlatform;
|
||||
|
||||
@Rule
|
||||
@@ -79,6 +82,7 @@ public class EngineShutdownDrainTest {
|
||||
previousPlatform = IrisPlatforms.isBound() ? IrisPlatforms.get() : null;
|
||||
IrisPlatforms.unbind();
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.dataFile("iris.json")).thenReturn(PLATFORM_FILES.getRoot().toPath().resolve("iris.json").toFile());
|
||||
PlatformRegistries registries = mock(PlatformRegistries.class);
|
||||
when(platform.registries()).thenReturn(registries);
|
||||
when(registries.block(anyString())).thenReturn(mock(PlatformBlockState.class));
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class HydrologyNoiseCacheBudgetTest {
|
||||
@Test
|
||||
public void reservationsClampToTheRemainingSharedCapacity() {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(4_096L * 15L + 4_095L);
|
||||
HydrologyNoiseCacheBudget.Reservation first = budget.reserve(4, 10);
|
||||
HydrologyNoiseCacheBudget.Reservation second = budget.reserve(2, 10);
|
||||
assertEquals(3, first.additionalChunks());
|
||||
assertEquals(1, second.additionalChunks());
|
||||
assertEquals(14L * 4_096L, budget.reservedBytes());
|
||||
first.close();
|
||||
first.close();
|
||||
assertEquals(2L * 4_096L, budget.reservedBytes());
|
||||
second.close();
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyBudgetAndZeroDemandReserveNothing() {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(0L);
|
||||
try (HydrologyNoiseCacheBudget.Reservation reservation = budget.reserve(5, 32_768)) {
|
||||
assertEquals(0, reservation.additionalChunks());
|
||||
}
|
||||
HydrologyNoiseCacheBudget available = new HydrologyNoiseCacheBudget(1_000_000L);
|
||||
try (HydrologyNoiseCacheBudget.Reservation reservation = available.reserve(5, 0)) {
|
||||
assertEquals(0, reservation.additionalChunks());
|
||||
}
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
assertEquals(0L, available.reservedBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void requestsCannotExceedTheCapacityLimit() {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
try (HydrologyNoiseCacheBudget.Reservation reservation = budget.reserve(5, 32_768)) {
|
||||
assertEquals(32_768, reservation.additionalChunks());
|
||||
assertEquals(5L * 32_768L * 4_096L, budget.reservedBytes());
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> budget.reserve(0, 10));
|
||||
assertThrows(IllegalArgumentException.class, () -> budget.reserve(5, -1));
|
||||
assertThrows(IllegalArgumentException.class, () -> budget.reserve(5, 32_769));
|
||||
assertThrows(IllegalArgumentException.class, () -> new HydrologyNoiseCacheBudget(-1L));
|
||||
}
|
||||
|
||||
@Test(timeout = 10_000L)
|
||||
public void concurrentOwnersNeverOversubscribeAndReleaseIndependently() throws Exception {
|
||||
long maximumBytes = 4_096L * 503L;
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(maximumBytes);
|
||||
CountDownLatch start = new CountDownLatch(1);
|
||||
List<Future<HydrologyNoiseCacheBudget.Reservation>> pending = new ArrayList<>();
|
||||
try (ExecutorService workers = Executors.newFixedThreadPool(16)) {
|
||||
for (int index = 0; index < 32; index++) {
|
||||
pending.add(workers.submit(() -> {
|
||||
assertTrue(start.await(5L, TimeUnit.SECONDS));
|
||||
return budget.reserve(5, 50);
|
||||
}));
|
||||
}
|
||||
start.countDown();
|
||||
List<HydrologyNoiseCacheBudget.Reservation> reservations = new ArrayList<>();
|
||||
long expectedBytes = 0L;
|
||||
for (Future<HydrologyNoiseCacheBudget.Reservation> future : pending) {
|
||||
HydrologyNoiseCacheBudget.Reservation reservation = future.get(5L, TimeUnit.SECONDS);
|
||||
reservations.add(reservation);
|
||||
expectedBytes += reservation.additionalChunks() * 5L * 4_096L;
|
||||
}
|
||||
assertEquals(expectedBytes, budget.reservedBytes());
|
||||
assertTrue(expectedBytes <= maximumBytes);
|
||||
List<Future<?>> releases = new ArrayList<>();
|
||||
for (HydrologyNoiseCacheBudget.Reservation reservation : reservations) {
|
||||
releases.add(workers.submit(() -> {
|
||||
reservation.close();
|
||||
reservation.close();
|
||||
}));
|
||||
}
|
||||
for (Future<?> release : releases) {
|
||||
release.get(5L, TimeUnit.SECONDS);
|
||||
}
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
package art.arcane.iris.generation.runtime;
|
||||
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.world.pregen.PregenPerformanceProfile;
|
||||
import art.arcane.iris.generation.biome.IrisBiome;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyPlanner;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTile;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
|
||||
import art.arcane.iris.generation.hydrology.HydrologyTileKey;
|
||||
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
|
||||
import art.arcane.iris.generation.stream.CachedDoubleStream2D;
|
||||
import art.arcane.iris.generation.stream.CachedStream2D;
|
||||
import art.arcane.iris.generation.terrain.InferredType;
|
||||
import art.arcane.iris.generation.terrain.IrisRegion;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.volmlib.util.stream.ProceduralStream;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
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.TimeoutException;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.CALLS_REAL_METHODS;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class IrisComplexHydrologyNoiseCacheTest {
|
||||
@Test
|
||||
public void pregenGrowsTheBackingHeightCacheWithoutReplacingItsThreadAwareStream() throws Exception {
|
||||
IrisSettings previousSettings = IrisSettings.settings;
|
||||
IrisSettings.settings = new IrisSettings();
|
||||
IrisSettings.get().getPerformance().setNoiseCacheSize(1_024);
|
||||
try (Fixture fixture = new Fixture(1_024)) {
|
||||
ProceduralStream<Double> wrapper = fixture.complex.getRawHeightStream();
|
||||
assertEquals(13D, fixture.height.getDouble(18, 5), 0D);
|
||||
assertEquals(13D, fixture.rawHeight.getDouble(18, 5), 0D);
|
||||
|
||||
PregenPerformanceProfile.apply(fixture.engine);
|
||||
|
||||
assertEquals(4_096L * 256L, fixture.height.getMaxSize());
|
||||
assertEquals(4_096L * 256L, fixture.rawHeight.getMaxSize());
|
||||
assertEquals(1_024L * 256L, fixture.biomes.getMaxSize());
|
||||
assertSame(wrapper, fixture.complex.getRawHeightStream());
|
||||
assertEquals(47D, wrapper.getDouble(18, 5), 0D);
|
||||
assertEquals(13D, fixture.height.getDouble(18, 5), 0D);
|
||||
assertEquals(13D, fixture.rawHeight.getDouble(18, 5), 0D);
|
||||
} finally {
|
||||
PregenPerformanceProfile.restore();
|
||||
IrisSettings.settings = previousSettings;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pregenPreservesActiveHydrologyGrants() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024)) {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
long reservedBytes = budget.reservedBytes();
|
||||
|
||||
fixture.complex.ensureTerrainNoiseCacheSize(4_096);
|
||||
|
||||
fixture.assertCapacities(32_768);
|
||||
assertEquals(4_096L * 256L, fixture.rawHeight.getMaxSize());
|
||||
assertEquals(reservedBytes, budget.reservedBytes());
|
||||
fixture.complex.close();
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pregenPreservesLargerRuntimeCacheCapacities() throws Exception {
|
||||
try (Fixture fixture = new Fixture(65_536)) {
|
||||
fixture.complex.ensureTerrainNoiseCacheSize(4_096);
|
||||
|
||||
fixture.assertCapacities(65_536);
|
||||
assertEquals(65_536L * 256L, fixture.rawHeight.getMaxSize());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void idleRuntimeReservesOnlyOnDemandAndRestoresAfterHydrologyDrains() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024)) {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
HydrologyTileCache cache = fixture.planningCache(budget);
|
||||
fixture.assertCapacities(1_024);
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
cache.get(new HydrologyTileKey(0, 0));
|
||||
cache.get(new HydrologyTileKey(0, 0));
|
||||
fixture.assertCapacities(32_768);
|
||||
assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
|
||||
doAnswer(invocation -> {
|
||||
fixture.assertCapacities(32_768);
|
||||
assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
|
||||
cache.close();
|
||||
return null;
|
||||
}).when(fixture.hydrology).close();
|
||||
fixture.complex.close();
|
||||
fixture.assertCapacities(1_024);
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
doNothing().when(fixture.hydrology).close();
|
||||
fixture.complex.close();
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
fixture.assertCapacities(1_024);
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 10_000L)
|
||||
public void concurrentFirstDemandsShareOneReservation() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2)) {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
HydrologyTileCache cache = fixture.planningCache(budget);
|
||||
CountDownLatch entered = new CountDownLatch(2);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
cache.setTerrainPreparation(() -> {
|
||||
entered.countDown();
|
||||
await(release);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
});
|
||||
Future<?> first = callers.submit(() -> cache.get(new HydrologyTileKey(0, 0)));
|
||||
Future<?> second = callers.submit(() -> cache.get(new HydrologyTileKey(1, 0)));
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
release.countDown();
|
||||
first.get(5L, TimeUnit.SECONDS);
|
||||
second.get(5L, TimeUnit.SECONDS);
|
||||
fixture.assertCapacities(32_768);
|
||||
assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
|
||||
fixture.complex.close();
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 10_000L)
|
||||
public void closeDrainsAnAdmittedFirstDemandBeforeReleasingItsGrant() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024);
|
||||
ExecutorService callers = Executors.newFixedThreadPool(2)) {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
HydrologyTileCache cache = fixture.planningCache(budget);
|
||||
CountDownLatch preparing = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
CountDownLatch closing = new CountDownLatch(1);
|
||||
cache.setTerrainPreparation(() -> {
|
||||
preparing.countDown();
|
||||
await(release);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
});
|
||||
doAnswer(invocation -> {
|
||||
closing.countDown();
|
||||
cache.close();
|
||||
return null;
|
||||
}).when(fixture.hydrology).close();
|
||||
Future<?> demand = callers.submit(() -> cache.get(new HydrologyTileKey(0, 0)));
|
||||
assertTrue(preparing.await(5L, TimeUnit.SECONDS));
|
||||
Future<?> close = callers.submit(fixture.complex::close);
|
||||
assertTrue(closing.await(5L, TimeUnit.SECONDS));
|
||||
try {
|
||||
assertThrows(TimeoutException.class, () -> close.get(100L, TimeUnit.MILLISECONDS));
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
} finally {
|
||||
release.countDown();
|
||||
}
|
||||
demand.get(5L, TimeUnit.SECONDS);
|
||||
close.get(5L, TimeUnit.SECONDS);
|
||||
fixture.assertCapacities(1_024);
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void volumetricNaturalHeightReservesItsDistinctFifthCache() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024)) {
|
||||
CachedDoubleStream2D natural = fixture.heightCache(1_024);
|
||||
fixture.complex.setUnblendedNaturalHeightStream(natural);
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
fixture.assertCapacities(32_768);
|
||||
assertEquals(32_768L * 256L, natural.getMaxSize());
|
||||
assertEquals(5L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
|
||||
fixture.complex.close();
|
||||
assertEquals(1_024L * 256L, natural.getMaxSize());
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialGrantPreservesTheConfiguredFloorForEveryCache() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024)) {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(4L * 80L * 4_096L + 4_095L);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
fixture.assertCapacities(1_104);
|
||||
assertEquals(4L * 80L * 4_096L, budget.reservedBytes());
|
||||
fixture.complex.close();
|
||||
fixture.assertCapacities(1_024);
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void configuredCapacityAboveTargetRemainsUnchanged() throws Exception {
|
||||
try (Fixture fixture = new Fixture(65_536)) {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
fixture.assertCapacities(65_536);
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
fixture.complex.close();
|
||||
fixture.assertCapacities(65_536);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detachedAndNonHydrologyRuntimesDoNotReserve() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024)) {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
setField(fixture.complex, "detached", true);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
fixture.assertCapacities(1_024);
|
||||
setField(fixture.complex, "detached", false);
|
||||
fixture.complex.setHydrologyRuntime(null);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
fixture.assertCapacities(1_024);
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedHydrologyDrainRetainsTheReservationUntilRetry() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024)) {
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
HydrologyTileCache cache = fixture.planningCache(budget);
|
||||
cache.get(new HydrologyTileKey(0, 0));
|
||||
doThrow(new IllegalStateException("Hydrology is still draining")).when(fixture.hydrology).close();
|
||||
assertThrows(IllegalStateException.class, fixture.complex::close);
|
||||
fixture.assertCapacities(32_768);
|
||||
assertEquals(4L * (32_768L - 1_024L) * 4_096L, budget.reservedBytes());
|
||||
doAnswer(invocation -> {
|
||||
cache.close();
|
||||
return null;
|
||||
}).when(fixture.hydrology).close();
|
||||
fixture.complex.close();
|
||||
fixture.assertCapacities(1_024);
|
||||
assertEquals(0L, budget.reservedBytes());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mappedStreamRetainsOnlyTheFinalCacheAtTheExpandedCapacity() throws Exception {
|
||||
try (Fixture fixture = new Fixture(1_024)) {
|
||||
CachedStream2D<IrisBiome> inner = fixture.biomes;
|
||||
CachedStream2D<IrisBiome> mapped = new CachedStream2D<>("mapped", fixture.engine, inner, 1_024);
|
||||
fixture.complex.setBaseBiomeStream(mapped);
|
||||
HydrologyNoiseCacheBudget budget = new HydrologyNoiseCacheBudget(Long.MAX_VALUE);
|
||||
fixture.complex.expandHydrologyNoiseCaches(budget);
|
||||
assertEquals(1_024L * 256L, inner.getMaxSize());
|
||||
assertEquals(32_768L * 256L, mapped.getMaxSize());
|
||||
fixture.complex.close();
|
||||
assertEquals(1_024L * 256L, mapped.getMaxSize());
|
||||
}
|
||||
}
|
||||
|
||||
private static void setField(IrisComplex complex, String name, Object value) throws Exception {
|
||||
Field field = IrisComplex.class.getDeclaredField(name);
|
||||
field.setAccessible(true);
|
||||
field.set(complex, value);
|
||||
}
|
||||
|
||||
private static void await(CountDownLatch latch) {
|
||||
try {
|
||||
assertTrue(latch.await(5L, TimeUnit.SECONDS));
|
||||
} catch (InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Fixture implements AutoCloseable {
|
||||
private final MockedStatic<IrisServices> services = mockStatic(IrisServices.class);
|
||||
private final Engine engine = mock(Engine.class);
|
||||
private final IrisComplex complex = mock(IrisComplex.class, CALLS_REAL_METHODS);
|
||||
private final IrisHydrologyRuntime hydrology = mock(IrisHydrologyRuntime.class);
|
||||
private final CachedStream2D<IrisBiome> biomes;
|
||||
private final CachedStream2D<IrisRegion> regions;
|
||||
private final CachedStream2D<InferredType> bridge;
|
||||
private final CachedDoubleStream2D height;
|
||||
private final CachedDoubleStream2D rawHeight;
|
||||
|
||||
private Fixture(int capacity) throws Exception {
|
||||
services.when(() -> IrisServices.get(PreservationRegistry.class)).thenReturn(mock(PreservationRegistry.class));
|
||||
biomes = objectCache(capacity);
|
||||
regions = objectCache(capacity);
|
||||
bridge = objectCache(capacity);
|
||||
height = heightCache(capacity);
|
||||
rawHeight = heightCache(capacity);
|
||||
complex.setNaturalHeightStream(height);
|
||||
complex.setHeightStream(ProceduralStream.ofDouble((x, z) -> 47D));
|
||||
setField(complex, "cachedHeightStream", rawHeight);
|
||||
when(engine.getComplex()).thenReturn(complex);
|
||||
complex.setBaseBiomeStream(biomes);
|
||||
complex.setRegionStream(regions);
|
||||
complex.setBridgeStream(bridge);
|
||||
complex.setBaseTerrainHeightStream(height);
|
||||
complex.setUnblendedNaturalHeightStream(height);
|
||||
complex.setHydrologyRuntime(hydrology);
|
||||
setField(complex, "resolvedTerrain", mock(ResolvedTerrainProvider.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
services.close();
|
||||
}
|
||||
|
||||
private <T> CachedStream2D<T> objectCache(int capacity) {
|
||||
@SuppressWarnings("unchecked")
|
||||
ProceduralStream<T> source = mock(ProceduralStream.class);
|
||||
return new CachedStream2D<>("cache", engine, source, capacity);
|
||||
}
|
||||
|
||||
private CachedDoubleStream2D heightCache(int capacity) {
|
||||
return new CachedDoubleStream2D("height", engine, ProceduralStream.ofDouble((x, z) -> x - z), capacity);
|
||||
}
|
||||
|
||||
private HydrologyTileCache planningCache(HydrologyNoiseCacheBudget budget) {
|
||||
HydrologyPlanner planner = mock(HydrologyPlanner.class);
|
||||
when(planner.plan(any(HydrologyTileKey.class))).thenReturn(mock(HydrologyTile.class));
|
||||
HydrologyTileCache cache = new HydrologyTileCache(planner, 4);
|
||||
cache.setTerrainPreparation(() -> complex.expandHydrologyNoiseCaches(budget));
|
||||
doAnswer(invocation -> {
|
||||
cache.close();
|
||||
return null;
|
||||
}).when(hydrology).close();
|
||||
return cache;
|
||||
}
|
||||
|
||||
private void assertCapacities(int chunks) {
|
||||
assertEquals(chunks * 256L, biomes.getMaxSize());
|
||||
assertEquals(chunks * 256L, regions.getMaxSize());
|
||||
assertEquals(chunks * 256L, bridge.getMaxSize());
|
||||
assertEquals(chunks * 256L, height.getMaxSize());
|
||||
}
|
||||
}
|
||||
}
|
||||
+149
-3
@@ -129,7 +129,8 @@ public class IrisComplexSurfaceBiomeTest {
|
||||
70D,
|
||||
constant(base),
|
||||
constant(sea),
|
||||
constant(base)
|
||||
constant(base),
|
||||
constantSlope(0D)
|
||||
);
|
||||
|
||||
assertSame(sea, resolved);
|
||||
@@ -152,7 +153,8 @@ public class IrisComplexSurfaceBiomeTest {
|
||||
63D,
|
||||
constant(base),
|
||||
constant(base),
|
||||
constant(shore)
|
||||
constant(shore),
|
||||
constantSlope(0D)
|
||||
);
|
||||
|
||||
assertSame(shore, resolved);
|
||||
@@ -176,12 +178,151 @@ public class IrisComplexSurfaceBiomeTest {
|
||||
63D,
|
||||
constant(land),
|
||||
constant(aquatic),
|
||||
constant(aquatic)
|
||||
constant(aquatic),
|
||||
constantSlope(0D)
|
||||
);
|
||||
|
||||
assertSame(land, resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zeroShoreMinimumWidthLeavesTheVerticalBandAlone() {
|
||||
IrisBiome base = mock(IrisBiome.class);
|
||||
IrisBiome land = mock(IrisBiome.class);
|
||||
IrisRegion region = mock(IrisRegion.class);
|
||||
doReturn(false).when(base).isShore();
|
||||
doReturn(false).when(base).isLand();
|
||||
doReturn(3D).when(region).getShoreHeight(12D, 18D);
|
||||
doReturn(0D).when(region).getShoreMinimumWidth();
|
||||
|
||||
IrisBiome resolved = IrisComplex.resolveSurfaceBiome(
|
||||
72D,
|
||||
base,
|
||||
region,
|
||||
12D,
|
||||
18D,
|
||||
63D,
|
||||
constant(land),
|
||||
constant(base),
|
||||
constant(base),
|
||||
unsampledSlopes()
|
||||
);
|
||||
|
||||
assertSame(land, resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shoreMinimumWidthWidensTheBandOnSteepGround() {
|
||||
IrisBiome base = mock(IrisBiome.class);
|
||||
IrisBiome shore = mock(IrisBiome.class);
|
||||
IrisRegion region = mock(IrisRegion.class);
|
||||
doReturn(false).when(base).isShore();
|
||||
doReturn(3D).when(region).getShoreHeight(12D, 18D);
|
||||
doReturn(3D).when(region).getShoreMinimumWidth();
|
||||
|
||||
IrisBiome resolved = IrisComplex.resolveSurfaceBiome(
|
||||
74D,
|
||||
base,
|
||||
region,
|
||||
12D,
|
||||
18D,
|
||||
63D,
|
||||
constant(base),
|
||||
constant(base),
|
||||
constant(shore),
|
||||
constantSlope(9D)
|
||||
);
|
||||
|
||||
assertSame(shore, resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shoreMinimumWidthDoesNothingOnFlatGround() {
|
||||
IrisBiome base = mock(IrisBiome.class);
|
||||
IrisBiome land = mock(IrisBiome.class);
|
||||
IrisRegion region = mock(IrisRegion.class);
|
||||
doReturn(false).when(base).isShore();
|
||||
doReturn(false).when(base).isLand();
|
||||
doReturn(3D).when(region).getShoreHeight(12D, 18D);
|
||||
doReturn(3D).when(region).getShoreMinimumWidth();
|
||||
|
||||
IrisBiome resolved = IrisComplex.resolveSurfaceBiome(
|
||||
67D,
|
||||
base,
|
||||
region,
|
||||
12D,
|
||||
18D,
|
||||
63D,
|
||||
constant(land),
|
||||
constant(base),
|
||||
constant(base),
|
||||
constantSlope(0D)
|
||||
);
|
||||
|
||||
assertSame(land, resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void widenedBandStillLeavesDeepWaterAndHighGroundAlone() {
|
||||
IrisBiome base = mock(IrisBiome.class);
|
||||
IrisBiome sea = mock(IrisBiome.class);
|
||||
IrisBiome land = mock(IrisBiome.class);
|
||||
IrisRegion region = mock(IrisRegion.class);
|
||||
doReturn(false).when(base).isShore();
|
||||
doReturn(false).when(base).isLand();
|
||||
doReturn(false).when(base).isAquatic();
|
||||
doReturn(3D).when(region).getShoreHeight(12D, 18D);
|
||||
doReturn(3D).when(region).getShoreMinimumWidth();
|
||||
|
||||
assertSame(sea, IrisComplex.resolveSurfaceBiome(
|
||||
60D,
|
||||
base,
|
||||
region,
|
||||
12D,
|
||||
18D,
|
||||
63D,
|
||||
constant(land),
|
||||
constant(sea),
|
||||
constant(base),
|
||||
unsampledSlopes()
|
||||
));
|
||||
assertSame(land, IrisComplex.resolveSurfaceBiome(
|
||||
200D,
|
||||
base,
|
||||
region,
|
||||
12D,
|
||||
18D,
|
||||
63D,
|
||||
constant(land),
|
||||
constant(sea),
|
||||
constant(base),
|
||||
unsampledSlopes()
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shoreBandTopTradesHorizontalWidthForHeightOnSlopes() {
|
||||
IrisRegion region = mock(IrisRegion.class);
|
||||
doReturn(3D).when(region).getShoreHeight(12D, 18D);
|
||||
doReturn(3D).when(region).getShoreMinimumWidth();
|
||||
|
||||
assertEquals(66D, IrisComplex.shoreBandTop(67D, region, 12D, 18D, 63D, constantSlope(0D)), 0D);
|
||||
assertEquals(75D, IrisComplex.shoreBandTop(74D, region, 12D, 18D, 63D, constantSlope(9D)), 0D);
|
||||
assertEquals(75D, IrisComplex.shoreBandTop(74D, region, 12D, 18D, 63D, constantSlope(120D)), 0D);
|
||||
assertEquals(66D, IrisComplex.shoreBandTop(80D, region, 12D, 18D, 63D, constantSlope(120D)), 0D);
|
||||
assertEquals(66D, IrisComplex.shoreBandTop(64D, region, 12D, 18D, 63D, unsampledSlopes()), 0D);
|
||||
}
|
||||
|
||||
private static ProceduralStream<Double> constantSlope(double slope) {
|
||||
return ProceduralStream.ofDouble((x, z) -> slope);
|
||||
}
|
||||
|
||||
private static ProceduralStream<Double> unsampledSlopes() {
|
||||
return ProceduralStream.ofDouble((x, z) -> {
|
||||
throw new AssertionError("The shore band sampled the slope for a column that cannot be widened");
|
||||
});
|
||||
}
|
||||
|
||||
private static ProceduralStream<IrisBiome> constant(IrisBiome biome) {
|
||||
@SuppressWarnings("unchecked")
|
||||
ProceduralStream<IrisBiome> stream = mock(ProceduralStream.class);
|
||||
@@ -189,6 +330,11 @@ public class IrisComplexSurfaceBiomeTest {
|
||||
return stream;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void regionShoreMinimumWidthDefaultsToZeroSoExistingPacksAreUnchanged() {
|
||||
assertEquals(0D, new IrisRegion().getShoreMinimumWidth(), 0D);
|
||||
}
|
||||
|
||||
private static ProceduralStream<InferredType> constantType(InferredType type) {
|
||||
return ProceduralStream.of(
|
||||
(x, z) -> type,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user