Terrain-first hydrology

- Replace the river package with the hydrology planner, footprint compiler, cave containment and runtime
- Add the hydrology pack schema, validator, probes and tests
- Wire hydrology into IrisComplex, actuators, mantle, Vision and commands
This commit is contained in:
Brian Neumann-Fopiano committed 2026-09-01 18:28:00 -04:00
1 parent bdaa84975a
commit 48afa0d93f
352 files changed
+57870 -19028

No files matched your search

@@ -28,6 +28,9 @@ import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.framework.StructureReachability;
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisNativeStructureDecision;
import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRegion;
@@ -37,7 +40,9 @@ import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.PlatformStructureHooks; import art.arcane.iris.spi.PlatformStructureHooks;
import art.arcane.iris.util.common.director.DirectorExecutor; import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.iris.util.common.director.specialhandlers.HydrologyTypeHandler;
import art.arcane.iris.util.common.director.specialhandlers.ObjectHandler; import art.arcane.iris.util.common.director.specialhandlers.ObjectHandler;
import art.arcane.iris.util.common.director.specialhandlers.ReachableBiomeHandler;
import art.arcane.iris.util.common.director.specialhandlers.StructureHandler; import art.arcane.iris.util.common.director.specialhandlers.StructureHandler;
import art.arcane.iris.util.common.format.C; import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.plugin.VolmitSender;
@@ -68,7 +73,7 @@ import java.util.Set;
public class CommandFind implements DirectorExecutor { public class CommandFind implements DirectorExecutor {
@Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome") @Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_biome")
public void biome( public void biome(
@Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look") @Param(description = "The biome to look for", descriptionKey = "iris.director.commandfind.param.biome_look", customHandler = ReachableBiomeHandler.class)
IrisBiome biome, IrisBiome biome,
@Param(description = "Should you be teleported", descriptionKey = "iris.director.commandfind.param.should_you_be_teleported", defaultValue = "true") @Param(description = "Should you be teleported", descriptionKey = "iris.director.commandfind.param.should_you_be_teleported", defaultValue = "true")
boolean teleport boolean teleport
@@ -116,6 +121,73 @@ public class CommandFind implements DirectorExecutor {
EngineBukkitOps.gotoPOI(e, type, player(), teleport); EngineBukkitOps.gotoPOI(e, type, player(), teleport);
} }
@Director(description = "Find an accepted hydrology feature")
public void river(
@Param(description = "Feature type: surface, waterfall, underground, grotto, mouth, or a deep-fluid id", customHandler = HydrologyTypeHandler.class)
String type,
@Param(description = "Should you be teleported", defaultValue = "true")
boolean teleport
) {
Engine activeEngine = engine();
VolmitSender commandSender = sender();
Player target = player();
if (activeEngine == null || commandSender == null || target == null) {
if (commandSender != null) {
commandSender.sendMessage(C.RED + "Run this command from an Iris world.");
}
return;
}
IrisHydrologyRuntime runtime = activeEngine.getComplex().getHydrologyRuntime();
if (runtime == null) {
commandSender.sendMessage(C.YELLOW + "Hydrology is not active in this world.");
return;
}
HydrologyFeatureQuery query;
try {
query = HydrologyFeatureQuery.parse(type);
} catch (IllegalArgumentException error) {
commandSender.sendMessage(C.RED + error.getMessage());
return;
}
Location origin = target.getLocation();
int requestedDistance = Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime.maximumFeatureSearchDistance(
origin.getBlockX(), origin.getBlockZ(), requestedDistance);
commandSender.sendMessage(C.GRAY + "Searching accepted hydrology plans for " + type + "...");
J.a(() -> {
try {
HydrologyFeatureRef feature = runtime.nearestFeature(
query.types(),
query.profileKey(),
origin.getBlockX(),
origin.getBlockZ(),
maximumDistance
).orElse(null);
if (feature == null) {
sendStructureMessage(target, commandSender,
C.YELLOW + "No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
return;
}
int worldY = feature.y() + activeEngine.getDimension().getMinHeight();
String label = type + " hydrology feature";
if (!teleport) {
sendStructureMessage(target, commandSender,
C.GREEN + "Found " + label + " at " + feature.x() + ", " + worldY + ", " + feature.z() + ".");
return;
}
Location destination = new Location(
target.getWorld(), feature.x(), worldY, feature.z());
prepareStructureTeleport(
target, target.getWorld(), commandSender, label, destination, true);
} catch (Throwable error) {
sendStructureMessage(target, commandSender,
C.RED + "Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
Iris.reportError("Could not locate accepted hydrology feature '" + type + "'.", error);
}
});
}
@Director(description = "Find a structure (a vanilla key like minecraft:village_plains or minecraft:stronghold, or an imported iris structure key)", descriptionKey = "iris.director.commandfind.director.find_structure_vanilla_key_like_minecraft_village_plains_minecraft_stronghold_imported_iris", sync = true) @Director(description = "Find a structure (a vanilla key like minecraft:village_plains or minecraft:stronghold, or an imported iris structure key)", descriptionKey = "iris.director.commandfind.director.find_structure_vanilla_key_like_minecraft_village_plains_minecraft_stronghold_imported_iris", sync = true)
public void structure( public void structure(
@Param(description = "The structure to look for (e.g. minecraft:village_plains, minecraft:stronghold, minecraft_ancient_city)", descriptionKey = "iris.director.commandfind.param.structure_look_e_g_minecraft_village_plains_minecraft_stronghold_minecraft_ancient_city", customHandler = StructureHandler.class) @Param(description = "The structure to look for (e.g. minecraft:village_plains, minecraft:stronghold, minecraft_ancient_city)", descriptionKey = "iris.director.commandfind.param.structure_look_e_g_minecraft_village_plains_minecraft_stronghold_minecraft_ancient_city", customHandler = StructureHandler.class)
@@ -25,7 +25,10 @@ import art.arcane.iris.core.pack.PackValidationCache;
import art.arcane.iris.core.pack.PackValidationRegistry; import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult; import art.arcane.iris.core.pack.PackValidationResult;
import art.arcane.iris.core.pack.PackValidator; import art.arcane.iris.core.pack.PackValidator;
import art.arcane.iris.core.service.StudioSVC;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.util.common.director.DirectorExecutor; import art.arcane.iris.util.common.director.DirectorExecutor;
import art.arcane.iris.util.common.director.handlers.DimensionHandler;
import art.arcane.iris.util.common.plugin.VolmitSender; import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.director.annotations.Director; import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param; import art.arcane.volmlib.util.director.annotations.Param;
@@ -90,6 +93,18 @@ public class CommandPack implements DirectorExecutor {
persistValidationCache(packsRoot); persistValidationCache(packsRoot);
} }
@Director(description = "Package a validated dimension into a compressed format", descriptionKey = "iris.director.commandstudio.director.package_dimension_into_compressed_format", aliases = "package")
public void pkg(
@Param(name = "dimension", description = "The dimension pack to compress", descriptionKey = "iris.director.commandstudio.param.dimension_pack_compress", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
IrisDimension dimension,
@Param(name = "obfuscate", description = "Whether or not to obfuscate the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_obfuscate_pack", defaultValue = "false")
boolean obfuscate,
@Param(name = "minify", description = "Whether or not to minify the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_minify_pack", defaultValue = "true")
boolean minify
) {
Iris.service(StudioSVC.class).compilePackage(sender(), dimension.getLoadKey(), obfuscate, minify);
}
@Director(description = "Preview or apply unused-resource cleanup", descriptionKey = "iris.director.commandpack.director.preview_apply_unused_resource_cleanup", aliases = {"c"}) @Director(description = "Preview or apply unused-resource cleanup", descriptionKey = "iris.director.commandpack.director.preview_apply_unused_resource_cleanup", aliases = {"c"})
public void cleanup( public void cleanup(
@Param(description = "The pack folder name to clean", descriptionKey = "iris.director.commandpack.param.pack_folder_name_clean") @Param(description = "The pack folder name to clean", descriptionKey = "iris.director.commandpack.param.pack_folder_name_clean")
@@ -420,18 +420,6 @@ public class CommandStudio implements DirectorExecutor {
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP)); sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
} }
@Director(description = "Package a dimension into a compressed format", descriptionKey = "iris.director.commandstudio.director.package_dimension_into_compressed_format", aliases = "package")
public void pkg(
@Param(name = "dimension", description = "The dimension pack to compress", descriptionKey = "iris.director.commandstudio.param.dimension_pack_compress", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
IrisDimension dimension,
@Param(name = "obfuscate", description = "Whether or not to obfuscate the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_obfuscate_pack", defaultValue = "false")
boolean obfuscate,
@Param(name = "minify", description = "Whether or not to minify the pack", descriptionKey = "iris.director.commandstudio.param.whether_not_minify_pack", defaultValue = "true")
boolean minify
) {
Iris.service(StudioSVC.class).compilePackage(sender(), dimension.getLoadKey(), obfuscate, minify);
}
@Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER) @Director(description = "Profiles the performance of a dimension", descriptionKey = "iris.director.commandstudio.director.profiles_performance_dimension", origin = DirectorOrigin.PLAYER)
public void profile( public void profile(
@Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class) @Param(description = "The dimension to profile", descriptionKey = "iris.director.commandstudio.param.dimension_profile", contextual = true, contextualOverride = true, defaultValue = "default", customHandler = DimensionHandler.class)
@@ -15,12 +15,12 @@ import art.arcane.iris.core.service.terrain.IrisSampleLimits;
import art.arcane.iris.core.service.terrain.IrisSurfaceClassifier; import art.arcane.iris.core.service.terrain.IrisSurfaceClassifier;
import art.arcane.iris.core.service.terrain.IrisWorldInfoFactory; import art.arcane.iris.core.service.terrain.IrisWorldInfoFactory;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.platform.PlatformChunkGenerator; import art.arcane.iris.engine.platform.PlatformChunkGenerator;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
import art.arcane.iris.platform.bukkit.BukkitPlatform; import art.arcane.iris.platform.bukkit.BukkitPlatform;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices; import art.arcane.iris.spi.IrisServices;
@@ -105,14 +105,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
try { try {
int surface = engine.getHeight(blockX, blockZ); int surface = engine.getHeight(blockX, blockZ);
IrisRiverSurfaceSample riverSurface = engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ); HydrologyColumnSample hydrology = hydrologySample(engine, blockX, blockZ);
int fluid = (int) Math.round(riverSurface.waterSurfaceY()); int fluid = (int) Math.round(
engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
InferredType inferredType = null; InferredType inferredType = null;
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) { if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ); IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
inferredType = biome == null ? null : biome.getInferredType(); inferredType = biome == null ? null : biome.getInferredType();
} }
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, riverSurface); return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology);
} catch (Throwable error) { } catch (Throwable error) {
reportQueryFault("surfaceKind", world, error); reportQueryFault("surfaceKind", world, error);
return IrisSurfaceKind.UNKNOWN; return IrisSurfaceKind.UNKNOWN;
@@ -246,13 +247,13 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
return false; return false;
} }
IrisRiverSurfaceSample riverSurface = wantRiver HydrologyColumnSample hydrology = wantRiver
? engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ) ? hydrologySample(engine, blockX, blockZ)
: null; : null;
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0; int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
int fluid = riverSurface == null int fluid = wantRiver
? engine.getDimension().getFluidHeight() ? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ))
: (int) Math.round(riverSurface.waterSurfaceY()); : engine.getDimension().getFluidHeight();
boolean needsBiome = wantBiome boolean needsBiome = wantBiome
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)); || (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
IrisBiome biome = needsBiome ? engine.getSurfaceBiome(blockX, blockZ) : null; IrisBiome biome = needsBiome ? engine.getSurfaceBiome(blockX, blockZ) : null;
@@ -261,26 +262,29 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
surface, surface,
fluid, fluid,
biome == null ? null : biome.getInferredType(), biome == null ? null : biome.getInferredType(),
riverSurface hydrology
) )
: IrisSurfaceKind.UNKNOWN; : IrisSurfaceKind.UNKNOWN;
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null; String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
int natural = wantNaturalHeight int natural = wantNaturalHeight
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight ? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT; : IrisColumnSample.UNAVAILABLE_HEIGHT;
boolean riverPresent = riverSurface != null && riverSurface.river().present(); HydrologyColumnLayer surfaceLayer = hydrology == null
IrisRiverState riverState = wantRiverState && riverSurface != null ? null
? riverState(riverSurface) : hydrology.primarySurfaceLayer().orElse(null);
boolean riverPresent = surfaceLayer != null;
IrisRiverState riverState = wantRiverState
? riverState(hydrology)
: IrisRiverState.NONE; : IrisRiverState.NONE;
double riverDistance = wantRiverDistance && riverPresent double riverDistance = wantRiverDistance && riverPresent
? riverSurface.river().distance() ? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ)
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE; : IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
int riverFlow = wantRiverFlow && riverPresent int riverFlow = wantRiverFlow && riverPresent
? riverSurface.river().flow() ? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ))
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW; : IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
int riverWaterSurfaceY = wantRiverWaterSurface int riverWaterSurfaceY = wantRiverWaterSurface
&& riverPresent && surfaceLayer != null
&& riverSurface.river().state() == RiverRouteState.WET && surfaceLayer.connectedFluid()
? fluid + minHeight ? fluid + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT; : IrisColumnSample.UNAVAILABLE_HEIGHT;
sink.accept(new IrisColumnSample( sink.accept(new IrisColumnSample(
@@ -304,15 +308,21 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
} }
} }
static IrisRiverState riverState(IrisRiverSurfaceSample surface) { static IrisRiverState riverState(HydrologyColumnSample hydrology) {
if (!surface.river().present()) { HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer == null || !layer.channel()) {
return IrisRiverState.NONE; return IrisRiverState.NONE;
} }
return switch (surface.river().state()) { return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY;
case WET -> IrisRiverState.WET; }
case DRY -> IrisRiverState.DRY;
case SUPPRESSED -> IrisRiverState.NONE; 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) { private static Optional<String> key(IrisBiome biome) {
@@ -1,10 +1,9 @@
package art.arcane.iris.core.service.terrain; package art.arcane.iris.core.service.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
public final class IrisSurfaceClassifier { public final class IrisSurfaceClassifier {
private IrisSurfaceClassifier() { private IrisSurfaceClassifier() {
@@ -30,25 +29,23 @@ public final class IrisSurfaceClassifier {
int engineSurfaceHeight, int engineSurfaceHeight,
int engineFluidHeight, int engineFluidHeight,
InferredType inferredType, InferredType inferredType,
IrisRiverSurfaceSample riverSurface HydrologyColumnSample hydrology
) { ) {
if (engineSurfaceHeight <= 0) { if (engineSurfaceHeight <= 0) {
return IrisSurfaceKind.VOID; return IrisSurfaceKind.VOID;
} }
if (riverSurface != null && riverSurface.river().present() && !riverSurface.subterranean()) { HydrologyColumnLayer layer = hydrology == null
if (riverSurface.river().state() == RiverRouteState.DRY) { ? null
return riverSurface.river().section() == RiverSection.DRY_CHANNEL : hydrology.primarySurfaceLayer().orElse(null);
? IrisSurfaceKind.DRY_CHANNEL if (layer != null) {
: IrisSurfaceKind.LAND; if (layer.shore()) {
}
if (riverSurface.river().state() == RiverRouteState.WET) {
RiverSection section = riverSurface.river().section();
if (section == RiverSection.BANK) {
return IrisSurfaceKind.RIVER_SHORE; return IrisSurfaceKind.RIVER_SHORE;
} }
if (section == RiverSection.CHANNEL || section == RiverSection.MOUTH) { if (layer.channel()) {
return IrisSurfaceKind.RIVER; return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL;
} }
if (layer.grading()) {
return IrisSurfaceKind.LAND;
} }
} }
return classify(engineSurfaceHeight, engineFluidHeight, inferredType); return classify(engineSurfaceHeight, engineFluidHeight, inferredType);
@@ -0,0 +1,20 @@
package art.arcane.iris.core.commands;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.util.common.director.specialhandlers.ReachableBiomeHandler;
import art.arcane.volmlib.util.director.annotations.Param;
import org.junit.Test;
import java.lang.reflect.Method;
import static org.junit.Assert.assertEquals;
public class CommandFindBiomeContractTest {
@Test
public void biomeLookupUsesReachableBiomeHandler() throws Exception {
Method method = CommandFind.class.getMethod("biome", IrisBiome.class, boolean.class);
Param parameter = method.getParameters()[0].getAnnotation(Param.class);
assertEquals(ReachableBiomeHandler.class, parameter.customHandler());
}
}
@@ -1,10 +1,12 @@
package art.arcane.iris.core.commands; package art.arcane.iris.core.commands;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility; import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility;
import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor; import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor;
import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor; import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor;
import org.junit.Test; import org.junit.Test;
import java.lang.reflect.Method;
import java.util.List; import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@@ -52,8 +54,23 @@ public class CommandPackOptionalPackContractTest {
assertFalse(CommandPack.wantsAllPacks("overworld")); assertFalse(CommandPack.wantsAllPacks("overworld"));
} }
private static java.lang.reflect.Method findMethod(String name) { @Test
for (java.lang.reflect.Method method : CommandPack.class.getDeclaredMethods()) { public void packageCommandIsOwnedByPackNamespace() throws Exception {
Method method = CommandPack.class.getDeclaredMethod(
"pkg",
IrisDimension.class,
boolean.class,
boolean.class
);
DirectorNodeDescriptor descriptor = DirectorAnnotationCompatibility.fromMethod(method).orElseThrow();
assertTrue(descriptor.getAliases().contains("package"));
for (Method studioMethod : CommandStudio.class.getDeclaredMethods()) {
assertFalse("Studio must not retain the package command", studioMethod.getName().equals("pkg"));
}
}
private static Method findMethod(String name) {
for (Method method : CommandPack.class.getDeclaredMethods()) {
if (method.getName().equals(name)) { if (method.getName().equals(name)) {
return method; return method;
} }
@@ -5,12 +5,10 @@ import art.arcane.iris.api.terrain.IrisColumnQuery;
import art.arcane.iris.api.terrain.IrisRiverState; import art.arcane.iris.api.terrain.IrisRiverState;
import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.api.terrain.IrisTerrainService; import art.arcane.iris.api.terrain.IrisTerrainService;
import art.arcane.iris.engine.river.RiverEdgeId; import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.river.RiverNodeId; import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.river.RiverRouteState; import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.river.RiverSample; import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
import art.arcane.iris.util.common.plugin.IrisService; import art.arcane.iris.util.common.plugin.IrisService;
import org.bukkit.World; import org.bukkit.World;
import org.junit.Test; import org.junit.Test;
@@ -19,6 +17,7 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.OptionalInt; import java.util.OptionalInt;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
@@ -104,36 +103,46 @@ public class IrisTerrainSVCTest {
} }
@Test @Test
public void riverRouteStatesMapToThePublicDiagnosticStates() { public void acceptedHydrologyMapsToThePublicDiagnosticStates() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState( assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(null));
new IrisRiverSurfaceSample(RiverSample.none(), 70D, 70D, 70D, false, false) assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(true)));
)); assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false)));
assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(RiverRouteState.WET)));
assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(RiverRouteState.DRY)));
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(river(RiverRouteState.SUPPRESSED)));
} }
private static IrisRiverSurfaceSample river(RiverRouteState state) { private static HydrologyColumnSample river(boolean connectedFluid) {
RiverSection section = switch (state) { HydrologyFeatureRef feature = new HydrologyFeatureRef(
case WET -> RiverSection.CHANNEL; 1L,
case DRY -> RiverSection.DRY_CHANNEL; HydrologyFeatureType.SURFACE_POOL,
case SUPPRESSED -> RiverSection.NONE; 2L,
}; 3L,
RiverSample sample = new RiverSample( 0,
true, 63,
state, 0,
section,
0D,
0.5D,
1D,
1, 1,
1, 0,
8D, false
4D,
3D,
false,
RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0))
); );
return new IrisRiverSurfaceSample(sample, 70D, 60D, 63D, false, state == RiverRouteState.WET); 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,15 +1,15 @@
package art.arcane.iris.core.service.terrain; package art.arcane.iris.core.service.terrain;
import art.arcane.iris.api.terrain.IrisSurfaceKind; import art.arcane.iris.api.terrain.IrisSurfaceKind;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.river.RiverEdgeId;
import art.arcane.iris.engine.river.RiverNodeId;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.RiverSample;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
import org.junit.Test; import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -59,91 +59,108 @@ public class IrisSurfaceClassifierTest {
IrisSurfaceKind expected = IrisSurfaceClassifier.classify(surface, FLUID, null); IrisSurfaceKind expected = IrisSurfaceClassifier.classify(surface, FLUID, null);
for (InferredType inferredType : InferredType.values()) { for (InferredType inferredType : InferredType.values()) {
assertEquals("surface=" + surface + " type=" + inferredType, assertEquals(
expected, IrisSurfaceClassifier.classify(surface, FLUID, inferredType)); "surface=" + surface + " type=" + inferredType,
expected,
IrisSurfaceClassifier.classify(surface, FLUID, inferredType)
);
} }
} }
} }
@Test @Test
public void activeRiverGeometryOverridesGenericOceanAndLandKinds() { public void acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() {
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60, 60,
63, 63,
InferredType.SEA, InferredType.SEA,
river(RiverRouteState.WET, RiverSection.CHANNEL) hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
)); ));
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify( assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60, 60,
63, 63,
InferredType.SEA, InferredType.SEA,
river(RiverRouteState.WET, RiverSection.MOUTH) hydrology(HydrologyFeatureType.MOUTH, true, false, true)
)); ));
assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify( assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify(
64, 64,
63, 63,
InferredType.SHORE, InferredType.SHORE,
river(RiverRouteState.WET, RiverSection.BANK) hydrology(HydrologyFeatureType.SURFACE_POOL, false, true, false)
)); ));
assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify( assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify(
60, 60,
60, 60,
InferredType.LAND, InferredType.LAND,
river(RiverRouteState.DRY, RiverSection.DRY_CHANNEL) hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, false)
)); ));
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify( assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
60, 60,
60, 60,
InferredType.LAND, InferredType.LAND,
river(RiverRouteState.DRY, RiverSection.DRY_BANK) hydrology(HydrologyFeatureType.SURFACE_POOL, false, false, false)
)); ));
} }
@Test @Test
public void voidClassificationWinsOverRiverGeometry() { public void voidClassificationWinsOverAcceptedRiverGeometry() {
for (RiverSection section : RiverSection.values()) {
if (section == RiverSection.NONE) {
continue;
}
RiverRouteState state = section == RiverSection.DRY_CHANNEL || section == RiverSection.DRY_BANK
? RiverRouteState.DRY
: RiverRouteState.WET;
assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify( assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(
0, 0,
63, 63,
InferredType.LAND, InferredType.LAND,
river(state, section) hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
)); ));
} }
}
@Test @Test
public void suppressedRoutesDoNotCreatePublicRiverSurfaceKinds() { public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify( assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
64, 64,
63, 63,
InferredType.LAND, InferredType.LAND,
river(RiverRouteState.SUPPRESSED, RiverSection.CHANNEL) null
)); ));
} }
private static IrisRiverSurfaceSample river(RiverRouteState state, RiverSection section) { private static HydrologyColumnSample hydrology(
RiverSample sample = new RiverSample( HydrologyFeatureType type,
true, boolean channel,
state, boolean shore,
section, boolean connectedFluid
0D, ) {
0.5D, HydrologyFeatureRef feature = new HydrologyFeatureRef(
1D, 1L,
type,
2L,
3L,
0,
63,
0,
1, 1,
1, 0,
8D, false
4D,
3D,
false,
RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0))
); );
double waterSurface = state == RiverRouteState.WET ? 63D : 60D; HydrologyColumnLayer layer = new HydrologyColumnLayer(
return new IrisRiverSurfaceSample(sample, 70D, 60D, waterSurface, false, state == RiverRouteState.WET); 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));
} }
} }
+4
View File
@@ -114,6 +114,10 @@ dependencies {
minecraft("com.mojang:minecraft:${minecraftVersion}") minecraft("com.mojang:minecraft:${minecraftVersion}")
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}") implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
testImplementation('junit:junit:4.13.2') testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.spigot) {
transitive = false
}
// registrySync and resourceLoader are LOAD-BEARING despite zero imports anywhere in Iris: // registrySync and resourceLoader are LOAD-BEARING despite zero imports anywhere in Iris:
// fabric-registry-sync-v0 delays registry freeze past mod init, which is what lets // fabric-registry-sync-v0 delays registry freeze past mod init, which is what lets
// IrisFabricBootstrap register the CHUNK_GENERATOR codec in // IrisFabricBootstrap register the CHUNK_GENERATOR codec in
+4
View File
@@ -131,6 +131,10 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies { dependencies {
implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}") implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
testImplementation('junit:junit:4.13.2') testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.spigot) {
transitive = false
}
compileOnly('org.slf4j:slf4j-api:2.0.17') compileOnly('org.slf4j:slf4j-api:2.0.17')
compileOnly(libs.spigot) { compileOnly(libs.spigot) {
transitive = false transitive = false
@@ -18,6 +18,7 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.engine.IrisEngineMantle;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -41,7 +42,7 @@ public final class MainWorldService {
"region", "region",
"entities", "entities",
"poi", "poi",
"mantle", IrisEngineMantle.STORAGE_FOLDER_NAME,
"dimensions/minecraft/overworld", "dimensions/minecraft/overworld",
"dimensions/minecraft/the_nether", "dimensions/minecraft/the_nether",
"dimensions/minecraft/the_end", "dimensions/minecraft/the_end",
@@ -18,6 +18,7 @@
package art.arcane.iris.modded; package art.arcane.iris.modded;
import art.arcane.iris.engine.IrisEngineMantle;
import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.Level; import net.minecraft.world.level.Level;
@@ -33,7 +34,12 @@ import java.util.List;
import java.util.stream.Stream; import java.util.stream.Stream;
public final class ModdedDimensionStorage { public final class ModdedDimensionStorage {
private static final List<String> CHUNK_DATA_FOLDERS = List.of("region", "entities", "poi", "mantle"); private static final List<String> CHUNK_DATA_FOLDERS = List.of(
"region",
"entities",
"poi",
IrisEngineMantle.STORAGE_FOLDER_NAME
);
private ModdedDimensionStorage() { private ModdedDimensionStorage() {
} }
@@ -325,7 +325,7 @@ public final class IrisModdedCommands {
return 0; return 0;
} }
String target = downloadDisplayTarget(request); String target = downloadDisplayTarget(request);
String downloadSource = request.pack() == null ? "direct ZIP URL" : "built-in beta release"; String downloadSource = request.pack() == null ? "direct ZIP URL" : "built-in stable release";
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull(); ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
if (scheduler == null) { if (scheduler == null) {
fail(source, IrisLanguage.plain( fail(source, IrisLanguage.plain(
@@ -25,6 +25,8 @@ import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator; import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.framework.StructureReachability;
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisNativeStructureDecision;
import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.IrisModdedChunkGenerator;
@@ -62,6 +64,7 @@ final class ModdedCommandSuggestions {
static final SuggestionProvider<CommandSourceStack> OBJECT_KEYS = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestObjectKeys(context, builder); static final SuggestionProvider<CommandSourceStack> OBJECT_KEYS = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestObjectKeys(context, builder);
static final SuggestionProvider<CommandSourceStack> STRUCTURE_KEYS = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestStructureKeys(context, builder); static final SuggestionProvider<CommandSourceStack> STRUCTURE_KEYS = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestStructureKeys(context, builder);
static final SuggestionProvider<CommandSourceStack> POI_TYPES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> SharedSuggestionProvider.suggest(List.of("buried_treasure"), builder); static final SuggestionProvider<CommandSourceStack> POI_TYPES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> SharedSuggestionProvider.suggest(List.of("buried_treasure"), builder);
static final SuggestionProvider<CommandSourceStack> HYDROLOGY_TYPES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestHydrologyTypes(context, builder);
static final SuggestionProvider<CommandSourceStack> PACK_NAMES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestPackNames(context, builder); static final SuggestionProvider<CommandSourceStack> PACK_NAMES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestPackNames(context, builder);
static final SuggestionProvider<CommandSourceStack> DIMENSION_NAMES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder); static final SuggestionProvider<CommandSourceStack> DIMENSION_NAMES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder);
@@ -79,7 +82,7 @@ final class ModdedCommandSuggestions {
try { try {
Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel()); Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel());
if (engine != null) { if (engine != null) {
return SharedSuggestionProvider.suggest(engine.getData().getBiomeLoader().getPossibleKeys(), builder); return SharedSuggestionProvider.suggest(reachableBiomeKeys(engine), builder);
} }
} catch (Throwable e) { } catch (Throwable e) {
warnTabFailure("biome keys", context.getSource(), e); warnTabFailure("biome keys", context.getSource(), e);
@@ -87,6 +90,46 @@ final class ModdedCommandSuggestions {
return builder.buildFuture(); return builder.buildFuture();
} }
private static CompletableFuture<Suggestions> suggestHydrologyTypes(
CommandContext<CommandSourceStack> context,
SuggestionsBuilder builder
) {
ModdedCommandFeedback.tab(context.getSource());
try {
Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel());
if (engine != null && engine.getComplex().getHydrologyRuntime() != null) {
return SharedSuggestionProvider.suggest(
engine.getComplex().getHydrologyRuntime().featureQueryKeys(), builder);
}
} catch (Throwable error) {
warnTabFailure("hydrology types", context.getSource(), error);
}
return SharedSuggestionProvider.suggest(
HydrologyFeatureQuery.suggestions(List.of()), builder);
}
static Set<String> reachableBiomeKeys(Engine engine) {
return reachableBiomeKeys(engine.getAllBiomes());
}
static Set<String> reachableBiomeKeys(Iterable<IrisBiome> biomes) {
Set<String> keys = new TreeSet<>();
for (IrisBiome biome : biomes) {
if (biome != null && biome.getLoadKey() != null && !biome.getLoadKey().isBlank()) {
keys.add(biome.getLoadKey());
}
}
return keys;
}
static boolean isReachableBiome(Engine engine, String biomeKey) {
return isReachableBiome(engine.getAllBiomes(), biomeKey);
}
static boolean isReachableBiome(Iterable<IrisBiome> biomes, String biomeKey) {
return biomeKey != null && reachableBiomeKeys(biomes).contains(biomeKey.trim());
}
private static CompletableFuture<Suggestions> suggestRegionKeys(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) { private static CompletableFuture<Suggestions> suggestRegionKeys(CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) {
ModdedCommandFeedback.tab(context.getSource()); ModdedCommandFeedback.tab(context.getSource());
try { try {
@@ -227,6 +227,9 @@ final class ModdedCommandTree {
.then(Commands.literal("object") .then(Commands.literal("object")
.then(Commands.argument("key", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.OBJECT_KEYS) .then(Commands.argument("key", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.OBJECT_KEYS)
.executes((CommandContext<CommandSourceStack> context) -> ModdedLocateCommands.gotoObject(context.getSource(), StringArgumentType.getString(context, "key"))))) .executes((CommandContext<CommandSourceStack> context) -> ModdedLocateCommands.gotoObject(context.getSource(), StringArgumentType.getString(context, "key")))))
.then(Commands.literal("river")
.then(Commands.argument("type", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.HYDROLOGY_TYPES)
.executes((CommandContext<CommandSourceStack> context) -> ModdedLocateCommands.gotoRiver(context.getSource(), StringArgumentType.getString(context, "type")))))
.then(Commands.literal("unregistered") .then(Commands.literal("unregistered")
.executes((CommandContext<CommandSourceStack> context) -> .executes((CommandContext<CommandSourceStack> context) ->
ModdedUnregisteredStructures.print(context.getSource()))) ModdedUnregisteredStructures.print(context.getSource())))
@@ -29,12 +29,16 @@ import art.arcane.iris.engine.framework.Locator;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy; import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
import art.arcane.iris.engine.framework.StructureReachability; import art.arcane.iris.engine.framework.StructureReachability;
import art.arcane.iris.engine.framework.WrongEngineBroException; import art.arcane.iris.engine.framework.WrongEngineBroException;
import art.arcane.iris.engine.hydrology.HydrologyFeatureQuery;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisNativeStructureDecision; import art.arcane.iris.engine.object.IrisNativeStructureDecision;
import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.NativeStructureGenerationStatus; import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
import art.arcane.iris.modded.IrisModdedChunkGenerator; import art.arcane.iris.modded.IrisModdedChunkGenerator;
import art.arcane.iris.util.project.context.IrisContext; import art.arcane.iris.util.project.context.IrisContext;
import art.arcane.iris.util.common.parallel.MultiBurst;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
import art.arcane.volmlib.util.math.Position2; import art.arcane.volmlib.util.math.Position2;
import com.mojang.datafixers.util.Pair; import com.mojang.datafixers.util.Pair;
@@ -87,6 +91,10 @@ final class ModdedLocateCommands {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key))); IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_UNKNOWN_BIOME_2, MessageArgument.untrusted("key", key)));
return 0; return 0;
} }
if (!ModdedCommandSuggestions.isReachableBiome(engine, biome.getLoadKey())) {
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_IS_NOT_DEFINED_DIMENSION, MessageArgument.untrusted("value", biome.getLoadKey())));
return 0;
}
locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey()); locate(source, level, engine, player, Locator.surfaceBiome(biome.getLoadKey()), "biome " + biome.getLoadKey());
return 1; return 1;
} }
@@ -137,6 +145,65 @@ final class ModdedLocateCommands {
return 1; return 1;
} }
static int gotoRiver(CommandSourceStack source, String type) {
ServerPlayer player = source.getPlayer();
if (player == null) {
IrisModdedCommands.fail(source, "This command can only be used by a player.");
return 0;
}
ServerLevel level = source.getLevel();
Engine engine = IrisModdedCommands.engineFor(level);
if (engine == null) {
IrisModdedCommands.fail(source, "This dimension is not generated by Iris.");
return 0;
}
IrisHydrologyRuntime runtime = engine.getComplex().getHydrologyRuntime();
if (runtime == null) {
IrisModdedCommands.fail(source, "Hydrology is not active in this dimension.");
return 0;
}
HydrologyFeatureQuery query;
try {
query = HydrologyFeatureQuery.parse(type);
} catch (IllegalArgumentException error) {
IrisModdedCommands.fail(source, error.getMessage());
return 0;
}
int originX = player.blockPosition().getX();
int originZ = player.blockPosition().getZ();
int requestedDistance = Math.min(8192, runtime.settings().routing().tileSize() * 15);
int maximumDistance = runtime.maximumFeatureSearchDistance(originX, originZ, requestedDistance);
MinecraftServer server = source.getServer();
IrisModdedCommands.ok(source, "Searching accepted hydrology plans for " + type + "...");
MultiBurst.burst.completeValueAsync(() -> runtime.nearestFeature(
query.types(), query.profileKey(), originX, originZ, maximumDistance).orElse(null))
.whenComplete((HydrologyFeatureRef feature, Throwable error) -> server.execute(() -> {
if (error != null) {
ModdedIrisLog.error("Hydrology locate failed for {}", type, error);
IrisModdedCommands.fail(source,
"Could not locate " + type + " hydrology: " + error.getClass().getSimpleName());
return;
}
if (feature == null) {
IrisModdedCommands.fail(source,
"No accepted " + type + " hydrology feature found within "
+ maximumDistance + " blocks.");
return;
}
int worldY = feature.y() + engine.getDimension().getMinHeight() + 2;
teleportToStructure(
source,
level,
player,
feature.x(),
worldY,
feature.z(),
type + " hydrology feature"
);
}));
return 1;
}
static int gotoStructure(CommandSourceStack source, String keyRaw) { static int gotoStructure(CommandSourceStack source, String keyRaw) {
ServerLevel level = source.getLevel(); ServerLevel level = source.getLevel();
Engine engine = IrisModdedCommands.engineFor(level); Engine engine = IrisModdedCommands.engineFor(level);
@@ -341,8 +341,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
} }
markCompleted(); markCompleted();
listener.onChunkGenerated(x, z); listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z); cleanupMantleChunksCoveredBy(x, z, listener);
listener.onChunkCleaned(x, z);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} catch (TimeoutException | ExecutionException e) { } catch (TimeoutException | ExecutionException e) {
@@ -406,8 +405,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
onSuccess(); onSuccess();
markCompleted(); markCompleted();
listener.onChunkGenerated(x, z); listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z); cleanupMantleChunksCoveredBy(x, z, listener);
listener.onChunkCleaned(x, z);
} finally { } finally {
markFinished(); markFinished();
semaphore.release(); semaphore.release();
@@ -494,9 +492,9 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
} }
} }
private void cleanupMantleChunk(int x, int z) { private void cleanupMantleChunksCoveredBy(int x, int z, PregenListener listener) {
try { try {
engine.getMantle().forceCleanupChunk(x, z); engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned);
} catch (Throwable e) { } catch (Throwable e) {
ModdedIrisLog.debug("Iris pregen mantle cleanup skipped for {},{}: {}", x, z, e.toString()); ModdedIrisLog.debug("Iris pregen mantle cleanup skipped for {},{}: {}", x, z, e.toString());
} }
@@ -301,7 +301,7 @@ public final class ModdedChunkUpdateService implements ModdedTickableService {
} }
} }
private void runUpdatePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) { void runUpdatePass(Engine engine, ServerLevel level, int chunkX, int chunkZ, MantleChunk<Matter> chunk) {
PrecisionStopwatch stopwatch = PrecisionStopwatch.start(); PrecisionStopwatch stopwatch = PrecisionStopwatch.start();
int minHeight = engine.getWorld().minHeight(); int minHeight = engine.getWorld().minHeight();
int baseX = chunkX << 4; int baseX = chunkX << 4;
@@ -187,6 +187,17 @@ public class ModdedGenerationLeaseContractTest {
assertTrue(logFailure.contains("cause.toString()")); assertTrue(logFailure.contains("cause.toString()"));
} }
@Test
public void pregenReportsOnlyCleanupCallbacksFromCoveredTargets() throws IOException {
String source = source("art/arcane/iris/modded/command/ModdedPregenMethod.java");
String cleanup = method(source, "private void cleanupMantleChunksCoveredBy(");
assertTrue(source.contains("cleanupMantleChunksCoveredBy(x, z, listener);"));
assertFalse(source.contains("cleanupMantleChunk(x, z);"));
assertTrue(cleanup.contains("cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned)"));
assertFalse(cleanup.contains("forceCleanupChunk"));
}
@Test @Test
public void invalidPregenBoundsFailBeforeRuntimeMutation() throws IOException { public void invalidPregenBoundsFailBeforeRuntimeMutation() throws IOException {
String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java"); String jobSource = source("art/arcane/iris/modded/command/ModdedPregenJob.java");
@@ -0,0 +1,30 @@
package art.arcane.iris.modded.command;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.volmlib.util.collection.KList;
import org.junit.Test;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisModdedBiomeCommandTest {
@Test
public void biomeKeysAreSortedAndLimitedToReachableBiomes() {
KList<IrisBiome> reachable = new KList<>();
IrisBiome river = new IrisBiome();
river.setLoadKey("river");
IrisBiome natural = new IrisBiome();
natural.setLoadKey("natural");
reachable.add(river);
reachable.add(natural);
assertEquals(
List.of("natural", "river"),
List.copyOf(ModdedCommandSuggestions.reachableBiomeKeys(reachable))
);
assertTrue(ModdedCommandSuggestions.isReachableBiome(reachable, "river"));
assertFalse(ModdedCommandSuggestions.isReachableBiome(reachable, "unused"));
}
}
@@ -1,11 +1,27 @@
package art.arcane.iris.modded.service; package art.arcane.iris.modded.service;
import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineMetrics;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.util.project.matter.TileWrapper; import art.arcane.iris.util.project.matter.TileWrapper;
import art.arcane.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk; import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter; import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks; import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
import art.arcane.volmlib.util.matter.Matter; import art.arcane.volmlib.util.matter.Matter;
import art.arcane.volmlib.util.matter.MatterUpdate;
import art.arcane.volmlib.util.matter.slices.UpdateMatter;
import net.minecraft.SharedConstants;
import net.minecraft.core.BlockPos;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.LiquidBlock;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.material.FlowingFluid;
import org.junit.Test; import org.junit.Test;
import org.mockito.InOrder;
import java.lang.reflect.Proxy; import java.lang.reflect.Proxy;
import java.util.ArrayList; import java.util.ArrayList;
@@ -15,8 +31,42 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
public class ModdedChunkUpdateServiceTest { public class ModdedChunkUpdateServiceTest {
@Test
public void fallingFluidUpdateRetainsLevelEightStateAndRequestsPhysics() {
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
IrisWorld world = mock(IrisWorld.class);
when(world.minHeight()).thenReturn(-64);
Engine engine = mock(Engine.class);
when(engine.getWorld()).thenReturn(world);
when(engine.getMetrics()).thenReturn(new EngineMetrics(8));
ServerLevel level = mock(ServerLevel.class);
when(level.getMinY()).thenReturn(-64);
when(level.getMaxY()).thenReturn(320);
BlockPos position = new BlockPos(15, -59, 14);
BlockState falling = Blocks.WATER.defaultBlockState().setValue(LiquidBlock.LEVEL, 8);
when(level.getBlockState(position)).thenReturn(falling);
UpdateRecordingMantleChunk chunk = new UpdateRecordingMantleChunk();
new ModdedChunkUpdateService().runUpdatePass(engine, level, 0, 0, chunk);
assertEquals(8, falling.getValue(LiquidBlock.LEVEL).intValue());
if (falling.getFluidState().hasProperty(FlowingFluid.FALLING)) {
assertTrue(falling.getFluidState().getValue(FlowingFluid.FALLING));
}
InOrder order = inOrder(level);
order.verify(level).setBlock(eq(position), eq(Blocks.AIR.defaultBlockState()), anyInt());
order.verify(level).setBlock(position, falling, Block.UPDATE_ALL);
assertTrue(chunk.deleted());
}
@Test @Test
public void scansWhenPlayersArePresent() { public void scansWhenPlayersArePresent() {
assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false)); assertTrue(ModdedChunkUpdateService.hasUpdateTargets(true, false));
@@ -84,6 +134,35 @@ public class ModdedChunkUpdateServiceTest {
} }
} }
private static final class UpdateRecordingMantleChunk extends MantleChunk<Matter> {
private boolean deleted;
private UpdateRecordingMantleChunk() {
super(1, 0, 0, emptyAdapter(), MantleHooks.NONE);
}
@Override
public <T> void iterate(
Class<T> type,
Consumer4<Integer, Integer, Integer, T> iterator
) {
if (type == MatterUpdate.class) {
iterator.accept(-1, 5, -2, type.cast(UpdateMatter.ON));
}
}
@Override
public void deleteSlices(Class<?> type) {
if (type == MatterUpdate.class) {
deleted = true;
}
}
private boolean deleted() {
return deleted;
}
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private static MantleDataAdapter<Matter> emptyAdapter() { private static MantleDataAdapter<Matter> emptyAdapter() {
return (MantleDataAdapter<Matter>) Proxy.newProxyInstance( return (MantleDataAdapter<Matter>) Proxy.newProxyInstance(
+4
View File
@@ -105,6 +105,10 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies { dependencies {
testImplementation('junit:junit:4.13.2') testImplementation('junit:junit:4.13.2')
testImplementation('org.mockito:mockito-core:5.23.0')
testImplementation(libs.spigot) {
transitive = false
}
testRuntimeOnly('org.junit.platform:junit-platform-launcher:6.1.2') testRuntimeOnly('org.junit.platform:junit-platform-launcher:6.1.2')
testRuntimeOnly('org.junit.vintage:junit-vintage-engine:6.1.2') testRuntimeOnly('org.junit.vintage:junit-vintage-engine:6.1.2')
compileOnly('org.slf4j:slf4j-api:2.0.17') compileOnly('org.slf4j:slf4j-api:2.0.17')
@@ -23,10 +23,11 @@ import art.arcane.iris.core.localization.IrisLanguage;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.render.IrisRenderer; import art.arcane.iris.engine.framework.render.IrisRenderer;
import art.arcane.iris.engine.framework.render.RenderType; import art.arcane.iris.engine.framework.render.RenderType;
import art.arcane.iris.engine.hydrology.HydrologyCandidateKind;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.format.Form; import art.arcane.volmlib.util.format.Form;
import art.arcane.volmlib.util.localization.MessageArgument; import art.arcane.volmlib.util.localization.MessageArgument;
@@ -125,6 +126,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
private VisionRenderController.Frame renderFrame; private VisionRenderController.Frame renderFrame;
private JComboBox<RenderType> modeSelector; private JComboBox<RenderType> modeSelector;
private JToggleButton gridToggle; private JToggleButton gridToggle;
private JToggleButton entitiesToggle;
private JToggleButton followToggle; private JToggleButton followToggle;
private RenderType currentType; private RenderType currentType;
private List<GuiMarker> players; private List<GuiMarker> players;
@@ -142,6 +144,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
private double blocksPerPixel; private double blocksPerPixel;
private int paintCadenceFps; private int paintCadenceFps;
private boolean grid; private boolean grid;
private boolean entitiesVisible;
private boolean follow; private boolean follow;
private boolean help; private boolean help;
private boolean debug; private boolean debug;
@@ -160,6 +163,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
this.notifications = new LinkedHashMap<>(); this.notifications = new LinkedHashMap<>();
this.players = List.of(); this.players = List.of();
this.entities = List.of(); this.entities = List.of();
this.entitiesVisible = false;
this.currentType = RenderType.BIOME; this.currentType = RenderType.BIOME;
this.blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL; this.blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL;
this.contentRevision = 1L; this.contentRevision = 1L;
@@ -265,6 +269,12 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
vision.gridToggle = createToggle(IrisLanguage.plain(DesktopUiMessages.VISION_GRID), vision.grid); vision.gridToggle = createToggle(IrisLanguage.plain(DesktopUiMessages.VISION_GRID), vision.grid);
vision.gridToggle.addActionListener(event -> vision.toggleGrid()); vision.gridToggle.addActionListener(event -> vision.toggleGrid());
trailing.add(vision.gridToggle); trailing.add(vision.gridToggle);
vision.entitiesToggle = createToggle(
IrisLanguage.plain(DesktopUiMessages.VISION_ENTITIES),
vision.entitiesVisible
);
vision.entitiesToggle.addActionListener(event -> vision.toggleEntities());
trailing.add(vision.entitiesToggle);
vision.followToggle = createToggle(IrisLanguage.plain(DesktopUiMessages.VISION_FOLLOW), vision.follow); vision.followToggle = createToggle(IrisLanguage.plain(DesktopUiMessages.VISION_FOLLOW), vision.follow);
vision.followToggle.addActionListener(event -> vision.toggleFollow()); vision.followToggle.addActionListener(event -> vision.toggleFollow());
trailing.add(vision.followToggle); trailing.add(vision.followToggle);
@@ -411,12 +421,14 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
} }
private void renderMarkers(Graphics2D canvas) { private void renderMarkers(Graphics2D canvas) {
if (entitiesVisible) {
for (GuiMarker marker : entities) { for (GuiMarker marker : entities) {
int screenX = (int) Math.round(worldToScreenX(marker.worldX())); int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
int screenY = (int) Math.round(worldToScreenZ(marker.worldZ())); int screenY = (int) Math.round(worldToScreenZ(marker.worldZ()));
canvas.setColor(MOB_COLOR); canvas.setColor(MOB_COLOR);
canvas.fillRect(screenX - 2, screenY - 2, 5, 5); canvas.fillRect(screenX - 2, screenY - 2, 5, 5);
} }
}
for (GuiMarker marker : players) { for (GuiMarker marker : players) {
int screenX = (int) Math.round(worldToScreenX(marker.worldX())); int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
@@ -430,7 +442,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
int labelWidth = canvas.getFontMetrics().stringWidth(marker.label()); int labelWidth = canvas.getFontMetrics().stringWidth(marker.label());
canvas.drawString(marker.label(), screenX - labelWidth / 2, screenY - 14); canvas.drawString(marker.label(), screenX - labelWidth / 2, screenY - 14);
} }
if (detailedHover) { if (entitiesVisible && detailedHover) {
renderNearestEntity(canvas); renderNearestEntity(canvas);
} }
} }
@@ -483,21 +495,40 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
} }
private void renderRiverLegend(Graphics2D canvas) { private void renderRiverLegend(Graphics2D canvas) {
RiverSection[] sections = RiverSection.values(); HydrologyFeatureType[] types = HydrologyFeatureType.values();
HydrologyCandidateKind[] candidateKinds = HydrologyCandidateKind.values();
int lineHeight = 18; int lineHeight = 18;
int width = 148; int width = 188;
int height = sections.length * lineHeight + CARD_PADDING * 2; int height = (types.length + candidateKinds.length + 1) * lineHeight + CARD_PADDING * 2;
int x = getWidth() - width - CARD_PADDING; int x = getWidth() - width - CARD_PADDING;
int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - CARD_PADDING; int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - CARD_PADDING;
drawCardBackground(canvas, x, y, width, height); drawCardBackground(canvas, x, y, width, height);
canvas.setFont(BODY_FONT); canvas.setFont(BODY_FONT);
for (int index = 0; index < sections.length; index++) { int headwaterY = y + CARD_PADDING;
RiverSection section = sections[index]; canvas.setColor(new Color(IrisRenderer.headwaterColor()));
int rowY = y + CARD_PADDING + index * lineHeight; canvas.fillRoundRect(x + CARD_PADDING, headwaterY + 2, 12, 12, 4, 4);
canvas.setColor(new Color(IrisRenderer.riverColor(section))); canvas.setColor(new Color(IrisRenderer.headwaterDirectionColor()));
canvas.drawLine(x + CARD_PADDING + 3, headwaterY + 8, x + CARD_PADDING + 9, headwaterY + 8);
canvas.drawLine(x + CARD_PADDING + 9, headwaterY + 8, x + CARD_PADDING + 6, headwaterY + 5);
canvas.drawLine(x + CARD_PADDING + 9, headwaterY + 8, x + CARD_PADDING + 6, headwaterY + 11);
canvas.setColor(TEXT_SECONDARY);
canvas.drawString("headwater / source flow", x + CARD_PADDING + 20, headwaterY + 13);
for (int index = 0; index < types.length; index++) {
HydrologyFeatureType type = types[index];
int rowY = y + CARD_PADDING + (index + 1) * lineHeight;
canvas.setColor(new Color(IrisRenderer.hydrologyFeatureColor(type)));
canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4); canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4);
canvas.setColor(TEXT_SECONDARY); canvas.setColor(TEXT_SECONDARY);
String label = section.name().toLowerCase(Locale.ROOT).replace('_', ' '); String label = type.name().toLowerCase(Locale.ROOT).replace('_', ' ');
canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13);
}
for (int index = 0; index < candidateKinds.length; index++) {
HydrologyCandidateKind kind = candidateKinds[index];
int rowY = y + CARD_PADDING + (types.length + index + 1) * lineHeight;
canvas.setColor(new Color(IrisRenderer.hydrologyDiagnosticColor(kind)));
canvas.fillRoundRect(x + CARD_PADDING, rowY + 2, 12, 12, 4, 4);
canvas.setColor(TEXT_SECONDARY);
String label = "projected " + kind.name().toLowerCase(Locale.ROOT).replace('_', ' ');
canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13); canvas.drawString(label, x + CARD_PADDING + 20, rowY + 13);
} }
} }
@@ -783,12 +814,16 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
try { try {
List<GuiMarker> nextPlayers = overlay.players(); List<GuiMarker> nextPlayers = overlay.players();
players = nextPlayers == null ? List.of() : List.copyOf(nextPlayers); players = nextPlayers == null ? List.of() : List.copyOf(nextPlayers);
if (entitiesVisible) {
overlay.requestEntities(next -> EventQueue.invokeLater(() -> { overlay.requestEntities(next -> EventQueue.invokeLater(() -> {
if (!closed) { if (!closed && entitiesVisible) {
entities = next == null ? List.of() : List.copyOf(next); entities = next == null ? List.of() : List.copyOf(next);
repaint(); repaint();
} }
})); }));
} else if (!entities.isEmpty()) {
entities = List.of();
}
if (follow && !players.isEmpty()) { if (follow && !players.isEmpty()) {
GuiMarker player = players.get(0); GuiMarker player = players.get(0);
if (Math.abs(centerX - player.worldX()) > 0.5D || Math.abs(centerZ - player.worldZ()) > 0.5D) { if (Math.abs(centerX - player.worldX()) > 0.5D || Math.abs(centerZ - player.worldZ()) > 0.5D) {
@@ -852,6 +887,22 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
notifyUser(IrisLanguage.plain(grid ? DesktopUiMessages.VISION_GRID_ENABLED : DesktopUiMessages.VISION_GRID_DISABLED)); notifyUser(IrisLanguage.plain(grid ? DesktopUiMessages.VISION_GRID_ENABLED : DesktopUiMessages.VISION_GRID_DISABLED));
} }
private void toggleEntities() {
entitiesVisible = !entitiesVisible;
if (entitiesVisible) {
refreshMarkers();
} else {
entities = List.of();
repaint();
}
syncControls();
notifyUser(IrisLanguage.plain(
entitiesVisible
? DesktopUiMessages.VISION_ENTITIES_ENABLED
: DesktopUiMessages.VISION_ENTITIES_DISABLED
));
}
private void toggleFollow() { private void toggleFollow() {
follow = !follow; follow = !follow;
if (follow && players.isEmpty()) { if (follow && players.isEmpty()) {
@@ -881,6 +932,9 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
if (gridToggle != null) { if (gridToggle != null) {
gridToggle.setSelected(grid); gridToggle.setSelected(grid);
} }
if (entitiesToggle != null) {
entitiesToggle.setSelected(entitiesVisible);
}
if (followToggle != null) { if (followToggle != null) {
followToggle.setSelected(follow); followToggle.setSelected(follow);
} }
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey; import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.engine.IrisEngineMantle;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.InvalidConfigurationException;
@@ -253,7 +254,9 @@ public final class BukkitWorldConfiguration {
if (Files.isDirectory(worldRoot.resolve("iris"), LinkOption.NOFOLLOW_LINKS)) { if (Files.isDirectory(worldRoot.resolve("iris"), LinkOption.NOFOLLOW_LINKS)) {
return true; return true;
} }
for (String directory : new String[]{"region", "entities", "poi", "mantle"}) { for (String directory : new String[]{
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
}) {
if (hasEntries(worldRoot.resolve(directory))) { if (hasEntries(worldRoot.resolve(directory))) {
return true; return true;
} }
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage; import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey; import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.engine.IrisEngineMantle;
import java.io.IOException; import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.AtomicMoveNotSupportedException;
@@ -37,7 +38,9 @@ import java.util.stream.Stream;
public final class HuskWorldQuarantine { public final class HuskWorldQuarantine {
private static final String IRIS_NAMESPACE = "iris"; private static final String IRIS_NAMESPACE = "iris";
private static final Pattern MANAGED_KEY = Pattern.compile("[a-z0-9_-]+"); private static final Pattern MANAGED_KEY = Pattern.compile("[a-z0-9_-]+");
private static final String[] CHUNK_DATA_DIRECTORIES = {"region", "entities", "poi", "mantle"}; private static final String[] CHUNK_DATA_DIRECTORIES = {
"region", "entities", "poi", IrisEngineMantle.STORAGE_FOLDER_NAME
};
private static final Set<String> OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini"); private static final Set<String> OS_METADATA_FILES = Set.of(".DS_Store", "Thumbs.db", "desktop.ini");
private static final String APPLE_DOUBLE_PREFIX = "._"; private static final String APPLE_DOUBLE_PREFIX = "._";
private static final DateTimeFormatter STAMP = DateTimeFormatter private static final DateTimeFormatter STAMP = DateTimeFormatter
@@ -9,12 +9,15 @@ public final class DesktopUiMessages {
public static final TextKey VISION_TITLE = TextKey.of("iris.desktop.vision.title", "Iris Vision"); public static final TextKey VISION_TITLE = TextKey.of("iris.desktop.vision.title", "Iris Vision");
public static final TextKey VISION_VIEW = TextKey.of("iris.desktop.vision.view", "View:"); public static final TextKey VISION_VIEW = TextKey.of("iris.desktop.vision.view", "View:");
public static final TextKey VISION_GRID = TextKey.of("iris.desktop.vision.grid", "Grid"); public static final TextKey VISION_GRID = TextKey.of("iris.desktop.vision.grid", "Grid");
public static final TextKey VISION_ENTITIES = TextKey.of("iris.desktop.vision.entities", "Entities");
public static final TextKey VISION_FOLLOW = TextKey.of("iris.desktop.vision.follow", "Follow"); public static final TextKey VISION_FOLLOW = TextKey.of("iris.desktop.vision.follow", "Follow");
public static final TextKey VISION_REFRESHING = TextKey.of("iris.desktop.vision.refreshing", "Refreshing"); public static final TextKey VISION_REFRESHING = TextKey.of("iris.desktop.vision.refreshing", "Refreshing");
public static final TextKey VISION_FPS = TextKey.of("iris.desktop.vision.fps", "{fps} FPS"); public static final TextKey VISION_FPS = TextKey.of("iris.desktop.vision.fps", "{fps} FPS");
public static final TextKey VISION_ZOOM_RESET = TextKey.of("iris.desktop.vision.zoom_reset", "Zoom reset"); public static final TextKey VISION_ZOOM_RESET = TextKey.of("iris.desktop.vision.zoom_reset", "Zoom reset");
public static final TextKey VISION_GRID_ENABLED = TextKey.of("iris.desktop.vision.grid_enabled", "Grid enabled"); public static final TextKey VISION_GRID_ENABLED = TextKey.of("iris.desktop.vision.grid_enabled", "Grid enabled");
public static final TextKey VISION_GRID_DISABLED = TextKey.of("iris.desktop.vision.grid_disabled", "Grid disabled"); public static final TextKey VISION_GRID_DISABLED = TextKey.of("iris.desktop.vision.grid_disabled", "Grid disabled");
public static final TextKey VISION_ENTITIES_ENABLED = TextKey.of("iris.desktop.vision.entities_enabled", "Entities enabled");
public static final TextKey VISION_ENTITIES_DISABLED = TextKey.of("iris.desktop.vision.entities_disabled", "Entities disabled");
public static final TextKey VISION_FOLLOWING = TextKey.of("iris.desktop.vision.following", "Following {player}"); public static final TextKey VISION_FOLLOWING = TextKey.of("iris.desktop.vision.following", "Following {player}");
public static final TextKey VISION_NO_PLAYER = TextKey.of("iris.desktop.vision.no_player", "No player in world"); public static final TextKey VISION_NO_PLAYER = TextKey.of("iris.desktop.vision.no_player", "No player in world");
public static final TextKey VISION_FOLLOW_DISABLED = TextKey.of("iris.desktop.vision.follow_disabled", "Follow disabled"); public static final TextKey VISION_FOLLOW_DISABLED = TextKey.of("iris.desktop.vision.follow_disabled", "Follow disabled");
@@ -158,8 +161,9 @@ public final class DesktopUiMessages {
public static final TextKey IMAGEMAP_PREVIEW_STATUS = TextKey.of("iris.desktop.imagemap.preview_status", "X {x} Z {z} | {value} | {scale} blocks/pixel"); public static final TextKey IMAGEMAP_PREVIEW_STATUS = TextKey.of("iris.desktop.imagemap.preview_status", "X {x} Z {z} | {value} | {scale} blocks/pixel");
private static final List<MessageKey> KEYS = List.of( private static final List<MessageKey> KEYS = List.of(
VISION_TITLE, VISION_VIEW, VISION_GRID, VISION_FOLLOW, VISION_TITLE, VISION_VIEW, VISION_GRID, VISION_ENTITIES, VISION_FOLLOW,
VISION_REFRESHING, VISION_FPS, VISION_ZOOM_RESET, VISION_GRID_ENABLED, VISION_GRID_DISABLED, VISION_REFRESHING, VISION_FPS, VISION_ZOOM_RESET, VISION_GRID_ENABLED, VISION_GRID_DISABLED,
VISION_ENTITIES_ENABLED, VISION_ENTITIES_DISABLED,
VISION_FOLLOWING, VISION_NO_PLAYER, VISION_FOLLOW_DISABLED, VISION_FOLLOWING, VISION_NO_PLAYER, VISION_FOLLOW_DISABLED,
VISION_STATUS_LEFT, VISION_STATUS_RIGHT, VISION_ENTITY_POSITION, VISION_ENTITY_HEALTH, VISION_STATUS_LEFT, VISION_STATUS_RIGHT, VISION_ENTITY_POSITION, VISION_ENTITY_HEALTH,
VISION_BLOCK_POSITION, VISION_CHUNK_POSITION, VISION_REGION_POSITION, VISION_BIOME_KEY, VISION_BLOCK_POSITION, VISION_CHUNK_POSITION, VISION_REGION_POSITION, VISION_BIOME_KEY,
@@ -388,7 +388,7 @@ public final class DefaultPackBootstrapProvisioner {
try { try {
validCache = validateArchive(archivePath, spec); validCache = validateArchive(archivePath, spec);
} catch (IOException exception) { } catch (IOException exception) {
feedback.accept("Cached Iris " + spec.key() + " beta archive is invalid; downloading a replacement."); feedback.accept("Cached Iris " + spec.key() + " release archive is invalid; downloading a replacement.");
} }
} }
String cachedSource = metadata.getProperty("source", ""); String cachedSource = metadata.getProperty("source", "");
@@ -439,7 +439,7 @@ public final class DefaultPackBootstrapProvisioner {
copyLimited(input, output, options.maxArchiveBytes(), spec); copyLimited(input, output, options.maxArchiveBytes(), spec);
} }
if (!validateArchive(temporary, spec)) { if (!validateArchive(temporary, spec)) {
throw new IOException("Downloaded Iris " + spec.key() + " beta archive is invalid"); throw new IOException("Downloaded Iris " + spec.key() + " release archive is invalid");
} }
move(temporary, archivePath, true); move(temporary, archivePath, true);
} finally { } finally {
@@ -452,11 +452,11 @@ public final class DefaultPackBootstrapProvisioner {
updated.setProperty("fetchedAt", Long.toString(options.clock().millis())); updated.setProperty("fetchedAt", Long.toString(options.clock().millis()));
updated.setProperty("sha256", sha256(archivePath)); updated.setProperty("sha256", sha256(archivePath));
storePropertiesAtomic(metadataPath, updated); storePropertiesAtomic(metadataPath, updated);
feedback.accept("Downloaded the Iris " + spec.key() + " beta archive."); feedback.accept("Downloaded the Iris " + spec.key() + " release archive.");
return new Archive(archivePath, updated.getProperty("sha256")); return new Archive(archivePath, updated.getProperty("sha256"));
} }
close(response.body()); close(response.body());
IOException statusFailure = new IOException("Iris " + spec.key() + " beta download returned HTTP " + status); IOException statusFailure = new IOException("Iris " + spec.key() + " release download returned HTTP " + status);
if (!retryableStatus(status)) { if (!retryableStatus(status)) {
networkFailure = statusFailure; networkFailure = statusFailure;
break; break;
@@ -464,7 +464,7 @@ public final class DefaultPackBootstrapProvisioner {
networkFailure = statusFailure; networkFailure = statusFailure;
} catch (InterruptedException exception) { } catch (InterruptedException exception) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new IOException("Iris " + spec.key() + " beta download was interrupted", exception); throw new IOException("Iris " + spec.key() + " release download was interrupted", exception);
} catch (IOException exception) { } catch (IOException exception) {
networkFailure = exception; networkFailure = exception;
} }
@@ -474,7 +474,7 @@ public final class DefaultPackBootstrapProvisioner {
} }
if (cacheMatchesSource) { if (cacheMatchesSource) {
feedback.accept("Iris " + spec.key() + " beta download failed; using the validated cached archive."); feedback.accept("Iris " + spec.key() + " release download failed; using the validated cached archive.");
return new Archive(archivePath, sha256(archivePath)); return new Archive(archivePath, sha256(archivePath));
} }
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) { if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) {
@@ -482,8 +482,8 @@ public final class DefaultPackBootstrapProvisioner {
return new Archive(null, sourceSha); return new Archive(null, sourceSha);
} }
throw networkFailure == null throw networkFailure == null
? new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists") ? new IOException("Iris " + spec.key() + " release archive is unavailable and no valid cache exists")
: new IOException("Iris " + spec.key() + " beta archive is unavailable and no valid cache exists", networkFailure); : new IOException("Iris " + spec.key() + " release archive is unavailable and no valid cache exists", networkFailure);
} }
private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory, PackSpec spec) { private static boolean isManagedPackOutputUsable(Properties marker, Path dataDirectory, PackSpec spec) {
@@ -514,11 +514,11 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) { while ((entry = zip.getNextEntry()) != null) {
entries++; entries++;
if (entries > MAX_ARCHIVE_ENTRIES) { if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries"); throw new IOException("Iris " + spec.key() + " release archive contains too many entries");
} }
String name = normalizedZipEntry(entry.getName()); String name = normalizedZipEntry(entry.getName());
if (!paths.add(name)) { if (!paths.add(name)) {
throw new IOException("Iris " + spec.key() + " beta archive contains duplicate path " + name); throw new IOException("Iris " + spec.key() + " release archive contains duplicate path " + name);
} }
String requiredDimension = "dimensions/" + spec.requiredDimension() + ".json"; String requiredDimension = "dimensions/" + spec.requiredDimension() + ".json";
if (name.equals(requiredDimension) || name.endsWith("/" + requiredDimension)) { if (name.equals(requiredDimension) || name.endsWith("/" + requiredDimension)) {
@@ -530,7 +530,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) { while ((read = zip.read(buffer)) >= 0) {
expanded += read; expanded += read;
if (expanded > MAX_EXPANDED_BYTES) { if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit"); throw new IOException("Iris " + spec.key() + " release archive expands beyond the safety limit");
} }
} }
} }
@@ -542,7 +542,7 @@ public final class DefaultPackBootstrapProvisioner {
private static Path extractArchive(Path archive, Path extractionRoot, PackSpec spec) throws IOException { private static Path extractArchive(Path archive, Path extractionRoot, PackSpec spec) throws IOException {
if (archive == null) { if (archive == null) {
throw new IOException("Cached Iris " + spec.key() + " beta archive is unavailable for required pack rebuild"); throw new IOException("Cached Iris " + spec.key() + " release archive is unavailable for required pack rebuild");
} }
long expanded = 0L; long expanded = 0L;
int entries = 0; int entries = 0;
@@ -551,12 +551,12 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) { while ((entry = zip.getNextEntry()) != null) {
entries++; entries++;
if (entries > MAX_ARCHIVE_ENTRIES) { if (entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Iris " + spec.key() + " beta archive contains too many entries"); throw new IOException("Iris " + spec.key() + " release archive contains too many entries");
} }
String name = normalizedZipEntry(entry.getName()); String name = normalizedZipEntry(entry.getName());
Path output = extractionRoot.resolve(name).normalize(); Path output = extractionRoot.resolve(name).normalize();
if (!output.startsWith(extractionRoot)) { if (!output.startsWith(extractionRoot)) {
throw new IOException("Unsafe path in Iris " + spec.key() + " beta archive: " + entry.getName()); throw new IOException("Unsafe path in Iris " + spec.key() + " release archive: " + entry.getName());
} }
if (entry.isDirectory()) { if (entry.isDirectory()) {
Files.createDirectories(output); Files.createDirectories(output);
@@ -568,7 +568,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) { while ((read = zip.read(buffer)) >= 0) {
expanded += read; expanded += read;
if (expanded > MAX_EXPANDED_BYTES) { if (expanded > MAX_EXPANDED_BYTES) {
throw new IOException("Iris " + spec.key() + " beta archive expands beyond the safety limit"); throw new IOException("Iris " + spec.key() + " release archive expands beyond the safety limit");
} }
file.write(buffer, 0, read); file.write(buffer, 0, read);
} }
@@ -589,7 +589,7 @@ public final class DefaultPackBootstrapProvisioner {
} }
} }
if (candidates.size() != 1) { if (candidates.size() != 1) {
throw new IOException("Iris " + spec.key() + " beta archive has an invalid root layout"); throw new IOException("Iris " + spec.key() + " release archive has an invalid root layout");
} }
return candidates.getFirst(); return candidates.getFirst();
} }
@@ -613,7 +613,7 @@ public final class DefaultPackBootstrapProvisioner {
private static void validatePackRoot(Path path, PackSpec spec) throws IOException { private static void validatePackRoot(Path path, PackSpec spec) throws IOException {
if (!isPackRoot(path, spec)) { if (!isPackRoot(path, spec)) {
throw new IOException("Iris " + spec.key() + " beta pack is missing dimensions/" throw new IOException("Iris " + spec.key() + " release pack is missing dimensions/"
+ spec.requiredDimension() + ".json at " + path); + spec.requiredDimension() + ".json at " + path);
} }
} }
@@ -822,7 +822,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = input.read(buffer)) >= 0) { while ((read = input.read(buffer)) >= 0) {
total += read; total += read;
if (total > limit) { if (total > limit) {
throw new IOException("Iris " + spec.key() + " beta archive exceeds the download size limit"); throw new IOException("Iris " + spec.key() + " release archive exceeds the download size limit");
} }
output.write(buffer, 0, read); output.write(buffer, 0, read);
} }
@@ -63,10 +63,10 @@ import java.util.zip.ZipInputStream;
public final class PackDownloader { public final class PackDownloader {
private static final String DEFAULT_OVERWORLD_PACK = "overworld"; private static final String DEFAULT_OVERWORLD_PACK = "overworld";
private static final String DEFAULT_OVERWORLD_URL = private static final String DEFAULT_OVERWORLD_URL =
"https://github.com/IrisDimensions/overworld/releases/download/beta/overworld.zip"; "https://github.com/IrisDimensions/overworld/releases/download/4002/overworld.zip";
private static final String UNDERWORLD_PACK = "underworld"; private static final String UNDERWORLD_PACK = "underworld";
private static final String UNDERWORLD_URL = private static final String UNDERWORLD_URL =
"https://github.com/IrisDimensions/underworld/releases/download/beta/underworld.zip"; "https://github.com/IrisDimensions/underworld/releases/download/1005/underworld.zip";
private static final List<String> BUILT_IN_PACK_KEYS = List.of(DEFAULT_OVERWORLD_PACK, UNDERWORLD_PACK); private static final List<String> BUILT_IN_PACK_KEYS = List.of(DEFAULT_OVERWORLD_PACK, UNDERWORLD_PACK);
private static final Map<String, String> BUILT_IN_PACK_URLS = Map.of( private static final Map<String, String> BUILT_IN_PACK_URLS = Map.of(
DEFAULT_OVERWORLD_PACK, DEFAULT_OVERWORLD_URL, DEFAULT_OVERWORLD_PACK, DEFAULT_OVERWORLD_URL,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -84,9 +84,10 @@ public final class PackValidator {
packFolder, dimensionFiles, validateLiveRegistries); packFolder, dimensionFiles, validateLiveRegistries);
addDistinct(blockingErrors, imageMaps.errors()); addDistinct(blockingErrors, imageMaps.errors());
addDistinct(warnings, imageMaps.warnings()); addDistinct(warnings, imageMaps.warnings());
PackRiverValidator.Validation riverValidation = PackRiverValidator.validate(packFolder, dimensionFiles); PackHydrologyValidator.Validation hydrologyValidation =
addDistinct(blockingErrors, riverValidation.errors()); PackHydrologyValidator.validate(packFolder, dimensionFiles);
addDistinct(warnings, riverValidation.warnings()); addDistinct(blockingErrors, hydrologyValidation.errors());
addDistinct(warnings, hydrologyValidation.warnings());
blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder)); blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder));
PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder); PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder);
addDistinct(blockingErrors, lootIssues.errors()); addDistinct(blockingErrors, lootIssues.errors());
@@ -481,8 +481,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
listener.onChunkFailed(x, z); listener.onChunkFailed(x, z);
} else { } else {
listener.onChunkGenerated(x, z); listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z); cleanupMantleChunksCoveredBy(x, z, listener);
listener.onChunkCleaned(x, z);
onChunkCompleted(x, z, chunk); onChunkCompleted(x, z, chunk);
success = true; success = true;
} }
@@ -692,11 +691,11 @@ public class AsyncPregenMethod implements PregeneratorMethod {
} }
} }
private void cleanupMantleChunk(int x, int z) { private void cleanupMantleChunksCoveredBy(int x, int z, PregenListener listener) {
Engine engine = resolveMetricsEngine(); Engine engine = resolveMetricsEngine();
if (engine != null) { if (engine != null) {
try { try {
engine.getMantle().forceCleanupChunk(x, z); engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned);
} catch (Throwable ignored) { } catch (Throwable ignored) {
} }
} }
@@ -92,6 +92,7 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
@Accessors(fluent = true, chain = true) @Accessors(fluent = true, chain = true)
public class IrisCreator { public class IrisCreator {
private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L; private static final long WORLD_CREATE_TIMEOUT_SECONDS = 120L;
private static final long INITIAL_SPAWN_TIMEOUT_SECONDS = 600L;
private static final long WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS = 60L; private static final long WORLD_ENTRY_TELEPORT_TIMEOUT_SECONDS = 60L;
private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L; private static final long ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L;
@@ -619,7 +620,7 @@ public class IrisCreator {
generator.getInitialSpawnReady(), generator.getInitialSpawnReady(),
"Initial spawn preparation future"); "Initial spawn preparation future");
try { try {
initialSpawnReady.get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS); initialSpawnReady.get(INITIAL_SPAWN_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException failure) { } catch (TimeoutException failure) {
throw new TimeoutException("Initial spawn preparation timed out for world \"" throw new TimeoutException("Initial spawn preparation timed out for world \""
+ worldName + "\"."); + worldName + "\".");
@@ -22,7 +22,14 @@ import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.Cache; import art.arcane.iris.engine.data.cache.Cache;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyNaturalSample;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntimeContext;
import art.arcane.iris.engine.image.IrisImageMapRuntime; import art.arcane.iris.engine.image.IrisImageMapRuntime;
import art.arcane.iris.engine.mantle.components.MantleHydrologyCaveVoxelView;
import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorationPart;
@@ -31,20 +38,11 @@ import art.arcane.iris.engine.object.IrisGenerator;
import art.arcane.iris.engine.object.IrisInterpolator; import art.arcane.iris.engine.object.IrisInterpolator;
import art.arcane.iris.engine.object.IrisMaterialPalette; import art.arcane.iris.engine.object.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisRiverCaves; import art.arcane.iris.engine.object.IrisDeepFluidConfig;
import art.arcane.iris.engine.object.IrisRiverDeepPools; import art.arcane.iris.engine.object.IrisHydrology;
import art.arcane.iris.engine.object.IrisRiverNetwork; import art.arcane.iris.engine.object.IrisRiverHydrology;
import art.arcane.iris.engine.object.IrisRiverOverride; import art.arcane.iris.engine.object.IrisRiverProfile;
import art.arcane.iris.engine.object.IrisRiverRoutingPolicy;
import art.arcane.iris.engine.object.IrisRiverWater;
import art.arcane.iris.engine.object.IrisShapedGeneratorStyle; import art.arcane.iris.engine.object.IrisShapedGeneratorStyle;
import art.arcane.iris.engine.river.runtime.IrisRiverRuntime;
import art.arcane.iris.engine.river.runtime.IrisRiverRuntimeContext;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.RiverSample;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.engine.river.cave.RiverCaveFluidKind;
import art.arcane.iris.spi.IrisPlatforms; import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBiome;
@@ -67,36 +65,48 @@ import lombok.ToString;
import java.io.File; import java.io.File;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiFunction;
@Data @Data
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime", "imageMapRuntime"}) @EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime", "imageMapRuntime"}) @ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
public class IrisComplex implements DataProvider { public class IrisComplex implements DataProvider {
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D); private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L); private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L);
private static final int GRID_BOUNDS_CACHE_SIZE = 8192; private static final int GRID_BOUNDS_CACHE_SIZE = 8192;
private static final int HEIGHT_BOUNDS_GRID = 4; private static final int HEIGHT_BOUNDS_GRID = 4;
private static final Comparator<IrisInterpolator> INTERPOLATOR_ORDER = Comparator
.comparing((IrisInterpolator interpolator) -> interpolator.getFunction().name())
.thenComparingDouble(IrisInterpolator::getHorizontalScale);
private static final Comparator<IrisGenerator> GENERATOR_ORDER = Comparator.comparing(
IrisGenerator::getLoadKey,
Comparator.nullsFirst(Comparator.naturalOrder())
);
private static final InferredType[] INFERRED_BIOME_PREPARATION_ORDER = {
InferredType.LAND,
InferredType.CAVE,
InferredType.SEA,
InferredType.SHORE
};
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
private final transient ThreadLocal<GridBoundsCache> gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new); private final transient ThreadLocal<GridBoundsCache> gridBoundsCache = ThreadLocal.withInitial(GridBoundsCache::new);
/**
* Immutable snapshot of {@link #generators} taken once at the end of construction, in the exact
* iteration order the map produces. The per-column height paths walk these arrays instead of
* allocating map/set iterators, and the frozen order keeps the floating point accumulation order
* identical to the map iteration it replaces. Mutating {@link #generators} after construction is
* not reflected here.
*/
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
private final transient IrisInterpolator[] frozenInterpolators; private final transient IrisInterpolator[] frozenInterpolators;
@Getter(AccessLevel.NONE) @Getter(AccessLevel.NONE)
private final transient IrisGenerator[][] frozenGenerators; private final transient IrisGenerator[][] frozenGenerators;
@Getter(AccessLevel.NONE)
private final transient Map<IrisRegion, Map<InferredType, ProceduralStream<IrisBiome>>> inferredBiomeStreams;
private RNG rng; private RNG rng;
private double fluidHeight; private double fluidHeight;
private IrisData data; private IrisData data;
@@ -121,9 +131,7 @@ public class IrisComplex implements DataProvider {
private ProceduralStream<Double> maxHeightStream; private ProceduralStream<Double> maxHeightStream;
private ProceduralStream<Double> overlayStream; private ProceduralStream<Double> overlayStream;
private ProceduralStream<Double> heightFluidStream; private ProceduralStream<Double> heightFluidStream;
private ProceduralStream<Double> naturalSlopeStream;
private ProceduralStream<Double> slopeStream; private ProceduralStream<Double> slopeStream;
private ProceduralStream<IrisRiverSurfaceSample> riverSurfaceStream;
private ProceduralStream<Double> riverDistanceStream; private ProceduralStream<Double> riverDistanceStream;
private ProceduralStream<Double> riverFlowStream; private ProceduralStream<Double> riverFlowStream;
private ProceduralStream<Double> riverCarveWeightStream; private ProceduralStream<Double> riverCarveWeightStream;
@@ -138,13 +146,12 @@ public class IrisComplex implements DataProvider {
private ProceduralStream<IrisDecorator> shoreSurfaceDecoration; private ProceduralStream<IrisDecorator> shoreSurfaceDecoration;
private ProceduralStream<PlatformBlockState> rockStream; private ProceduralStream<PlatformBlockState> rockStream;
private ProceduralStream<PlatformBlockState> fluidStream; private ProceduralStream<PlatformBlockState> fluidStream;
private ProceduralStream<PlatformBlockState> riverFluidStream; private Map<String, ProceduralStream<PlatformBlockState>> hydrologyFluidStreams;
private ProceduralStream<PlatformBlockState> riverDeepPoolFluidStream;
private IrisBiome focusBiome; private IrisBiome focusBiome;
private IrisRegion focusRegion; private IrisRegion focusRegion;
private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> generatorBounds; private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> generatorBounds;
private Set<IrisBiome> generatorBiomes; private Set<IrisBiome> generatorBiomes;
private IrisRiverRuntime riverRuntime; private IrisHydrologyRuntime hydrologyRuntime;
private transient IrisImageMapRuntime imageMapRuntime; private transient IrisImageMapRuntime imageMapRuntime;
// Copy-on-write: reads happen per column on every burst thread; the synchronizedMap // Copy-on-write: reads happen per column on every burst thread; the synchronizedMap
// monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map // monitor was taken on every HIT. Writes are once per biome and bounded, so a fresh map
@@ -170,6 +177,7 @@ public class IrisComplex implements DataProvider {
focusBiome = engine.getFocus(); focusBiome = engine.getFocus();
focusRegion = engine.getFocusRegion(); focusRegion = engine.getFocusRegion();
Map<InferredType, ProceduralStream<IrisBiome>> inferredStreams = new HashMap<>(); Map<InferredType, ProceduralStream<IrisBiome>> inferredStreams = new HashMap<>();
KList<IrisRegion> preparedRegions = new KList<>();
if (focusBiome != null) { if (focusBiome != null) {
focusBiome = focusBiome.withInferredType(InferredType.LAND); focusBiome = focusBiome.withInferredType(InferredType.LAND);
@@ -178,7 +186,7 @@ public class IrisComplex implements DataProvider {
//@builder //@builder
if (focusRegion != null) { if (focusRegion != null) {
prepareInferredBiomes(focusRegion); prepareInferredBiomes(focusRegion, preparedRegions);
focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators); focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators);
} else { } else {
engine.getDimension().getRegions().forEach(regionKey -> { engine.getDimension().getRegions().forEach(regionKey -> {
@@ -186,23 +194,26 @@ public class IrisComplex implements DataProvider {
if (region == null) { if (region == null) {
return; return;
} }
prepareInferredBiomes(region); prepareInferredBiomes(region, preparedRegions);
region.getNaturalBiomes(this).forEach(this::registerGenerators); region.getNaturalBiomes(this).forEach(this::registerGenerators);
}); });
for (IrisRegion region : imageMapRuntime.getMappedRegions()) { for (IrisRegion region : imageMapRuntime.getMappedRegions()) {
prepareInferredBiomes(region); prepareInferredBiomes(region, preparedRegions);
region.getNaturalBiomes(this).forEach(this::registerGenerators); region.getNaturalBiomes(this).forEach(this::registerGenerators);
} }
imageMapRuntime.getMappedBiomes().forEach(this::registerGenerators); imageMapRuntime.getMappedBiomes().forEach(this::registerGenerators);
} }
int interpolatorCount = generators.size(); inferredBiomeStreams = compileInferredBiomeStreams(
preparedRegions,
(region, inferredType) -> compileInferredBiomeStream(engine, region, inferredType, emptyBiome)
);
GeneratorGroup[] generatorGroups = freezeGeneratorGroups(generators);
int interpolatorCount = generatorGroups.length;
frozenInterpolators = new IrisInterpolator[interpolatorCount]; frozenInterpolators = new IrisInterpolator[interpolatorCount];
frozenGenerators = new IrisGenerator[interpolatorCount][]; frozenGenerators = new IrisGenerator[interpolatorCount][];
int frozenIndex = 0; for (int frozenIndex = 0; frozenIndex < generatorGroups.length; frozenIndex++) {
for (Map.Entry<IrisInterpolator, Set<IrisGenerator>> entry : generators.entrySet()) { frozenInterpolators[frozenIndex] = generatorGroups[frozenIndex].interpolator();
frozenInterpolators[frozenIndex] = entry.getKey(); frozenGenerators[frozenIndex] = generatorGroups[frozenIndex].generators();
frozenGenerators[frozenIndex] = entry.getValue().toArray(new IrisGenerator[0]);
frozenIndex++;
} }
generatorBounds = buildGeneratorBounds(engine); generatorBounds = buildGeneratorBounds(engine);
KList<IrisShapedGeneratorStyle> overlayNoise = engine.getDimension().getOverlayNoise(); KList<IrisShapedGeneratorStyle> overlayNoise = engine.getDimension().getOverlayNoise();
@@ -221,29 +232,7 @@ public class IrisComplex implements DataProvider {
.select(engine.getDimension().getRockPalette().getBlockData(data)); .select(engine.getDimension().getRockPalette().getBlockData(data));
fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream() fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream()
.select(engine.getDimension().getFluidPalette().getBlockData(data)); .select(engine.getDimension().getFluidPalette().getBlockData(data));
IrisRiverNetwork configuredRivers = engine.getDimension().getRivers(); hydrologyFluidStreams = createHydrologyFluidStreams(engine.getDimension().getHydrology());
IrisRiverWater configuredRiverWater = configuredRivers == null ? null : configuredRivers.getWater();
riverFluidStream = configuredRivers != null && configuredRivers.isEnabled()
? configuredFluidStream(
Objects.requireNonNull(configuredRiverWater).getFluidPalette(),
rng.nextParallelRNG(79),
"River water"
)
: fluidStream;
IrisRiverCaves configuredRiverCaves = configuredRivers == null ? null : configuredRivers.getCaves();
IrisRiverDeepPools configuredDeepPools = configuredRiverCaves == null
? null
: configuredRiverCaves.getDeepPools();
riverDeepPoolFluidStream = configuredRivers != null
&& configuredRivers.isEnabled()
&& configuredDeepPools != null
&& configuredDeepPools.isEnabled()
? configuredFluidStream(
configuredDeepPools.getFluidPalette(),
rng.nextParallelRNG(80),
"River deep-pool"
)
: riverFluidStream;
regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream() regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream()
.zoom(engine.getDimension().getRegionZoom()); .zoom(engine.getDimension().getRegionZoom());
regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE); regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE);
@@ -262,41 +251,22 @@ public class IrisComplex implements DataProvider {
regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i), regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i),
String.valueOf(i * 38445).hashCode() * 3245556666L)); String.valueOf(i * 38445).hashCode() * 3245556666L));
caveBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) caveBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r) .convert((r) -> createInferredBiomeStream(r, InferredType.CAVE))
-> engine.getDimension().getCaveBiomeStyle().create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream() .convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize);
.zoom(engine.getDimension().getBiomeZoom())
.zoom(r.getCaveBiomeZoom())
.selectRarity(loadInferredBiomes(r.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome)
).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.CAVE, caveBiomeStream); inferredStreams.put(InferredType.CAVE, caveBiomeStream);
landBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) landBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r) .convert((r) -> createInferredBiomeStream(r, InferredType.LAND))
-> engine.getDimension().getLandBiomeStyle().create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream() .convertAware2D(ProceduralStream::get)
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getLandZoom())
.zoom(r.getLandBiomeZoom())
.selectRarity(loadInferredBiomes(r.getLandBiomes(), InferredType.LAND))
).convertAware2D(ProceduralStream::get)
.cache2D("landBiomeStream", engine, cacheSize); .cache2D("landBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.LAND, landBiomeStream); inferredStreams.put(InferredType.LAND, landBiomeStream);
seaBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) seaBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r) .convert((r) -> createInferredBiomeStream(r, InferredType.SEA))
-> engine.getDimension().getSeaBiomeStyle().create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream() .convertAware2D(ProceduralStream::get)
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getSeaZoom())
.zoom(r.getSeaBiomeZoom())
.selectRarity(loadInferredBiomes(r.getSeaBiomes(), InferredType.SEA))
).convertAware2D(ProceduralStream::get)
.cache2D("seaBiomeStream", engine, cacheSize); .cache2D("seaBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.SEA, seaBiomeStream); inferredStreams.put(InferredType.SEA, seaBiomeStream);
shoreBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z)) shoreBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r) .convert((r) -> createInferredBiomeStream(r, InferredType.SHORE))
-> engine.getDimension().getShoreBiomeStyle().create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream() .convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize);
.zoom(engine.getDimension().getBiomeZoom())
.zoom(r.getShoreBiomeZoom())
.selectRarity(loadInferredBiomes(r.getShoreBiomes(), InferredType.SHORE))
).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.SHORE, shoreBiomeStream); inferredStreams.put(InferredType.SHORE, shoreBiomeStream);
bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(), bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(),
Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) : Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) :
@@ -315,13 +285,10 @@ public class IrisComplex implements DataProvider {
return mapped == null ? biome : mapped; return mapped == null ? biome : mapped;
}) })
.cache2D("imageMappedBaseBiomeStream", engine, cacheSize); .cache2D("imageMappedBaseBiomeStream", engine, cacheSize);
naturalHeightStream = ProceduralStream.of((x, z) -> { naturalHeightStream = ProceduralStream.of(
IrisBiome b = focusBiome != null ? focusBiome : baseBiomeStream.get(x, z); (x, z) -> sampleNaturalTerrainHeight(engine, x, z),
double proceduralHeight = getHeight(engine, b, x, z, engine.getSeedManager().getHeight()); Interpolated.DOUBLE
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight); ).cache2DDouble("naturalHeightStream", engine, cacheSize);
}, Interpolated.DOUBLE).cache2DDouble("naturalHeightStream", engine, cacheSize);
naturalSlopeStream = naturalHeightStream.slope(3)
.cache2DDouble("naturalSlopeStream", engine, cacheSize);
naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome)) b -> focusBiome))
.cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream .cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream
@@ -332,46 +299,20 @@ public class IrisComplex implements DataProvider {
: mapped; : mapped;
}) })
.cache2D("naturalTrueBiomeStream", engine, cacheSize); .cache2D("naturalTrueBiomeStream", engine, cacheSize);
if (engine.getDimension().getRivers() != null && engine.getDimension().getRivers().isEnabled()) { IrisHydrology configuredHydrology = engine.getDimension().getHydrology();
ProceduralStream<Boolean> naturalOceanStream = createNaturalOceanStream( if (hydrologyActive(configuredHydrology)) {
bridgeStream, hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(
focusBiome
).cache2D("naturalOceanStream", engine, cacheSize);
int riverFluidHeight = engine.getDimension().getRivers().getWater().getFluidHeight()
- engine.getDimension().getMinHeight();
riverRuntime = new IrisRiverRuntime(new IrisRiverRuntimeContext(
engine.getSeedManager().getBodies(), engine.getSeedManager().getBodies(),
engine.getDimension().getRivers(), engine.getHeight(),
engine.getDimension(),
data, data,
riverFluidHeight, (x, z, naturalHeight) -> sampleHydrologyNatural(engine, x, z, naturalHeight),
(int) Math.round(fluidHeight), (x, z) -> naturalHeightStream.getDouble(x, z),
IrisEngineMantle.isRiverHydrologyEnabled(engine.getDimension()), this::sampleNaturalOcean,
IrisEngineMantle.isRiverCaveHydrologyEnabled(engine.getDimension()), footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint)
blockingRiverRoutingPossible(engine),
variableMaxIncisionPossible(engine),
biomeRiverOverridesPossible(focusBiome, generatorBiomes),
(blockX, blockZ) -> naturalHeightBounds(engine, overlayNoise, blockX, blockZ),
naturalHeightStream,
naturalSlopeStream,
naturalOceanStream,
naturalTrueBiomeStream,
regionStream
)); ));
riverSurfaceStream = ProceduralStream.of(
(x, z) -> riverRuntime.sample(x, z),
Interpolated.of(
IrisRiverSurfaceSample::terrainHeight,
value -> IrisRiverSurfaceSample.none(value, fluidHeight)
)
)
.cache2D("riverSurfaceStream", engine, cacheSize);
} else {
riverSurfaceStream = naturalHeightStream.convert(
value -> IrisRiverSurfaceSample.none(value, fluidHeight)
)
.cache2D("riverSurfaceStream-disabled", engine, cacheSize);
} }
heightStream = riverSurfaceStream.convert(IrisRiverSurfaceSample::terrainHeight) heightStream = ProceduralStream.ofDouble((x, z) -> resolveHydrologyTerrainHeight(x, z))
.cache2DDouble("heightStream", engine, cacheSize); .cache2DDouble("heightStream", engine, cacheSize);
roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z)) roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.round(); .round();
@@ -379,20 +320,18 @@ public class IrisComplex implements DataProvider {
.slope(3).cache2DDouble("slopeStream", engine, cacheSize); .slope(3).cache2DDouble("slopeStream", engine, cacheSize);
trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D, trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome)) b -> focusBiome))
.cache2D("trueBiomeStream-focus", engine, cacheSize) : riverSurfaceStream .cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream
.convertAware2D((sample, x, z) -> resolveRiverSurfaceBiome(sample, x, z)) .convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z))
.cache2D("trueBiomeStream", engine, cacheSize); .cache2D("trueBiomeStream", engine, cacheSize);
trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z)) trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize); .convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize);
riverDistanceStream = riverSurfaceStream.convert(sample -> sample.river().present() riverDistanceStream = ProceduralStream.ofDouble(this::resolveHydrologyDistance)
? sample.river().distance()
: Double.MAX_VALUE)
.cache2DDouble("riverDistanceStream", engine, cacheSize); .cache2DDouble("riverDistanceStream", engine, cacheSize);
riverFlowStream = riverSurfaceStream.convert(sample -> (double) sample.river().flow()) riverFlowStream = ProceduralStream.ofDouble(this::resolveHydrologyFlow)
.cache2DDouble("riverFlowStream", engine, cacheSize); .cache2DDouble("riverFlowStream", engine, cacheSize);
riverCarveWeightStream = riverSurfaceStream.convert(sample -> sample.river().carveWeight()) riverCarveWeightStream = ProceduralStream.ofDouble(this::resolveHydrologyCarveWeight)
.cache2DDouble("riverCarveWeightStream", engine, cacheSize); .cache2DDouble("riverCarveWeightStream", engine, cacheSize);
riverWaterSurfaceStream = riverSurfaceStream.convert(IrisRiverSurfaceSample::waterSurfaceY) riverWaterSurfaceStream = ProceduralStream.ofDouble(this::resolveHydrologyFluidSurface)
.cache2DDouble("riverWaterSurfaceStream", engine, cacheSize); .cache2DDouble("riverWaterSurfaceStream", engine, cacheSize);
heightFluidStream = ProceduralStream.ofDouble((x, z) -> Math.max( heightFluidStream = ProceduralStream.ofDouble((x, z) -> Math.max(
heightStream.get(x, z), heightStream.get(x, z),
@@ -424,79 +363,204 @@ public class IrisComplex implements DataProvider {
//@done //@done
} }
private boolean blockingRiverRoutingPossible(Engine engine) { static InferredType resolveNaturalInferredType(
if (blocksRiverRouting(focusRegion == null ? null : focusRegion.getRiverOverride())
|| blocksRiverRouting(focusBiome == null ? null : focusBiome.getRiverOverride())) {
return true;
}
KList<IrisRegion> regions = engine.getDimension().getAllRegions(engine);
for (IrisRegion loadedRegion : regions) {
if (loadedRegion != null && blocksRiverRouting(loadedRegion.getRiverOverride())) {
return true;
}
}
KList<IrisBiome> biomes = engine.getDimension().getReachableBiomes(engine);
for (IrisBiome biome : biomes) {
if (biome != null && blocksRiverRouting(biome.getRiverOverride())) {
return true;
}
}
return false;
}
private static boolean blocksRiverRouting(IrisRiverOverride override) {
return override != null && override.getRoutingPolicy() == IrisRiverRoutingPolicy.BLOCK;
}
private boolean variableMaxIncisionPossible(Engine engine) {
if (changesMaxIncision(focusRegion == null ? null : focusRegion.getRiverOverride())
|| changesMaxIncision(focusBiome == null ? null : focusBiome.getRiverOverride())) {
return true;
}
KList<IrisRegion> regions = engine.getDimension().getAllRegions(engine);
for (IrisRegion loadedRegion : regions) {
if (loadedRegion != null && changesMaxIncision(loadedRegion.getRiverOverride())) {
return true;
}
}
KList<IrisBiome> biomes = engine.getDimension().getReachableBiomes(engine);
for (IrisBiome biome : biomes) {
if (biome != null && changesMaxIncision(biome.getRiverOverride())) {
return true;
}
}
return false;
}
static boolean changesMaxIncision(IrisRiverOverride override) {
if (override == null || override.getMaxIncisionMultiplier() == null) {
return false;
}
double multiplier = override.getMaxIncisionMultiplier();
return Double.isFinite(multiplier) && Double.compare(Math.max(0D, multiplier), 1D) != 0;
}
static boolean biomeRiverOverridesPossible(IrisBiome focusBiome, Iterable<IrisBiome> naturalBiomes) {
if (focusBiome != null) {
return focusBiome.getRiverOverride() != null;
}
for (IrisBiome biome : naturalBiomes) {
if (biome != null && biome.getRiverOverride() != null) {
return true;
}
}
return false;
}
static ProceduralStream<Boolean> createNaturalOceanStream(
ProceduralStream<InferredType> bridgeStream, ProceduralStream<InferredType> bridgeStream,
IrisBiome focusBiome IrisBiome focusBiome,
double x,
double z
) { ) {
if (focusBiome != null) { if (focusBiome != null) {
boolean ocean = focusBiome.getInferredType() == InferredType.SEA; return focusBiome.getInferredType();
return ProceduralStream.of((x, z) -> ocean, Interpolated.BOOLEAN);
} }
return bridgeStream.convert(type -> type == InferredType.SEA); return bridgeStream.get(x, z);
}
private IrisHydrologyNaturalSample sampleHydrologyNatural(
Engine engine,
int x,
int z,
double naturalHeight
) {
InferredType inferredType = resolveNaturalInferredType(bridgeStream, focusBiome, x, z);
IrisRegion region = regionStream.get(x, z);
IrisBiome biome = focusBiome == null
? sampleNaturalBiome(inferredType, region, naturalHeight, x, z)
: focusBiome;
return new IrisHydrologyNaturalSample(
naturalHeight,
inferredType == InferredType.SEA,
biome,
region
);
}
private boolean sampleNaturalOcean(int x, int z) {
return resolveNaturalInferredType(bridgeStream, focusBiome, x, z) == InferredType.SEA;
}
private double sampleNaturalTerrainHeight(Engine engine, double x, double z) {
double proceduralHeight = getHeight(engine, x, z, engine.getSeedManager().getHeight());
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight);
}
static double calculateNaturalSlope(double naturalHeight, double easternHeight, double southernHeight) {
double deltaX = easternHeight - naturalHeight;
double deltaZ = southernHeight - naturalHeight;
return Math.sqrt(deltaX * deltaX + deltaZ * deltaZ);
}
private IrisBiome sampleNaturalBiome(
InferredType inferredType,
IrisRegion region,
double naturalHeight,
double x,
double z
) {
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
if (mapped != null) {
return mapped;
}
IrisBiome baseBiome = implode(sampleInferredBiome(region, inferredType, x, z), x, z);
IrisBiome resolved = resolveNaturalSurfaceBiome(naturalHeight, baseBiome, region, x, z);
return resolved == baseBiome ? baseBiome : implode(resolved, x, z);
}
private IrisBiome resolveNaturalSurfaceBiome(
double height,
IrisBiome biome,
IrisRegion region,
double x,
double z
) {
if (biome == null || region == null) {
return biome;
}
double shoreHeight = region.getShoreHeight(x, z);
if (height >= fluidHeight - 1 && height <= fluidHeight + shoreHeight && !biome.isShore()) {
return sampleInferredBiome(region, InferredType.SHORE, x, z);
}
if (height > fluidHeight + shoreHeight && !biome.isLand()) {
return sampleInferredBiome(region, InferredType.LAND, x, z);
}
if (height < fluidHeight && !biome.isAquatic()) {
return sampleInferredBiome(region, InferredType.SEA, x, z);
}
if (height == fluidHeight && !biome.isShore()) {
return sampleInferredBiome(region, InferredType.SHORE, x, z);
}
return biome;
}
private IrisBiome sampleInferredBiome(
IrisRegion region,
InferredType inferredType,
double x,
double z
) {
return createInferredBiomeStream(region, inferredType).get(x, z);
}
private ProceduralStream<IrisBiome> createInferredBiomeStream(
IrisRegion region,
InferredType inferredType
) {
return preparedInferredBiomeStream(inferredBiomeStreams, region, inferredType);
}
private ProceduralStream<IrisBiome> compileInferredBiomeStream(
Engine engine,
IrisRegion region,
InferredType inferredType,
IrisBiome emptyBiome
) {
return switch (inferredType) {
case CAVE -> engine.getDimension().getCaveBiomeStyle()
.create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getCaveBiomeZoom())
.selectRarity(loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome);
case LAND -> engine.getDimension().getLandBiomeStyle()
.create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getLandZoom())
.zoom(region.getLandBiomeZoom())
.selectRarity(loadInferredBiomes(region.getLandBiomes(), InferredType.LAND));
case SEA -> engine.getDimension().getSeaBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getSeaZoom())
.zoom(region.getSeaBiomeZoom())
.selectRarity(loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA));
case SHORE -> engine.getDimension().getShoreBiomeStyle()
.create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(region.getShoreBiomeZoom())
.selectRarity(loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE));
};
}
private static boolean hydrologyActive(IrisHydrology hydrology) {
if (hydrology == null) {
return false;
}
IrisRiverHydrology rivers = hydrology.getRivers();
if (rivers != null && rivers.isEnabled()) {
return true;
}
for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) {
if (deepFluid != null
&& deepFluid.getDensity() > 0D
&& (deepFluid.isContainedPools() || deepFluid.isShortChannels())) {
return true;
}
}
return false;
}
private Map<String, ProceduralStream<PlatformBlockState>> createHydrologyFluidStreams(IrisHydrology hydrology) {
if (hydrology == null) {
return Map.of();
}
LinkedHashMap<String, ProceduralStream<PlatformBlockState>> streams = new LinkedHashMap<>();
IrisRiverHydrology rivers = Objects.requireNonNull(hydrology.getRivers(), "hydrology.rivers");
int streamIndex = 0;
for (IrisRiverProfile profile : rivers.getProfiles()) {
if (profile == null) {
throw new IllegalArgumentException("hydrology.rivers.profiles cannot contain null entries");
}
String profileKey = requireHydrologyKey(profile.getId(), "hydrology.rivers.profiles[].id");
ProceduralStream<PlatformBlockState> stream = configuredFluidStream(
profile.getFluidPalette(),
rng.nextParallelRNG(7900 + streamIndex++),
"hydrology river profile " + profileKey
);
if (streams.putIfAbsent(profileKey, stream) != null) {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
for (IrisDeepFluidConfig deepFluid : hydrology.getDeepFluids()) {
if (deepFluid == null) {
throw new IllegalArgumentException("hydrology.deepFluids cannot contain null entries");
}
String profileKey = requireHydrologyKey(deepFluid.getId(), "hydrology.deepFluids[].id");
ProceduralStream<PlatformBlockState> stream = configuredFluidStream(
deepFluid.getFluidPalette(),
rng.nextParallelRNG(8900 + streamIndex++),
"hydrology deep-fluid profile " + profileKey
);
if (streams.putIfAbsent(profileKey, stream) != null) {
throw new IllegalArgumentException("Duplicate hydrology fluid profile: " + profileKey);
}
}
return Collections.unmodifiableMap(streams);
}
private static String requireHydrologyKey(String key, String path) {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException(path + " must not be blank");
}
return key.trim();
} }
private ProceduralStream<PlatformBlockState> configuredFluidStream( private ProceduralStream<PlatformBlockState> configuredFluidStream(
@@ -519,21 +583,81 @@ public class IrisComplex implements DataProvider {
return palette.getLayerGenerator(fluidRng, data).stream().select(blocks); return palette.getLayerGenerator(fluidRng, data).stream().select(blocks);
} }
public PlatformBlockState resolveRiverCaveFluid(RiverCaveFluidKind fluidKind, double x, double z) { public PlatformBlockState resolveHydrologyFluid(String profileKey, double x, double z) {
return switch (Objects.requireNonNull(fluidKind)) { String key = Objects.requireNonNull(profileKey, "profileKey").trim();
case RIVER -> riverFluidStream.get(x, z); ProceduralStream<PlatformBlockState> stream = hydrologyFluidStreams.get(key);
case DEEP_POOL -> riverDeepPoolFluidStream.get(x, z); if (stream == null) {
}; throw new IllegalArgumentException("Unknown hydrology fluid profile: " + key);
}
return stream.get(x, z);
} }
public PlatformBlockState resolveSurfaceFluid(double x, double z) { public PlatformBlockState resolveSurfaceFluid(double x, double z) {
IrisRiverSurfaceSample sample = riverSurfaceStream.get(x, z); HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
if (sample.river().present() && sample.surfaceFluid()) { if (layer != null) {
return riverFluidStream.get(x, z); return resolveHydrologyFluid(layer.profileKey(), x, z);
} }
return fluidStream.get(x, z); return fluidStream.get(x, z);
} }
private double resolveHydrologyTerrainHeight(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? naturalHeightStream.get(x, z) : sample.terrainHeight();
}
private double resolveHydrologyDistance(double x, double z) {
HydrologyColumnLayer layer = surfaceLayer(x, z);
if (layer == null) {
return Double.MAX_VALUE;
}
if (layer.channel()) {
return 0D;
}
return layer.shore() ? 1D : 2D;
}
private double resolveHydrologyFlow(double x, double z) {
HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
if (layer == null) {
return 0D;
}
return layer.feature().flowDeltaX() == 0 && layer.feature().flowDeltaZ() == 0 ? 0D : 1D;
}
private double resolveHydrologyCarveWeight(double x, double z) {
HydrologyColumnLayer layer = surfaceLayer(x, z);
if (layer == null) {
return 0D;
}
if (layer.channel()) {
return 1D;
}
return layer.shore() ? 0.5D : 0.25D;
}
private double resolveHydrologyFluidSurface(double x, double z) {
HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
return layer == null ? fluidHeight : layer.fluidHeadY();
}
private HydrologyColumnSample hydrologySample(double x, double z) {
return hydrologyRuntime == null ? null : hydrologyRuntime.sample(x, z).orElse(null);
}
private HydrologyColumnLayer surfaceLayer(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? null : sample.primarySurfaceLayer().orElse(null);
}
private HydrologyColumnLayer surfaceFluidLayer(double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
return sample == null ? null : sample.primarySurfaceFluidLayer().orElse(null);
}
public boolean hasHydrologySurfaceFluid(int x, int z) {
return surfaceFluidLayer(x, z) != null;
}
public ProceduralStream<IrisBiome> getBiomeStream(InferredType type) { public ProceduralStream<IrisBiome> getBiomeStream(InferredType type) {
switch (type) { switch (type) {
case CAVE: case CAVE:
@@ -555,7 +679,7 @@ public class IrisComplex implements DataProvider {
if (engine == null) { if (engine == null) {
throw new IllegalArgumentException("Engine is required to sample procedural terrain height"); throw new IllegalArgumentException("Engine is required to sample procedural terrain height");
} }
return getHeight(engine, null, worldX, worldZ, engine.getSeedManager().getHeight()); return getHeight(engine, worldX, worldZ, engine.getSeedManager().getHeight());
} }
private IrisRegion findRegion(IrisBiome focus, Engine engine) { private IrisRegion findRegion(IrisBiome focus, Engine engine) {
@@ -594,46 +718,29 @@ public class IrisComplex implements DataProvider {
return null; return null;
} }
private IrisBiome resolveRiverSurfaceBiome(IrisRiverSurfaceSample sample, double x, double z) { private IrisBiome resolveHydrologySurfaceBiome(double terrainHeight, double x, double z) {
HydrologyColumnSample sample = hydrologySample(x, z);
HydrologyColumnLayer layer = sample == null ? null : sample.primarySurfaceLayer().orElse(null);
IrisBiome mapped = imageMapRuntime.sampleBiome(x, z); IrisBiome mapped = imageMapRuntime.sampleBiome(x, z);
if (mapped != null && (riverRuntime == null || !sample.river().present())) { if (mapped != null && layer == null) {
return mapped; return mapped;
} }
if (riverRuntime != null) { if (layer != null) {
if (sample.subterranean()) { String biomeKey = hydrologySurfaceBiomeKey(sample);
return fixBiomeType( IrisBiome biome = data.getBiomeLoader().load(biomeKey);
sample.naturalHeight(), if (biome == null) {
baseBiomeStream.get(x, z), throw new IllegalStateException("Hydrology biome does not exist: " + biomeKey);
regionStream.get(x, z),
x,
z,
fluidHeight
);
} }
IrisBiome riverBiome = riverRuntime.selectSurfaceBiome(sample, x, z); if (layer.biomeKey() == null) {
if (riverBiome != null) { return biome;
return implode(riverBiome, x, z);
}
InferredType directFallback = directRiverFallback(sample.river());
if (directFallback != null) {
IrisBiome baseBiome = baseBiomeStream.get(x, z);
return implode(baseBiome.withInferredType(directFallback), x, z);
}
if (sample.river().present()
&& sample.river().state() == RiverRouteState.WET
&& sample.river().section() == RiverSection.BANK) {
return fixBiomeType(
sample.terrainHeight(),
baseBiomeStream.get(x, z),
regionStream.get(x, z),
x,
z,
sample.waterSurfaceY()
);
} }
InferredType inferredType = layer.shore()
? InferredType.SHORE
: layer.connectedFluid() ? InferredType.SEA : InferredType.LAND;
return implode(biome.withInferredType(inferredType), x, z);
} }
return fixBiomeType( return fixBiomeType(
sample.terrainHeight(), terrainHeight,
baseBiomeStream.get(x, z), baseBiomeStream.get(x, z),
regionStream.get(x, z), regionStream.get(x, z),
x, x,
@@ -642,17 +749,14 @@ public class IrisComplex implements DataProvider {
); );
} }
static InferredType directRiverFallback(RiverSample river) { static String hydrologySurfaceBiomeKey(HydrologyColumnSample sample) {
if (!river.present()) { Objects.requireNonNull(sample, "sample");
return null; HydrologyColumnLayer layer = sample.primarySurfaceLayer().orElse(null);
if (layer == null) {
return sample.parentBiomeKey();
} }
if (river.state() == RiverRouteState.DRY) { String biomeKey = layer.biomeKey();
return InferredType.LAND; return biomeKey == null ? sample.parentBiomeKey() : biomeKey;
}
return switch (river.section()) {
case CHANNEL, MOUTH -> InferredType.SEA;
default -> null;
};
} }
private IrisBiome fixBiomeType(Double height, IrisBiome biome, IrisRegion region, Double x, Double z, double fluidHeight) { private IrisBiome fixBiomeType(Double height, IrisBiome biome, IrisRegion region, Double x, Double z, double fluidHeight) {
@@ -713,13 +817,7 @@ public class IrisComplex implements DataProvider {
double hi = sampledBounds.max(); double hi = sampledBounds.max();
double lo = sampledBounds.min(); double lo = sampledBounds.min();
double d = 0; return averageGeneratorHeights(generators, lo, hi, x, z, seed + 239945);
for (IrisGenerator i : generators) {
d += M.lerp(lo, hi, i.getHeight(x, z, seed + 239945));
}
return d / generators.length;
} }
private NoiseBounds gridSampleBounds(Engine engine, IrisInterpolator interpolator, int interpolatorIndex, IrisGenerator[] generators, double x, double z) { private NoiseBounds gridSampleBounds(Engine engine, IrisInterpolator interpolator, int interpolatorIndex, IrisGenerator[] generators, double x, double z) {
@@ -742,6 +840,22 @@ public class IrisComplex implements DataProvider {
return new NoiseBounds(boundsLow(b00), boundsHigh(b00)); return new NoiseBounds(boundsLow(b00), boundsHigh(b00));
} }
if (fz == 0D) {
long b10 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz);
return new NoiseBounds(
biLerp(boundsLow(b00), boundsLow(b10), boundsLow(b00), boundsLow(b10), fx, fz),
biLerp(boundsHigh(b00), boundsHigh(b10), boundsHigh(b00), boundsHigh(b10), fx, fz)
);
}
if (fx == 0D) {
long b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz + grid);
return new NoiseBounds(
biLerp(boundsLow(b00), boundsLow(b00), boundsLow(b01), boundsLow(b01), fx, fz),
biLerp(boundsHigh(b00), boundsHigh(b00), boundsHigh(b01), boundsHigh(b01), fx, fz)
);
}
long b10 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz); long b10 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz);
long b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz + grid); long b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, gz + grid);
long b11 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz + grid); long b11 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx + grid, gz + grid);
@@ -835,15 +949,54 @@ public class IrisComplex implements DataProvider {
); );
} }
private double getHeight(Engine engine, IrisBiome b, double x, double z, long seed) { private double getHeight(Engine engine, double x, double z, long seed) {
return Math.max(Math.min(getInterpolatedHeight(engine, x, z, seed) + fluidHeight + overlayStream.get(x, z), engine.getHeight()), 0); return Math.max(Math.min(getInterpolatedHeight(engine, x, z, seed) + fluidHeight + overlayStream.get(x, z), engine.getHeight()), 0);
} }
private void prepareInferredBiomes(IrisRegion region) { private void prepareInferredBiomes(IrisRegion region, KList<IrisRegion> preparedRegions) {
loadInferredBiomes(region.getLandBiomes(), InferredType.LAND); loadInferredBiomes(region.getLandBiomes(), InferredType.LAND);
loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE); loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE);
loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA); loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA);
loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE); loadInferredBiomes(region.getShoreBiomes(), InferredType.SHORE);
preparedRegions.add(region);
}
static Map<IrisRegion, Map<InferredType, ProceduralStream<IrisBiome>>> compileInferredBiomeStreams(
Iterable<IrisRegion> regions,
BiFunction<IrisRegion, InferredType, ProceduralStream<IrisBiome>> compiler
) {
IdentityHashMap<IrisRegion, Map<InferredType, ProceduralStream<IrisBiome>>> compiled = new IdentityHashMap<>();
for (IrisRegion region : regions) {
if (compiled.containsKey(region)) {
continue;
}
EnumMap<InferredType, ProceduralStream<IrisBiome>> regionStreams = new EnumMap<>(InferredType.class);
for (InferredType inferredType : INFERRED_BIOME_PREPARATION_ORDER) {
regionStreams.put(inferredType, Objects.requireNonNull(compiler.apply(region, inferredType)));
}
compiled.put(region, Collections.unmodifiableMap(regionStreams));
}
return Collections.unmodifiableMap(compiled);
}
static ProceduralStream<IrisBiome> preparedInferredBiomeStream(
Map<IrisRegion, Map<InferredType, ProceduralStream<IrisBiome>>> streams,
IrisRegion region,
InferredType inferredType
) {
Map<InferredType, ProceduralStream<IrisBiome>> regionStreams = streams.get(region);
if (regionStreams == null) {
String regionKey = region == null || region.getLoadKey() == null || region.getLoadKey().isBlank()
? "<unkeyed>"
: region.getLoadKey();
throw new IllegalStateException("Inferred-biome streams were not prepared for region '"
+ regionKey + "'.");
}
ProceduralStream<IrisBiome> stream = regionStreams.get(inferredType);
if (stream == null) {
throw new IllegalStateException("Inferred-biome stream was not prepared for type " + inferredType + ".");
}
return stream;
} }
private KList<IrisBiome> loadInferredBiomes(KList<String> keys, InferredType type) { private KList<IrisBiome> loadInferredBiomes(KList<String> keys, InferredType type) {
@@ -863,6 +1016,37 @@ public class IrisComplex implements DataProvider {
generators.computeIfAbsent(cachedGenerator.getInterpolator(), (k) -> new HashSet<>()).add(cachedGenerator); generators.computeIfAbsent(cachedGenerator.getInterpolator(), (k) -> new HashSet<>()).add(cachedGenerator);
} }
static GeneratorGroup[] freezeGeneratorGroups(Map<IrisInterpolator, Set<IrisGenerator>> generators) {
GeneratorGroup[] groups = new GeneratorGroup[generators.size()];
int groupIndex = 0;
for (Map.Entry<IrisInterpolator, Set<IrisGenerator>> entry : generators.entrySet()) {
IrisGenerator[] groupGenerators = entry.getValue().toArray(new IrisGenerator[0]);
Arrays.sort(groupGenerators, GENERATOR_ORDER);
groups[groupIndex] = new GeneratorGroup(entry.getKey(), groupGenerators);
groupIndex++;
}
Arrays.sort(groups, Comparator.comparing(GeneratorGroup::interpolator, INTERPOLATOR_ORDER));
return groups;
}
static double averageGeneratorHeights(
IrisGenerator[] generators,
double low,
double high,
double x,
double z,
long seed
) {
if (generators.length == 0) {
return 0D;
}
double height = 0D;
for (IrisGenerator generator : generators) {
height += M.lerp(low, high, generator.getHeight(x, z, seed));
}
return height / generators.length;
}
private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> buildGeneratorBounds(Engine engine) { private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> buildGeneratorBounds(Engine engine) {
Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> bounds = new HashMap<>(); Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> bounds = new HashMap<>();
KList<IrisBiome> allBiomes = new KList<>(generatorBiomes); KList<IrisBiome> allBiomes = new KList<>(generatorBiomes);
@@ -1265,9 +1449,12 @@ public class IrisComplex implements DataProvider {
} }
} }
record GeneratorGroup(IrisInterpolator interpolator, IrisGenerator[] generators) {
}
public void close() { public void close() {
if (riverRuntime != null) { if (hydrologyRuntime != null) {
riverRuntime.close(); hydrologyRuntime.close();
} }
} }
} }
@@ -30,7 +30,7 @@ import art.arcane.iris.engine.mantle.MantlePass;
import art.arcane.iris.engine.mantle.components.MantleCarvingComponent; import art.arcane.iris.engine.mantle.components.MantleCarvingComponent;
import art.arcane.iris.engine.mantle.components.MantleFloatingObjectComponent; import art.arcane.iris.engine.mantle.components.MantleFloatingObjectComponent;
import art.arcane.iris.engine.mantle.components.MantleObjectComponent; import art.arcane.iris.engine.mantle.components.MantleObjectComponent;
import art.arcane.iris.engine.mantle.components.MantleRiverHydrologyComponent; import art.arcane.iris.engine.mantle.components.MantleHydrologyComponent;
import art.arcane.iris.engine.mantle.components.IrisStructureComponent; import art.arcane.iris.engine.mantle.components.IrisStructureComponent;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
@@ -75,6 +75,7 @@ import java.util.function.Supplier;
@EqualsAndHashCode(exclude = "engine") @EqualsAndHashCode(exclude = "engine")
@ToString(exclude = "engine") @ToString(exclude = "engine")
public class IrisEngineMantle implements EngineMantle { public class IrisEngineMantle implements EngineMantle {
public static final String STORAGE_FOLDER_NAME = "mantle-hydrology";
private static final boolean BUKKIT_PRESENT = IrisMatterSupport.isBukkitPresent(); private static final boolean BUKKIT_PRESENT = IrisMatterSupport.isBukkitPresent();
private final Engine engine; private final Engine engine;
private final Mantle<Matter> mantle; private final Mantle<Matter> mantle;
@@ -90,7 +91,7 @@ public class IrisEngineMantle implements EngineMantle {
this.mantle = createMantle(engine); this.mantle = createMantle(engine);
components = new KMap<>(); components = new KMap<>();
registerComponent(new MantleCarvingComponent(this)); registerComponent(new MantleCarvingComponent(this));
registerComponent(new MantleRiverHydrologyComponent(this)); registerComponent(new MantleHydrologyComponent(this));
object = new MantleObjectComponent(this); object = new MantleObjectComponent(this);
registerComponent(object); registerComponent(object);
registerComponent(new MantleFloatingObjectComponent(this)); registerComponent(new MantleFloatingObjectComponent(this));
@@ -126,7 +127,7 @@ public class IrisEngineMantle implements EngineMantle {
List<MantleComponent> pass = passes.get(i); List<MantleComponent> pass = passes.get(i);
int passBlockRadius = pass.stream() int passBlockRadius = pass.stream()
.filter(MantleComponent::isEnabled) .filter(MantleComponent::isEnabled)
.mapToInt(MantleComponent::getRadius) .mapToInt(MantleComponent::getOutputRadius)
.max() .max()
.orElse(0); .orElse(0);
int passInputRadius = pass.stream() int passInputRadius = pass.stream()
@@ -180,19 +181,15 @@ public class IrisEngineMantle implements EngineMantle {
disabled.addIfMissing(ReservedFlag.CARVED); disabled.addIfMissing(ReservedFlag.CARVED);
} }
if (disabled.contains(ReservedFlag.CARVED) if (disabled.contains(ReservedFlag.CARVED)
|| !isRiverHydrologyEnabled(getDimension())) { || !isHydrologyEnabled(getDimension())) {
disabled.addIfMissing(ReservedFlag.RIVER_HYDROLOGY); disabled.addIfMissing(ReservedFlag.RIVER_HYDROLOGY);
} }
return Set.copyOf(disabled); return Set.copyOf(disabled);
}); });
} }
static boolean isRiverHydrologyEnabled(IrisDimension dimension) { static boolean isHydrologyEnabled(IrisDimension dimension) {
return MantleRiverHydrologyComponent.isEnabledFor(dimension); return MantleHydrologyComponent.isEnabledFor(dimension);
}
static boolean isRiverCaveHydrologyEnabled(IrisDimension dimension) {
return MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension);
} }
@Override @Override
@@ -202,7 +199,7 @@ public class IrisEngineMantle implements EngineMantle {
private static Mantle<Matter> createMantle(Engine engine) { private static Mantle<Matter> createMantle(Engine engine) {
IrisMatterSupport.ensureRegistered(); IrisMatterSupport.ensureRegistered();
File dataFolder = new File(engine.getWorld().worldFolder(), "mantle"); File dataFolder = new File(engine.getWorld().worldFolder(), STORAGE_FOLDER_NAME);
int worldHeight = engine.getTarget().getHeight(); int worldHeight = engine.getTarget().getHeight();
MantleDataAdapter<Matter> adapter = createDataAdapter(engine::getData); MantleDataAdapter<Matter> adapter = createDataAdapter(engine::getData);
MantleHooks hooks = createHooks(EnginePanic.scoped("world " + engine.getWorld().name())); MantleHooks hooks = createHooks(EnginePanic.scoped("world " + engine.getWorld().name()));
@@ -14,7 +14,6 @@ import art.arcane.iris.engine.object.IrisShapedGeneratorStyle;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
import art.arcane.iris.util.common.data.DataProvider; import art.arcane.iris.util.common.data.DataProvider;
import art.arcane.volmlib.util.math.M;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import art.arcane.iris.util.project.interpolation.NoiseBounds; import art.arcane.iris.util.project.interpolation.NoiseBounds;
import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformBlockState;
@@ -112,13 +111,14 @@ public class UpperDimensionContext implements DataProvider {
registerBiomeGenerators(mappedBiome, dataProvider, allBiomes, generators); registerBiomeGenerators(mappedBiome, dataProvider, allBiomes, generators);
} }
IrisComplex.GeneratorGroup[] generatorGroups = IrisComplex.freezeGeneratorGroups(generators);
Map<IrisInterpolator, IdentityHashMap<IrisBiome, NoiseBounds>> generatorBounds = new HashMap<>(); Map<IrisInterpolator, IdentityHashMap<IrisBiome, NoiseBounds>> generatorBounds = new HashMap<>();
for (Map.Entry<IrisInterpolator, Set<IrisGenerator>> entry : generators.entrySet()) { for (IrisComplex.GeneratorGroup group : generatorGroups) {
IdentityHashMap<IrisBiome, NoiseBounds> interpolatorBounds = new IdentityHashMap<>(Math.max(allBiomes.size(), 16)); IdentityHashMap<IrisBiome, NoiseBounds> interpolatorBounds = new IdentityHashMap<>(Math.max(allBiomes.size(), 16));
for (IrisBiome biome : allBiomes) { for (IrisBiome biome : allBiomes) {
double min = 0D; double min = 0D;
double max = 0D; double max = 0D;
for (IrisGenerator gen : entry.getValue()) { for (IrisGenerator gen : group.generators()) {
String key = gen.getLoadKey(); String key = gen.getLoadKey();
if (key == null || key.isBlank()) { if (key == null || key.isBlank()) {
continue; continue;
@@ -128,7 +128,7 @@ public class UpperDimensionContext implements DataProvider {
} }
interpolatorBounds.put(biome, new NoiseBounds(min, max)); interpolatorBounds.put(biome, new NoiseBounds(min, max));
} }
generatorBounds.put(entry.getKey(), interpolatorBounds); generatorBounds.put(group.interpolator(), interpolatorBounds);
} }
ProceduralStream<Double> regionStyleStream = upperDim.getRegionStyle() ProceduralStream<Double> regionStyleStream = upperDim.getRegionStyle()
@@ -204,10 +204,10 @@ public class UpperDimensionContext implements DataProvider {
return mappedTerrainHeight(imageMapRuntime, fluidHeight, x, z); return mappedTerrainHeight(imageMapRuntime, fluidHeight, x, z);
} }
double interpolatedHeight = 0; double interpolatedHeight = 0;
for (Map.Entry<IrisInterpolator, Set<IrisGenerator>> entry : generators.entrySet()) { for (IrisComplex.GeneratorGroup group : generatorGroups) {
IrisInterpolator interpolator = entry.getKey(); IrisInterpolator interpolator = group.interpolator();
Set<IrisGenerator> gens = entry.getValue(); IrisGenerator[] groupGenerators = group.generators();
if (gens.isEmpty()) { if (groupGenerators.length == 0) {
continue; continue;
} }
IdentityHashMap<IrisBiome, NoiseBounds> cachedBounds = generatorBounds.get(interpolator); IdentityHashMap<IrisBiome, NoiseBounds> cachedBounds = generatorBounds.get(interpolator);
@@ -223,7 +223,7 @@ public class UpperDimensionContext implements DataProvider {
} }
double bMin = 0D; double bMin = 0D;
double bMax = 0D; double bMax = 0D;
for (IrisGenerator gen : gens) { for (IrisGenerator gen : groupGenerators) {
String key = gen.getLoadKey(); String key = gen.getLoadKey();
if (key == null || key.isBlank()) { if (key == null || key.isBlank()) {
continue; continue;
@@ -239,11 +239,14 @@ public class UpperDimensionContext implements DataProvider {
}); });
double hi = sampledBounds.max(); double hi = sampledBounds.max();
double lo = sampledBounds.min(); double lo = sampledBounds.min();
double d = 0; interpolatedHeight += IrisComplex.averageGeneratorHeights(
for (IrisGenerator gen : gens) { groupGenerators,
d += M.lerp(lo, hi, gen.getHeight(x, z, heightSeed + 239945)); lo,
} hi,
interpolatedHeight += d / gens.size(); x,
z,
heightSeed + 239945
);
} }
double proceduralHeight = Math.max( double proceduralHeight = Math.max(
Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), chunkHeight), Math.min(interpolatedHeight + fluidHeight + overlayStream.get(x, z), chunkHeight),
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.actuator;
import art.arcane.iris.spi.PlatformBlockState;
import art.arcane.volmlib.util.collection.KList;
final class HydrologyFluidLayerSelector {
private HydrologyFluidLayerSelector() {
}
static PlatformBlockState select(
KList<PlatformBlockState> seaLayers,
int depth,
PlatformBlockState fluid,
boolean hydrologyOwned
) {
if (!hydrologyOwned && seaLayers != null && seaLayers.hasIndex(depth)) {
return seaLayers.get(depth);
}
return fluid;
}
}
@@ -26,9 +26,10 @@ import art.arcane.iris.engine.decorator.IrisSurfaceDecorator;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.EngineAssignedActuator; import art.arcane.iris.engine.framework.EngineAssignedActuator;
import art.arcane.iris.engine.framework.EngineDecorator; import art.arcane.iris.engine.framework.EngineDecorator;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.river.RiverRouteState;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
import art.arcane.iris.util.common.data.B; import art.arcane.iris.util.common.data.B;
import art.arcane.iris.util.project.context.ChunkContext; import art.arcane.iris.util.project.context.ChunkContext;
import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.volmlib.util.documentation.BlockCoordinates;
@@ -52,7 +53,7 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
@Getter @Getter
private final EngineDecorator seaFloorDecorator; private final EngineDecorator seaFloorDecorator;
@Getter @Getter
private final EngineDecorator shoreLineDecorator; private final IrisShoreLineDecorator shoreLineDecorator;
private final boolean shouldRay; private final boolean shouldRay;
public IrisDecorantActuator(Engine engine) { public IrisDecorantActuator(Engine engine) {
@@ -66,10 +67,82 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
seaFloorDecorator = new IrisSeaFloorDecorator(getEngine()); seaFloorDecorator = new IrisSeaFloorDecorator(getEngine());
} }
static boolean shouldDecorateShoreline(IrisRiverSurfaceSample sample, int height) { static ShorelineDecorationMode shorelineDecorationMode(
return !sample.subterranean() HydrologyColumnSample sample,
&& height == Math.round(sample.waterSurfaceY()) int height,
&& (!sample.river().present() || sample.river().state() != RiverRouteState.DRY); int fluidHeight
) {
HydrologyColumnLayer surfaceLayer = sample == null
? null
: sample.primarySurfaceLayer().orElse(null);
if (surfaceLayer != null) {
return surfaceLayer.shore()
? ShorelineDecorationMode.ACCEPTED
: ShorelineDecorationMode.NONE;
}
return height == fluidHeight
? ShorelineDecorationMode.LEGACY
: ShorelineDecorationMode.NONE;
}
static boolean hasConnectedSurfaceWater(
Hunk<PlatformBlockState> output,
int x,
int z,
int height,
int fluidHeight
) {
return height < fluidHeight
&& height + 1 < output.getHeight()
&& IrisSurfaceDecorator.hasConnectedWater(output, x, height + 1, z);
}
static boolean hasConnectedWaterColumn(
Hunk<PlatformBlockState> output,
int x,
int z,
int lowerY,
int upperY
) {
int boundedLowerY = Math.max(0, lowerY);
int boundedUpperY = Math.min(output.getHeight() - 1, upperY);
if (boundedLowerY > boundedUpperY) {
return false;
}
for (int y = boundedLowerY; y <= boundedUpperY; y++) {
if (!IrisSurfaceDecorator.hasConnectedWater(output, x, y, z)) {
return false;
}
}
return true;
}
static void restoreUnsupportedAquaticPlacement(
Hunk<PlatformBlockState> output,
int x,
int y,
int z,
PlatformBlockState original
) {
PlatformBlockState placed = output.get(x, y, z);
if (placed != original && IrisSurfaceDecorator.isAquaticPlacement(placed)) {
output.set(x, y, z, original);
}
}
static boolean isFallingWaterfallThroat(HydrologyColumnSample sample) {
if (sample == null) {
return false;
}
for (HydrologyColumnLayer layer : sample.layers()) {
if ((layer.feature().type() == HydrologyFeatureType.WATERFALL
|| layer.feature().type() == HydrologyFeatureType.SINKHOLE)
&& layer.channel()
&& layer.fallingFluid()) {
return true;
}
}
return false;
} }
@BlockCoordinates @BlockCoordinates
@@ -80,7 +153,6 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
} }
PrecisionStopwatch p = PrecisionStopwatch.start(); PrecisionStopwatch p = PrecisionStopwatch.start();
for (int i = 0; i < output.getWidth(); i++) { for (int i = 0; i < output.getWidth(); i++) {
int height; int height;
int realX = Math.round(x + i); int realX = Math.round(x + i);
@@ -94,25 +166,57 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
height = context.getRoundedHeight(i, j); height = context.getRoundedHeight(i, j);
biome = context.getBiome().get(i, j); biome = context.getBiome().get(i, j);
cave = shouldRay ? context.getCave().get(i, j) : null; cave = shouldRay ? context.getCave().get(i, j) : null;
IrisRiverSurfaceSample riverSurface = getComplex().getRiverSurfaceStream().get(realX, realZ); HydrologyColumnSample hydrology = getComplex().getHydrologyRuntime() == null
int surfaceFluidHeight = (int) Math.round(riverSurface.waterSurfaceY()); ? null
: getComplex().getHydrologyRuntime().sample(realX, realZ).orElse(null);
if (isFallingWaterfallThroat(hydrology)) {
continue;
}
int surfaceFluidHeight = (int) Math.round(
getComplex().getRiverWaterSurfaceStream().get(realX, realZ));
if (biome.getDecorators().isEmpty() && (cave == null || cave.getDecorators().isEmpty())) { if (biome.getDecorators().isEmpty() && (cave == null || cave.getDecorators().isEmpty())) {
continue; continue;
} }
if (height < surfaceFluidHeight && PREDICATE_SOLID.test(output.get(i, height, j)) if (PREDICATE_SOLID.test(output.get(i, height, j))
&& height + 1 < output.getHeight() && B.isFluid(output.get(i, height + 1, j))) { && hasConnectedSurfaceWater(output, i, j, height, surfaceFluidHeight)) {
int seaSurfaceY = surfaceFluidHeight + 1;
PlatformBlockState seaSurfaceOriginal = seaSurfaceY < output.getHeight()
? output.get(i, seaSurfaceY, j)
: null;
getSeaSurfaceDecorator().decorate(i, j, getSeaSurfaceDecorator().decorate(i, j,
realX, Math.round(i + 1), Math.round(x + i - 1), realX, Math.round(i + 1), Math.round(x + i - 1),
realZ, Math.round(z + j + 1), Math.round(z + j - 1), realZ, Math.round(z + j + 1), Math.round(z + j - 1),
output, biome, surfaceFluidHeight, getEngine().getHeight()); output, biome, surfaceFluidHeight, getEngine().getHeight());
if (seaSurfaceY < output.getHeight()) {
restoreUnsupportedAquaticPlacement(
output, i, seaSurfaceY, j, seaSurfaceOriginal);
}
if (hasConnectedWaterColumn(output, i, j, height + 1, surfaceFluidHeight)) {
getSeaFloorDecorator().decorate(i, j, getSeaFloorDecorator().decorate(i, j,
realX, realZ, output, biome, height + 1, realX, realZ, output, biome, height + 1,
surfaceFluidHeight + 1); surfaceFluidHeight + 1);
} }
}
if (shouldDecorateShoreline(riverSurface, height)) { ShorelineDecorationMode shorelineMode = shorelineDecorationMode(
hydrology,
height,
surfaceFluidHeight
);
if (shorelineMode == ShorelineDecorationMode.ACCEPTED) {
getShoreLineDecorator().decorateAcceptedShore(
i,
j,
realX,
realZ,
output,
biome,
height,
getEngine().getHeight()
);
} else if (shorelineMode == ShorelineDecorationMode.LEGACY) {
getShoreLineDecorator().decorate(i, j, getShoreLineDecorator().decorate(i, j,
realX, Math.round(x + i + 1), Math.round(x + i - 1), realX, Math.round(x + i + 1), Math.round(x + i - 1),
realZ, Math.round(z + j + 1), Math.round(z + j - 1), realZ, Math.round(z + j + 1), Math.round(z + j - 1),
@@ -150,4 +254,10 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
private boolean shouldRayDecorate() { private boolean shouldRayDecorate() {
return false; // TODO CAVES return false; // TODO CAVES
} }
enum ShorelineDecorationMode {
NONE,
LEGACY,
ACCEPTED
}
} }
@@ -23,6 +23,8 @@ import art.arcane.iris.engine.framework.EngineAssignedActuator;
import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.UpperDimensionContext; import art.arcane.iris.engine.UpperDimensionContext;
import art.arcane.iris.engine.hydrology.HydrologyColumnLayer;
import art.arcane.iris.engine.hydrology.HydrologyColumnSample;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisOreGenerator; import art.arcane.iris.engine.object.IrisOreGenerator;
@@ -117,7 +119,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
} }
int topY = Math.min(hf, chunkHeight - 1); int topY = Math.min(hf, chunkHeight - 1);
PlatformBlockState fluid = complex.resolveSurfaceFluid(realX, realZ); HydrologyColumnSample hydrology = complex.getHydrologyRuntime() == null
? null
: complex.getHydrologyRuntime().sample(realX, realZ).orElse(null);
HydrologyColumnLayer hydrologyFluid = hydrology == null
? null
: hydrology.primarySurfaceFluidLayer().orElse(null);
PlatformBlockState fluid = hydrologyFluid == null
? complex.resolveSurfaceFluid(realX, realZ)
: complex.resolveHydrologyFluid(hydrologyFluid.profileKey(), realX, realZ);
PlatformBlockState rock = rockCache.get(xf, zf); PlatformBlockState rock = rockCache.get(xf, zf);
PlatformBlockState mappedSurfaceBlock = complex.getImageMapRuntime().sampleSurfaceBlock(realX, realZ); PlatformBlockState mappedSurfaceBlock = complex.getImageMapRuntime().sampleSurfaceBlock(realX, realZ);
KList<IrisOreGenerator> biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators(); KList<IrisOreGenerator> biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators();
@@ -159,15 +169,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
if (i > he && i <= hf) { if (i > he && i <= hf) {
int fdepth = hf - i; int fdepth = hf - i;
if (fblocks == null) { if (hydrologyFluid == null && fblocks == null) {
fblocks = biome.generateSeaLayers(realX, realZ, localRng, hf - he, data); fblocks = biome.generateSeaLayers(realX, realZ, localRng, hf - he, data);
} }
h.setRaw(xf, i, zf, HydrologyFluidLayerSelector.select(
if (fblocks.hasIndex(fdepth)) { fblocks,
h.setRaw(xf, i, zf, fblocks.get(fdepth)); fdepth,
} else { fluid,
h.setRaw(xf, i, zf, fluid); hydrologyFluid != null
} ));
continue; continue;
} }
@@ -54,6 +54,32 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
return; return;
} }
place(x, z, realX, realZ, data, biome, height, max);
}
public void decorateAcceptedShore(
int x,
int z,
int realX,
int realZ,
Hunk<PlatformBlockState> data,
IrisBiome biome,
int height,
int max
) {
place(x, z, realX, realZ, data, biome, height, max);
}
private void place(
int x,
int z,
int realX,
int realZ,
Hunk<PlatformBlockState> data,
IrisBiome biome,
int height,
int max
) {
RNG rng = getRNG(realX, realZ); RNG rng = getRNG(realX, realZ);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ); IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -71,6 +97,8 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
return; return;
} }
IrisSurfaceDecorator.AquaticPlacementSnapshot aquaticSnapshot = IrisSurfaceDecorator.captureAquaticPlacement(
decorator, getData(), data, x, z, height, max);
if (!decorator.isStacking()) { if (!decorator.isStacking()) {
int targetY = height + 1; int targetY = height + 1;
if (targetY >= data.getHeight() if (targetY >= data.getHeight()
@@ -80,6 +108,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData()); PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData());
if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) { if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
data.set(x, targetY, z, block); data.set(x, targetY, z, block);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
} }
return; return;
} }
@@ -100,6 +129,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData()); PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData());
if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) { if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
data.set(x, targetY, z, block); data.set(x, targetY, z, block);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
} }
return; return;
} }
@@ -123,5 +153,6 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
} }
data.set(x, targetY, z, block); data.set(x, targetY, z, block);
} }
aquaticSnapshot.restoreIfUnsupported(data, x, z);
} }
} }
@@ -18,11 +18,13 @@
package art.arcane.iris.engine.decorator; package art.arcane.iris.engine.decorator;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.object.InferredType; import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorationPart; import art.arcane.iris.engine.object.IrisDecorationPart;
import art.arcane.iris.engine.object.IrisDecorator; import art.arcane.iris.engine.object.IrisDecorator;
import art.arcane.iris.engine.object.IrisProceduralBlocks;
import art.arcane.iris.util.project.hunk.Hunk; import art.arcane.iris.util.project.hunk.Hunk;
import art.arcane.volmlib.util.documentation.BlockCoordinates; import art.arcane.volmlib.util.documentation.BlockCoordinates;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
@@ -64,6 +66,9 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
} }
boolean underwater = isUnderwater(inferredType, height, fluidHeight); boolean underwater = isUnderwater(inferredType, height, fluidHeight);
if (underwater && !hasFluidAbove(data, x, height, z)) {
return;
}
boolean caveSkipFluid = skipsFluid(inferredType); boolean caveSkipFluid = skipsFluid(inferredType);
RNG rng = getRNG(realX, realZ); RNG rng = getRNG(realX, realZ);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ); IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -72,6 +77,8 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
return; return;
} }
AquaticPlacementSnapshot aquaticSnapshot = captureAquaticPlacement(
decorator, getData(), data, x, z, height, max);
if (decorator.isStacking()) { if (decorator.isStacking()) {
DecoratorCore.PlaceOpts opts = DecoratorCore.SCRATCH_OPTS.get(); DecoratorCore.PlaceOpts opts = DecoratorCore.SCRATCH_OPTS.get();
opts.reset(); opts.reset();
@@ -79,11 +86,13 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
opts.fluidHeight = fluidHeight; opts.fluidHeight = fluidHeight;
opts.caveSkipFluid = caveSkipFluid; opts.caveSkipFluid = caveSkipFluid;
DecoratorCore.placeStackUp(decorator, x, z, realX, realZ, height, max, data, rng, getData(), opts); DecoratorCore.placeStackUp(decorator, x, z, realX, realZ, height, max, data, rng, getData(), opts);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
return; return;
} }
DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ, DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ,
data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle()); data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle());
aquaticSnapshot.restoreIfUnsupported(data, x, z);
} }
static boolean isUnderwater(InferredType inferredType, int height, int fluidHeight) { static boolean isUnderwater(InferredType inferredType, int height, int fluidHeight) {
@@ -93,4 +102,148 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
static boolean skipsFluid(InferredType inferredType) { static boolean skipsFluid(InferredType inferredType) {
return inferredType == InferredType.CAVE; return inferredType == InferredType.CAVE;
} }
static boolean hasFluidAbove(Hunk<PlatformBlockState> data, int x, int height, int z) {
return height + 1 < data.getHeight() && data.get(x, height + 1, z).isFluid();
}
public static boolean isAquaticPlacement(PlatformBlockState state) {
if (state == null) {
return false;
}
if (state.isWaterLogged()) {
return true;
}
String material = IrisProceduralBlocks.materialKey(state);
if (material.equals("minecraft:seagrass")
|| material.equals("minecraft:tall_seagrass")
|| material.equals("minecraft:kelp")
|| material.equals("minecraft:kelp_plant")
|| material.equals("minecraft:sea_pickle")) {
return true;
}
return material.startsWith("minecraft:")
&& material.contains("_coral")
&& !material.contains(":dead_");
}
public static boolean hasConnectedWater(Hunk<PlatformBlockState> data, int x, int y, int z) {
if (y < 0 || y >= data.getHeight() || !isWater(data.get(x, y, z))) {
return false;
}
return isWaterNeighbor(data, x - 1, y, z)
|| isWaterNeighbor(data, x + 1, y, z)
|| isWaterNeighbor(data, x, y, z - 1)
|| isWaterNeighbor(data, x, y, z + 1);
}
static AquaticPlacementSnapshot captureAquaticPlacement(
IrisDecorator decorator,
IrisData irisData,
Hunk<PlatformBlockState> data,
int x,
int z,
int height,
int max
) {
if (!mayPlaceAquatic(decorator, irisData)) {
return AquaticPlacementSnapshot.EMPTY;
}
int highestOffset = decorator.isStacking()
? maximumStackHeight(decorator, max)
: 2;
int lowerY = Math.max(0, height);
int upperY = Math.min(data.getHeight() - 1, height + highestOffset);
if (lowerY > upperY) {
return AquaticPlacementSnapshot.EMPTY;
}
PlatformBlockState[] originals = new PlatformBlockState[upperY - lowerY + 1];
boolean[] connectedWater = new boolean[originals.length];
for (int i = 0; i < originals.length; i++) {
int y = lowerY + i;
originals[i] = data.get(x, y, z);
connectedWater[i] = hasConnectedWater(data, x, y, z);
}
return new AquaticPlacementSnapshot(lowerY, originals, connectedWater);
}
private static boolean mayPlaceAquatic(IrisDecorator decorator, IrisData irisData) {
PlatformBlockState[] palette = decorator.getBlockDataArray(irisData);
if (palette != null) {
for (PlatformBlockState state : palette) {
if (isAquaticPlacement(state)) {
return true;
}
}
}
PlatformBlockState[] topPalette = decorator.getBlockDataTopsArray(irisData);
if (topPalette != null) {
for (PlatformBlockState state : topPalette) {
if (isAquaticPlacement(state)) {
return true;
}
}
}
return decorator.getForceBlock() != null
&& isAquaticPlacement(decorator.getForceBlock().getBlockData(irisData));
}
private static int maximumStackHeight(IrisDecorator decorator, int max) {
if (decorator.isScaleStack()) {
return Math.max(1, decorator.getAbsoluteMaxStack());
}
return Math.max(1, Math.min(Math.max(1, max), decorator.getStackMax()));
}
private static boolean isWaterNeighbor(Hunk<PlatformBlockState> data, int x, int y, int z) {
if (x < 0 || x >= data.getWidth() || z < 0 || z >= data.getDepth()) {
return false;
}
return isWater(data.get(x, y, z));
}
private static boolean isWater(PlatformBlockState state) {
return state != null && state.isWater();
}
static final class AquaticPlacementSnapshot {
private static final AquaticPlacementSnapshot EMPTY = new AquaticPlacementSnapshot(
0, new PlatformBlockState[0], new boolean[0]);
private final int lowerY;
private final PlatformBlockState[] originals;
private final boolean[] connectedWater;
private AquaticPlacementSnapshot(
int lowerY,
PlatformBlockState[] originals,
boolean[] connectedWater
) {
this.lowerY = lowerY;
this.originals = originals;
this.connectedWater = connectedWater;
}
void restoreIfUnsupported(Hunk<PlatformBlockState> data, int x, int z) {
if (!containsUnsupportedAquatic(data, x, z)) {
return;
}
for (int i = 0; i < originals.length; i++) {
data.set(x, lowerY + i, z, originals[i]);
}
}
private boolean containsUnsupportedAquatic(Hunk<PlatformBlockState> data, int x, int z) {
for (int i = 0; i < originals.length; i++) {
PlatformBlockState placed = data.get(x, lowerY + i, z);
if (placed != originals[i] && isAquaticPlacement(placed) && !connectedWater[i]) {
return true;
}
}
return false;
}
}
} }
@@ -42,8 +42,8 @@ import art.arcane.iris.engine.object.IrisPosition;
import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisStructure; import art.arcane.iris.engine.object.IrisStructure;
import art.arcane.iris.engine.object.IrisWorld; import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage;
import art.arcane.iris.spi.IrisLogging; import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBiome; import art.arcane.iris.spi.PlatformBiome;
import art.arcane.iris.spi.PlatformBlockState; import art.arcane.iris.spi.PlatformBlockState;
@@ -246,7 +246,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
@BlockCoordinates @BlockCoordinates
default IrisBiome getCaveOrMantleBiome(int x, int y, int z) { default IrisBiome getCaveOrMantleBiome(int x, int y, int z) {
RiverCaveHydrology hydrology = RiverCaveHydrologyStorage.getIfPresent( HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent(
getMantle().getMantle(), x, y, z); getMantle().getMantle(), x, y, z);
if (hydrology != null && !hydrology.floodedBiomeKey().isEmpty()) { if (hydrology != null && !hydrology.floodedBiomeKey().isEmpty()) {
IrisBiome biome = getData().getBiomeLoader().load(hydrology.floodedBiomeKey()); IrisBiome biome = getData().getBiomeLoader().load(hydrology.floodedBiomeKey());
@@ -455,6 +455,10 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
default PlacedObject getObjectPlacement(int x, int y, int z, MantleChunk<Matter> chunk) { default PlacedObject getObjectPlacement(int x, int y, int z, MantleChunk<Matter> chunk) {
String objectAt = chunk.get(x & 15, y, z & 15, String.class); String objectAt = chunk.get(x & 15, y, z & 15, String.class);
return resolveObjectPlacementMarker(x, z, objectAt);
}
default PlacedObject resolveObjectPlacementMarker(int x, int z, @Nullable String objectAt) {
if (objectAt == null || objectAt.isEmpty()) { if (objectAt == null || objectAt.isEmpty()) {
return null; return null;
} }
@@ -562,7 +566,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
return; return;
} }
if (IrisSettings.get().getPerformance().isTrimMantleInStudio() || !isStudio()) { if (IrisSettings.get().getPerformance().isTrimMantleInStudio() || !isStudio()) {
getMantle().cleanupChunk(x, z); getMantle().cleanupChunksCoveredBy(
x,
z,
false,
EngineMantle.ChunkCleanupCallback.NONE
);
} }
} }
} }
@@ -19,6 +19,7 @@
package art.arcane.iris.engine.framework; package art.arcane.iris.engine.framework;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.mantle.MantleComponent;
import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KList;
public interface EnginePlatformHooks { public interface EnginePlatformHooks {
@@ -70,4 +71,8 @@ public interface EnginePlatformHooks {
default boolean shouldBypassMantleStages(Engine engine) { default boolean shouldBypassMantleStages(Engine engine) {
return false; return false;
} }
default boolean shouldGenerateMantleComponent(Engine engine, MantleComponent component) {
return true;
}
} }
@@ -328,7 +328,11 @@ public final class HintedLocator<T> implements Locator<T> {
} }
if (!surfaceRegions.isEmpty()) { if (!surfaceRegions.isEmpty()) {
return SearchPlan.of(surfaceCoarse(complex, surfaceRegions, landHosts, seaHosts, shoreHosts), BIOME_STRIDE_CHUNKS, null); return SearchPlan.of(
surfaceCoarse(complex, surfaceRegions, landHosts, seaHosts, shoreHosts),
BIOME_STRIDE_CHUNKS,
null
);
} }
if (!caveRegions.isEmpty()) { if (!caveRegions.isEmpty()) {
@@ -341,6 +345,11 @@ public final class HintedLocator<T> implements Locator<T> {
return SearchPlan.of(coarse, BIOME_STRIDE_CHUNKS, caveExact); return SearchPlan.of(coarse, BIOME_STRIDE_CHUNKS, caveExact);
} }
for (IrisBiome reachableBiome : engine.getDimension().getReachableBiomes(engine)) {
if (reachableBiome != null && biomeKey.equals(reachableBiome.getLoadKey())) {
return SearchPlan.unpruned();
}
}
return SearchPlan.impossible(); return SearchPlan.impossible();
} }
@@ -576,6 +585,36 @@ public final class HintedLocator<T> implements Locator<T> {
return false; return false;
} }
private KList<IrisBiome> closure(String rootKey) {
KList<IrisBiome> biomes = new KList<>();
if (rootKey == null) {
return biomes;
}
KSet<String> visited = new KSet<>();
ArrayDeque<String> queue = new ArrayDeque<>();
queue.add(rootKey);
while (!queue.isEmpty()) {
String key = queue.poll();
if (!visited.add(key)) {
continue;
}
IrisBiome biome = load(key);
if (biome == null) {
continue;
}
biomes.add(biome);
for (String child : biome.getChildren()) {
if (child != null && !visited.contains(child)) {
queue.add(child);
}
}
}
return biomes;
}
private boolean closurePlacesObject(String rootKey, String objectKey) { private boolean closurePlacesObject(String rootKey, String objectKey) {
if (rootKey == null || objectKey == null) { if (rootKey == null || objectKey == null) {
return false; return false;
@@ -21,6 +21,7 @@ package art.arcane.iris.engine.framework;
import art.arcane.iris.core.IrisSettings; import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.nms.container.BlockPos; import art.arcane.iris.core.nms.container.BlockPos;
import art.arcane.iris.core.nms.container.Pair; import art.arcane.iris.core.nms.container.Pair;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisObject; import art.arcane.iris.engine.object.IrisObject;
import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRegion;
@@ -53,10 +54,22 @@ public interface Locator<T> {
} }
static Locator<IrisBiome> surfaceBiome(String loadKey) { static Locator<IrisBiome> surfaceBiome(String loadKey) {
Locator<IrisBiome> exact = (e, c) -> e.getSurfaceBiome((c.getX() << 4) + 8, (c.getZ() << 4) + 8).getLoadKey().equals(loadKey); Locator<IrisBiome> exact = (e, c) -> chunkContainsSurfaceBiome(e, c, loadKey);
return new HintedLocator<>(exact, (engine) -> HintedLocator.biomePlan(engine, loadKey)); return new HintedLocator<>(exact, (engine) -> HintedLocator.biomePlan(engine, loadKey));
} }
static boolean chunkContainsSurfaceBiome(Engine engine, Position2 chunk, String loadKey) {
int minimumX = chunk.getX() << 4;
int minimumZ = chunk.getZ() << 4;
IrisBiome center = engine.getSurfaceBiome(minimumX + 8, minimumZ + 8);
if (center != null && loadKey.equals(center.getLoadKey())) {
return true;
}
IrisHydrologyRuntime hydrology = engine.getComplex().getHydrologyRuntime();
return hydrology != null
&& hydrology.hasAcceptedSurfaceBiomeInChunk(loadKey, chunk.getX(), chunk.getZ());
}
static Locator<art.arcane.iris.engine.object.IrisStructure> structure(String key) { static Locator<art.arcane.iris.engine.object.IrisStructure> structure(String key) {
return (e, c) -> IrisStructureLocator.startsInChunk(e, key, c.getX(), c.getZ()); return (e, c) -> IrisStructureLocator.startsInChunk(e, key, c.getX(), c.getZ());
} }
@@ -3,8 +3,8 @@ package art.arcane.iris.engine.framework;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisStructureAnchorMode; import art.arcane.iris.engine.object.IrisStructureAnchorMode;
import art.arcane.iris.engine.object.IrisStructurePlacement; import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.engine.river.cave.RiverCaveHydrology; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage; import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage;
import art.arcane.volmlib.util.math.RNG; import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.matter.MatterCavern; import art.arcane.volmlib.util.matter.MatterCavern;
@@ -288,7 +288,7 @@ public final class StructureCaveAnchorResolver {
int mantleY, int mantleY,
int blockZ int blockZ
) { ) {
RiverCaveHydrology hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ);
MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ); MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ);
return acceptsAnchorFluid( return acceptsAnchorFluid(
placement.isUnderwater(), placement.isUnderwater(),
@@ -310,7 +310,7 @@ public final class StructureCaveAnchorResolver {
static boolean acceptsAnchorFluid( static boolean acceptsAnchorFluid(
boolean underwater, boolean underwater,
MatterCavern cavern, MatterCavern cavern,
RiverCaveHydrology hydrology, HydrologyCaveCell hydrology,
int mantleY, int mantleY,
int defaultLavaHeight int defaultLavaHeight
) { ) {
@@ -332,12 +332,12 @@ public final class StructureCaveAnchorResolver {
private static MatterCavern cavernAt(Engine engine, int blockX, int mantleY, int blockZ) { private static MatterCavern cavernAt(Engine engine, int blockX, int mantleY, int blockZ) {
MatterCavern baseline = engine.getMantle().getMantle() MatterCavern baseline = engine.getMantle().getMantle()
.get(blockX, mantleY, blockZ, MatterCavern.class); .get(blockX, mantleY, blockZ, MatterCavern.class);
RiverCaveHydrology hydrology = hydrologyAt(engine, blockX, mantleY, blockZ); HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ);
return hydrology == null ? baseline : hydrology.asCavern(); return hydrology == null ? baseline : hydrology.asCavern();
} }
private static RiverCaveHydrology hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) { private static HydrologyCaveCell hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) {
return RiverCaveHydrologyStorage.getIfPresent( return HydrologyCaveStorage.getIfPresent(
engine.getMantle().getMantle(), blockX, mantleY, blockZ); engine.getMantle().getMantle(), blockX, mantleY, blockZ);
} }
@@ -20,21 +20,29 @@ package art.arcane.iris.engine.framework.render;
import art.arcane.iris.engine.IrisComplex; import art.arcane.iris.engine.IrisComplex;
import art.arcane.iris.engine.framework.Engine; import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.hydrology.HydrologyCandidateKind;
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticCandidate;
import art.arcane.iris.engine.hydrology.HydrologyDiagnosticRenderSample;
import art.arcane.iris.engine.hydrology.HydrologyFeatureRef;
import art.arcane.iris.engine.hydrology.HydrologyFeatureType;
import art.arcane.iris.engine.hydrology.HydrologyRenderSample;
import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime;
import art.arcane.iris.engine.object.IrisBiome; import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisBiomeGeneratorLink; import art.arcane.iris.engine.object.IrisBiomeGeneratorLink;
import art.arcane.iris.engine.object.IrisDimension; import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion; import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.river.RiverSample;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.engine.river.runtime.IrisRiverRuntime;
import art.arcane.iris.util.project.stream.ProceduralStream; import art.arcane.iris.util.project.stream.ProceduralStream;
import java.awt.Color; import java.awt.Color;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt; import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator;
import java.util.IdentityHashMap; import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.CancellationException; import java.util.concurrent.CancellationException;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
@@ -43,11 +51,22 @@ public final class IrisRenderer {
private static final int BLUE = new Color(45, 91, 156).getRGB(); private static final int BLUE = new Color(45, 91, 156).getRGB();
private static final int YELLOW = new Color(211, 164, 67).getRGB(); private static final int YELLOW = new Color(211, 164, 67).getRGB();
private static final int GREEN = new Color(78, 137, 83).getRGB(); private static final int GREEN = new Color(78, 137, 83).getRGB();
private static final int HEADWATER = new Color(99, 184, 230).getRGB();
private static final int HEADWATER_DIRECTION = new Color(13, 38, 55).getRGB();
private static final int RIVER_CHANNEL = new Color(48, 112, 190).getRGB(); private static final int RIVER_CHANNEL = new Color(48, 112, 190).getRGB();
private static final int RIVER_RIFFLE = new Color(96, 166, 214).getRGB();
private static final int RIVER_CASCADE = new Color(135, 204, 232).getRGB();
private static final int SUBTERRANEAN_CHANNEL = new Color(102, 86, 196).getRGB();
private static final int RIDGE_TUNNEL = new Color(120, 99, 174).getRGB();
private static final int COASTAL_GROTTO = new Color(51, 174, 174).getRGB();
private static final int INLAND_GROTTO = new Color(116, 76, 150).getRGB();
private static final int RIVER_SINKHOLE = new Color(176, 146, 232).getRGB();
private static final int RIVER_MOUTH = new Color(54, 164, 205).getRGB(); private static final int RIVER_MOUTH = new Color(54, 164, 205).getRGB();
private static final int RIVER_BANK = new Color(92, 146, 78).getRGB(); private static final int RIVER_WATERFALL = new Color(190, 236, 255).getRGB();
private static final int DRY_CHANNEL = new Color(171, 128, 68).getRGB(); private static final int CONFIRMED_DEEP_LAVA = new Color(194, 50, 22).getRGB();
private static final int DRY_BANK = new Color(132, 105, 62).getRGB(); private static final int PROJECTED_SOURCE = new Color(225, 75, 178).getRGB();
private static final int PROJECTED_OUTLET = new Color(235, 145, 54).getRGB();
private static final int PROJECTED_DEEP_FLUID = new Color(175, 70, 118).getRGB();
private static final int NO_RIVER = new Color(28, 31, 38).getRGB(); private static final int NO_RIVER = new Color(28, 31, 38).getRGB();
private static final int DEEP_WATER = new Color(20, 48, 92).getRGB(); private static final int DEEP_WATER = new Color(20, 48, 92).getRGB();
private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB(); private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB();
@@ -150,15 +169,53 @@ public final class IrisRenderer {
return image; return image;
} }
public static int riverColor(RiverSection section) { public static int waterfallColor() {
Objects.requireNonNull(section, "section"); return RIVER_WATERFALL;
return switch (section) { }
case CHANNEL -> RIVER_CHANNEL;
public static int surfaceWaterColor() {
return RIVER_CHANNEL;
}
public static int subterraneanColor() {
return SUBTERRANEAN_CHANNEL;
}
public static int confirmedDeepLavaColor() {
return CONFIRMED_DEEP_LAVA;
}
public static int hydrologyFeatureColor(HydrologyFeatureType type) {
Objects.requireNonNull(type, "type");
return switch (type) {
case SURFACE_POOL -> RIVER_CHANNEL;
case RIFFLE -> RIVER_RIFFLE;
case CASCADE -> RIVER_CASCADE;
case WATERFALL -> RIVER_WATERFALL;
case SINKHOLE -> RIVER_SINKHOLE;
case RIDGE_BORE -> RIDGE_TUNNEL;
case UNDERGROUND_POOL, UNDERGROUND_DROP -> SUBTERRANEAN_CHANNEL;
case COASTAL_GROTTO -> COASTAL_GROTTO;
case INLAND_GROTTO -> INLAND_GROTTO;
case MOUTH -> RIVER_MOUTH; case MOUTH -> RIVER_MOUTH;
case BANK -> RIVER_BANK; case DEEP_POOL, DEEP_CHANNEL -> CONFIRMED_DEEP_LAVA;
case DRY_CHANNEL -> DRY_CHANNEL; };
case DRY_BANK -> DRY_BANK; }
case NONE -> NO_RIVER;
public static int headwaterColor() {
return HEADWATER;
}
public static int headwaterDirectionColor() {
return HEADWATER_DIRECTION;
}
public static int hydrologyDiagnosticColor(HydrologyCandidateKind kind) {
Objects.requireNonNull(kind, "kind");
return switch (kind) {
case SOURCE -> PROJECTED_SOURCE;
case OUTLET -> PROJECTED_OUTLET;
case DEEP_FLUID -> PROJECTED_DEEP_FLUID;
}; };
} }
@@ -277,10 +334,11 @@ public final class IrisRenderer {
BooleanSupplier cancelled, BooleanSupplier cancelled,
boolean composite boolean composite
) { ) {
IrisRiverRuntime runtime = complex.getRiverRuntime(); IrisHydrologyRuntime runtime = complex.getHydrologyRuntime();
if (runtime == null) { if (runtime == null) {
return; return;
} }
Map<Long, HydrologyFeatureRef> headwaters = new LinkedHashMap<>();
int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step))); int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step)));
int blockPixels = Integer.highestOneBit(maximumPixels); int blockPixels = Integer.highestOneBit(maximumPixels);
for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) { for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
@@ -295,6 +353,8 @@ public final class IrisRenderer {
runtime, runtime,
cancelled, cancelled,
composite, composite,
!composite,
headwaters,
pixelX, pixelX,
pixelZ, pixelZ,
Math.min(blockPixels, resolution - pixelX), Math.min(blockPixels, resolution - pixelX),
@@ -302,6 +362,7 @@ public final class IrisRenderer {
); );
} }
} }
renderHeadwaterDirections(pixels, resolution, startX, startZ, step, headwaters);
} }
private static void renderRiverBlock( private static void renderRiverBlock(
@@ -310,58 +371,163 @@ public final class IrisRenderer {
double startX, double startX,
double startZ, double startZ,
double step, double step,
IrisRiverRuntime runtime, IrisHydrologyRuntime runtime,
BooleanSupplier cancelled, BooleanSupplier cancelled,
boolean composite, boolean composite,
boolean diagnostics,
Map<Long, HydrologyFeatureRef> headwaters,
int pixelX, int pixelX,
int pixelZ, int pixelZ,
int width, int width,
int height int height
) { ) {
checkCancelled(cancelled); checkCancelled(cancelled);
RiverSample sample = runtime.sampleFootprint( HydrologyRenderSample sample = runtime.sampleRenderFootprint(
startX + pixelX * step, startX + pixelX * step,
startZ + pixelZ * step, startZ + pixelZ * step,
startX + (pixelX + width) * step, startX + (pixelX + width) * step,
startZ + (pixelZ + height) * step startZ + (pixelZ + height) * step
); );
if (!sample.present()) { HydrologyDiagnosticRenderSample diagnosticSample = diagnostics
? runtime.sampleDiagnosticFootprint(
startX + pixelX * step,
startZ + pixelZ * step,
startX + (pixelX + width) * step,
startZ + (pixelZ + height) * step
)
: null;
collectHeadwaters(sample, headwaters);
if (!sample.present() && (diagnosticSample == null || !diagnosticSample.present())) {
return; return;
} }
if (width == 1 && height == 1) { if (width == 1 && height == 1) {
int index = pixelZ * resolution + pixelX; int index = pixelZ * resolution + pixelX;
int color = riverColor(sample.section()); int color = sample.present()
pixels[index] = composite ? riverCompositeColor(pixels[index], sample.section(), color) : color; ? hydrologyColor(sample)
: hydrologyDiagnosticColor(diagnosticSample);
pixels[index] = composite
? blend(pixels[index], color, 0.72D)
: color;
return; return;
} }
int leftWidth = Math.max(1, width / 2); int leftWidth = Math.max(1, width / 2);
int rightWidth = width - leftWidth; int rightWidth = width - leftWidth;
int topHeight = Math.max(1, height / 2); int topHeight = Math.max(1, height / 2);
int bottomHeight = height - topHeight; int bottomHeight = height - topHeight;
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX, pixelZ, leftWidth, topHeight); pixelX, pixelZ, leftWidth, topHeight);
if (rightWidth > 0) { if (rightWidth > 0) {
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX + leftWidth, pixelZ, rightWidth, topHeight); pixelX + leftWidth, pixelZ, rightWidth, topHeight);
} }
if (bottomHeight > 0) { if (bottomHeight > 0) {
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX, pixelZ + topHeight, leftWidth, bottomHeight); pixelX, pixelZ + topHeight, leftWidth, bottomHeight);
if (rightWidth > 0) { if (rightWidth > 0) {
renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, renderRiverBlock(pixels, resolution, startX, startZ, step, runtime, cancelled, composite, diagnostics,
headwaters,
pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight); pixelX + leftWidth, pixelZ + topHeight, rightWidth, bottomHeight);
} }
} }
} }
private static int riverCompositeColor(int base, RiverSection section, int river) { private static void collectHeadwaters(
return switch (section) { HydrologyRenderSample sample,
case CHANNEL, MOUTH -> river; Map<Long, HydrologyFeatureRef> headwaters
case BANK -> blend(base, river, 0.68D); ) {
case DRY_CHANNEL -> blend(base, river, 0.88D); for (HydrologyFeatureRef feature : sample.features()) {
case DRY_BANK -> blend(base, river, 0.58D); if (!feature.source()) {
case NONE -> base; continue;
}; }
HydrologyFeatureRef existing = headwaters.putIfAbsent(feature.id(), feature);
if (existing != null && !existing.equals(feature)) {
throw new IllegalStateException("Hydrology headwater feature id collision in renderer footprint.");
}
}
}
private static void renderHeadwaterDirections(
int[] pixels,
int resolution,
double startX,
double startZ,
double step,
Map<Long, HydrologyFeatureRef> headwaters
) {
if (resolution < 5 || headwaters.isEmpty()) {
return;
}
ArrayList<HydrologyFeatureRef> ordered = new ArrayList<>(headwaters.values());
ordered.sort(Comparator.comparingLong(HydrologyFeatureRef::id));
for (HydrologyFeatureRef feature : ordered) {
int pixelX = worldToPixel(feature.x(), startX, step, resolution);
int pixelZ = worldToPixel(feature.z(), startZ, step, resolution);
drawHeadwaterDirection(
pixels,
resolution,
pixelX,
pixelZ,
feature.flowDeltaX(),
feature.flowDeltaZ()
);
}
}
private static int worldToPixel(int coordinate, double start, double step, int resolution) {
int pixel = (int) StrictMath.floor((coordinate + 0.5D - start) / step);
int markerRadius = 2;
return Math.max(markerRadius, Math.min(resolution - markerRadius - 1, pixel));
}
private static void drawHeadwaterDirection(
int[] pixels,
int resolution,
int centerX,
int centerZ,
int flowX,
int flowZ
) {
int markerRadius = Math.min(2, (resolution - 1) / 2);
for (int deltaZ = -markerRadius; deltaZ <= markerRadius; deltaZ++) {
for (int deltaX = -markerRadius; deltaX <= markerRadius; deltaX++) {
setPixel(pixels, resolution, centerX + deltaX, centerZ + deltaZ, HEADWATER);
}
}
if (flowX == 0 && flowZ == 0) {
setPixel(pixels, resolution, centerX, centerZ, HEADWATER_DIRECTION);
return;
}
setPixel(pixels, resolution, centerX - flowX * 2, centerZ - flowZ * 2, HEADWATER_DIRECTION);
setPixel(pixels, resolution, centerX - flowX, centerZ - flowZ, HEADWATER_DIRECTION);
setPixel(pixels, resolution, centerX, centerZ, HEADWATER_DIRECTION);
setPixel(pixels, resolution, centerX + flowX, centerZ + flowZ, HEADWATER_DIRECTION);
int tipX = centerX + flowX * 2;
int tipZ = centerZ + flowZ * 2;
setPixel(pixels, resolution, tipX, tipZ, HEADWATER_DIRECTION);
setPixel(
pixels,
resolution,
tipX - flowX - flowZ,
tipZ - flowZ + flowX,
HEADWATER_DIRECTION
);
setPixel(
pixels,
resolution,
tipX - flowX + flowZ,
tipZ - flowZ - flowX,
HEADWATER_DIRECTION
);
}
private static void setPixel(int[] pixels, int resolution, int x, int z, int color) {
if (x < 0 || x >= resolution || z < 0 || z >= resolution) {
return;
}
pixels[z * resolution + x] = color;
} }
private PixelShader biomeShader(ProceduralStream<IrisBiome> stream, RenderType currentType) { private PixelShader biomeShader(ProceduralStream<IrisBiome> stream, RenderType currentType) {
@@ -399,19 +565,47 @@ public final class IrisRenderer {
} }
private int riverColor(IrisComplex complex, double x, double z, double step) { private int riverColor(IrisComplex complex, double x, double z, double step) {
IrisRiverRuntime runtime = complex.getRiverRuntime(); IrisHydrologyRuntime runtime = complex.getHydrologyRuntime();
if (runtime == null) { if (runtime == null) {
return riverColor(RiverSection.NONE); return NO_RIVER;
} }
double endX = x + step; double endX = x + step;
double endZ = z + step; double endZ = z + step;
RiverSample sample = runtime.sampleFootprint( HydrologyRenderSample sample = runtime.sampleRenderFootprint(
StrictMath.min(x, endX), StrictMath.min(x, endX),
StrictMath.min(z, endZ), StrictMath.min(z, endZ),
StrictMath.max(x, endX), StrictMath.max(x, endX),
StrictMath.max(z, endZ) StrictMath.max(z, endZ)
); );
return riverColor(sample.section()); if (sample.present()) {
return hydrologyColor(sample);
}
return hydrologyDiagnosticColor(runtime.sampleDiagnosticFootprint(
StrictMath.min(x, endX),
StrictMath.min(z, endZ),
StrictMath.max(x, endX),
StrictMath.max(z, endZ)
));
}
static int hydrologyColor(HydrologyRenderSample sample) {
Objects.requireNonNull(sample, "sample");
HydrologyFeatureRef feature = sample.primaryFeature().orElse(null);
if (feature == null) {
return NO_RIVER;
}
if (feature.source()) {
return HEADWATER;
}
return hydrologyFeatureColor(feature.type());
}
static int hydrologyDiagnosticColor(HydrologyDiagnosticRenderSample sample) {
Objects.requireNonNull(sample, "sample");
HydrologyDiagnosticCandidate candidate = sample.candidates().isEmpty()
? null
: sample.candidates().getFirst();
return candidate == null ? NO_RIVER : hydrologyDiagnosticColor(candidate.kind());
} }
private int continentColor(double x, double z) { private int continentColor(double x, double z) {
@@ -0,0 +1,36 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
public record DrainageEdge(
long id,
long upstreamNodeId,
long downstreamNodeId,
long outletId,
double cost,
int contributingSurfaceSources,
int contributingUndergroundSources,
List<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.engine.hydrology;
import java.util.Objects;
public record DrainageNode(
long id,
int x,
int z,
HydrologyTerrainSample terrain,
double potential,
long outletId
) {
public DrainageNode {
Objects.requireNonNull(terrain, "terrain");
if (!Double.isFinite(potential) || potential < 0D) {
throw new IllegalArgumentException("Drainage potential must be finite and non-negative.");
}
if (terrain.ocean()) {
throw new IllegalArgumentException("Drainage nodes must remain landward of the first ocean column.");
}
}
public HydrologyPoint naturalPoint() {
return new HydrologyPoint(x, terrain.naturalHeight(), z);
}
}
@@ -0,0 +1,66 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
public record HydraulicSegment(
long id,
long courseId,
HydrologyFeatureType type,
int upstreamHeadY,
int downstreamHeadY,
int width,
int depth,
boolean fallingFluid,
boolean receivingPool,
List<HydrologyPoint> centerline
) {
public HydraulicSegment {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(centerline, "centerline");
centerline = List.copyOf(centerline);
if (centerline.isEmpty()) {
throw new IllegalArgumentException("A hydraulic segment requires a centerline.");
}
if (upstreamHeadY < downstreamHeadY) {
throw new IllegalArgumentException("Hydraulic heads cannot rise downstream.");
}
if (width <= 0 || depth <= 0) {
throw new IllegalArgumentException("Hydraulic segment width and depth must be positive.");
}
int drop = upstreamHeadY - downstreamHeadY;
if (fallingFluid && drop == 0) {
throw new IllegalArgumentException("Falling fluid requires a positive head transition.");
}
if (drop > 0 && !type.isDrop()) {
throw new IllegalArgumentException("A positive head transition requires a drop segment type.");
}
if (drop == 0 && type.isDrop()) {
throw new IllegalArgumentException("A drop segment type requires a positive head transition.");
}
if (drop > 0 && !fallingFluid) {
if (centerline.getFirst().y() != upstreamHeadY || centerline.getLast().y() != downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must span its declared heads.");
}
int previousHead = upstreamHeadY;
for (HydrologyPoint point : centerline) {
if (point.y() > previousHead || point.y() < downstreamHeadY) {
throw new IllegalArgumentException("A graded head transition must descend monotonically.");
}
previousHead = point.y();
}
}
}
public int drop() {
return upstreamHeadY - downstreamHeadY;
}
public HydrologyPoint start() {
return centerline.getFirst();
}
public HydrologyPoint end() {
return centerline.getLast();
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.engine.hydrology;
public enum HydrologyCandidateKind {
SOURCE,
OUTLET,
DEEP_FLUID
}
@@ -0,0 +1,25 @@
package art.arcane.iris.engine.hydrology;
public enum HydrologyCandidateRejection {
NO_LEGAL_OUTLET,
NO_DRAINAGE_PATH,
ROUTE_LIMIT,
COURSE_TOO_SHORT,
SOURCE_QUOTA,
SOURCE_SPACING,
OUTLET_LIMIT,
RIDGE_CONTAINMENT,
OUTLET_LEVEL,
CAVE_CONTAINMENT,
VOLUME_LIMIT,
SURFACE_HEAD_RANGE,
SURFACE_HEAD_DISTRIBUTION,
SURFACE_RIDGE_TUNNEL_LIMIT,
SURFACE_EXPOSURE,
SURFACE_DROP_UNSUPPORTED,
SURFACE_CORRIDOR_UNSUPPORTED,
SURFACE_TRANSITION_UNSUPPORTED,
SURFACE_SEGMENT_UNSUPPORTED,
SURFACE_SINKHOLE_CLEARANCE,
SURFACE_SHAPE_UNSUPPORTED
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,13 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
@FunctionalInterface
public interface HydrologyCaveVoxelViewFactory {
CaveVoxelView create(PlannedSurface plannedSurface);
@FunctionalInterface
interface PlannedSurface {
int resolve(int x, int z, int naturalHeight);
}
}
@@ -0,0 +1,89 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record HydrologyColumnLayer(
HydrologyFeatureRef feature,
int bedY,
int fluidHeadY,
int ceilingY,
boolean channel,
boolean shore,
boolean grading,
boolean connectedFluid,
boolean fallingFluid,
boolean receivingPool,
boolean terrainOwned,
boolean fluidOwned,
boolean oceanApron,
String profileKey,
String surfaceBiomeKey,
String mouthBiomeKey,
String shoreBiomeKey,
String dryBiomeKey,
String floodedCaveBiomeKey
) {
public HydrologyColumnLayer {
Objects.requireNonNull(feature, "feature");
profileKey = requireKey(profileKey, "profileKey");
surfaceBiomeKey = requireKey(surfaceBiomeKey, "surfaceBiomeKey");
mouthBiomeKey = requireKey(mouthBiomeKey, "mouthBiomeKey");
shoreBiomeKey = requireKey(shoreBiomeKey, "shoreBiomeKey");
dryBiomeKey = requireKey(dryBiomeKey, "dryBiomeKey");
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 null;
}
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;
}
public boolean publishesSurfaceFluid() {
return feature.type().isSurface()
&& !oceanApron
&& channel
&& fluidOwned
&& (terrainOwned || fallingFluid)
&& (feature.type().isDrop() || fallingFluid || receivingPool)
&& 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.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
public record HydrologyColumnSample(
int x,
int z,
int naturalHeight,
int seaLevel,
boolean ocean,
String parentBiomeKey,
List<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())) {
throw new IllegalArgumentException(
"Naturally submerged columns cannot contain owned surface hydrology writes."
);
}
}
}
for (HydrologyColumnLayer layer : layers) {
if (layer.feature().type().isSurface()
&& !layer.oceanApron()
&& layer.channel()
&& layer.fluidOwned()
&& layer.fluidHeadY() >= naturalHeight) {
throw new IllegalArgumentException("Owned surface fluid must remain below natural terrain.");
}
}
}
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 selectSurfaceLayer(false);
}
public Optional<HydrologyColumnLayer> primarySurfaceFluidLayer() {
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() {
Optional<HydrologyColumnLayer> layer = primarySurfaceLayer();
if (layer.isEmpty()) {
return naturalHeight;
}
HydrologyColumnLayer primary = layer.get();
if (primary.channel()) {
return primary.bedY();
}
int terrainHeight = primary.bedY();
for (HydrologyColumnLayer candidate : layers) {
if (candidate.feature().type().isSurface()
&& candidate.terrainOwned()
&& !candidate.channel()) {
terrainHeight = Math.max(terrainHeight, candidate.bedY());
}
}
return terrainHeight;
}
public HydrologyRenderSample renderSample() {
ArrayList<HydrologyFeatureRef> features = new ArrayList<>(layers.size());
for (HydrologyColumnLayer layer : layers) {
features.add(layer.feature());
}
return new HydrologyRenderSample(x, z, features);
}
private Optional<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;
}
} else if (!layer.terrainOwned()) {
continue;
}
if (selected == null || SURFACE_LAYER_ORDER.compare(layer, selected) < 0) {
selected = layer;
}
}
return Optional.ofNullable(selected);
}
private static int surfaceRolePriority(HydrologyColumnLayer layer) {
if (layer.channel()) {
return 0;
}
return layer.shore() ? 1 : 2;
}
private static int surfaceActionPriority(HydrologyCaveAction action) {
return action == HydrologyCaveAction.WET_SOURCE ? 0 : 1;
}
public record SurfacePublicationCell(
HydrologyColumnLayer layer,
HydrologyCaveAction action
) {
public SurfacePublicationCell {
Objects.requireNonNull(layer, "layer");
if (action != HydrologyCaveAction.WET_SOURCE
&& action != HydrologyCaveAction.FALLING_FLUID) {
throw new IllegalArgumentException("Surface publication cells must contain fluid.");
}
}
}
}
@@ -0,0 +1,137 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveConflictPolicy;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
final class HydrologyCrossTileCaveAdmission {
private static final Comparator<Claim> 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,163 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
final class HydrologyCrossTileSurfaceAdmission {
private HydrologyCrossTileSurfaceAdmission() {
}
static Result admit(
List<Claim> currentClaims,
List<RankedClaim> blockers,
int minimumSeparation
) {
ArrayList<Rejection> rejections = new ArrayList<>();
for (Claim current : currentClaims) {
for (RankedClaim ranked : blockers) {
Claim blocker = ranked.claim();
if (!conflicts(current, blocker, minimumSeparation)) {
continue;
}
rejections.add(new Rejection(current, blocker.courseId()));
break;
}
}
return new Result(List.copyOf(rejections));
}
private static boolean conflicts(Claim first, Claim second, int minimumSeparation) {
if (first.outletId() == second.outletId()) {
return true;
}
long minimumSeparationSquared = (long) minimumSeparation * minimumSeparation;
if (first.centerline().getFirst().distanceSquared2D(second.centerline().getFirst())
< minimumSeparationSquared) {
return true;
}
if (first.reachesOutlet() && second.reachesOutlet()
&& first.terminal().distanceSquared2D(second.terminal())
< minimumSeparationSquared) {
return true;
}
double clearance = Math.max(4D, (first.maximumWidth() + second.maximumWidth()) / 2D + 4D);
return centerlinesWithin(first.centerline(), second.centerline(), clearance);
}
private static boolean centerlinesWithin(
List<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
) {
Claim {
Objects.requireNonNull(terminal, "terminal");
centerline = List.copyOf(Objects.requireNonNull(centerline, "centerline"));
if (maximumWidth < 1 || centerline.size() < 2) {
throw new IllegalArgumentException("Surface claims require a positive width and centerline.");
}
}
}
record RankedClaim(HydrologyTileKey ownerKey, int ownerRank, Claim claim) {
RankedClaim {
Objects.requireNonNull(ownerKey, "ownerKey");
Objects.requireNonNull(claim, "claim");
}
}
record Rejection(Claim loser, long winnerCourseId) {
Rejection {
Objects.requireNonNull(loser, "loser");
}
}
record Result(List<Rejection> rejections) {
Result {
rejections = List.copyOf(Objects.requireNonNull(rejections, "rejections"));
}
}
}
@@ -0,0 +1,18 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record HydrologyDiagnosticCandidate(
long id,
HydrologyCandidateKind kind,
HydrologyFeatureType projectedType,
HydrologyPoint point,
HydrologyCandidateRejection rejection
) {
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.engine.hydrology;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
public record HydrologyDiagnosticRenderSample(
int x,
int z,
List<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,96 @@
package art.arcane.iris.engine.hydrology;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
public record HydrologyFeatureQuery(Set<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.RIDGE_BORE,
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 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(DEEP, 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("ridge_tunnel", Set.of(HydrologyFeatureType.RIDGE_BORE));
builtIns.put("deep", DEEP);
return Collections.unmodifiableMap(builtIns);
}
}
@@ -0,0 +1,27 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record HydrologyFeatureRef(
long id,
HydrologyFeatureType type,
long courseId,
long segmentId,
int x,
int y,
int z,
int flowDeltaX,
int flowDeltaZ,
boolean source
) {
public HydrologyFeatureRef {
Objects.requireNonNull(type, "type");
if (flowDeltaX < -1 || flowDeltaX > 1 || flowDeltaZ < -1 || flowDeltaZ > 1) {
throw new IllegalArgumentException("Flow direction components must be between -1 and 1.");
}
}
public HydrologyPoint point() {
return new HydrologyPoint(x, y, z);
}
}
@@ -0,0 +1,62 @@
package art.arcane.iris.engine.hydrology;
public enum HydrologyFeatureType {
SURFACE_POOL,
RIFFLE,
CASCADE,
WATERFALL,
RIDGE_BORE,
UNDERGROUND_POOL,
UNDERGROUND_DROP,
SINKHOLE,
COASTAL_GROTTO,
INLAND_GROTTO,
MOUTH,
DEEP_POOL,
DEEP_CHANNEL;
public boolean isSurface() {
return switch (this) {
case SURFACE_POOL, RIFFLE, CASCADE, WATERFALL, MOUTH -> 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;
};
}
}
@@ -0,0 +1,115 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
@FunctionalInterface
public interface HydrologyGeometrySampler {
int sample(Request request);
default int sample(
Field field,
String profileKey,
int x,
int z,
int minimum,
int maximum
) {
return sample(field, profileKey, x, z, 0L, minimum, maximum);
}
default int sample(
Field field,
String profileKey,
int x,
int z,
long stableId,
int minimum,
int maximum
) {
Request request = new Request(field, profileKey, x, z, stableId, minimum, maximum);
int value = sample(request);
if (value < minimum || value > maximum) {
throw new IllegalStateException(
"Hydrology geometry sampler returned " + value + " outside " + minimum + ".." + maximum
+ " for " + field + " at " + x + "," + z + "."
);
}
return value;
}
static HydrologyGeometrySampler deterministic(
long worldSeed,
HydrologyTerrainSampler terrainSampler
) {
Objects.requireNonNull(terrainSampler, "terrainSampler");
return request -> {
if (request.field() == Field.UNDERGROUND_FLUID_LEVEL) {
HydrologyTerrainSample terrain = Objects.requireNonNull(
terrainSampler.sample(request.x(), request.z()),
"Hydrology terrain sampler returned null at " + request.x() + "," + request.z()
);
return Math.max(request.minimum(), Math.min(request.maximum(), terrain.caveFluidY()));
}
if (request.field() == Field.SURFACE_WIDTH
|| request.field() == Field.SURFACE_DEPTH
|| request.field() == Field.SURFACE_INSET
|| request.field() == Field.UNDERGROUND_WIDTH
|| request.field() == Field.UNDERGROUND_DEPTH) {
return request.minimum();
}
long stable = switch (request.field()) {
case SURFACE_BLEND_WIDTH -> HydrologyHash.mix(
request.stableId(),
request.x(),
request.z(),
0x424c454e44L
);
case TARGET_POOL_LENGTH -> HydrologyHash.mix(
worldSeed,
request.stableId(),
0x504f4f4cL
);
case UNDERGROUND_HEADROOM -> HydrologyHash.mix(
request.stableId(),
request.x(),
request.z(),
0x48454144L
);
case DEEP_FLUID_HEIGHT -> HydrologyHash.mix(request.stableId(), 0x484549474854L);
default -> throw new IllegalStateException("Missing deterministic geometry rule for " + request.field() + ".");
};
return HydrologyHash.between(stable, request.minimum(), request.maximum());
};
}
enum Field {
SURFACE_WIDTH,
SURFACE_DEPTH,
SURFACE_INSET,
SURFACE_BLEND_WIDTH,
TARGET_POOL_LENGTH,
UNDERGROUND_FLUID_LEVEL,
UNDERGROUND_WIDTH,
UNDERGROUND_DEPTH,
UNDERGROUND_HEADROOM,
DEEP_FLUID_HEIGHT
}
record Request(
Field field,
String profileKey,
int x,
int z,
long stableId,
int minimum,
int maximum
) {
public Request {
field = Objects.requireNonNull(field, "field");
profileKey = profileKey == null ? "" : profileKey;
if (minimum > maximum) {
throw new IllegalArgumentException("Hydrology geometry sample bounds are reversed.");
}
}
}
}
@@ -0,0 +1,43 @@
package art.arcane.iris.engine.hydrology;
final class HydrologyHash {
private HydrologyHash() {
}
static long mix(long seed, long... values) {
long mixed = avalanche(seed ^ 0x9e3779b97f4a7c15L);
for (long value : values) {
mixed = avalanche(mixed ^ avalanche(value + 0x9e3779b97f4a7c15L));
}
return mixed;
}
static long text(String value) {
long hash = 0xcbf29ce484222325L;
for (int index = 0; index < value.length(); index++) {
hash ^= value.charAt(index);
hash *= 0x100000001b3L;
}
return avalanche(hash);
}
static double unit(long value) {
return (avalanche(value) >>> 11) * 0x1.0p-53;
}
static int between(long value, int minimum, int maximum) {
if (minimum > maximum) {
throw new IllegalArgumentException("minimum cannot exceed maximum.");
}
long span = (long) maximum - minimum + 1L;
return minimum + (int) Long.remainderUnsigned(avalanche(value), span);
}
private static long avalanche(long value) {
value ^= value >>> 30;
value *= 0xbf58476d1ce4e5b9L;
value ^= value >>> 27;
value *= 0x94d049bb133111ebL;
return value ^ value >>> 31;
}
}
@@ -0,0 +1,5 @@
package art.arcane.iris.engine.hydrology;
public interface HydrologyNaturalTerrainSampler extends HydrologyRoutingTerrainSampler {
HydrologyTerrainSample sampleBasis(int blockX, int blockZ);
}
@@ -0,0 +1,48 @@
package art.arcane.iris.engine.hydrology;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public final class HydrologyObservedPlannedSurface implements HydrologyCaveVoxelViewFactory.PlannedSurface {
private final HydrologyCaveVoxelViewFactory.PlannedSurface delegate;
private final Long2ObjectOpenHashMap<ArrayList<Observation>> observations;
public HydrologyObservedPlannedSurface(HydrologyCaveVoxelViewFactory.PlannedSurface delegate) {
this.delegate = Objects.requireNonNull(delegate);
this.observations = new Long2ObjectOpenHashMap<>();
}
@Override
public int resolve(int x, int z, int naturalHeight) {
int resolvedHeight = delegate.resolve(x, z, naturalHeight);
long key = RiverFootprint.pack(x, z);
ArrayList<Observation> column = observations.get(key);
if (column == null) {
column = new ArrayList<>(1);
observations.put(key, column);
}
for (Observation observation : column) {
if (observation.naturalHeight() == naturalHeight) {
return resolvedHeight;
}
}
column.add(new Observation(x, z, naturalHeight, resolvedHeight));
return resolvedHeight;
}
public List<Observation> observationsAt(int x, int z) {
ArrayList<Observation> column = observations.get(RiverFootprint.pack(x, z));
return column == null ? List.of() : List.copyOf(column);
}
public record Observation(
int x,
int z,
int naturalHeight,
int resolvedHeight
) {
}
}
@@ -0,0 +1,47 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
final class HydrologyOceanBoundaryRefiner {
private HydrologyOceanBoundaryRefiner() {
}
static Result refine(
List<HydrologyPoint> crossing,
HydrologyTerrainSampler terrainSampler,
HydrologyRoutingTerrainSampler routingSampler
) {
Objects.requireNonNull(crossing, "crossing");
Objects.requireNonNull(terrainSampler, "terrainSampler");
Objects.requireNonNull(routingSampler, "routingSampler");
if (crossing.size() < 2) {
return null;
}
HydrologyPoint previous = crossing.getFirst();
for (int index = 1; index < crossing.size(); index++) {
HydrologyPoint point = crossing.get(index);
HydrologyRoutingTerrainSampler.NaturalClassification classification =
routingSampler.classifyNatural(point.x(), point.z());
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.UNAVAILABLE) {
return null;
}
if (classification == HydrologyRoutingTerrainSampler.NaturalClassification.OCEAN) {
HydrologyTerrainSample landwardTerrain = terrainSampler.sample(previous.x(), previous.z());
if (landwardTerrain == null || landwardTerrain.ocean()) {
return null;
}
return new Result(previous, point, landwardTerrain);
}
previous = point;
}
return null;
}
record Result(
HydrologyPoint landwardPoint,
HydrologyPoint oceanPoint,
HydrologyTerrainSample landwardTerrain
) {
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,522 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
public record HydrologyPlannerSettings(
int seaLevel,
Routing routing,
Surface surface,
Hydraulics hydraulics,
Underground underground,
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids
) {
private static final long PLAN_FORMAT_REVISION = 5L;
private static final int MAXIMUM_CROSS_TILE_COLOR_PERIOD = 4;
public HydrologyPlannerSettings {
if (seaLevel < -2048 || seaLevel > 2048) {
throw new IllegalArgumentException("seaLevel is outside the supported planning range.");
}
if (routing == null || surface == null || hydraulics == null || underground == null
|| outlets == null || geometry == null) {
throw new IllegalArgumentException("Hydrology planner settings cannot contain null sections.");
}
deepFluids = deepFluids == null ? List.of() : List.copyOf(deepFluids);
int publicationRadius = publicationRadius(routing, surface, underground, outlets, geometry, deepFluids);
if (crossTileColorPeriod(publicationRadius, routing.tileSize()) > MAXIMUM_CROSS_TILE_COLOR_PERIOD) {
throw new IllegalArgumentException("Hydrology publication envelope exceeds the bounded cross-tile admission period.");
}
}
public static HydrologyPlannerSettings defaults() {
Source surfaceSources = new Source(true, 0.5D, 88, 0, 1, 384);
Source undergroundSources = new Source(true, 0.25D, Integer.MIN_VALUE, 0, 1, 512);
return new HydrologyPlannerSettings(
63,
new Routing(2048, 64, 8, 8192, 8192, new Branching(384, 192), 1.5D, 24D, 2D, 0.2D),
new Surface(true, surfaceSources, 4, 8, 1, 4, 3, 7, 6, 2, 10, 24, true, 192, 10),
new Hydraulics(80, 180, 1, 7, 24, 8),
new Underground(true, undergroundSources, -48, 72, 3, 8, 1, 3, 6, 14, true),
new Outlets(
true,
new Grotto(true, 18, 8, 8, 32768),
new Grotto(true, 18, 8, 8, 32768),
false,
12,
64,
8,
12
),
Geometry.defaults(),
List.of()
);
}
public long fingerprint() {
long hash = HydrologyHash.mix(
0x485944524f4c4f47L,
PLAN_FORMAT_REVISION,
routing.hashCode(),
surface.hashCode(),
hydraulics.hashCode(),
underground.hashCode(),
outlets.hashCode(),
geometry.hashCode()
);
for (DeepFluid deepFluid : deepFluids) {
hash = HydrologyHash.mix(hash, deepFluid.hashCode(), HydrologyHash.text(deepFluid.id()));
}
return hash;
}
public int publicationRadius() {
return publicationRadius(routing, surface, underground, outlets, geometry, deepFluids);
}
int crossTileColorPeriod() {
return crossTileColorPeriod(publicationRadius(), routing.tileSize());
}
private static int publicationRadius(
Routing routing,
Surface surface,
Underground underground,
Outlets outlets,
Geometry geometry,
List<DeepFluid> deepFluids
) {
int alignedHalo = Math.floorDiv(
Math.min(
routing.maximumRouteLength(),
Math.multiplyExact(routing.sampleSpacing(), 2)
),
routing.sampleSpacing()
) * routing.sampleSpacing();
int radius = 0;
int routeDisplacement = Math.multiplyExact(routing.sampleSpacing(), 3);
if (surface.enabled() && surface.sources().enabled()) {
int surfaceRadius = (int) StrictMath.ceil(
surface.maximumWidth() / 2D + surface.shoreWidth() + surface.maximumTerrainBlendWidth()
);
surfaceRadius = Math.max(
surfaceRadius,
(int) StrictMath.ceil(geometry.drops().basinWidth(
geometry.drops().flowWidth(surface.maximumWidth())
) / 2D + surface.shoreWidth() + surface.maximumTerrainBlendWidth())
);
surfaceRadius = Math.max(surfaceRadius, outlets.coastalGrotto().horizontalRadius());
surfaceRadius = Math.max(surfaceRadius, outlets.inlandGrotto().horizontalRadius());
radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact(surfaceRadius, routeDisplacement)));
}
if (underground.enabled() && underground.sources().enabled()) {
int undergroundRadius = (int) StrictMath.ceil(underground.maximumWidth() / 2D);
undergroundRadius = Math.max(
undergroundRadius,
(int) StrictMath.ceil(geometry.drops().basinWidth(
geometry.drops().flowWidth(underground.maximumWidth())
) / 2D)
);
undergroundRadius = Math.max(undergroundRadius, outlets.coastalGrotto().horizontalRadius());
undergroundRadius = Math.max(undergroundRadius, outlets.inlandGrotto().horizontalRadius());
radius = Math.max(radius, Math.addExact(alignedHalo, Math.addExact(undergroundRadius, routeDisplacement)));
}
for (DeepFluid deepFluid : deepFluids) {
if (!deepFluid.enabled() || deepFluid.maximumPerTile() == 0) {
continue;
}
int poolReach = deepFluid.containedPools() ? deepFluid.maximumHorizontalRadius() : 0;
int channelReach = deepFluid.shortChannels()
? Math.addExact(deepFluid.maximumChannelLength(), (int) StrictMath.ceil(deepFluid.channelWidth() / 2D))
: 0;
radius = Math.max(radius, Math.max(poolReach, channelReach));
}
return radius;
}
private static int crossTileColorPeriod(int publicationRadius, int tileSize) {
long actionReach = Math.addExact((long) publicationRadius, 1L);
return Math.toIntExact(Math.addExact(Math.floorDiv(Math.multiplyExact(2L, actionReach), tileSize), 2L));
}
public record Routing(
int tileSize,
int sampleSpacing,
int refinementSpacing,
int maximumRouteNodes,
int maximumRouteLength,
Branching branching,
double valleyPreference,
double uphillPenalty,
double slopePenalty,
double confluenceAttraction
) {
public Routing {
if (tileSize < 32 || sampleSpacing < 4 || refinementSpacing < 1 || branching == null
|| tileSize % sampleSpacing != 0 || sampleSpacing % refinementSpacing != 0) {
throw new IllegalArgumentException("Routing sizes must form an exact bounded tile lattice.");
}
int latticeWidth = tileSize / sampleSpacing + 1;
int halo = Math.min(maximumRouteLength, Math.multiplyExact(sampleSpacing, 2));
int alignedHalo = Math.floorDiv(halo, sampleSpacing) * sampleSpacing;
int expandedWidth = latticeWidth + alignedHalo * 2 / sampleSpacing;
long latticeNodes = (long) expandedWidth * expandedWidth;
if (maximumRouteNodes < latticeNodes || maximumRouteNodes > 1_000_000 || maximumRouteLength < 1) {
throw new IllegalArgumentException("maximumRouteNodes must contain the complete lattice and remain bounded.");
}
if (branching.minimumSurfaceCourseLength() > maximumRouteLength
|| branching.minimumUndergroundCourseLength() > maximumRouteLength) {
throw new IllegalArgumentException("Minimum course lengths cannot exceed maximum route length.");
}
requireFiniteNonNegative(valleyPreference, "valleyPreference");
requireFiniteNonNegative(uphillPenalty, "uphillPenalty");
requireFiniteNonNegative(slopePenalty, "slopePenalty");
requireFiniteNonNegative(confluenceAttraction, "confluenceAttraction");
}
}
public record Branching(
int minimumSurfaceCourseLength,
int minimumUndergroundCourseLength
) {
public Branching {
if (minimumSurfaceCourseLength < 0 || minimumSurfaceCourseLength > 32_768
|| minimumUndergroundCourseLength < 0 || minimumUndergroundCourseLength > 32_768) {
throw new IllegalArgumentException("Hydrology branching lengths are invalid.");
}
}
public int minimumCourseLength(boolean surface) {
return surface ? minimumSurfaceCourseLength : minimumUndergroundCourseLength;
}
}
public record Source(
boolean enabled,
double density,
int minimumElevation,
int minimumPerTile,
int maximumPerTile,
int minimumSpacing
) {
public Source {
if (!Double.isFinite(density) || density < 0D || density > 64D) {
throw new IllegalArgumentException("Source density must be between 0 and 64 expected sources per tile.");
}
if (minimumPerTile < 0 || maximumPerTile < minimumPerTile || maximumPerTile > 4096) {
throw new IllegalArgumentException("Source tile quotas are invalid.");
}
if (minimumSpacing < 0) {
throw new IllegalArgumentException("Source spacing cannot be negative.");
}
}
}
public record Surface(
boolean enabled,
Source sources,
int minimumWidth,
int maximumWidth,
int minimumDepth,
int maximumDepth,
int minimumSurfaceInset,
int maximumSurfaceInset,
int maximumIncision,
double shoreWidth,
int minimumTerrainBlendWidth,
int maximumTerrainBlendWidth,
boolean ridgeTunnelsEnabled,
int maximumRidgeTunnelLength,
int ridgeTunnelHeadroom
) {
public Surface {
if (sources == null) {
throw new IllegalArgumentException("Surface source settings are required.");
}
requireRange(minimumWidth, maximumWidth, 1, 256, "surface width");
requireRange(minimumDepth, maximumDepth, 1, 64, "surface depth");
requireRange(minimumSurfaceInset, maximumSurfaceInset, 1, 64, "surface inset");
if (maximumIncision < 0 || !Double.isFinite(shoreWidth) || shoreWidth < 0D || shoreWidth > 32D) {
throw new IllegalArgumentException("Surface incision and shore width are invalid.");
}
requireRange(minimumTerrainBlendWidth, maximumTerrainBlendWidth, 0, 128, "terrain blend width");
if (maximumRidgeTunnelLength < 0 || ridgeTunnelHeadroom < 0) {
throw new IllegalArgumentException("Ridge tunnel bounds cannot be negative.");
}
}
}
public record Hydraulics(
int minimumTargetPoolLength,
int maximumTargetPoolLength,
int riffleDrop,
int maximumGradualDrop,
int maximumGradualLength,
int waterfallMinimumDrop
) {
public Hydraulics {
requireRange(minimumTargetPoolLength, maximumTargetPoolLength, 1, 4096, "target pool length");
if (riffleDrop < 0 || maximumGradualDrop < riffleDrop
|| maximumGradualLength < Math.max(1, maximumGradualDrop)
|| waterfallMinimumDrop != maximumGradualDrop + 1
|| minimumTargetPoolLength < maximumGradualLength) {
throw new IllegalArgumentException("Hydraulic transition thresholds are inconsistent.");
}
}
}
public record Underground(
boolean enabled,
Source sources,
int minimumFluidY,
int maximumFluidY,
int minimumWidth,
int maximumWidth,
int minimumDepth,
int maximumDepth,
int minimumHeadroom,
int maximumHeadroom,
boolean connectToExistingCaves
) {
public Underground {
if (sources == null || minimumFluidY > maximumFluidY) {
throw new IllegalArgumentException("Underground source and height settings are invalid.");
}
requireRange(minimumWidth, maximumWidth, 1, 256, "underground width");
requireRange(minimumDepth, maximumDepth, 1, 64, "underground depth");
requireRange(minimumHeadroom, maximumHeadroom, 1, 128, "underground headroom");
}
}
public record Outlets(
boolean oceanEnabled,
Grotto coastalGrotto,
Grotto inlandGrotto,
boolean surfaceSinkholesEnabled,
int coastalCliffMinimumHeight,
int mouthLevelingDistance,
int maximumOceanApron,
int maximumPerTile
) {
public Outlets {
if (coastalGrotto == null || inlandGrotto == null) {
throw new IllegalArgumentException("Coastal and inland grotto settings are required.");
}
if (surfaceSinkholesEnabled && !inlandGrotto.enabled()) {
throw new IllegalArgumentException("Surface sinkholes require inland grotto outlets.");
}
if (coastalCliffMinimumHeight < 0 || maximumOceanApron < 0 || maximumOceanApron > 64
|| mouthLevelingDistance < 0 || maximumPerTile < 1 || maximumPerTile > 256) {
throw new IllegalArgumentException("Outlet bounds are invalid.");
}
}
}
public record Grotto(
boolean enabled,
int horizontalRadius,
int verticalRadius,
int headroom,
int maximumVolume
) {
public Grotto {
if (horizontalRadius < 1 || horizontalRadius > 128 || verticalRadius < 1 || verticalRadius > 128
|| headroom < 1 || headroom > 128 || maximumVolume < 1 || maximumVolume > 1_048_576) {
throw new IllegalArgumentException("Grotto bounds are invalid.");
}
}
}
public record Geometry(
Meanders meanders,
ChannelShape surface,
ChannelShape underground,
ChannelShape grottos,
Drops drops
) {
public Geometry {
if (meanders == null || surface == null || underground == null || grottos == null || drops == null) {
throw new IllegalArgumentException("Hydrology geometry settings cannot contain null sections.");
}
}
public static Geometry defaults() {
ChannelShape channel = new ChannelShape(2.4D, 0.28D, 0.24D, 11);
return new Geometry(
new Meanders(64, 12, 0.34D, 0.42D, 0.48D, 1, 82D),
channel,
channel,
channel,
new Drops(2, 1.4D, 2, 0.45D, 2, 1.8D, 8)
);
}
}
public record Meanders(
int primaryWavelength,
int detailWavelength,
double primaryStrength,
double detailStrength,
double maximumOffsetRatio,
int smoothingPasses,
double maximumTurnDegrees
) {
public Meanders {
if (primaryWavelength < 8 || primaryWavelength > 512
|| detailWavelength < 4 || detailWavelength > 128
|| detailWavelength > primaryWavelength
|| smoothingPasses < 0 || smoothingPasses > 4
|| !Double.isFinite(primaryStrength) || primaryStrength < 0D || primaryStrength > 2D
|| !Double.isFinite(detailStrength) || detailStrength < 0D || detailStrength > 2D
|| !Double.isFinite(maximumOffsetRatio) || maximumOffsetRatio < 0D || maximumOffsetRatio > 1D
|| !Double.isFinite(maximumTurnDegrees)
|| maximumTurnDegrees < 10D || maximumTurnDegrees > 150D) {
throw new IllegalArgumentException("Hydrology meander geometry is invalid.");
}
}
}
public record ChannelShape(
double bedRoundness,
double bedRoughness,
double wallRoughness,
int roughnessWavelength
) {
public ChannelShape {
if (!Double.isFinite(bedRoundness) || bedRoundness < 1D || bedRoundness > 6D
|| !Double.isFinite(bedRoughness) || bedRoughness < 0D || bedRoughness > 1D
|| !Double.isFinite(wallRoughness) || wallRoughness < 0D || wallRoughness > 1D
|| roughnessWavelength < 3 || roughnessWavelength > 128) {
throw new IllegalArgumentException("Hydrology channel shape is invalid.");
}
}
}
public record Drops(
int cascadeRunPerBlock,
double cascadeExponent,
int maximumCascadeStep,
double flowWidthRatio,
int maximumFlowDepth,
double basinWidthRatio,
int maximumBasinDepth
) {
public Drops {
if (cascadeRunPerBlock < 1 || cascadeRunPerBlock > 16
|| !Double.isFinite(cascadeExponent) || cascadeExponent < 0.25D || cascadeExponent > 6D
|| maximumCascadeStep < 1 || maximumCascadeStep > 4
|| !Double.isFinite(flowWidthRatio) || flowWidthRatio < 0.25D || flowWidthRatio > 1D
|| maximumFlowDepth < 1 || maximumFlowDepth > 16
|| !Double.isFinite(basinWidthRatio) || basinWidthRatio < 1D || basinWidthRatio > 4D
|| maximumBasinDepth < maximumFlowDepth || maximumBasinDepth > 32) {
throw new IllegalArgumentException("Hydrology drop geometry is invalid.");
}
}
public int flowWidth(int channelWidth) {
return Math.max(1, Math.min(channelWidth, (int) StrictMath.ceil(channelWidth * flowWidthRatio)));
}
public int stepLimit(HydrologyFeatureType type) {
return type.isSurface() ? 1 : maximumCascadeStep;
}
public int flowDepth(int channelDepth) {
return Math.max(1, Math.min(channelDepth, maximumFlowDepth));
}
public int basinWidth(int flowWidth) {
return Math.max(flowWidth, (int) StrictMath.ceil(flowWidth * basinWidthRatio));
}
public int basinDepth(int flowDepth, int drop) {
int erodedDepth = Math.addExact(flowDepth, Math.max(1, (int) StrictMath.ceil(StrictMath.sqrt(drop))));
return Math.max(flowDepth, Math.min(maximumBasinDepth, erodedDepth));
}
}
public record DeepFluid(
String id,
boolean enabled,
double density,
int spacing,
int minimumY,
int maximumY,
int minimumHorizontalRadius,
int maximumHorizontalRadius,
int minimumVerticalRadius,
int maximumVerticalRadius,
int minimumChannelLength,
int maximumChannelLength,
int channelWidth,
int channelDepth,
int headroom,
int maximumVolume,
int maximumPerTile,
boolean containedPools,
boolean shortChannels
) {
public DeepFluid {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("Deep-fluid id must not be blank.");
}
id = id.trim();
if (!Double.isFinite(density) || density < 0D || density > 64D || spacing < 16 || minimumY > maximumY) {
throw new IllegalArgumentException("Deep-fluid density or height range is invalid.");
}
requireRange(minimumHorizontalRadius, maximumHorizontalRadius, 1, 128, "deep-fluid horizontal radius");
requireRange(minimumVerticalRadius, maximumVerticalRadius, 1, 128, "deep-fluid vertical radius");
requireRange(minimumChannelLength, maximumChannelLength, 0, 32_768, "deep-fluid channel length");
if (channelWidth < 1 || channelWidth > 256 || channelDepth < 1 || channelDepth > 64 || headroom < 1
|| maximumVolume < 64 || maximumVolume > 1_048_576
|| maximumPerTile < 0 || maximumPerTile > 1024) {
throw new IllegalArgumentException("Deep-fluid channel bounds are invalid.");
}
long poolEnvelope = ellipsoidVolume(
maximumHorizontalRadius,
Math.max(channelDepth, maximumVerticalRadius),
headroom
);
if (enabled && containedPools && poolEnvelope > maximumVolume) {
throw new IllegalArgumentException("Deep-fluid maximumVolume cannot contain its configured pool ellipsoid.");
}
if (enabled && !containedPools && !shortChannels) {
throw new IllegalArgumentException("An enabled deep fluid requires a pool or channel.");
}
if (enabled && shortChannels && minimumChannelLength < 1) {
throw new IllegalArgumentException("Enabled deep-fluid channels require a positive minimum length.");
}
}
}
private static void requireRange(int minimum, int maximum, int lower, int upper, String name) {
if (minimum < lower || maximum < minimum || maximum > upper) {
throw new IllegalArgumentException(name + " range is invalid.");
}
}
private static void requireFiniteNonNegative(double value, String name) {
if (!Double.isFinite(value) || value < 0D) {
throw new IllegalArgumentException(name + " must be finite and non-negative.");
}
}
static long ellipsoidVolume(int radius, int lowerExtent, int upperExtent) {
long volume = 0L;
for (int deltaZ = -radius; deltaZ <= radius; deltaZ++) {
for (int deltaX = -radius; deltaX <= radius; deltaX++) {
double distance = StrictMath.hypot(deltaX, deltaZ);
if (distance > radius + 0.25D) {
continue;
}
double normalized = Math.min(1D, distance / Math.max(1D, radius));
double scale = StrictMath.sqrt(Math.max(0D, 1D - normalized * normalized));
int lower = (int) StrictMath.ceil(lowerExtent * scale);
int upper = (int) StrictMath.floor(upperExtent * scale);
volume += lower + upper + 1L;
}
}
return volume;
}
}
@@ -0,0 +1,13 @@
package art.arcane.iris.engine.hydrology;
public record HydrologyPoint(int x, int y, int z) {
public long packedColumn() {
return RiverFootprint.pack(x, z);
}
public long distanceSquared2D(HydrologyPoint other) {
long deltaX = (long) x - other.x;
long deltaZ = (long) z - other.z;
return deltaX * deltaX + deltaZ * deltaZ;
}
}
@@ -0,0 +1,44 @@
package art.arcane.iris.engine.hydrology;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
public record HydrologyRenderSample(int x, int z, List<HydrologyFeatureRef> features) {
private static final Comparator<HydrologyFeatureRef> FEATURE_ORDER = Comparator
.comparingInt((HydrologyFeatureRef feature) -> feature.type().renderPriority())
.thenComparingLong(HydrologyFeatureRef::id);
public HydrologyRenderSample {
Objects.requireNonNull(features, "features");
ArrayList<HydrologyFeatureRef> ordered = new ArrayList<>(features);
ordered.sort(FEATURE_ORDER);
Map<Long, HydrologyFeatureRef> unique = new LinkedHashMap<>();
for (HydrologyFeatureRef feature : ordered) {
unique.putIfAbsent(feature.id(), feature);
}
features = List.copyOf(unique.values());
}
public boolean present() {
return !features.isEmpty();
}
public boolean hasFeature(HydrologyFeatureType type) {
Objects.requireNonNull(type, "type");
for (HydrologyFeatureRef feature : features) {
if (feature.type() == type) {
return true;
}
}
return false;
}
public Optional<HydrologyFeatureRef> primaryFeature() {
return features.isEmpty() ? Optional.empty() : Optional.of(features.getFirst());
}
}
@@ -0,0 +1,21 @@
package art.arcane.iris.engine.hydrology;
public interface HydrologyRoutingTerrainSampler {
HydrologyTerrainSample[] sampleGrid(GridRequest request);
NaturalClassification classifyNatural(int blockX, int blockZ);
enum NaturalClassification {
LAND,
OCEAN,
UNAVAILABLE
}
record GridRequest(int minimumX, int minimumZ, int width, int spacing) {
public GridRequest {
if (width < 1 || spacing < 1) {
throw new IllegalArgumentException("Hydrology routing grid dimensions must be positive");
}
}
}
}
@@ -0,0 +1,107 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.CaveVoxel;
import art.arcane.iris.engine.hydrology.cave.CaveVoxelView;
import it.unimi.dsi.fastutil.longs.Long2ObjectOpenHashMap;
import java.util.Objects;
final class HydrologyTerrainCaveVoxelView implements CaveVoxelView {
private final HydrologyTerrainSampler sampler;
private final Long2ObjectOpenHashMap<HydrologyTerrainSample> samples;
private final int seaLevel;
private final int minimumY;
private final int maximumY;
HydrologyTerrainCaveVoxelView(HydrologyTerrainSampler sampler, int seaLevel, int worldHeight) {
this(sampler, seaLevel, 0, worldHeight);
}
HydrologyTerrainCaveVoxelView(
HydrologyTerrainSampler sampler,
int seaLevel,
int minimumY,
int maximumY
) {
this(sampler, seaLevel, minimumY, maximumY, new Long2ObjectOpenHashMap<>());
}
private HydrologyTerrainCaveVoxelView(
HydrologyTerrainSampler sampler,
int seaLevel,
int minimumY,
int maximumY,
Long2ObjectOpenHashMap<HydrologyTerrainSample> samples
) {
this.sampler = Objects.requireNonNull(sampler);
this.samples = Objects.requireNonNull(samples);
this.seaLevel = seaLevel;
if (maximumY - minimumY < 3) {
throw new IllegalArgumentException("The cave view requires at least three vertical blocks");
}
this.minimumY = minimumY;
this.maximumY = maximumY;
}
HydrologyTerrainCaveVoxelView withSampleCache(
Long2ObjectOpenHashMap<HydrologyTerrainSample> sampleCache
) {
return new HydrologyTerrainCaveVoxelView(
sampler,
seaLevel,
minimumY,
maximumY,
sampleCache
);
}
@Override
public boolean isInWorld(CavePosition position) {
return position.y() > minimumY && position.y() < maximumY - 1;
}
@Override
public CaveVoxel voxelAt(CavePosition position) {
HydrologyTerrainSample terrain = sample(position.x(), position.z());
if (position.y() <= terrain.naturalHeight()) {
return CaveVoxel.SOLID;
}
if (terrain.ocean() && position.y() <= seaLevel) {
return CaveVoxel.COMPATIBLE_FLUID;
}
return CaveVoxel.CAVE_AIR;
}
@Override
public boolean isOpenToSurface(CavePosition position) {
return isAboveTerrainSurface(position);
}
@Override
public boolean isAboveTerrainSurface(CavePosition position) {
HydrologyTerrainSample terrain = sample(position.x(), position.z());
return position.y() > terrain.naturalHeight();
}
@Override
public boolean hasAboveTerrainSurface(int x, int z, int requestedMinimumY, int requestedMaximumY) {
int firstY = Math.max(requestedMinimumY, minimumY + 1);
int lastY = Math.min(requestedMaximumY, maximumY - 2);
return firstY <= lastY && lastY > sample(x, z).naturalHeight();
}
private HydrologyTerrainSample sample(int x, int z) {
long key = (long) x << 32 ^ z & 0xffffffffL;
HydrologyTerrainSample cached = samples.get(key);
if (cached != null) {
return cached;
}
HydrologyTerrainSample sampled = Objects.requireNonNull(
sampler.sample(x, z),
"Hydrology terrain sampler returned null at " + x + "," + z
);
samples.put(key, sampled);
return sampled;
}
}
@@ -0,0 +1,192 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
public record HydrologyTerrainSample(
int naturalHeight,
double slope,
boolean ocean,
boolean caveAvailable,
int caveFloorY,
int caveFluidY,
boolean transitAllowed,
boolean outletAllowed,
boolean surfaceSourceAllowed,
boolean surfaceSourceRequired,
boolean undergroundSourceAllowed,
boolean undergroundSourceRequired,
double routingCost,
double surfaceSourceWeight,
double undergroundSourceWeight,
double widthMultiplier,
double depthMultiplier,
double incisionMultiplier,
double routingMultiplier,
String parentBiomeKey,
String surfaceBiomeKey,
String mouthBiomeKey,
String shoreBiomeKey,
String dryBiomeKey,
String floodedCaveBiomeKey,
List<String> preferredProfileKeys
) {
public HydrologyTerrainSample {
requireFiniteNonNegative(slope, "slope");
requireFiniteNonNegative(routingCost, "routingCost");
requireFiniteNonNegative(surfaceSourceWeight, "surfaceSourceWeight");
requireFiniteNonNegative(undergroundSourceWeight, "undergroundSourceWeight");
requireFinitePositive(widthMultiplier, "widthMultiplier");
requireFinitePositive(depthMultiplier, "depthMultiplier");
requireFiniteNonNegative(incisionMultiplier, "incisionMultiplier");
requireFiniteNonNegative(routingMultiplier, "routingMultiplier");
if (caveAvailable && (caveFluidY <= caveFloorY || caveFluidY >= naturalHeight)) {
throw new IllegalArgumentException("Available cave fluid must be above its floor and below natural terrain.");
}
parentBiomeKey = normalizeKey(parentBiomeKey, "parent");
surfaceBiomeKey = normalizeKey(surfaceBiomeKey, parentBiomeKey);
mouthBiomeKey = normalizeKey(mouthBiomeKey, surfaceBiomeKey);
shoreBiomeKey = normalizeKey(shoreBiomeKey, parentBiomeKey);
dryBiomeKey = normalizeKey(dryBiomeKey, parentBiomeKey);
floodedCaveBiomeKey = normalizeKey(floodedCaveBiomeKey, surfaceBiomeKey);
preferredProfileKeys = normalizeProfiles(preferredProfileKeys);
}
public static HydrologyTerrainSample openLand(int naturalHeight, double slope, String parentBiomeKey) {
return new HydrologyTerrainSample(
naturalHeight,
slope,
false,
false,
naturalHeight - 32,
naturalHeight - 30,
true,
true,
true,
false,
false,
false,
0D,
1D,
1D,
1D,
1D,
1D,
1D,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
List.of("default")
);
}
public static HydrologyTerrainSample ocean(int naturalHeight, String parentBiomeKey) {
return new HydrologyTerrainSample(
naturalHeight,
0D,
true,
false,
naturalHeight - 32,
naturalHeight - 30,
false,
false,
false,
false,
false,
false,
0D,
0D,
0D,
1D,
1D,
1D,
1D,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
parentBiomeKey,
List.of("default")
);
}
public HydrologyTerrainSample withSlope(double replacementSlope) {
return new HydrologyTerrainSample(
naturalHeight,
replacementSlope,
ocean,
caveAvailable,
caveFloorY,
caveFluidY,
transitAllowed,
outletAllowed,
surfaceSourceAllowed,
surfaceSourceRequired,
undergroundSourceAllowed,
undergroundSourceRequired,
routingCost,
surfaceSourceWeight,
undergroundSourceWeight,
widthMultiplier,
depthMultiplier,
incisionMultiplier,
routingMultiplier,
parentBiomeKey,
surfaceBiomeKey,
mouthBiomeKey,
shoreBiomeKey,
dryBiomeKey,
floodedCaveBiomeKey,
preferredProfileKeys
);
}
private static List<String> normalizeProfiles(List<String> values) {
if (values == null || values.isEmpty()) {
return List.of("default");
}
if (profilesNormalized(values)) {
return List.copyOf(values);
}
List<String> normalized = values.stream()
.filter((String value) -> value != null && !value.isBlank())
.map(String::trim)
.distinct()
.sorted()
.toList();
return normalized.isEmpty() ? List.of("default") : normalized;
}
private static boolean profilesNormalized(List<String> values) {
String previous = null;
for (String value : values) {
if (value == null
|| value.isBlank()
|| !value.equals(value.trim())
|| previous != null && previous.compareTo(value) >= 0) {
return false;
}
previous = value;
}
return true;
}
private static String normalizeKey(String value, String fallback) {
return value == null || value.isBlank() ? fallback : value.trim();
}
private static void requireFiniteNonNegative(double value, String name) {
if (!Double.isFinite(value) || value < 0D) {
throw new IllegalArgumentException(name + " must be finite and non-negative.");
}
}
private static void requireFinitePositive(double value, String name) {
if (!Double.isFinite(value) || value <= 0D) {
throw new IllegalArgumentException(name + " must be finite and positive.");
}
}
}
@@ -0,0 +1,6 @@
package art.arcane.iris.engine.hydrology;
@FunctionalInterface
public interface HydrologyTerrainSampler {
HydrologyTerrainSample sample(int blockX, int blockZ);
}
@@ -0,0 +1,618 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.engine.hydrology.cave.CavePosition;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveAction;
import art.arcane.iris.engine.hydrology.cave.HydrologyCavePlan;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
public final class HydrologyTile {
private final HydrologyTileKey key;
private final long worldSeed;
private final long settingsFingerprint;
private final int tileSize;
private final List<DrainageNode> nodes;
private final List<DrainageEdge> edges;
private final List<RiverOutlet> outlets;
private final List<RiverCourse> courses;
private final List<HydrologyCavePlan> cavePlans;
private final List<HydrologyDiagnosticCandidate> diagnosticCandidates;
private final RiverFootprint footprint;
private final Map<Long, DrainageNode> nodesById;
private final Map<Long, RiverOutlet> outletsById;
private final Map<Long, HydrologyCavePlan> cavePlansByCourseId;
private final List<HydrologyFeatureRef> features;
public HydrologyTile(
HydrologyTileKey key,
long worldSeed,
long settingsFingerprint,
int tileSize,
List<DrainageNode> nodes,
List<DrainageEdge> edges,
List<RiverOutlet> outlets,
List<RiverCourse> courses,
List<HydrologyCavePlan> cavePlans,
List<HydrologyDiagnosticCandidate> diagnosticCandidates,
RiverFootprint footprint
) {
this.key = Objects.requireNonNull(key, "key");
if (tileSize <= 0) {
throw new IllegalArgumentException("tileSize must be positive.");
}
this.worldSeed = worldSeed;
this.settingsFingerprint = settingsFingerprint;
this.tileSize = tileSize;
this.nodes = sortedCopy(nodes, Comparator.comparingLong(DrainageNode::id));
this.edges = sortedCopy(edges, Comparator.comparingLong(DrainageEdge::id));
this.outlets = sortedCopy(outlets, Comparator.comparingLong(RiverOutlet::id));
this.courses = sortedCopy(courses, Comparator.comparingLong(RiverCourse::id));
this.cavePlans = sortedCopy(
cavePlans,
Comparator.comparingLong((HydrologyCavePlan plan) -> plan.source().sourceId())
);
this.diagnosticCandidates = sortedCopy(
diagnosticCandidates,
Comparator.comparingLong(HydrologyDiagnosticCandidate::id)
);
this.footprint = Objects.requireNonNull(footprint, "footprint");
this.nodesById = indexNodes(this.nodes);
this.outletsById = indexOutlets(this.outlets);
this.cavePlansByCourseId = indexCavePlans(this.cavePlans);
validateGraph();
validateCourses();
this.features = collectFeatures(footprint);
}
public HydrologyTileKey key() {
return key;
}
public long worldSeed() {
return worldSeed;
}
public long settingsFingerprint() {
return settingsFingerprint;
}
public int tileSize() {
return tileSize;
}
public List<DrainageNode> nodes() {
return nodes;
}
public List<DrainageEdge> edges() {
return edges;
}
public List<RiverOutlet> outlets() {
return outlets;
}
public List<RiverCourse> courses() {
return courses;
}
public List<HydrologyCavePlan> cavePlans() {
return cavePlans;
}
public List<HydrologyDiagnosticCandidate> diagnosticCandidates() {
return diagnosticCandidates;
}
public RiverFootprint footprint() {
return footprint;
}
public Optional<DrainageNode> node(long id) {
return Optional.ofNullable(nodesById.get(id));
}
public Optional<RiverOutlet> outlet(long id) {
return Optional.ofNullable(outletsById.get(id));
}
public Optional<HydrologyCavePlan> cavePlan(long courseId) {
return Optional.ofNullable(cavePlansByCourseId.get(courseId));
}
public Optional<HydrologyColumnSample> columnAt(int x, int z) {
return footprint.sample(x, z);
}
public HydrologyRenderSample renderAt(int x, int z) {
return footprint.renderSample(x, z);
}
public HydrologyDiagnosticRenderSample diagnosticRenderAt(int x, int z, int maximumDistance) {
if (maximumDistance < 0) {
throw new IllegalArgumentException("maximumDistance cannot be negative.");
}
long maximumDistanceSquared = (long) maximumDistance * maximumDistance;
ArrayList<HydrologyDiagnosticCandidate> selected = new ArrayList<>();
for (HydrologyDiagnosticCandidate candidate : diagnosticCandidates) {
long distance = candidate.point().distanceSquared2D(new HydrologyPoint(x, candidate.point().y(), z));
if (distance <= maximumDistanceSquared) {
selected.add(candidate);
}
}
return new HydrologyDiagnosticRenderSample(x, z, selected);
}
public List<HydrologyFeatureRef> features() {
return features;
}
public Optional<HydrologyFeatureRef> nearestFeature(
HydrologyFeatureType type,
int x,
int z,
int maximumDistance
) {
return nearestFeature(Set.of(Objects.requireNonNull(type, "type")), null, x, z, maximumDistance);
}
public Optional<HydrologyFeatureRef> nearestFeature(
Set<HydrologyFeatureType> types,
String profileKey,
int x,
int z,
int maximumDistance
) {
Objects.requireNonNull(types, "types");
if (maximumDistance < 0) {
throw new IllegalArgumentException("maximumDistance cannot be negative.");
}
long maximumDistanceSquared = (long) maximumDistance * maximumDistance;
HydrologyFeatureRef nearest = null;
long nearestDistance = Long.MAX_VALUE;
for (RiverCourse course : courses) {
if (profileKey != null && !profileKey.isBlank() && !profileKey.equals(course.profileKey())) {
continue;
}
for (HydraulicSegment segment : course.segments()) {
if (!types.contains(segment.type())) {
continue;
}
for (HydrologyPoint point : segment.centerline()) {
long deltaX = (long) point.x() - x;
long deltaZ = (long) point.z() - z;
long distance = deltaX * deltaX + deltaZ * deltaZ;
if (distance > maximumDistanceSquared || distance > nearestDistance) {
continue;
}
HydrologyFeatureRef feature = publishedCenterlineFeature(segment, point);
if (feature == null) {
continue;
}
if (distance < nearestDistance || nearest == null || feature.id() < nearest.id()) {
nearest = feature;
nearestDistance = distance;
}
}
}
}
return Optional.ofNullable(nearest);
}
public boolean acyclic() {
for (DrainageEdge edge : edges) {
DrainageNode upstream = nodesById.get(edge.upstreamNodeId());
DrainageNode downstream = nodesById.get(edge.downstreamNodeId());
if (upstream == null || downstream == null || downstream.potential() >= upstream.potential()) {
return false;
}
}
return true;
}
private void validateGraph() {
if (!acyclic()) {
throw new IllegalArgumentException("Drainage edges must strictly lower potential.");
}
for (DrainageNode node : nodes) {
if (!outletsById.containsKey(node.outletId())) {
throw new IllegalArgumentException("Every drainage node must resolve to an accepted outlet.");
}
}
}
private void validateCourses() {
LinkedHashMap<Long, RiverCourse> coursesById = new LinkedHashMap<>();
for (RiverCourse course : courses) {
if (coursesById.put(course.id(), course) != null) {
throw new IllegalArgumentException("Duplicate river course id: " + course.id());
}
if (!course.hydraulicallyNonRising()) {
throw new IllegalArgumentException("Course heads must be non-rising downstream.");
}
if (course.sourceNodeId().isPresent() && !nodesById.containsKey(course.sourceNodeId().getAsLong())) {
throw new IllegalArgumentException("Course source is not part of the accepted drainage graph.");
}
if (course.outletId().isPresent() && !outletsById.containsKey(course.outletId().getAsLong())) {
throw new IllegalArgumentException("Course outlet is not accepted by this tile.");
}
if (isCaveBearing(course) != cavePlansByCourseId.containsKey(course.id())) {
throw new IllegalArgumentException("Every cave-bearing course must have one accepted containment plan.");
}
}
validateFootprintCourses(coursesById);
}
private void validateFootprintCourses(Map<Long, RiverCourse> coursesById) {
LinkedHashMap<Long, Map<CavePosition, HydrologyCaveAction>> expectedActions = new LinkedHashMap<>();
for (HydrologyColumnSample sample : footprint.columns().values()) {
ArrayList<Long> caveCourseIds = new ArrayList<>();
for (HydrologyColumnLayer layer : sample.layers()) {
long courseId = layer.feature().courseId();
if (!coursesById.containsKey(courseId)) {
throw new IllegalArgumentException("Footprint references an unaccepted river course: " + courseId);
}
if (layer.oceanApron() || !layer.channel() || !layer.terrainOwned()
|| !isCaveLayer(layer)) {
continue;
}
Map<CavePosition, HydrologyCaveAction> courseActions = expectedActions.computeIfAbsent(
courseId,
(Long ignored) -> new HashMap<>()
);
if (!caveCourseIds.contains(courseId)) {
caveCourseIds.add(courseId);
}
for (int y = layer.bedY() + 1; y <= layer.ceilingY(); y++) {
CavePosition position = new CavePosition(sample.x(), y, sample.z());
HydrologyCaveAction action = actionAt(layer, y);
HydrologyCaveAction existing = courseActions.get(position);
if (existing == null || actionPriority(action) < actionPriority(existing)) {
courseActions.put(position, action);
}
}
}
composeSurfaceActions(sample, caveCourseIds, coursesById, expectedActions);
}
HashSet<Long> validatedCourseIds = new HashSet<>();
for (RiverCourse course : courses) {
if (!isCaveBearing(course)) {
continue;
}
if (!validatedCourseIds.add(course.id())) {
continue;
}
List<RiverCourse> sharedCourses = sharedOutletCaveCourses(course);
HashMap<CavePosition, HydrologyCaveAction> expected = new HashMap<>();
HashMap<CavePosition, HydrologyCaveAction> actual = new HashMap<>();
for (RiverCourse sharedCourse : sharedCourses) {
validatedCourseIds.add(sharedCourse.id());
mergeCaveActions(
expected,
expectedActions.getOrDefault(sharedCourse.id(), Map.of())
);
HydrologyCavePlan plan = cavePlansByCourseId.get(sharedCourse.id());
plan.forEachAction((CavePosition position, HydrologyCaveAction action) -> {
if (action != HydrologyCaveAction.SEAL_GUARD) {
mergeCaveAction(actual, position, action);
}
});
}
if (!actual.equals(expected)) {
throw caveFootprintMismatch(course.id(), expected, actual);
}
}
}
private List<RiverCourse> sharedOutletCaveCourses(RiverCourse course) {
if (course.outletId().isEmpty()) {
return List.of(course);
}
long outletId = course.outletId().getAsLong();
ArrayList<RiverCourse> shared = new ArrayList<>();
for (RiverCourse candidate : courses) {
if (isCaveBearing(candidate)
&& candidate.outletId().isPresent()
&& candidate.outletId().getAsLong() == outletId
&& candidate.profileKey().equals(course.profileKey())) {
shared.add(candidate);
}
}
return List.copyOf(shared);
}
private void mergeCaveActions(
Map<CavePosition, HydrologyCaveAction> target,
Map<CavePosition, HydrologyCaveAction> source
) {
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : source.entrySet()) {
mergeCaveAction(target, entry.getKey(), entry.getValue());
}
}
private void mergeCaveAction(
Map<CavePosition, HydrologyCaveAction> actions,
CavePosition position,
HydrologyCaveAction action
) {
HydrologyCaveAction existing = actions.get(position);
if (existing == null || actionPriority(action) < actionPriority(existing)) {
actions.put(position, action);
}
}
private IllegalArgumentException caveFootprintMismatch(
long courseId,
Map<CavePosition, HydrologyCaveAction> expected,
Map<CavePosition, HydrologyCaveAction> actual
) {
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : expected.entrySet()) {
HydrologyCaveAction actualAction = actual.get(entry.getKey());
if (actualAction != entry.getValue()) {
return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: "
+ courseId + " at " + entry.getKey() + ", footprint=" + entry.getValue()
+ ", plan=" + actualAction + ".");
}
}
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : actual.entrySet()) {
if (!expected.containsKey(entry.getKey())) {
return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: "
+ courseId + " has extra plan action " + entry.getValue() + " at " + entry.getKey() + ".");
}
}
return new IllegalArgumentException("Cave footprint differs from its accepted containment plan: "
+ courseId + ".");
}
private void composeSurfaceActions(
HydrologyColumnSample sample,
List<Long> caveCourseIds,
Map<Long, RiverCourse> coursesById,
Map<Long, Map<CavePosition, HydrologyCaveAction>> expectedActions
) {
if (caveCourseIds.isEmpty()) {
return;
}
int minimumY = Integer.MAX_VALUE;
int maximumY = Integer.MIN_VALUE;
for (HydrologyColumnLayer layer : sample.layers()) {
if (!layer.publishesSurfaceFluid()) {
continue;
}
minimumY = Math.min(minimumY, layer.bedY() + 1);
maximumY = Math.max(maximumY, layer.fluidHeadY());
}
for (int y = minimumY; y <= maximumY; y++) {
HydrologyColumnSample.SurfacePublicationCell surfaceCell = sample
.surfacePublicationCellAt(y)
.orElse(null);
if (surfaceCell == null) {
continue;
}
CavePosition position = new CavePosition(sample.x(), y, sample.z());
for (long courseId : caveCourseIds) {
Map<CavePosition, HydrologyCaveAction> courseActions = expectedActions.get(courseId);
if (!courseActions.containsKey(position)) {
continue;
}
RiverCourse course = coursesById.get(courseId);
if (!course.profileKey().equals(surfaceCell.layer().profileKey())) {
throw new IllegalArgumentException("Surface and cave hydrology profiles conflict at "
+ position + ".");
}
courseActions.put(position, surfaceCell.action());
}
}
}
private boolean isCaveBearing(RiverCourse course) {
for (HydraulicSegment segment : course.segments()) {
if (segment.type().isUnderground() || segment.type().isDeepFluid()) {
return true;
}
}
return false;
}
private boolean isCaveLayer(HydrologyColumnLayer layer) {
return layer.feature().type().isUnderground() || layer.feature().type().isDeepFluid();
}
private HydrologyCaveAction actionAt(HydrologyColumnLayer layer, int y) {
if (y > layer.fluidHeadY()) {
return HydrologyCaveAction.DRY_AIR;
}
if (layer.fallingFluid() && y < layer.fluidHeadY()) {
return HydrologyCaveAction.FALLING_FLUID;
}
return HydrologyCaveAction.WET_SOURCE;
}
private int actionPriority(HydrologyCaveAction action) {
return switch (action) {
case WET_SOURCE -> 0;
case FALLING_FLUID -> 1;
case DRY_AIR -> 2;
case SEAL_GUARD -> 3;
};
}
private static Map<Long, DrainageNode> indexNodes(List<DrainageNode> values) {
LinkedHashMap<Long, DrainageNode> indexed = new LinkedHashMap<>();
for (DrainageNode value : values) {
if (indexed.put(value.id(), value) != null) {
throw new IllegalArgumentException("Duplicate drainage node id: " + value.id());
}
}
return Collections.unmodifiableMap(indexed);
}
private static Map<Long, RiverOutlet> indexOutlets(List<RiverOutlet> values) {
LinkedHashMap<Long, RiverOutlet> indexed = new LinkedHashMap<>();
for (RiverOutlet value : values) {
if (indexed.put(value.id(), value) != null) {
throw new IllegalArgumentException("Duplicate river outlet id: " + value.id());
}
}
return Collections.unmodifiableMap(indexed);
}
private static Map<Long, HydrologyCavePlan> indexCavePlans(List<HydrologyCavePlan> values) {
LinkedHashMap<Long, HydrologyCavePlan> indexed = new LinkedHashMap<>();
for (HydrologyCavePlan value : values) {
if (!value.accepted()) {
throw new IllegalArgumentException("Hydrology tiles cannot contain rejected cave plans.");
}
long courseId = value.source().sourceId();
if (indexed.put(courseId, value) != null) {
throw new IllegalArgumentException("Duplicate cave plan for course: " + courseId);
}
}
return Collections.unmodifiableMap(indexed);
}
private HydrologyFeatureRef publishedCenterlineFeature(
HydraulicSegment segment,
HydrologyPoint point
) {
HydrologyColumnSample column = footprint.columns().get(RiverFootprint.pack(point.x(), point.z()));
if (column == null) {
return null;
}
for (HydrologyColumnLayer layer : column.layers()) {
HydrologyFeatureRef feature = layer.feature();
if (layer.channel()
&& feature.segmentId() == segment.id()
&& feature.type() == segment.type()) {
if (feature.x() == point.x() && feature.z() == point.z()) {
return feature;
}
return new HydrologyFeatureRef(
feature.id(),
feature.type(),
feature.courseId(),
feature.segmentId(),
point.x(),
layer.fluidHeadY(),
point.z(),
feature.flowDeltaX(),
feature.flowDeltaZ(),
feature.source()
);
}
}
return null;
}
static List<HydrologyFeatureRef> collectFeatures(RiverFootprint footprint) {
HashMap<Long, HydrologyColumnLayer> representatives = new HashMap<>();
for (HydrologyColumnSample column : footprint.columns().values()) {
for (HydrologyColumnLayer layer : column.layers()) {
long segmentId = layer.feature().segmentId();
HydrologyColumnLayer existing = representatives.get(segmentId);
if (existing == null || featureOrder(layer, existing) < 0) {
representatives.put(segmentId, layer);
}
}
}
ArrayList<HydrologyFeatureRef> ordered = new ArrayList<>(representatives.size());
for (HydrologyColumnLayer layer : representatives.values()) {
ordered.add(layer.feature());
}
ordered.sort(Comparator.comparingInt((HydrologyFeatureRef feature) -> feature.type().renderPriority())
.thenComparingLong(HydrologyFeatureRef::id));
return List.copyOf(ordered);
}
private static int featureOrder(HydrologyColumnLayer first, HydrologyColumnLayer second) {
int role = Integer.compare(featureRole(first), featureRole(second));
if (role != 0) {
return role;
}
HydrologyFeatureRef firstFeature = first.feature();
HydrologyFeatureRef secondFeature = second.feature();
int y = Integer.compare(firstFeature.y(), secondFeature.y());
if (y != 0) {
return y;
}
int x = Integer.compare(firstFeature.x(), secondFeature.x());
return x != 0 ? x : Integer.compare(firstFeature.z(), secondFeature.z());
}
private static int featureRole(HydrologyColumnLayer layer) {
if (layer.feature().source()) {
return 0;
}
if (layer.fallingFluid()) {
return 1;
}
if (layer.receivingPool()) {
return 2;
}
if (layer.channel() && !layer.oceanApron()) {
return 3;
}
if (layer.channel()) {
return 4;
}
return 5;
}
private static <T> List<T> sortedCopy(List<T> values, Comparator<T> comparator) {
Objects.requireNonNull(values, "values");
ArrayList<T> ordered = new ArrayList<>(values);
ordered.sort(comparator);
return List.copyOf(ordered);
}
@Override
public boolean equals(Object other) {
if (!(other instanceof HydrologyTile tile)) {
return false;
}
return worldSeed == tile.worldSeed
&& settingsFingerprint == tile.settingsFingerprint
&& tileSize == tile.tileSize
&& key.equals(tile.key)
&& nodes.equals(tile.nodes)
&& edges.equals(tile.edges)
&& outlets.equals(tile.outlets)
&& courses.equals(tile.courses)
&& cavePlans.equals(tile.cavePlans)
&& diagnosticCandidates.equals(tile.diagnosticCandidates)
&& footprint.equals(tile.footprint);
}
@Override
public int hashCode() {
return Objects.hash(
key,
worldSeed,
settingsFingerprint,
tileSize,
nodes,
edges,
outlets,
courses,
cavePlans,
diagnosticCandidates,
footprint
);
}
@Override
public String toString() {
return "HydrologyTile[key=" + key + ", courses=" + courses.size() + ", candidates="
+ diagnosticCandidates.size() + ", columns=" + footprint.size() + "]";
}
}
@@ -0,0 +1,267 @@
package art.arcane.iris.engine.hydrology;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.iris.util.common.parallel.MultiBurst;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.atomic.AtomicLong;
public final class HydrologyTileCache implements AutoCloseable {
private static final int DEFAULT_MAXIMUM_ENTRIES = 64;
private static final int MAXIMUM_COMPOSED_CHUNKS = 256;
private static final int CHUNK_SIZE = 16;
private static final int CHUNK_COLUMN_COUNT = CHUNK_SIZE * CHUNK_SIZE;
private final HydrologyPlanner planner;
private final int maximumEntries;
private final Cache<HydrologyTileKey, HydrologyTile> tiles;
private final Cache<Long, ChunkColumns> composedChunks;
private final AtomicLong cacheEpoch;
private final ThreadLocal<LocalChunkColumns> localChunkColumns;
public HydrologyTileCache(HydrologyPlanner planner) {
this(planner, DEFAULT_MAXIMUM_ENTRIES);
}
public HydrologyTileCache(HydrologyPlanner planner, int maximumEntries) {
this.planner = Objects.requireNonNull(planner, "planner");
if (maximumEntries < 1) {
throw new IllegalArgumentException("maximumEntries must be positive.");
}
this.maximumEntries = maximumEntries;
this.tiles = Caffeine.newBuilder()
.maximumSize(maximumEntries)
.build();
this.composedChunks = Caffeine.newBuilder()
.maximumSize(MAXIMUM_COMPOSED_CHUNKS)
.build();
this.cacheEpoch = new AtomicLong();
this.localChunkColumns = new ThreadLocal<>();
}
public HydrologyTile get(HydrologyTileKey key) {
Objects.requireNonNull(key, "key");
return tiles.get(key, planner::plan);
}
public Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) {
return chunkColumns(blockX, blockZ).columnAt(blockX, blockZ);
}
public void prepareChunkColumns(int blockX, int blockZ) {
chunkColumns(blockX, blockZ);
}
public HydrologyRenderSample renderAt(int blockX, int blockZ) {
return columnAt(blockX, blockZ)
.map(HydrologyColumnSample::renderSample)
.orElseGet(() -> new HydrologyRenderSample(blockX, blockZ, List.of()));
}
public void invalidate(HydrologyTileKey key) {
Objects.requireNonNull(key, "key");
clear();
}
public void clear() {
cacheEpoch.incrementAndGet();
planner.clearOwnerDrafts();
tiles.invalidateAll();
composedChunks.invalidateAll();
localChunkColumns.remove();
}
public int size() {
tiles.cleanUp();
return Math.toIntExact(tiles.estimatedSize());
}
public int maximumEntries() {
return maximumEntries;
}
@Override
public void close() {
clear();
}
private ChunkColumns chunkColumns(int blockX, int blockZ) {
int chunkX = Math.floorDiv(blockX, CHUNK_SIZE);
int chunkZ = Math.floorDiv(blockZ, CHUNK_SIZE);
long epoch = cacheEpoch.get();
LocalChunkColumns local = localChunkColumns.get();
if (local != null && local.epoch() == epoch
&& local.columns().chunkX() == chunkX
&& local.columns().chunkZ() == chunkZ) {
return local.columns();
}
long packedChunk = RiverFootprint.pack(chunkX, chunkZ);
ChunkColumns columns = composedChunks.get(
packedChunk,
ignored -> composeChunkColumns(chunkX, chunkZ)
);
localChunkColumns.set(new LocalChunkColumns(epoch, columns));
return columns;
}
private ChunkColumns composeChunkColumns(int chunkX, int chunkZ) {
int minimumBlockX = Math.multiplyExact(chunkX, CHUNK_SIZE);
int minimumBlockZ = Math.multiplyExact(chunkZ, CHUNK_SIZE);
int tileSize = planner.settings().routing().tileSize();
int publicationRadius = planner.settings().publicationRadius();
int minimumTileX = tileCoordinate((long) minimumBlockX - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) minimumBlockX + CHUNK_SIZE - 1L + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) minimumBlockZ - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) minimumBlockZ + CHUNK_SIZE - 1L + publicationRadius, tileSize);
ArrayList<HydrologyTileKey> relevantKeys = new ArrayList<>(
Math.multiplyExact(maximumTileX - minimumTileX + 1, maximumTileZ - minimumTileZ + 1)
);
for (int tileZ = minimumTileZ; tileZ <= maximumTileZ; tileZ++) {
for (int tileX = minimumTileX; tileX <= maximumTileX; tileX++) {
relevantKeys.add(new HydrologyTileKey(tileX, tileZ));
}
}
List<HydrologyTile> relevantTiles = loadTiles(relevantKeys);
HydrologyColumnSample[] columns = new HydrologyColumnSample[CHUNK_COLUMN_COUNT];
for (int localZ = 0; localZ < CHUNK_SIZE; localZ++) {
for (int localX = 0; localX < CHUNK_SIZE; localX++) {
int blockX = minimumBlockX + localX;
int blockZ = minimumBlockZ + localZ;
columns[localZ * CHUNK_SIZE + localX] = composeColumn(
blockX,
blockZ,
relevantKeys,
relevantTiles
)
.orElse(null);
}
}
return new ChunkColumns(chunkX, chunkZ, columns);
}
private List<HydrologyTile> loadTiles(List<HydrologyTileKey> keys) {
if (keys.size() < 2
|| !IrisPlatforms.isBound()) {
ArrayList<HydrologyTile> loaded = new ArrayList<>(keys.size());
for (HydrologyTileKey key : keys) {
loaded.add(get(key));
}
return List.copyOf(loaded);
}
ArrayList<CompletableFuture<HydrologyTile>> futures = new ArrayList<>(keys.size());
for (HydrologyTileKey key : keys) {
futures.add(CompletableFuture.supplyAsync(() -> get(key), MultiBurst.burst));
}
ArrayList<HydrologyTile> loaded = new ArrayList<>(keys.size());
for (CompletableFuture<HydrologyTile> future : futures) {
loaded.add(awaitTile(future));
}
return List.copyOf(loaded);
}
private static HydrologyTile awaitTile(CompletableFuture<HydrologyTile> future) {
try {
return future.join();
} catch (CompletionException failure) {
Throwable cause = failure.getCause();
if (cause instanceof RuntimeException runtimeException) {
throw runtimeException;
}
if (cause instanceof Error error) {
throw error;
}
throw new IllegalStateException("Hydrology tile planning failed.", cause);
}
}
private Optional<HydrologyColumnSample> composeColumn(
int blockX,
int blockZ,
List<HydrologyTileKey> relevantKeys,
List<HydrologyTile> relevantTiles
) {
ArrayList<HydrologyColumnLayer> layers = new ArrayList<>();
HydrologyColumnSample template = null;
int tileSize = planner.settings().routing().tileSize();
int publicationRadius = planner.settings().publicationRadius();
int minimumTileX = tileCoordinate((long) blockX - publicationRadius, tileSize);
int maximumTileX = tileCoordinate((long) blockX + publicationRadius, tileSize);
int minimumTileZ = tileCoordinate((long) blockZ - publicationRadius, tileSize);
int maximumTileZ = tileCoordinate((long) blockZ + publicationRadius, tileSize);
for (int tileIndex = 0; tileIndex < relevantTiles.size(); tileIndex++) {
HydrologyTileKey key = relevantKeys.get(tileIndex);
if (key.tileX() < minimumTileX || key.tileX() > maximumTileX
|| key.tileZ() < minimumTileZ || key.tileZ() > maximumTileZ) {
continue;
}
HydrologyTile tile = relevantTiles.get(tileIndex);
Optional<HydrologyColumnSample> candidate = tile.columnAt(blockX, blockZ);
if (candidate.isEmpty()) {
continue;
}
HydrologyColumnSample sample = candidate.get();
if (template == null) {
template = sample;
} else {
requireMatchingTerrain(template, sample);
}
layers.addAll(sample.layers());
}
if (template == null) {
return Optional.empty();
}
LinkedHashMap<Long, HydrologyColumnLayer> unique = new LinkedHashMap<>();
for (HydrologyColumnLayer layer : layers) {
HydrologyColumnLayer existing = unique.putIfAbsent(layer.feature().id(), layer);
if (existing != null && !existing.equals(layer)) {
throw new IllegalStateException("Hydrology feature id collision at " + blockX + "," + blockZ + ".");
}
}
return Optional.of(new HydrologyColumnSample(
blockX,
blockZ,
template.naturalHeight(),
template.seaLevel(),
template.ocean(),
template.parentBiomeKey(),
new ArrayList<>(unique.values())
));
}
private static int tileCoordinate(long blockCoordinate, int tileSize) {
return Math.toIntExact(Math.floorDiv(blockCoordinate, tileSize));
}
private static void requireMatchingTerrain(
HydrologyColumnSample expected,
HydrologyColumnSample actual
) {
if (expected.naturalHeight() != actual.naturalHeight()
|| expected.seaLevel() != actual.seaLevel()
|| expected.ocean() != actual.ocean()
|| !expected.parentBiomeKey().equals(actual.parentBiomeKey())) {
throw new IllegalStateException(
"Hydrology plans disagree on terrain metadata at " + expected.x() + "," + expected.z() + "."
);
}
}
private record ChunkColumns(int chunkX, int chunkZ, HydrologyColumnSample[] columns) {
private Optional<HydrologyColumnSample> columnAt(int blockX, int blockZ) {
int localX = Math.floorMod(blockX, CHUNK_SIZE);
int localZ = Math.floorMod(blockZ, CHUNK_SIZE);
return Optional.ofNullable(columns[localZ * CHUNK_SIZE + localX]);
}
}
private record LocalChunkColumns(long epoch, ChunkColumns columns) {
}
}
@@ -0,0 +1,31 @@
package art.arcane.iris.engine.hydrology;
public record HydrologyTileKey(int tileX, int tileZ) implements Comparable<HydrologyTileKey> {
public static HydrologyTileKey fromBlock(int blockX, int blockZ, int tileSize) {
if (tileSize <= 0) {
throw new IllegalArgumentException("Tile size must be positive.");
}
return new HydrologyTileKey(Math.floorDiv(blockX, tileSize), Math.floorDiv(blockZ, tileSize));
}
public int minimumBlockX(int tileSize) {
return Math.multiplyExact(tileX, tileSize);
}
public int minimumBlockZ(int tileSize) {
return Math.multiplyExact(tileZ, tileSize);
}
public boolean contains(int blockX, int blockZ, int tileSize) {
int minimumX = minimumBlockX(tileSize);
int minimumZ = minimumBlockZ(tileSize);
return blockX >= minimumX && blockX < minimumX + tileSize
&& blockZ >= minimumZ && blockZ < minimumZ + tileSize;
}
@Override
public int compareTo(HydrologyTileKey other) {
int xComparison = Integer.compare(tileX, other.tileX);
return xComparison != 0 ? xComparison : Integer.compare(tileZ, other.tileZ);
}
}
@@ -0,0 +1,107 @@
package art.arcane.iris.engine.hydrology;
import java.util.List;
import java.util.Objects;
import java.util.OptionalLong;
public record RiverCourse(
long id,
RiverCourseType type,
OptionalLong sourceNodeId,
OptionalLong outletId,
String profileKey,
int discharge,
List<DrainageEdge> drainageEdges,
List<HydraulicSegment> segments
) {
public RiverCourse {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(sourceNodeId, "sourceNodeId");
Objects.requireNonNull(outletId, "outletId");
Objects.requireNonNull(drainageEdges, "drainageEdges");
Objects.requireNonNull(segments, "segments");
if (profileKey == null || profileKey.isBlank()) {
throw new IllegalArgumentException("profileKey must not be blank.");
}
profileKey = profileKey.trim();
if (discharge <= 0) {
throw new IllegalArgumentException("Course discharge must be positive.");
}
drainageEdges = List.copyOf(drainageEdges);
segments = List.copyOf(segments);
if (segments.isEmpty()) {
throw new IllegalArgumentException("A river course requires at least one hydraulic segment.");
}
for (HydraulicSegment segment : segments) {
if (segment.courseId() != id) {
throw new IllegalArgumentException("Every segment must belong to its containing course.");
}
}
validateSinkholeContinuation(type, segments);
if (type == RiverCourseType.DEEP_FLUID && (sourceNodeId.isPresent() || outletId.isPresent())) {
throw new IllegalArgumentException("Independent deep-fluid courses cannot reference drainage nodes or river outlets.");
}
if (type != RiverCourseType.DEEP_FLUID && (sourceNodeId.isEmpty() || outletId.isEmpty())) {
throw new IllegalArgumentException("Surface and underground courses require a source and outlet.");
}
}
public boolean hydraulicallyNonRising() {
int previousHead = Integer.MAX_VALUE;
for (HydraulicSegment segment : segments) {
if (segment.upstreamHeadY() > previousHead || segment.downstreamHeadY() > segment.upstreamHeadY()) {
return false;
}
previousHead = segment.downstreamHeadY();
}
return true;
}
public boolean surfaceSinkholeContinuation() {
return type == RiverCourseType.SURFACE
&& segments.size() >= 2
&& segments.get(segments.size() - 2).type() == HydrologyFeatureType.SINKHOLE
&& segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO;
}
private static void validateSinkholeContinuation(
RiverCourseType courseType,
List<HydraulicSegment> segments
) {
int sinkholeCount = 0;
for (HydraulicSegment segment : segments) {
if (segment.type() == HydrologyFeatureType.SINKHOLE) {
sinkholeCount++;
}
}
boolean terminalInlandGrotto = courseType == RiverCourseType.SURFACE
&& segments.getLast().type() == HydrologyFeatureType.INLAND_GROTTO;
if (!terminalInlandGrotto) {
if (sinkholeCount > 0) {
throw new IllegalArgumentException("A sinkhole must link a surface course to its terminal inland grotto.");
}
return;
}
if (sinkholeCount != 1 || segments.size() < 3) {
throw new IllegalArgumentException("A surface inland grotto requires one explicit sinkhole link and surface lip.");
}
HydraulicSegment lip = segments.get(segments.size() - 3);
HydraulicSegment sinkhole = segments.get(segments.size() - 2);
HydraulicSegment grotto = segments.getLast();
if (!lip.type().isSurface() && lip.type() != HydrologyFeatureType.RIDGE_BORE
|| sinkhole.type() != HydrologyFeatureType.SINKHOLE
|| sinkhole.drop() <= 0
|| !sinkhole.receivingPool()
|| lip.downstreamHeadY() != sinkhole.upstreamHeadY()
|| sinkhole.downstreamHeadY() != grotto.upstreamHeadY()
|| grotto.upstreamHeadY() != grotto.downstreamHeadY()
|| lip.end().y() != sinkhole.upstreamHeadY()
|| sinkhole.start().y() != sinkhole.upstreamHeadY()
|| sinkhole.end().y() != sinkhole.downstreamHeadY()
|| grotto.start().y() != grotto.upstreamHeadY()
|| lip.end().distanceSquared2D(sinkhole.start()) != 0L
|| sinkhole.end().distanceSquared2D(grotto.start()) != 0L) {
throw new IllegalArgumentException("A surface sinkhole requires a connected lip, graded descent, and receiving grotto.");
}
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.engine.hydrology;
public enum RiverCourseType {
SURFACE,
UNDERGROUND,
DEEP_FLUID
}
@@ -0,0 +1,115 @@
package art.arcane.iris.engine.hydrology;
import it.unimi.dsi.fastutil.longs.Long2ObjectLinkedOpenHashMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
public final class RiverFootprint {
private final Map<Long, HydrologyColumnSample> columns;
public RiverFootprint(Map<Long, HydrologyColumnSample> columns) {
if (columns == null) {
throw new IllegalArgumentException("columns must not be null.");
}
TreeMap<Long, HydrologyColumnSample> ordered = new TreeMap<>();
for (Map.Entry<Long, HydrologyColumnSample> entry : columns.entrySet()) {
HydrologyColumnSample sample = entry.getValue();
if (sample == null || entry.getKey() != pack(sample.x(), sample.z())) {
throw new IllegalArgumentException("Footprint keys must match their column coordinates.");
}
ordered.put(entry.getKey(), sample);
}
Long2ObjectLinkedOpenHashMap<HydrologyColumnSample> indexed =
new Long2ObjectLinkedOpenHashMap<>(ordered.size());
for (Map.Entry<Long, HydrologyColumnSample> entry : ordered.entrySet()) {
indexed.put(entry.getKey().longValue(), entry.getValue());
}
columns = Collections.unmodifiableMap(indexed);
this.columns = columns;
}
public static RiverFootprint empty() {
return new RiverFootprint(Map.of());
}
public Optional<HydrologyColumnSample> sample(int x, int z) {
return Optional.ofNullable(columns.get(pack(x, z)));
}
public HydrologyRenderSample renderSample(int x, int z) {
HydrologyColumnSample sample = columns.get(pack(x, z));
return sample == null ? new HydrologyRenderSample(x, z, List.of()) : sample.renderSample();
}
public Map<Long, HydrologyColumnSample> columns() {
return columns;
}
public List<HydrologyColumnSample> columnsIn(int minimumX, int minimumZ, int maximumX, int maximumZ) {
ArrayList<HydrologyColumnSample> selected = new ArrayList<>();
long width = (long) maximumX - minimumX;
long height = (long) maximumZ - minimumZ;
if (width <= 0L || height <= 0L) {
return List.of();
}
long area = width > Long.MAX_VALUE / height ? Long.MAX_VALUE : width * height;
if (area < columns.size()) {
for (long z = minimumZ; z < maximumZ; z++) {
for (long x = minimumX; x < maximumX; x++) {
HydrologyColumnSample sample = columns.get(pack((int) x, (int) z));
if (sample != null) {
selected.add(sample);
}
}
}
return List.copyOf(selected);
}
for (HydrologyColumnSample sample : columns.values()) {
if (sample.x() >= minimumX && sample.x() < maximumX
&& sample.z() >= minimumZ && sample.z() < maximumZ) {
selected.add(sample);
}
}
return List.copyOf(selected);
}
public int size() {
return columns.size();
}
public boolean isEmpty() {
return columns.isEmpty();
}
public static long pack(int x, int z) {
return ((long) x << 32) ^ (z & 0xffffffffL);
}
public static int unpackX(long packed) {
return (int) (packed >> 32);
}
public static int unpackZ(long packed) {
return (int) packed;
}
@Override
public boolean equals(Object other) {
return other instanceof RiverFootprint footprint && columns.equals(footprint.columns);
}
@Override
public int hashCode() {
return columns.hashCode();
}
@Override
public String toString() {
return "RiverFootprint[columns=" + columns.size() + "]";
}
}
@@ -0,0 +1,30 @@
package art.arcane.iris.engine.hydrology;
import java.util.Objects;
public record RiverOutlet(
long id,
HydrologyFeatureType type,
long drainageNodeId,
HydrologyPoint landwardPoint,
HydrologyPoint connectionPoint,
int seaLevel,
boolean directOcean
) {
public RiverOutlet {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(landwardPoint, "landwardPoint");
Objects.requireNonNull(connectionPoint, "connectionPoint");
if (type != HydrologyFeatureType.MOUTH
&& type != HydrologyFeatureType.COASTAL_GROTTO
&& type != HydrologyFeatureType.INLAND_GROTTO) {
throw new IllegalArgumentException("Unsupported outlet feature type: " + type);
}
if (directOcean && type == HydrologyFeatureType.INLAND_GROTTO) {
throw new IllegalArgumentException("An inland grotto cannot be a direct ocean outlet.");
}
if (directOcean && connectionPoint.y() != seaLevel) {
throw new IllegalArgumentException("A direct ocean connection must be at sea level.");
}
}
}
@@ -0,0 +1,20 @@
package art.arcane.iris.engine.hydrology.cave;
public record CavePosition(int x, int y, int z) {
public CavePosition offset(int dx, int dy, int dz) {
return new CavePosition(x + dx, y + dy, z + dz);
}
@Override
public int hashCode() {
int hash = 0x811C9DC5;
hash = (hash ^ x) * 0x01000193;
hash = (hash ^ y) * 0x01000193;
hash = (hash ^ z) * 0x01000193;
hash ^= hash >>> 16;
hash *= 0x7FEB352D;
hash ^= hash >>> 15;
hash *= 0x846CA68B;
return hash ^ hash >>> 16;
}
}
@@ -0,0 +1,111 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Collection;
public final class CavePositionIndex {
private static final int DEFAULT_CAPACITY = 16;
private static final int MAXIMUM_CAPACITY = 1 << 30;
private int[] xCoordinates;
private int[] yCoordinates;
private int[] zCoordinates;
private boolean[] occupied;
private int mask;
private int resizeThreshold;
private int size;
public CavePositionIndex() {
initialize(DEFAULT_CAPACITY);
}
public CavePositionIndex(int expectedSize) {
initialize(capacityFor(expectedSize));
}
public static CavePositionIndex copyOf(Collection<CavePosition> positions) {
CavePositionIndex index = new CavePositionIndex(positions.size());
for (CavePosition position : positions) {
index.add(position.x(), position.y(), position.z());
}
return index;
}
public boolean add(int x, int y, int z) {
int slot = findSlot(x, y, z);
if (occupied[slot]) {
return false;
}
occupied[slot] = true;
xCoordinates[slot] = x;
yCoordinates[slot] = y;
zCoordinates[slot] = z;
size++;
if (size > resizeThreshold) {
resize();
}
return true;
}
public boolean contains(int x, int y, int z) {
return occupied[findSlot(x, y, z)];
}
private void initialize(int capacity) {
xCoordinates = new int[capacity];
yCoordinates = new int[capacity];
zCoordinates = new int[capacity];
occupied = new boolean[capacity];
mask = capacity - 1;
resizeThreshold = capacity - (capacity >>> 2);
size = 0;
}
private int findSlot(int x, int y, int z) {
int slot = hash(x, y, z) & mask;
while (occupied[slot]
&& (xCoordinates[slot] != x || yCoordinates[slot] != y || zCoordinates[slot] != z)) {
slot = slot + 1 & mask;
}
return slot;
}
private void resize() {
if (occupied.length == MAXIMUM_CAPACITY) {
resizeThreshold = Integer.MAX_VALUE;
return;
}
int[] previousXCoordinates = xCoordinates;
int[] previousYCoordinates = yCoordinates;
int[] previousZCoordinates = zCoordinates;
boolean[] previousOccupied = occupied;
initialize(occupied.length << 1);
for (int index = 0; index < previousOccupied.length; index++) {
if (previousOccupied[index]) {
add(previousXCoordinates[index], previousYCoordinates[index], previousZCoordinates[index]);
}
}
}
private static int capacityFor(int expectedSize) {
if (expectedSize < 0) {
throw new IllegalArgumentException("Expected position count must not be negative.");
}
long requiredCapacity = Math.max(DEFAULT_CAPACITY, ((long) expectedSize * 4L + 2L) / 3L);
if (requiredCapacity >= MAXIMUM_CAPACITY) {
return MAXIMUM_CAPACITY;
}
return Integer.highestOneBit((int) requiredCapacity - 1) << 1;
}
private static int hash(int x, int y, int z) {
int hash = 0x811C9DC5;
hash = (hash ^ x) * 0x01000193;
hash = (hash ^ y) * 0x01000193;
hash = (hash ^ z) * 0x01000193;
hash ^= hash >>> 16;
hash *= 0x7FEB352D;
hash ^= hash >>> 15;
hash *= 0x846CA68B;
return hash ^ hash >>> 16;
}
}
@@ -0,0 +1,10 @@
package art.arcane.iris.engine.hydrology.cave;
public enum CaveVoxel {
SOLID,
CAVE_AIR,
COMPATIBLE_FLUID,
LAVA,
INCOMPATIBLE_FLUID,
UNCONDITIONAL
}
@@ -1,4 +1,4 @@
package art.arcane.iris.engine.river.cave; package art.arcane.iris.engine.hydrology.cave;
import java.util.Objects; import java.util.Objects;
@@ -0,0 +1,26 @@
package art.arcane.iris.engine.hydrology.cave;
public interface CaveVoxelView {
boolean isInWorld(CavePosition position);
CaveVoxel voxelAt(CavePosition position);
boolean isOpenToSurface(CavePosition position);
boolean isAboveTerrainSurface(CavePosition position);
default boolean hasAboveTerrainSurface(int x, int z, int minimumY, int maximumY) {
if (minimumY > maximumY) {
return false;
}
for (int y = minimumY; ; y++) {
CavePosition position = new CavePosition(x, y, z);
if (isInWorld(position) && isAboveTerrainSurface(position)) {
return true;
}
if (y == maximumY) {
return false;
}
}
}
}
@@ -0,0 +1,8 @@
package art.arcane.iris.engine.hydrology.cave;
public enum HydrologyCaveAction {
WET_SOURCE,
FALLING_FLUID,
DRY_AIR,
SEAL_GUARD
}
@@ -0,0 +1,33 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
public record HydrologyCaveCandidate(
HydrologyCaveSource source,
String profileKey,
HydrologyCavePlannerSettings settings,
boolean allowDryCaveConnections,
Map<CavePosition, HydrologyCaveAction> actions,
Set<CavePosition> intentionalOpenings
) {
public HydrologyCaveCandidate {
Objects.requireNonNull(source);
if (profileKey == null || profileKey.isBlank()) {
throw new IllegalArgumentException("profileKey must not be blank");
}
profileKey = profileKey.trim();
Objects.requireNonNull(settings);
actions = Collections.unmodifiableMap(new LinkedHashMap<>(Objects.requireNonNull(actions)));
intentionalOpenings = Collections.unmodifiableSet(
new LinkedHashSet<>(Objects.requireNonNull(intentionalOpenings))
);
if (actions.isEmpty()) {
throw new IllegalArgumentException("A cave candidate requires at least one planned action");
}
}
}
@@ -0,0 +1,98 @@
package art.arcane.iris.engine.hydrology.cave;
import art.arcane.volmlib.util.matter.MatterCavern;
import java.util.Objects;
public final class HydrologyCaveCell {
private static final String DEFAULT_FLUID_PROFILE_KEY = "default";
private static final byte LIQUID_FLUID = 1;
private static final byte LIQUID_FORCED_AIR = 3;
private final HydrologyCaveAction action;
private final String fluidProfileKey;
private final String floodedBiomeKey;
private final MatterCavern cavern;
public HydrologyCaveCell(
HydrologyCaveAction action,
String fluidProfileKey,
String floodedBiomeKey
) {
this.action = Objects.requireNonNull(action);
if (fluidProfileKey == null || fluidProfileKey.isBlank()) {
throw new IllegalArgumentException("fluidProfileKey must not be blank");
}
this.fluidProfileKey = fluidProfileKey.trim();
this.floodedBiomeKey = floodedBiomeKey == null ? "" : floodedBiomeKey.trim();
this.cavern = switch (action) {
case WET_SOURCE, FALLING_FLUID -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FLUID);
case DRY_AIR -> new MatterCavern(true, this.floodedBiomeKey, LIQUID_FORCED_AIR);
case SEAL_GUARD -> null;
};
}
public static HydrologyCaveCell of(HydrologyCaveAction action) {
return new HydrologyCaveCell(action, DEFAULT_FLUID_PROFILE_KEY, "");
}
public static HydrologyCaveCell of(HydrologyCaveAction action, String fluidProfileKey) {
return new HydrologyCaveCell(action, fluidProfileKey, "");
}
public boolean carves() {
return action != HydrologyCaveAction.SEAL_GUARD;
}
public boolean isWet() {
return action == HydrologyCaveAction.WET_SOURCE || action == HydrologyCaveAction.FALLING_FLUID;
}
public boolean isFalling() {
return action == HydrologyCaveAction.FALLING_FLUID;
}
public boolean protectsPlacement() {
return true;
}
public MatterCavern asCavern() {
return cavern;
}
public HydrologyCaveAction action() {
return action;
}
public String floodedBiomeKey() {
return floodedBiomeKey;
}
public String fluidProfileKey() {
return fluidProfileKey;
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (!(object instanceof HydrologyCaveCell hydrology)) {
return false;
}
return action == hydrology.action
&& fluidProfileKey.equals(hydrology.fluidProfileKey)
&& floodedBiomeKey.equals(hydrology.floodedBiomeKey);
}
@Override
public int hashCode() {
return (31 * ((31 * action.hashCode()) + fluidProfileKey.hashCode())) + floodedBiomeKey.hashCode();
}
@Override
public String toString() {
return "HydrologyCaveCell[action=" + action + ", fluidProfileKey=" + fluidProfileKey
+ ", floodedBiomeKey=" + floodedBiomeKey + "]";
}
}
@@ -0,0 +1,82 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.Comparator;
import java.util.Map;
import java.util.Objects;
public final class HydrologyCaveConflictPolicy {
private static final Comparator<HydrologyCaveSource> SOURCE_PRIORITY = Comparator
.comparingInt(HydrologyCaveSource::waterHeadY)
.reversed()
.thenComparingLong(HydrologyCaveSource::sourceId)
.thenComparingInt(source -> source.entry().x())
.thenComparingInt(source -> source.entry().y())
.thenComparingInt(source -> source.entry().z())
.thenComparingInt(source -> source.target().x())
.thenComparingInt(source -> source.target().y())
.thenComparingInt(source -> source.target().z())
.thenComparing(HydrologyCaveSource::mode);
private HydrologyCaveConflictPolicy() {
}
public static Comparator<HydrologyCaveSource> sourcePriority() {
return SOURCE_PRIORITY;
}
public static int compareSources(HydrologyCaveSource left, HydrologyCaveSource right) {
return SOURCE_PRIORITY.compare(
Objects.requireNonNull(left, "left"),
Objects.requireNonNull(right, "right")
);
}
public static boolean hasIncompatibleOverlap(
String leftProfileKey,
Map<CavePosition, HydrologyCaveAction> leftActions,
String rightProfileKey,
Map<CavePosition, HydrologyCaveAction> rightActions
) {
String normalizedLeftProfileKey = requireProfileKey(leftProfileKey, "leftProfileKey");
String normalizedRightProfileKey = requireProfileKey(rightProfileKey, "rightProfileKey");
Map<CavePosition, HydrologyCaveAction> normalizedLeftActions = Objects.requireNonNull(
leftActions,
"leftActions"
);
Map<CavePosition, HydrologyCaveAction> normalizedRightActions = Objects.requireNonNull(
rightActions,
"rightActions"
);
if (normalizedLeftActions.size() > normalizedRightActions.size()) {
return hasIncompatibleOverlap(
normalizedRightProfileKey,
normalizedRightActions,
normalizedLeftProfileKey,
normalizedLeftActions
);
}
boolean sameProfile = normalizedLeftProfileKey.equals(normalizedRightProfileKey);
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : normalizedLeftActions.entrySet()) {
CavePosition position = Objects.requireNonNull(entry.getKey(), "left action position");
if (!normalizedRightActions.containsKey(position)) {
continue;
}
HydrologyCaveAction leftAction = Objects.requireNonNull(entry.getValue(), "left action");
HydrologyCaveAction rightAction = Objects.requireNonNull(
normalizedRightActions.get(position),
"right action"
);
if (!sameProfile || leftAction != rightAction) {
return true;
}
}
return false;
}
private static String requireProfileKey(String value, String name) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
}
@@ -0,0 +1,7 @@
package art.arcane.iris.engine.hydrology.cave;
public enum HydrologyCaveFluidPolicy {
REJECT_EXISTING,
ALLOW_COMPATIBLE,
REPLACE_CONTAINED
}
@@ -1,8 +1,8 @@
package art.arcane.iris.engine.river.cave; package art.arcane.iris.engine.hydrology.cave;
@FunctionalInterface @FunctionalInterface
public interface RiverCaveGrottoShape { public interface HydrologyCaveGrottoShape {
RiverCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> { HydrologyCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> {
double horizontalRadius = settings.grottoHorizontalRadius(); double horizontalRadius = settings.grottoHorizontalRadius();
double verticalRadius = settings.grottoVerticalRadius(); double verticalRadius = settings.grottoVerticalRadius();
double normalized = ((double) dx * dx / (horizontalRadius * horizontalRadius)) double normalized = ((double) dx * dx / (horizontalRadius * horizontalRadius))
@@ -12,8 +12,8 @@ public interface RiverCaveGrottoShape {
}; };
boolean contains( boolean contains(
RiverCaveSource source, HydrologyCaveSource source,
RiverCavePlannerSettings settings, HydrologyCavePlannerSettings settings,
int offsetX, int offsetX,
int offsetY, int offsetY,
int offsetZ int offsetZ
@@ -1,6 +1,6 @@
package art.arcane.iris.engine.river.cave; package art.arcane.iris.engine.hydrology.cave;
public enum RiverCaveMode { public enum HydrologyCaveMode {
CLOSED_COMPONENT, CLOSED_COMPONENT,
GENERATED_GROTTO, GENERATED_GROTTO,
GROTTO_OR_CLOSED_COMPONENT, GROTTO_OR_CLOSED_COMPONENT,
@@ -0,0 +1,692 @@
package art.arcane.iris.engine.hydrology.cave;
import java.util.AbstractCollection;
import java.util.AbstractMap;
import java.util.AbstractSet;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.NoSuchElementException;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.BiPredicate;
public record HydrologyCavePlan(
HydrologyCaveSource source,
HydrologyCaveRejection rejection,
Map<CavePosition, HydrologyCaveAction> actions,
Map<CavePosition, CaveVoxelPrecondition> baselinePreconditions,
OptionalLong arbitrationWinnerSourceId
) {
private static final int ACTION_MASK = 0x03;
private static final int ACTION_PRESENT = 0x04;
private static final int VOXEL_SHIFT = 3;
private static final int VOXEL_MASK = 0x38;
private static final int OPEN_TO_SURFACE = 0x40;
private static final HydrologyCaveAction[] ACTIONS = HydrologyCaveAction.values();
private static final CaveVoxel[] VOXELS = CaveVoxel.values();
private static final CaveVoxelPrecondition[][] PRECONDITIONS = createPreconditions();
public HydrologyCavePlan {
Objects.requireNonNull(source);
Objects.requireNonNull(rejection);
Map<CavePosition, HydrologyCaveAction> requestedActions = Objects.requireNonNull(actions);
Map<CavePosition, CaveVoxelPrecondition> requestedPreconditions =
Objects.requireNonNull(baselinePreconditions);
Objects.requireNonNull(arbitrationWinnerSourceId);
if (rejection != HydrologyCaveRejection.NONE
&& (!requestedActions.isEmpty() || !requestedPreconditions.isEmpty())) {
throw new IllegalArgumentException("Rejected cave plans cannot contain mutations or preconditions");
}
if (!requestedPreconditions.keySet().containsAll(requestedActions.keySet())) {
throw new IllegalArgumentException("Every cave action requires a baseline precondition");
}
if (rejection != HydrologyCaveRejection.OVERLAPPING_SOURCE && arbitrationWinnerSourceId.isPresent()) {
throw new IllegalArgumentException("Only overlap rejections can name an arbitration winner");
}
if (requestedPreconditions.isEmpty()) {
actions = Map.of();
baselinePreconditions = Map.of();
} else {
LinkedHashMap<CavePosition, Byte> packed = new LinkedHashMap<>(requestedPreconditions.size());
for (Map.Entry<CavePosition, HydrologyCaveAction> entry : requestedActions.entrySet()) {
CaveVoxelPrecondition precondition = requestedPreconditions.get(entry.getKey());
packed.put(entry.getKey(), encode(entry.getValue(), precondition));
}
for (Map.Entry<CavePosition, CaveVoxelPrecondition> entry : requestedPreconditions.entrySet()) {
packed.putIfAbsent(entry.getKey(), encode(null, entry.getValue()));
}
Map<CavePosition, Byte> immutablePacked = Collections.unmodifiableMap(packed);
PositionSpatialIndex spatialIndex = new PositionSpatialIndex(immutablePacked);
actions = new ActionMap(immutablePacked, requestedActions.size(), spatialIndex);
baselinePreconditions = new PreconditionMap(
immutablePacked,
List.copyOf(requestedPreconditions.keySet()),
spatialIndex
);
}
}
public boolean accepted() {
return rejection == HydrologyCaveRejection.NONE;
}
public void forEachAction(BiConsumer<CavePosition, HydrologyCaveAction> consumer) {
actions.forEach(consumer);
}
public void forEachPrecondition(BiConsumer<CavePosition, CaveVoxelPrecondition> consumer) {
baselinePreconditions.forEach(consumer);
}
public boolean allPreconditions(BiPredicate<CavePosition, CaveVoxelPrecondition> predicate) {
Objects.requireNonNull(predicate);
for (CavePosition position : baselinePreconditions.keySet()) {
if (!predicate.test(position, baselinePreconditions.get(position))) {
return false;
}
}
return true;
}
public boolean intersectsActions(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ
) {
if (maximumX <= minimumX || maximumZ <= minimumZ || !(actions instanceof ActionMap actionMap)) {
return false;
}
return actionMap.intersects(minimumX, minimumZ, maximumX, maximumZ);
}
public void forEachActionIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
BiConsumer<CavePosition, HydrologyCaveAction> consumer
) {
Objects.requireNonNull(consumer);
if (maximumX <= minimumX || maximumZ <= minimumZ || !(actions instanceof ActionMap actionMap)) {
return;
}
actionMap.forEachIn(minimumX, minimumZ, maximumX, maximumZ, consumer);
}
public boolean allPreconditionsIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
BiPredicate<CavePosition, CaveVoxelPrecondition> predicate
) {
Objects.requireNonNull(predicate);
if (maximumX <= minimumX || maximumZ <= minimumZ
|| !(baselinePreconditions instanceof PreconditionMap preconditionMap)) {
return true;
}
return preconditionMap.allIn(minimumX, minimumZ, maximumX, maximumZ, predicate);
}
private static byte encode(
HydrologyCaveAction action,
CaveVoxelPrecondition precondition
) {
Objects.requireNonNull(precondition);
int packed = precondition.voxel().ordinal() << VOXEL_SHIFT;
if (precondition.openToSurface()) {
packed |= OPEN_TO_SURFACE;
}
if (action != null) {
packed |= ACTION_PRESENT | action.ordinal();
}
return (byte) packed;
}
private static boolean hasAction(byte packed) {
return (packed & ACTION_PRESENT) != 0;
}
private static HydrologyCaveAction decodeAction(byte packed) {
return ACTIONS[packed & ACTION_MASK];
}
private static CaveVoxelPrecondition decodePrecondition(byte packed) {
int unsigned = Byte.toUnsignedInt(packed);
int voxel = unsigned & VOXEL_MASK;
int open = unsigned & OPEN_TO_SURFACE;
return PRECONDITIONS[voxel >>> VOXEL_SHIFT][open == 0 ? 0 : 1];
}
private static CaveVoxelPrecondition[][] createPreconditions() {
if (ACTIONS.length > ACTION_MASK + 1) {
throw new IllegalStateException("Hydrology cave action packing capacity exceeded");
}
if (VOXELS.length > (VOXEL_MASK >>> VOXEL_SHIFT) + 1) {
throw new IllegalStateException("Hydrology cave voxel packing capacity exceeded");
}
CaveVoxelPrecondition[][] preconditions = new CaveVoxelPrecondition[VOXELS.length][2];
for (CaveVoxel voxel : VOXELS) {
preconditions[voxel.ordinal()][0] = new CaveVoxelPrecondition(voxel, false);
preconditions[voxel.ordinal()][1] = new CaveVoxelPrecondition(voxel, true);
}
return preconditions;
}
private static final class ActionMap extends AbstractMap<CavePosition, HydrologyCaveAction> {
private final Map<CavePosition, Byte> packed;
private final int size;
private final PositionSpatialIndex spatialIndex;
private ActionMap(Map<CavePosition, Byte> packed, int size, PositionSpatialIndex spatialIndex) {
this.packed = packed;
this.size = size;
this.spatialIndex = spatialIndex;
}
@Override
public HydrologyCaveAction get(Object key) {
Byte value = packed.get(key);
return value == null || !hasAction(value) ? null : decodeAction(value);
}
@Override
public boolean containsKey(Object key) {
Byte value = packed.get(key);
return value != null && hasAction(value);
}
@Override
public int size() {
return size;
}
@Override
public Set<CavePosition> keySet() {
return new ActionKeySet(packed, size);
}
@Override
public Set<Entry<CavePosition, HydrologyCaveAction>> entrySet() {
return new ActionEntrySet(packed, size);
}
@Override
public Collection<HydrologyCaveAction> values() {
return new ActionValues(packed, size);
}
@Override
public void forEach(BiConsumer<? super CavePosition, ? super HydrologyCaveAction> consumer) {
Objects.requireNonNull(consumer);
for (Map.Entry<CavePosition, Byte> entry : packed.entrySet()) {
if (hasAction(entry.getValue())) {
consumer.accept(entry.getKey(), decodeAction(entry.getValue()));
}
}
}
private boolean intersects(int minimumX, int minimumZ, int maximumX, int maximumZ) {
return spatialIndex.anyIn(minimumX, minimumZ, maximumX, maximumZ,
(CavePosition position) -> hasAction(packed.get(position)));
}
private void forEachIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
BiConsumer<CavePosition, HydrologyCaveAction> consumer
) {
spatialIndex.forEachIn(minimumX, minimumZ, maximumX, maximumZ, (CavePosition position) -> {
byte value = packed.get(position);
if (hasAction(value)) {
consumer.accept(position, decodeAction(value));
}
});
}
}
private static final class PreconditionMap extends AbstractMap<CavePosition, CaveVoxelPrecondition> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private final PositionSpatialIndex spatialIndex;
private PreconditionMap(
Map<CavePosition, Byte> packed,
List<CavePosition> order,
PositionSpatialIndex spatialIndex
) {
this.packed = packed;
this.order = order;
this.spatialIndex = spatialIndex;
}
@Override
public CaveVoxelPrecondition get(Object key) {
Byte value = packed.get(key);
return value == null ? null : decodePrecondition(value);
}
@Override
public boolean containsKey(Object key) {
return packed.containsKey(key);
}
@Override
public int size() {
return order.size();
}
@Override
public Set<CavePosition> keySet() {
return new PreconditionKeySet(packed, order);
}
@Override
public Set<Entry<CavePosition, CaveVoxelPrecondition>> entrySet() {
return new PreconditionEntrySet(packed, order);
}
@Override
public Collection<CaveVoxelPrecondition> values() {
return new PreconditionValues(packed, order);
}
@Override
public void forEach(BiConsumer<? super CavePosition, ? super CaveVoxelPrecondition> consumer) {
Objects.requireNonNull(consumer);
for (CavePosition position : order) {
consumer.accept(position, decodePrecondition(packed.get(position)));
}
}
private boolean allIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
BiPredicate<CavePosition, CaveVoxelPrecondition> predicate
) {
return spatialIndex.allIn(minimumX, minimumZ, maximumX, maximumZ,
(CavePosition position) -> predicate.test(position, decodePrecondition(packed.get(position))));
}
}
private static final class PositionSpatialIndex {
private static final int CHUNK_SHIFT = 4;
private final Map<Long, List<CavePosition>> positionsByChunk;
private PositionSpatialIndex(Map<CavePosition, Byte> packed) {
HashMap<Long, ArrayList<CavePosition>> mutable = new HashMap<>();
for (CavePosition position : packed.keySet()) {
long chunkKey = packChunk(position.x() >> CHUNK_SHIFT, position.z() >> CHUNK_SHIFT);
mutable.computeIfAbsent(chunkKey, ignored -> new ArrayList<>()).add(position);
}
HashMap<Long, List<CavePosition>> immutable = HashMap.newHashMap(mutable.size());
for (Map.Entry<Long, ArrayList<CavePosition>> entry : mutable.entrySet()) {
immutable.put(entry.getKey(), List.copyOf(entry.getValue()));
}
positionsByChunk = Map.copyOf(immutable);
}
private boolean anyIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
java.util.function.Predicate<CavePosition> predicate
) {
int minimumChunkX = minimumX >> CHUNK_SHIFT;
int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT;
int minimumChunkZ = minimumZ >> CHUNK_SHIFT;
int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT;
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
List<CavePosition> positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
if (positions == null) {
continue;
}
for (CavePosition position : positions) {
if (inside(position, minimumX, minimumZ, maximumX, maximumZ)
&& predicate.test(position)) {
return true;
}
}
}
}
return false;
}
private boolean allIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
java.util.function.Predicate<CavePosition> predicate
) {
int minimumChunkX = minimumX >> CHUNK_SHIFT;
int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT;
int minimumChunkZ = minimumZ >> CHUNK_SHIFT;
int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT;
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
List<CavePosition> positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
if (positions == null) {
continue;
}
for (CavePosition position : positions) {
if (inside(position, minimumX, minimumZ, maximumX, maximumZ)
&& !predicate.test(position)) {
return false;
}
}
}
}
return true;
}
private void forEachIn(
int minimumX,
int minimumZ,
int maximumX,
int maximumZ,
java.util.function.Consumer<CavePosition> consumer
) {
int minimumChunkX = minimumX >> CHUNK_SHIFT;
int maximumChunkX = maximumX - 1 >> CHUNK_SHIFT;
int minimumChunkZ = minimumZ >> CHUNK_SHIFT;
int maximumChunkZ = maximumZ - 1 >> CHUNK_SHIFT;
for (int chunkZ = minimumChunkZ; chunkZ <= maximumChunkZ; chunkZ++) {
for (int chunkX = minimumChunkX; chunkX <= maximumChunkX; chunkX++) {
List<CavePosition> positions = positionsByChunk.get(packChunk(chunkX, chunkZ));
if (positions == null) {
continue;
}
for (CavePosition position : positions) {
if (inside(position, minimumX, minimumZ, maximumX, maximumZ)) {
consumer.accept(position);
}
}
}
}
}
private static boolean inside(
CavePosition position,
int minimumX,
int minimumZ,
int maximumX,
int maximumZ
) {
return position.x() >= minimumX && position.x() < maximumX
&& position.z() >= minimumZ && position.z() < maximumZ;
}
private static long packChunk(int chunkX, int chunkZ) {
return (long) chunkX << 32 ^ chunkZ & 0xffffffffL;
}
}
private static final class ActionKeySet extends AbstractSet<CavePosition> {
private final Map<CavePosition, Byte> packed;
private final int size;
private ActionKeySet(Map<CavePosition, Byte> packed, int size) {
this.packed = packed;
this.size = size;
}
@Override
public Iterator<CavePosition> iterator() {
return new ActionKeyIterator(packed.entrySet().iterator());
}
@Override
public int size() {
return size;
}
@Override
public boolean contains(Object value) {
Byte packedValue = packed.get(value);
return packedValue != null && hasAction(packedValue);
}
}
private static final class ActionEntrySet extends AbstractSet<Entry<CavePosition, HydrologyCaveAction>> {
private final Map<CavePosition, Byte> packed;
private final int size;
private ActionEntrySet(Map<CavePosition, Byte> packed, int size) {
this.packed = packed;
this.size = size;
}
@Override
public Iterator<Entry<CavePosition, HydrologyCaveAction>> iterator() {
return new ActionEntryIterator(packed.entrySet().iterator());
}
@Override
public int size() {
return size;
}
}
private static final class PreconditionEntrySet
extends AbstractSet<Entry<CavePosition, CaveVoxelPrecondition>> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private PreconditionEntrySet(Map<CavePosition, Byte> packed, List<CavePosition> order) {
this.packed = packed;
this.order = order;
}
@Override
public Iterator<Entry<CavePosition, CaveVoxelPrecondition>> iterator() {
return new PreconditionEntryIterator(packed, order.iterator());
}
@Override
public int size() {
return order.size();
}
}
private static final class PreconditionKeySet extends AbstractSet<CavePosition> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private PreconditionKeySet(Map<CavePosition, Byte> packed, List<CavePosition> order) {
this.packed = packed;
this.order = order;
}
@Override
public Iterator<CavePosition> iterator() {
return order.iterator();
}
@Override
public int size() {
return order.size();
}
@Override
public boolean contains(Object value) {
return packed.containsKey(value);
}
}
private static final class ActionValues extends AbstractCollection<HydrologyCaveAction> {
private final Map<CavePosition, Byte> packed;
private final int size;
private ActionValues(Map<CavePosition, Byte> packed, int size) {
this.packed = packed;
this.size = size;
}
@Override
public Iterator<HydrologyCaveAction> iterator() {
return new ActionValueIterator(packed.entrySet().iterator());
}
@Override
public int size() {
return size;
}
}
private static final class PreconditionValues extends AbstractCollection<CaveVoxelPrecondition> {
private final Map<CavePosition, Byte> packed;
private final List<CavePosition> order;
private PreconditionValues(Map<CavePosition, Byte> packed, List<CavePosition> order) {
this.packed = packed;
this.order = order;
}
@Override
public Iterator<CaveVoxelPrecondition> iterator() {
return new PreconditionValueIterator(packed, order.iterator());
}
@Override
public int size() {
return order.size();
}
}
private abstract static class FilteredActionIterator<T> implements Iterator<T> {
private final Iterator<Map.Entry<CavePosition, Byte>> entries;
private Map.Entry<CavePosition, Byte> next;
private FilteredActionIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
this.entries = entries;
}
@Override
public boolean hasNext() {
advance();
return next != null;
}
@Override
public T next() {
advance();
if (next == null) {
throw new NoSuchElementException();
}
Map.Entry<CavePosition, Byte> selected = next;
next = null;
return map(selected);
}
protected abstract T map(Map.Entry<CavePosition, Byte> entry);
private void advance() {
while (next == null && entries.hasNext()) {
Map.Entry<CavePosition, Byte> candidate = entries.next();
if (hasAction(candidate.getValue())) {
next = candidate;
}
}
}
}
private static final class ActionKeyIterator extends FilteredActionIterator<CavePosition> {
private ActionKeyIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
super(entries);
}
@Override
protected CavePosition map(Map.Entry<CavePosition, Byte> entry) {
return entry.getKey();
}
}
private static final class ActionEntryIterator
extends FilteredActionIterator<Entry<CavePosition, HydrologyCaveAction>> {
private ActionEntryIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
super(entries);
}
@Override
protected Entry<CavePosition, HydrologyCaveAction> map(Map.Entry<CavePosition, Byte> entry) {
return Map.entry(entry.getKey(), decodeAction(entry.getValue()));
}
}
private static final class ActionValueIterator extends FilteredActionIterator<HydrologyCaveAction> {
private ActionValueIterator(Iterator<Map.Entry<CavePosition, Byte>> entries) {
super(entries);
}
@Override
protected HydrologyCaveAction map(Map.Entry<CavePosition, Byte> entry) {
return decodeAction(entry.getValue());
}
}
private static final class PreconditionEntryIterator
implements Iterator<Entry<CavePosition, CaveVoxelPrecondition>> {
private final Map<CavePosition, Byte> packed;
private final Iterator<CavePosition> positions;
private PreconditionEntryIterator(
Map<CavePosition, Byte> packed,
Iterator<CavePosition> positions
) {
this.packed = packed;
this.positions = positions;
}
@Override
public boolean hasNext() {
return positions.hasNext();
}
@Override
public Entry<CavePosition, CaveVoxelPrecondition> next() {
CavePosition position = positions.next();
return Map.entry(position, decodePrecondition(packed.get(position)));
}
}
private static final class PreconditionValueIterator implements Iterator<CaveVoxelPrecondition> {
private final Map<CavePosition, Byte> packed;
private final Iterator<CavePosition> positions;
private PreconditionValueIterator(
Map<CavePosition, Byte> packed,
Iterator<CavePosition> positions
) {
this.packed = packed;
this.positions = positions;
}
@Override
public boolean hasNext() {
return positions.hasNext();
}
@Override
public CaveVoxelPrecondition next() {
return decodePrecondition(packed.get(positions.next()));
}
}
}
@@ -1,8 +1,8 @@
package art.arcane.iris.engine.river.cave; package art.arcane.iris.engine.hydrology.cave;
import java.util.Objects; import java.util.Objects;
public record RiverCavePlannerSettings( public record HydrologyCavePlannerSettings(
int maxHorizontalRadius, int maxHorizontalRadius,
int maxDepth, int maxDepth,
int maxFloodVolume, int maxFloodVolume,
@@ -11,12 +11,14 @@ public record RiverCavePlannerSettings(
int grottoHorizontalRadius, int grottoHorizontalRadius,
int grottoVerticalRadius, int grottoVerticalRadius,
int dryHeadroom, int dryHeadroom,
RiverCaveFluidPolicy existingFluidPolicy, HydrologyCaveFluidPolicy existingFluidPolicy,
RiverCaveGrottoShape grottoShape, HydrologyCaveGrottoShape grottoShape,
int maxClosedComponentHorizontalRadius, int maxClosedComponentHorizontalRadius,
int maxClosedComponentDepth int maxClosedComponentDepth
) { ) {
public RiverCavePlannerSettings( public static final int MAXIMUM_PLANNED_MUTATIONS = 262_144;
public HydrologyCavePlannerSettings(
int maxHorizontalRadius, int maxHorizontalRadius,
int maxDepth, int maxDepth,
int maxFloodVolume, int maxFloodVolume,
@@ -25,8 +27,8 @@ public record RiverCavePlannerSettings(
int grottoHorizontalRadius, int grottoHorizontalRadius,
int grottoVerticalRadius, int grottoVerticalRadius,
int dryHeadroom, int dryHeadroom,
RiverCaveFluidPolicy existingFluidPolicy, HydrologyCaveFluidPolicy existingFluidPolicy,
RiverCaveGrottoShape grottoShape HydrologyCaveGrottoShape grottoShape
) { ) {
this( this(
maxHorizontalRadius, maxHorizontalRadius,
@@ -44,14 +46,14 @@ public record RiverCavePlannerSettings(
); );
} }
public RiverCavePlannerSettings( public HydrologyCavePlannerSettings(
int maxHorizontalRadius, int maxHorizontalRadius,
int maxDepth, int maxDepth,
int maxFloodVolume, int maxFloodVolume,
int maxThroatLength, int maxThroatLength,
int grottoHorizontalRadius, int grottoHorizontalRadius,
int grottoVerticalRadius, int grottoVerticalRadius,
RiverCaveFluidPolicy existingFluidPolicy HydrologyCaveFluidPolicy existingFluidPolicy
) { ) {
this( this(
maxHorizontalRadius, maxHorizontalRadius,
@@ -63,11 +65,11 @@ public record RiverCavePlannerSettings(
grottoVerticalRadius, grottoVerticalRadius,
0, 0,
existingFluidPolicy, existingFluidPolicy,
RiverCaveGrottoShape.ELLIPSOID HydrologyCaveGrottoShape.ELLIPSOID
); );
} }
public RiverCavePlannerSettings { public HydrologyCavePlannerSettings {
Objects.requireNonNull(existingFluidPolicy); Objects.requireNonNull(existingFluidPolicy);
Objects.requireNonNull(grottoShape); Objects.requireNonNull(grottoShape);
if (maxHorizontalRadius < 1) { if (maxHorizontalRadius < 1) {
Loaded 100 of 352 files, more files were not shown because too many files have changed in this diff. Show more