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
2026-09-01 18:28:00 -04:00
parent bdaa84975a
commit 48afa0d93f
352 changed files with 57955 additions and 19113 deletions
@@ -28,6 +28,9 @@ import art.arcane.iris.engine.framework.Engine;
import art.arcane.iris.engine.framework.IrisStructureLocator;
import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
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.IrisNativeStructureDecision;
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.PlatformStructureHooks;
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.ReachableBiomeHandler;
import art.arcane.iris.util.common.director.specialhandlers.StructureHandler;
import art.arcane.iris.util.common.format.C;
import art.arcane.iris.util.common.plugin.VolmitSender;
@@ -68,7 +73,7 @@ import java.util.Set;
public class CommandFind implements DirectorExecutor {
@Director(description = "Find a biome", descriptionKey = "iris.director.commandfind.director.find_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,
@Param(description = "Should you be teleported", descriptionKey = "iris.director.commandfind.param.should_you_be_teleported", defaultValue = "true")
boolean teleport
@@ -116,6 +121,73 @@ public class CommandFind implements DirectorExecutor {
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)
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)
@@ -25,7 +25,10 @@ import art.arcane.iris.core.pack.PackValidationCache;
import art.arcane.iris.core.pack.PackValidationRegistry;
import art.arcane.iris.core.pack.PackValidationResult;
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.handlers.DimensionHandler;
import art.arcane.iris.util.common.plugin.VolmitSender;
import art.arcane.volmlib.util.director.annotations.Director;
import art.arcane.volmlib.util.director.annotations.Param;
@@ -90,6 +93,18 @@ public class CommandPack implements DirectorExecutor {
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"})
public void cleanup(
@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));
}
@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)
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)
@@ -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.IrisWorldInfoFactory;
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.IrisBiome;
import art.arcane.iris.engine.object.IrisRegion;
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.spi.IrisLogging;
import art.arcane.iris.spi.IrisServices;
@@ -105,14 +105,15 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
try {
int surface = engine.getHeight(blockX, blockZ);
IrisRiverSurfaceSample riverSurface = engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ);
int fluid = (int) Math.round(riverSurface.waterSurfaceY());
HydrologyColumnSample hydrology = hydrologySample(engine, blockX, blockZ);
int fluid = (int) Math.round(
engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ));
InferredType inferredType = null;
if (IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid)) {
IrisBiome biome = engine.getSurfaceBiome(blockX, blockZ);
inferredType = biome == null ? null : biome.getInferredType();
}
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, riverSurface);
return IrisSurfaceClassifier.classify(surface, fluid, inferredType, hydrology);
} catch (Throwable error) {
reportQueryFault("surfaceKind", world, error);
return IrisSurfaceKind.UNKNOWN;
@@ -246,13 +247,13 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
return false;
}
IrisRiverSurfaceSample riverSurface = wantRiver
? engine.getComplex().getRiverSurfaceStream().get(blockX, blockZ)
HydrologyColumnSample hydrology = wantRiver
? hydrologySample(engine, blockX, blockZ)
: null;
int surface = wantHeight || wantKind ? engine.getHeight(blockX, blockZ) : 0;
int fluid = riverSurface == null
? engine.getDimension().getFluidHeight()
: (int) Math.round(riverSurface.waterSurfaceY());
int fluid = wantRiver
? (int) Math.round(engine.getComplex().getRiverWaterSurfaceStream().get(blockX, blockZ))
: engine.getDimension().getFluidHeight();
boolean needsBiome = wantBiome
|| (wantKind && IrisSurfaceClassifier.requiresSurfaceBiome(surface, fluid));
IrisBiome biome = needsBiome ? engine.getSurfaceBiome(blockX, blockZ) : null;
@@ -261,26 +262,29 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
surface,
fluid,
biome == null ? null : biome.getInferredType(),
riverSurface
hydrology
)
: IrisSurfaceKind.UNKNOWN;
String biomeKey = wantBiome && biome != null ? biome.getLoadKey() : null;
int natural = wantNaturalHeight
? (int) Math.round(engine.getComplex().getNaturalHeightStream().get(blockX, blockZ)) + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
boolean riverPresent = riverSurface != null && riverSurface.river().present();
IrisRiverState riverState = wantRiverState && riverSurface != null
? riverState(riverSurface)
HydrologyColumnLayer surfaceLayer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
boolean riverPresent = surfaceLayer != null;
IrisRiverState riverState = wantRiverState
? riverState(hydrology)
: IrisRiverState.NONE;
double riverDistance = wantRiverDistance && riverPresent
? riverSurface.river().distance()
? engine.getComplex().getRiverDistanceStream().get(blockX, blockZ)
: IrisColumnSample.UNAVAILABLE_RIVER_DISTANCE;
int riverFlow = wantRiverFlow && riverPresent
? riverSurface.river().flow()
? (int) Math.round(engine.getComplex().getRiverFlowStream().get(blockX, blockZ))
: IrisColumnSample.UNAVAILABLE_RIVER_FLOW;
int riverWaterSurfaceY = wantRiverWaterSurface
&& riverPresent
&& riverSurface.river().state() == RiverRouteState.WET
&& surfaceLayer != null
&& surfaceLayer.connectedFluid()
? fluid + minHeight
: IrisColumnSample.UNAVAILABLE_HEIGHT;
sink.accept(new IrisColumnSample(
@@ -304,15 +308,21 @@ public class IrisTerrainSVC implements IrisService, IrisTerrainService {
}
}
static IrisRiverState riverState(IrisRiverSurfaceSample surface) {
if (!surface.river().present()) {
static IrisRiverState riverState(HydrologyColumnSample hydrology) {
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer == null || !layer.channel()) {
return IrisRiverState.NONE;
}
return switch (surface.river().state()) {
case WET -> IrisRiverState.WET;
case DRY -> IrisRiverState.DRY;
case SUPPRESSED -> IrisRiverState.NONE;
};
return layer.connectedFluid() ? IrisRiverState.WET : IrisRiverState.DRY;
}
private static HydrologyColumnSample hydrologySample(Engine engine, int blockX, int blockZ) {
if (engine.getComplex().getHydrologyRuntime() == null) {
return null;
}
return engine.getComplex().getHydrologyRuntime().sample(blockX, blockZ).orElse(null);
}
private static Optional<String> key(IrisBiome biome) {
@@ -1,10 +1,9 @@
package art.arcane.iris.core.service.terrain;
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.river.RiverRouteState;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
public final class IrisSurfaceClassifier {
private IrisSurfaceClassifier() {
@@ -30,25 +29,23 @@ public final class IrisSurfaceClassifier {
int engineSurfaceHeight,
int engineFluidHeight,
InferredType inferredType,
IrisRiverSurfaceSample riverSurface
HydrologyColumnSample hydrology
) {
if (engineSurfaceHeight <= 0) {
return IrisSurfaceKind.VOID;
}
if (riverSurface != null && riverSurface.river().present() && !riverSurface.subterranean()) {
if (riverSurface.river().state() == RiverRouteState.DRY) {
return riverSurface.river().section() == RiverSection.DRY_CHANNEL
? IrisSurfaceKind.DRY_CHANNEL
: IrisSurfaceKind.LAND;
HydrologyColumnLayer layer = hydrology == null
? null
: hydrology.primarySurfaceLayer().orElse(null);
if (layer != null) {
if (layer.shore()) {
return IrisSurfaceKind.RIVER_SHORE;
}
if (riverSurface.river().state() == RiverRouteState.WET) {
RiverSection section = riverSurface.river().section();
if (section == RiverSection.BANK) {
return IrisSurfaceKind.RIVER_SHORE;
}
if (section == RiverSection.CHANNEL || section == RiverSection.MOUTH) {
return IrisSurfaceKind.RIVER;
}
if (layer.channel()) {
return layer.connectedFluid() ? IrisSurfaceKind.RIVER : IrisSurfaceKind.DRY_CHANNEL;
}
if (layer.grading()) {
return IrisSurfaceKind.LAND;
}
}
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;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.volmlib.util.director.compat.DirectorAnnotationCompatibility;
import art.arcane.volmlib.util.director.runtime.DirectorNodeDescriptor;
import art.arcane.volmlib.util.director.runtime.DirectorParameterDescriptor;
import org.junit.Test;
import java.lang.reflect.Method;
import java.util.List;
import static org.junit.Assert.assertEquals;
@@ -52,8 +54,23 @@ public class CommandPackOptionalPackContractTest {
assertFalse(CommandPack.wantsAllPacks("overworld"));
}
private static java.lang.reflect.Method findMethod(String name) {
for (java.lang.reflect.Method method : CommandPack.class.getDeclaredMethods()) {
@Test
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)) {
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.IrisSurfaceKind;
import art.arcane.iris.api.terrain.IrisTerrainService;
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 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.util.common.plugin.IrisService;
import org.bukkit.World;
import org.junit.Test;
@@ -19,6 +17,7 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.List;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.concurrent.atomic.AtomicInteger;
@@ -104,36 +103,46 @@ public class IrisTerrainSVCTest {
}
@Test
public void riverRouteStatesMapToThePublicDiagnosticStates() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(
new IrisRiverSurfaceSample(RiverSample.none(), 70D, 70D, 70D, false, 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)));
public void acceptedHydrologyMapsToThePublicDiagnosticStates() {
assertEquals(IrisRiverState.NONE, IrisTerrainSVC.riverState(null));
assertEquals(IrisRiverState.WET, IrisTerrainSVC.riverState(river(true)));
assertEquals(IrisRiverState.DRY, IrisTerrainSVC.riverState(river(false)));
}
private static IrisRiverSurfaceSample river(RiverRouteState state) {
RiverSection section = switch (state) {
case WET -> RiverSection.CHANNEL;
case DRY -> RiverSection.DRY_CHANNEL;
case SUPPRESSED -> RiverSection.NONE;
};
RiverSample sample = new RiverSample(
true,
state,
section,
0D,
0.5D,
1D,
private static HydrologyColumnSample river(boolean connectedFluid) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
HydrologyFeatureType.SURFACE_POOL,
2L,
3L,
0,
63,
0,
1,
1,
8D,
4D,
3D,
false,
RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0))
0,
false
);
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;
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.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 java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@@ -59,91 +59,108 @@ public class IrisSurfaceClassifierTest {
IrisSurfaceKind expected = IrisSurfaceClassifier.classify(surface, FLUID, null);
for (InferredType inferredType : InferredType.values()) {
assertEquals("surface=" + surface + " type=" + inferredType,
expected, IrisSurfaceClassifier.classify(surface, FLUID, inferredType));
assertEquals(
"surface=" + surface + " type=" + inferredType,
expected,
IrisSurfaceClassifier.classify(surface, FLUID, inferredType)
);
}
}
}
@Test
public void activeRiverGeometryOverridesGenericOceanAndLandKinds() {
public void acceptedSurfaceRolesOverrideGenericOceanAndLandKinds() {
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
river(RiverRouteState.WET, RiverSection.CHANNEL)
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER, IrisSurfaceClassifier.classify(
60,
63,
InferredType.SEA,
river(RiverRouteState.WET, RiverSection.MOUTH)
hydrology(HydrologyFeatureType.MOUTH, true, false, true)
));
assertEquals(IrisSurfaceKind.RIVER_SHORE, IrisSurfaceClassifier.classify(
64,
63,
InferredType.SHORE,
river(RiverRouteState.WET, RiverSection.BANK)
hydrology(HydrologyFeatureType.SURFACE_POOL, false, true, false)
));
assertEquals(IrisSurfaceKind.DRY_CHANNEL, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
river(RiverRouteState.DRY, RiverSection.DRY_CHANNEL)
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, false)
));
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
60,
60,
InferredType.LAND,
river(RiverRouteState.DRY, RiverSection.DRY_BANK)
hydrology(HydrologyFeatureType.SURFACE_POOL, false, false, false)
));
}
@Test
public void voidClassificationWinsOverRiverGeometry() {
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(
0,
63,
InferredType.LAND,
river(state, section)
));
}
public void voidClassificationWinsOverAcceptedRiverGeometry() {
assertEquals(IrisSurfaceKind.VOID, IrisSurfaceClassifier.classify(
0,
63,
InferredType.LAND,
hydrology(HydrologyFeatureType.SURFACE_POOL, true, false, true)
));
}
@Test
public void suppressedRoutesDoNotCreatePublicRiverSurfaceKinds() {
public void absentRejectedCandidatesDoNotCreatePublicRiverKinds() {
assertEquals(IrisSurfaceKind.LAND, IrisSurfaceClassifier.classify(
64,
63,
InferredType.LAND,
river(RiverRouteState.SUPPRESSED, RiverSection.CHANNEL)
null
));
}
private static IrisRiverSurfaceSample river(RiverRouteState state, RiverSection section) {
RiverSample sample = new RiverSample(
true,
state,
section,
0D,
0.5D,
1D,
private static HydrologyColumnSample hydrology(
HydrologyFeatureType type,
boolean channel,
boolean shore,
boolean connectedFluid
) {
HydrologyFeatureRef feature = new HydrologyFeatureRef(
1L,
type,
2L,
3L,
0,
63,
0,
1,
1,
8D,
4D,
3D,
false,
RiverEdgeId.of(new RiverNodeId(0, 0), new RiverNodeId(1, 0))
0,
false
);
double waterSurface = state == RiverRouteState.WET ? 63D : 60D;
return new IrisRiverSurfaceSample(sample, 70D, 60D, waterSurface, false, state == RiverRouteState.WET);
HydrologyColumnLayer layer = new HydrologyColumnLayer(
feature,
60,
63,
63,
channel,
shore,
!channel && !shore,
connectedFluid,
false,
false,
true,
connectedFluid && channel,
false,
"water",
"river",
"mouth",
"shore",
"dry",
"cave"
);
return new HydrologyColumnSample(0, 0, 70, 63, false, "parent", List.of(layer));
}
}
+4
View File
@@ -114,6 +114,10 @@ dependencies {
minecraft("com.mojang:minecraft:${minecraftVersion}")
implementation("net.fabricmc:fabric-loader:${fabricLoaderVersion}")
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:
// fabric-registry-sync-v0 delays registry freeze past mod init, which is what lets
// IrisFabricBootstrap register the CHUNK_GENERATOR codec in
+4
View File
@@ -131,6 +131,10 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies {
implementation minecraft.dependency("net.minecraftforge:forge:${forgeVersion}")
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(libs.spigot) {
transitive = false
@@ -18,6 +18,7 @@
package art.arcane.iris.modded;
import art.arcane.iris.engine.IrisEngineMantle;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -41,7 +42,7 @@ public final class MainWorldService {
"region",
"entities",
"poi",
"mantle",
IrisEngineMantle.STORAGE_FOLDER_NAME,
"dimensions/minecraft/overworld",
"dimensions/minecraft/the_nether",
"dimensions/minecraft/the_end",
@@ -18,6 +18,7 @@
package art.arcane.iris.modded;
import art.arcane.iris.engine.IrisEngineMantle;
import net.minecraft.resources.ResourceKey;
import net.minecraft.server.MinecraftServer;
import net.minecraft.world.level.Level;
@@ -33,7 +34,12 @@ import java.util.List;
import java.util.stream.Stream;
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() {
}
@@ -325,7 +325,7 @@ public final class IrisModdedCommands {
return 0;
}
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();
if (scheduler == null) {
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.NativeStructureGenerationPolicy;
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.NativeStructureGenerationStatus;
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> 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> 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> DIMENSION_NAMES = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> suggestDimensionNames(context, builder);
@@ -79,7 +82,7 @@ final class ModdedCommandSuggestions {
try {
Engine engine = IrisModdedCommands.engineFor(context.getSource().getLevel());
if (engine != null) {
return SharedSuggestionProvider.suggest(engine.getData().getBiomeLoader().getPossibleKeys(), builder);
return SharedSuggestionProvider.suggest(reachableBiomeKeys(engine), builder);
}
} catch (Throwable e) {
warnTabFailure("biome keys", context.getSource(), e);
@@ -87,6 +90,46 @@ final class ModdedCommandSuggestions {
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) {
ModdedCommandFeedback.tab(context.getSource());
try {
@@ -227,6 +227,9 @@ final class ModdedCommandTree {
.then(Commands.literal("object")
.then(Commands.argument("key", StringArgumentType.greedyString()).suggests(ModdedCommandSuggestions.OBJECT_KEYS)
.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")
.executes((CommandContext<CommandSourceStack> context) ->
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.StructureReachability;
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.IrisNativeStructureDecision;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.NativeStructureGenerationStatus;
import art.arcane.iris.modded.IrisModdedChunkGenerator;
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.math.Position2;
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)));
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());
return 1;
}
@@ -137,6 +145,65 @@ final class ModdedLocateCommands {
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) {
ServerLevel level = source.getLevel();
Engine engine = IrisModdedCommands.engineFor(level);
@@ -341,8 +341,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
}
markCompleted();
listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z);
listener.onChunkCleaned(x, z);
cleanupMantleChunksCoveredBy(x, z, listener);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (TimeoutException | ExecutionException e) {
@@ -406,8 +405,7 @@ public final class ModdedPregenMethod implements PregeneratorMethod {
onSuccess();
markCompleted();
listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z);
listener.onChunkCleaned(x, z);
cleanupMantleChunksCoveredBy(x, z, listener);
} finally {
markFinished();
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 {
engine.getMantle().forceCleanupChunk(x, z);
engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned);
} catch (Throwable e) {
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();
int minHeight = engine.getWorld().minHeight();
int baseX = chunkX << 4;
@@ -187,6 +187,17 @@ public class ModdedGenerationLeaseContractTest {
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
public void invalidPregenBoundsFailBeforeRuntimeMutation() throws IOException {
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;
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.volmlib.util.function.Consumer4;
import art.arcane.volmlib.util.mantle.runtime.MantleChunk;
import art.arcane.volmlib.util.mantle.runtime.MantleDataAdapter;
import art.arcane.volmlib.util.mantle.runtime.MantleHooks;
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.mockito.InOrder;
import java.lang.reflect.Proxy;
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.assertThrows;
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 {
@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
public void scansWhenPlayersArePresent() {
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")
private static MantleDataAdapter<Matter> emptyAdapter() {
return (MantleDataAdapter<Matter>) Proxy.newProxyInstance(
+4
View File
@@ -105,6 +105,10 @@ configurations.testRuntimeClasspath.extendsFrom(configurations.runtimeClasspath,
dependencies {
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.vintage:junit-vintage-engine:6.1.2')
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.render.IrisRenderer;
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.IrisDimension;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.river.RiverSection;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.volmlib.util.format.Form;
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 JComboBox<RenderType> modeSelector;
private JToggleButton gridToggle;
private JToggleButton entitiesToggle;
private JToggleButton followToggle;
private RenderType currentType;
private List<GuiMarker> players;
@@ -142,6 +144,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
private double blocksPerPixel;
private int paintCadenceFps;
private boolean grid;
private boolean entitiesVisible;
private boolean follow;
private boolean help;
private boolean debug;
@@ -160,6 +163,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
this.notifications = new LinkedHashMap<>();
this.players = List.of();
this.entities = List.of();
this.entitiesVisible = false;
this.currentType = RenderType.BIOME;
this.blocksPerPixel = DEFAULT_BLOCKS_PER_PIXEL;
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.addActionListener(event -> vision.toggleGrid());
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.addActionListener(event -> vision.toggleFollow());
trailing.add(vision.followToggle);
@@ -411,11 +421,13 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
}
private void renderMarkers(Graphics2D canvas) {
for (GuiMarker marker : entities) {
int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
int screenY = (int) Math.round(worldToScreenZ(marker.worldZ()));
canvas.setColor(MOB_COLOR);
canvas.fillRect(screenX - 2, screenY - 2, 5, 5);
if (entitiesVisible) {
for (GuiMarker marker : entities) {
int screenX = (int) Math.round(worldToScreenX(marker.worldX()));
int screenY = (int) Math.round(worldToScreenZ(marker.worldZ()));
canvas.setColor(MOB_COLOR);
canvas.fillRect(screenX - 2, screenY - 2, 5, 5);
}
}
for (GuiMarker marker : players) {
@@ -430,7 +442,7 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
int labelWidth = canvas.getFontMetrics().stringWidth(marker.label());
canvas.drawString(marker.label(), screenX - labelWidth / 2, screenY - 14);
}
if (detailedHover) {
if (entitiesVisible && detailedHover) {
renderNearestEntity(canvas);
}
}
@@ -483,21 +495,40 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
}
private void renderRiverLegend(Graphics2D canvas) {
RiverSection[] sections = RiverSection.values();
HydrologyFeatureType[] types = HydrologyFeatureType.values();
HydrologyCandidateKind[] candidateKinds = HydrologyCandidateKind.values();
int lineHeight = 18;
int width = 148;
int height = sections.length * lineHeight + CARD_PADDING * 2;
int width = 188;
int height = (types.length + candidateKinds.length + 1) * lineHeight + CARD_PADDING * 2;
int x = getWidth() - width - CARD_PADDING;
int y = getHeight() - STATUS_HEIGHT - PROGRESS_HEIGHT - height - CARD_PADDING;
drawCardBackground(canvas, x, y, width, height);
canvas.setFont(BODY_FONT);
for (int index = 0; index < sections.length; index++) {
RiverSection section = sections[index];
int rowY = y + CARD_PADDING + index * lineHeight;
canvas.setColor(new Color(IrisRenderer.riverColor(section)));
int headwaterY = y + CARD_PADDING;
canvas.setColor(new Color(IrisRenderer.headwaterColor()));
canvas.fillRoundRect(x + CARD_PADDING, headwaterY + 2, 12, 12, 4, 4);
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.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);
}
}
@@ -783,12 +814,16 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
try {
List<GuiMarker> nextPlayers = overlay.players();
players = nextPlayers == null ? List.of() : List.copyOf(nextPlayers);
overlay.requestEntities(next -> EventQueue.invokeLater(() -> {
if (!closed) {
entities = next == null ? List.of() : List.copyOf(next);
repaint();
}
}));
if (entitiesVisible) {
overlay.requestEntities(next -> EventQueue.invokeLater(() -> {
if (!closed && entitiesVisible) {
entities = next == null ? List.of() : List.copyOf(next);
repaint();
}
}));
} else if (!entities.isEmpty()) {
entities = List.of();
}
if (follow && !players.isEmpty()) {
GuiMarker player = players.get(0);
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));
}
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() {
follow = !follow;
if (follow && players.isEmpty()) {
@@ -881,6 +932,9 @@ public final class VisionGUI extends JPanel implements MouseWheelListener, KeyLi
if (gridToggle != null) {
gridToggle.setSelected(grid);
}
if (entitiesToggle != null) {
entitiesToggle.setSelected(entitiesVisible);
}
if (followToggle != null) {
followToggle.setSelected(follow);
}
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.engine.IrisEngineMantle;
import org.bukkit.NamespacedKey;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
@@ -253,7 +254,9 @@ public final class BukkitWorldConfiguration {
if (Files.isDirectory(worldRoot.resolve("iris"), LinkOption.NOFOLLOW_LINKS)) {
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))) {
return true;
}
@@ -2,6 +2,7 @@ package art.arcane.iris.core.lifecycle;
import art.arcane.iris.core.IrisWorldStorage;
import art.arcane.iris.core.WorldSlotKey;
import art.arcane.iris.engine.IrisEngineMantle;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
@@ -37,7 +38,9 @@ import java.util.stream.Stream;
public final class HuskWorldQuarantine {
private static final String IRIS_NAMESPACE = "iris";
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 String APPLE_DOUBLE_PREFIX = "._";
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_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_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_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_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_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_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");
@@ -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");
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_ENTITIES_ENABLED, VISION_ENTITIES_DISABLED,
VISION_FOLLOWING, VISION_NO_PLAYER, VISION_FOLLOW_DISABLED,
VISION_STATUS_LEFT, VISION_STATUS_RIGHT, VISION_ENTITY_POSITION, VISION_ENTITY_HEALTH,
VISION_BLOCK_POSITION, VISION_CHUNK_POSITION, VISION_REGION_POSITION, VISION_BIOME_KEY,
@@ -388,7 +388,7 @@ public final class DefaultPackBootstrapProvisioner {
try {
validCache = validateArchive(archivePath, spec);
} 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", "");
@@ -439,7 +439,7 @@ public final class DefaultPackBootstrapProvisioner {
copyLimited(input, output, options.maxArchiveBytes(), 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);
} finally {
@@ -452,11 +452,11 @@ public final class DefaultPackBootstrapProvisioner {
updated.setProperty("fetchedAt", Long.toString(options.clock().millis()));
updated.setProperty("sha256", sha256(archivePath));
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"));
}
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)) {
networkFailure = statusFailure;
break;
@@ -464,7 +464,7 @@ public final class DefaultPackBootstrapProvisioner {
networkFailure = statusFailure;
} catch (InterruptedException exception) {
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) {
networkFailure = exception;
}
@@ -474,7 +474,7 @@ public final class DefaultPackBootstrapProvisioner {
}
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));
}
if (isManagedPackOutputUsable(marker, cacheRoot.getParent().getParent(), spec)) {
@@ -482,8 +482,8 @@ public final class DefaultPackBootstrapProvisioner {
return new Archive(null, sourceSha);
}
throw networkFailure == null
? new IOException("Iris " + spec.key() + " beta 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")
: 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) {
@@ -514,11 +514,11 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) {
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());
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";
if (name.equals(requiredDimension) || name.endsWith("/" + requiredDimension)) {
@@ -530,7 +530,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
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 {
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;
int entries = 0;
@@ -551,12 +551,12 @@ public final class DefaultPackBootstrapProvisioner {
while ((entry = zip.getNextEntry()) != null) {
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());
Path output = extractionRoot.resolve(name).normalize();
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()) {
Files.createDirectories(output);
@@ -568,7 +568,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = zip.read(buffer)) >= 0) {
expanded += read;
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);
}
@@ -589,7 +589,7 @@ public final class DefaultPackBootstrapProvisioner {
}
}
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();
}
@@ -613,7 +613,7 @@ public final class DefaultPackBootstrapProvisioner {
private static void validatePackRoot(Path path, PackSpec spec) throws IOException {
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);
}
}
@@ -822,7 +822,7 @@ public final class DefaultPackBootstrapProvisioner {
while ((read = input.read(buffer)) >= 0) {
total += read;
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);
}
@@ -63,10 +63,10 @@ import java.util.zip.ZipInputStream;
public final class PackDownloader {
private static final String DEFAULT_OVERWORLD_PACK = "overworld";
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_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 Map<String, String> BUILT_IN_PACK_URLS = Map.of(
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);
addDistinct(blockingErrors, imageMaps.errors());
addDistinct(warnings, imageMaps.warnings());
PackRiverValidator.Validation riverValidation = PackRiverValidator.validate(packFolder, dimensionFiles);
addDistinct(blockingErrors, riverValidation.errors());
addDistinct(warnings, riverValidation.warnings());
PackHydrologyValidator.Validation hydrologyValidation =
PackHydrologyValidator.validate(packFolder, dimensionFiles);
addDistinct(blockingErrors, hydrologyValidation.errors());
addDistinct(warnings, hydrologyValidation.warnings());
blockingErrors.addAll(PackCaveProfileValidator.validateLegacyFields(packFolder));
PackLootValidator.LootGraphIssues lootIssues = PackLootValidator.validateLootGraph(packFolder);
addDistinct(blockingErrors, lootIssues.errors());
@@ -481,8 +481,7 @@ public class AsyncPregenMethod implements PregeneratorMethod {
listener.onChunkFailed(x, z);
} else {
listener.onChunkGenerated(x, z);
cleanupMantleChunk(x, z);
listener.onChunkCleaned(x, z);
cleanupMantleChunksCoveredBy(x, z, listener);
onChunkCompleted(x, z, chunk);
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();
if (engine != null) {
try {
engine.getMantle().forceCleanupChunk(x, z);
engine.getMantle().cleanupChunksCoveredBy(x, z, true, listener::onChunkCleaned);
} catch (Throwable ignored) {
}
}
@@ -92,6 +92,7 @@ import static art.arcane.iris.util.common.misc.ServerProperties.BUKKIT_YML;
@Accessors(fluent = true, chain = true)
public class IrisCreator {
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 ROLLBACK_PHASE_TIMEOUT_SECONDS = 120L;
@@ -619,7 +620,7 @@ public class IrisCreator {
generator.getInitialSpawnReady(),
"Initial spawn preparation future");
try {
initialSpawnReady.get(WORLD_CREATE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
initialSpawnReady.get(INITIAL_SPAWN_TIMEOUT_SECONDS, TimeUnit.SECONDS);
} catch (TimeoutException failure) {
throw new TimeoutException("Initial spawn preparation timed out for world \""
+ worldName + "\".");
@@ -22,7 +22,14 @@ import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.loader.IrisData;
import art.arcane.iris.engine.data.cache.Cache;
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.mantle.components.MantleHydrologyCaveVoxelView;
import art.arcane.iris.engine.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
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.IrisMaterialPalette;
import art.arcane.iris.engine.object.IrisRegion;
import art.arcane.iris.engine.object.IrisRiverCaves;
import art.arcane.iris.engine.object.IrisRiverDeepPools;
import art.arcane.iris.engine.object.IrisRiverNetwork;
import art.arcane.iris.engine.object.IrisRiverOverride;
import art.arcane.iris.engine.object.IrisRiverRoutingPolicy;
import art.arcane.iris.engine.object.IrisRiverWater;
import art.arcane.iris.engine.object.IrisDeepFluidConfig;
import art.arcane.iris.engine.object.IrisHydrology;
import art.arcane.iris.engine.object.IrisRiverHydrology;
import art.arcane.iris.engine.object.IrisRiverProfile;
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.IrisLogging;
import art.arcane.iris.spi.PlatformBiome;
@@ -67,36 +65,48 @@ import lombok.ToString;
import java.io.File;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.BiFunction;
@Data
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime", "imageMapRuntime"})
@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "riverRuntime", "imageMapRuntime"})
@EqualsAndHashCode(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
@ToString(exclude = {"data", "gridBoundsCache", "frozenInterpolators", "frozenGenerators", "inferredBiomeStreams", "hydrologyRuntime", "imageMapRuntime"})
public class IrisComplex implements DataProvider {
private static final NoiseBounds ZERO_NOISE_BOUNDS = new NoiseBounds(0D, 0D);
private static final AtomicLong lastBoundsFailureLog = new AtomicLong(0L);
private static final int GRID_BOUNDS_CACHE_SIZE = 8192;
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)
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)
private final transient IrisInterpolator[] frozenInterpolators;
@Getter(AccessLevel.NONE)
private final transient IrisGenerator[][] frozenGenerators;
@Getter(AccessLevel.NONE)
private final transient Map<IrisRegion, Map<InferredType, ProceduralStream<IrisBiome>>> inferredBiomeStreams;
private RNG rng;
private double fluidHeight;
private IrisData data;
@@ -121,9 +131,7 @@ public class IrisComplex implements DataProvider {
private ProceduralStream<Double> maxHeightStream;
private ProceduralStream<Double> overlayStream;
private ProceduralStream<Double> heightFluidStream;
private ProceduralStream<Double> naturalSlopeStream;
private ProceduralStream<Double> slopeStream;
private ProceduralStream<IrisRiverSurfaceSample> riverSurfaceStream;
private ProceduralStream<Double> riverDistanceStream;
private ProceduralStream<Double> riverFlowStream;
private ProceduralStream<Double> riverCarveWeightStream;
@@ -138,13 +146,12 @@ public class IrisComplex implements DataProvider {
private ProceduralStream<IrisDecorator> shoreSurfaceDecoration;
private ProceduralStream<PlatformBlockState> rockStream;
private ProceduralStream<PlatformBlockState> fluidStream;
private ProceduralStream<PlatformBlockState> riverFluidStream;
private ProceduralStream<PlatformBlockState> riverDeepPoolFluidStream;
private Map<String, ProceduralStream<PlatformBlockState>> hydrologyFluidStreams;
private IrisBiome focusBiome;
private IrisRegion focusRegion;
private Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> generatorBounds;
private Set<IrisBiome> generatorBiomes;
private IrisRiverRuntime riverRuntime;
private IrisHydrologyRuntime hydrologyRuntime;
private transient IrisImageMapRuntime imageMapRuntime;
// 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
@@ -170,6 +177,7 @@ public class IrisComplex implements DataProvider {
focusBiome = engine.getFocus();
focusRegion = engine.getFocusRegion();
Map<InferredType, ProceduralStream<IrisBiome>> inferredStreams = new HashMap<>();
KList<IrisRegion> preparedRegions = new KList<>();
if (focusBiome != null) {
focusBiome = focusBiome.withInferredType(InferredType.LAND);
@@ -178,7 +186,7 @@ public class IrisComplex implements DataProvider {
//@builder
if (focusRegion != null) {
prepareInferredBiomes(focusRegion);
prepareInferredBiomes(focusRegion, preparedRegions);
focusRegion.getNaturalBiomes(this).forEach(this::registerGenerators);
} else {
engine.getDimension().getRegions().forEach(regionKey -> {
@@ -186,23 +194,26 @@ public class IrisComplex implements DataProvider {
if (region == null) {
return;
}
prepareInferredBiomes(region);
prepareInferredBiomes(region, preparedRegions);
region.getNaturalBiomes(this).forEach(this::registerGenerators);
});
for (IrisRegion region : imageMapRuntime.getMappedRegions()) {
prepareInferredBiomes(region);
prepareInferredBiomes(region, preparedRegions);
region.getNaturalBiomes(this).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];
frozenGenerators = new IrisGenerator[interpolatorCount][];
int frozenIndex = 0;
for (Map.Entry<IrisInterpolator, Set<IrisGenerator>> entry : generators.entrySet()) {
frozenInterpolators[frozenIndex] = entry.getKey();
frozenGenerators[frozenIndex] = entry.getValue().toArray(new IrisGenerator[0]);
frozenIndex++;
for (int frozenIndex = 0; frozenIndex < generatorGroups.length; frozenIndex++) {
frozenInterpolators[frozenIndex] = generatorGroups[frozenIndex].interpolator();
frozenGenerators[frozenIndex] = generatorGroups[frozenIndex].generators();
}
generatorBounds = buildGeneratorBounds(engine);
KList<IrisShapedGeneratorStyle> overlayNoise = engine.getDimension().getOverlayNoise();
@@ -221,29 +232,7 @@ public class IrisComplex implements DataProvider {
.select(engine.getDimension().getRockPalette().getBlockData(data));
fluidStream = engine.getDimension().getFluidPalette().getLayerGenerator(rng.nextParallelRNG(78), data).stream()
.select(engine.getDimension().getFluidPalette().getBlockData(data));
IrisRiverNetwork configuredRivers = engine.getDimension().getRivers();
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;
hydrologyFluidStreams = createHydrologyFluidStreams(engine.getDimension().getHydrology());
regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream()
.zoom(engine.getDimension().getRegionZoom());
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),
String.valueOf(i * 38445).hashCode() * 3245556666L));
caveBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
-> engine.getDimension().getCaveBiomeStyle().create(rng.nextParallelRNG(InferredType.CAVE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(r.getCaveBiomeZoom())
.selectRarity(loadInferredBiomes(r.getCaveBiomes(), InferredType.CAVE))
.onNull(emptyBiome)
).convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize);
.convert((r) -> createInferredBiomeStream(r, InferredType.CAVE))
.convertAware2D(ProceduralStream::get).cache2D("caveBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.CAVE, caveBiomeStream);
landBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
-> engine.getDimension().getLandBiomeStyle().create(rng.nextParallelRNG(InferredType.LAND.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getLandZoom())
.zoom(r.getLandBiomeZoom())
.selectRarity(loadInferredBiomes(r.getLandBiomes(), InferredType.LAND))
).convertAware2D(ProceduralStream::get)
.convert((r) -> createInferredBiomeStream(r, InferredType.LAND))
.convertAware2D(ProceduralStream::get)
.cache2D("landBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.LAND, landBiomeStream);
seaBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
-> engine.getDimension().getSeaBiomeStyle().create(rng.nextParallelRNG(InferredType.SEA.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(engine.getDimension().getSeaZoom())
.zoom(r.getSeaBiomeZoom())
.selectRarity(loadInferredBiomes(r.getSeaBiomes(), InferredType.SEA))
).convertAware2D(ProceduralStream::get)
.convert((r) -> createInferredBiomeStream(r, InferredType.SEA))
.convertAware2D(ProceduralStream::get)
.cache2D("seaBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.SEA, seaBiomeStream);
shoreBiomeStream = regionStream.contextInjecting(engine, (c, x, z) -> c.getRegion().get(x, z))
.convert((r)
-> engine.getDimension().getShoreBiomeStyle().create(rng.nextParallelRNG(InferredType.SHORE.ordinal()), getData()).stream()
.zoom(engine.getDimension().getBiomeZoom())
.zoom(r.getShoreBiomeZoom())
.selectRarity(loadInferredBiomes(r.getShoreBiomes(), InferredType.SHORE))
).convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize);
.convert((r) -> createInferredBiomeStream(r, InferredType.SHORE))
.convertAware2D(ProceduralStream::get).cache2D("shoreBiomeStream", engine, cacheSize);
inferredStreams.put(InferredType.SHORE, shoreBiomeStream);
bridgeStream = focusBiome != null ? ProceduralStream.of((x, z) -> focusBiome.getInferredType(),
Interpolated.of(a -> 0D, a -> focusBiome.getInferredType())) :
@@ -315,13 +285,10 @@ public class IrisComplex implements DataProvider {
return mapped == null ? biome : mapped;
})
.cache2D("imageMappedBaseBiomeStream", engine, cacheSize);
naturalHeightStream = ProceduralStream.of((x, z) -> {
IrisBiome b = focusBiome != null ? focusBiome : baseBiomeStream.get(x, z);
double proceduralHeight = getHeight(engine, b, x, z, engine.getSeedManager().getHeight());
return imageMapRuntime.sampleTerrainHeight(x, z, proceduralHeight);
}, Interpolated.DOUBLE).cache2DDouble("naturalHeightStream", engine, cacheSize);
naturalSlopeStream = naturalHeightStream.slope(3)
.cache2DDouble("naturalSlopeStream", engine, cacheSize);
naturalHeightStream = ProceduralStream.of(
(x, z) -> sampleNaturalTerrainHeight(engine, x, z),
Interpolated.DOUBLE
).cache2DDouble("naturalHeightStream", engine, cacheSize);
naturalTrueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome))
.cache2D("naturalTrueBiomeStream-focus", engine, cacheSize) : naturalHeightStream
@@ -332,46 +299,20 @@ public class IrisComplex implements DataProvider {
: mapped;
})
.cache2D("naturalTrueBiomeStream", engine, cacheSize);
if (engine.getDimension().getRivers() != null && engine.getDimension().getRivers().isEnabled()) {
ProceduralStream<Boolean> naturalOceanStream = createNaturalOceanStream(
bridgeStream,
focusBiome
).cache2D("naturalOceanStream", engine, cacheSize);
int riverFluidHeight = engine.getDimension().getRivers().getWater().getFluidHeight()
- engine.getDimension().getMinHeight();
riverRuntime = new IrisRiverRuntime(new IrisRiverRuntimeContext(
IrisHydrology configuredHydrology = engine.getDimension().getHydrology();
if (hydrologyActive(configuredHydrology)) {
hydrologyRuntime = new IrisHydrologyRuntime(new IrisHydrologyRuntimeContext(
engine.getSeedManager().getBodies(),
engine.getDimension().getRivers(),
engine.getHeight(),
engine.getDimension(),
data,
riverFluidHeight,
(int) Math.round(fluidHeight),
IrisEngineMantle.isRiverHydrologyEnabled(engine.getDimension()),
IrisEngineMantle.isRiverCaveHydrologyEnabled(engine.getDimension()),
blockingRiverRoutingPossible(engine),
variableMaxIncisionPossible(engine),
biomeRiverOverridesPossible(focusBiome, generatorBiomes),
(blockX, blockZ) -> naturalHeightBounds(engine, overlayNoise, blockX, blockZ),
naturalHeightStream,
naturalSlopeStream,
naturalOceanStream,
naturalTrueBiomeStream,
regionStream
(x, z, naturalHeight) -> sampleHydrologyNatural(engine, x, z, naturalHeight),
(x, z) -> naturalHeightStream.getDouble(x, z),
this::sampleNaturalOcean,
footprint -> new MantleHydrologyCaveVoxelView(engine, this, footprint)
));
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);
roundedHeighteightStream = heightStream.contextInjecting(engine, (c, x, z) -> c.getHeight().getDouble(x, z))
.round();
@@ -379,20 +320,18 @@ public class IrisComplex implements DataProvider {
.slope(3).cache2DDouble("slopeStream", engine, cacheSize);
trueBiomeStream = focusBiome != null ? ProceduralStream.of((x, y) -> focusBiome, Interpolated.of(a -> 0D,
b -> focusBiome))
.cache2D("trueBiomeStream-focus", engine, cacheSize) : riverSurfaceStream
.convertAware2D((sample, x, z) -> resolveRiverSurfaceBiome(sample, x, z))
.cache2D("trueBiomeStream-focus", engine, cacheSize) : heightStream
.convertAware2D((terrainHeight, x, z) -> resolveHydrologySurfaceBiome(terrainHeight, x, z))
.cache2D("trueBiomeStream", engine, cacheSize);
trueBiomeDerivativeStream = trueBiomeStream.contextInjecting(engine, (c, x, z) -> c.getBiome().get(x, z))
.convert((b) -> IrisPlatforms.get().registries().biome(b.getDerivativeKey())).cache2D("trueBiomeDerivativeStream", engine, cacheSize);
riverDistanceStream = riverSurfaceStream.convert(sample -> sample.river().present()
? sample.river().distance()
: Double.MAX_VALUE)
riverDistanceStream = ProceduralStream.ofDouble(this::resolveHydrologyDistance)
.cache2DDouble("riverDistanceStream", engine, cacheSize);
riverFlowStream = riverSurfaceStream.convert(sample -> (double) sample.river().flow())
riverFlowStream = ProceduralStream.ofDouble(this::resolveHydrologyFlow)
.cache2DDouble("riverFlowStream", engine, cacheSize);
riverCarveWeightStream = riverSurfaceStream.convert(sample -> sample.river().carveWeight())
riverCarveWeightStream = ProceduralStream.ofDouble(this::resolveHydrologyCarveWeight)
.cache2DDouble("riverCarveWeightStream", engine, cacheSize);
riverWaterSurfaceStream = riverSurfaceStream.convert(IrisRiverSurfaceSample::waterSurfaceY)
riverWaterSurfaceStream = ProceduralStream.ofDouble(this::resolveHydrologyFluidSurface)
.cache2DDouble("riverWaterSurfaceStream", engine, cacheSize);
heightFluidStream = ProceduralStream.ofDouble((x, z) -> Math.max(
heightStream.get(x, z),
@@ -424,79 +363,204 @@ public class IrisComplex implements DataProvider {
//@done
}
private boolean blockingRiverRoutingPossible(Engine engine) {
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(
static InferredType resolveNaturalInferredType(
ProceduralStream<InferredType> bridgeStream,
IrisBiome focusBiome
IrisBiome focusBiome,
double x,
double z
) {
if (focusBiome != null) {
boolean ocean = focusBiome.getInferredType() == InferredType.SEA;
return ProceduralStream.of((x, z) -> ocean, Interpolated.BOOLEAN);
return focusBiome.getInferredType();
}
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(
@@ -519,21 +583,81 @@ public class IrisComplex implements DataProvider {
return palette.getLayerGenerator(fluidRng, data).stream().select(blocks);
}
public PlatformBlockState resolveRiverCaveFluid(RiverCaveFluidKind fluidKind, double x, double z) {
return switch (Objects.requireNonNull(fluidKind)) {
case RIVER -> riverFluidStream.get(x, z);
case DEEP_POOL -> riverDeepPoolFluidStream.get(x, z);
};
public PlatformBlockState resolveHydrologyFluid(String profileKey, double x, double z) {
String key = Objects.requireNonNull(profileKey, "profileKey").trim();
ProceduralStream<PlatformBlockState> stream = hydrologyFluidStreams.get(key);
if (stream == null) {
throw new IllegalArgumentException("Unknown hydrology fluid profile: " + key);
}
return stream.get(x, z);
}
public PlatformBlockState resolveSurfaceFluid(double x, double z) {
IrisRiverSurfaceSample sample = riverSurfaceStream.get(x, z);
if (sample.river().present() && sample.surfaceFluid()) {
return riverFluidStream.get(x, z);
HydrologyColumnLayer layer = surfaceFluidLayer(x, z);
if (layer != null) {
return resolveHydrologyFluid(layer.profileKey(), 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) {
switch (type) {
case CAVE:
@@ -555,7 +679,7 @@ public class IrisComplex implements DataProvider {
if (engine == null) {
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) {
@@ -594,46 +718,29 @@ public class IrisComplex implements DataProvider {
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);
if (mapped != null && (riverRuntime == null || !sample.river().present())) {
if (mapped != null && layer == null) {
return mapped;
}
if (riverRuntime != null) {
if (sample.subterranean()) {
return fixBiomeType(
sample.naturalHeight(),
baseBiomeStream.get(x, z),
regionStream.get(x, z),
x,
z,
fluidHeight
);
if (layer != null) {
String biomeKey = hydrologySurfaceBiomeKey(sample);
IrisBiome biome = data.getBiomeLoader().load(biomeKey);
if (biome == null) {
throw new IllegalStateException("Hydrology biome does not exist: " + biomeKey);
}
IrisBiome riverBiome = riverRuntime.selectSurfaceBiome(sample, x, z);
if (riverBiome != null) {
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()
);
if (layer.biomeKey() == null) {
return biome;
}
InferredType inferredType = layer.shore()
? InferredType.SHORE
: layer.connectedFluid() ? InferredType.SEA : InferredType.LAND;
return implode(biome.withInferredType(inferredType), x, z);
}
return fixBiomeType(
sample.terrainHeight(),
terrainHeight,
baseBiomeStream.get(x, z),
regionStream.get(x, z),
x,
@@ -642,17 +749,14 @@ public class IrisComplex implements DataProvider {
);
}
static InferredType directRiverFallback(RiverSample river) {
if (!river.present()) {
return null;
static String hydrologySurfaceBiomeKey(HydrologyColumnSample sample) {
Objects.requireNonNull(sample, "sample");
HydrologyColumnLayer layer = sample.primarySurfaceLayer().orElse(null);
if (layer == null) {
return sample.parentBiomeKey();
}
if (river.state() == RiverRouteState.DRY) {
return InferredType.LAND;
}
return switch (river.section()) {
case CHANNEL, MOUTH -> InferredType.SEA;
default -> null;
};
String biomeKey = layer.biomeKey();
return biomeKey == null ? sample.parentBiomeKey() : biomeKey;
}
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 lo = sampledBounds.min();
double d = 0;
for (IrisGenerator i : generators) {
d += M.lerp(lo, hi, i.getHeight(x, z, seed + 239945));
}
return d / generators.length;
return averageGeneratorHeights(generators, lo, hi, x, z, seed + 239945);
}
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));
}
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 b01 = cornerBounds(cache, engine, interpolator, interpolatorIndex, generators, gx, 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);
}
private void prepareInferredBiomes(IrisRegion region) {
private void prepareInferredBiomes(IrisRegion region, KList<IrisRegion> preparedRegions) {
loadInferredBiomes(region.getLandBiomes(), InferredType.LAND);
loadInferredBiomes(region.getCaveBiomes(), InferredType.CAVE);
loadInferredBiomes(region.getSeaBiomes(), InferredType.SEA);
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) {
@@ -863,6 +1016,37 @@ public class IrisComplex implements DataProvider {
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) {
Map<IrisInterpolator, IdentityHashMap<IrisBiome, GeneratorBounds>> bounds = new HashMap<>();
KList<IrisBiome> allBiomes = new KList<>(generatorBiomes);
@@ -1265,9 +1449,12 @@ public class IrisComplex implements DataProvider {
}
}
record GeneratorGroup(IrisInterpolator interpolator, IrisGenerator[] generators) {
}
public void close() {
if (riverRuntime != null) {
riverRuntime.close();
if (hydrologyRuntime != null) {
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.MantleFloatingObjectComponent;
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.object.IrisDimension;
import art.arcane.iris.spi.IrisLogging;
@@ -75,6 +75,7 @@ import java.util.function.Supplier;
@EqualsAndHashCode(exclude = "engine")
@ToString(exclude = "engine")
public class IrisEngineMantle implements EngineMantle {
public static final String STORAGE_FOLDER_NAME = "mantle-hydrology";
private static final boolean BUKKIT_PRESENT = IrisMatterSupport.isBukkitPresent();
private final Engine engine;
private final Mantle<Matter> mantle;
@@ -90,7 +91,7 @@ public class IrisEngineMantle implements EngineMantle {
this.mantle = createMantle(engine);
components = new KMap<>();
registerComponent(new MantleCarvingComponent(this));
registerComponent(new MantleRiverHydrologyComponent(this));
registerComponent(new MantleHydrologyComponent(this));
object = new MantleObjectComponent(this);
registerComponent(object);
registerComponent(new MantleFloatingObjectComponent(this));
@@ -126,7 +127,7 @@ public class IrisEngineMantle implements EngineMantle {
List<MantleComponent> pass = passes.get(i);
int passBlockRadius = pass.stream()
.filter(MantleComponent::isEnabled)
.mapToInt(MantleComponent::getRadius)
.mapToInt(MantleComponent::getOutputRadius)
.max()
.orElse(0);
int passInputRadius = pass.stream()
@@ -180,19 +181,15 @@ public class IrisEngineMantle implements EngineMantle {
disabled.addIfMissing(ReservedFlag.CARVED);
}
if (disabled.contains(ReservedFlag.CARVED)
|| !isRiverHydrologyEnabled(getDimension())) {
|| !isHydrologyEnabled(getDimension())) {
disabled.addIfMissing(ReservedFlag.RIVER_HYDROLOGY);
}
return Set.copyOf(disabled);
});
}
static boolean isRiverHydrologyEnabled(IrisDimension dimension) {
return MantleRiverHydrologyComponent.isEnabledFor(dimension);
}
static boolean isRiverCaveHydrologyEnabled(IrisDimension dimension) {
return MantleRiverHydrologyComponent.isCaveConnectionsEnabledFor(dimension);
static boolean isHydrologyEnabled(IrisDimension dimension) {
return MantleHydrologyComponent.isEnabledFor(dimension);
}
@Override
@@ -202,7 +199,7 @@ public class IrisEngineMantle implements EngineMantle {
private static Mantle<Matter> createMantle(Engine engine) {
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();
MantleDataAdapter<Matter> adapter = createDataAdapter(engine::getData);
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.volmlib.util.collection.KList;
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.iris.util.project.interpolation.NoiseBounds;
import art.arcane.iris.spi.PlatformBlockState;
@@ -112,13 +111,14 @@ public class UpperDimensionContext implements DataProvider {
registerBiomeGenerators(mappedBiome, dataProvider, allBiomes, generators);
}
IrisComplex.GeneratorGroup[] generatorGroups = IrisComplex.freezeGeneratorGroups(generators);
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));
for (IrisBiome biome : allBiomes) {
double min = 0D;
double max = 0D;
for (IrisGenerator gen : entry.getValue()) {
for (IrisGenerator gen : group.generators()) {
String key = gen.getLoadKey();
if (key == null || key.isBlank()) {
continue;
@@ -128,7 +128,7 @@ public class UpperDimensionContext implements DataProvider {
}
interpolatorBounds.put(biome, new NoiseBounds(min, max));
}
generatorBounds.put(entry.getKey(), interpolatorBounds);
generatorBounds.put(group.interpolator(), interpolatorBounds);
}
ProceduralStream<Double> regionStyleStream = upperDim.getRegionStyle()
@@ -204,10 +204,10 @@ public class UpperDimensionContext implements DataProvider {
return mappedTerrainHeight(imageMapRuntime, fluidHeight, x, z);
}
double interpolatedHeight = 0;
for (Map.Entry<IrisInterpolator, Set<IrisGenerator>> entry : generators.entrySet()) {
IrisInterpolator interpolator = entry.getKey();
Set<IrisGenerator> gens = entry.getValue();
if (gens.isEmpty()) {
for (IrisComplex.GeneratorGroup group : generatorGroups) {
IrisInterpolator interpolator = group.interpolator();
IrisGenerator[] groupGenerators = group.generators();
if (groupGenerators.length == 0) {
continue;
}
IdentityHashMap<IrisBiome, NoiseBounds> cachedBounds = generatorBounds.get(interpolator);
@@ -223,7 +223,7 @@ public class UpperDimensionContext implements DataProvider {
}
double bMin = 0D;
double bMax = 0D;
for (IrisGenerator gen : gens) {
for (IrisGenerator gen : groupGenerators) {
String key = gen.getLoadKey();
if (key == null || key.isBlank()) {
continue;
@@ -239,11 +239,14 @@ public class UpperDimensionContext implements DataProvider {
});
double hi = sampledBounds.max();
double lo = sampledBounds.min();
double d = 0;
for (IrisGenerator gen : gens) {
d += M.lerp(lo, hi, gen.getHeight(x, z, heightSeed + 239945));
}
interpolatedHeight += d / gens.size();
interpolatedHeight += IrisComplex.averageGeneratorHeights(
groupGenerators,
lo,
hi,
x,
z,
heightSeed + 239945
);
}
double proceduralHeight = Math.max(
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.EngineAssignedActuator;
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.river.RiverRouteState;
import art.arcane.iris.engine.river.runtime.IrisRiverSurfaceSample;
import art.arcane.iris.util.common.data.B;
import art.arcane.iris.util.project.context.ChunkContext;
import art.arcane.volmlib.util.documentation.BlockCoordinates;
@@ -52,7 +53,7 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
@Getter
private final EngineDecorator seaFloorDecorator;
@Getter
private final EngineDecorator shoreLineDecorator;
private final IrisShoreLineDecorator shoreLineDecorator;
private final boolean shouldRay;
public IrisDecorantActuator(Engine engine) {
@@ -66,10 +67,82 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
seaFloorDecorator = new IrisSeaFloorDecorator(getEngine());
}
static boolean shouldDecorateShoreline(IrisRiverSurfaceSample sample, int height) {
return !sample.subterranean()
&& height == Math.round(sample.waterSurfaceY())
&& (!sample.river().present() || sample.river().state() != RiverRouteState.DRY);
static ShorelineDecorationMode shorelineDecorationMode(
HydrologyColumnSample sample,
int height,
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
@@ -80,7 +153,6 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
}
PrecisionStopwatch p = PrecisionStopwatch.start();
for (int i = 0; i < output.getWidth(); i++) {
int height;
int realX = Math.round(x + i);
@@ -94,25 +166,57 @@ public class IrisDecorantActuator extends EngineAssignedActuator<PlatformBlockSt
height = context.getRoundedHeight(i, j);
biome = context.getBiome().get(i, j);
cave = shouldRay ? context.getCave().get(i, j) : null;
IrisRiverSurfaceSample riverSurface = getComplex().getRiverSurfaceStream().get(realX, realZ);
int surfaceFluidHeight = (int) Math.round(riverSurface.waterSurfaceY());
HydrologyColumnSample hydrology = getComplex().getHydrologyRuntime() == null
? 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())) {
continue;
}
if (height < surfaceFluidHeight && PREDICATE_SOLID.test(output.get(i, height, j))
&& height + 1 < output.getHeight() && B.isFluid(output.get(i, height + 1, j))) {
if (PREDICATE_SOLID.test(output.get(i, height, 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,
realX, Math.round(i + 1), Math.round(x + i - 1),
realZ, Math.round(z + j + 1), Math.round(z + j - 1),
output, biome, surfaceFluidHeight, getEngine().getHeight());
getSeaFloorDecorator().decorate(i, j,
realX, realZ, output, biome, height + 1,
surfaceFluidHeight + 1);
if (seaSurfaceY < output.getHeight()) {
restoreUnsupportedAquaticPlacement(
output, i, seaSurfaceY, j, seaSurfaceOriginal);
}
if (hasConnectedWaterColumn(output, i, j, height + 1, surfaceFluidHeight)) {
getSeaFloorDecorator().decorate(i, j,
realX, realZ, output, biome, height + 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,
realX, Math.round(x + i + 1), Math.round(x + i - 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() {
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.engine.IrisComplex;
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.IrisDimension;
import art.arcane.iris.engine.object.IrisOreGenerator;
@@ -117,7 +119,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
}
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 mappedSurfaceBlock = complex.getImageMapRuntime().sampleSurfaceBlock(realX, realZ);
KList<IrisOreGenerator> biomeSurfaceOres = hideOres ? null : biome.getSurfaceOreGenerators();
@@ -159,15 +169,15 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator<PlatformBl
if (i > he && i <= hf) {
int fdepth = hf - i;
if (fblocks == null) {
if (hydrologyFluid == null && fblocks == null) {
fblocks = biome.generateSeaLayers(realX, realZ, localRng, hf - he, data);
}
if (fblocks.hasIndex(fdepth)) {
h.setRaw(xf, i, zf, fblocks.get(fdepth));
} else {
h.setRaw(xf, i, zf, fluid);
}
h.setRaw(xf, i, zf, HydrologyFluidLayerSelector.select(
fblocks,
fdepth,
fluid,
hydrologyFluid != null
));
continue;
}
@@ -54,6 +54,32 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
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);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -71,6 +97,8 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
return;
}
IrisSurfaceDecorator.AquaticPlacementSnapshot aquaticSnapshot = IrisSurfaceDecorator.captureAquaticPlacement(
decorator, getData(), data, x, z, height, max);
if (!decorator.isStacking()) {
int targetY = height + 1;
if (targetY >= data.getHeight()
@@ -80,6 +108,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
PlatformBlockState block = decorator.getBlockData100(biome, rng, realX, height, realZ, getData());
if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
data.set(x, targetY, z, block);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
return;
}
@@ -100,6 +129,7 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
PlatformBlockState block = decorator.getBlockDataForTop(biome, rng, realX, height, realZ, getData());
if (block != null && DecoratorCore.isValidShorelineSupport(decorator, block, support)) {
data.set(x, targetY, z, block);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
return;
}
@@ -123,5 +153,6 @@ public class IrisShoreLineDecorator extends IrisEngineDecorator {
}
data.set(x, targetY, z, block);
}
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
}
@@ -18,11 +18,13 @@
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.object.InferredType;
import art.arcane.iris.engine.object.IrisBiome;
import art.arcane.iris.engine.object.IrisDecorationPart;
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.volmlib.util.documentation.BlockCoordinates;
import art.arcane.volmlib.util.math.RNG;
@@ -64,6 +66,9 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
}
boolean underwater = isUnderwater(inferredType, height, fluidHeight);
if (underwater && !hasFluidAbove(data, x, height, z)) {
return;
}
boolean caveSkipFluid = skipsFluid(inferredType);
RNG rng = getRNG(realX, realZ);
IrisDecorator decorator = DecoratorCore.pickDecorator(biome, getPart(), partRNG, rng, getData(), realX, realZ);
@@ -72,6 +77,8 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
return;
}
AquaticPlacementSnapshot aquaticSnapshot = captureAquaticPlacement(
decorator, getData(), data, x, z, height, max);
if (decorator.isStacking()) {
DecoratorCore.PlaceOpts opts = DecoratorCore.SCRATCH_OPTS.get();
opts.reset();
@@ -79,11 +86,13 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
opts.fluidHeight = fluidHeight;
opts.caveSkipFluid = caveSkipFluid;
DecoratorCore.placeStackUp(decorator, x, z, realX, realZ, height, max, data, rng, getData(), opts);
aquaticSnapshot.restoreIfUnsupported(data, x, z);
return;
}
DecoratorCore.placeSurfaceSingle(decorator, x, z, realX, height, realZ,
data, rng, getData(), underwater, caveSkipFluid, getEngine().getMantle());
aquaticSnapshot.restoreIfUnsupported(data, x, z);
}
static boolean isUnderwater(InferredType inferredType, int height, int fluidHeight) {
@@ -93,4 +102,148 @@ public class IrisSurfaceDecorator extends IrisEngineDecorator {
static boolean skipsFluid(InferredType inferredType) {
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.IrisStructure;
import art.arcane.iris.engine.object.IrisWorld;
import art.arcane.iris.engine.river.cave.RiverCaveHydrology;
import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage;
import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.PlatformBiome;
import art.arcane.iris.spi.PlatformBlockState;
@@ -246,7 +246,7 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
@BlockCoordinates
default IrisBiome getCaveOrMantleBiome(int x, int y, int z) {
RiverCaveHydrology hydrology = RiverCaveHydrologyStorage.getIfPresent(
HydrologyCaveCell hydrology = HydrologyCaveStorage.getIfPresent(
getMantle().getMantle(), x, y, z);
if (hydrology != null && !hydrology.floodedBiomeKey().isEmpty()) {
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) {
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()) {
return null;
}
@@ -562,7 +566,12 @@ public interface Engine extends DataProvider, Fallible, BlockUpdater, Renderer,
return;
}
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;
import art.arcane.iris.engine.object.IrisDimension;
import art.arcane.iris.engine.mantle.MantleComponent;
import art.arcane.volmlib.util.collection.KList;
public interface EnginePlatformHooks {
@@ -70,4 +71,8 @@ public interface EnginePlatformHooks {
default boolean shouldBypassMantleStages(Engine engine) {
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()) {
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()) {
@@ -341,6 +345,11 @@ public final class HintedLocator<T> implements Locator<T> {
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();
}
@@ -576,6 +585,36 @@ public final class HintedLocator<T> implements Locator<T> {
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) {
if (rootKey == null || objectKey == null) {
return false;
@@ -21,6 +21,7 @@ package art.arcane.iris.engine.framework;
import art.arcane.iris.core.IrisSettings;
import art.arcane.iris.core.nms.container.BlockPos;
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.IrisObject;
import art.arcane.iris.engine.object.IrisRegion;
@@ -53,10 +54,22 @@ public interface Locator<T> {
}
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));
}
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) {
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.IrisStructureAnchorMode;
import art.arcane.iris.engine.object.IrisStructurePlacement;
import art.arcane.iris.engine.river.cave.RiverCaveHydrology;
import art.arcane.iris.engine.river.cave.RiverCaveHydrologyStorage;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveCell;
import art.arcane.iris.engine.hydrology.cave.HydrologyCaveStorage;
import art.arcane.volmlib.util.math.RNG;
import art.arcane.volmlib.util.matter.MatterCavern;
@@ -288,7 +288,7 @@ public final class StructureCaveAnchorResolver {
int mantleY,
int blockZ
) {
RiverCaveHydrology hydrology = hydrologyAt(engine, blockX, mantleY, blockZ);
HydrologyCaveCell hydrology = hydrologyAt(engine, blockX, mantleY, blockZ);
MatterCavern cavern = cavernAt(engine, blockX, mantleY, blockZ);
return acceptsAnchorFluid(
placement.isUnderwater(),
@@ -310,7 +310,7 @@ public final class StructureCaveAnchorResolver {
static boolean acceptsAnchorFluid(
boolean underwater,
MatterCavern cavern,
RiverCaveHydrology hydrology,
HydrologyCaveCell hydrology,
int mantleY,
int defaultLavaHeight
) {
@@ -332,12 +332,12 @@ public final class StructureCaveAnchorResolver {
private static MatterCavern cavernAt(Engine engine, int blockX, int mantleY, int blockZ) {
MatterCavern baseline = engine.getMantle().getMantle()
.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();
}
private static RiverCaveHydrology hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) {
return RiverCaveHydrologyStorage.getIfPresent(
private static HydrologyCaveCell hydrologyAt(Engine engine, int blockX, int mantleY, int blockZ) {
return HydrologyCaveStorage.getIfPresent(
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.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.IrisBiomeGeneratorLink;
import art.arcane.iris.engine.object.IrisDimension;
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 java.awt.Color;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CancellationException;
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 YELLOW = new Color(211, 164, 67).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_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_BANK = new Color(92, 146, 78).getRGB();
private static final int DRY_CHANNEL = new Color(171, 128, 68).getRGB();
private static final int DRY_BANK = new Color(132, 105, 62).getRGB();
private static final int RIVER_WATERFALL = new Color(190, 236, 255).getRGB();
private static final int CONFIRMED_DEEP_LAVA = new Color(194, 50, 22).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 DEEP_WATER = new Color(20, 48, 92).getRGB();
private static final int SHALLOW_WATER = new Color(50, 112, 154).getRGB();
@@ -150,15 +169,53 @@ public final class IrisRenderer {
return image;
}
public static int riverColor(RiverSection section) {
Objects.requireNonNull(section, "section");
return switch (section) {
case CHANNEL -> RIVER_CHANNEL;
public static int waterfallColor() {
return RIVER_WATERFALL;
}
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 BANK -> RIVER_BANK;
case DRY_CHANNEL -> DRY_CHANNEL;
case DRY_BANK -> DRY_BANK;
case NONE -> NO_RIVER;
case DEEP_POOL, DEEP_CHANNEL -> CONFIRMED_DEEP_LAVA;
};
}
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,
boolean composite
) {
IrisRiverRuntime runtime = complex.getRiverRuntime();
IrisHydrologyRuntime runtime = complex.getHydrologyRuntime();
if (runtime == null) {
return;
}
Map<Long, HydrologyFeatureRef> headwaters = new LinkedHashMap<>();
int maximumPixels = Math.max(1, Math.min(16, (int) Math.floor(64D / step)));
int blockPixels = Integer.highestOneBit(maximumPixels);
for (int pixelZ = 0; pixelZ < resolution; pixelZ += blockPixels) {
@@ -295,6 +353,8 @@ public final class IrisRenderer {
runtime,
cancelled,
composite,
!composite,
headwaters,
pixelX,
pixelZ,
Math.min(blockPixels, resolution - pixelX),
@@ -302,6 +362,7 @@ public final class IrisRenderer {
);
}
}
renderHeadwaterDirections(pixels, resolution, startX, startZ, step, headwaters);
}
private static void renderRiverBlock(
@@ -310,58 +371,163 @@ public final class IrisRenderer {
double startX,
double startZ,
double step,
IrisRiverRuntime runtime,
IrisHydrologyRuntime runtime,
BooleanSupplier cancelled,
boolean composite,
boolean diagnostics,
Map<Long, HydrologyFeatureRef> headwaters,
int pixelX,
int pixelZ,
int width,
int height
) {
checkCancelled(cancelled);
RiverSample sample = runtime.sampleFootprint(
HydrologyRenderSample sample = runtime.sampleRenderFootprint(
startX + pixelX * step,
startZ + pixelZ * step,
startX + (pixelX + width) * 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;
}
if (width == 1 && height == 1) {
int index = pixelZ * resolution + pixelX;
int color = riverColor(sample.section());
pixels[index] = composite ? riverCompositeColor(pixels[index], sample.section(), color) : color;
int color = sample.present()
? hydrologyColor(sample)
: hydrologyDiagnosticColor(diagnosticSample);
pixels[index] = composite
? blend(pixels[index], color, 0.72D)
: color;
return;
}
int leftWidth = Math.max(1, width / 2);
int rightWidth = width - leftWidth;
int topHeight = Math.max(1, height / 2);
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);
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);
}
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);
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);
}
}
}
private static int riverCompositeColor(int base, RiverSection section, int river) {
return switch (section) {
case CHANNEL, MOUTH -> river;
case BANK -> blend(base, river, 0.68D);
case DRY_CHANNEL -> blend(base, river, 0.88D);
case DRY_BANK -> blend(base, river, 0.58D);
case NONE -> base;
};
private static void collectHeadwaters(
HydrologyRenderSample sample,
Map<Long, HydrologyFeatureRef> headwaters
) {
for (HydrologyFeatureRef feature : sample.features()) {
if (!feature.source()) {
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) {
@@ -399,19 +565,47 @@ public final class IrisRenderer {
}
private int riverColor(IrisComplex complex, double x, double z, double step) {
IrisRiverRuntime runtime = complex.getRiverRuntime();
IrisHydrologyRuntime runtime = complex.getHydrologyRuntime();
if (runtime == null) {
return riverColor(RiverSection.NONE);
return NO_RIVER;
}
double endX = x + step;
double endZ = z + step;
RiverSample sample = runtime.sampleFootprint(
HydrologyRenderSample sample = runtime.sampleRenderFootprint(
StrictMath.min(x, endX),
StrictMath.min(z, endZ),
StrictMath.max(x, endX),
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) {
@@ -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
}
@@ -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;
@@ -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
public interface RiverCaveGrottoShape {
RiverCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> {
public interface HydrologyCaveGrottoShape {
HydrologyCaveGrottoShape ELLIPSOID = (source, settings, dx, dy, dz) -> {
double horizontalRadius = settings.grottoHorizontalRadius();
double verticalRadius = settings.grottoVerticalRadius();
double normalized = ((double) dx * dx / (horizontalRadius * horizontalRadius))
@@ -12,8 +12,8 @@ public interface RiverCaveGrottoShape {
};
boolean contains(
RiverCaveSource source,
RiverCavePlannerSettings settings,
HydrologyCaveSource source,
HydrologyCavePlannerSettings settings,
int offsetX,
int offsetY,
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,
GENERATED_GROTTO,
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;
public record RiverCavePlannerSettings(
public record HydrologyCavePlannerSettings(
int maxHorizontalRadius,
int maxDepth,
int maxFloodVolume,
@@ -11,12 +11,14 @@ public record RiverCavePlannerSettings(
int grottoHorizontalRadius,
int grottoVerticalRadius,
int dryHeadroom,
RiverCaveFluidPolicy existingFluidPolicy,
RiverCaveGrottoShape grottoShape,
HydrologyCaveFluidPolicy existingFluidPolicy,
HydrologyCaveGrottoShape grottoShape,
int maxClosedComponentHorizontalRadius,
int maxClosedComponentDepth
) {
public RiverCavePlannerSettings(
public static final int MAXIMUM_PLANNED_MUTATIONS = 262_144;
public HydrologyCavePlannerSettings(
int maxHorizontalRadius,
int maxDepth,
int maxFloodVolume,
@@ -25,8 +27,8 @@ public record RiverCavePlannerSettings(
int grottoHorizontalRadius,
int grottoVerticalRadius,
int dryHeadroom,
RiverCaveFluidPolicy existingFluidPolicy,
RiverCaveGrottoShape grottoShape
HydrologyCaveFluidPolicy existingFluidPolicy,
HydrologyCaveGrottoShape grottoShape
) {
this(
maxHorizontalRadius,
@@ -44,14 +46,14 @@ public record RiverCavePlannerSettings(
);
}
public RiverCavePlannerSettings(
public HydrologyCavePlannerSettings(
int maxHorizontalRadius,
int maxDepth,
int maxFloodVolume,
int maxThroatLength,
int grottoHorizontalRadius,
int grottoVerticalRadius,
RiverCaveFluidPolicy existingFluidPolicy
HydrologyCaveFluidPolicy existingFluidPolicy
) {
this(
maxHorizontalRadius,
@@ -63,11 +65,11 @@ public record RiverCavePlannerSettings(
grottoVerticalRadius,
0,
existingFluidPolicy,
RiverCaveGrottoShape.ELLIPSOID
HydrologyCaveGrottoShape.ELLIPSOID
);
}
public RiverCavePlannerSettings {
public HydrologyCavePlannerSettings {
Objects.requireNonNull(existingFluidPolicy);
Objects.requireNonNull(grottoShape);
if (maxHorizontalRadius < 1) {

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