Compare commits

..
964 changed files with 99125 additions and 73343 deletions

No files matched your search

-2
View File
@@ -81,5 +81,3 @@ BUILDS/
/docs/
/.perf/
/.codegraph/
.superpowers/
@@ -2,9 +2,8 @@ package art.arcane.iris.probe;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.concurrent.MultiBurst;
import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
import art.arcane.iris.generation.hydrology.plan.WindowKey;
import art.arcane.iris.world.pregen.PregenHydrology;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.world.pregen.PregenPerformanceProfile;
import net.minecraft.core.registries.Registries;
@@ -32,16 +31,11 @@ import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Locale;
import java.util.Set;
public final class OfflineRegionGenerator {
private static final int CHUNKS_PER_HYDROLOGY_REGION = WindowKey.SIZE >> 4;
private OfflineRegionGenerator() {
}
@@ -103,13 +97,14 @@ public final class OfflineRegionGenerator {
PregenPerformanceProfile.apply(session.engine());
System.out.println("generationProfile=pregen noiseCacheSize=" + IrisSettings.get().getPerformance().getNoiseCacheSize()
+ " burstParallelism=" + MultiBurst.burst.parallelism() + " multicore=true");
try (HydrologyPregeneration pregeneration = PregenHydrology.open(session.engine());
IrisHydrologyRuntime hydrology = session.engine().getComplex().getHydrologyRuntime();
try (HydrologyTileCache.PregenerationScope hydrologyScope = hydrology == null ? null
: hydrology.preparePregeneration(configuration.hydrologyArea());
HeadlessTerrainContext context = HeadlessTerrainContext.create(new HeadlessTerrainContext.Options(
session.engine(), runtime, staged.resolve("templates"), configuration.levelKey(),
configuration.noiseSettings(), spigot));
RegionGenerationWindow workers = new RegionGenerationWindow(configuration.parallelism(),
RegionGenerationWindow.Policy.STANDALONE)) {
PregenHydrology.prepareAll(pregeneration, configuration.hydrologyRegions());
HeadlessRegionTerrain.Session rolling = new HeadlessRegionTerrain.Session(workers);
Path regionDirectory = session.engine().getTarget().getWorld().worldFolder().toPath().resolve("region");
Files.createDirectory(regionDirectory);
@@ -244,27 +239,14 @@ public final class OfflineRegionGenerator {
}
}
List<long[]> hydrologyRegions() {
HydrologyTileCache.PregenerationArea hydrologyArea() {
int halo = NativeRegionPlan.terrainHalo();
Set<Long> seen = new HashSet<>();
List<long[]> regions = new ArrayList<>();
for (int row = 0; row < regionsZ; row++) {
for (int column = 0; column < regionsX; column++) {
int regionX = row % 2 == 0 ? column : regionsX - column - 1;
int minimumX = Math.floorDiv(chunkX + regionX * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
int maximumX = Math.floorDiv(chunkX + (regionX + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
int minimumZ = Math.floorDiv(chunkZ + row * width - halo, CHUNKS_PER_HYDROLOGY_REGION);
int maximumZ = Math.floorDiv(chunkZ + (row + 1) * width - 1 + halo, CHUNKS_PER_HYDROLOGY_REGION);
for (int hydrologyZ = minimumZ; hydrologyZ <= maximumZ; hydrologyZ++) {
for (int hydrologyX = minimumX; hydrologyX <= maximumX; hydrologyX++) {
if (seen.add(((long) hydrologyX << 32) | (hydrologyZ & 0xFFFFFFFFL))) {
regions.add(new long[]{hydrologyX, hydrologyZ});
}
}
}
}
}
return regions;
long minimumX = ((long) chunkX - halo) * 16L;
long minimumZ = ((long) chunkZ - halo) * 16L;
long maximumX = ((long) chunkX + (long) width * regionsX + halo) * 16L - 1L;
long maximumZ = ((long) chunkZ + (long) width * regionsZ + halo) * 16L - 1L;
return new HydrologyTileCache.PregenerationArea(Math.toIntExact((minimumX + maximumX) / 2L),
Math.toIntExact((minimumZ + maximumZ) / 2L), minimumX, minimumZ, maximumX, maximumZ);
}
static Configuration parse(String[] arguments) throws IOException {
@@ -137,7 +137,7 @@ public class HeadlessChunkGeneratorTest {
}
Files.writeString(pack.resolve("dimensions/main.json"), """
{"name":"Native columns","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
"landChance":1,"carvingEnabled":false}
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
""");
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
@@ -1,6 +1,7 @@
package art.arcane.iris.probe;
import art.arcane.iris.structure.nativegen.NativeStructureVolumeIndex;
import art.arcane.iris.generation.hydrology.HydrologyTileCache;
import art.arcane.volmlib.nativelib.terrain.structure.NativeStructureVolume;
import art.arcane.volmlib.util.collection.KList;
import net.minecraft.nbt.CompoundTag;
@@ -67,10 +68,11 @@ public final class HeadlessRegionTerrainTest {
OfflineRegionGenerator.Configuration export = new OfflineRegionGenerator.Configuration(
workspace.pack(), "main", 69420L, -3, 2, 1, 2, 3, 4, configuration,
"probe", Level.OVERWORLD, NoiseGeneratorSettings.OVERWORLD, root.resolve("export"));
List<long[]> regions = export.hydrologyRegions();
assertEquals(2, regions.size());
assertArrayEquals(new long[]{-1L, 0L}, regions.get(0));
assertArrayEquals(new long[]{0L, 0L}, regions.get(1));
HydrologyTileCache.PregenerationArea bounds = export.hydrologyArea();
assertEquals(-80L, bounds.minimumBlockX());
assertEquals(0L, bounds.minimumBlockZ());
assertEquals(15L, bounds.maximumBlockX());
assertEquals(111L, bounds.maximumBlockZ());
assertEquals(1, result.targetChunks());
assertEquals(529, result.chunks().size());
long terrain = result.chunks().stream().filter(chunk -> chunk.getPersistedStatus() == ChunkStatus.TERRAIN).count();
@@ -187,7 +189,7 @@ public final class HeadlessRegionTerrainTest {
}
Files.writeString(pack.resolve("dimensions/main.json"), """
{"name":"Native region","focus":"swamp","regions":["owned"],"fluidHeight":40,"logicalHeight":256,
"landChance":1,"carvingEnabled":false}
"landChance":1,"carvingEnabled":false,"hydrology":{"rivers":{"enabled":false}}}
""");
Files.writeString(pack.resolve("regions/owned.json"), "{\"landBiomes\":[\"swamp\"]}");
Files.writeString(pack.resolve("generators/flat.json"), "{\"composite\":[]}");
+1 -1
View File
@@ -14,7 +14,7 @@ dependencies {
// log4j-core Filter interface, so its constructor reference needs the type resolvable.
compileOnly(libs.log4j.core)
testImplementation('junit:junit:4.13.2')
testImplementation(libs.mockito.core)
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.paper.api)
testImplementation(libs.bstats)
testImplementation(project(':core'))
@@ -60,7 +60,6 @@ import art.arcane.iris.integration.MultiverseCoreLink;
import art.arcane.iris.platform.bukkit.nms.INMS;
import art.arcane.volmlib.nativelib.terrain.ServerShutdownBoundary;
import art.arcane.iris.studio.view.BukkitGuiHost;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.PregeneratorJob;
import art.arcane.iris.platform.bukkit.BoardSVC;
import art.arcane.iris.command.CommandSVC;
@@ -989,7 +988,6 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
}
public void onDisable() {
GuiHost.shutdown();
preservePendingBlockDrops();
papiLifecycleActive = false;
if (updateNotifier != null) {
@@ -31,7 +31,8 @@ import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -195,9 +196,9 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoPOI(e, type, player(), teleport);
}
@Director(description = "Find a hydrology water feature", sync = true)
@Director(description = "Find an accepted hydrology feature", sync = true)
public void river(
@Param(description = "Feature type: stream, river, major_river, cascade, waterfall, mouth, pond, lake, dam, sea_inlet or cave_pool", customHandler = HydrologyTypeHandler.class)
@Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class)
String type,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
@@ -211,32 +212,42 @@ public class CommandFind implements DirectorExecutor {
}
return;
}
HydrologyFeatureType featureType;
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
HydrologyFeatureQuery query;
try {
featureType = HydrologyFeatureType.parse(type);
query = HydrologyFeatureQuery.parse(type);
} catch (IllegalArgumentException error) {
commandSender.sendMessage(C.RED + error.getMessage());
return;
}
String label = featureType.key().replace('_', ' ');
Location origin = target.getLocation();
World originWorld = origin.getWorld();
int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
commandSender.sendMessage(C.GRAY + GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
int requestedDistance = runtime == null
? 8192
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime == null
? requestedDistance
: runtime.maximumFeatureSearchDistance(
origin.getBlockX(), origin.getBlockZ(), requestedDistance);
commandSender.sendMessage(C.GRAY + "Searching accepted hydrology plans for " + type + "...");
J.a(() -> {
try {
GenerationSemanticQueries.RiverResult feature = GenerationSemanticQueries.nearestRiver(
activeEngine,
featureType,
query,
origin.getBlockX(),
origin.getBlockZ(),
maximumDistance
maximumDistance,
(int visited) -> sendStructureMessage(target, commandSender,
C.GRAY + "Searched " + visited + " hydrology tiles for " + type + "...")
).orElse(null);
if (feature == null) {
sendStructureMessage(target, commandSender,
C.YELLOW + GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
C.YELLOW + "No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
return;
}
String label = type + " hydrology feature";
if (!teleport) {
sendStructureMessage(target, commandSender,
C.GREEN + "Found " + label + " at " + feature.x() + ", " + feature.y() + ", " + feature.z() + ".");
@@ -248,9 +259,8 @@ public class CommandFind implements DirectorExecutor {
target, originWorld, commandSender, label, destination, true);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not search for " + label + ": " + error.getClass().getSimpleName()
+ (error.getMessage() == null ? "" : " - " + error.getMessage()));
Iris.reportError("Could not locate hydrology feature '" + featureType.key() + "'.", error);
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
Iris.reportError("Could not locate accepted hydrology feature '" + type + "'.", error);
}
});
}
@@ -21,9 +21,10 @@ package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.ImageMapStudioGUI;
import art.arcane.iris.studio.view.NoiseExplorerGUI;
import art.arcane.iris.studio.view.VisionGUI;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.studio.workspace.IrisProject;
import art.arcane.iris.studio.workspace.IrisCodeWorkspace;
@@ -97,9 +98,7 @@ import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import art.arcane.iris.localization.IrisLanguage;
import art.arcane.iris.localization.BukkitCommandMessages;
@@ -256,10 +255,15 @@ public class CommandStudio implements DirectorExecutor {
long seed
) {
if (noGUI()) return;
VolmitSender commandSender = sender();
UUID openerId = commandSender.isPlayer() ? commandSender.player().getUniqueId() : null;
MapWindow.NoiseSelection selection = new MapWindow.NoiseSelection(generator == null ? null : generator.getLoadKey(), generator, seed);
withPlayerContext(opener -> openNoiseAtPlayer(commandSender, openerId, opener, selection));
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
if (generator == null) {
NoiseExplorerGUI.launch();
return;
}
String generatorKey = generator.getLoadKey();
NoiseExplorerGUI.launchGeneratorKey(generatorKey, generator, seed);
}
@Director(description = "Open the image-map studio (External GUI)", descriptionKey = "iris.director.commandstudio.director.open_image_map_studio_external_gui", aliases = {"imap"})
@@ -275,7 +279,8 @@ public class CommandStudio implements DirectorExecutor {
return;
}
requestMapStart(world, MapWindow::openImageMaps);
Engine activeEngine = IrisToolbelt.access(world).getEngine();
ImageMapStudioGUI.launch(activeEngine);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_IMAGE_MAP_STUDIO));
}
@@ -415,59 +420,9 @@ public class CommandStudio implements DirectorExecutor {
return;
}
requestMapStart(world, MapWindow::openMap);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
}
// The map opens where the player stands when they are in that world, otherwise at the world origin.
private PaletteExplorerStart mapStartIn(World world, Player opener, UUID openerId) {
Location center = opener != null && opener.getWorld().equals(world) ? opener.getLocation() : null;
return new PaletteExplorerStart(
IrisToolbelt.access(world).getEngine(),
world.getUID(),
openerId,
null,
center == null ? 0D : center.getX(),
center == null ? 0D : center.getZ()
);
}
private void requestMapStart(World world, Consumer<PaletteExplorerStart> launch) {
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
withPlayerContext(opener -> launch.accept(mapStartIn(world, opener, openerId)));
}
private void openNoiseAtPlayer(VolmitSender commandSender, UUID openerId, Player opener, MapWindow.NoiseSelection selection) {
World world = opener == null ? null : opener.getWorld();
PlatformChunkGenerator worldGenerator = world == null ? null : IrisToolbelt.access(world);
Engine engine = worldGenerator == null ? GuiHost.get().findActiveEngine() : worldGenerator.getEngine();
if (engine == null) {
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_YOU_NEED_BE_SPECIFY_IRIS_GENERATED_WORLD));
return;
}
PaletteExplorerStart start = worldGenerator == null
? new PaletteExplorerStart(engine, null, openerId, null, 0D, 0D)
: mapStartIn(world, opener, openerId);
MapWindow.openNoise(start, selection);
commandSender.sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_NOISE_EXPLORER));
}
private void withPlayerContext(Consumer<Player> action) {
Player opener = sender().isPlayer() ? player() : null;
if (opener == null) {
action.accept(null);
return;
}
AtomicBoolean completed = new AtomicBoolean();
Consumer<Player> complete = player -> {
if (completed.compareAndSet(false, true)) {
action.accept(player);
}
};
Runnable retired = () -> complete.accept(null);
if (!J.runEntity(opener, () -> complete.accept(opener.isOnline() ? opener : null), 0, retired)) {
retired.run();
}
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
}
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
@@ -17,7 +17,8 @@ import art.arcane.iris.platform.bukkit.terrain.IrisSampleLimits;
import art.arcane.iris.platform.bukkit.terrain.IrisSurfaceClassifier;
import art.arcane.iris.platform.bukkit.terrain.IrisWorldInfoFactory;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -107,15 +108,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
try {
int surface = engine.getHeight(blockX, blockZ);
WaterRole role = engine.getComplex().getWaterRoleStream().get(blockX, blockZ);
HydrologyColumnSample hydrology = hydrologySample(engine, blockX, blockZ);
int fluid = (int) Math.round(
engine.getComplex().getWaterHeadStream().get(blockX, blockZ));
engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
InferredType inferredType = null;
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
inferredType = biome == null ? null : biome.getInferredType();
}
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, role);
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology);
} catch (Throwable error) {
reportQueryFault("surfaceKind", world, error);
return IrisSurfaceKind.UNKNOWN;
@@ -265,12 +266,12 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
return false;
}
WaterRole role = wantRiver
? engine.getComplex().getWaterRoleStream().get(blockX, blockZ)
: WaterRole.NONE;
HydrologyColumnSample hydrology = wantRiver
? hydrologySample(engine, blockX, blockZ)
: null;
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
int fluid = wantRiver
? (int) Math.round(engine.getComplex().getWaterHeadStream().get(blockX, blockZ))
? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ))
: engine.getDimension().getFluidHeight();
boolean needsBiome = wantBiome
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
@@ -280,23 +281,29 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
surface,
fluid,
biome == null ? null : biome.getInferredType(),
role
hydrology
)
: IrisSurfaceKind.UNKNOWN;
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
int natural = wantNaturalHeight
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
HydrologyColumnLayer surfaceLayer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
boolean riverPresent = surfaceLayer != null;
IrisRiverState riverState = wantRiverState
? riverState(role)
? riverState(hydrology)
: IrisRiverState.NONE;
double riverDistance = wantRiverDistance
? riverDistance(role)
double riverDistance = wantRiverDistance && riverPresent
? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ)
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
int riverFlow = wantRiverFlow
? riverFlow(role)
int riverFlow = wantRiverFlow && riverPresent
? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ))
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
int riverWaterSurfaceY = wantRiverWaterSurface && role.wet()
int riverWaterSurfaceY = wantRiverWaterSurface
&& surfaceLayer != null
&& surfaceLayer.connectedFluid()
? fluid + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
sink.accept(new IrisColumnSample(
@@ -320,25 +327,21 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
}
}
static IrisRiverState riverState(WaterRole role) {
return role.wet() ? IrisRiverState.WET : IrisRiverState.NONE;
static IrisRiverState riverState(HydrologyColumnSample hydrology) {
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer == null || !layer.channel()) {
return IrisRiverState.NONE;
}
return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY;
}
static double riverDistance(WaterRole role) {
return switch (role) {
case NONE -> IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> 0D;
case SHORE, POND_SHORE -> 1D;
case BANK, DAM -> 2D;
};
}
static int riverFlow(WaterRole role) {
return switch (role) {
case NONE, SHORE, POND_SHORE, BANK, DAM -> IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
case CHANNEL, MOUTH, CASCADE, WATERFALL -> 1;
case POND, INLET -> 0;
};
private static HydrologyColumnSample hydrologySample(Engine engine, int blockX, int blockZ) {
if (engine.getComplex().getHydrologyRuntime() == null) {
return null;
}
return engine.getComplex().getHydrologyRuntime().sample(blockX, blockZ).orElse(null);
}
private static Optional<String> key(IrisBiome biome) {
@@ -1,7 +1,8 @@
package art.arcane.iris.platform.bukkit.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.terrain.InferredType;
public final class IrisSurfaceClassifier {
@@ -28,16 +29,25 @@ public final class IrisSurfaceClassifier {
int engineSurfaceHeight,
int engineFluidHeight,
InferredType inferredType,
WaterRole role
HydrologyColumnSample hydrology
) {
if (engineSurfaceHeight <= 0) {
return IrisSurfaceKind.VOID;
}
return switch (role) {
case NONE -> classify(engineSurfaceHeight, engineFluidHeight, inferredType);
case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
case BANK, DAM -> IrisSurfaceKind.LAND;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
};
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer != null) {
if (layer.shore()) {
return IrisSurfaceKind.RIVER_SHORE;
}
if (layer.channel()) {
return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL;
}
if (layer.grading()) {
return IrisSurfaceKind.LAND;
}
}
return classify(engineSurfaceHeight, engineFluidHeight, inferredType);
}
}
@@ -23,8 +23,6 @@ import art.arcane.iris.world.event.IrisEngineHotloadEvent;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.StudioSVC;
import art.arcane.iris.studio.workspace.IrisProject;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.event.EventHandler;
@@ -42,21 +40,16 @@ public final class BukkitGuiHost implements GuiHost.Provider {
GuiHost.set(new BukkitGuiHost());
}
/**
* The engine of the open studio project, which reads the pack folder being edited, or else the first
* Iris world. A normal Iris world runs on its own copy of the pack, made when the world was created.
*/
@Override
public Engine findActiveEngine() {
Engine studioEngine = findStudioEngine();
if (studioEngine != null) {
return studioEngine;
}
try {
for (World world : new ArrayList<>(Bukkit.getWorlds())) {
Engine engine = engineOf(world);
if (engine != null) {
return engine;
try {
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
return access.getEngine();
}
} catch (Throwable ignored) {
}
}
} catch (Throwable ignored) {
@@ -64,35 +57,6 @@ public final class BukkitGuiHost implements GuiHost.Provider {
return null;
}
@Override
public Engine findEngine(UUID worldId) {
World world = worldId == null ? null : Bukkit.getWorld(worldId);
return world == null ? null : engineOf(world);
}
private static Engine findStudioEngine() {
try {
StudioSVC studio = Iris.service(StudioSVC.class);
IrisProject project = studio == null || !studio.isProjectOpen() ? null : studio.getActiveProject();
PlatformChunkGenerator provider = project == null ? null : project.getActiveProvider();
Engine engine = provider == null ? null : provider.getEngine();
return engine == null || engine.isClosed() ? null : engine;
} catch (Throwable ignored) {
return null;
}
}
private static Engine engineOf(World world) {
try {
PlatformChunkGenerator access = IrisToolbelt.access(world);
if (access != null && access.getEngine() != null && !access.getEngine().isClosed()) {
return access.getEngine();
}
} catch (Throwable ignored) {
}
return null;
}
@Override
public void registerHotloadHook(Runnable onHotload) {
Listener listener = new HotloadListener(onHotload);
@@ -18,19 +18,23 @@
package art.arcane.iris.studio.view;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.task.J;
import art.arcane.volmlib.util.format.Form;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.LivingEntity;
import org.bukkit.entity.Player;
import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -52,7 +56,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
private final AtomicLong teleportSequence = new AtomicLong();
private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>();
private volatile List<GuiMarker> playerMarkers = List.of();
private volatile List<WorldMarker> playerMarkers = List.of();
public BukkitVisionOverlay(Engine engine, UUID openerId) {
this.engine = engine;
@@ -64,7 +68,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
* built by a server thread.
*/
@Override
public List<GuiMarker> players() {
public List<WorldMarker> players() {
queuePlayerRefresh();
return playerMarkers;
}
@@ -74,48 +78,26 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return;
}
boolean scheduled = J.runGlobal(this::refreshPlayers);
boolean scheduled = J.runGlobal(() -> {
try {
List<WorldMarker> markers = new ArrayList<>();
for (Player player : BukkitWorldBinding.players(engine.getWorld())) {
Location at = player.getLocation();
markers.add(WorldMarker.player(player.getName(), at.getX(), at.getZ()));
}
playerMarkers = List.copyOf(markers);
} finally {
playerRefreshQueued.set(false);
}
});
if (!scheduled) {
playerRefreshQueued.set(false);
}
}
private void refreshPlayers() {
World world = BukkitWorldBinding.world(engine.getWorld());
List<Player> players = BukkitWorldBinding.players(engine.getWorld());
if (players.isEmpty() || world == null) {
playerMarkers = List.of();
playerRefreshQueued.set(false);
return;
}
List<GuiMarker> markers = Collections.synchronizedList(new ArrayList<>(players.size()));
AtomicInteger pending = new AtomicInteger(players.size());
for (Player player : players) {
AtomicBoolean completed = new AtomicBoolean();
Runnable complete = () -> {
if (completed.compareAndSet(false, true) && pending.decrementAndGet() == 0) {
playerMarkers = List.copyOf(markers);
playerRefreshQueued.set(false);
}
};
if (!J.runEntity(player, () -> {
try {
if (player.isOnline() && player.getWorld() == world) {
Location at = player.getLocation();
markers.add(GuiMarker.player(player.getName(), at.getX(), at.getZ(), at.getYaw()));
}
} finally {
complete.run();
}
}, 0, complete)) {
complete.run();
}
}
}
@Override
public void requestEntities(Consumer<List<GuiMarker>> sink) {
public void requestEntities(Consumer<List<WorldMarker>> sink) {
J.runGlobal(() -> {
IrisWorld target = engine.getWorld();
World world = BukkitWorldBinding.world(target);
@@ -136,7 +118,7 @@ public final class BukkitVisionOverlay implements GuiOverlay {
return;
}
List<GuiMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
List<WorldMarker> collected = Collections.synchronizedList(new ArrayList<>(living.size()));
AtomicInteger pending = new AtomicInteger(living.size());
Runnable complete = () -> {
if (pending.decrementAndGet() == 0) {
@@ -162,22 +144,22 @@ public final class BukkitVisionOverlay implements GuiOverlay {
});
}
private GuiMarker marker(LivingEntity entity, Location at) {
private WorldMarker marker(LivingEntity entity, Location at) {
String label = Form.capitalizeWords(entity.getType().name().toLowerCase(Locale.ROOT).replaceAll("\\Q_\\E", " "));
double maxHealth = 0;
try {
maxHealth = entity.getAttribute(MAX_HEALTH).getValue();
} catch (Throwable ignored) {
}
return GuiMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
return WorldMarker.entity(label, at.getX(), at.getY(), at.getZ(), entity.getHealth(), maxHealth);
}
@Override
public void teleport(double worldX, double worldZ) {
VisionTeleportRequest request = new VisionTeleportRequest(
teleportSequence.incrementAndGet(),
(int) StrictMath.floor(worldX),
(int) StrictMath.floor(worldZ));
VisionGUI.floorWorldCoordinate(worldX),
VisionGUI.floorWorldCoordinate(worldZ));
latestTeleport.set(request);
startTeleport(request);
}
@@ -230,46 +212,6 @@ public final class BukkitVisionOverlay implements GuiOverlay {
}
}
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
AtomicBoolean completed = new AtomicBoolean();
Consumer<OpenerPosition> complete = position -> {
if (completed.compareAndSet(false, true)) {
sink.accept(position);
}
};
if (openerId == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
boolean scheduled = J.runGlobal(() -> {
Player player = Bukkit.getPlayer(openerId);
if (player == null) {
complete.accept(OpenerPosition.OFFLINE);
return;
}
Runnable retired = () -> complete.accept(OpenerPosition.OFFLINE);
if (!J.runEntity(player, () -> complete.accept(positionOf(player,
BukkitWorldBinding.world(engine.getWorld()))), 0, retired)) {
retired.run();
}
});
if (!scheduled) {
complete.accept(OpenerPosition.OFFLINE);
}
}
private static OpenerPosition positionOf(Player player, World world) {
if (!player.isOnline()) {
return OpenerPosition.OFFLINE;
}
if (world == null || player.getWorld() != world) {
return OpenerPosition.IN_OTHER_WORLD;
}
Location at = player.getLocation();
return OpenerPosition.inWorld(at.getX(), at.getY(), at.getZ());
}
private Player selectPlayer(List<Player> players) {
if (openerId == null) {
return players.isEmpty() ? null : players.get(0);
@@ -374,6 +316,21 @@ public final class BukkitVisionOverlay implements GuiOverlay {
+ blockX + "," + blockZ + ".", failure);
}
@Override
public String openInEditor(double worldX, double worldZ, RenderType type) {
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
default -> null;
};
return file == null ? null : file.getName();
}
private static final class VisionTeleportRequest {
private final long sequence;
private final int blockX;
@@ -1,7 +1,7 @@
package art.arcane.iris.command;
import art.arcane.iris.Iris;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.localization.IrisLanguage;
@@ -22,7 +22,6 @@ import java.util.OptionalLong;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -46,7 +45,7 @@ public class CommandFindHydrologyTest {
@Test
public void worldChangeDuringSearchDoesNotLoadOrTeleportIntoTheNewWorld() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("river", true);
fixture.command.river("surface", true);
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.search.get().run();
@@ -59,7 +58,7 @@ public class CommandFindHydrologyTest {
@Test
public void disconnectDuringSearchDoesNotLoadTheDestination() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("river", true);
fixture.command.river("surface", true);
when(fixture.player.isOnline()).thenReturn(false);
fixture.search.get().run();
@@ -70,7 +69,7 @@ public class CommandFindHydrologyTest {
@Test
public void worldChangeWhileLoadingTheDestinationCancelsTeleport() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("river", true);
fixture.command.river("surface", true);
fixture.search.get().run();
when(fixture.player.getWorld()).thenReturn(mock(World.class));
fixture.chunk.complete(mock(Chunk.class));
@@ -83,7 +82,7 @@ public class CommandFindHydrologyTest {
@Test
public void successIsReportedOnlyAfterTeleportCompletes() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("river", true);
fixture.command.river("surface", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
@@ -96,7 +95,7 @@ public class CommandFindHydrologyTest {
@Test
public void rejectedTeleportReportsFailureWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("river", true);
fixture.command.river("surface", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.complete(false);
@@ -110,7 +109,7 @@ public class CommandFindHydrologyTest {
public void failedTeleportReportsTheFullCauseWithoutSuccess() {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("destination unloaded");
fixture.command.river("river", true);
fixture.command.river("surface", true);
fixture.search.get().run();
fixture.chunk.complete(mock(Chunk.class));
fixture.teleport.completeExceptionally(failure);
@@ -121,72 +120,6 @@ public class CommandFindHydrologyTest {
}
}
@Test
public void everyFeatureTypeIsSearchedOffTheCommandThreadAtTheRuntimeRadius() {
for (HydrologyFeatureType type : HydrologyFeatureType.values()) {
try (Fixture fixture = new Fixture()) {
fixture.command.river(type.key(), false);
fixture.queries.verify(() -> GenerationSemanticQueries.nearestRiver(any(), any(), anyInt(), anyInt(), anyInt()), never());
fixture.search.get().run();
fixture.queries.verify(() -> GenerationSemanticQueries.nearestRiver(fixture.engine, type, 0, 0,
GenerationSemanticQueries.RIVER_SEARCH_DISTANCE));
verify(fixture.sender).sendMessage(contains("Found " + type.key().replace('_', ' ') + " at 32, 63, 32."));
}
}
}
@Test
public void unknownTypeIsRejectedWithTheAcceptedTypesBeforeAnySearch() {
try (Fixture fixture = new Fixture()) {
fixture.command.river("surface", true);
verify(fixture.sender).sendMessage(contains("Use one of: stream, river, major_river, cascade, waterfall, mouth, pond, lake, dam, sea_inlet, cave_pool."));
assertNull(fixture.search.get());
}
}
@Test
public void missingCavePoolIsReportedAsNotFoundInGeneratedChunks() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.empty());
fixture.command.river("cave_pool", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("Searching generated chunks for the nearest cave pool within 7168 blocks"));
verify(fixture.sender).sendMessage(contains("No cave pool found in generated chunks within 7168 blocks."));
}
}
@Test
public void missingFeatureIsReportedAsNotFound() {
try (Fixture fixture = new Fixture()) {
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenReturn(Optional.empty());
fixture.command.river("sea_inlet", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("No sea inlet found within 7168 blocks."));
fixture.platform.verify(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true)), never());
}
}
@Test
public void plannerFailureIsReportedAsAnErrorNotAsNotFound() {
try (Fixture fixture = new Fixture()) {
IllegalStateException failure = new IllegalStateException("window planner exploded");
fixture.queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(fixture.engine), any(), eq(0), eq(0), anyInt()))
.thenThrow(failure);
fixture.command.river("lake", true);
fixture.search.get().run();
verify(fixture.sender).sendMessage(contains("Could not search for lake: IllegalStateException - window planner exploded"));
verify(fixture.sender, never()).sendMessage(contains("No lake found"));
fixture.logging.verify(() -> Iris.reportError(any(String.class), eq(failure)));
}
}
private static final class Fixture implements AutoCloseable {
private final CommandFind command = mock(CommandFind.class, CALLS_REAL_METHODS);
private final Engine engine = mock(Engine.class);
@@ -199,7 +132,7 @@ public class CommandFindHydrologyTest {
private final CompletableFuture<Boolean> teleport = new CompletableFuture<>();
private final MockedStatic<J> scheduler = mockStatic(J.class);
private final MockedStatic<BukkitPlatform> platform = mockStatic(BukkitPlatform.class);
private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class, CALLS_REAL_METHODS);
private final MockedStatic<GenerationSemanticQueries> queries = mockStatic(GenerationSemanticQueries.class);
private final MockedStatic<IrisLanguage> language = mockStatic(IrisLanguage.class, invocation -> "teleported");
private final MockedStatic<Iris> logging = mockStatic(Iris.class);
@@ -227,10 +160,10 @@ public class CommandFindHydrologyTest {
});
platform.when(() -> BukkitPlatform.chunkAtAsync(any(World.class), anyInt(), anyInt(), eq(true))).thenReturn(chunk);
platform.when(() -> BukkitPlatform.teleportAsync(eq(player), any(Location.class))).thenReturn(teleport);
queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt()))
queries.when(() -> GenerationSemanticQueries.nearestRiver(eq(engine), any(), eq(0), eq(0), anyInt(), any()))
.thenReturn(Optional.of(new GenerationSemanticQueries.RiverResult(
GenerationSemanticQueries.RiverSource.ACTIVE_PREDICTION,
HydrologyFeatureType.RIVER, 1L, 32, 63, 32, OptionalLong.empty())));
HydrologyFeatureType.RIFFLE, Optional.empty(), 1L, 32, 63, 32, OptionalLong.empty())));
}
@Override
@@ -20,26 +20,26 @@ import static org.mockito.Mockito.when;
public class CommandObjectFluidHeightTest {
@Test
public void absoluteObjectPlacementShiftsTheColumnWaterHeadFromNegativeMinY() {
public void absoluteObjectPlacementShiftsTheColumnRiverHeadFromNegativeMinY() {
World world = mock(World.class);
Engine engine = mock(Engine.class);
IrisDimension dimension = mock(IrisDimension.class);
IrisComplex complex = mock(IrisComplex.class);
@SuppressWarnings("unchecked")
ProceduralStream<Double> waterHead = mock(ProceduralStream.class);
ProceduralStream<Double> riverHead = mock(ProceduralStream.class);
Map<Block, BlockData> future = new HashMap<>();
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getDimension()).thenReturn(dimension);
when(engine.getComplex()).thenReturn(complex);
when(dimension.getFluidHeight()).thenReturn(127);
when(complex.getWaterHeadStream()).thenReturn(waterHead);
when(waterHead.get(12, -7)).thenReturn(131D);
when(complex.getRiverWaterSurfaceStream()).thenReturn(riverHead);
when(riverHead.get(12, -7)).thenReturn(131D);
IObjectPlacer placer = CommandObject.createPlacer(world, future, engine);
assertEquals(63, placer.getFluidHeight());
assertEquals(67, placer.getFluidHeight(12, -7));
verify(waterHead).get(12, -7);
verify(riverHead).get(12, -7);
}
}
@@ -1,99 +0,0 @@
package art.arcane.iris.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.plugin.VolmitSender;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.world.IrisToolbelt;
import art.arcane.iris.world.task.J;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class StudioMapPositionTest {
@Test
public void mapCapturesItsStartingPositionOnThePlayerScheduler() {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
IrisSettings.settings.getGui().setUseServerLaunchedGuis(true);
World world = mock(World.class);
Player player = mock(Player.class);
VolmitSender sender = mock(VolmitSender.class);
PlatformChunkGenerator generator = mock(PlatformChunkGenerator.class);
Engine engine = mock(Engine.class);
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
AtomicBoolean owned = new AtomicBoolean();
AtomicReference<Runnable> task = new AtomicReference<>();
AtomicReference<PaletteExplorerStart> start = new AtomicReference<>();
when(world.getUID()).thenReturn(worldId);
when(player.getUniqueId()).thenReturn(playerId);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenAnswer(invocation -> {
assertTrue("world read must use the player scheduler", owned.get());
return world;
});
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read must use the player scheduler", owned.get());
return new Location(world, 33.25D, 91D, -72.5D);
});
when(sender.isPlayer()).thenReturn(true);
when(sender.player()).thenReturn(player);
when(generator.getEngine()).thenReturn(engine);
CommandStudio command = new CommandStudio() {
@Override
public VolmitSender sender() {
return sender;
}
};
try (MockedStatic<GuiHost> host = mockStatic(GuiHost.class);
MockedStatic<IrisToolbelt> toolbelt = mockStatic(IrisToolbelt.class);
MockedStatic<J> scheduling = mockStatic(J.class);
MockedStatic<MapWindow> windows = mockStatic(MapWindow.class)) {
host.when(GuiHost::isAvailable).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.isIrisWorld(world)).thenReturn(true);
toolbelt.when(() -> IrisToolbelt.access(world)).thenReturn(generator);
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task.set(invocation.getArgument(1));
return true;
});
windows.when(() -> MapWindow.openMap(any(PaletteExplorerStart.class))).thenAnswer(invocation -> {
start.set(invocation.getArgument(0));
return null;
});
command.map(world);
assertNull(start.get());
owned.set(true);
task.get().run();
assertEquals(engine, start.get().engine());
assertEquals(playerId, start.get().playerId());
assertEquals(worldId, start.get().worldId());
assertEquals(33.25D, start.get().centerX(), 0D);
assertEquals(-72.5D, start.get().centerZ(), 0D);
} finally {
IrisSettings.settings = previous;
}
}
}
@@ -9,18 +9,16 @@ import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.runtime.BiomeEnvironment;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.InferredType;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.generation.terrain.IrisRegion;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.platform.generation.PlatformChunkGenerator;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.world.history.SavedBiomeUnavailableException;
import art.arcane.volmlib.util.stream.ProceduralStream;
import art.arcane.volmlib.util.stream.interpolation.Interpolated;
import art.arcane.iris.api.terrain.IrisColumnSample;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.platform.bukkit.plugin.IrisService;
import org.bukkit.World;
import org.bukkit.generator.ChunkGenerator;
@@ -28,7 +26,6 @@ import org.junit.Test;
import org.mockito.MockedStatic;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
@@ -115,32 +112,10 @@ public class IrisTerrainSVCTest {
}
@Test
public void waterRolesMapToThePublicRiverFields() {
for (WaterRole role : WaterRole.values()) {
assertEquals(role.name(), role.wet() ? IrisRiverState.WET : IrisRiverState.NONE, IrisTerrainSVC.riverState(role));
double distance = switch (role) {
case NONE -> IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> 0D;
case SHORE, POND_SHORE -> 1D;
case BANK, DAM -> 2D;
};
assertEquals(role.name(), distance, IrisTerrainSVC.riverDistance(role), 0D);
int flow = switch (role) {
case NONE, SHORE, POND_SHORE, BANK, DAM -> IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
case CHANNEL, MOUTH, CASCADE, WATERFALL -> 1;
case POND, INLET -> 0;
};
assertEquals(role.name(), flow, IrisTerrainSVC.riverFlow(role));
}
}
@Test
public void columnSamplesReportTheWaterRoleOfEachColumn() throws Exception {
assertEquals(List.of("RIVER", "WET", "0.0", "1", "6"), sampled(WaterRole.CHANNEL, 70));
assertEquals(List.of("RIVER", "WET", "0.0", "0", "6"), sampled(WaterRole.POND, 70));
assertEquals(List.of("RIVER_SHORE", "NONE", "1.0", "-1", "unavailable"), sampled(WaterRole.SHORE, 50));
assertEquals(List.of("LAND", "NONE", "2.0", "-1", "unavailable"), sampled(WaterRole.BANK, 50));
assertEquals(List.of("LAND", "NONE", "NaN", "-1", "unavailable"), sampled(WaterRole.NONE, 50));
public void acceptedHydrologyMapsToThePublicDiagnosticStates() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(null));
assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(true)));
assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false)));
}
@Test
@@ -244,33 +219,6 @@ public class IrisTerrainSVCTest {
}
}
private static List<String> sampled(WaterRole role, int head) throws Exception {
Engine engine = mock(Engine.class);
IrisComplex complex = mock(IrisComplex.class);
IrisBiome biome = mock(IrisBiome.class);
when(biome.getInferredType()).thenReturn(InferredType.LAND);
when(engine.getComplex()).thenReturn(complex);
when(engine.getDimension()).thenReturn(mock(IrisDimension.class));
when(engine.getHeight(0, 0)).thenReturn(60);
when(engine.getMinHeight()).thenReturn(-64);
when(engine.getSurfaceBiome(0, 0)).thenReturn(biome);
when(complex.getWaterRoleStream()).thenReturn(ProceduralStream.of((x, z) -> role, Interpolated.of(value -> 0D, value -> role)));
when(complex.getWaterHeadStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> (double) head));
IrisColumnQuery query = IrisColumnQuery.rect(0, 0, 0, 0, 1, EnumSet.of(IrisColumnField.SURFACE_KIND,
IrisColumnField.RIVER_STATE, IrisColumnField.RIVER_DISTANCE, IrisColumnField.RIVER_FLOW,
IrisColumnField.RIVER_WATER_SURFACE_Y));
List<IrisColumnSample> samples = new ArrayList<>();
try (MockedStatic<IrisSettings> settings = mockStatic(IrisSettings.class)) {
settings.when(IrisSettings::get).thenReturn(new IrisSettings());
assertTrue(enabledService().sampleColumns(world(engine), query, samples::add));
}
assertEquals(1, samples.size());
IrisColumnSample sample = samples.get(0);
return List.of(sample.surfaceKind().name(), sample.riverState().name(), String.valueOf(sample.riverDistance()),
String.valueOf(sample.riverFlow()),
sample.riverWaterSurfaceY() == IrisColumnSample.UNAVAILABLE_HEIGHT ? "unavailable" : String.valueOf(sample.riverWaterSurfaceY()));
}
private static World world(Engine engine) {
World world = mock(World.class);
ChunkGenerator generator = mock(ChunkGenerator.class, withSettings().extraInterfaces(PlatformChunkGenerator.class));
@@ -287,4 +235,41 @@ public class IrisTerrainSVCTest {
((AtomicBoolean) enabled.get(service)).set(true);
return service;
}
private static HydrologyColumnSample river(boolean connectedFluid) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
HydrologyFeatureType.SURFACE_POOL,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
true,
false,
false,
connectedFluid,
false,
false,
true,
connectedFluid,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
}
}
@@ -1,10 +1,15 @@
package art.arcane.iris.platform.bukkit.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.hydrology.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.HydrologyFeatureType;
import art.arcane.iris.generation.terrain.InferredType;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@@ -64,28 +69,98 @@ public class IrisSurfaceClassifierTest {
}
@Test
public void waterRolesOverrideGenericOceanAndLandKinds() {
for (WaterRole role : WaterRole.values()) {
IrisSurfaceKind expected = switch (role) {
case NONE -> IrisSurfaceKind.OCEAN;
case SHORE, POND_SHORE -> IrisSurfaceKind.RIVER_SHORE;
case BANK, DAM -> IrisSurfaceKind.LAND;
case CHANNEL, POND, MOUTH, CASCADE, WATERFALL, INLET -> IrisSurfaceKind.RIVER;
};
assertEquals(role.name(), expected, IrisSurfaceClassifier.classify(60, 63, InferredType.SEA, role));
}
public void acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() {
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
hydrology(HydrologyFeatureType.MOUTH, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify(
64,
63,
InferredType.SHORE,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, true, false)
));
assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, false)
));
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, false, false, false)
));
}
@Test
public void voidClassificationWinsOverWaterRoles() {
for (WaterRole role : WaterRole.values()) {
assertEquals(role.name(), IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(0, 63, InferredType.LAND, role));
}
public void voidClassificationWinsOverAcceptedRiverGeometry() {
assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(
0,
63,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
}
@Test
public void columnsWithoutAWaterRoleKeepTheGenericKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(64, 63, InferredType.LAND, WaterRole.NONE));
assertEquals(IrisSurfaceKind.SHORE, IrisSurfaceClassifier.classify(64, 63, InferredType.SHORE, WaterRole.NONE));
public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
64,
63,
InferredType.LAND,
null
));
}
private static HydrologyColumnSample hydrology(
HydrologyFeatureType type,
boolean channel,
boolean shore,
boolean connectedFluid
) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
type,
2L,
3L,
0,
63,
0,
1,
0,
false
);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
channel,
shore,
!channel && !shore,
connectedFluid,
false,
false,
true,
connectedFluid && channel,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
}
}
@@ -1,165 +0,0 @@
package art.arcane.iris.studio.view;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.world.task.J;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.Test;
import org.mockito.MockedStatic;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
public class BukkitOpenerPositionTest {
@Test
public void playerMarkersAreCapturedOnTheirEntitySchedulers() {
try (Harness harness = new Harness()) {
harness.binding.when(() -> BukkitWorldBinding.players(harness.target)).thenReturn(List.of(harness.player));
when(harness.player.getName()).thenReturn("Explorer");
assertTrue(harness.overlay.players().isEmpty());
harness.runEntity();
List<GuiMarker> markers = harness.overlay.players();
assertEquals(1, markers.size());
assertEquals("Explorer", markers.getFirst().label());
assertEquals(18.5D, markers.getFirst().worldX(), 0D);
assertEquals(-4.25D, markers.getFirst().worldZ(), 0D);
}
}
@Test
public void positionIsCapturedOnlyOnTheOwningEntityTask() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue(harness.results.isEmpty());
harness.runEntity();
assertEquals(List.of(OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void retiredEntityCompletesTheRequestOnce() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
assertTrue("retirement callback must be registered", harness.retired != null);
harness.retired.run();
harness.retired.run();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void disconnectBeforeCaptureCompletesAsOfflineAndReconnectCanRefresh() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.isOnline()).thenReturn(false);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
when(harness.player.isOnline()).thenReturn(true);
harness.overlay.requestOpenerPosition(harness.results::add);
harness.runEntity();
assertEquals(List.of(OpenerPosition.OFFLINE, OpenerPosition.inWorld(18.5D, 72D, -4.25D)), harness.results);
}
}
@Test
public void rejectedEntityTaskCompletesOnceEvenIfRetirementAlreadyRan() {
try (Harness harness = new Harness()) {
harness.reject = true;
harness.overlay.requestOpenerPosition(harness.results::add);
assertEquals(List.of(OpenerPosition.OFFLINE), harness.results);
}
}
@Test
public void changingWorldBeforeCaptureReportsOtherWorld() {
try (Harness harness = new Harness()) {
harness.overlay.requestOpenerPosition(harness.results::add);
when(harness.player.getWorld()).thenReturn(mock(World.class));
harness.runEntity();
assertEquals(List.of(OpenerPosition.IN_OTHER_WORLD), harness.results);
}
}
private static final class Harness implements AutoCloseable {
private final Player player = mock(Player.class);
private final World world = mock(World.class);
private final Engine engine = mock(Engine.class);
private final IrisWorld target = mock(IrisWorld.class);
private final UUID opener = UUID.randomUUID();
private final List<OpenerPosition> results = new ArrayList<>();
private final MockedStatic<J> scheduling = mockStatic(J.class);
private final MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class);
private final MockedStatic<BukkitWorldBinding> binding = mockStatic(BukkitWorldBinding.class);
private final BukkitVisionOverlay overlay = new BukkitVisionOverlay(engine, opener);
private Runnable task;
private Runnable retired;
private boolean entityOwned;
private boolean reject;
private Harness() {
when(engine.getWorld()).thenReturn(target);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenAnswer(invocation -> {
assertTrue("location read outside owning entity task", entityOwned);
return new Location(world, 18.5D, 72D, -4.25D);
});
bukkit.when(() -> Bukkit.getPlayer(opener)).thenReturn(player);
binding.when(() -> BukkitWorldBinding.world(target)).thenReturn(world);
scheduling.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> {
invocation.getArgument(0, Runnable.class).run();
return true;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> {
task = invocation.getArgument(1);
return !reject;
});
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class), eq(0), any(Runnable.class)))
.thenAnswer(invocation -> {
task = invocation.getArgument(1);
retired = invocation.getArgument(3);
if (reject) {
retired.run();
}
return !reject;
});
}
private void runEntity() {
entityOwned = true;
try {
task.run();
} finally {
entityOwned = false;
}
}
@Override
public void close() {
binding.close();
bukkit.close();
scheduling.close();
}
}
}
+2 -8
View File
@@ -131,8 +131,6 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -155,7 +153,7 @@ dependencies {
String modConfiguration = irisModTarget.fabricRemap ? 'modImplementation' : 'implementation'
add(modConfiguration, "net.fabricmc:fabric-loader:${fabricLoaderVersion}")
testImplementation('junit:junit:4.13.2')
testImplementation(libs.mockito.core)
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -218,9 +216,7 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
libs.jaxen
]
shared.each { Object notation ->
['bundle', 'devBundle'].each { String configuration ->
@@ -240,8 +236,6 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
}
+2 -10
View File
@@ -152,16 +152,12 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.named('runBundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -182,7 +178,7 @@ dependencies {
? minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
: minecraft.dependency("iris-${buildLane}", "net.minecraftforge:forge:${forgeVersion}"))
testImplementation('junit:junit:4.13.2')
testImplementation(libs.mockito.core)
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -219,9 +215,7 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
libs.jaxen
]
shared.each { Object notation ->
['bundle', 'devBundle', 'runBundle'].each { String configuration ->
@@ -278,8 +272,6 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
}
@@ -227,7 +227,6 @@ public final class ModdedEngineBootstrap {
failure = runStopStage(failure, "wand service", ModdedWandService::clearAll);
failure = runStopStage(failure, "block break handler", ModdedBlockBreakHandler::clear);
failure = runStopStage(failure, "studio commands", ModdedStudioCommands::clear);
failure = runStopStage(failure, "gui windows", GuiHost::shutdown);
failure = runStopStage(failure, "gui host", ModdedGuiHost::clear);
failure = runStopStage(failure, "services", () -> services().disableAll());
reportStopFailure("stopping", failure);
@@ -25,7 +25,7 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.structure.placement.IrisStructureLocator;
import art.arcane.iris.structure.nativegen.NativeStructureGenerationPolicy;
import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
@@ -61,7 +61,7 @@ final class ModdedCommandSuggestions {
static final SuggestionProvider<NativeCommandSource> OBJECT_KEYS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestObjectKeys(context, builder);
static final SuggestionProvider<NativeCommandSource> STRUCTURE_KEYS = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestStructureKeys(context, builder);
static final SuggestionProvider<NativeCommandSource> POI_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(List.of("buried_treasure"), builder);
static final SuggestionProvider<NativeCommandSource> HYDROLOGY_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> NativeCommandRegistration.suggest(HydrologyFeatureType.keys(), builder);
static final SuggestionProvider<NativeCommandSource> HYDROLOGY_TYPES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestHydrologyTypes(context, builder);
static final SuggestionProvider<NativeCommandSource> PACK_NAMES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestPackNames(context, builder);
static final SuggestionProvider<NativeCommandSource> DIMENSION_NAMES = (CommandContext<NativeCommandSource> context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder);
@@ -87,6 +87,24 @@ final class ModdedCommandSuggestions {
return builder.buildFuture();
}
private static CompletableFuture<Suggestions> suggestHydrologyTypes(
CommandContext<NativeCommandSource> context,
SuggestionsBuilder builder
) {
ModdedCommandFeedback.tab(context.getSource());
try {
Engine engine = IrisModdedCommands.engineFor(context.getSource().world());
if (engine != null && engine.getComplex().getHydrologyRuntime() != null) {
return NativeCommandRegistration.suggest(
engine.getComplex().getHydrologyRuntime().featureQueryKeys(), builder);
}
} catch (Throwable error) {
warnTabFailure("hydrology types", context.getSource(), error);
}
return NativeCommandRegistration.suggest(
HydrologyFeatureQuery.suggestions(List.of()), builder);
}
static Set<String> reachableBiomeKeys(Engine engine) {
return reachableBiomeKeys(GenerationFindCatalog.biomes(engine));
}
@@ -39,7 +39,8 @@ import art.arcane.iris.structure.placement.StructureReachability;
import art.arcane.iris.generation.runtime.WrongEngineBroException;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.iris.world.history.GenerationFindCatalog;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.structure.nativegen.IrisNativeStructureDecision;
import art.arcane.iris.generation.terrain.IrisRegion;
@@ -142,31 +143,39 @@ final class ModdedLocateCommands {
IrisModdedCommands.fail(source, "This dimension is not generated by Iris.");
return 0;
}
HydrologyFeatureType featureType;
IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime();
HydrologyFeatureQuery query;
try {
featureType = HydrologyFeatureType.parse(type);
query = HydrologyFeatureQuery.parse(type);
} catch (IllegalArgumentException error) {
IrisModdedCommands.fail(source, error.getMessage());
return 0;
}
String label = featureType.key().replace('_', ' ');
int originX = player.blockX();
int originZ = player.blockZ();
int maximumDistance = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
int requestedDistance = runtime == null
? 8192
: Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime == null
? requestedDistance
: runtime.maximumFeatureSearchDistance(originX, originZ, requestedDistance);
NativeModdedServer server = source.server();
IrisModdedCommands.ok(source, GenerationSemanticQueries.riverSearchMessage(featureType, maximumDistance));
IrisModdedCommands.ok(source, "Searching accepted hydrology plans for " + type + "...");
MultiBurst.burst.completeValueAsync(() -> GenerationSemanticQueries.nearestRiver(
engine, featureType, originX, originZ, maximumDistance).orElse(null))
engine, query, originX, originZ, maximumDistance,
(int visited) -> server.execute(() -> IrisModdedCommands.ok(source,
"Searched " + visited + " hydrology tiles for " + type + "..."))).orElse(null))
.whenComplete((GenerationSemanticQueries.RiverResult feature, Throwable error) -> server.execute(() -> {
if (error != null) {
Throwable cause = error instanceof CompletionException && error.getCause() != null ? error.getCause() : error;
ModdedIrisLog.error("Hydrology locate failed for {}", featureType.key(), cause);
IrisModdedCommands.fail(source, "Could not search for " + label + ": " + cause.getClass().getSimpleName()
+ (cause.getMessage() == null ? "" : " - " + cause.getMessage()));
ModdedIrisLog.error("Hydrology locate failed for {}", type, error);
IrisModdedCommands.fail(source,
"Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
return;
}
if (feature == null) {
IrisModdedCommands.fail(source, GenerationSemanticQueries.riverNotFoundMessage(featureType, maximumDistance));
IrisModdedCommands.fail(source,
"No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
return;
}
teleportToStructure(
@@ -176,7 +185,7 @@ final class ModdedLocateCommands {
feature.x(),
feature.y() + 2,
feature.z(),
label
type + " hydrology feature"
);
}));
return 1;
@@ -23,7 +23,6 @@ import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime;
import art.arcane.volmlib.nativelib.terrain.NativePregenRuntime.ChunkLoad;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.world.pregen.MantleHeapPressure;
import art.arcane.iris.world.pregen.PregenHydrology;
import art.arcane.iris.world.pregen.PregenListener;
import art.arcane.iris.world.pregen.PregenMantleBackpressure;
import art.arcane.iris.world.pregen.PregeneratorMethod;
@@ -31,7 +30,6 @@ import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.volmlib.util.mantle.runtime.Mantle;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@@ -73,8 +71,6 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
private final PregenMantleBackpressure backpressure;
private final PauseWhenEmptyGuard pauseGuard;
private final LongSupplier clock;
private final AtomicBoolean closing = new AtomicBoolean();
private final PregenHydrology hydrology;
public ModdedPregenMethod(Configuration configuration) {
this(configuration, System::nanoTime);
@@ -84,7 +80,6 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
this.clock = Objects.requireNonNull(clock);
runtime = configuration.runtime();
engine = configuration.engine();
hydrology = new PregenHydrology(() -> engine);
sync = configuration.sync();
pauseGuard = new PauseWhenEmptyGuard(runtime);
IrisSettings.IrisSettingsPregen pregen = IrisSettings.get().getPregen();
@@ -126,8 +121,6 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
@Override
public void close() {
closing.set(true);
hydrology.close();
try {
awaitPendingChunks();
ModdedIrisLog.info("Iris modded pregen closing: dim={} completed={} pending={} peakInFlight={} finalLimit={}",
@@ -348,13 +341,6 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
return false;
}
@Override
public void onRegionsAhead(List<long[]> regions) {
if (!closing.get()) {
hydrology.prepare(regions);
}
}
@Override
public String getMethod(int x, int z) {
return "Modded";
@@ -23,9 +23,10 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldView;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.studio.map.MapWindow;
import art.arcane.iris.studio.map.PaletteExplorerStart;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.iris.studio.view.ImageMapStudioGUI;
import art.arcane.iris.studio.view.NoiseExplorerGUI;
import art.arcane.iris.studio.view.VisionGUI;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.iris.pack.StructurePackageClosure;
import art.arcane.iris.pack.ImageMapPackageClosure;
@@ -37,7 +38,6 @@ import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.biome.IrisBiomeGeneratorLink;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.entity.IrisEntitySpawn;
import art.arcane.iris.world.IrisWorld;
import art.arcane.iris.generation.noise.IrisGenerator;
import art.arcane.iris.pack.PackExportClosure;
import art.arcane.iris.world.entity.IrisEntity;
@@ -247,9 +247,8 @@ public final class ModdedStudioCommands {
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
}
Engine mapEngine = engine != null ? engine : GuiHost.get().findActiveEngine();
if ((generatorKey == null || generatorKey.isBlank()) && mapEngine != null) {
MapWindow.openNoise(mapStart(mapEngine, source.player()), new MapWindow.NoiseSelection(null, null, seed));
if (generatorKey == null || generatorKey.isBlank()) {
NoiseExplorerGUI.launch();
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_ON_SERVER_DISPLAY));
return 1;
}
@@ -263,7 +262,7 @@ public final class ModdedStudioCommands {
return 0;
}
String selectedGeneratorKey = generatorKey.trim();
MapWindow.openNoise(mapStart(engine, source.player()), new MapWindow.NoiseSelection(selectedGeneratorKey, generator, seed));
NoiseExplorerGUI.launchGeneratorKey(selectedGeneratorKey, generator, seed);
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_NOISE_EXPLORER_GENERATOR_SEED, MessageArgument.untrusted("value", generatorKey.trim()), MessageArgument.untrusted("seed", seed)));
return 1;
}
@@ -281,20 +280,11 @@ public final class ModdedStudioCommands {
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
MapWindow.openMap(mapStart(engine, player));
VisionGUI.launch(engine, player == null ? null : player.id());
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, MessageArgument.untrusted("value", level.name())));
return 1;
}
// Modded worlds have no Bukkit world id; the map finds the engine again through the bound GUI context.
private static PaletteExplorerStart mapStart(Engine engine, NativeProtocolPlayer player) {
IrisWorld world = engine.getWorld();
NativeWorld level = world == null ? null : world.platformWorld();
boolean inWorld = player != null && player.connected() && level != null && player.inWorld(level);
return new PaletteExplorerStart(engine, null, player == null ? null : player.id(), null,
inWorld ? player.x() : 0D, inWorld ? player.z() : 0D);
}
private static int imageMap(NativeCommandSource source) {
NativeWorld level = source.world();
Engine engine = IrisModdedCommands.engineFor(level);
@@ -304,13 +294,13 @@ public final class ModdedStudioCommands {
));
return 0;
}
if (!GuiHost.isAvailable() || !IrisSettings.get().getGui().isUseServerLaunchedGuis()) {
if (!ImageMapStudioGUI.isAvailable()) {
IrisModdedCommands.fail(source, guiUnavailableMessage());
return 0;
}
NativeProtocolPlayer player = source.player();
ModdedGuiHost.bindContext(worldView(source.server(), level), engine, player == null ? null : player.id());
MapWindow.openImageMaps(mapStart(engine, player));
ImageMapStudioGUI.launch(engine);
IrisModdedCommands.ok(source, IrisLanguage.plain(
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_IMAGE_MAP_STUDIO_ON_SERVER_DISPLAY,
MessageArgument.untrusted("value", level.name())
@@ -18,22 +18,21 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.studio.view.GuiMarker;
import art.arcane.iris.studio.view.GuiHost;
import art.arcane.volmlib.nativelib.view.WorldMarker;
import art.arcane.iris.studio.view.GuiOverlay;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.render.RenderType;
import art.arcane.iris.modded.ModdedIrisLog;
import art.arcane.iris.modded.ModdedTeleportDeadline;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.awt.Desktop;
import java.io.File;
import java.util.List;
import java.util.Optional;
import art.arcane.volmlib.nativelib.view.WorldView;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.Consumer;
public final class ModdedVisionOverlay implements GuiOverlay {
@@ -47,33 +46,14 @@ public final class ModdedVisionOverlay implements GuiOverlay {
opener = context.opener();
}
// WorldView doesn't report which way a player faces, so their markers all face south.
@Override
public List<GuiMarker> players() {
return world.players().stream()
.map(player -> GuiMarker.player(player.label(), player.worldX(), player.worldZ(), 0F))
.toList();
public List<WorldMarker> players() {
return world.players();
}
@Override
public void requestEntities(Consumer<List<GuiMarker>> sink) {
world.requestEntities(entities -> sink.accept(entities.stream()
.map(entity -> GuiMarker.entity(entity.label(), entity.worldX(), entity.worldY(), entity.worldZ(),
entity.health(), entity.maxHealth()))
.toList()));
}
@Override
public void requestOpenerPosition(Consumer<OpenerPosition> sink) {
if (opener == null) {
sink.accept(OpenerPosition.OFFLINE);
return;
}
try {
world.execute(() -> sink.accept(openerPosition()));
} catch (RejectedExecutionException failure) {
sink.accept(OpenerPosition.OFFLINE);
}
public void requestEntities(Consumer<List<WorldMarker>> sink) {
world.requestEntities(sink);
}
@Override
@@ -100,25 +80,23 @@ public final class ModdedVisionOverlay implements GuiOverlay {
});
}
private OpenerPosition openerPosition() {
IrisWorld target = engine.getWorld();
if (target == null || engine.isClosing() || engine.isClosed()) {
return OpenerPosition.OFFLINE;
@Override
public String openInEditor(double worldX, double worldZ, RenderType type) {
if (!GuiHost.isAvailable() || !Desktop.isDesktopSupported()) {
return null;
}
NativeWorld level = target.platformWorld();
if (level == null) {
return OpenerPosition.OFFLINE;
}
NativeProtocolPlayer player = NativeModdedServer.forWorld(level).player(opener);
if (player == null || !player.connected()) {
return OpenerPosition.OFFLINE;
}
if (!player.inWorld(level)) {
return OpenerPosition.IN_OTHER_WORLD;
}
return OpenerPosition.inWorld(player.x(), player.y(), player.z());
IrisComplex complex = engine.getComplex();
File file = switch (type) {
case BIOME, LAYER_LOAD, DECORATOR_LOAD, OBJECT_LOAD, HEIGHT, RIVER ->
complex.getTrueBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_LAND -> complex.getLandBiomeStream().get(worldX, worldZ).openInVSCode();
case BIOME_SEA -> complex.getSeaBiomeStream().get(worldX, worldZ).openInVSCode();
case REGION -> complex.getRegionStream().get(worldX, worldZ).openInVSCode();
case CAVE_LAND -> complex.getCaveBiomeStream().get(worldX, worldZ).openInVSCode();
default -> null;
};
return file == null ? null : file.getName();
}
public record Context(WorldView world, Engine engine, UUID opener) {
}
@@ -1,149 +0,0 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureRef;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.terrain.IrisDimension;
import art.arcane.iris.world.history.GenerationSemanticQueries;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeCommandSource;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeStructureQueries;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyDouble;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
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 ModdedLocateRiverTest {
private static final int DISTANCE = GenerationSemanticQueries.RIVER_SEARCH_DISTANCE;
@Test
public void unknownTypeFailsBeforeSearching() {
Fixture fixture = new Fixture();
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(0, ModdedLocateCommands.gotoRiver(fixture.source, "ocean_trench"));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()));
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), never());
verify(fixture.server, never()).execute(any());
verify(fixture.runtime, never()).nearestFeature(anyInt(), anyInt(), any(), anyInt(), any());
}
}
@Test
public void missingFeatureReportsNotFound() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any())).thenReturn(Optional.empty());
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "river"));
fixture.finishOnServerThread();
commands.verify(() -> IrisModdedCommands.fail(fixture.source, "No river found within " + DISTANCE + " blocks."));
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
}
}
@Test
public void plannerFailureIsReportedAsAnErrorNotAsNotFound() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any()))
.thenThrow(new IllegalStateException("window planner exploded"));
try (MockedStatic<IrisModdedCommands> commands = fixture.commands()) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "major_river"));
fixture.finishOnServerThread();
commands.verify(() -> IrisModdedCommands.fail(fixture.source,
"Could not search for major river: IllegalStateException - window planner exploded"));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), eq("No major river found within " + DISTANCE + " blocks.")),
never());
verify(fixture.player, never()).teleport(any(), anyDouble(), anyDouble(), anyDouble());
}
}
@Test
public void foundFeatureTeleportsAboveItFromThePlayersPosition() throws Exception {
Fixture fixture = new Fixture();
when(fixture.runtime.nearestFeature(anyInt(), anyInt(), any(), anyInt(), any()))
.thenReturn(Optional.of(new HydrologyFeatureRef(9L, HydrologyFeatureType.WATERFALL, 400, 130, -250)));
when(fixture.player.teleport(fixture.world, 400.5D, 68D, -249.5D)).thenReturn(true);
try (MockedStatic<IrisModdedCommands> commands = fixture.commands();
MockedConstruction<NativeStructureQueries> queries = mockConstruction(NativeStructureQueries.class)) {
assertEquals(1, ModdedLocateCommands.gotoRiver(fixture.source, "waterfall"));
fixture.finishOnServerThread();
ArgumentCaptor<HydrologyFeatureQuery> query = ArgumentCaptor.forClass(HydrologyFeatureQuery.class);
verify(fixture.runtime).nearestFeature(eq(120), eq(-75), query.capture(), eq(DISTANCE), any());
assertEquals(HydrologyFeatureQuery.of(HydrologyFeatureType.WATERFALL), query.getValue());
verify(queries.constructed().getFirst()).loadChunk(400 >> 4, -250 >> 4);
verify(fixture.player).teleport(fixture.world, 400.5D, 68D, -249.5D);
commands.verify(() -> IrisModdedCommands.ok(eq(fixture.source), anyString()), times(2));
commands.verify(() -> IrisModdedCommands.fail(eq(fixture.source), anyString()), never());
}
}
private static final class Fixture {
private final NativeCommandSource source = mock(NativeCommandSource.class);
private final NativeWorld world = mock(NativeWorld.class);
private final NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
private final NativeModdedServer server = mock(NativeModdedServer.class);
private final Engine engine = mock(Engine.class);
private final HydrologyRuntime runtime = mock(HydrologyRuntime.class);
private final CompletableFuture<Runnable> completion = new CompletableFuture<>();
private Fixture() {
IrisComplex complex = mock(IrisComplex.class);
IrisDimension dimension = mock(IrisDimension.class);
when(source.player()).thenReturn(player);
when(source.world()).thenReturn(world);
when(source.server()).thenReturn(server);
when(player.blockX()).thenReturn(120);
when(player.blockZ()).thenReturn(-75);
when(player.connected()).thenReturn(true);
when(player.inWorld(world)).thenReturn(true);
when(world.minHeight()).thenReturn(-64);
when(world.maxHeight()).thenReturn(320);
when(engine.getComplex()).thenReturn(complex);
when(engine.getDimension()).thenReturn(dimension);
when(dimension.getMinHeight()).thenReturn(-64);
when(complex.getHydrologyRuntime()).thenReturn(runtime);
when(runtime.enabled()).thenReturn(true);
doAnswer(invocation -> {
completion.complete(invocation.getArgument(0));
return null;
}).when(server).execute(any());
}
private MockedStatic<IrisModdedCommands> commands() {
MockedStatic<IrisModdedCommands> commands = mockStatic(IrisModdedCommands.class);
commands.when(() -> IrisModdedCommands.engineFor(world)).thenReturn(engine);
return commands;
}
private void finishOnServerThread() throws Exception {
completion.get(10L, TimeUnit.SECONDS).run();
}
}
}
@@ -1,7 +1,6 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.configuration.IrisSettings;
import art.arcane.iris.generation.hydrology.engine.HydrologyPregeneration;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.modded.ModdedGenPool;
import art.arcane.iris.world.pregen.MantleHeapPressure;
@@ -12,10 +11,8 @@ import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InOrder;
import org.mockito.MockedStatic;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
@@ -34,11 +31,9 @@ import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
@@ -94,25 +89,6 @@ public class ModdedPregenMethodLifecycleTest {
verify(runtime, never()).execute(any());
}
@Test
public void closeWithdrawsQueuedHydrologyPrefetchAndRefusesLaterAnnouncements() {
HydrologyPregeneration epoch = mock(HydrologyPregeneration.class);
when(engine.getComplex().getHydrologyRuntime().openPregeneration()).thenReturn(epoch);
when(runtime.isServerThread()).thenReturn(true);
assertTrue(method.deferFinalSaveToServerThread());
List<long[]> ahead = List.of(new long[]{0L, 0L}, new long[]{1L, 0L});
method.onRegionsAhead(ahead);
method.close();
method.onRegionsAhead(ahead);
InOrder order = inOrder(epoch);
order.verify(epoch).prepare(ahead);
order.verify(epoch).close();
verify(epoch, times(1)).prepare(anyList());
method.completeDeferredFinalSave();
}
@Test
public void asynchronousCallerCannotClaimTheShutdownSave() {
assertFalse(method.deferFinalSaveToServerThread());
@@ -1,98 +1,25 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.studio.view.OpenerPosition;
import art.arcane.iris.world.IrisWorld;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
import art.arcane.volmlib.nativelib.view.WorldView;
import org.junit.Test;
import org.mockito.MockedStatic;
import org.mockito.invocation.InvocationOnMock;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
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 ModdedVisionOverlayTest {
@Test
public void rejectedServerTaskCompletesThePositionRequestAsOffline() {
WorldView world = mock(WorldView.class);
doThrow(new RejectedExecutionException("Server stopped")).when(world).execute(any(Runnable.class));
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world,
mock(Engine.class), UUID.randomUUID()));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
overlay.requestOpenerPosition(position::set);
assertEquals(OpenerPosition.OFFLINE, position.get());
}
@Test
public void openerPositionRunsOnTheServerAndTracksDisconnectAndWorldChanges() {
WorldView world = mock(WorldView.class);
AtomicReference<Runnable> scheduled = new AtomicReference<>();
doAnswer(invocation -> {
scheduled.set(invocation.getArgument(0));
return null;
}).when(world).execute(any(Runnable.class));
Engine engine = mock(Engine.class);
IrisWorld irisWorld = mock(IrisWorld.class);
NativeWorld level = mock(NativeWorld.class);
NativeModdedServer server = mock(NativeModdedServer.class);
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
UUID opener = UUID.randomUUID();
when(engine.getWorld()).thenReturn(irisWorld);
when(irisWorld.platformWorld()).thenReturn(level);
when(server.player(opener)).thenReturn(player);
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(true);
when(player.x()).thenReturn(21.5D);
when(player.y()).thenReturn(85.25D);
when(player.z()).thenReturn(-39.5D);
ModdedVisionOverlay overlay = new ModdedVisionOverlay(new ModdedVisionOverlay.Context(world, engine, opener));
AtomicReference<OpenerPosition> position = new AtomicReference<>();
try (MockedStatic<NativeModdedServer> nativeServer = mockStatic(NativeModdedServer.class)) {
nativeServer.when(() -> NativeModdedServer.forWorld(level)).thenReturn(server);
overlay.requestOpenerPosition(position::set);
assertNull("position reads must wait for the server task", position.get());
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
overlay.requestOpenerPosition(position::set);
when(player.connected()).thenReturn(false);
scheduled.get().run();
assertEquals(OpenerPosition.OFFLINE, position.get());
when(player.connected()).thenReturn(true);
when(player.inWorld(level)).thenReturn(false);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.IN_OTHER_WORLD, position.get());
when(player.inWorld(level)).thenReturn(true);
overlay.requestOpenerPosition(position::set);
scheduled.get().run();
assertEquals(OpenerPosition.inWorld(21.5D, 85.25D, -39.5D), position.get());
}
}
/**
* A Vision click can land anywhere in the world, so the destination chunk is usually cold and gets the same
* bounded deadline as /iris tp.
+2 -8
View File
@@ -132,8 +132,6 @@ configurations.named('bundle').configure {
exclude(group: 'de.crazydev22.slimjar.helper')
exclude(group: 'de.crazydev22.slimjar')
exclude(group: 'io.github.slimjar')
// The game ships its own LWJGL; without the modules it leaves out, the map's 3D views draw in software.
exclude(group: 'org.lwjgl')
}
configurations.compileClasspath.extendsFrom(configurations.devBundle)
@@ -143,7 +141,7 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies {
testImplementation('junit:junit:4.13.2')
testImplementation(libs.mockito.core)
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(testFixtures('art.arcane:core'))
testImplementation(libs.spigot) {
transitive = false
@@ -175,9 +173,7 @@ dependencies {
libs.caffeine,
'com.fulcrumgenomics:jlibdeflate:0.2.0',
libs.dom4j,
libs.jaxen,
libs.flatlaf,
libs.flatlaf.extras
libs.jaxen
]
add('bundle', libs.asm)
add('devBundle', 'org.ow2.asm:asm') {
@@ -265,8 +261,6 @@ tasks.named('compileJava', JavaCompile).configure {
options.release.set((int) irisModTarget.java)
}
apply from: file('../../gradle/mockito-agent.gradle')
tasks.named('test').configure {
useJUnitPlatform()
classpath = classpath.minus(developmentModLibraries).plus(sourceSets.modLibraries.output)
+1 -4
View File
@@ -127,7 +127,7 @@ nmsBindings.each { String key ->
compileOnly(rootProject.libs.annotations)
compileOnly(rootProject.libs.byteBuddy.core)
testImplementation('junit:junit:4.13.2')
testImplementation(rootProject.libs.mockito.core)
testImplementation('org.mockito:mockito-core:5.23.0')
if (key == 'v26_3_R1') {
testImplementation(project(':probe'))
}
@@ -586,14 +586,11 @@ allprojects {
layout.buildDirectory.set(layout.projectDirectory.dir("build/${runtimeOutputVariant}"))
}
apply from: rootProject.file('gradle/mockito-agent.gradle')
tasks.withType(Test).configureEach {
int moduleForks = project.path.startsWith(':adapters:bukkit:') ? Math.min(2, testForks)
: project.path == ':probe' ? Math.min(4, testForks) : testForks
maxParallelForks = Math.min(gradle.startParameter.maxWorkerCount, moduleForks)
jvmArgs('-XX:ParallelGCThreads=2', '-XX:ConcGCThreads=1', '-XX:CICompilerCount=2')
timeout = java.time.Duration.ofMinutes(60)
}
java {
+3 -13
View File
@@ -136,13 +136,6 @@ dependencies {
implementation('com.moandjiezana.toml:toml4j:0.7.2') {
transitive = false
}
// Shaded: the runtime downloader can neither fetch the natives classifiers nor relocate lwjgl's own jar.
implementation(libs.lwjgl)
implementation(libs.lwjgl.opengl)
implementation(libs.lwjgl.egl)
implementation(libs.lwjgl.jawt)
runtimeOnly(variantOf(libs.lwjgl) { classifier('natives-linux') })
runtimeOnly(variantOf(libs.lwjgl.opengl) { classifier('natives-linux') })
// Dynamically Loaded
slim(libs.gson)
slim(libs.lru)
@@ -168,15 +161,13 @@ dependencies {
slim(libs.byteBuddy.agent)
slim(libs.dom4j)
slim(libs.jaxen)
slim(libs.flatlaf)
slim(libs.flatlaf.extras)
testFixturesImplementation('junit:junit:4.13.2')
testFixturesImplementation(libs.mockito.core)
testFixturesImplementation('org.mockito:mockito-core:5.23.0')
testFixturesCompileOnly(libs.paper.api)
testImplementation('junit:junit:4.13.2')
testImplementation(libs.mockito.core)
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.paper.api)
testImplementation(libs.log4j.core)
testRuntimeOnly(libs.paper.api)
@@ -186,8 +177,7 @@ dependencies {
}
tasks.named('test').configure {
timeout = java.time.Duration.ofMinutes(60)
maxHeapSize = '2g'
maxHeapSize = '1g'
jvmArgs('-XX:+ExitOnOutOfMemoryError')
systemProperty('iris.generation.counters', 'true')
}
@@ -1,14 +1,25 @@
package art.arcane.iris.command.specialhandlers;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.command.DirectorParameterHandler;
import art.arcane.iris.generation.hydrology.query.HydrologyFeatureType;
import art.arcane.volmlib.util.collection.KList;
import art.arcane.volmlib.util.director.exceptions.DirectorParsingException;
import java.util.List;
public final class HydrologyTypeHandler implements DirectorParameterHandler<String> {
@Override
public KList<String> getPossibilities() {
return new KList<>(HydrologyFeatureType.keys());
Engine activeEngine = engine();
if (activeEngine == null) {
return new KList<>(HydrologyFeatureQuery.suggestions(List.of()));
}
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
return new KList<>(runtime == null
? HydrologyFeatureQuery.suggestions(List.of())
: runtime.featureQueryKeys());
}
@Override
@@ -18,11 +29,16 @@ public final class HydrologyTypeHandler implements DirectorParameterHandler<Stri
@Override
public String parse(String input, boolean force) throws DirectorParsingException {
try {
return HydrologyFeatureType.parse(input).key();
} catch (IllegalArgumentException error) {
throw new DirectorParsingException(error.getMessage());
String normalized = input == null ? "" : input.trim();
if (normalized.isEmpty()) {
throw new DirectorParsingException("Hydrology feature type must not be blank.");
}
for (String possibility : getPossibilities(normalized)) {
if (possibility.equalsIgnoreCase(normalized)) {
return possibility;
}
}
return normalized;
}
@Override
@@ -32,6 +48,6 @@ public final class HydrologyTypeHandler implements DirectorParameterHandler<Stri
@Override
public String getRandomDefault() {
return HydrologyFeatureType.RIVER.key();
return "surface";
}
}
@@ -373,7 +373,6 @@ public class IrisSettings {
public boolean useServerLaunchedGuis = true;
public boolean maximumPregenGuiFPS = false;
public boolean colorMode = true;
public String theme = "auto";
}
@Data
@@ -26,7 +26,7 @@ import art.arcane.iris.generation.decoration.IrisDepositVariant;
import art.arcane.iris.generation.decoration.IrisOreGenerator;
import art.arcane.iris.generation.decoration.IrisOreBands;
import art.arcane.iris.generation.decoration.IrisProceduralObjects;
import art.arcane.iris.generation.hydrology.config.IrisHydrologyPolicy;
import art.arcane.iris.generation.hydrology.IrisRiverPolicy;
import art.arcane.iris.generation.noise.IrisGeneratorStyle;
import art.arcane.iris.generation.noise.NoiseStyle;
import art.arcane.iris.generation.terrain.InferredType;
@@ -161,8 +161,8 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
private int lockLayersMax = 7;
@Description("Profile-driven 3D cave configuration")
private IrisCaveProfile caveProfile = new IrisCaveProfile();
@Description("Water settings for this biome: rainfall, pond density, where streams and ponds may form, and the fluid that fills them. A value left unset comes from the region, then from the dimension.")
private IrisHydrologyPolicy hydrologyPolicy = null;
@Description("Biome-level river placement, routing, profile, biome, and geometry policy. Omit to inherit region and dimension settings.")
private IrisRiverPolicy riverPolicy = null;
@MinNumber(1)
@MaxNumber(512)
@Description("The rarity of this biome (integer)")
@@ -580,10 +580,6 @@ public class IrisBiome extends IrisRegistrant implements Rarity {
return IrisBiomeLayerGenerator.getLayerSeaHeightGenerators(this, rng, data);
}
public KList<CNG> getCaveCeilingLayerHeightGenerators(RNG rng, IrisData data) {
return IrisBiomeLayerGenerator.getCaveCeilingLayerHeightGenerators(this, rng, data);
}
public NativeBlockState getSurfaceBlock(int x, int z, RNG rng, IrisData idm) {
return IrisBiomeLayerGenerator.getSurfaceBlock(this, x, z, rng, idm);
}
@@ -148,7 +148,8 @@ final class IrisBiomeLayerGenerator {
return data;
}
KList<CNG> heightGenerators = getCaveCeilingLayerHeightGenerators(biome, random, rdata);
KList<CNG> heightGenerators = getHeightGenerators(
biome.getLayerCeilingHeightGenerators(), ceilingLayers, 7235, random, rdata);
for (int i = 0; i < layerCount; i++) {
IrisBiomePaletteLayer layer = ceilingLayers.get(i);
@@ -316,10 +317,6 @@ final class IrisBiomeLayerGenerator {
return getHeightGenerators(biome.getLayerSeaHeightGenerators(), biome.getSeaLayers(), 7735, rng, data);
}
static KList<CNG> getCaveCeilingLayerHeightGenerators(IrisBiome biome, RNG rng, IrisData data) {
return getHeightGenerators(biome.getLayerCeilingHeightGenerators(), biome.getCaveCeilingLayers(), 7235, rng, data);
}
private static KList<CNG> getHeightGenerators(IrisBiomeLayerHeightCache cache,
KList<IrisBiomePaletteLayer> layers,
int seedOffset, RNG rng, IrisData data) {
@@ -5,7 +5,7 @@ import art.arcane.iris.generation.runtime.DimensionStackLayout;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.EngineMetrics;
import art.arcane.iris.generation.hydrology.raster.ChunkWaterRaster;
import art.arcane.iris.generation.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.generation.biome.FloatingIslandBoundarySampler;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.iris.generation.mantle.CaveTerrainSnapshot;
@@ -25,7 +25,6 @@ public class ChunkContext {
private FloatingBiomeOverlay floatingBiomes;
private CaveTerrainSnapshot caveTerrain;
private TransitionTerrainColumn[] transitionTerrain;
private volatile ChunkWaterRaster waterRaster;
private final int x;
private final int z;
private final IrisComplex complex;
@@ -90,6 +89,10 @@ public class ChunkContext {
PrefillPlan resolvedPlan = prefillPlan == null ? PrefillPlan.NO_CAVE : prefillPlan;
boolean capturePrefillMetric = metrics != null;
long totalStartNanos = capturePrefillMetric ? System.nanoTime() : 0L;
IrisHydrologyRuntime hydrologyRuntime = complex.getHydrologyRuntime();
if (hydrologyRuntime != null && (resolvedPlan.height || resolvedPlan.biome)) {
hydrologyRuntime.prepareChunkColumns(x, z);
}
if (shouldPrefillAsync(resolvedPlan, complex.isPlatformMainThread())) {
prefillAsync(resolvedPlan);
} else {
@@ -112,9 +115,6 @@ public class ChunkContext {
}
private void prefillSurface(PrefillPlan prefillPlan) {
if (prefillPlan.height || prefillPlan.biome) {
waterRaster();
}
if (prefillPlan.height) {
height.fillRounded(roundedHeight);
}
@@ -209,18 +209,6 @@ public class ChunkContext {
return complex;
}
public ChunkWaterRaster waterRaster() {
ChunkWaterRaster known = waterRaster;
if (known != null) {
return known;
}
ChunkWaterRaster raster = complex.getHydrologyRuntime().raster(x >> 4, z >> 4);
if (!raster.provisional()) {
waterRaster = raster;
}
return raster;
}
public FloatingIslandBoundarySampler getFloatingIslandBoundarySampler() {
return floatingIslandBoundarySampler;
}
@@ -542,7 +542,7 @@ final class DecoratorCore {
return stack;
}
static boolean allowsSurface(IrisDecorator decorator, NativeBlockState surface, IrisData data) {
private static boolean allowsSurface(IrisDecorator decorator, NativeBlockState surface, IrisData data) {
return decorator.isForcePlace()
|| (decorator.getWhitelist() == null || matchesPalette(decorator.getWhitelistArray(data), surface))
&& (decorator.getBlacklist() == null || !matchesPalette(decorator.getBlacklistArray(data), surface));
@@ -1,390 +0,0 @@
package art.arcane.iris.generation.decoration;
import art.arcane.iris.generation.block.B;
import art.arcane.iris.generation.block.IrisBlockData;
import art.arcane.iris.generation.hydrology.plan.HydrologySettings;
import art.arcane.iris.generation.hydrology.raster.WaterRole;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.pack.loading.IrisData;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
import art.arcane.volmlib.util.math.RNG;
import java.util.ArrayList;
import java.util.List;
public final class HydrologyVegetation {
private static final long SALT = 0x4859445256454745L;
private static final long GOLDEN = 0x9e3779b97f4a7c15L;
private static final int[][] SIDES = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
private final long seed;
private final Engine engine;
private final RNG floorRng;
private final RNG surfaceRng;
private final RNG shoreRng;
public HydrologyVegetation(long seed, Engine engine) {
this.seed = seed ^ SALT;
this.engine = engine;
this.floorRng = new RNG(DecoratorCore.partSeed(this.seed, IrisDecorationPart.SEA_FLOOR));
this.surfaceRng = new RNG(DecoratorCore.partSeed(this.seed, IrisDecorationPart.SEA_SURFACE));
this.shoreRng = new RNG(DecoratorCore.partSeed(this.seed, IrisDecorationPart.SHORE_LINE));
}
public void decorate(Hunk<NativeBlockState> output, int localX, int localZ, int x, int z, WaterRole role, Water water, IrisData data) {
Placement placement = placement(role);
if (placement == Placement.NONE) {
return;
}
Decorators decorators = water.decorators(x, z);
if (decorators.empty()) {
return;
}
int ground = water.ground(x, z);
if (ground < 0 || ground + 1 >= output.getHeight()) {
return;
}
Column column = new Column(output, localX, localZ, x, z, ground, water, data);
if (placement == Placement.SHORE) {
decorateShore(column, decorators.shore);
return;
}
int head = water.head(x, z);
if (head <= ground || head + 1 >= output.getHeight()) {
return;
}
decorateFloor(column, head, decorators.floor);
if (placement == Placement.STILL && decorators.surface.length > 0 && still(water, x, z, head)) {
decorateSurface(column, head, decorators.surface);
}
}
private static Placement placement(WaterRole role) {
return switch (role) {
case CHANNEL, POND -> Placement.STILL;
case CASCADE, WATERFALL -> Placement.FLOOR;
case BANK, SHORE, POND_SHORE -> Placement.SHORE;
case NONE, DAM, MOUTH, INLET -> Placement.NONE;
};
}
private void decorateFloor(Column column, int head, IrisDecorator[] bucket) {
int room = head - column.ground - 1;
if (room < 1 || bucket.length == 0) {
return;
}
IrisDecorator decorator = pick(bucket, floorRng, column, IrisDecorationPart.SEA_FLOOR);
NativeBlockState support = column.output.get(column.localX, column.ground, column.localZ);
if (decorator == null || !B.isSolid(support) || !column.accepts(decorator, support)) {
return;
}
column.place(decorator, floorRng, column.ground + 1, room, Slot.FLOOR, null);
}
private void decorateSurface(Column column, int head, IrisDecorator[] bucket) {
NativeBlockState support = column.output.get(column.localX, head, column.localZ);
if (!isWaterSource(support)) {
return;
}
IrisDecorator decorator = pick(bucket, surfaceRng, column, IrisDecorationPart.SEA_SURFACE);
if (decorator == null || !column.accepts(decorator, support)) {
return;
}
column.place(decorator, surfaceRng, head + 1, column.output.getHeight() - head - 1, Slot.SURFACE, null);
}
private void decorateShore(Column column, IrisDecorator[] bucket) {
NativeBlockState support = column.output.get(column.localX, column.ground, column.localZ);
if (bucket.length == 0 || support == null || !support.isSolid()) {
return;
}
IrisDecorator decorator = pick(bucket, shoreRng, column, IrisDecorationPart.SHORE_LINE);
if (decorator == null || !column.accepts(decorator, support)) {
return;
}
column.place(decorator, shoreRng, column.ground + 1, column.output.getHeight() - column.ground - 1, Slot.SHORE, support);
}
private IrisDecorator pick(IrisDecorator[] bucket, RNG partRng, Column column, IrisDecorationPart part) {
IrisDecorator picked = null;
long choice = seed + part.ordinal();
int count = 0;
for (IrisDecorator decorator : bucket) {
if (!decorator.passesChanceGate(partRng, column.x, column.z, column.data)) {
continue;
}
count++;
choice = mix(choice ^ mix(column.x * 0x632be59bd9b4e019L + column.z * 0x8cb92ba72f3d8dd7L + count));
if (count == 1 || Long.remainderUnsigned(choice, count) == 0L) {
picked = decorator;
}
}
if (picked == null || picked.isForcePlace() || picked.getSlopeCondition().isDefault()
|| picked.getSlopeCondition().isValid(column.water.slope(column.x, column.z))) {
return picked;
}
return null;
}
static boolean still(Water water, int x, int z, int head) {
for (int[] side : SIDES) {
int neighbourX = x + side[0];
int neighbourZ = z + side[1];
int neighbourHead = water.head(neighbourX, neighbourZ);
if (neighbourHead != head && water.ground(neighbourX, neighbourZ) < neighbourHead) {
return false;
}
}
return true;
}
static boolean isWaterSource(NativeBlockState state) {
if (state == null || !state.isWater()) {
return false;
}
String level = IrisProceduralBlocks.propertyValue(state, "level");
return level == null || level.equals("0");
}
static boolean holdsWater(NativeBlockState state) {
return B.isFluid(state) || B.isWaterLogged(state) || IrisSurfaceDecorator.isAquaticPlacement(state);
}
static boolean plannedWater(Water water, int x, int z, int y) {
int ground = water.ground(x, z);
int top = water.head(x, z);
if (y <= ground || top <= ground) {
return false;
}
for (int[] side : SIDES) {
int neighbourHead = water.head(x + side[0], z + side[1]);
if (neighbourHead > top && water.ground(x + side[0], z + side[1]) < neighbourHead) {
top = neighbourHead;
}
}
return y <= top;
}
private static boolean twoBlock(NativeBlockState state) {
String half = IrisProceduralBlocks.propertyValue(state, "half");
return "lower".equals(half) || "upper".equals(half);
}
private static long mix(long value) {
long mixed = (value + GOLDEN ^ (value + GOLDEN >>> 30)) * 0xbf58476d1ce4e5b9L;
mixed = (mixed ^ (mixed >>> 27)) * 0x94d049bb133111ebL;
return mixed ^ (mixed >>> 31);
}
public interface Water {
int ground(int x, int z);
int head(int x, int z);
double slope(int x, int z);
Decorators decorators(int x, int z);
}
private enum Placement {
NONE,
SHORE,
FLOOR,
STILL
}
private enum Slot {
FLOOR,
SURFACE,
SHORE
}
private final class Column {
private final Hunk<NativeBlockState> output;
private final int localX;
private final int localZ;
private final int x;
private final int z;
private final int ground;
private final Water water;
private final IrisData data;
private Column(Hunk<NativeBlockState> output, int localX, int localZ, int x, int z, int ground, Water water, IrisData data) {
this.output = output;
this.localX = localX;
this.localZ = localZ;
this.x = x;
this.z = z;
this.ground = ground;
this.water = water;
this.data = data;
}
private boolean accepts(IrisDecorator decorator, NativeBlockState support) {
return decorator.isForcePlace() || DecoratorCore.allowsSurface(decorator, support, data);
}
private int stack(IrisDecorator decorator, RNG rng, int room) {
return decorator.isStacking() ? Math.min(room, DecoratorCore.computeStack(decorator, rng, x, z, data, room)) : 1;
}
private void place(IrisDecorator decorator, RNG rng, int baseY, int room, Slot slot, NativeBlockState support) {
NativeBlockState first = decorator.pickBlockData(rng, data, x, z);
if (first == null) {
return;
}
if (twoBlock(first)) {
if (room >= 2) {
placePair(decorator, first, baseY, slot, support);
}
return;
}
int stack = stack(decorator, rng, room);
NativeBlockState sampled = first;
boolean sampledTop = false;
for (int index = 0; index < stack; index++) {
int y = baseY + index;
boolean top = (stack == 1 ? 1D : (double) index / (stack - 1)) >= decorator.getTopThreshold();
if (top != sampledTop) {
sampled = top ? decorator.pickBlockDataTop(rng, data, x, z) : decorator.pickBlockData(rng, data, x, z);
sampledTop = top;
}
NativeBlockState block = sampled == null ? null : fit(sampled, y, slot, decorator, index == 0 ? support : null);
if (block == null) {
return;
}
output.set(localX, y, localZ, block);
}
}
private void placePair(IrisDecorator decorator, NativeBlockState block, int y, Slot slot, NativeBlockState support) {
NativeBlockState lower = fit(block.withProperty("half", "lower"), y, slot, decorator, support);
NativeBlockState upper = lower == null ? null : fit(block.withProperty("half", "upper"), y + 1, slot, decorator, null);
if (upper != null) {
output.set(localX, y, localZ, lower);
output.set(localX, y + 1, localZ, upper);
}
}
private NativeBlockState fit(NativeBlockState block, int y, Slot slot, IrisDecorator decorator, NativeBlockState support) {
NativeBlockState existing = output.get(localX, y, localZ);
if (slot == Slot.FLOOR) {
NativeBlockState placed = IrisProceduralBlocks.normalizeWaterlogging(block, existing, true);
return isWaterSource(existing) && IrisSurfaceDecorator.isAquaticPlacement(placed) ? placed : null;
}
if (!B.isAir(existing) || holdsWater(block)) {
return null;
}
if (slot == Slot.SURFACE) {
return block;
}
if (besideWater(y) || support != null && !DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
return null;
}
return IrisSugarCane.canPlace(block, output, localX, y, localZ, x, z, engine) ? block : null;
}
private boolean besideWater(int y) {
for (int[] side : SIDES) {
int neighbourX = localX + side[0];
int neighbourZ = localZ + side[1];
if (neighbourX >= 0 && neighbourX < output.getWidth() && neighbourZ >= 0 && neighbourZ < output.getDepth()) {
if (holdsWater(output.get(neighbourX, y, neighbourZ))) {
return true;
}
} else if (plannedWater(water, x + side[0], z + side[1], y)) {
return true;
}
}
return false;
}
}
public static final class Decorators {
public static final Decorators EMPTY = new Decorators(new IrisDecorator[0], new IrisDecorator[0], new IrisDecorator[0]);
private final IrisDecorator[] floor;
private final IrisDecorator[] surface;
private final IrisDecorator[] shore;
private Decorators(IrisDecorator[] floor, IrisDecorator[] surface, IrisDecorator[] shore) {
this.floor = floor;
this.surface = surface;
this.shore = shore;
}
public static Decorators compile(List<IrisDecorator> decorators, IrisData data, String path) {
HydrologySettings.requireDecorators(decorators, path);
if (decorators == null || decorators.isEmpty()) {
return EMPTY;
}
List<IrisDecorator> floor = new ArrayList<>();
List<IrisDecorator> surface = new ArrayList<>();
List<IrisDecorator> shore = new ArrayList<>();
for (int index = 0; index < decorators.size(); index++) {
IrisDecorator decorator = decorators.get(index);
String field = path + "[" + index + "]";
IrisDecorationPart part = decorator.getPartOf();
requirePlaced(decorator.getPalette(), data, field + ".palette", part);
requirePlaced(decorator.getTopPalette(), data, field + ".topPalette", part);
requireKnown(decorator.getWhitelist(), data, field + ".whitelist");
requireKnown(decorator.getBlacklist(), data, field + ".blacklist");
switch (part) {
case SEA_FLOOR -> floor.add(decorator);
case SEA_SURFACE -> surface.add(decorator);
default -> shore.add(decorator);
}
}
return new Decorators(floor.toArray(new IrisDecorator[0]), surface.toArray(new IrisDecorator[0]), shore.toArray(new IrisDecorator[0]));
}
public boolean empty() {
return floor.length == 0 && surface.length == 0 && shore.length == 0;
}
private static void requirePlaced(List<IrisBlockData> blocks, IrisData data, String path, IrisDecorationPart part) {
if (blocks == null) {
return;
}
for (int index = 0; index < blocks.size(); index++) {
String field = path + "[" + index + "]";
String name = blocks.get(index).getBlock();
NativeBlockState state = requireKnown(blocks.get(index), data, field);
if (state.isFluid()) {
throw new IllegalArgumentException(field + " names the fluid '" + name + "'; water decorators never place fluids");
}
if (part == IrisDecorationPart.SEA_FLOOR && !waterCompatible(state)) {
throw new IllegalArgumentException(field + " names '" + name + "', which cannot stand in water; SEA_FLOOR decorators place"
+ " only waterloggable or water plants such as seagrass, kelp, coral and sea pickles");
}
if (part != IrisDecorationPart.SEA_FLOOR && holdsWater(state)) {
throw new IllegalArgumentException(field + " names '" + name + "', which holds water; " + part
+ " decorators grow in air and place only blocks without water, never waterlogged blocks, seagrass, kelp, sea pickles or coral");
}
}
}
private static void requireKnown(List<IrisBlockData> blocks, IrisData data, String path) {
if (blocks == null) {
return;
}
for (int index = 0; index < blocks.size(); index++) {
requireKnown(blocks.get(index), data, path + "[" + index + "]");
}
}
private static NativeBlockState requireKnown(IrisBlockData block, IrisData data, String path) {
NativeBlockState state = block.getBlockData(data);
if (state == null || state.isAir()) {
throw new IllegalArgumentException(path + " names the block '" + block.getBlock()
+ "', which does not exist on this server; water decorators never place air");
}
return state;
}
private static boolean waterCompatible(NativeBlockState state) {
return IrisSurfaceDecorator.isAquaticPlacement(state) || IrisProceduralBlocks.propertyValue(state, "waterlogged") != null;
}
}
}
@@ -2,6 +2,8 @@ package art.arcane.iris.generation.decoration;
import art.arcane.iris.generation.runtime.IrisComplex;
import art.arcane.iris.generation.runtime.Engine;
import art.arcane.iris.generation.hydrology.HydrologyColumnLayer;
import art.arcane.iris.generation.hydrology.HydrologyColumnSample;
import art.arcane.iris.generation.biome.IrisBiome;
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
import art.arcane.volmlib.util.hunk.Hunk;
@@ -69,18 +71,21 @@ final class IrisSugarCane {
private static boolean plannedWater(Engine engine, int x, int y, int z) {
IrisComplex complex = engine.getComplex();
HydrologyColumnSample sample = complex.sampleHydrologyColumn(x, z);
HydrologyColumnLayer fluid = sample == null ? null : sample.primarySurfaceFluidLayerOrNull();
if (fluid != null) {
return y > fluid.bedY() && y <= fluid.fluidHeadY()
&& supportsAdjacent(complex.resolveHydrologyFluid(fluid.profileKey(), x, z));
}
int terrainHeight = (int) Math.round(complex.getHeightStream().getDouble(x, z));
int fluidHeight = (int) Math.round(complex.getWaterHeadStream().getDouble(x, z));
int fluidHeight = (int) Math.round(complex.getRiverWaterSurfaceStream().getDouble(x, z));
if (y <= terrainHeight || y > fluidHeight) {
return false;
}
if (complex.getWaterRoleStream().get(x, z).wet()) {
return supportsAdjacent(complex.resolveWaterFluid(x, z));
}
IrisBiome biome = complex.getTrueBiomeStream().get(x, z);
KList<NativeBlockState> layers = biome.generateSeaLayers(x, z,
new RNG(engine.getSeedManager().getTerrain()), fluidHeight - terrainHeight, engine.getData());
int depth = fluidHeight - y;
return supportsAdjacent(layers.hasIndex(depth) ? layers.get(depth) : complex.resolveSeaFluid(x, z));
return supportsAdjacent(layers.hasIndex(depth) ? layers.get(depth) : complex.resolveSurfaceFluid(x, z));
}
}
@@ -0,0 +1,35 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
record CaveCandidateBounds(
int entryX,
int entryY,
int entryZ,
int waterHeadY,
int horizontalRadius,
int maximumDepth,
int dryHeadroom
) {
boolean containsColumn(int x, int z) {
long deltaX = (long) x - entryX;
long deltaZ = (long) z - entryZ;
long radiusSquared = (long) horizontalRadius * horizontalRadius;
return deltaX * deltaX + deltaZ * deltaZ <= radiusSquared;
}
int minimumY() {
return Math.toIntExact((long) entryY - maximumDepth);
}
int maximumY() {
return Math.max(entryY, Math.addExact(waterHeadY, dryHeadroom + 1));
}
boolean contains(CavePosition position) {
if (!containsColumn(position.x(), position.z())) {
return false;
}
return position.y() >= minimumY() && position.y() <= maximumY();
}
}
@@ -0,0 +1,202 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import art.arcane.iris.generation.hydrology.cave.CavePositionIndex;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveFluidPolicy;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveMode;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlannerSettings;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveSource;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
final class CaveCandidateBuilder {
private final RiverCourse course;
private final HydraulicSegment representative;
private final LinkedHashMap<CavePosition, HydrologyCaveAction> actions;
private final LinkedHashSet<CavePosition> openings;
private final CavePositionIndex openingIndex;
private final List<CaveSurfaceOpening> surfaceOpenings;
private int minimumX;
private int maximumX;
private int minimumY;
private int maximumY;
private int minimumZ;
private int maximumZ;
CaveCandidateBuilder(RiverCourse course, HydraulicSegment representative) {
this.course = course;
this.representative = representative;
this.actions = new LinkedHashMap<>();
this.openings = new LinkedHashSet<>();
this.openingIndex = new CavePositionIndex();
this.surfaceOpenings = HydrologyCaveCourseFilter.surfaceOpenings(course);
this.minimumX = Integer.MAX_VALUE;
this.maximumX = Integer.MIN_VALUE;
this.minimumY = Integer.MAX_VALUE;
this.maximumY = Integer.MIN_VALUE;
this.minimumZ = Integer.MAX_VALUE;
this.maximumZ = Integer.MIN_VALUE;
}
RiverCourse course() {
return course;
}
private HydraulicSegment representative() {
return representative;
}
boolean isEmpty() {
return actions.isEmpty();
}
void addAction(CavePosition position, HydrologyCaveAction action) {
HydrologyCaveAction existing = actions.get(position);
if (existing == null || actionPriority(action) < actionPriority(existing)) {
actions.put(position, action);
}
include(position);
}
void addOpening(CavePosition position) {
if (openingIndex.add(position.x(), position.y(), position.z())) {
openings.add(position);
}
include(position);
}
void addOpening(int x, int y, int z) {
if (openingIndex.add(x, y, z)) {
openings.add(new CavePosition(x, y, z));
}
include(x, y, z);
}
void addOpeningNeighborhood(CavePosition position) {
addOpening(position);
for (int[] offset : HydrologyCaveCourseFilter.NEIGHBORS) {
addOpening(
position.x() + offset[0],
position.y() + offset[1],
position.z() + offset[2]
);
}
}
void addAdjacentSurfaceOpenings(
HydrologyFootprintCompiler.ValidationRaster validation
) {
ArrayList<CavePosition> actionPositions = new ArrayList<>(actions.keySet());
for (CavePosition position : actionPositions) {
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
int neighborX = position.x() + offset[0];
int neighborZ = position.z() + offset[1];
if (actions.containsKey(new CavePosition(neighborX, position.y(), neighborZ))) {
continue;
}
HydrologyColumnSample neighbor = validation.surfaceColumnAt(neighborX, neighborZ);
if (neighbor == null) {
continue;
}
for (HydrologyColumnLayer layer : neighbor.layers()) {
if (layer.feature().courseId() == course.id() && layer.channel() && layer.fluidOwned()
&& layer.bedY() < position.y() && position.y() <= layer.fluidHeadY()) {
addOpening(neighborX, position.y(), neighborZ);
break;
}
}
}
}
}
void addSurfaceOpening(HydrologyColumnLayer layer, CavePosition position) {
for (CaveSurfaceOpening opening : surfaceOpenings) {
if (!opening.matches(layer, position)) {
continue;
}
if (opening.includeNeighborhood()) {
addOpening(position);
for (int[] offset : HydrologyCaveCourseFilter.NEIGHBORS) {
int y = position.y() + offset[1];
if (y >= opening.minimumY()) {
addOpening(position.x() + offset[0], y, position.z() + offset[2]);
}
}
} else {
addOpening(position);
}
}
}
private void include(CavePosition position) {
include(position.x(), position.y(), position.z());
}
private void include(int x, int y, int z) {
minimumX = Math.min(minimumX, x);
maximumX = Math.max(maximumX, x);
minimumY = Math.min(minimumY, y);
maximumY = Math.max(maximumY, y);
minimumZ = Math.min(minimumZ, z);
maximumZ = Math.max(maximumZ, z);
}
HydrologyCaveCandidate build(HydrologyCaveCourseFilter.Options options) {
HydrologyPoint start = representative.start();
int waterHead = representative.upstreamHeadY();
int entryY = Math.max(waterHead, maximumY);
CavePosition entry = new CavePosition(start.x(), entryY, start.z());
CavePosition target = new CavePosition(start.x(), waterHead, start.z());
int horizontalRadius = HydrologyCaveCourseFilter.horizontalRadius(
entry.x(),
entry.z(),
minimumX,
maximumX,
minimumZ,
maximumZ
);
int maximumDepth = Math.max(1, entryY - minimumY + 2);
int dryHeadroom = Math.max(0, maximumY - waterHead);
int volume = Math.max(1, actions.size());
HydrologyCavePlannerSettings settings = new HydrologyCavePlannerSettings(
horizontalRadius,
maximumDepth,
volume,
1,
1,
dryHeadroom,
HydrologyCaveFluidPolicy.REJECT_EXISTING,
horizontalRadius,
maximumDepth
);
return new HydrologyCaveCandidate(
new HydrologyCaveSource(
course.id(),
entry,
target,
waterHead,
HydrologyCaveMode.GENERATED_GROTTO
),
course.profileKey(),
settings,
options.connectToExistingCaves() && course.type() == RiverCourseType.UNDERGROUND,
actions,
openings
);
}
private static int actionPriority(HydrologyCaveAction action) {
return switch (action) {
case WET_SOURCE -> 0;
case FALLING_FLUID -> 1;
case DRY_AIR -> 2;
case SEAL_GUARD -> 3;
};
}
}
@@ -0,0 +1,395 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import art.arcane.iris.generation.hydrology.cave.CaveVoxelView;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
final class CaveCandidateSpanBuilder {
private final RiverCourse course;
private final HydraulicSegment representative;
private final LinkedHashMap<Long, CaveSpanSet> actions;
private final LinkedHashMap<Long, CaveSpanSet> openings;
private final LinkedHashMap<Long, CaveSpanSet> coastalGrottoActions;
private final LinkedHashMap<Long, CaveSpanSet> inlandGrottoActions;
private final List<CaveSurfaceOpening> surfaceOpenings;
private int minimumX;
private int maximumX;
private int minimumY;
private int maximumY;
private int minimumZ;
private int maximumZ;
CaveCandidateSpanBuilder(RiverCourse course, HydraulicSegment representative) {
this.course = course;
this.representative = representative;
this.actions = new LinkedHashMap<>();
this.openings = new LinkedHashMap<>();
this.coastalGrottoActions = new LinkedHashMap<>();
this.inlandGrottoActions = new LinkedHashMap<>();
this.surfaceOpenings = HydrologyCaveCourseFilter.surfaceOpenings(course);
this.minimumX = Integer.MAX_VALUE;
this.maximumX = Integer.MIN_VALUE;
this.minimumY = Integer.MAX_VALUE;
this.maximumY = Integer.MIN_VALUE;
this.minimumZ = Integer.MAX_VALUE;
this.maximumZ = Integer.MIN_VALUE;
}
RiverCourse course() {
return course;
}
HydraulicSegment representative() {
return representative;
}
boolean isEmpty() {
return actions.isEmpty();
}
boolean allowsIntentionalSurfaceExposure() {
return course.surfaceSinkholeContinuation();
}
void addAction(int x, int z, HydrologyColumnLayer layer) {
int minimumActionY = layer.bedY() + 1;
int maximumActionY = layer.ceilingY();
if (minimumActionY > maximumActionY) {
return;
}
addSpan(actions, x, z, minimumActionY, maximumActionY);
if (layer.feature().type() == HydrologyFeatureType.COASTAL_GROTTO) {
addSpan(coastalGrottoActions, x, z, minimumActionY, maximumActionY);
} else if (layer.feature().type() == HydrologyFeatureType.INLAND_GROTTO) {
addSpan(inlandGrottoActions, x, z, minimumActionY, maximumActionY);
}
include(x, minimumActionY, z);
include(x, maximumActionY, z);
for (CaveSurfaceOpening opening : surfaceOpenings) {
if (!opening.matchesColumn(layer, x, z)) {
continue;
}
int minimumOpeningY = Math.max(minimumActionY, opening.minimumY());
if (minimumOpeningY > maximumActionY) {
continue;
}
if (!opening.includeNeighborhood()) {
addOpening(x, z, minimumOpeningY, maximumActionY);
continue;
}
addOpening(x, z, Math.max(minimumOpeningY - 1, opening.minimumY()), maximumActionY + 1);
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
addOpening(x + offset[0], z + offset[1], minimumOpeningY, maximumActionY);
}
}
}
void addOceanOpening(int x, int z, int minimumOpeningY, int maximumOpeningY) {
if (minimumOpeningY <= maximumOpeningY) {
addOpening(x, z, minimumOpeningY, maximumOpeningY);
}
}
void addOpeningNeighborhood(
int x,
int z,
int minimumOpeningY,
int maximumOpeningY
) {
if (minimumOpeningY > maximumOpeningY) {
return;
}
addOpening(x, z, minimumOpeningY - 1, maximumOpeningY + 1);
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
addOpening(x + offset[0], z + offset[1], minimumOpeningY, maximumOpeningY);
}
}
void addAdjacentSurfaceOpenings(
HydrologyFootprintCompiler.ValidationRaster validation
) {
for (Map.Entry<Long, CaveSpanSet> entry : actions.entrySet()) {
int x = RiverFootprint.unpackX(entry.getKey());
int z = RiverFootprint.unpackZ(entry.getKey());
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
int neighborX = x + offset[0];
int neighborZ = z + offset[1];
HydrologyColumnSample neighbor = validation.surfaceColumnAt(neighborX, neighborZ);
if (neighbor == null) {
continue;
}
for (HydrologyColumnLayer layer : neighbor.layers()) {
if (layer.feature().courseId() != course.id() || !layer.channel() || !layer.fluidOwned()) {
continue;
}
for (CaveYSpan span : entry.getValue().spans()) {
int minimumY = Math.max(span.minimumY(), layer.bedY() + 1);
int maximumY = Math.min(span.maximumY(), layer.fluidHeadY());
if (minimumY <= maximumY) {
addAdjacentFluidOpening(neighborX, neighborZ, minimumY, maximumY);
}
}
}
}
}
}
private void addAdjacentFluidOpening(int x, int z, int minimumY, int maximumY) {
CaveSpanSet owned = actions.get(RiverFootprint.pack(x, z));
if (owned == null) {
addOpening(x, z, minimumY, maximumY);
return;
}
int cursor = minimumY;
for (CaveYSpan span : owned.spans()) {
if (span.maximumY() < cursor) {
continue;
}
if (span.minimumY() > maximumY) {
break;
}
if (cursor < span.minimumY()) {
addOpening(x, z, cursor, Math.min(maximumY, span.minimumY() - 1));
}
cursor = Math.max(cursor, span.maximumY() + 1);
if (cursor > maximumY) {
return;
}
}
addOpening(x, z, cursor, maximumY);
}
HydrologyFeatureType oversizedGrotto(HydrologyCaveCourseFilter.Options options) {
if (positionCount(coastalGrottoActions)
> options.maximumVolume(HydrologyFeatureType.COASTAL_GROTTO)) {
return HydrologyFeatureType.COASTAL_GROTTO;
}
if (positionCount(inlandGrottoActions)
> options.maximumVolume(HydrologyFeatureType.INLAND_GROTTO)) {
return HydrologyFeatureType.INLAND_GROTTO;
}
return null;
}
long positionCount() {
return positionCount(actions);
}
boolean exposed(CaveVoxelView view) {
CaveCandidateBounds bounds = bounds();
for (Map.Entry<Long, CaveSpanSet> entry : actions.entrySet()) {
int x = RiverFootprint.unpackX(entry.getKey());
int z = RiverFootprint.unpackZ(entry.getKey());
CaveSpanSet actionSpans = entry.getValue();
CaveSpanSet openingSpans = openings.get(entry.getKey());
if (exposedDifference(actionSpans, null, openingSpans, x, z, bounds, view)) {
return true;
}
for (CaveYSpan span : actionSpans.spans()) {
int lowerBoundary = span.minimumY() - 1;
if (!actionSpans.contains(lowerBoundary)
&& !contains(openingSpans, lowerBoundary)
&& exposed(x, lowerBoundary, z, bounds, view)) {
return true;
}
int upperBoundary = span.maximumY() + 1;
if (!actionSpans.contains(upperBoundary)
&& !contains(openingSpans, upperBoundary)
&& exposed(x, upperBoundary, z, bounds, view)) {
return true;
}
}
for (int[] offset : HydrologyCaveCourseFilter.HORIZONTAL_NEIGHBORS) {
int neighborX = x + offset[0];
int neighborZ = z + offset[1];
long neighborKey = RiverFootprint.pack(neighborX, neighborZ);
if (exposedDifference(
actionSpans,
actions.get(neighborKey),
openings.get(neighborKey),
neighborX,
neighborZ,
bounds,
view
)) {
return true;
}
}
}
return false;
}
private boolean exposedDifference(
CaveSpanSet source,
CaveSpanSet excludedActions,
CaveSpanSet excludedOpenings,
int x,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
for (CaveYSpan span : source.spans()) {
if (exposedDifference(
span,
excludedActions,
excludedOpenings,
x,
z,
bounds,
view
)) {
return true;
}
}
return false;
}
private boolean exposedDifference(
CaveYSpan source,
CaveSpanSet excludedActions,
CaveSpanSet excludedOpenings,
int x,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
List<CaveYSpan> actionSpans = excludedActions == null ? List.of() : excludedActions.spans();
List<CaveYSpan> openingSpans = excludedOpenings == null ? List.of() : excludedOpenings.spans();
int actionIndex = 0;
int openingIndex = 0;
long cursor = source.minimumY();
while (cursor <= source.maximumY()) {
while (actionIndex < actionSpans.size()
&& actionSpans.get(actionIndex).maximumY() < cursor) {
actionIndex++;
}
while (openingIndex < openingSpans.size()
&& openingSpans.get(openingIndex).maximumY() < cursor) {
openingIndex++;
}
CaveYSpan action = actionIndex < actionSpans.size() ? actionSpans.get(actionIndex) : null;
CaveYSpan opening = openingIndex < openingSpans.size() ? openingSpans.get(openingIndex) : null;
CaveYSpan excluded = first(action, opening);
if (excluded == null || excluded.minimumY() > source.maximumY()) {
return exposed(x, (int) cursor, source.maximumY(), z, bounds, view);
}
if (cursor < excluded.minimumY()
&& exposed(x, (int) cursor, excluded.minimumY() - 1, z, bounds, view)) {
return true;
}
cursor = Math.max(cursor, (long) excluded.maximumY() + 1L);
if (excluded == action) {
actionIndex++;
} else {
openingIndex++;
}
}
return false;
}
private CaveYSpan first(CaveYSpan first, CaveYSpan second) {
if (first == null) {
return second;
}
if (second == null) {
return first;
}
return first.minimumY() <= second.minimumY() ? first : second;
}
boolean exposed(
int x,
int minimumY,
int maximumY,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
if (!bounds.containsColumn(x, z)) {
return false;
}
int boundedMinimumY = Math.max(minimumY, bounds.minimumY());
int boundedMaximumY = Math.min(maximumY, bounds.maximumY());
return boundedMinimumY <= boundedMaximumY
&& view.hasAboveTerrainSurface(x, z, boundedMinimumY, boundedMaximumY);
}
boolean exposed(
int x,
int y,
int z,
CaveCandidateBounds bounds,
CaveVoxelView view
) {
CavePosition position = new CavePosition(x, y, z);
return bounds.contains(position)
&& view.isInWorld(position)
&& view.isAboveTerrainSurface(position);
}
private CaveCandidateBounds bounds() {
HydrologyPoint start = representative.start();
int waterHead = representative.upstreamHeadY();
int entryY = Math.max(waterHead, maximumY);
int horizontalRadius = HydrologyCaveCourseFilter.horizontalRadius(
start.x(),
start.z(),
minimumX,
maximumX,
minimumZ,
maximumZ
);
int maximumDepth = Math.max(1, entryY - minimumY + 2);
int dryHeadroom = Math.max(0, maximumY - waterHead);
return new CaveCandidateBounds(
start.x(),
entryY,
start.z(),
waterHead,
horizontalRadius,
maximumDepth,
dryHeadroom
);
}
private void addOpening(int x, int z, int minimumOpeningY, int maximumOpeningY) {
addSpan(openings, x, z, minimumOpeningY, maximumOpeningY);
include(x, minimumOpeningY, z);
include(x, maximumOpeningY, z);
}
private void include(int x, int y, int z) {
minimumX = Math.min(minimumX, x);
maximumX = Math.max(maximumX, x);
minimumY = Math.min(minimumY, y);
maximumY = Math.max(maximumY, y);
minimumZ = Math.min(minimumZ, z);
maximumZ = Math.max(maximumZ, z);
}
private static void addSpan(
Map<Long, CaveSpanSet> spans,
int x,
int z,
int minimumY,
int maximumY
) {
spans.computeIfAbsent(RiverFootprint.pack(x, z), (Long ignored) -> new CaveSpanSet())
.add(minimumY, maximumY);
}
private static boolean contains(CaveSpanSet spans, int y) {
return spans != null && spans.contains(y);
}
static long positionCount(Map<Long, CaveSpanSet> spans) {
long count = 0L;
for (CaveSpanSet spanSet : spans.values()) {
count += spanSet.positionCount();
}
return count;
}
}
@@ -0,0 +1,10 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
record CaveCourseGraph(
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets
) {
}
@@ -0,0 +1,103 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
final class CaveSpanSet {
private int[] pending;
private int size;
private List<CaveYSpan> normalized;
CaveSpanSet() {
this.pending = new int[4];
this.normalized = null;
}
void add(int minimumY, int maximumY) {
if (minimumY > maximumY) {
return;
}
int requiredLength = Math.multiplyExact(size + 1, 2);
if (requiredLength > pending.length) {
pending = Arrays.copyOf(pending, Math.multiplyExact(pending.length, 2));
}
pending[size * 2] = minimumY;
pending[size * 2 + 1] = maximumY;
size++;
normalized = null;
}
List<CaveYSpan> spans() {
if (normalized != null) {
return normalized;
}
sortPending();
ArrayList<CaveYSpan> merged = new ArrayList<>(size);
for (int index = 0; index < size; index++) {
CaveYSpan span = new CaveYSpan(pending[index * 2], pending[index * 2 + 1]);
if (merged.isEmpty()) {
merged.add(span);
continue;
}
CaveYSpan previous = merged.getLast();
if ((long) span.minimumY() > (long) previous.maximumY() + 1L) {
merged.add(span);
continue;
}
merged.set(
merged.size() - 1,
new CaveYSpan(previous.minimumY(), Math.max(previous.maximumY(), span.maximumY()))
);
}
normalized = List.copyOf(merged);
return normalized;
}
private void sortPending() {
for (int index = 1; index < size; index++) {
int minimumY = pending[index * 2];
int maximumY = pending[index * 2 + 1];
int insertionIndex = index;
while (insertionIndex > 0) {
int previousMinimumY = pending[(insertionIndex - 1) * 2];
int previousMaximumY = pending[(insertionIndex - 1) * 2 + 1];
if (previousMinimumY < minimumY
|| previousMinimumY == minimumY && previousMaximumY <= maximumY) {
break;
}
pending[insertionIndex * 2] = previousMinimumY;
pending[insertionIndex * 2 + 1] = previousMaximumY;
insertionIndex--;
}
pending[insertionIndex * 2] = minimumY;
pending[insertionIndex * 2 + 1] = maximumY;
}
}
boolean contains(int y) {
List<CaveYSpan> spans = spans();
int minimumIndex = 0;
int maximumIndex = spans.size() - 1;
while (minimumIndex <= maximumIndex) {
int index = (minimumIndex + maximumIndex) >>> 1;
CaveYSpan span = spans.get(index);
if (y < span.minimumY()) {
maximumIndex = index - 1;
} else if (y > span.maximumY()) {
minimumIndex = index + 1;
} else {
return true;
}
}
return false;
}
long positionCount() {
long count = 0L;
for (CaveYSpan span : spans()) {
count += (long) span.maximumY() - span.minimumY() + 1L;
}
return count;
}
}
@@ -0,0 +1,18 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveCandidate;
import java.util.Objects;
record CaveSurfaceComposition(
HydrologyCaveCandidate candidate,
boolean accepted
) {
static CaveSurfaceComposition accepted(HydrologyCaveCandidate candidate) {
return new CaveSurfaceComposition(Objects.requireNonNull(candidate), true);
}
static CaveSurfaceComposition rejected() {
return new CaveSurfaceComposition(null, false);
}
}
@@ -0,0 +1,79 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CavePosition;
import java.util.List;
record CaveSurfaceOpening(
long segmentId,
HydrologyPoint point,
double interiorX,
double interiorZ,
long radiusSquared,
int minimumY,
boolean includeNeighborhood,
boolean surfaceFall
) {
static CaveSurfaceOpening create(
HydraulicSegment segment,
boolean start,
long radiusSquared,
int minimumY,
boolean includeNeighborhood
) {
List<HydrologyPoint> centerline = segment.centerline();
int boundaryIndex = start ? 0 : centerline.size() - 1;
int interiorIndex = start
? Math.min(1, centerline.size() - 1)
: Math.max(0, centerline.size() - 2);
HydrologyPoint point = centerline.get(boundaryIndex);
HydrologyPoint interior = centerline.get(interiorIndex);
double deltaX = interior.x() - point.x();
double deltaZ = interior.z() - point.z();
double lengthSquared = deltaX * deltaX + deltaZ * deltaZ;
double scale = 0D;
if (includeNeighborhood) {
scale = lengthSquared <= radiusSquared
? 1D
: StrictMath.sqrt(radiusSquared / lengthSquared);
}
return new CaveSurfaceOpening(
segment.id(),
point,
point.x() + deltaX * scale,
point.z() + deltaZ * scale,
radiusSquared,
minimumY,
includeNeighborhood,
segment.type().isSurface() && segment.fallingFluid()
);
}
boolean matches(HydrologyColumnLayer layer, CavePosition position) {
return matchesColumn(layer, position.x(), position.z())
&& position.y() >= minimumY;
}
boolean matchesColumn(HydrologyColumnLayer layer, int x, int z) {
if (surfaceFall) {
return layer.fallingFluid() && layer.feature().segmentId() == segmentId;
}
return (includeNeighborhood || layer.feature().segmentId() == segmentId)
&& distanceSquared(x, z) <= radiusSquared;
}
private double distanceSquared(int x, int z) {
double pathX = interiorX - point.x();
double pathZ = interiorZ - point.z();
double pathLengthSquared = pathX * pathX + pathZ * pathZ;
double positionX = x - point.x();
double positionZ = z - point.z();
double progress = pathLengthSquared == 0D
? 0D
: (positionX * pathX + positionZ * pathZ) / pathLengthSquared;
double clampedProgress = Math.max(0D, Math.min(1D, progress));
double deltaX = positionX - pathX * clampedProgress;
double deltaZ = positionZ - pathZ * clampedProgress;
return deltaX * deltaX + deltaZ * deltaZ;
}
}
@@ -0,0 +1,4 @@
package art.arcane.iris.generation.hydrology;
record CaveYSpan(int minimumY, int maximumY) {
}
@@ -0,0 +1,12 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Map;
record CompiledGraph(
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets,
Map<Integer, DrainageEdge> edgeByUpstream
) {
}
@@ -0,0 +1,9 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
record CourseEdgeKey(RiverCourseType type, long edgeId) {
CourseEdgeKey {
Objects.requireNonNull(type, "type");
}
}
@@ -0,0 +1,14 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTileBlockers(
List<HydrologyCrossTileCaveAdmission.RankedClaim> caveClaims,
List<HydrologyCrossTileSurfaceAdmission.RankedClaim> surfaceClaims
) {
CrossTileBlockers {
caveClaims = List.copyOf(Objects.requireNonNull(caveClaims, "caveClaims"));
surfaceClaims = List.copyOf(Objects.requireNonNull(surfaceClaims, "surfaceClaims"));
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.generation.hydrology;
interface CrossTileDraftAdmission {
void prepare();
CrossTilePublicationAdmission admit(HydrologyCaveCourseFilter.Result result);
}
@@ -0,0 +1,15 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTilePublicationAdmission(
HydrologyCaveCourseFilter.Result result,
List<HydrologyDiagnosticCandidate> diagnostics,
boolean rejectedCourses
) {
CrossTilePublicationAdmission {
Objects.requireNonNull(result, "result");
diagnostics = List.copyOf(Objects.requireNonNull(diagnostics, "diagnostics"));
}
}
@@ -0,0 +1,9 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
record CrossTileRejectedCourse(RiverCourse course, long winnerSourceId) {
CrossTileRejectedCourse {
Objects.requireNonNull(course, "course");
}
}
@@ -0,0 +1,4 @@
package art.arcane.iris.generation.hydrology;
record CrossTileRejectionKey(long courseId, long winnerCourseId) {
}
@@ -0,0 +1,17 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTileResolution(
HydrologyOwnerDraft draft,
List<CrossTileRejectedCourse> observedRejections,
int iterations,
int ownerCount,
long resolutionNanos
) {
CrossTileResolution {
Objects.requireNonNull(draft, "draft");
observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections"));
}
}
@@ -0,0 +1,73 @@
package art.arcane.iris.generation.hydrology;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
final class CrossTileResolutionContext {
final Map<HydrologyTileKey, CrossTileResolvedOwner> resolved;
private final Set<HydrologyTileKey> resolving;
final HydrologyTileKey root;
private final long maximumTileOffset;
private final int maximumOwners;
int iterations;
CrossTileResolutionContext(
HydrologyTileKey root,
long maximumTileOffset,
int maximumOwners
) {
this.resolved = new HashMap<>();
this.resolving = new HashSet<>();
this.root = Objects.requireNonNull(root, "root");
this.maximumTileOffset = maximumTileOffset;
this.maximumOwners = maximumOwners;
}
CrossTileResolvedOwner resolved(HydrologyTileKey key) {
return resolved.get(key);
}
void remember(HydrologyTileKey key, CrossTileResolvedOwner owner) {
long offsetX = Math.abs((long) key.tileX() - root.tileX());
long offsetZ = Math.abs((long) key.tileZ() - root.tileZ());
if (offsetX > maximumTileOffset || offsetZ > maximumTileOffset) {
throw new IllegalStateException("Cross-tile owner dependency exceeded its color-ranked geometry bound.");
}
CrossTileResolvedOwner existing = resolved.putIfAbsent(key, owner);
if (existing != null
&& !existing.withoutFootprintCompiler().equals(owner.withoutFootprintCompiler())) {
throw new IllegalStateException("Cross-tile owner resolution produced inconsistent cached results for "
+ key.tileX() + "," + key.tileZ() + " while planning " + root.tileX() + "," + root.tileZ() + ".");
}
if (resolved.size() > maximumOwners) {
throw new IllegalStateException("Cross-tile owner dependencies exceeded their color-ranked count bound.");
}
}
boolean begin(HydrologyTileKey key) {
return resolving.add(key);
}
void end(HydrologyTileKey key) {
resolving.remove(key);
}
void recordIteration() {
iterations++;
}
int iterations() {
return iterations;
}
int ownerCount() {
return resolved.size();
}
HydrologyTileKey root() {
return root;
}
}
@@ -0,0 +1,21 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
record CrossTileResolvedOwner(
HydrologyOwnerDraft draft,
List<CrossTileRejectedCourse> observedRejections
) {
CrossTileResolvedOwner {
Objects.requireNonNull(draft, "draft");
observedRejections = List.copyOf(Objects.requireNonNull(observedRejections, "observedRejections"));
}
CrossTileResolvedOwner withoutFootprintCompiler() {
HydrologyOwnerDraft uncachedDraft = draft.withoutFootprintCompiler();
return uncachedDraft == draft
? this
: new CrossTileResolvedOwner(uncachedDraft, observedRejections);
}
}
@@ -0,0 +1,8 @@
package art.arcane.iris.generation.hydrology;
enum DraftPhase {
CONTEXT,
SELECT,
SETTLE,
PUBLISH
}
@@ -0,0 +1,60 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.spi.IrisLogging;
/**
* Wall-clock breakdown of one owner draft for the debug log: sampling and routing context, source
* selection, settling, publication passes, and the time spent waiting on lower-rank neighbour drafts.
*/
final class DraftProfile {
private final long[] phaseNanos = new long[DraftPhase.values().length];
private long dependencyNanos;
private int dependencies;
private int admissions;
int earlyOwners;
int routeCalls;
int routeSolves;
long routeSolveNanos;
int rasterCalls;
long rasterNanos;
int filterCalls;
long filterNanos;
long record(DraftPhase phase, long started) {
long now = System.nanoTime();
phaseNanos[phase.ordinal()] += now - started;
return now;
}
void recordDependencies(int count, long started) {
dependencyNanos += System.nanoTime() - started;
dependencies += count;
admissions++;
}
void log(HydrologyTileKey key, int rank, long totalNanos) {
IrisLogging.debug(
"Hydrology owner %d,%d rank=%d drafted in %dms: context=%dms select=%dms settle=%dms publish=%dms deps=%d wait=%dms admissions=%d earlyOwners=%d routes=%d reuses=%d routeSolve=%dms rasters=%d raster=%dms filters=%d filter=%dms on %s",
key.tileX(),
key.tileZ(),
rank,
totalNanos / 1_000_000L,
phaseNanos[DraftPhase.CONTEXT.ordinal()] / 1_000_000L,
phaseNanos[DraftPhase.SELECT.ordinal()] / 1_000_000L,
phaseNanos[DraftPhase.SETTLE.ordinal()] / 1_000_000L,
phaseNanos[DraftPhase.PUBLISH.ordinal()] / 1_000_000L,
dependencies,
dependencyNanos / 1_000_000L,
admissions,
earlyOwners,
routeCalls,
routeCalls - routeSolves,
routeSolveNanos / 1_000_000L,
rasterCalls,
rasterNanos / 1_000_000L,
filterCalls,
filterNanos / 1_000_000L,
Thread.currentThread().getName()
);
}
}
@@ -0,0 +1,36 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
public record DrainageEdge(
long id,
long upstreamNodeId,
long downstreamNodeId,
long outletId,
double cost,
int contributingSurfaceSources,
int contributingUndergroundSources,
List<HydrologyPoint> centerline
) {
public DrainageEdge {
if (upstreamNodeId == downstreamNodeId) {
throw new IllegalArgumentException("A drainage edge cannot point to the same node.");
}
if (!Double.isFinite(cost) || cost <= 0D) {
throw new IllegalArgumentException("Drainage edge cost must be finite and positive.");
}
if (contributingSurfaceSources < 0 || contributingUndergroundSources < 0) {
throw new IllegalArgumentException("Contributing source counts cannot be negative.");
}
Objects.requireNonNull(centerline, "centerline");
centerline = List.copyOf(centerline);
if (centerline.size() < 2) {
throw new IllegalArgumentException("A drainage edge requires at least two centerline points.");
}
}
public int totalContributingSources() {
return contributingSurfaceSources + contributingUndergroundSources;
}
}
@@ -0,0 +1,26 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
public record DrainageNode(
long id,
int x,
int z,
HydrologyTerrainSample terrain,
double potential,
long outletId
) {
public DrainageNode {
Objects.requireNonNull(terrain, "terrain");
if (!Double.isFinite(potential) || potential < 0D) {
throw new IllegalArgumentException("Drainage potential must be finite and non-negative.");
}
if (terrain.ocean()) {
throw new IllegalArgumentException("Drainage nodes must remain landward of the first ocean column.");
}
}
public HydrologyPoint naturalPoint() {
return new HydrologyPoint(x, terrain.naturalHeight(), z);
}
}
@@ -0,0 +1,11 @@
package art.arcane.iris.generation.hydrology;
record FootprintCenterlineProjection(
int pointIndex,
double x,
double z,
double distance,
int flowX,
int flowZ
) {
}
@@ -0,0 +1,11 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
record FootprintCourseRasterKey(
long courseId,
RiverCourseType courseType,
String profileKey,
List<HydraulicSegment> segments
) {
}
@@ -0,0 +1,11 @@
package art.arcane.iris.generation.hydrology;
record FootprintFeatureKey(
long courseId,
long segmentId,
HydrologyFeatureType type,
int role,
int flowX,
int flowZ
) {
}
@@ -0,0 +1,20 @@
package art.arcane.iris.generation.hydrology;
record FootprintLayerShape(
int channelRadius,
double shoreWidth,
double gradingWidth,
int bed,
int fluidHead,
int ceiling,
boolean ellipsoid,
boolean archedChannel,
boolean roundedSurfaceBed,
boolean organicBoundary,
boolean fallingThroat,
boolean inlet
) {
double totalRadius() {
return channelRadius + shoreWidth + gradingWidth;
}
}
@@ -0,0 +1,173 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
final class FootprintMutableColumn {
private final int x;
private final int z;
private final int naturalHeight;
private final int seaLevel;
private final boolean ocean;
private final String parentBiomeKey;
private HydrologyColumnLayer singleLayer;
private LinkedHashMap<Long, HydrologyColumnLayer> layers;
FootprintMutableColumn(int x, int z, HydrologyTerrainSample terrain, int seaLevel) {
this.x = x;
this.z = z;
this.naturalHeight = terrain.naturalHeight();
this.seaLevel = seaLevel;
this.ocean = terrain.ocean();
this.parentBiomeKey = terrain.parentBiomeKey();
}
FootprintMutableColumn(HydrologyColumnSample sample) {
this.x = sample.x();
this.z = sample.z();
this.naturalHeight = sample.naturalHeight();
this.seaLevel = sample.seaLevel();
this.ocean = sample.ocean();
this.parentBiomeKey = sample.parentBiomeKey();
for (HydrologyColumnLayer layer : sample.layers()) {
add(layer);
}
}
void add(HydrologyColumnLayer layer) {
if (layers == null) {
if (singleLayer == null) {
singleLayer = layer;
return;
}
if (singleLayer.feature().id() == layer.feature().id()) {
singleLayer = merge(singleLayer, layer);
return;
}
layers = new LinkedHashMap<>();
layers.put(singleLayer.feature().id(), singleLayer);
singleLayer = null;
}
HydrologyColumnLayer existing = layers.get(layer.feature().id());
layers.put(layer.feature().id(), existing == null ? layer : merge(existing, layer));
}
HydrologyColumnSample build() {
List<HydrologyColumnLayer> builtLayers;
if (layers != null) {
builtLayers = resolveSurfaceHeadConflicts(new ArrayList<>(layers.values()));
} else if (singleLayer != null) {
builtLayers = List.of(singleLayer);
} else {
builtLayers = List.of();
}
return new HydrologyColumnSample(
x,
z,
naturalHeight,
seaLevel,
ocean,
parentBiomeKey,
builtLayers
);
}
private List<HydrologyColumnLayer> resolveSurfaceHeadConflicts(List<HydrologyColumnLayer> candidates) {
HashMap<Long, HydrologyColumnLayer> selectedByCourse = new HashMap<>();
for (HydrologyColumnLayer candidate : candidates) {
if (!conflictingSurfaceCandidate(candidate)) {
continue;
}
long courseId = candidate.feature().courseId();
HydrologyColumnLayer selected = selectedByCourse.get(courseId);
if (selected == null || prefersSurfaceLayer(candidate, selected)) {
selectedByCourse.put(courseId, candidate);
}
}
if (selectedByCourse.isEmpty()) {
return candidates;
}
ArrayList<HydrologyColumnLayer> resolved = new ArrayList<>(candidates.size());
for (HydrologyColumnLayer candidate : candidates) {
HydrologyColumnLayer selected = selectedByCourse.get(candidate.feature().courseId());
if (!conflictingSurfaceCandidate(candidate)
|| selected == null
|| candidate.fluidHeadY() == selected.fluidHeadY()) {
resolved.add(candidate);
}
}
return List.copyOf(resolved);
}
private boolean conflictingSurfaceCandidate(HydrologyColumnLayer layer) {
return layer.feature().type().isSurface()
&& layer.channel()
&& layer.connectedFluid()
&& layer.fluidOwned()
&& !layer.fallingFluid();
}
private boolean prefersSurfaceLayer(HydrologyColumnLayer candidate, HydrologyColumnLayer selected) {
if (candidate.receivingPool() != selected.receivingPool()) {
return candidate.receivingPool();
}
if (candidate.fluidHeadY() != selected.fluidHeadY()) {
return candidate.fluidHeadY() > selected.fluidHeadY();
}
return candidate.feature().id() < selected.feature().id();
}
void merge(HydrologyColumnSample sample) {
if (sample.x() != x
|| sample.z() != z
|| sample.naturalHeight() != naturalHeight
|| sample.seaLevel() != seaLevel
|| sample.ocean() != ocean
|| !sample.parentBiomeKey().equals(parentBiomeKey)) {
throw new IllegalStateException("Hydrology course footprints disagree on terrain metadata at "
+ x + "," + z + ".");
}
for (HydrologyColumnLayer layer : sample.layers()) {
add(layer);
}
}
private HydrologyColumnLayer merge(HydrologyColumnLayer first, HydrologyColumnLayer second) {
if (first.equals(second)) {
return first;
}
HydrologyFeatureRef feature = first.feature().y() <= second.feature().y()
? first.feature()
: second.feature();
boolean drySurfaceBlend = first.feature().type().isSurface()
&& second.feature().type().isSurface()
&& !first.channel()
&& !second.channel();
int bedY = drySurfaceBlend
? Math.max(first.bedY(), second.bedY())
: Math.min(first.bedY(), second.bedY());
return new HydrologyColumnLayer(
feature,
bedY,
Math.max(first.fluidHeadY(), second.fluidHeadY()),
Math.max(first.ceilingY(), second.ceilingY()),
first.channel() || second.channel(),
first.shore() || second.shore(),
first.grading() || second.grading(),
first.connectedFluid() || second.connectedFluid(),
first.fallingFluid() || second.fallingFluid(),
first.receivingPool() || second.receivingPool(),
(first.terrainOwned() || second.terrainOwned()) && (!drySurfaceBlend || bedY != naturalHeight),
first.fluidOwned() || second.fluidOwned(),
first.oceanApron() || second.oceanApron(),
first.profileKey(),
first.surfaceBiomeKey(),
first.mouthBiomeKey(),
first.shoreBiomeKey(),
first.bankBiomeKey(),
first.floodedCaveBiomeKey()
);
}
}
@@ -0,0 +1,11 @@
package art.arcane.iris.generation.hydrology;
record FootprintRasterStencil(
int[] deltaXs,
int[] deltaZs,
double[] distances
) {
int size() {
return deltaXs.length;
}
}
@@ -0,0 +1,37 @@
package art.arcane.iris.generation.hydrology;
import java.util.Arrays;
final class FootprintRasterStencilBuilder {
private int[] deltaXs;
private int[] deltaZs;
private double[] distances;
private int size;
FootprintRasterStencilBuilder() {
this.deltaXs = new int[64];
this.deltaZs = new int[64];
this.distances = new double[64];
}
void add(int deltaX, int deltaZ, double distance) {
if (size == deltaXs.length) {
int expandedSize = Math.multiplyExact(size, 2);
deltaXs = Arrays.copyOf(deltaXs, expandedSize);
deltaZs = Arrays.copyOf(deltaZs, expandedSize);
distances = Arrays.copyOf(distances, expandedSize);
}
deltaXs[size] = deltaX;
deltaZs[size] = deltaZ;
distances[size] = distance;
size++;
}
FootprintRasterStencil build() {
return new FootprintRasterStencil(
Arrays.copyOf(deltaXs, size),
Arrays.copyOf(deltaZs, size),
Arrays.copyOf(distances, size)
);
}
}
@@ -0,0 +1,8 @@
package art.arcane.iris.generation.hydrology;
record FootprintRasterStencilKey(
int channelRadius,
double shoreWidth,
double gradingWidth
) {
}
@@ -0,0 +1,12 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceFootprint;
import java.util.List;
record FootprintValidationCourseRaster(
long courseId,
List<HydrologyColumnSample> columns,
SurfaceFootprint surface
) {
}
@@ -0,0 +1,87 @@
package art.arcane.iris.generation.hydrology;
import java.util.Arrays;
import java.util.Objects;
public record HydraulicChannelProfile(double[] widths, double[] depths) {
public HydraulicChannelProfile {
Objects.requireNonNull(widths, "widths");
Objects.requireNonNull(depths, "depths");
if (widths.length == 0 || widths.length != depths.length) {
throw new IllegalArgumentException("Channel dimensions require equally sized, nonempty station arrays.");
}
widths = widths.clone();
depths = depths.clone();
for (int station = 0; station < widths.length; station++) {
if (!Double.isFinite(widths[station]) || widths[station] <= 0D
|| !Double.isFinite(depths[station]) || depths[station] <= 0D) {
throw new IllegalArgumentException("Channel dimensions must be finite and positive.");
}
}
}
public static HydraulicChannelProfile uniform(double width, double depth) {
return new HydraulicChannelProfile(new double[]{width}, new double[]{depth});
}
public static HydraulicChannelProfile range(double[] widths, double[] depths, int from, int to) {
Objects.checkFromToIndex(from, to, widths.length);
Objects.checkFromToIndex(from, to, depths.length);
return new HydraulicChannelProfile(Arrays.copyOfRange(widths, from, to), Arrays.copyOfRange(depths, from, to));
}
@Override
public double[] widths() {
return widths.clone();
}
@Override
public double[] depths() {
return depths.clone();
}
public int size() {
return widths.length;
}
public double widthAt(int station) {
return widths[widths.length == 1 ? 0 : station];
}
public double depthAt(int station) {
return depths[depths.length == 1 ? 0 : station];
}
public double maximumWidth() {
double maximum = 0D;
for (double width : widths) {
maximum = Math.max(maximum, width);
}
return maximum;
}
public double maximumDepth() {
double maximum = 0D;
for (double depth : depths) {
maximum = Math.max(maximum, depth);
}
return maximum;
}
@Override
public boolean equals(Object other) {
return other instanceof HydraulicChannelProfile profile
&& Arrays.equals(widths, profile.widths)
&& Arrays.equals(depths, profile.depths);
}
@Override
public int hashCode() {
return 31 * Arrays.hashCode(widths) + Arrays.hashCode(depths);
}
@Override
public String toString() {
return "HydraulicChannelProfile[widths=" + Arrays.toString(widths) + ", depths=" + Arrays.toString(depths) + "]";
}
}
@@ -0,0 +1,74 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
import java.util.Objects;
public record HydraulicSegment(
long id,
long courseId,
HydrologyFeatureType type,
int upstreamHeadY,
int downstreamHeadY,
int width,
int depth,
boolean fallingFluid,
boolean receivingPool,
List<HydrologyPoint> centerline,
HydraulicChannelProfile channelProfile
) {
public HydraulicSegment {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(centerline, "centerline");
Objects.requireNonNull(channelProfile, "channelProfile");
centerline = List.copyOf(centerline);
if (centerline.isEmpty()) {
throw new IllegalArgumentException("A hydraulic segment requires a centerline.");
}
if (upstreamHeadY < downstreamHeadY) {
throw new IllegalArgumentException("Hydraulic heads cannot rise downstream.");
}
if (width <= 0 || depth <= 0) {
throw new IllegalArgumentException("Hydraulic segment width and depth must be positive.");
}
if (channelProfile.size() != 1 && channelProfile.size() != centerline.size()) {
throw new IllegalArgumentException("Channel dimensions must cover every centerline station.");
}
if (channelProfile.maximumWidth() > width || channelProfile.maximumDepth() > depth) {
throw new IllegalArgumentException("Hydraulic segment bounds must contain its channel dimensions.");
}
int drop = upstreamHeadY - downstreamHeadY;
if (fallingFluid && drop == 0) {
throw new IllegalArgumentException("Falling fluid requires a positive head transition.");
}
if (drop > 0 && !type.isDrop()) {
throw new IllegalArgumentException("A positive head transition requires a drop segment type.");
}
if (drop == 0 && type.isDrop()) {
throw new IllegalArgumentException("A drop segment type requires a positive head transition.");
}
if (drop > 0 && !fallingFluid) {
if (centerline.getFirst().y() != upstreamHeadY || centerline.getLast().y() != downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must span its declared heads.");
}
int previousHead = upstreamHeadY;
for (HydrologyPoint point : centerline) {
if (point.y() > previousHead || point.y() < downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must descend monotonically.");
}
previousHead = point.y();
}
}
}
public int drop() {
return upstreamHeadY - downstreamHeadY;
}
public HydrologyPoint start() {
return centerline.getFirst();
}
public HydrologyPoint end() {
return centerline.getLast();
}
}
@@ -0,0 +1,325 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import art.arcane.volmlib.util.cache.CacheKey;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectMap;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Objects;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RejectedExecutionException;
final class HydrologyBasisCache {
private static final int MAXIMUM_ENTRIES = 32_768;
private static final int MAXIMUM_STRIPES = 64;
private final Stripe[] full;
private final Stripe[] withoutSlope;
HydrologyBasisCache() {
this(MAXIMUM_ENTRIES);
}
HydrologyBasisCache(int maximumEntries) {
if (maximumEntries < 1 || Integer.bitCount(maximumEntries) != 1) {
throw new IllegalArgumentException("Terrain basis cache size must be a positive power of two.");
}
int stripeCount = Math.min(MAXIMUM_STRIPES, maximumEntries);
full = new Stripe[stripeCount];
withoutSlope = new Stripe[stripeCount];
for (int index = 0; index < stripeCount; index++) {
full[index] = new Stripe(maximumEntries / stripeCount);
withoutSlope[index] = new Stripe(maximumEntries / stripeCount);
}
}
HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler) {
return sample(x, z, sampler, false);
}
HydrologyTerrainSample sampleWithoutSlope(int x, int z, HydrologyNaturalTerrainSampler sampler) {
return sample(x, z, sampler, true);
}
void seed(Long2ObjectMap<HydrologyTerrainSample> samples) {
seed(samples, full);
}
HydrologyTerrainSample[] sampleWithoutSlopeBatch(long[] coordinates, int count,
HydrologyNaturalTerrainSampler sampler) {
Objects.checkFromIndexSize(0, count, coordinates.length);
if (count > HydrologyTerrainSampler.MAXIMUM_BATCH_SIZE) {
throw new IllegalArgumentException("Terrain basis batch exceeds its sample limit");
}
HydrologyTerrainSample[] samples = new HydrologyTerrainSample[count];
Pending[] pending = new Pending[count];
long[] ownedCoordinates = new long[count];
Pending[] owned = new Pending[count];
int ownedCount = count;
Completion completion = new Completion();
int[] next = new int[count];
int[] heads = groupByStripe(coordinates, count, next);
try {
for (int stripeIndex = 0; stripeIndex < withoutSlope.length; stripeIndex++) {
if (heads[stripeIndex] == 0) {
continue;
}
Stripe stripe = withoutSlope[stripeIndex];
synchronized (stripe) {
for (int position = heads[stripeIndex]; position != 0; position = next[position - 1]) {
int index = position - 1;
long packed = coordinates[index];
HydrologyTerrainSample cached = stripe.samples.getAndMoveToFirst(packed);
if (cached != null || stripe.samples.containsKey(packed)) {
samples[index] = cached;
continue;
}
Pending loading = stripe.pending.get(packed);
if (loading == null) {
loading = new Pending(completion);
stripe.pending.put(packed, loading);
ownedCoordinates[index] = packed;
owned[index] = loading;
}
pending[index] = loading;
}
}
}
int claimed = 0;
for (int index = 0; index < count; index++) {
if (owned[index] != null) {
ownedCoordinates[claimed] = ownedCoordinates[index];
owned[claimed++] = owned[index];
}
}
ownedCount = claimed;
if (ownedCount > 0) {
loadBatch(ownedCoordinates, owned, ownedCount, sampler);
}
} catch (RuntimeException | Error failure) {
for (int index = 0; index < ownedCount; index++) {
if (owned[index] != null) {
fail(withoutSlope, ownedCoordinates[index], owned[index], failure);
}
}
completion.failure = failure;
throw failure;
} finally {
completion.complete.countDown();
}
for (int index = 0; index < count; index++) {
if (pending[index] != null) {
samples[index] = pending[index].await();
}
}
return samples;
}
void seedWithoutSlope(Long2ObjectMap<HydrologyTerrainSample> samples) {
seed(samples, withoutSlope);
}
private HydrologyTerrainSample sample(int x, int z, HydrologyNaturalTerrainSampler sampler, boolean omitSlope) {
long packed = RiverFootprint.pack(x, z);
Stripe[] stripes = omitSlope ? withoutSlope : full;
Stripe stripe = stripe(stripes, packed);
Pending loading;
boolean owner = false;
synchronized (stripe) {
HydrologyTerrainSample cached = stripe.samples.getAndMoveToFirst(packed);
if (cached != null || stripe.samples.containsKey(packed)) {
return cached;
}
loading = stripe.pending.get(packed);
if (loading == null) {
loading = new Pending(new Completion());
stripe.pending.put(packed, loading);
owner = true;
}
}
if (owner) {
try {
HydrologyTerrainSample sampled = ProvisionalSampling.memoizable(() -> omitSlope
? sampler.sampleBasisWithoutSlope(x, z) : sampler.sampleBasis(x, z));
complete(stripes, packed, loading, sampled, false);
} catch (ProvisionalSampling.Unmemoizable provisional) {
complete(stripes, packed, loading, provisional.value(), true);
} catch (RuntimeException | Error failure) {
fail(stripes, packed, loading, failure);
throw failure;
} finally {
loading.completion.complete.countDown();
}
}
return loading.await();
}
private void loadBatch(long[] coordinates, Pending[] pending, int count, HydrologyNaturalTerrainSampler sampler) {
HydrologyTerrainSample[] samples;
boolean provisional = false;
try {
samples = ProvisionalSampling.memoizable(() -> sampler.sampleBasisWithoutSlopeBatch(coordinates, count));
} catch (ProvisionalSampling.Unmemoizable value) {
samples = value.value();
provisional = true;
}
if (samples.length < count) {
throw new IllegalStateException("Terrain basis batch returned too few samples");
}
int[] next = new int[count];
int[] heads = groupByStripe(coordinates, count, next);
for (int stripeIndex = 0; stripeIndex < withoutSlope.length; stripeIndex++) {
if (heads[stripeIndex] == 0) {
continue;
}
Stripe stripe = withoutSlope[stripeIndex];
synchronized (stripe) {
for (int position = heads[stripeIndex]; position != 0; position = next[position - 1]) {
int index = position - 1;
complete(stripe, coordinates[index], pending[index], samples[index], provisional);
}
}
}
}
private int[] groupByStripe(long[] coordinates, int count, int[] next) {
int[] heads = new int[withoutSlope.length];
for (int index = count - 1; index >= 0; index--) {
int stripeIndex = (int) CacheKey.mix(coordinates[index]) & (withoutSlope.length - 1);
next[index] = heads[stripeIndex];
heads[stripeIndex] = index + 1;
}
return heads;
}
private void complete(Stripe[] stripes, long packed, Pending pending, HydrologyTerrainSample sample,
boolean provisional) {
Stripe stripe = stripe(stripes, packed);
synchronized (stripe) {
complete(stripe, packed, pending, sample, provisional);
}
}
private void complete(Stripe stripe, long packed, Pending pending, HydrologyTerrainSample sample,
boolean provisional) {
if (stripe.samples.containsKey(packed)) {
sample = stripe.samples.getAndMoveToFirst(packed);
provisional = false;
} else if (!provisional) {
stripe.put(packed, sample);
}
stripe.pending.remove(packed);
pending.sample = sample;
pending.provisional = provisional;
}
private void fail(Stripe[] stripes, long packed, Pending pending, Throwable failure) {
Stripe stripe = stripe(stripes, packed);
synchronized (stripe) {
if (stripe.pending.remove(packed, pending)) {
pending.completion.failure = failure;
}
}
}
private void seed(Long2ObjectMap<HydrologyTerrainSample> samples, Stripe[] stripes) {
for (Long2ObjectMap.Entry<HydrologyTerrainSample> entry : samples.long2ObjectEntrySet()) {
Stripe stripe = stripe(stripes, entry.getLongKey());
synchronized (stripe) {
stripe.put(entry.getLongKey(), entry.getValue());
}
}
}
private Stripe stripe(Stripe[] stripes, long packed) {
return stripes[(int) CacheKey.mix(packed) & (stripes.length - 1)];
}
private static final class Stripe {
private final int maximumEntries;
private final Long2ObjectLinkedOpenHashMap<HydrologyTerrainSample> samples;
private final Long2ObjectOpenHashMap<Pending> pending = new Long2ObjectOpenHashMap<>();
private Stripe(int maximumEntries) {
this.maximumEntries = maximumEntries;
samples = new Long2ObjectLinkedOpenHashMap<>(Math.min(16, maximumEntries));
}
private void put(long packed, HydrologyTerrainSample value) {
if (samples.size() >= maximumEntries && !samples.containsKey(packed)) {
samples.removeLast();
}
samples.putAndMoveToFirst(packed, value);
}
}
private static final class Pending {
private final Completion completion;
private HydrologyTerrainSample sample;
private boolean provisional;
private Pending(Completion completion) {
this.completion = completion;
}
private HydrologyTerrainSample await() {
completion.await();
if (provisional) {
ProvisionalSampling.mark();
}
return sample;
}
}
private static final class Completion implements ForkJoinPool.ManagedBlocker {
private final Thread owner = Thread.currentThread();
private final CountDownLatch complete = new CountDownLatch(1);
private Throwable failure;
private void await() {
if (!isReleasable() && owner == Thread.currentThread()) {
throw new IllegalStateException("Recursive terrain basis sampling");
}
boolean interrupted = false;
boolean managed = true;
try {
while (!isReleasable()) {
try {
if (managed) {
ForkJoinPool.managedBlock(this);
} else {
complete.await();
}
} catch (InterruptedException interruption) {
interrupted = true;
managed = false;
} catch (RejectedExecutionException unavailable) {
managed = false;
}
}
} finally {
if (interrupted) {
Thread.currentThread().interrupt();
}
}
if (failure instanceof Error error) {
throw error;
}
if (failure instanceof RuntimeException runtime) {
throw runtime;
}
}
@Override
public boolean block() throws InterruptedException {
complete.await();
return true;
}
@Override
public boolean isReleasable() {
return complete.getCount() == 0;
}
}
}
@@ -0,0 +1,72 @@
package art.arcane.iris.generation.hydrology;
final class HydrologyCacheBudget {
private static final long MEBIBYTE = 1024L * 1024L;
private static final HydrologyCacheBudget REGIONAL_RUNTIME = forHeap(Runtime.getRuntime().maxMemory(), true);
private static final HydrologyCacheBudget LOCAL_RUNTIME = forHeap(Runtime.getRuntime().maxMemory(), false);
private final long plannerBytes;
private final long publicationBytes;
private final boolean regionalEnabled;
private HydrologyCacheBudget(long retainedBytes, boolean regionalEnabled) {
plannerBytes = retainedBytes / 2L;
publicationBytes = retainedBytes - plannerBytes;
this.regionalEnabled = regionalEnabled;
}
static HydrologyCacheBudget runtime() {
return REGIONAL_RUNTIME;
}
static HydrologyCacheBudget runtime(boolean regionalEnabled) {
return regionalEnabled ? REGIONAL_RUNTIME : LOCAL_RUNTIME;
}
static HydrologyCacheBudget forHeap(long maximumHeapBytes, boolean regionalEnabled) {
if (maximumHeapBytes <= 0L) {
throw new IllegalArgumentException("Maximum heap size must be positive.");
}
return new HydrologyCacheBudget(Math.clamp(maximumHeapBytes / 8L, 8L * MEBIBYTE, 256L * MEBIBYTE), regionalEnabled);
}
static long sharedStudioBytes() {
return Math.clamp(Runtime.getRuntime().maxMemory() / 128L, MEBIBYTE, 16L * MEBIBYTE);
}
long tileBytes() {
return publicationBytes * 3L / 4L;
}
long columnBytes() {
return publicationBytes / 8L;
}
long diagnosticBytes() {
return publicationBytes / 8L;
}
long routingBytes() {
return plannerBytes * 3L / 16L;
}
long ownerBytes() {
return regionalEnabled ? plannerBytes / 4L : plannerBytes * 3L / 4L;
}
long edgeBytes() {
return plannerBytes / 16L;
}
long regionalDraftBytes() {
return regionalEnabled ? plannerBytes / 8L : 0L;
}
long regionalCoarseBytes() {
return regionalEnabled ? plannerBytes / 8L : 0L;
}
long regionalTerrainBytes() {
return regionalEnabled ? plannerBytes / 4L : 0L;
}
}
@@ -0,0 +1,127 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
import java.util.List;
final class HydrologyCacheWeights {
private static final long ENTRY_BYTES = 192L;
private static final long TERRAIN_BYTES = 256L;
private static final long POINT_BYTES = 40L;
private HydrologyCacheWeights() {
}
static int bounded(long estimatedBytes, long budget, int maximumEntries) {
return saturated(Math.max(estimatedBytes, Math.ceilDiv(budget, maximumEntries)));
}
static int bounded(long estimatedBytes, long budget, int maximumEntries, int units, int maximumUnits) {
return bounded(Math.max(estimatedBytes, Math.ceilDiv((long) units * budget, maximumUnits)), budget, maximumEntries);
}
static int tile(HydrologyTile tile) {
long bytes = network(tile.nodes(), tile.edges(), tile.outlets(), tile.courses(), tile.cavePlans());
bytes += diagnostics(tile.localDiagnosticCandidates());
bytes += (long) tile.regionalCourseIds().size() * 64L;
if (tile.resolvedOwner() != null) {
bytes += owner(tile.resolvedOwner());
}
RiverFootprint footprint = tile.footprint();
if (footprint != null) {
bytes += (long) footprint.size() * 64L;
for (HydrologyColumnSample sample : footprint.columns().values()) {
bytes += column(sample);
}
}
return saturated(bytes);
}
static int column(HydrologyColumnSample sample) {
return saturated(96L + (long) sample.layers().size() * 192L);
}
static int diagnostics(List<HydrologyDiagnosticCandidate> diagnostics) {
return saturated(ENTRY_BYTES + (long) diagnostics.size() * 112L);
}
static int routing(SourceRoutingContext context) {
return saturated(ENTRY_BYTES + grid(context.grid()) + routingPlan(context.surfaceRouting())
+ routingPlan(context.undergroundRouting()) + diagnostics(context.diagnostics()));
}
static int owner(CrossTileResolvedOwner owner) {
HydrologyCaveCourseFilter.Result result = owner.draft().result();
long bytes = network(result.nodes(), result.edges(), result.outlets(), result.courses(), result.cavePlans())
+ diagnostics(owner.draft().diagnostics());
for (CrossTileRejectedCourse rejection : owner.observedRejections()) {
bytes += 48L + course(rejection.course());
}
return saturated(bytes);
}
static int points(List<HydrologyPoint> points) {
return saturated(ENTRY_BYTES + (long) points.size() * POINT_BYTES);
}
static int regionalNetwork(HydrologyRegionalNetwork network) {
return saturated(network(network.nodes(), network.edges(), network.outlets(), network.courses(), network.cavePlans())
+ diagnostics(network.diagnostics()));
}
static int regionalCoarse(HydrologyRegionalCoarsePlan plan) {
HydrologyRegionalGraph.Tree tree = plan.tree();
long treeBytes = tree == null ? 0L : 96L + (long) tree.selected().length * 8L
+ ((long) tree.accumulation().length + tree.heads().length) * 4L + tree.retainedLabelCount() * 96L;
return saturated(ENTRY_BYTES + grid(plan.grid()) + treeBytes + (long) plan.outlets().size() * 224L
+ (long) plan.sources().size() * 32L + (long) plan.envelopes().size() * 64L);
}
static int terrain(HydrologyTerrainSample terrain) {
return saturated(128L + (terrain == null ? 0L : TERRAIN_BYTES));
}
private static long grid(HydrologySampledGrid grid) {
return grid == null ? 0L : 96L + (long) grid.nodes().size() * (64L + TERRAIN_BYTES + 104L);
}
private static long routingPlan(HydrologyRoutingPlan plan) {
if (plan == null) {
return 0L;
}
return 128L + (long) plan.potential().length * 8L
+ ((long) plan.parent().length + plan.outletIndex().length + plan.routeLengths().length) * 4L
+ (long) plan.outlets().size() * 224L
+ (plan.surfaceDrainage() == null ? 0L : plan.surfaceDrainage().maximumRetainedBytes());
}
private static long network(List<DrainageNode> nodes, List<DrainageEdge> edges, List<RiverOutlet> outlets,
List<RiverCourse> courses, List<HydrologyCavePlan> cavePlans) {
long bytes = ENTRY_BYTES + (long) nodes.size() * (96L + TERRAIN_BYTES) + (long) outlets.size() * 224L;
for (DrainageEdge edge : edges) {
bytes += 96L + points(edge.centerline());
}
for (RiverCourse course : courses) {
bytes += course(course);
}
for (HydrologyCavePlan plan : cavePlans) {
bytes += plan.estimatedRetainedBytes();
}
return bytes;
}
private static long course(RiverCourse course) {
long bytes = 128L;
for (DrainageEdge edge : course.drainageEdges()) {
bytes += 96L + points(edge.centerline());
}
for (HydraulicSegment segment : course.segments()) {
bytes += 128L + points(segment.centerline()) + (long) segment.channelProfile().size() * 16L;
}
return bytes;
}
private static int saturated(long bytes) {
return (int) Math.clamp(bytes, 1L, Integer.MAX_VALUE);
}
}
@@ -0,0 +1,12 @@
package art.arcane.iris.generation.hydrology;
public enum HydrologyCandidateKind {
SOURCE,
OUTLET,
DEEP_FLUID,
POOL,
/** A later course to an outlet that could not be joined to the outlet's main course. */
TRIBUTARY,
REGIONAL_SOURCE,
COASTAL_CHANNEL
}
@@ -0,0 +1,28 @@
package art.arcane.iris.generation.hydrology;
public enum HydrologyCandidateRejection {
NO_LEGAL_OUTLET,
NO_DRAINAGE_PATH,
CONFINED_NO_OUTLET,
ROUTE_LIMIT,
COURSE_TOO_SHORT,
SOURCE_QUOTA,
SOURCE_SPACING,
OUTLET_LIMIT,
OUTLET_LEVEL,
CAVE_CONTAINMENT,
VOLUME_LIMIT,
SURFACE_HEAD_RANGE,
SURFACE_HEAD_DISTRIBUTION,
SURFACE_EXPOSURE,
SURFACE_DROP_UNSUPPORTED,
SURFACE_CORRIDOR_UNSUPPORTED,
SURFACE_TRANSITION_UNSUPPORTED,
SURFACE_SEGMENT_UNSUPPORTED,
SURFACE_SINKHOLE_CLEARANCE,
SURFACE_SHAPE_UNSUPPORTED,
SURFACE_MOUTH_DISCONNECTED,
SURFACE_WATER_CONTAINMENT,
SURFACE_BANK_BUDGET,
POLICY_EXCLUDED;
}
@@ -0,0 +1,14 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.CaveVoxelView;
@FunctionalInterface
public interface HydrologyCaveVoxelViewFactory {
CaveVoxelView create(PlannedSurface plannedSurface);
interface PlannedSurface {
int resolve(int x, int z, int naturalHeight);
boolean ownsTerrain(int x, int z);
}
}
@@ -0,0 +1,352 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.SurfaceNoise;
import art.arcane.iris.generation.hydrology.surface.SurfaceInletRange;
final class HydrologyChannelGeometry {
private final HydrologyFootprintCompiler compiler;
private RiverCourse inletCourse;
private SurfaceInletRange inletRange;
HydrologyChannelGeometry(HydrologyFootprintCompiler compiler) {
this.compiler = compiler;
}
static final long ORGANIC_SHAPE_FIRST_PHASE_SALT = 0x4f5247414e31L;
static final long ORGANIC_SHAPE_SECOND_PHASE_SALT = 0x4f5247414e32L;
static final long ORGANIC_BED_VARIATION_SALT = 0x424544564152L;
static final long ORGANIC_CEILING_VARIATION_SALT = 0x4345494c564152L;
double smoothStep(double progress) {
return progress * progress * (3D - 2D * progress);
}
FootprintLayerShape shape(
RiverCourse course,
HydraulicSegment segment,
HydrologyPoint point,
int bed,
int fluidHead,
boolean falling,
boolean receiving
) {
HydrologyFeatureType type = segment.type();
int channelRadius = Math.max(1, segment.width() / 2);
double shoreWidth = 0D;
double gradingWidth = 0D;
int ceiling = fluidHead;
boolean ellipsoid = false;
boolean archedChannel = false;
boolean roundedSurfaceBed = false;
boolean organicBoundary = type.isSurface() || type.isUnderground() || type.isDeepFluid() || falling || receiving;
if (course.type() == RiverCourseType.SURFACE && type.isSurface()) {
roundedSurfaceBed = !falling;
shoreWidth = compiler.settings.surface().shoreWidth();
gradingWidth = compiler.geometrySampler.sample(
HydrologyGeometrySampler.Field.SURFACE_BLEND_WIDTH,
course.profileKey(),
point.x(),
point.z(),
segment.id(),
compiler.settings.surface().banks().minimumBlendWidth(),
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));
}
}
if (type == HydrologyFeatureType.UNDERGROUND_POOL
|| type == HydrologyFeatureType.UNDERGROUND_DROP
|| type == HydrologyFeatureType.SINKHOLE) {
ceiling = fluidHead + compiler.geometrySampler.sample(
HydrologyGeometrySampler.Field.UNDERGROUND_HEADROOM,
course.profileKey(),
point.x(),
point.z(),
course.id(),
compiler.settings.underground().minimumHeadroom(),
compiler.settings.underground().maximumHeadroom()
);
archedChannel = !falling;
} else if (type == HydrologyFeatureType.COASTAL_GROTTO) {
HydrologyPlannerSettings.Grotto grotto = compiler.settings.outlets().coastalGrotto();
channelRadius = grotto.horizontalRadius();
bed = fluidHead - grotto.verticalRadius();
ceiling = fluidHead + grotto.headroom();
ellipsoid = true;
organicBoundary = true;
} else if (type == HydrologyFeatureType.INLAND_GROTTO) {
HydrologyPlannerSettings.Grotto grotto = compiler.settings.outlets().inlandGrotto();
channelRadius = grotto.horizontalRadius();
bed = fluidHead - grotto.verticalRadius();
ceiling = fluidHead + grotto.headroom();
ellipsoid = true;
organicBoundary = true;
} else if (type.isDeepFluid()) {
HydrologyPlannerSettings.DeepFluid deepFluid = compiler.deepFluids.get(course.profileKey());
if (deepFluid == null) {
throw new IllegalStateException("Missing deep-fluid planner settings for " + course.profileKey() + ".");
}
if (type == HydrologyFeatureType.DEEP_POOL) {
channelRadius = Math.max(1, segment.width() / 2);
bed = fluidHead - Math.max(segment.depth(), deepFluid.minimumVerticalRadius());
ellipsoid = true;
} else {
archedChannel = !falling;
}
ceiling = fluidHead + deepFluid.headroom();
organicBoundary = true;
}
if (receiving) {
HydrologyPlannerSettings.Drops drops = compiler.settings.geometry().drops();
if (course.type() != RiverCourseType.SURFACE || !type.isSurface()) {
channelRadius = Math.max(
channelRadius,
(int) StrictMath.ceil(drops.basinWidth(segment.width()) / 2D)
);
}
bed = Math.min(bed, fluidHead - drops.basinDepth(segment.depth(), segment.drop()));
if (course.type() == RiverCourseType.SURFACE && type.isSurface()) {
gradingWidth = Math.max(
gradingWidth,
Math.min(compiler.settings.surface().banks().maximumBlendWidth(), channelRadius * 2D)
);
}
}
if (type == HydrologyFeatureType.UNDERGROUND_POOL && segment.centerline().size() > 1) {
int depthVariation = Math.min(
2,
Math.max(0, compiler.settings.underground().maximumDepth() - (fluidHead - bed))
);
bed -= organicVerticalVariation(
HydrologyHash.mix(segment.id(), ORGANIC_BED_VARIATION_SALT),
point.x(),
point.z(),
depthVariation
);
}
if (falling) {
bed = Math.min(bed, segment.downstreamHeadY() - 1);
ceiling = Math.max(ceiling, segment.upstreamHeadY());
}
return new FootprintLayerShape(
channelRadius,
shoreWidth,
gradingWidth,
bed,
fluidHead,
ceiling,
ellipsoid,
archedChannel,
roundedSurfaceBed,
organicBoundary,
falling,
inletStation(course, segment, point)
);
}
private boolean inletStation(RiverCourse course, HydraulicSegment segment, HydrologyPoint point) {
if (course.type() != RiverCourseType.SURFACE || !segment.type().isSurface() || !segment.fallingFluid()) {
return false;
}
if (inletCourse != course) {
HydrologyTerrainSampler receiving = HydrologyOceanReceiver.forCourse(
compiler.settings, compiler::sampleTerrainBasis, course);
inletRange = SurfaceInletRange.forCourse(compiler.settings, course, receiving);
inletCourse = course;
}
return inletRange.allowsFallingStation(segment.id(), point.x(), point.z());
}
int organicVerticalVariation(
long seed,
int x,
int z,
int maximum
) {
if (maximum == 0) {
return 0;
}
double sampled = SurfaceNoise.value(seed, x, z, 12);
return (int) StrictMath.round(sampled * maximum);
}
double ellipsoidScale(int radius, double distance, double roundness) {
double normalized = Math.min(1D, distance / Math.max(1D, radius));
return StrictMath.pow(
Math.max(0D, 1D - StrictMath.pow(normalized, roundness)),
1D / roundness
);
}
double shapedDistance(
FootprintLayerShape shape,
HydraulicSegment segment,
int worldX,
int worldZ,
double deltaX,
double deltaZ,
int flowX,
int flowZ,
double rawDistance
) {
if (segment.type() == HydrologyFeatureType.DEEP_POOL) {
return deepPoolDistance(shape, segment, worldX, worldZ, deltaX, deltaZ);
}
if (segment.type().isSurface() && !shape.fallingThroat()) {
return surfaceChannelDistance(shape, segment, worldX, worldZ, deltaX, deltaZ, flowX, flowZ);
}
if (rawDistance == 0D) {
return 0D;
}
double distance = rawDistance;
if (shape.fallingThroat() && (flowX != 0 || flowZ != 0)) {
double flowLength = StrictMath.hypot(flowX, flowZ);
double along = (deltaX * flowX + deltaZ * flowZ) / flowLength;
double across = StrictMath.abs(deltaX * -flowZ + deltaZ * flowX) / flowLength;
if (StrictMath.abs(along) > 0.75D || segment.type().isSurface() && along < 0D) {
return shape.totalRadius() + 1D;
}
distance = across;
}
if (!shape.organicBoundary()) {
return distance;
}
HydrologyPlannerSettings.ChannelShape channelShape = channelShape(segment.type());
if (segment.type() == HydrologyFeatureType.COASTAL_GROTTO
|| segment.type() == HydrologyFeatureType.INLAND_GROTTO) {
double orientation = HydrologyHash.unit(HydrologyHash.mix(
segment.courseId(),
ORGANIC_SHAPE_FIRST_PHASE_SALT
)) * StrictMath.PI * 2D;
double cosine = StrictMath.cos(orientation);
double sine = StrictMath.sin(orientation);
double rotatedX = deltaX * cosine + deltaZ * sine;
double rotatedZ = -deltaX * sine + deltaZ * cosine;
double aspect = channelShape.aspectMinimum() + HydrologyHash.unit(HydrologyHash.mix(
segment.courseId(),
ORGANIC_SHAPE_SECOND_PHASE_SALT
)) * channelShape.aspectRange();
distance = StrictMath.hypot(rotatedX, rotatedZ / aspect);
}
double angle = StrictMath.atan2(deltaZ, deltaX);
double firstPhase = HydrologyHash.unit(HydrologyHash.mix(
segment.courseId(),
ORGANIC_SHAPE_FIRST_PHASE_SALT
)) * StrictMath.PI * 2D;
double secondPhase = HydrologyHash.unit(HydrologyHash.mix(
segment.courseId(),
ORGANIC_SHAPE_SECOND_PHASE_SALT
)) * StrictMath.PI * 2D;
double firstLobe = 0.5D + 0.5D * StrictMath.sin(angle * 3D + firstPhase);
double secondLobe = 0.5D + 0.5D * StrictMath.sin(angle * 5D + secondPhase);
double coherent = SurfaceNoise.signed(
segment.courseId(),
worldX,
worldZ,
channelShape.roughnessWavelength()
);
double detail = SurfaceNoise.signed(
HydrologyHash.mix(segment.courseId(), ORGANIC_SHAPE_SECOND_PHASE_SALT),
worldX,
worldZ,
Math.max(3, channelShape.roughnessWavelength() / 2)
);
double radialScale = channelShape.radialBase()
+ (firstLobe - 0.5D) * channelShape.primaryLobeStrength()
+ (secondLobe - 0.5D) * channelShape.detailLobeStrength()
+ coherent * channelShape.wallRoughness() * 0.7D
+ detail * channelShape.wallRoughness() * 0.3D;
radialScale = Math.max(channelShape.radialMinimum(), Math.min(channelShape.radialMaximum(), radialScale));
if (segment.type().isSurface() && shape.fallingThroat()) {
radialScale = Math.max(1D, radialScale);
}
return distance / radialScale;
}
double surfaceChannelDistance(
FootprintLayerShape shape,
HydraulicSegment segment,
int worldX,
int worldZ,
double deltaX,
double deltaZ,
int flowX,
int flowZ
) {
double flowLength = StrictMath.hypot(flowX, flowZ);
if (flowLength == 0D) {
return deepPoolDistance(shape, segment, worldX, worldZ, deltaX, deltaZ);
}
double signedCross = (deltaX * -flowZ + deltaZ * flowX) / flowLength;
HydrologyPlannerSettings.ChannelShape channelShape = compiler.settings.geometry().surface();
double thalweg = SurfaceNoise.signed(
HydrologyHash.mix(segment.courseId(), ORGANIC_BED_VARIATION_SALT),
worldX,
worldZ,
channelShape.roughnessWavelength()
) * shape.channelRadius() * channelShape.wallRoughness();
long bankSeed = HydrologyHash.mix(segment.courseId(), ORGANIC_SHAPE_SECOND_PHASE_SALT);
double bankNoise = SurfaceNoise.signed(
bankSeed,
worldX,
worldZ,
channelShape.roughnessWavelength()
);
double sideBias = signedCross < thalweg
? HydrologyHash.unit(HydrologyHash.mix(bankSeed, 1L))
: HydrologyHash.unit(HydrologyHash.mix(bankSeed, 2L));
double widthScale = 0.86D
+ bankNoise * channelShape.wallRoughness()
+ (sideBias - 0.5D) * 0.08D;
widthScale = Math.max(1D, Math.min(1.18D, widthScale));
double organicDistance = StrictMath.abs(signedCross - thalweg) / widthScale;
double along = (deltaX * flowX + deltaZ * flowZ) / flowLength;
return StrictMath.hypot(along, Math.min(StrictMath.abs(signedCross), organicDistance));
}
HydrologyPlannerSettings.ChannelShape channelShape(HydrologyFeatureType type) {
if (type.isGrotto() || type == HydrologyFeatureType.DEEP_POOL) {
return compiler.settings.geometry().grottos();
}
if (type.isUnderground() || type == HydrologyFeatureType.DEEP_CHANNEL) {
return compiler.settings.geometry().underground();
}
return compiler.settings.geometry().surface();
}
double deepPoolDistance(
FootprintLayerShape shape,
HydraulicSegment segment,
int worldX,
int worldZ,
double deltaX,
double deltaZ
) {
double radius = Math.max(1D, shape.channelRadius());
long seed = HydrologyHash.mix(segment.courseId(), ORGANIC_SHAPE_FIRST_PHASE_SALT);
double orientation = HydrologyHash.unit(seed) * StrictMath.PI * 2D;
double cosine = StrictMath.cos(orientation);
double sine = StrictMath.sin(orientation);
double rotatedX = deltaX * cosine + deltaZ * sine;
double rotatedZ = -deltaX * sine + deltaZ * cosine;
double angle = StrictMath.atan2(rotatedZ, rotatedX);
double secondPhase = HydrologyHash.unit(HydrologyHash.mix(seed, 2L)) * StrictMath.PI * 2D;
double thirdPhase = HydrologyHash.unit(HydrologyHash.mix(seed, 3L)) * StrictMath.PI * 2D;
double fifthPhase = HydrologyHash.unit(HydrologyHash.mix(seed, 5L)) * StrictMath.PI * 2D;
double radialScale = 0.84D
+ StrictMath.sin(angle * 2D + secondPhase) * 0.07D
+ StrictMath.sin(angle * 3D + thirdPhase) * 0.05D
+ StrictMath.sin(angle * 5D + fifthPhase) * 0.035D;
radialScale = Math.max(0.68D, Math.min(1D, radialScale));
double normalizedDistance = StrictMath.hypot(
rotatedX / (radius * 0.92D),
rotatedZ / (radius * 0.7D)
);
return normalizedDistance * radius / radialScale;
}
}
@@ -0,0 +1,64 @@
package art.arcane.iris.generation.hydrology;
/**
* The chunks a planned tile publishes at least one footprint column into. A chunk outside every
* relevant tile's occupancy composes to no hydrology at all, so it can be answered without the
* tiles being resident.
*/
final class HydrologyChunkOccupancy {
static final HydrologyChunkOccupancy EMPTY = new HydrologyChunkOccupancy(0, 0, 0, 0, new long[0]);
private final int minimumChunkX;
private final int minimumChunkZ;
private final int width;
private final int depth;
private final long[] bits;
private HydrologyChunkOccupancy(int minimumChunkX, int minimumChunkZ, int width, int depth, long[] bits) {
this.minimumChunkX = minimumChunkX;
this.minimumChunkZ = minimumChunkZ;
this.width = width;
this.depth = depth;
this.bits = bits;
}
static HydrologyChunkOccupancy of(RiverFootprint footprint) {
if (footprint == null) {
return EMPTY;
}
int minimumX = Integer.MAX_VALUE;
int minimumZ = Integer.MAX_VALUE;
int maximumX = Integer.MIN_VALUE;
int maximumZ = Integer.MIN_VALUE;
for (long packed : footprint.columns().keySet()) {
int chunkX = RiverFootprint.unpackX(packed) >> 4;
int chunkZ = RiverFootprint.unpackZ(packed) >> 4;
minimumX = Math.min(minimumX, chunkX);
minimumZ = Math.min(minimumZ, chunkZ);
maximumX = Math.max(maximumX, chunkX);
maximumZ = Math.max(maximumZ, chunkZ);
}
if (minimumX > maximumX) {
return EMPTY;
}
int width = maximumX - minimumX + 1;
int depth = maximumZ - minimumZ + 1;
long[] bits = new long[Math.toIntExact(Math.ceilDiv(Math.multiplyExact((long) width, depth), 64L))];
for (long packed : footprint.columns().keySet()) {
int index = ((RiverFootprint.unpackZ(packed) >> 4) - minimumZ) * width
+ (RiverFootprint.unpackX(packed) >> 4) - minimumX;
bits[index >>> 6] |= 1L << index;
}
return new HydrologyChunkOccupancy(minimumX, minimumZ, width, depth, bits);
}
boolean occupies(int chunkX, int chunkZ) {
int localX = chunkX - minimumChunkX;
int localZ = chunkZ - minimumChunkZ;
if (localX < 0 || localZ < 0 || localX >= width || localZ >= depth) {
return false;
}
int index = localZ * width + localX;
return (bits[index >>> 6] & 1L << index) != 0L;
}
}
@@ -0,0 +1,89 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
public record HydrologyColumnLayer(
HydrologyFeatureRef feature,
int bedY,
int fluidHeadY,
int ceilingY,
boolean channel,
boolean shore,
boolean grading,
boolean connectedFluid,
boolean fallingFluid,
boolean receivingPool,
boolean terrainOwned,
boolean fluidOwned,
boolean oceanApron,
String profileKey,
String surfaceBiomeKey,
String mouthBiomeKey,
String shoreBiomeKey,
String bankBiomeKey,
String floodedCaveBiomeKey
) {
public HydrologyColumnLayer {
Objects.requireNonNull(feature, "feature");
profileKey = requireKey(profileKey, "profileKey");
surfaceBiomeKey = requireKey(surfaceBiomeKey, "surfaceBiomeKey");
mouthBiomeKey = requireKey(mouthBiomeKey, "mouthBiomeKey");
shoreBiomeKey = requireKey(shoreBiomeKey, "shoreBiomeKey");
bankBiomeKey = requireKey(bankBiomeKey, "bankBiomeKey");
floodedCaveBiomeKey = requireKey(floodedCaveBiomeKey, "floodedCaveBiomeKey");
if (channel && bedY > fluidHeadY) {
throw new IllegalArgumentException("A channel bed cannot be above its fluid head.");
}
if (ceilingY < fluidHeadY) {
throw new IllegalArgumentException("A hydrology carve ceiling cannot be below its fluid head.");
}
if (!feature.type().isUnderground() && !feature.type().isDeepFluid() && ceilingY != fluidHeadY) {
throw new IllegalArgumentException("Surface hydrology ceilings must equal the fluid head.");
}
if (fluidOwned && !channel) {
throw new IllegalArgumentException("Owned fluid requires a channel footprint.");
}
if (fallingFluid && !connectedFluid) {
throw new IllegalArgumentException("Falling fluid must be connected.");
}
if (receivingPool && !connectedFluid) {
throw new IllegalArgumentException("A receiving pool must be connected.");
}
if (oceanApron && (terrainOwned || fluidOwned || grading || shore)) {
throw new IllegalArgumentException("An ocean apron cannot own river writes or grading.");
}
}
public String biomeKey() {
if (grading && !channel && !shore) {
return bankBiomeKey;
}
if (shore) {
return shoreBiomeKey;
}
if (feature.type() == HydrologyFeatureType.MOUTH || feature.type() == HydrologyFeatureType.COASTAL_GROTTO) {
return mouthBiomeKey;
}
if (feature.type().isUnderground() || feature.type().isDeepFluid()) {
return floodedCaveBiomeKey;
}
return surfaceBiomeKey;
}
/** Every owned channel water column is published so steps between stations receive fluid updates. */
public boolean publishesSurfaceFluid() {
return feature.type().isSurface()
&& !oceanApron
&& channel
&& fluidOwned
&& (terrainOwned || fallingFluid)
&& fluidHeadY > bedY;
}
private static String requireKey(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank.");
}
return value.trim();
}
}
@@ -0,0 +1,202 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveAction;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record HydrologyColumnSample(
int x,
int z,
int naturalHeight,
int seaLevel,
boolean ocean,
String parentBiomeKey,
List<HydrologyColumnLayer> layers
) {
private static final Comparator<HydrologyColumnLayer> LAYER_ORDER = Comparator
.comparingInt((HydrologyColumnLayer layer) -> layer.feature().type().renderPriority())
.thenComparing(Comparator.comparingInt(HydrologyColumnLayer::fluidHeadY).reversed())
.thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().id());
private static final Comparator<HydrologyColumnLayer> SURFACE_LAYER_ORDER = Comparator
.comparingInt(HydrologyColumnSample::surfaceRolePriority)
.thenComparingInt((HydrologyColumnLayer layer) -> layer.feature().type().renderPriority())
.thenComparing(Comparator.comparingInt(HydrologyColumnLayer::fluidHeadY).reversed())
.thenComparingInt(HydrologyColumnLayer::bedY)
.thenComparingLong((HydrologyColumnLayer layer) -> layer.feature().id());
public HydrologyColumnSample {
if (parentBiomeKey == null || parentBiomeKey.isBlank()) {
throw new IllegalArgumentException("parentBiomeKey must not be blank.");
}
parentBiomeKey = parentBiomeKey.trim();
Objects.requireNonNull(layers, "layers");
if (layers.size() < 2) {
layers = List.copyOf(layers);
} else {
ArrayList<HydrologyColumnLayer> ordered = new ArrayList<>(layers);
ordered.sort(LAYER_ORDER);
layers = List.copyOf(ordered);
}
if (ocean) {
for (HydrologyColumnLayer layer : layers) {
if (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore()) {
throw new IllegalArgumentException("Ocean columns cannot contain river-owned writes or grading.");
}
if (layer.fluidHeadY() > seaLevel) {
throw new IllegalArgumentException("Ocean column fluid cannot be elevated above sea level.");
}
}
}
if (naturalHeight <= seaLevel) {
for (HydrologyColumnLayer layer : layers) {
if (layer.feature().type().isSurface()
&& !layer.oceanApron()
&& (layer.terrainOwned() || layer.fluidOwned() || layer.grading() || layer.shore())
&& (naturalHeight < seaLevel || layer.fluidHeadY() > seaLevel)) {
throw new IllegalArgumentException(
"Surface hydrology cannot own submerged ground or raise sea-level water."
);
}
}
}
}
public boolean present() {
return !layers.isEmpty();
}
public boolean hasFeature(HydrologyFeatureType type) {
Objects.requireNonNull(type, "type");
for (HydrologyColumnLayer layer : layers) {
if (layer.feature().type() == type) {
return true;
}
}
return false;
}
public boolean hasConnectedFluid() {
for (HydrologyColumnLayer layer : layers) {
if (layer.connectedFluid()) {
return true;
}
}
return false;
}
public Optional<HydrologyColumnLayer> primaryLayer() {
return layers.isEmpty() ? Optional.empty() : Optional.of(layers.getFirst());
}
public Optional<HydrologyColumnLayer> primarySurfaceLayer() {
return Optional.ofNullable(selectSurfaceLayer(false));
}
public Optional<HydrologyColumnLayer> primarySurfaceFluidLayer() {
return Optional.ofNullable(selectSurfaceLayer(true));
}
public HydrologyColumnLayer primarySurfaceLayerOrNull() {
return selectSurfaceLayer(false);
}
public HydrologyColumnLayer primarySurfaceFluidLayerOrNull() {
return selectSurfaceLayer(true);
}
public Optional<SurfacePublicationCell> surfacePublicationCellAt(int y) {
if (y <= terrainHeight()) {
return Optional.empty();
}
SurfacePublicationCell selected = null;
for (HydrologyColumnLayer layer : layers) {
if (!layer.publishesSurfaceFluid() || y <= layer.bedY() || y > layer.fluidHeadY()) {
continue;
}
HydrologyCaveAction action = layer.fallingFluid() && y < layer.fluidHeadY()
? HydrologyCaveAction.FALLING_FLUID
: HydrologyCaveAction.WET_SOURCE;
SurfacePublicationCell candidate = new SurfacePublicationCell(layer, action);
if (selected == null || surfaceActionPriority(action) < surfaceActionPriority(selected.action())) {
selected = candidate;
}
}
return Optional.ofNullable(selected);
}
public int terrainHeight() {
HydrologyColumnLayer primary = selectSurfaceLayer(false);
if (primary == null || !primary.terrainOwned()) {
return naturalHeight;
}
if (primary.channel()) {
return primary.bedY();
}
int terrainHeight = primary.bedY();
for (HydrologyColumnLayer candidate : layers) {
if (candidate.feature().type().isSurface()
&& candidate.terrainOwned()
&& !candidate.channel()) {
terrainHeight = Math.max(terrainHeight, candidate.bedY());
}
}
return terrainHeight;
}
public HydrologyRenderSample renderSample() {
ArrayList<HydrologyFeatureRef> features = new ArrayList<>(layers.size());
for (HydrologyColumnLayer layer : layers) {
features.add(layer.feature());
}
return new HydrologyRenderSample(x, z, features);
}
private HydrologyColumnLayer selectSurfaceLayer(boolean fluidOnly) {
HydrologyColumnLayer selected = null;
for (HydrologyColumnLayer layer : layers) {
if (layer.oceanApron() || !layer.feature().type().isSurface()) {
continue;
}
if (fluidOnly) {
if (!layer.channel() || !layer.connectedFluid() || !layer.fluidOwned()) {
continue;
}
}
if (selected == null || SURFACE_LAYER_ORDER.compare(layer, selected) < 0) {
selected = layer;
}
}
return selected;
}
private static int surfaceRolePriority(HydrologyColumnLayer layer) {
if (layer.channel()) {
return 0;
}
if (layer.terrainOwned()) {
return layer.shore() ? 1 : 2;
}
return layer.shore() ? 3 : 4;
}
private static int surfaceActionPriority(HydrologyCaveAction action) {
return action == HydrologyCaveAction.WET_SOURCE ? 0 : 1;
}
public record SurfacePublicationCell(
HydrologyColumnLayer layer,
HydrologyCaveAction action
) {
public SurfacePublicationCell {
Objects.requireNonNull(layer, "layer");
if (action != HydrologyCaveAction.WET_SOURCE
&& action != HydrologyCaveAction.FALLING_FLUID) {
throw new IllegalArgumentException("Surface publication cells must contain fluid.");
}
}
}
}
@@ -0,0 +1,20 @@
package art.arcane.iris.generation.hydrology;
public record HydrologyColumnSnapshot(boolean available, HydrologyColumnSample column) {
private static final HydrologyColumnSnapshot UNAVAILABLE = new HydrologyColumnSnapshot(false, null);
private static final HydrologyColumnSnapshot READY_NATURAL = new HydrologyColumnSnapshot(true, null);
public HydrologyColumnSnapshot {
if (!available && column != null) {
throw new IllegalArgumentException("Unavailable hydrology cannot contain a planned column.");
}
}
public static HydrologyColumnSnapshot unavailable() {
return UNAVAILABLE;
}
public static HydrologyColumnSnapshot ready(HydrologyColumnSample column) {
return column == null ? READY_NATURAL : new HydrologyColumnSnapshot(true, column);
}
}
@@ -0,0 +1,18 @@
package art.arcane.iris.generation.hydrology;
import java.util.List;
/**
* A routed source-to-outlet path, or, when {@code junction} is set, the cut prefix of one that ends
* on the stem it joins: the path then owns only the drainage edges upstream of the junction.
*/
record HydrologyCoursePath(
List<HydrologyPoint> points,
List<DrainageEdge> pairEdges,
List<DrainageEdge> edges,
RiverOutlet outlet,
boolean reachesOutlet,
boolean organicSurfaceRequired,
HydrologyPoint junction
) {
}
@@ -0,0 +1,137 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.cave.HydrologyCaveConflictPolicy;
import art.arcane.iris.generation.hydrology.cave.HydrologyCavePlan;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
final class HydrologyCrossTileCaveAdmission {
private static final Comparator<Claim> CLAIM_PRIORITY = Comparator
.comparing((Claim claim) -> claim.plan().source(), HydrologyCaveConflictPolicy.sourcePriority())
.thenComparing(Claim::profileKey);
private static final Comparator<RankedClaim> BLOCKER_PRIORITY = Comparator
.comparingInt(RankedClaim::ownerRank)
.thenComparing(RankedClaim::claim, CLAIM_PRIORITY)
.thenComparing(RankedClaim::ownerKey);
private HydrologyCrossTileCaveAdmission() {
}
static Result admit(
int currentOwnerRank,
Collection<Claim> currentClaims,
Collection<RankedClaim> blockers
) {
if (currentOwnerRank < 0) {
throw new IllegalArgumentException("currentOwnerRank cannot be negative");
}
ArrayList<Claim> orderedClaims = new ArrayList<>(Objects.requireNonNull(currentClaims, "currentClaims"));
orderedClaims.sort(CLAIM_PRIORITY);
ArrayList<RankedClaim> orderedBlockers = new ArrayList<>(Objects.requireNonNull(blockers, "blockers"));
orderedBlockers.sort(BLOCKER_PRIORITY);
for (RankedClaim blocker : orderedBlockers) {
if (blocker.ownerRank() >= currentOwnerRank) {
throw new IllegalArgumentException("Cross-tile blockers must have a lower owner color rank");
}
}
ArrayList<Rejection> rejections = new ArrayList<>();
for (Claim claim : orderedClaims) {
RankedClaim winner = null;
for (RankedClaim blocker : orderedBlockers) {
if (claim.courseId() == blocker.claim().courseId()) {
throw new IllegalArgumentException("A cave course cannot belong to multiple color-ranked owners");
}
if (!conflicts(claim, blocker.claim())) {
continue;
}
if (winner == null || BLOCKER_PRIORITY.compare(blocker, winner) < 0) {
winner = blocker;
}
}
if (winner != null) {
rejections.add(new Rejection(claim, winner));
}
}
return new Result(rejections);
}
private static boolean conflicts(Claim left, Claim right) {
return HydrologyCaveConflictPolicy.hasIncompatibleOverlap(
left.profileKey(),
left.plan().actions(),
right.profileKey(),
right.plan().actions()
);
}
record Claim(String profileKey, HydrologyCavePlan plan) {
Claim {
if (profileKey == null || profileKey.isBlank()) {
throw new IllegalArgumentException("profileKey must not be blank");
}
profileKey = profileKey.trim();
Objects.requireNonNull(plan, "plan");
if (!plan.accepted()) {
throw new IllegalArgumentException("Cross-tile admission requires accepted cave plans");
}
}
long courseId() {
return plan.source().sourceId();
}
}
record RankedClaim(HydrologyTileKey ownerKey, int ownerRank, Claim claim) {
RankedClaim {
Objects.requireNonNull(ownerKey, "ownerKey");
if (ownerRank < 0) {
throw new IllegalArgumentException("ownerRank cannot be negative");
}
Objects.requireNonNull(claim, "claim");
}
}
record Result(List<Rejection> rejections) {
Result {
rejections = List.copyOf(Objects.requireNonNull(rejections, "rejections"));
}
Map<Long, Long> loserCourseIdsToWinnerSourceIds() {
TreeMap<Long, Long> ordered = new TreeMap<>();
for (Rejection rejection : rejections) {
ordered.put(rejection.loser().courseId(), rejection.winnerSourceId());
}
return Collections.unmodifiableMap(new LinkedHashMap<>(ordered));
}
Rejection rejection(Claim claim) {
Objects.requireNonNull(claim, "claim");
for (Rejection rejection : rejections) {
if (rejection.loser().equals(claim)) {
return rejection;
}
}
return null;
}
}
record Rejection(Claim loser, RankedClaim winner) {
Rejection {
Objects.requireNonNull(loser, "loser");
Objects.requireNonNull(winner, "winner");
}
long winnerSourceId() {
return winner.claim().plan().source().sourceId();
}
}
}
@@ -0,0 +1,164 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
final class HydrologyCrossTileSurfaceAdmission {
private HydrologyCrossTileSurfaceAdmission() {
}
static Result admit(
List<Claim> currentClaims,
List<RankedClaim> blockers
) {
ArrayList<Rejection> rejections = new ArrayList<>();
for (Claim current : currentClaims) {
for (RankedClaim ranked : blockers) {
Claim blocker = ranked.claim();
if (!conflicts(current, blocker)) {
continue;
}
rejections.add(new Rejection(current, blocker.courseId()));
break;
}
}
return new Result(List.copyOf(rejections));
}
private static boolean conflicts(Claim first, Claim second) {
if (first.outletId() == second.outletId() && first.reachesOutlet() && second.reachesOutlet()) {
return true;
}
int minimumSeparation = Math.max(first.sourceSpacing(), second.sourceSpacing());
long minimumSeparationSquared = (long) minimumSeparation * minimumSeparation;
if (first.centerline().getFirst().distanceSquared2D(second.centerline().getFirst())
< minimumSeparationSquared) {
return true;
}
if (first.reachesOutlet() && second.reachesOutlet()
&& first.terminal().distanceSquared2D(second.terminal())
< minimumSeparationSquared) {
return true;
}
double clearance = Math.max(4D, (first.maximumWidth() + second.maximumWidth()) / 2D + 4D);
return centerlinesWithin(first.centerline(), second.centerline(), clearance);
}
private static boolean centerlinesWithin(
List<HydrologyPoint> first,
List<HydrologyPoint> second,
double clearance
) {
double clearanceSquared = clearance * clearance;
for (int firstIndex = 1; firstIndex < first.size(); firstIndex++) {
HydrologyPoint firstStart = first.get(firstIndex - 1);
HydrologyPoint firstEnd = first.get(firstIndex);
for (int secondIndex = 1; secondIndex < second.size(); secondIndex++) {
HydrologyPoint secondStart = second.get(secondIndex - 1);
HydrologyPoint secondEnd = second.get(secondIndex);
if (segmentDistanceSquared(firstStart, firstEnd, secondStart, secondEnd) <= clearanceSquared) {
return true;
}
}
}
return false;
}
private static double segmentDistanceSquared(
HydrologyPoint firstStart,
HydrologyPoint firstEnd,
HydrologyPoint secondStart,
HydrologyPoint secondEnd
) {
if (segmentsIntersect(firstStart, firstEnd, secondStart, secondEnd)) {
return 0D;
}
return Math.min(
Math.min(pointSegmentDistanceSquared(firstStart, secondStart, secondEnd),
pointSegmentDistanceSquared(firstEnd, secondStart, secondEnd)),
Math.min(pointSegmentDistanceSquared(secondStart, firstStart, firstEnd),
pointSegmentDistanceSquared(secondEnd, firstStart, firstEnd))
);
}
private static boolean segmentsIntersect(
HydrologyPoint firstStart,
HydrologyPoint firstEnd,
HydrologyPoint secondStart,
HydrologyPoint secondEnd
) {
long firstA = orientation(firstStart, firstEnd, secondStart);
long firstB = orientation(firstStart, firstEnd, secondEnd);
long secondA = orientation(secondStart, secondEnd, firstStart);
long secondB = orientation(secondStart, secondEnd, firstEnd);
return Long.signum(firstA) != Long.signum(firstB)
&& Long.signum(secondA) != Long.signum(secondB);
}
private static long orientation(HydrologyPoint start, HydrologyPoint end, HydrologyPoint point) {
long firstX = end.x() - (long) start.x();
long firstZ = end.z() - (long) start.z();
long secondX = point.x() - (long) start.x();
long secondZ = point.z() - (long) start.z();
return firstX * secondZ - firstZ * secondX;
}
private static double pointSegmentDistanceSquared(
HydrologyPoint point,
HydrologyPoint start,
HydrologyPoint end
) {
double deltaX = end.x() - start.x();
double deltaZ = end.z() - start.z();
double lengthSquared = deltaX * deltaX + deltaZ * deltaZ;
if (lengthSquared == 0D) {
return point.distanceSquared2D(start);
}
double progress = ((point.x() - start.x()) * deltaX + (point.z() - start.z()) * deltaZ)
/ lengthSquared;
progress = Math.max(0D, Math.min(1D, progress));
double closestX = start.x() + deltaX * progress;
double closestZ = start.z() + deltaZ * progress;
double pointX = point.x() - closestX;
double pointZ = point.z() - closestZ;
return pointX * pointX + pointZ * pointZ;
}
record Claim(
long courseId,
long outletId,
HydrologyPoint terminal,
boolean reachesOutlet,
int maximumWidth,
List<HydrologyPoint> centerline,
int sourceSpacing
) {
Claim {
Objects.requireNonNull(terminal, "terminal");
centerline = List.copyOf(Objects.requireNonNull(centerline, "centerline"));
if (maximumWidth < 1 || centerline.size() < 2 || sourceSpacing < 0 || sourceSpacing > 8192) {
throw new IllegalArgumentException("Surface claims require a positive width and centerline.");
}
}
}
record RankedClaim(HydrologyTileKey ownerKey, int ownerRank, Claim claim) {
RankedClaim {
Objects.requireNonNull(ownerKey, "ownerKey");
Objects.requireNonNull(claim, "claim");
}
}
record Rejection(Claim loser, long winnerCourseId) {
Rejection {
Objects.requireNonNull(loser, "loser");
}
}
record Result(List<Rejection> rejections) {
Result {
rejections = List.copyOf(Objects.requireNonNull(rejections, "rejections"));
}
}
}
@@ -0,0 +1,19 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
public record HydrologyDiagnosticCandidate(
long id,
HydrologyCandidateKind kind,
HydrologyFeatureType projectedType,
HydrologyPoint point,
HydrologyCandidateRejection rejection,
int detail
) {
public HydrologyDiagnosticCandidate {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(projectedType, "projectedType");
Objects.requireNonNull(point, "point");
Objects.requireNonNull(rejection, "rejection");
}
}
@@ -0,0 +1,28 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public record HydrologyDiagnosticRenderSample(
int x,
int z,
List<HydrologyDiagnosticCandidate> candidates
) {
private static final Comparator<HydrologyDiagnosticCandidate> CANDIDATE_ORDER = Comparator
.comparing(HydrologyDiagnosticCandidate::kind)
.thenComparing(HydrologyDiagnosticCandidate::rejection)
.thenComparingLong(HydrologyDiagnosticCandidate::id);
public HydrologyDiagnosticRenderSample {
Objects.requireNonNull(candidates, "candidates");
ArrayList<HydrologyDiagnosticCandidate> ordered = new ArrayList<>(candidates);
ordered.sort(CANDIDATE_ORDER);
candidates = List.copyOf(ordered);
}
public boolean present() {
return !candidates.isEmpty();
}
}
@@ -0,0 +1,280 @@
package art.arcane.iris.generation.hydrology;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicReferenceArray;
final class HydrologyDrainageGeometry {
private final HydrologyPlanner planner;
private final Basin basin;
private final int[] heads;
private final AtomicReferenceArray<HydrologyPoint> anchors;
private final AtomicReferenceArray<CompletableFuture<List<HydrologyPoint>>> edges;
HydrologyDrainageGeometry(HydrologyPlanner planner, Basin basin) {
this.planner = planner;
this.basin = basin;
this.heads = new int[basin.grid().nodes().size()];
this.anchors = new AtomicReferenceArray<>(heads.length);
this.edges = new AtomicReferenceArray<>(heads.length);
Arrays.fill(heads, Integer.MIN_VALUE);
planHeads();
}
int head(int index) {
return heads[index];
}
HydrologyPoint anchor(int index) {
HydrologyPoint existing = anchors.get(index);
if (existing != null) {
return existing;
}
HydrologyGridNode node = basin.grid().node(index);
HydrologyPoint point;
if (basin.parent()[index] < 0 && basin.outletIndex()[index] >= 0) {
HydrologyPoint landward = basin.outlets().get(basin.outletIndex()[index]).outlet().landwardPoint();
point = new HydrologyPoint(landward.x(), heads[index], landward.z());
} else {
point = displacedAnchor(node);
}
anchors.compareAndSet(index, null, point);
return anchors.get(index);
}
List<HydrologyPoint> edge(int index) {
DraftProfile profile = planner.currentDraftProfile();
profile.routeCalls++;
CompletableFuture<List<HydrologyPoint>> future = edges.get(index);
if (future == null) {
CompletableFuture<List<HydrologyPoint>> owned = new CompletableFuture<>();
CompletableFuture<List<HydrologyPoint>> existing = edges.compareAndExchange(index, null, owned);
if (existing == null) {
long started = System.nanoTime();
profile.routeSolves++;
try {
List<HydrologyPoint> points = compile(index);
owned.complete(points);
return points;
} catch (Throwable failure) {
owned.completeExceptionally(failure);
edges.compareAndSet(index, owned, null);
throw HydrologyPlanner.propagateOwnerFailure(failure);
} finally {
profile.routeSolveNanos += System.nanoTime() - started;
}
}
future = existing;
}
try {
return future.join();
} catch (CompletionException failure) {
throw HydrologyPlanner.propagateOwnerFailure(failure.getCause());
}
}
long maximumRetainedBytes() {
int stations = Math.ceilDiv(basin.grid().spacing() * 3, planner.settings.routing().refinementSpacing()) + 2;
return (long) heads.length * (112L + stations * 40L);
}
private void planHeads() {
int[] path = new int[heads.length];
int[] nominal = new int[heads.length];
for (int index = 0; index < heads.length; index++) {
if (heads[index] != Integer.MIN_VALUE || basin.outletIndex()[index] < 0) {
continue;
}
int current = index;
int size = 0;
while (current >= 0 && heads[current] == Integer.MIN_VALUE) {
if (size == path.length) {
throw new IllegalStateException("Surface drainage must be acyclic.");
}
path[size++] = current;
current = basin.parent()[current];
}
int downstream = current < 0 ? Integer.MIN_VALUE : heads[current];
while (size > 0) {
int node = path[--size];
HydrologyTerrainSample terrain = basin.grid().node(node).terrain();
if (basin.parent()[node] < 0) {
HydrologyPoint landward = basin.outlets().get(basin.outletIndex()[node]).outlet().landwardPoint();
HydrologyTerrainSample sampled = planner.sampleLandBasisWithoutSlope(landward.x(), landward.z());
if (sampled != null) {
terrain = sampled;
}
}
int depth = (planner.settings.surface().minimumDepth() + planner.settings.surface().maximumDepth()) / 2;
nominal[node] = terrain.naturalHeight() - planner.settings.surface().banks().sink() - depth;
int required = terrain.naturalHeight() - planner.sourcePlanner.permittedSurfaceIncision(terrain) + depth;
heads[node] = Math.max(Math.max(planner.settings.seaLevel(), required), downstream);
downstream = heads[node];
}
}
int[] required = heads.clone();
int[] ceiling = new int[heads.length];
Arrays.fill(ceiling, Integer.MAX_VALUE);
int[] upstream = new int[heads.length];
for (int index = 0; index < heads.length; index++) {
if (heads[index] == Integer.MIN_VALUE) {
continue;
}
if (planner.sourcePlanner.rawSourceEligible(basin.grid().node(index).terrain(),
planner.settings.surface().sources(), true)) {
ceiling[index] = Math.max(required[index], nominal[index]);
}
int parent = basin.parent()[index];
if (parent >= 0) {
upstream[parent]++;
}
}
int end = 0;
for (int index = 0; index < heads.length; index++) {
if (heads[index] != Integer.MIN_VALUE && upstream[index] == 0) {
path[end++] = index;
}
}
for (int position = 0; position < end; position++) {
int node = path[position];
int parent = basin.parent()[node];
if (parent >= 0) {
ceiling[parent] = Math.min(ceiling[parent], ceiling[node]);
if (--upstream[parent] == 0) {
path[end++] = parent;
}
}
}
for (int position = end - 1; position >= 0; position--) {
int node = path[position];
int parent = basin.parent()[node];
int downstream = parent < 0 ? Integer.MIN_VALUE : heads[parent];
heads[node] = Math.max(Math.max(required[node], downstream), Math.min(nominal[node], ceiling[node]));
}
}
private HydrologyPoint displacedAnchor(HydrologyGridNode node) {
double ratio = Math.min(HydrologyRouteGeometry.ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO,
planner.settings.geometry().meanders().maximumOffsetRatio() * 0.625D);
double wavelength = planner.settings.geometry().meanders().primaryWavelength() * 5D;
int x = node.x() + (int) StrictMath.round(basin.grid().spacing() * ratio
* planner.routeAnchorX.noiseSigned(node.x() / wavelength, node.z() / wavelength));
int z = node.z() + (int) StrictMath.round(basin.grid().spacing() * ratio
* planner.routeAnchorZ.noiseSigned(node.x() / wavelength, node.z() / wavelength));
if (!permittedAnchor(node, x, z)) {
x = node.x();
z = node.z();
}
if (nearCoast(node)) {
int clearance = (int) StrictMath.ceil(planner.settings.surface().maximumWidth()
* planner.settings.surface().banks().springWidthRatio() / 2D) + 1;
if (!landClearance(x, z, clearance)) {
int radius = Math.max(1, (int) StrictMath.ceil(basin.grid().spacing()
* HydrologyRouteGeometry.ROUTE_ANCHOR_MAXIMUM_OFFSET_RATIO));
long nearest = Long.MAX_VALUE;
int selectedX = x;
int selectedZ = z;
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
int candidateX = node.x() + offset.x() * radius;
int candidateZ = node.z() + offset.z() * radius;
long dx = (long) candidateX - x;
long dz = (long) candidateZ - z;
long distance = dx * dx + dz * dz;
if (distance < nearest && permittedAnchor(node, candidateX, candidateZ)
&& landClearance(candidateX, candidateZ, clearance)) {
nearest = distance;
selectedX = candidateX;
selectedZ = candidateZ;
}
}
x = selectedX;
z = selectedZ;
}
}
return new HydrologyPoint(x, heads[node.index()], z);
}
private boolean permittedAnchor(HydrologyGridNode node, int x, int z) {
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(x, z);
return terrain != null && !terrain.ocean() && terrain.transitAllowed()
&& node.terrain().drainsInto(terrain) && terrain.drainsInto(node.terrain())
&& HydrologySurfaceProfiles.sharesProfile(node.terrain(), terrain)
&& node.terrain().surfacePolicy().areaKey().equals(terrain.surfacePolicy().areaKey());
}
private boolean nearCoast(HydrologyGridNode node) {
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
HydrologyGridNode neighbor = basin.grid().nodeAt(node.gridX() + offset.x(), node.gridZ() + offset.z());
if (neighbor != null && neighbor.terrain().ocean()) {
return true;
}
}
return false;
}
private boolean landClearance(int x, int z, int radius) {
for (HydrologyGridOffset offset : HydrologySourcePlanner.ROUTING_OFFSETS) {
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(x + offset.x() * radius,
z + offset.z() * radius);
if (terrain == null || terrain.ocean()) {
return false;
}
}
return true;
}
private List<HydrologyPoint> compile(int index) {
int downstream = basin.parent()[index];
if (downstream < 0) {
return List.of();
}
HydrologyPoint start = anchor(index);
HydrologyPoint end = anchor(downstream);
int continuation = basin.parent()[downstream];
HydrologyPoint next = continuation < 0 ? basin.outlets().get(basin.outletIndex()[index])
.outlet().connectionPoint() : anchor(continuation);
RouteDirection startTangent = planner.routePaths.direction(start.x(), start.z(), end.x(), end.z());
RouteDirection endTangent = planner.routePaths.direction(end.x(), end.z(), next.x(), next.z());
if (endTangent.x() == 0D && endTangent.z() == 0D) {
endTangent = startTangent;
}
double distance = StrictMath.hypot(end.x() - start.x(), end.z() - start.z());
int steps = Math.max(1, (int) StrictMath.ceil(distance / planner.settings.routing().refinementSpacing()));
ArrayList<HydrologyPoint> points = new ArrayList<>(steps + 1);
for (int step = 0; step <= steps; step++) {
double progress = step / (double) steps;
RoutePosition position = planner.routeGeometry.routePosition(start, end, startTangent, endTangent,
distance * HydrologyRouteGeometry.ROUTE_TANGENT_SCALE, distance, progress, 1D);
HydrologyPoint point = step == 0 ? start : step == steps ? end
: new HydrologyPoint((int) StrictMath.round(position.x()),
(int) StrictMath.round(start.y() + (end.y() - start.y()) * progress),
(int) StrictMath.round(position.z()));
if (points.isEmpty() || points.getLast().distanceSquared2D(point) > 0L) {
points.add(point);
}
}
return permitted(points) ? List.copyOf(points) : List.of();
}
private boolean permitted(List<HydrologyPoint> points) {
HydrologyTerrainSample previous = null;
for (int index = 1; index < points.size(); index++) {
for (HydrologyPoint point : planner.segments.rasterLine(points.get(index - 1), points.get(index))) {
HydrologyTerrainSample terrain = planner.sampleLandBasisWithoutSlope(point.x(), point.z());
if (terrain == null || terrain.ocean() || !terrain.transitAllowed()
|| previous != null && (!previous.drainsInto(terrain)
|| !HydrologySurfaceProfiles.sharesProfile(previous, terrain))) {
return false;
}
previous = terrain;
}
}
return points.size() >= 2;
}
record Basin(HydrologySampledGrid grid, int[] parent, int[] outletIndex, List<OutletCandidate> outlets) {
}
}
@@ -0,0 +1,50 @@
package art.arcane.iris.generation.hydrology;
final class HydrologyDrainageGraph {
private final HydrologyPlannerSettings settings;
private final int[] upstream;
private final double[] costs;
private final byte[] eligibility;
HydrologyDrainageGraph(HydrologySampledGrid grid, HydrologySourcePlanner planner) {
settings = planner.settings();
int directions = HydrologySourcePlanner.ROUTING_OFFSETS.size();
int size = Math.multiplyExact(grid.nodes().size(), directions);
upstream = new int[size];
costs = new double[size];
eligibility = new byte[size];
for (HydrologyGridNode downstream : grid.nodes()) {
int valleyFloor = planner.minimumNeighborHeight(grid, downstream);
for (int direction = 0; direction < directions; direction++) {
int edge = downstream.index() * directions + direction;
HydrologyGridOffset offset = HydrologySourcePlanner.ROUTING_OFFSETS.get(direction);
HydrologyGridNode source = grid.nodeAt(downstream.gridX() + offset.x(), downstream.gridZ() + offset.z());
upstream[edge] = source == null ? -1 : source.index();
if (source == null || source.terrain().ocean() || !source.terrain().transitAllowed()
|| !source.terrain().drainsInto(downstream.terrain())) {
continue;
}
eligibility[edge] = 1;
if (HydrologySurfaceProfiles.sharesProfile(source.terrain(), downstream.terrain())
&& downstream.terrain().naturalHeight() - source.terrain().naturalHeight()
<= planner.maximumSurfaceEdgeRise(downstream.terrain())) {
eligibility[edge] |= 2;
}
costs[edge] = planner.routeCost(source, downstream, offset, valleyFloor);
}
}
}
HydrologyPlannerSettings settings() {
return settings;
}
int upstream(int downstream, int direction, boolean surface) {
int edge = downstream * HydrologySourcePlanner.ROUTING_OFFSETS.size() + direction;
return (eligibility[edge] & (surface ? 2 : 1)) == 0 ? -1 : upstream[edge];
}
double cost(int downstream, int direction) {
return costs[downstream * HydrologySourcePlanner.ROUTING_OFFSETS.size() + direction];
}
}
@@ -0,0 +1,100 @@
package art.arcane.iris.generation.hydrology;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
public record HydrologyFeatureQuery(Set<HydrologyFeatureType> types, String profileKey) {
private static final Set<HydrologyFeatureType> SURFACE = Set.of(
HydrologyFeatureType.SURFACE_POOL,
HydrologyFeatureType.RIFFLE,
HydrologyFeatureType.CASCADE,
HydrologyFeatureType.WATERFALL,
HydrologyFeatureType.MOUTH
);
private static final Set<HydrologyFeatureType> UNDERGROUND = Set.of(
HydrologyFeatureType.UNDERGROUND_POOL,
HydrologyFeatureType.UNDERGROUND_DROP,
HydrologyFeatureType.SINKHOLE
);
private static final Set<HydrologyFeatureType> GROTTOS = Set.of(
HydrologyFeatureType.COASTAL_GROTTO,
HydrologyFeatureType.INLAND_GROTTO
);
private static final Set<HydrologyFeatureType> DEEP = Set.of(
HydrologyFeatureType.DEEP_POOL,
HydrologyFeatureType.DEEP_CHANNEL
);
private static final Set<HydrologyFeatureType> PROFILED = Set.of(
HydrologyFeatureType.DEEP_POOL,
HydrologyFeatureType.DEEP_CHANNEL,
HydrologyFeatureType.STANDING_POOL
);
private static final Map<String, Set<HydrologyFeatureType>> BUILT_INS = builtIns();
public HydrologyFeatureQuery {
types = Set.copyOf(types);
if (types.isEmpty()) {
throw new IllegalArgumentException("Hydrology feature query requires at least one feature type.");
}
profileKey = profileKey == null || profileKey.isBlank() ? null : profileKey.trim();
}
public static HydrologyFeatureQuery parse(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Hydrology feature type must not be blank.");
}
String normalized = value.trim();
if (normalized.regionMatches(true, 0, "type=", 0, 5)) {
normalized = normalized.substring(5).trim();
}
String key = normalizeKeyword(normalized);
Set<HydrologyFeatureType> builtInTypes = BUILT_INS.get(key);
return builtInTypes == null
? new HydrologyFeatureQuery(PROFILED, normalized)
: new HydrologyFeatureQuery(builtInTypes, null);
}
public static List<String> suggestions(Collection<String> deepFluidIds) {
LinkedHashSet<String> suggestions = new LinkedHashSet<>(BUILT_INS.keySet());
TreeSet<String> orderedIds = new TreeSet<>();
if (deepFluidIds != null) {
for (String id : deepFluidIds) {
if (id != null && !id.isBlank() && !isReservedKeyword(id)) {
orderedIds.add(id.trim());
}
}
}
suggestions.addAll(orderedIds);
return List.copyOf(suggestions);
}
public static boolean isReservedKeyword(String value) {
return value != null && !value.isBlank() && BUILT_INS.containsKey(normalizeKeyword(value.trim()));
}
private static String normalizeKeyword(String value) {
return value.toLowerCase(Locale.ROOT).replace('-', '_');
}
private static Map<String, Set<HydrologyFeatureType>> builtIns() {
LinkedHashMap<String, Set<HydrologyFeatureType>> builtIns = new LinkedHashMap<>();
builtIns.put("surface", SURFACE);
builtIns.put("waterfall", Set.of(HydrologyFeatureType.WATERFALL));
builtIns.put("sinkhole", Set.of(HydrologyFeatureType.SINKHOLE));
builtIns.put("underground", UNDERGROUND);
builtIns.put("grotto", GROTTOS);
builtIns.put("coastal_grotto", Set.of(HydrologyFeatureType.COASTAL_GROTTO));
builtIns.put("inland_grotto", Set.of(HydrologyFeatureType.INLAND_GROTTO));
builtIns.put("mouth", Set.of(HydrologyFeatureType.MOUTH));
builtIns.put("deep", DEEP);
builtIns.put("pool", Set.of(HydrologyFeatureType.STANDING_POOL));
return Collections.unmodifiableMap(builtIns);
}
}
@@ -0,0 +1,27 @@
package art.arcane.iris.generation.hydrology;
import java.util.Objects;
public record HydrologyFeatureRef(
long id,
HydrologyFeatureType type,
long courseId,
long segmentId,
int x,
int y,
int z,
int flowDeltaX,
int flowDeltaZ,
boolean source
) {
public HydrologyFeatureRef {
Objects.requireNonNull(type, "type");
if (flowDeltaX < -1 || flowDeltaX > 1 || flowDeltaZ < -1 || flowDeltaZ > 1) {
throw new IllegalArgumentException("Flow direction components must be between -1 and 1.");
}
}
public HydrologyPoint point() {
return new HydrologyPoint(x, y, z);
}
}
@@ -0,0 +1,767 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.hydrology.surface.ChannelProfile;
import art.arcane.iris.generation.hydrology.surface.ValleyProfile;
import art.arcane.iris.generation.hydrology.surface.ValleyProfileSolver;
import art.arcane.iris.generation.hydrology.surface.SurfaceCenterline;
import art.arcane.iris.generation.hydrology.surface.SurfaceTerminal;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.OptionalLong;
final class HydrologyFeatureSitePlanner {
private final HydrologyPlanner planner;
HydrologyFeatureSitePlanner(HydrologyPlanner planner) {
this.planner = planner;
}
static final long DEEP_FLUID_SALT = 0x44454550464cL;
static final long SURFACE_POOL_SALT = 0x504f4f4cL;
static final long SEA_CAVE_SALT = 0x534541434156L;
static final long DEEP_FLUID_X_OFFSET_SALT = 0x44454550584fL;
static final long DEEP_FLUID_Z_OFFSET_SALT = 0x444545505a4fL;
static final long DEEP_CHANNEL_HEADING_SALT = 0x44454550484447L;
static final long DEEP_CHANNEL_FIRST_BEND_SALT = 0x444545504231L;
static final long DEEP_CHANNEL_SECOND_BEND_SALT = 0x444545504232L;
/**
* Standing surface pools: a jittered lattice of candidate sites per pool profile, admitted where the
* policy lists the pool, the ground is open land clear of every accepted course, and the bowl fits
* the incision cap. Each accepted pool is an independent course with one STANDING_POOL segment.
*/
void compileSurfacePools(
HydrologyTileKey key,
List<RiverCourse> courses,
List<HydrologyDiagnosticCandidate> diagnostics
) {
int tileSize = planner.settings.routing().tileSize();
for (HydrologyPlannerSettings.SurfacePool pool : planner.settings.surfacePools()) {
if (!pool.enabled() || pool.maximumPerTile() == 0) {
continue;
}
long profileSeed = HydrologyHash.mix(planner.worldSeed, SURFACE_POOL_SALT, HydrologyHash.text(pool.id()));
int target = Math.min(pool.maximumPerTile(), planner.sourcePlanner.expectedCount(pool.density(), HydrologyHash.mix(
profileSeed, key.tileX(), key.tileZ())));
int accepted = 0;
for (DeepSite site : poolSites(key, pool, profileSeed, tileSize)) {
if (accepted >= target) {
addPoolDiagnostic(site, HydrologyCandidateRejection.SOURCE_QUOTA, diagnostics);
continue;
}
HydrologyCandidateRejection rejection = poolAdmission(pool, site, courses);
if (rejection != null) {
addPoolDiagnostic(site, rejection, diagnostics);
continue;
}
RiverCourse course = buildSurfacePoolCourse(pool, site);
if (course == null) {
addPoolDiagnostic(site, HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, diagnostics);
continue;
}
courses.add(course);
accepted++;
}
}
}
ArrayList<DeepSite> poolSites(
HydrologyTileKey key,
HydrologyPlannerSettings.SurfacePool pool,
long profileSeed,
int tileSize
) {
int minimumX = key.minimumBlockX(tileSize);
int minimumZ = key.minimumBlockZ(tileSize);
int spacing = pool.spacing();
int xOffset = HydrologyHash.between(HydrologyHash.mix(profileSeed, DEEP_FLUID_X_OFFSET_SALT), 0, spacing - 1);
int zOffset = HydrologyHash.between(HydrologyHash.mix(profileSeed, DEEP_FLUID_Z_OFFSET_SALT), 0, spacing - 1);
long firstCellX = ceilDiv((long) minimumX - xOffset, spacing);
long firstCellZ = ceilDiv((long) minimumZ - zOffset, spacing);
long lastCellX = Math.floorDiv((long) minimumX + tileSize - 1L - xOffset, spacing);
long lastCellZ = Math.floorDiv((long) minimumZ + tileSize - 1L - zOffset, spacing);
ArrayList<DeepSite> sites = new ArrayList<>();
for (long cellZ = firstCellZ; cellZ <= lastCellZ; cellZ++) {
for (long cellX = firstCellX; cellX <= lastCellX; cellX++) {
long stable = HydrologyHash.mix(profileSeed, cellX, cellZ);
// Jitter inside the cell keeps pools off a visible grid.
int jitter = Math.max(1, spacing / 3);
int x = Math.toIntExact(xOffset + cellX * spacing + HydrologyHash.between(HydrologyHash.mix(stable, 11), -jitter, jitter));
int z = Math.toIntExact(zOffset + cellZ * spacing + HydrologyHash.between(HydrologyHash.mix(stable, 12), -jitter, jitter));
HydrologyTerrainSample terrain = planner.sampleLandBasis(x, z);
sites.add(new DeepSite(x, z, terrain == null ? 0 : terrain.naturalHeight(), stable));
}
}
sites.sort(Comparator.comparingLong(DeepSite::stableId));
return sites;
}
HydrologyCandidateRejection poolAdmission(
HydrologyPlannerSettings.SurfacePool pool,
DeepSite site,
List<RiverCourse> courses
) {
HydrologyTerrainSample terrain = planner.sampleLandBasis(site.x(), site.z());
if (terrain == null || terrain.ocean() || terrain.naturalHeight() <= planner.settings.seaLevel() + 1) {
return HydrologyCandidateRejection.SURFACE_EXPOSURE;
}
if (!terrain.transitAllowed() || !terrain.surfacePoolKeys().contains(pool.id())) {
return HydrologyCandidateRejection.POLICY_EXCLUDED;
}
long clearance = pool.maximumRadius() + (long) StrictMath.ceil(planner.settings.surface().shoreWidth())
+ planner.settings.surface().banks().maximumBlendWidth() + planner.settings.surface().maximumWidth();
long clearanceSquared = clearance * clearance;
for (RiverCourse course : courses) {
for (HydraulicSegment segment : course.segments()) {
for (HydrologyPoint point : segment.centerline()) {
long deltaX = (long) point.x() - site.x();
long deltaZ = (long) point.z() - site.z();
if (deltaX * deltaX + deltaZ * deltaZ < clearanceSquared) {
return HydrologyCandidateRejection.SOURCE_SPACING;
}
}
}
}
return null;
}
RiverCourse buildSurfacePoolCourse(HydrologyPlannerSettings.SurfacePool pool, DeepSite site) {
int radius = HydrologyHash.between(HydrologyHash.mix(site.stableId(), 3), pool.minimumRadius(), pool.maximumRadius());
HydrologyTerrainSample terrain = planner.sampleLandBasis(site.x(), site.z());
List<HydrologyPoint> points = List.of(
new HydrologyPoint(site.x() - 1, site.head(), site.z()),
new HydrologyPoint(site.x(), site.head(), site.z()),
new HydrologyPoint(site.x() + 1, site.head(), site.z())
);
SurfaceCenterline centerline = SurfaceCenterline.densify(points);
int count = centerline.size();
double[] halfWidth = new double[count];
double[] depth = new double[count];
double[] bank = new double[count];
Arrays.fill(halfWidth, radius);
Arrays.fill(depth, pool.depth());
Arrays.fill(bank, terrain == null ? 1D : terrain.bankMultiplier());
ChannelProfile profile = new ChannelProfile(halfWidth, depth, bank);
ValleyProfile valley = new ValleyProfileSolver(planner.settings.surface(), planner::sampleBasis, planner.settings.seaLevel(), 0)
.solve(centerline, profile, SurfaceTerminal.SINKHOLE, Integer.MIN_VALUE);
if (!valley.accepted()) {
return null;
}
int center = Math.min(1, valley.exposedStations() - 1);
int head = valley.head()[center];
// A pool belongs in a hollow or on level ground: the whole rim must sit within a few blocks of the
// water, or the bowl becomes a scar dug into a slope.
int rimAllowance = pool.depth() + planner.settings.surface().banks().sink() + 2;
for (int station = 0; station < valley.exposedStations(); station++) {
if (valley.crossMax()[station] - valley.head()[station] > rimAllowance) {
return null;
}
}
long courseId = HydrologyHash.mix(planner.worldSeed, SURFACE_POOL_SALT, HydrologyHash.text(pool.id()), site.stableId());
long segmentId = HydrologyHash.mix(courseId, HydrologySegmentBuilder.SEGMENT_SALT, HydrologyFeatureType.STANDING_POOL.ordinal());
ArrayList<HydrologyPoint> centerlinePoints = new ArrayList<>(points.size());
for (HydrologyPoint point : points) {
centerlinePoints.add(new HydrologyPoint(point.x(), head, point.z()));
}
HydraulicSegment segment = new HydraulicSegment(
segmentId,
courseId,
HydrologyFeatureType.STANDING_POOL,
head,
head,
radius * 2,
pool.depth(),
false,
false,
centerlinePoints, HydraulicChannelProfile.uniform(radius * 2, pool.depth())
);
return new RiverCourse(
courseId,
RiverCourseType.SURFACE_POOL,
OptionalLong.empty(),
OptionalLong.empty(),
pool.id(),
1,
List.of(),
List.of(segment)
);
}
void addPoolDiagnostic(
DeepSite site,
HydrologyCandidateRejection rejection,
List<HydrologyDiagnosticCandidate> diagnostics
) {
diagnostics.add(new HydrologyDiagnosticCandidate(
HydrologyHash.mix(site.stableId(), HydrologySourcePlanner.DIAGNOSTIC_SALT, rejection.ordinal()),
HydrologyCandidateKind.POOL,
HydrologyFeatureType.STANDING_POOL,
new HydrologyPoint(site.x(), site.head(), site.z()),
rejection,
0
));
}
/**
* Sea caves: coastal grottos the sea opens into without a river. Every owned land node with a proven
* ocean boundary is a site, ranked by how high the coast stands over the sea; each accepted site is an
* independent course whose chamber is swept inland from the shoreline and opens only through its ocean
* face. Rivers keep their outlets: sea caves stay clear of every mouth and grotto already planned.
*/
void compileSeaCaves(
HydrologySampledGrid grid,
List<RiverCourse> courses,
List<HydrologyDiagnosticCandidate> diagnostics
) {
HydrologyPlannerSettings.SeaCaves seaCaves = planner.settings.seaCaves();
if (!seaCaves.enabled() || seaCaves.maximumPerTile() == 0) {
return;
}
HydrologyPlannerSettings.Grotto grotto = planner.settings.outlets().coastalGrotto();
boolean fits = sweptGrottoVolume(grotto, seaCaves.depth()) <= grotto.maximumVolume();
ArrayList<SeaCaveSite> accepted = new ArrayList<>();
for (SeaCaveSite site : seaCaveSites(grid)) {
if (accepted.size() >= seaCaves.maximumPerTile()) {
addSeaCaveDiagnostic(site, HydrologyCandidateRejection.SOURCE_QUOTA, diagnostics);
continue;
}
if (!fits) {
addSeaCaveDiagnostic(site, HydrologyCandidateRejection.VOLUME_LIMIT, diagnostics);
continue;
}
HydrologyCandidateRejection rejection = seaCaveAdmission(site, accepted, courses);
if (rejection != null) {
addSeaCaveDiagnostic(site, rejection, diagnostics);
continue;
}
courses.add(buildSeaCaveCourse(site));
accepted.add(site);
}
}
ArrayList<SeaCaveSite> seaCaveSites(HydrologySampledGrid grid) {
int depth = planner.settings.seaCaves().depth();
ArrayList<SeaCaveSite> sites = new ArrayList<>();
for (HydrologyGridNode land : grid.nodes()) {
HydrologyTerrainSample terrain = land.terrain();
if (!grid.owns(land.x(), land.z())
|| terrain.ocean()
|| !terrain.transitAllowed()
|| !terrain.outletAllowed()) {
continue;
}
HydrologyGridNode ocean = planner.outletPlanner.firstOceanNeighbor(grid, land, false);
if (ocean == null) {
continue;
}
HydrologyOceanBoundaryRefiner.Result boundary = planner.outletPlanner.refineOceanBoundary(land, ocean);
if (boundary == null
|| !boundary.landwardTerrain().transitAllowed()
|| !boundary.landwardTerrain().outletAllowed()) {
continue;
}
long stableId = HydrologyHash.mix(planner.worldSeed, SEA_CAVE_SALT, land.id(), ocean.id());
HydrologyPoint inner = seaCaveInnerPoint(land, ocean, boundary.landwardPoint(), stableId, depth);
int seaLevel = planner.settingsSeaLevel(ocean.terrain());
int coastHeight = seaCaveCoastHeight(boundary, inner, seaLevel);
sites.add(new SeaCaveSite(land, ocean, boundary, inner, coastHeight, stableId));
}
sites.sort(Comparator.comparingInt(SeaCaveSite::coastHeight).reversed()
.thenComparingLong(SeaCaveSite::stableId));
return sites;
}
/**
* How high the coast stands over the sea across the chamber: the lowest land at the shoreline, at the
* back of the chamber and at both flanks of each. Flanks in the sea are ignored (the chamber opens
* there like its face); a back in the sea leaves no coast at all.
*/
int seaCaveCoastHeight(HydrologyOceanBoundaryRefiner.Result boundary, HydrologyPoint inner, int seaLevel) {
HydrologyTerrainSample innerTerrain = planner.sampleLandBasis(inner.x(), inner.z());
if (innerTerrain == null) {
return 0;
}
HydrologyPoint landward = boundary.landwardPoint();
int lowest = Math.min(boundary.landwardTerrain().naturalHeight(), innerTerrain.naturalHeight());
int radius = planner.settings.outlets().coastalGrotto().horizontalRadius();
// Flanks lie along the shore, across the crossing from the landward point to the sea.
int spanX = boundary.oceanPoint().x() - landward.x();
int spanZ = boundary.oceanPoint().z() - landward.z();
double length = StrictMath.hypot(spanX, spanZ);
int flankX = (int) StrictMath.round(-spanZ / length * radius);
int flankZ = (int) StrictMath.round(spanX / length * radius);
int[][] flanks = {
{landward.x() + flankX, landward.z() + flankZ},
{landward.x() - flankX, landward.z() - flankZ},
{inner.x() + flankX, inner.z() + flankZ},
{inner.x() - flankX, inner.z() - flankZ}
};
for (int[] flank : flanks) {
HydrologyTerrainSample terrain = planner.sampleLandBasis(flank[0], flank[1]);
if (terrain != null) {
lowest = Math.min(lowest, terrain.naturalHeight());
}
}
return lowest - seaLevel;
}
// The chamber runs inland along the coast normal, turned by a stable jitter so caves do not all
// face the same way along a straight shore.
HydrologyPoint seaCaveInnerPoint(
HydrologyGridNode land,
HydrologyGridNode ocean,
HydrologyPoint landward,
long stableId,
int depth
) {
double normalX = land.x() - ocean.x();
double normalZ = land.z() - ocean.z();
double length = StrictMath.hypot(normalX, normalZ);
double jitter = StrictMath.toRadians(
(HydrologyHash.unit(HydrologyHash.mix(stableId, 7)) * 2D - 1D)
* planner.settings.seaCaves().sweepJitterDegrees()
);
double cos = StrictMath.cos(jitter);
double sin = StrictMath.sin(jitter);
double unitX = normalX / length;
double unitZ = normalZ / length;
double inlandX = unitX * cos - unitZ * sin;
double inlandZ = unitX * sin + unitZ * cos;
return new HydrologyPoint(
(int) StrictMath.round(landward.x() + inlandX * depth),
landward.y(),
(int) StrictMath.round(landward.z() + inlandZ * depth)
);
}
HydrologyCandidateRejection seaCaveAdmission(
SeaCaveSite site,
List<SeaCaveSite> accepted,
List<RiverCourse> courses
) {
HydrologyPlannerSettings.SeaCaves seaCaves = planner.settings.seaCaves();
if (site.coastHeight() < seaCaves.minimumCoastHeight()) {
return HydrologyCandidateRejection.SURFACE_HEAD_RANGE;
}
HydrologyPoint landward = site.boundary().landwardPoint();
long spacingSquared = (long) seaCaves.minimumSpacing() * seaCaves.minimumSpacing();
for (SeaCaveSite other : accepted) {
if (landward.distanceSquared2D(other.boundary().landwardPoint()) < spacingSquared) {
return HydrologyCandidateRejection.SOURCE_SPACING;
}
}
long clearance = 2L * planner.settings.outlets().coastalGrotto().horizontalRadius() + seaCaves.depth();
long clearanceSquared = clearance * clearance;
for (RiverCourse course : courses) {
if (course.type() == RiverCourseType.SEA_CAVE) {
continue;
}
for (HydraulicSegment segment : course.segments()) {
if (segment.type() != HydrologyFeatureType.MOUTH
&& segment.type() != HydrologyFeatureType.COASTAL_GROTTO
&& segment.type() != HydrologyFeatureType.INLAND_GROTTO) {
continue;
}
for (HydrologyPoint point : segment.centerline()) {
if (landward.distanceSquared2D(point) < clearanceSquared) {
return HydrologyCandidateRejection.SOURCE_SPACING;
}
}
}
}
return null;
}
RiverCourse buildSeaCaveCourse(SeaCaveSite site) {
HydrologyPlannerSettings.Grotto grotto = planner.settings.outlets().coastalGrotto();
long courseId = HydrologyHash.mix(planner.worldSeed, HydrologySurfaceCoursePlanner.COURSE_SALT, SEA_CAVE_SALT, site.stableId());
String profileKey = planner.segments.chooseProfile(site.boundary().landwardTerrain(), courseId);
int seaLevel = planner.settingsSeaLevel(site.ocean().terrain());
// The last point is the ocean connection: the footprint opens the chamber there and nowhere else.
List<HydrologyPoint> centerline = planner.segments.line(
HydrologyPlanner.withY(site.inner(), seaLevel),
HydrologyPlanner.withY(site.boundary().oceanPoint(), seaLevel),
planner.settings.routing().refinementSpacing()
);
ArrayList<HydraulicSegment> segments = new ArrayList<>(1);
planner.segments.addFlatSegment(
courseId,
HydrologyFeatureType.COASTAL_GROTTO,
seaLevel,
grotto.horizontalRadius() * 2,
grotto.verticalRadius(),
centerline,
segments
);
return new RiverCourse(
courseId,
RiverCourseType.SEA_CAVE,
OptionalLong.empty(),
OptionalLong.empty(),
profileKey,
1,
List.of(),
segments
);
}
void addSeaCaveDiagnostic(
SeaCaveSite site,
HydrologyCandidateRejection rejection,
List<HydrologyDiagnosticCandidate> diagnostics
) {
diagnostics.add(new HydrologyDiagnosticCandidate(
HydrologyHash.mix(site.stableId(), HydrologySourcePlanner.DIAGNOSTIC_SALT, rejection.ordinal()),
HydrologyCandidateKind.OUTLET,
HydrologyFeatureType.COASTAL_GROTTO,
site.boundary().landwardPoint(),
rejection,
0
));
}
/**
* The raster a swept grotto occupies: one ellipsoid plus its central cross-section extruded over the
* sweep. The containment filter measures the real carve; this only refuses chambers that cannot fit.
*/
static long sweptGrottoVolume(HydrologyPlannerSettings.Grotto grotto, int depth) {
int radius = grotto.horizontalRadius();
long section = 0L;
for (int deltaX = -radius; deltaX <= radius; deltaX++) {
double normalized = Math.min(1D, Math.abs(deltaX) / Math.max(1D, radius));
double scale = StrictMath.sqrt(Math.max(0D, 1D - normalized * normalized));
section += (long) StrictMath.ceil(grotto.verticalRadius() * scale)
+ (long) StrictMath.floor(grotto.headroom() * scale)
+ 1L;
}
return HydrologyPlannerSettings.ellipsoidVolume(radius, grotto.verticalRadius(), grotto.headroom())
+ section * depth;
}
record SeaCaveSite(
HydrologyGridNode land,
HydrologyGridNode ocean,
HydrologyOceanBoundaryRefiner.Result boundary,
HydrologyPoint inner,
int coastHeight,
long stableId
) {
}
void compileDeepFluidCourses(
HydrologyTileKey key,
List<RiverCourse> courses,
List<HydrologyDiagnosticCandidate> diagnostics
) {
for (HydrologyPlannerSettings.DeepFluid deepFluid : planner.settings.deepFluids()) {
if (!deepFluid.enabled() || deepFluid.maximumPerTile() == 0) {
continue;
}
ArrayList<DeepSite> sites = deepSites(key, deepFluid, diagnostics);
int target = Math.min(
deepFluid.maximumPerTile(),
planner.sourcePlanner.expectedCount(deepFluid.density(), HydrologyHash.mix(
planner.worldSeed,
DEEP_FLUID_SALT,
HydrologyHash.text(deepFluid.id()),
key.tileX(),
key.tileZ()
))
);
for (int index = 0; index < sites.size(); index++) {
DeepSite site = sites.get(index);
if (index >= target) {
addDeepDiagnostic(
deepFluid,
site,
HydrologyCandidateRejection.SOURCE_QUOTA,
diagnostics
);
continue;
}
RiverCourse course = buildDeepFluidCourse(deepFluid, site, index);
if (course != null) {
courses.add(course);
} else {
addDeepDiagnostic(
deepFluid,
site,
HydrologyCandidateRejection.VOLUME_LIMIT,
diagnostics
);
}
}
}
}
ArrayList<DeepSite> deepSites(
HydrologyTileKey key,
HydrologyPlannerSettings.DeepFluid deepFluid,
List<HydrologyDiagnosticCandidate> diagnostics
) {
int tileSize = planner.settings.routing().tileSize();
int minimumX = key.minimumBlockX(tileSize);
int minimumZ = key.minimumBlockZ(tileSize);
int spacing = deepFluid.spacing();
long profileSeed = HydrologyHash.mix(planner.worldSeed, DEEP_FLUID_SALT, HydrologyHash.text(deepFluid.id()));
int xOffset = HydrologyHash.between(
HydrologyHash.mix(profileSeed, DEEP_FLUID_X_OFFSET_SALT),
0,
spacing - 1
);
int zOffset = HydrologyHash.between(
HydrologyHash.mix(profileSeed, DEEP_FLUID_Z_OFFSET_SALT),
0,
spacing - 1
);
long firstCellX = ceilDiv((long) minimumX - xOffset, spacing);
long firstCellZ = ceilDiv((long) minimumZ - zOffset, spacing);
long lastCellX = Math.floorDiv((long) minimumX + tileSize - 1L - xOffset, spacing);
long lastCellZ = Math.floorDiv((long) minimumZ + tileSize - 1L - zOffset, spacing);
ArrayList<DeepSite> sites = new ArrayList<>();
for (long cellZ = firstCellZ; cellZ <= lastCellZ; cellZ++) {
for (long cellX = firstCellX; cellX <= lastCellX; cellX++) {
long stable = HydrologyHash.mix(
planner.worldSeed,
DEEP_FLUID_SALT,
HydrologyHash.text(deepFluid.id()),
cellX,
cellZ
);
int x = Math.toIntExact(xOffset + cellX * spacing);
int z = Math.toIntExact(zOffset + cellZ * spacing);
int head = planner.sampleGeometry(
HydrologyGeometrySampler.Field.DEEP_FLUID_HEIGHT,
deepFluid.id(),
x,
z,
stable,
deepFluid.minimumY(),
deepFluid.maximumY()
);
DeepSite site = new DeepSite(x, z, head, stable);
HydrologyTerrainSample terrain = planner.sampleLandBasis(x, z);
int verticalRadius = HydrologyHash.between(
HydrologyHash.mix(stable, 3),
deepFluid.minimumVerticalRadius(),
deepFluid.maximumVerticalRadius()
);
if (!deepSiteFits(terrain, deepFluid, head, verticalRadius, planner.minimumY)) {
if (terrain != null && !terrain.ocean()) {
addDeepDiagnostic(
deepFluid,
site,
HydrologyCandidateRejection.CAVE_CONTAINMENT,
diagnostics
);
}
continue;
}
sites.add(site);
}
}
sites.sort(Comparator.comparingLong(DeepSite::stableId));
return sites;
}
long ceilDiv(long value, int divisor) {
long quotient = Math.floorDiv(value, divisor);
return quotient * divisor == value ? quotient : quotient + 1L;
}
static boolean deepSiteFits(
HydrologyTerrainSample terrain,
HydrologyPlannerSettings.DeepFluid deepFluid,
int head,
int verticalRadius,
int minimumY
) {
if (terrain == null || terrain.ocean()) {
return false;
}
int lowerEnvelope = deepFluid.containedPools()
? Math.max(deepFluid.channelDepth(), verticalRadius)
: deepFluid.channelDepth();
return (long) head - lowerEnvelope > minimumY
&& head + deepFluid.headroom() < terrain.naturalHeight();
}
void addDeepDiagnostic(
HydrologyPlannerSettings.DeepFluid deepFluid,
DeepSite site,
HydrologyCandidateRejection rejection,
List<HydrologyDiagnosticCandidate> diagnostics
) {
diagnostics.add(new HydrologyDiagnosticCandidate(
HydrologyHash.mix(site.stableId(), HydrologySourcePlanner.DIAGNOSTIC_SALT, rejection.ordinal()),
HydrologyCandidateKind.DEEP_FLUID,
deepFluid.containedPools() ? HydrologyFeatureType.DEEP_POOL : HydrologyFeatureType.DEEP_CHANNEL,
new HydrologyPoint(site.x(), site.head(), site.z()),
rejection, 0
));
}
RiverCourse buildDeepFluidCourse(
HydrologyPlannerSettings.DeepFluid deepFluid,
DeepSite site,
int siteIndex
) {
long courseId = HydrologyHash.mix(
planner.worldSeed,
HydrologySurfaceCoursePlanner.COURSE_SALT,
DEEP_FLUID_SALT,
HydrologyHash.text(deepFluid.id()),
site.stableId(),
siteIndex
);
int horizontalRadius = HydrologyHash.between(
HydrologyHash.mix(site.stableId(), 2),
deepFluid.minimumHorizontalRadius(),
deepFluid.maximumHorizontalRadius()
);
int verticalRadius = HydrologyHash.between(
HydrologyHash.mix(site.stableId(), 3),
deepFluid.minimumVerticalRadius(),
deepFluid.maximumVerticalRadius()
);
ArrayList<HydraulicSegment> segments = new ArrayList<>();
HydrologyPoint center = new HydrologyPoint(site.x(), site.head(), site.z());
long usedVolume = 0L;
if (deepFluid.containedPools()) {
long poolVolume = HydrologyPlannerSettings.ellipsoidVolume(
horizontalRadius,
Math.max(deepFluid.channelDepth(), verticalRadius),
deepFluid.headroom()
);
if (poolVolume > deepFluid.maximumVolume()) {
return null;
}
usedVolume = poolVolume;
long poolId = HydrologyHash.mix(planner.worldSeed, HydrologySegmentBuilder.SEGMENT_SALT, courseId, HydrologyFeatureType.DEEP_POOL.ordinal());
segments.add(new HydraulicSegment(
poolId,
courseId,
HydrologyFeatureType.DEEP_POOL,
site.head(),
site.head(),
horizontalRadius * 2 + 1,
Math.max(deepFluid.channelDepth(), verticalRadius),
false,
false,
List.of(center), HydraulicChannelProfile.uniform(horizontalRadius * 2 + 1, Math.max(deepFluid.channelDepth(), verticalRadius))
));
}
if (deepFluid.shortChannels() && deepFluid.maximumChannelLength() > 0) {
int volumeBoundedMaximum = deepFluid.maximumChannelLength();
while (volumeBoundedMaximum >= deepFluid.minimumChannelLength()
&& usedVolume + deepChannelVolumeBound(deepFluid, volumeBoundedMaximum)
> deepFluid.maximumVolume()) {
volumeBoundedMaximum--;
}
if (volumeBoundedMaximum >= deepFluid.minimumChannelLength()) {
int length = HydrologyHash.between(
HydrologyHash.mix(site.stableId(), 4),
deepFluid.minimumChannelLength(),
volumeBoundedMaximum
);
long channelId = HydrologyHash.mix(planner.worldSeed, HydrologySegmentBuilder.SEGMENT_SALT, courseId, HydrologyFeatureType.DEEP_CHANNEL.ordinal());
segments.add(new HydraulicSegment(
channelId,
courseId,
HydrologyFeatureType.DEEP_CHANNEL,
site.head(),
site.head(),
deepFluid.channelWidth(),
deepFluid.channelDepth(),
false,
false,
organicDeepChannelCenterline(
center,
length,
planner.settings.routing().refinementSpacing(),
site.stableId()
), HydraulicChannelProfile.uniform(deepFluid.channelWidth(), deepFluid.channelDepth())
));
}
}
if (segments.isEmpty()) {
return null;
}
return new RiverCourse(
courseId,
RiverCourseType.DEEP_FLUID,
OptionalLong.empty(),
OptionalLong.empty(),
deepFluid.id(),
1,
List.of(),
segments
);
}
long deepChannelVolumeBound(HydrologyPlannerSettings.DeepFluid deepFluid, int length) {
int radius = Math.max(1, deepFluid.channelWidth() / 2);
int points = (int) StrictMath.ceil(
length * StrictMath.sqrt(2D) / planner.settings.routing().refinementSpacing()
) + 1;
long horizontalEnvelope = (long) (radius * 2 + 1) * (radius * 2 + 1);
long verticalEnvelope = deepFluid.channelDepth() + deepFluid.headroom() + 1L;
return horizontalEnvelope * verticalEnvelope * points;
}
List<HydrologyPoint> organicDeepChannelCenterline(
HydrologyPoint start,
int length,
int spacing,
long stableId
) {
double heading = HydrologyHash.unit(HydrologyHash.mix(stableId, DEEP_CHANNEL_HEADING_SALT))
* StrictMath.PI * 2D;
double forwardX = StrictMath.cos(heading);
double forwardZ = StrictMath.sin(heading);
double lateralX = -forwardZ;
double lateralZ = forwardX;
double bendEnvelope = Math.max(1.5D, Math.min(8D, length * 0.3D));
double firstBend = planner.segments.signedOrganicOffset(stableId, DEEP_CHANNEL_FIRST_BEND_SALT, bendEnvelope);
double secondBend = planner.segments.signedOrganicOffset(stableId, DEEP_CHANNEL_SECOND_BEND_SALT, bendEnvelope);
if (StrictMath.signum(firstBend) == StrictMath.signum(secondBend)) {
secondBend = -secondBend;
}
double endX = start.x() + forwardX * length;
double endZ = start.z() + forwardZ * length;
double firstControlX = start.x() + forwardX * length / 3D + lateralX * firstBend;
double firstControlZ = start.z() + forwardZ * length / 3D + lateralZ * firstBend;
double secondControlX = start.x() + forwardX * length * 2D / 3D + lateralX * secondBend;
double secondControlZ = start.z() + forwardZ * length * 2D / 3D + lateralZ * secondBend;
int steps = Math.max(4, (int) StrictMath.ceil(length / (double) Math.max(2, spacing)) * 2);
ArrayList<HydrologyPoint> points = new ArrayList<>(steps + 1);
for (int step = 0; step <= steps; step++) {
double progress = step / (double) steps;
double inverse = 1D - progress;
double x = inverse * inverse * inverse * start.x()
+ 3D * inverse * inverse * progress * firstControlX
+ 3D * inverse * progress * progress * secondControlX
+ progress * progress * progress * endX;
double z = inverse * inverse * inverse * start.z()
+ 3D * inverse * inverse * progress * firstControlZ
+ 3D * inverse * progress * progress * secondControlZ
+ progress * progress * progress * endZ;
HydrologyPoint point = new HydrologyPoint(
(int) StrictMath.round(x),
start.y(),
(int) StrictMath.round(z)
);
if (points.isEmpty() || point.x() != points.getLast().x() || point.z() != points.getLast().z()) {
points.add(point);
}
}
return List.copyOf(points);
}
record DeepSite(int x, int z, int head, long stableId) {
}
}
@@ -0,0 +1,67 @@
package art.arcane.iris.generation.hydrology;
public enum HydrologyFeatureType {
SURFACE_POOL,
RIFFLE,
CASCADE,
WATERFALL,
// No planner produces a ridge bore any more. The constant stays because its ordinal seeds the
// segment, outlet and feature ids of every constant after it on the cave and deep-fluid paths.
RIDGE_BORE,
UNDERGROUND_POOL,
UNDERGROUND_DROP,
SINKHOLE,
COASTAL_GROTTO,
INLAND_GROTTO,
MOUTH,
DEEP_POOL,
DEEP_CHANNEL,
// A standing surface pool (lava or any fluid) with no course: a bowl cut into the ground with a lip.
STANDING_POOL;
public boolean isSurface() {
return switch (this) {
case SURFACE_POOL, RIFFLE, CASCADE, WATERFALL, MOUTH, STANDING_POOL -> true;
default -> false;
};
}
public boolean isUnderground() {
return switch (this) {
case RIDGE_BORE, UNDERGROUND_POOL, UNDERGROUND_DROP, SINKHOLE, COASTAL_GROTTO, INLAND_GROTTO -> true;
default -> false;
};
}
public boolean isGrotto() {
return this == COASTAL_GROTTO || this == INLAND_GROTTO;
}
public boolean isDeepFluid() {
return this == DEEP_POOL || this == DEEP_CHANNEL;
}
public boolean isDrop() {
return this == RIFFLE || this == CASCADE || this == WATERFALL
|| this == UNDERGROUND_DROP || this == SINKHOLE;
}
int renderPriority() {
return switch (this) {
case WATERFALL -> 0;
case SINKHOLE -> 1;
case CASCADE -> 2;
case RIFFLE -> 3;
case COASTAL_GROTTO -> 4;
case INLAND_GROTTO -> 5;
case MOUTH -> 6;
case RIDGE_BORE -> 7;
case UNDERGROUND_DROP -> 8;
case UNDERGROUND_POOL -> 9;
case DEEP_CHANNEL -> 10;
case DEEP_POOL -> 11;
case SURFACE_POOL -> 12;
case STANDING_POOL -> 13;
};
}
}
@@ -0,0 +1,577 @@
package art.arcane.iris.generation.hydrology;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import java.util.ArrayList;
import java.util.List;
final class HydrologyFootprintRasterizer {
private final HydrologyFootprintCompiler compiler;
HydrologyFootprintRasterizer(HydrologyFootprintCompiler compiler) {
this.compiler = compiler;
}
void rasterizeSegment(
Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> columns,
RiverCourse course,
HydraulicSegment segment,
boolean firstSegment,
boolean clipStart,
boolean clipEnd,
boolean validationOnly,
HydrologyFootprintCompiler.SurfaceRasterIndex plannedSurface
) {
Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> segmentColumns = segment.type().isSurface() && segment.fallingFluid()
? new Long2ObjectLinkedOpenHashMap<>() : columns;
List<HydrologyPoint> centerline = continuousCenterline(segment);
if (segment.fallingFluid() && centerline.size() > 1) {
HydrologyPoint throat = centerline.getFirst();
int fluidHead = segment.upstreamHeadY();
FootprintLayerShape throatShape = compiler.channelGeometry.shape(
course,
segment,
throat,
segment.downstreamHeadY() - 1,
fluidHead,
true,
false
);
rasterizePoint(
segmentColumns,
course,
segment,
throat,
0,
throatShape,
compiler.flowDelta(centerline, 0, true),
compiler.flowDelta(centerline, 0, false),
firstSegment,
true,
false,
false,
validationOnly,
plannedSurface
);
rasterizeSweptSegment(
segmentColumns,
course,
segment,
List.copyOf(centerline.subList(1, centerline.size())),
false,
true,
clipEnd,
validationOnly,
plannedSurface
);
if (segmentColumns != columns) {
compiler.mergeSurfaceDrop(columns, segmentColumns);
}
return;
}
if (sweptChannel(segment, centerline)) {
rasterizeSweptSegment(
segmentColumns,
course,
segment,
centerline,
firstSegment,
clipStart,
clipEnd,
validationOnly,
plannedSurface
);
return;
}
for (int pointIndex = 0; pointIndex < centerline.size(); pointIndex++) {
HydrologyPoint point = centerline.get(pointIndex);
boolean oceanConnection = (segment.type() == HydrologyFeatureType.MOUTH
|| segment.type() == HydrologyFeatureType.COASTAL_GROTTO)
&& pointIndex == centerline.size() - 1;
boolean falling = segment.fallingFluid() && pointIndex == 0;
boolean receiving = segment.receivingPool() && pointIndex == centerline.size() - 1;
int fluidHead = falling ? segment.upstreamHeadY() : point.y();
int bed = falling
? segment.downstreamHeadY() - 1
: fluidHead - segment.depth();
FootprintLayerShape shape = compiler.channelGeometry.shape(course, segment, point, bed, fluidHead, falling, receiving);
int flowX = compiler.flowDelta(centerline, pointIndex, true);
int flowZ = compiler.flowDelta(centerline, pointIndex, false);
rasterizePoint(
segmentColumns,
course,
segment,
point,
pointIndex,
shape,
flowX,
flowZ,
firstSegment && pointIndex == 0,
falling,
receiving,
oceanConnection,
validationOnly,
plannedSurface
);
}
if (segmentColumns != columns) {
compiler.mergeSurfaceDrop(columns, segmentColumns);
}
}
boolean sweptChannel(
HydraulicSegment segment,
List<HydrologyPoint> centerline
) {
if (centerline.size() < 2 || segment.fallingFluid()) {
return false;
}
return segment.type().isSurface()
|| segment.type() == HydrologyFeatureType.UNDERGROUND_POOL
|| segment.type() == HydrologyFeatureType.UNDERGROUND_DROP
|| segment.type() == HydrologyFeatureType.SINKHOLE
|| segment.type() == HydrologyFeatureType.DEEP_CHANNEL;
}
void rasterizeSweptSegment(
Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> columns,
RiverCourse course,
HydraulicSegment segment,
List<HydrologyPoint> centerline,
boolean firstSegment,
boolean clipStart,
boolean clipEnd,
boolean validationOnly,
HydrologyFootprintCompiler.SurfaceRasterIndex plannedSurface
) {
FootprintLayerShape[] shapes = new FootprintLayerShape[centerline.size()];
int maximumRadius = 1;
int minimumX = Integer.MAX_VALUE;
int maximumX = Integer.MIN_VALUE;
int minimumZ = Integer.MAX_VALUE;
int maximumZ = Integer.MIN_VALUE;
for (int pointIndex = 0; pointIndex < centerline.size(); pointIndex++) {
HydrologyPoint point = centerline.get(pointIndex);
int fluidHead = point.y();
boolean receiving = segment.receivingPool() && pointIndex == centerline.size() - 1;
FootprintLayerShape shape = compiler.channelGeometry.shape(
course,
segment,
point,
fluidHead - segment.depth(),
fluidHead,
false,
receiving
);
shapes[pointIndex] = shape;
int rasterRadius = segment.type().isSurface()
? (int) StrictMath.ceil(shape.totalRadius())
: shape.channelRadius();
maximumRadius = Math.max(maximumRadius, rasterRadius);
minimumX = Math.min(minimumX, point.x());
maximumX = Math.max(maximumX, point.x());
minimumZ = Math.min(minimumZ, point.z());
maximumZ = Math.max(maximumZ, point.z());
}
minimumX = Math.subtractExact(minimumX, maximumRadius);
maximumX = Math.addExact(maximumX, maximumRadius);
minimumZ = Math.subtractExact(minimumZ, maximumRadius);
maximumZ = Math.addExact(maximumZ, maximumRadius);
HydrologyFeatureRef[][] pointFeatures = new HydrologyFeatureRef[centerline.size()][HydrologyFootprintCompiler.FEATURE_ROLE_COUNT];
for (int z = minimumZ; z <= maximumZ; z++) {
for (int x = minimumX; x <= maximumX; x++) {
if (!withinLongitudinalBounds(centerline, segment.start(), x, z, clipStart, clipEnd)) {
continue;
}
FootprintCenterlineProjection projection = projectCenterline(centerline, segment.start(), x, z);
int pointIndex = projection.pointIndex();
HydrologyPoint point = centerline.get(pointIndex);
boolean receiving = segment.receivingPool() && pointIndex == centerline.size() - 1;
FootprintLayerShape shape = shapes[pointIndex];
int deltaX = x - point.x();
int deltaZ = z - point.z();
double rawDistance = projection.distance();
double rasterRadius = segment.type().isSurface()
? shape.totalRadius()
: shape.channelRadius();
if (rawDistance > rasterRadius + 0.25D) {
continue;
}
int flowX = projection.flowX();
int flowZ = projection.flowZ();
double channelDistance = compiler.channelGeometry.shapedDistance(
shape,
segment,
x,
z,
x - projection.x(),
z - projection.z(),
flowX,
flowZ,
rawDistance
);
boolean channel = channelDistance <= shape.channelRadius() + 0.25D;
if (!segment.type().isSurface() && !channel) {
continue;
}
boolean shore = !channel
&& channelDistance <= shape.channelRadius() + shape.shoreWidth() + 0.25D;
boolean grading = !channel && !shore && rawDistance <= shape.totalRadius() + 0.25D;
if (!channel && !shore && !grading) {
continue;
}
double distance = grading ? rawDistance : channelDistance;
HydrologyRoutingTerrainSampler.NaturalClassification classification = compiler.classifyNatural(x, z);
boolean oceanConnection = (segment.type() == HydrologyFeatureType.MOUTH
|| segment.type() == HydrologyFeatureType.COASTAL_GROTTO)
&& pointIndex == centerline.size() - 1;
boolean apronEligible = compiler.oceanApronEligible(segment, distance);
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN
&& !apronEligible) {
continue;
}
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.LAND
&& oceanConnection) {
continue;
}
boolean exactSlope = classification != HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN
&& !oceanConnection
&& !channel
&& segment.type() != HydrologyFeatureType.WATERFALL
&& segment.type() != HydrologyFeatureType.CASCADE;
HydrologyTerrainSample terrain = exactSlope ? compiler.sampleTerrain(x, z) : compiler.sampleTerrainBasis(x, z);
if (terrain == null || compiler.elevatedSeaLevelSurfaceColumn(segment, terrain, shape.fluidHead())) {
continue;
}
boolean naturalOcean = classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN
|| terrain.ocean()
|| compiler.naturallySubmergedSurfaceColumn(segment, terrain);
if (oceanConnection || naturalOcean) {
if (naturalOcean && apronEligible) {
compiler.addOceanApron(columns, course, segment, point, x, z, distance, flowX, flowZ,
terrain, pointFeatures[pointIndex]);
}
continue;
}
boolean source = firstSegment && pointIndex == 0;
if (validationOnly && segment.type().isSurface() && !segment.fallingFluid()) {
compiler.feature(course, segment, x, shape.fluidHead(), z, flowX, flowZ,
source && deltaX == 0 && deltaZ == 0, channel, shore, grading,
false, false, pointFeatures[pointIndex]);
continue;
}
HydrologyColumnLayer layer = compiler.regularLayer(
course,
segment,
point,
shape,
terrain,
distance,
deltaX,
deltaZ,
flowX,
flowZ,
source,
channel,
shore,
grading,
false,
receiving,
pointFeatures[pointIndex]
);
layer = compiler.terrainContainedCaveLayer(course, segment, terrain, layer, x, z, plannedSurface);
if (layer == null) {
continue;
}
compiler.addLayer(columns, x, z, terrain, layer);
}
}
}
FootprintCenterlineProjection projectCenterline(List<HydrologyPoint> centerline, HydrologyPoint incoming, int x, int z) {
if (centerline.size() == 1) {
HydrologyPoint point = centerline.getFirst();
return new FootprintCenterlineProjection(
0,
point.x(),
point.z(),
StrictMath.hypot(x - point.x(), z - point.z()),
point.x() - incoming.x(),
point.z() - incoming.z()
);
}
int selectedPoint = 0;
double selectedX = centerline.getFirst().x();
double selectedZ = centerline.getFirst().z();
double selectedDistanceSquared = Double.POSITIVE_INFINITY;
int selectedFlowX = 1;
int selectedFlowZ = 0;
for (int segmentIndex = 0; segmentIndex < centerline.size() - 1; segmentIndex++) {
HydrologyPoint start = centerline.get(segmentIndex);
HydrologyPoint end = centerline.get(segmentIndex + 1);
double flowX = end.x() - start.x();
double flowZ = end.z() - start.z();
double lengthSquared = flowX * flowX + flowZ * flowZ;
if (lengthSquared == 0D) {
continue;
}
double progress = ((x - start.x()) * flowX + (z - start.z()) * flowZ) / lengthSquared;
progress = Math.max(0D, Math.min(1D, progress));
double projectedX = start.x() + flowX * progress;
double projectedZ = start.z() + flowZ * progress;
double deltaX = x - projectedX;
double deltaZ = z - projectedZ;
double distanceSquared = deltaX * deltaX + deltaZ * deltaZ;
if (distanceSquared >= selectedDistanceSquared) {
continue;
}
selectedDistanceSquared = distanceSquared;
selectedX = projectedX;
selectedZ = projectedZ;
selectedPoint = progress < 0.5D ? segmentIndex : segmentIndex + 1;
selectedFlowX = end.x() - start.x();
selectedFlowZ = end.z() - start.z();
}
int tangentReach = Math.max(2, compiler.settings.routing().refinementSpacing() * 2);
HydrologyPoint tangentStart = centerline.get(Math.max(0, selectedPoint - tangentReach));
HydrologyPoint tangentEnd = centerline.get(Math.min(centerline.size() - 1, selectedPoint + tangentReach));
int tangentX = tangentEnd.x() - tangentStart.x();
int tangentZ = tangentEnd.z() - tangentStart.z();
if (tangentX != 0 || tangentZ != 0) {
selectedFlowX = tangentX;
selectedFlowZ = tangentZ;
}
return new FootprintCenterlineProjection(
selectedPoint,
selectedX,
selectedZ,
StrictMath.sqrt(selectedDistanceSquared),
selectedFlowX,
selectedFlowZ
);
}
boolean withinLongitudinalBounds(
List<HydrologyPoint> centerline,
HydrologyPoint incoming,
int x,
int z,
boolean clipStart,
boolean clipEnd
) {
if (centerline.size() < 2) {
HydrologyPoint point = centerline.getFirst();
long dot = (long) (x - point.x()) * (point.x() - incoming.x())
+ (long) (z - point.z()) * (point.z() - incoming.z());
return (!clipStart || dot >= 0L) && (!clipEnd || dot <= 0L);
}
HydrologyPoint start = centerline.getFirst();
HydrologyPoint afterStart = centerline.get(1);
long startDot = (long) (x - start.x()) * (afterStart.x() - start.x())
+ (long) (z - start.z()) * (afterStart.z() - start.z());
if (clipStart && startDot < 0L) {
return false;
}
if (!clipEnd) {
return true;
}
HydrologyPoint end = centerline.getLast();
HydrologyPoint beforeEnd = centerline.get(centerline.size() - 2);
long endDot = (long) (x - end.x()) * (end.x() - beforeEnd.x())
+ (long) (z - end.z()) * (end.z() - beforeEnd.z());
return endDot <= 0L;
}
boolean segmentsJoin(HydraulicSegment upstream, HydraulicSegment downstream) {
HydrologyPoint upstreamEnd = upstream.centerline().getLast();
HydrologyPoint downstreamStart = downstream.centerline().getFirst();
return upstreamEnd.x() == downstreamStart.x()
&& upstreamEnd.y() == downstreamStart.y()
&& upstreamEnd.z() == downstreamStart.z();
}
List<HydrologyPoint> continuousCenterline(HydraulicSegment segment) {
List<HydrologyPoint> configured = segment.centerline();
if (configured.size() == 1) {
return configured;
}
ArrayList<HydrologyPoint> continuous = new ArrayList<>();
for (int pairIndex = 0; pairIndex < configured.size() - 1; pairIndex++) {
HydrologyPoint start = configured.get(pairIndex);
HydrologyPoint end = configured.get(pairIndex + 1);
int steps = Math.max(Math.abs(end.x() - start.x()), Math.abs(end.z() - start.z()));
if (steps == 0) {
if (continuous.isEmpty()) {
continuous.add(start);
}
continue;
}
int firstStep = continuous.isEmpty() ? 0 : 1;
for (int step = firstStep; step <= steps; step++) {
double progress = step / (double) steps;
int x = (int) StrictMath.round(start.x() + (end.x() - start.x()) * progress);
int z = (int) StrictMath.round(start.z() + (end.z() - start.z()) * progress);
int y = segment.fallingFluid() && !continuous.isEmpty()
? segment.downstreamHeadY()
: (int) StrictMath.round(start.y() + (end.y() - start.y()) * progress);
HydrologyPoint previous = continuous.isEmpty() ? null : continuous.getLast();
if (previous == null || previous.x() != x || previous.z() != z) {
continuous.add(new HydrologyPoint(x, y, z));
}
}
}
return List.copyOf(continuous);
}
void rasterizePoint(
Long2ObjectLinkedOpenHashMap<FootprintMutableColumn> columns,
RiverCourse course,
HydraulicSegment segment,
HydrologyPoint point,
int pointIndex,
FootprintLayerShape shape,
int flowX,
int flowZ,
boolean source,
boolean falling,
boolean receiving,
boolean oceanConnection,
boolean validationOnly,
HydrologyFootprintCompiler.SurfaceRasterIndex plannedSurface
) {
FootprintRasterStencil stencil = compiler.rasterStencil(shape);
HydrologyFeatureRef[] pointFeatures = new HydrologyFeatureRef[HydrologyFootprintCompiler.FEATURE_ROLE_COUNT];
boolean underground = segment.type().isUnderground() || segment.type().isDeepFluid();
for (int offsetIndex = 0; offsetIndex < stencil.size(); offsetIndex++) {
int deltaX = stencil.deltaXs()[offsetIndex];
int deltaZ = stencil.deltaZs()[offsetIndex];
int x = point.x() + deltaX;
int z = point.z() + deltaZ;
double channelDistance = compiler.channelGeometry.shapedDistance(
shape,
segment,
x,
z,
deltaX,
deltaZ,
flowX,
flowZ,
stencil.distances()[offsetIndex]
);
boolean channel = channelDistance <= shape.channelRadius() + 0.25D;
if (underground && !channel) {
continue;
}
boolean shore = !channel
&& stencil.distances()[offsetIndex] <= shape.channelRadius() + shape.shoreWidth() + 0.25D;
boolean grading = !channel
&& !shore
&& stencil.distances()[offsetIndex] <= shape.totalRadius() + 0.25D;
double distance = grading ? stencil.distances()[offsetIndex] : channelDistance;
HydrologyRoutingTerrainSampler.NaturalClassification classification = compiler.classifyNatural(x, z);
boolean apronEligible = compiler.oceanApronEligible(segment, distance);
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN
&& !apronEligible) {
continue;
}
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.LAND
&& oceanConnection) {
continue;
}
boolean exactSlope = classification != HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN
&& !oceanConnection
&& !channel
&& segment.type() != HydrologyFeatureType.WATERFALL
&& segment.type() != HydrologyFeatureType.CASCADE;
HydrologyTerrainSample terrain = exactSlope
? compiler.sampleTerrain(x, z)
: compiler.sampleTerrainBasis(x, z);
if (terrain == null || compiler.elevatedSeaLevelSurfaceColumn(segment, terrain, shape.fluidHead())) {
continue;
}
boolean naturalOcean = classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN
|| terrain.ocean()
|| compiler.naturallySubmergedSurfaceColumn(segment, terrain);
if (oceanConnection) {
if (naturalOcean) {
compiler.addOceanApron(
columns,
course,
segment,
point,
x,
z,
distance,
flowX,
flowZ,
terrain,
pointFeatures
);
}
continue;
}
if (naturalOcean) {
compiler.addOceanApron(
columns,
course,
segment,
point,
x,
z,
distance,
flowX,
flowZ,
terrain,
pointFeatures
);
continue;
}
if (validationOnly && !underground && !segment.fallingFluid()) {
compiler.feature(
course,
segment,
x,
shape.fluidHead(),
z,
flowX,
flowZ,
source && deltaX == 0 && deltaZ == 0,
channel,
shore,
grading,
channel && falling,
channel && receiving,
pointFeatures
);
continue;
}
HydrologyColumnLayer layer = compiler.regularLayer(
course,
segment,
point,
shape,
terrain,
distance,
deltaX,
deltaZ,
flowX,
flowZ,
source,
channel,
shore,
grading,
falling,
receiving,
pointFeatures
);
layer = compiler.terrainContainedCaveLayer(course, segment, terrain, layer, x, z, plannedSurface);
if (layer == null) {
continue;
}
compiler.addLayer(columns, x, z, terrain, layer);
if (segment.type() == HydrologyFeatureType.COASTAL_GROTTO) {
compiler.addAdjacentSeaApron(columns, course, segment, point, x, z, flowX, flowZ, pointFeatures);
}
}
}
}
@@ -0,0 +1,176 @@
package art.arcane.iris.generation.hydrology;
import art.arcane.iris.generation.stream.ProvisionalSampling;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinWorkerThread;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Runs independent planning tasks and returns results in request order, using the current pool or fallback.
* A worker claims its own queued tasks before waiting, and never helps unrelated work while holding
* an owner draft. Every started task finishes before the batch returns or throws.
*/
public final class HydrologyForkJoin {
private HydrologyForkJoin() {
}
public static <T> List<T> invokeAll(List<Callable<T>> tasks, Executor fallback) {
Objects.requireNonNull(tasks, "tasks");
boolean callerRuns = Thread.currentThread() instanceof ForkJoinWorkerThread;
if (tasks.size() < 2 || fallback == null && !callerRuns) {
ArrayList<T> results = new ArrayList<>(tasks.size());
for (Callable<T> task : tasks) {
results.add(call(task));
}
return results;
}
Executor executor = callerRuns ? ((ForkJoinWorkerThread) Thread.currentThread()).getPool() : fallback;
ArrayList<Task<T>> pending = new ArrayList<>(tasks.size());
for (Callable<T> task : tasks) {
pending.add(new Task<>(task));
}
Throwable failure = null;
int submitted = callerRuns ? 1 : 0;
for (int index = callerRuns ? 1 : 0; index < pending.size(); index++) {
try {
executor.execute(pending.get(index));
submitted = index + 1;
} catch (RuntimeException | Error rejected) {
failure = rejected;
break;
}
}
ArrayList<T> results = new ArrayList<>(tasks.size());
int drainCount = callerRuns ? pending.size() : submitted;
for (int index = 0; index < drainCount; index++) {
try {
results.add(pending.get(index).await(callerRuns));
} catch (RuntimeException | Error failed) {
if (failure == null) {
failure = failed;
} else if (failure != failed) {
failure.addSuppressed(failed);
}
}
}
if (failure != null) {
throw unwrap(failure);
}
return results;
}
private static <T> T call(Callable<T> task) {
try {
return task.call();
} catch (RuntimeException | Error failure) {
throw failure;
} catch (Exception failure) {
throw new IllegalStateException("Hydrology planning task failed.", failure);
}
}
private static RuntimeException unwrap(Throwable cause) {
if (cause instanceof Error error) {
throw error;
}
if (cause instanceof RuntimeException runtime) {
return runtime;
}
return new IllegalStateException("Hydrology planning task failed.", cause);
}
static final class Task<T> implements Runnable, ForkJoinPool.ManagedBlocker {
private final Callable<T> work;
private final AtomicBoolean claimed = new AtomicBoolean();
private final CountDownLatch complete = new CountDownLatch(1);
private T result;
private Throwable failure;
private boolean provisional;
Task(Callable<T> work) {
this.work = Objects.requireNonNull(work);
}
T await() {
return await(true);
}
private T await(boolean claimPending) {
if (claimPending) {
run();
}
boolean interrupted = false;
boolean managed = true;
Throwable blockingFailure = null;
while (!isReleasable()) {
try {
if (managed) {
ForkJoinPool.managedBlock(this);
} else {
complete.await();
}
} catch (InterruptedException interruption) {
interrupted = true;
managed = false;
} catch (RejectedExecutionException unavailable) {
managed = false;
} catch (RuntimeException | Error failed) {
blockingFailure = failed;
managed = false;
}
}
if (interrupted) {
Thread.currentThread().interrupt();
}
if (blockingFailure != null) {
if (failure != null && failure != blockingFailure) {
blockingFailure.addSuppressed(failure);
}
throw unwrap(blockingFailure);
}
if (failure != null) {
throw unwrap(failure);
}
if (provisional) {
ProvisionalSampling.mark();
}
return result;
}
@Override
public boolean isReleasable() {
return complete.getCount() == 0;
}
@Override
public boolean block() throws InterruptedException {
complete.await();
return true;
}
@Override
public void run() {
if (!claimed.compareAndSet(false, true)) {
return;
}
try {
result = ProvisionalSampling.memoizable(() -> call(work));
} catch (ProvisionalSampling.Unmemoizable value) {
result = value.value();
provisional = true;
} catch (RuntimeException | Error failed) {
failure = failed;
} finally {
complete.countDown();
}
}
}
}
Loaded 100 of 964 files, more files were not shown because too many files have changed in this diff. Show more