From 7226fc44002023141ddbefedf8e82b8a928bc2b3 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Mon, 5 Oct 2026 16:22:55 -0400 Subject: [PATCH] Refine organic cave generation - Add seeded cave surface variation and rounded basin undersides. - Keep dry cave intersections open while preserving fluid seals. - Apply room biome decorators and preserve their floor substrates. - Allow ceiling growth above reserved walking clearance. - Restrict feature placement by region across live and saved generation. - Cover cave geometry, placement, decoration, and regional filtering with regression tests. --- .../CaveObjectPlacementTransaction.java | 3 +- .../mantle/MantleCarvingComponent.java | 3 + .../iris/generation/runtime/IrisComplex.java | 28 +-- .../generation/stage/IrisCarveModifier.java | 38 +++- .../subterrain/IrisSubterrainFeature.java | 9 + .../generation/subterrain/SubterrainPlan.java | 203 ++++++++++++++++-- .../subterrain/SubterrainPlanner.java | 42 +++- .../subterrain/SubterrainRasterizer.java | 2 +- .../SubterrainSurfaceMaterials.java | 43 +++- .../generation/terrain/RegionSelection.java | 80 +++++++ .../iris/world/history/SavedBiomeRuntime.java | 11 +- .../locator/SubterrainLocatorTest.java | 4 +- .../CaveObjectPlacementTransactionTest.java | 22 ++ .../NaturalCaveProceduralPlacementTest.java | 2 +- .../runtime/SubterrainBiomeOwnershipTest.java | 2 +- ...EntitySpawnerUndergroundOwnershipTest.java | 2 +- .../IrisCarveModifierBoundarySupportTest.java | 25 +++ .../stage/IrisNaturalCaveScanTest.java | 24 +++ .../subterrain/SubterrainPlannerTest.java | 172 ++++++++++++++- .../subterrain/SubterrainRasterizerTest.java | 9 +- .../SubterrainRegionSelectionTest.java | 70 ++++++ .../SubterrainSurfaceMaterialsTest.java | 62 +++++- .../schema/SchemaBuilderIdentityTest.java | 9 +- .../world/history/SavedBiomeCaptureTest.java | 2 +- .../history/SavedSubterrainQueriesTest.java | 4 +- 25 files changed, 785 insertions(+), 86 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/generation/terrain/RegionSelection.java create mode 100644 core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRegionSelectionTest.java diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java b/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java index 9430c523d..2138c2769 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransaction.java @@ -82,7 +82,8 @@ final class CaveObjectPlacementTransaction implements IObjectPlacer { return CommitResult.REJECTED_BOUNDS; } SubterrainCell feature = engine.getSubterrainCell(mutation.x(), mutation.y(), mutation.z()); - if (SubterrainRasterizer.protectsPlacement(feature)) { + if (SubterrainRasterizer.protectsPlacement(feature) + || feature != null && feature.solid() && delegate.isSolid(mutation.x(), mutation.y(), mutation.z())) { discard(); return CommitResult.REJECTED_SUBTERRAIN; } diff --git a/core/src/main/java/art/arcane/iris/generation/mantle/MantleCarvingComponent.java b/core/src/main/java/art/arcane/iris/generation/mantle/MantleCarvingComponent.java index f4c3e7e1a..cc32d1d6e 100644 --- a/core/src/main/java/art/arcane/iris/generation/mantle/MantleCarvingComponent.java +++ b/core/src/main/java/art/arcane/iris/generation/mantle/MantleCarvingComponent.java @@ -178,6 +178,9 @@ public class MantleCarvingComponent extends IrisMantleComponent { if (y <= 0 || y >= writer.getMantle().getWorldHeight()) { return; } + if (cell.solid() && !cell.room().reservedSolid()) { + return; + } if (cell.solid()) { writer.clearData(x, y, z, MatterCavern.class); } else { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java index cc6677a82..e87cc0092 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/IrisComplex.java @@ -64,6 +64,7 @@ import art.arcane.iris.generation.terrain.transform.TerrainTransformer; import art.arcane.iris.generation.noise.IrisInterpolator; import art.arcane.iris.generation.terrain.IrisMaterialPalette; import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.generation.terrain.RegionSelection; import art.arcane.iris.generation.hydrology.IrisDeepFluidConfig; import art.arcane.iris.generation.hydrology.IrisSurfacePoolConfig; import art.arcane.iris.generation.hydrology.IrisHydrology; @@ -274,7 +275,8 @@ public class IrisComplex implements DataProvider { subterrainPlanner = new SubterrainPlanner(new SubterrainPlanner.Options( engine.getDimension().allowsSubterrainFeatures() ? engine.getDimension().getSubterrainFeatures() : List.of(), engine.getSeedManager().getBodies(), - engine.getDimension().getMinHeight(), engine.getDimension().getMaxHeight()), + engine.getDimension().getMinHeight(), engine.getDimension().getMaxHeight(), + (x, z) -> regionStream.get(x, z).getLoadKey()), bounds -> transitionGenerationPlan == null || transitionGenerationPlan.allowsNewFootprint( bounds.minX(), bounds.minZ(), bounds.maxX(), bounds.maxZ())); biomeBoundsSamplingStep = engine.getDimension().getBiomeBoundsSamplingStep(); @@ -376,15 +378,10 @@ public class IrisComplex implements DataProvider { regionStyleStream = engine.getDimension().getRegionStyle().create(rng.nextParallelRNG(883), getData()).stream() .zoom(engine.getDimension().getRegionZoom()); regionIdentityStream = regionStyleStream.fit(Integer.MIN_VALUE, Integer.MAX_VALUE); - ProceduralStream proceduralRegionStream = focusedRegions != null ? focusedRegions - : GenerationStreams.cache2D(regionStyleStream - .selectRarity(compatRegionPool(engine)), "regionStream", engine, cacheSize); - regionStream = focusedRegions != null || !imageMapRuntime.has(IrisImageMapApplication.REGION) - ? proceduralRegionStream : GenerationStreams.cache2D(proceduralRegionStream - .convertAware2D((region, x, z) -> { - IrisRegion mapped = imageMapRuntime.sampleRegion(x, z); - return mapped == null ? region : mapped; - }), "imageMappedRegionStream", engine, cacheSize); + regionStream = focusedRegions != null ? focusedRegions + : GenerationStreams.cache2D(RegionSelection.select(new RegionSelection.Options( + engine.getDimension(), data, regionStyleStream, imageMapRuntime, null)), + "regionStream", engine, cacheSize); regionIDStream = regionIdentityStream.convertCached((i) -> new UUID(Double.doubleToLongBits(i), String.valueOf(i * 38445).hashCode() * 3245556666L)); caveBiomeStream = GenerationStreams.cache2D(GenerationStreams.contextInjecting(regionStream, engine, (c, x, z) -> c.getRegion().get(x, z)) @@ -1849,17 +1846,6 @@ public class IrisComplex implements DataProvider { return inferred; } - /** The dimension's region pool with regions the version-content gate excluded removed. */ - private KList compatRegionPool(Engine engine) { - KList pool = new KList<>(); - for (IrisRegion region : data.getRegionLoader().loadAll(engine.getDimension().getRegions())) { - if (!region.isCompatExcluded()) { - pool.add(region); - } - } - return pool; - } - private static T compatUsable(T registrant) { return registrant == null || registrant.isCompatExcluded() ? null : registrant; } diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java index e7029ea7d..8f5bd62e9 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisCarveModifier.java @@ -242,7 +242,7 @@ public class IrisCarveModifier extends EngineAssignedModifier output.setRaw(localX, surfaceY, localZ, AIR); } } - restoreSubterrainSolids(output, x, z); + restoreSubterrainSolids(output, x, z, false); } finally { getEngine().getMetrics().getCarveApply().put(applyStopwatch.getMilliseconds()); } @@ -272,6 +272,12 @@ public class IrisCarveModifier extends EngineAssignedModifier int x = worldX & 15; int z = worldZ & 15; NativeBlockState existing = output.getRaw(x, y, z); + if (cell.solid() && !cell.room().reservedSolid() && (existing == null || !existing.isSolid())) { + return; + } + if (materials.retainsFloorSupport(worldX, worldY, worldZ, cell, existing)) { + return; + } NativeBlockState planned = materials.state(worldX, worldY, worldZ, cell); if (cell.solid() || cell.room().reservedPassage() || cell.fluid() && (existing == null || B.isAir(existing) || B.isFluid(existing))) { output.setRaw(x, y, z, planned); @@ -281,7 +287,7 @@ public class IrisCarveModifier extends EngineAssignedModifier }); } - private void restoreSubterrainSolids(Hunk output, int chunkX, int chunkZ) { + private void restoreSubterrainSolids(Hunk output, int chunkX, int chunkZ, boolean preserveFloorSupports) { SubterrainPlanner planner = getComplex().getSubterrainPlanner(); if (planner == null || planner.isEmpty()) { return; @@ -290,7 +296,14 @@ public class IrisCarveModifier extends EngineAssignedModifier SubterrainSurfaceMaterials materials = subterrainMaterials(planner, chunkX, chunkZ); SubterrainRasterizer.rasterize(planner, chunkX, chunkZ, (worldX, worldY, worldZ, cell) -> { int y = worldY - minimumY; - if (cell.solid() && y > 0 && y < output.getHeight()) { + if (!cell.solid() || y <= 0 || y >= output.getHeight()) { + return; + } + NativeBlockState existing = output.getRaw(worldX & 15, y, worldZ & 15); + if (preserveFloorSupports && materials.retainsFloorSupport(worldX, worldY, worldZ, cell, existing)) { + return; + } + if (cell.room().reservedSolid() || existing != null && existing.isSolid()) { output.setRaw(worldX & 15, y, worldZ & 15, materials.state(worldX, worldY, worldZ, cell)); } }); @@ -421,7 +434,7 @@ public class IrisCarveModifier extends EngineAssignedModifier int columnIndex = PowerOfTwoCoordinates.packLocal16(localX, localZ); SubterrainCell feature = context.subterrainPlanner() == null ? SubterrainCell.OUTSIDE : context.subterrainPlanner().sample(context.subterrainPlans(), context.chunkBlockX() + localX, y + getEngine().getWorld().minHeight(), context.chunkBlockZ() + localZ); - if (feature.owned()) { + if (feature.owned() && (!feature.solid() || feature.room().reservedSolid())) { context.output().setRaw(localX, y, localZ, SubterrainRasterizer.state(feature)); if (feature.carve()) { context.columnMasks()[columnIndex].add(y); @@ -1007,28 +1020,27 @@ public class IrisCarveModifier extends EngineAssignedModifier } } } - restoreSubterrainSolids(output, blockX >> 4, blockZ >> 4); + restoreSubterrainSolids(output, blockX >> 4, blockZ >> 4, true); } private void decorateZone(Hunk output, CaveZone zone, int rx, int rz, int xx, int zz, IrisBiome floorBiome, IrisBiome ceilingBiome) { SubterrainCell feature = subterrainCell(xx, zone.getFloor(), zz); - if (feature.owned() && feature.room().reservedPassage()) { - return; - } + boolean reservedPassage = feature.owned() && feature.room().reservedPassage(); int maxY = output.getHeight(); IrisDecorator[] surfaceDecorators = floorBiome == null ? new IrisDecorator[0] : floorBiome.getDecoratorBucket(IrisDecorationPart.NONE); - if (surfaceDecorators.length > 0 && hasStableCaveFloorSupport(output, rx, zone.getFloor(), rz)) { + if (!reservedPassage && surfaceDecorators.length > 0 && hasStableCaveFloorSupport(output, rx, zone.getFloor(), rz)) { decorant.getSurfaceDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, floorBiome, InferredType.CAVE, zone.getFloor() - 1, zone.airThickness()); } IrisDecorator[] ceilingDecorators = ceilingBiome == null ? new IrisDecorator[0] : ceilingBiome.getDecoratorBucket(IrisDecorationPart.CEILING); - if (ceilingDecorators.length > 0 && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) { - decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, ceilingBiome, InferredType.CAVE, zone.getCeiling(), zone.airThickness()); + int ceilingSpace = zone.airThickness() - (reservedPassage ? 3 : 0); + if (ceilingSpace > 0 && ceilingDecorators.length > 0 && zone.getCeiling() + 1 < maxY && B.isSolid(output.getRaw(rx, zone.getCeiling() + 1, rz))) { + decorant.getCeilingDecorator().decorate(rx, rz, xx, xx, xx, zz, zz, zz, output, ceilingBiome, InferredType.CAVE, zone.getCeiling(), ceilingSpace); } } @@ -1075,6 +1087,10 @@ public class IrisCarveModifier extends EngineAssignedModifier } IrisBiome resolveCaveBoundaryBiome(MatterCavern cavern, int worldX, int y, int worldZ, CaveInputs resolverState, Long2ObjectOpenHashMap caveBiomeCache, Map customBiomeCache) { + IrisBiome featureBiome = getEngine().getSubterrainBiome(worldX, y, worldZ); + if (featureBiome != null) { + return featureBiome; + } if (isNaturalTerrainOpening(resolverState.complex, worldX, y, worldZ)) { return resolverState.complex.getTrueBiomeStream().get(worldX, worldZ); } diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFeature.java b/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFeature.java index 96ba768ba..dbbbb2203 100644 --- a/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFeature.java +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/IrisSubterrainFeature.java @@ -1,6 +1,8 @@ package art.arcane.iris.generation.subterrain; import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.volmlib.util.collection.KList; import art.arcane.iris.pack.schema.annotation.MaxNumber; import art.arcane.iris.pack.schema.annotation.MinNumber; import art.arcane.iris.pack.schema.annotation.RegistryListResource; @@ -26,6 +28,9 @@ public class IrisSubterrainFeature { @RegistryListResource(IrisBiome.class) @Description("Biome that owns occupied cells in this feature. Solid rims and surrounding terrain retain their biome.") private String biome = ""; + @RegistryListResource(IrisRegion.class) + @Description("Allowed region keys at the feature placement center. Empty allows all regions.") + private KList allowedRegions = new KList<>(); @Description("Inclusive absolute world Y bounds. The entire feature including seals must fit inside this band.") private IrisRange worldYRange = new IrisRange(-48, 48); @MinNumber(32) @@ -48,6 +53,10 @@ public class IrisSubterrainFeature { @MaxNumber(192) @Description("Main room height in blocks.") private int height = 32; + @MinNumber(0) + @MaxNumber(1) + @Description("Seeded erosion and shape distortion strength for lobed basins, winding passages, uneven banks and irregular vaults.") + private double shapeWarp = 0; @Description("Retained fluid. When omitted, lava tubes use LAVA and cenotes and terraces use WATER. Faults remain dry.") private IrisSubterrainFluid fluid; @MinNumber(0) diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java index 3ef31e087..d2d6a11d8 100644 --- a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlan.java @@ -11,6 +11,7 @@ public final class SubterrainPlan { private final String id; private final SubterrainBounds bounds; private final double shapePhase; + private final long shapeSeed; private final ThreadLocal columnCache = ThreadLocal.withInitial(ColumnCache::new); SubterrainPlan(Options options) { @@ -21,11 +22,12 @@ public final class SubterrainPlan { rotated = options.rotated(); id = options.id(); shapePhase = (SubterrainPlanner.mix(id.hashCode() ^ definition.seedSalt()) >>> 11) * 0x1.0p-53 * Math.PI * 2; + shapeSeed = SubterrainPlanner.mix(id.hashCode() ^ definition.seedSalt()); int along = family() == IrisSubterrainFamily.CENOTE ? definition.radius() : definition.length() / 2; int across = definition.radius(); int extentX = (rotated ? across : along) + 2; int extentZ = (rotated ? along : across) + 2; - bounds = new SubterrainBounds(centerX - extentX, baseY() - 2, centerZ - extentZ, + bounds = new SubterrainBounds(centerX - extentX, baseY() - 2 - definition.baseDepth(), centerZ - extentZ, centerX + extentX, baseY() + definition.height() + 2 + (family() == IrisSubterrainFamily.LAVA_TUBE ? definition.chimneyHeight() : 0), centerZ + extentZ); } @@ -125,18 +127,20 @@ public final class SubterrainPlan { if (column == null) { return SubterrainCell.OUTSIDE; } - int sealFloor = family() == IrisSubterrainFamily.CENOTE ? baseY() : column.floor(); - if (y < sealFloor - 2 || y > column.ceiling() + 2) { + double horizontalOutside = Math.max(0, -column.boundary()); + double verticalOutside = Math.max(0, Math.max(column.baseFloor() - y, y - column.ceiling())); + if (horizontalOutside * horizontalOutside + verticalOutside * verticalOutside > 4) { return SubterrainCell.OUTSIDE; } SubterrainCell.Kind kind = classify(column, u, v, y); int pathX = rotated ? centerX + column.pathOffset() : x; int pathZ = rotated ? z : centerZ + column.pathOffset(); - boolean passage = kind == SubterrainCell.Kind.AIR && column.pathDistance() <= 2.0; + boolean passage = kind == SubterrainCell.Kind.AIR && column.pathDistance() <= 2.0 + && y <= Math.max(column.floor(), column.head()) + 3; SubterrainRoom room = new SubterrainRoom(id, biome(), family(), centerX, centerY, centerZ, pathX, Math.max(column.floor() + 2, column.head() + 2), pathZ, column.floor(), column.ceiling(), column.boundary(), column.head(), passage, - kind == SubterrainCell.Kind.SOLID, kind); + kind == SubterrainCell.Kind.SOLID && retainedSolid(column, u, v, y), kind); String material = switch (kind) { case SOLID -> definition.solid(); case AIR -> "minecraft:cave_air"; @@ -147,6 +151,12 @@ public final class SubterrainPlan { return new SubterrainCell(kind, material, room); } + private boolean retainedSolid(Column column, int u, int v, int y) { + return family() != IrisSubterrainFamily.TECTONIC_FAULT && definition.fluidDepth() > 0 && y <= column.head() + 1 + || column.interior() && y > column.floor() && y < column.ceiling() + && formation(column, u, v, y); + } + private Column cachedColumn(int u, int v) { ColumnCache cache = columnCache.get(); long key = ((long) u << 32) ^ (v & 0xffffffffL); @@ -159,7 +169,7 @@ public final class SubterrainPlan { case CENOTE -> cenote(u, v); case TECTONIC_FAULT -> fault(u, v); case TRAVERTINE_TERRACES -> terraces(u, v); - case LAVA_TUBE -> throw new IllegalStateException("Tube columns depend on height"); + case LAVA_TUBE -> erodedTube(u, v); }; int slot = cache.next; cache.keys[slot] = key; @@ -173,6 +183,9 @@ public final class SubterrainPlan { if (!column.interior() || y <= column.floor() || y >= column.ceiling() || column.rim()) { return SubterrainCell.Kind.SOLID; } + if (erodedWall(column, u, v, y)) { + return SubterrainCell.Kind.SOLID; + } if (formation(column, u, v, y)) { return SubterrainCell.Kind.SOLID; } @@ -185,6 +198,18 @@ public final class SubterrainPlan { return SubterrainCell.Kind.AIR; } + private boolean erodedWall(Column column, int u, int v, int y) { + double strength = definition.shapeWarp(); + double reach = Math.min(7, definition.radius() * 0.28) * strength; + if (reach == 0 || column.boundary() >= reach || column.pathDistance() <= 2) { + return false; + } + double height = y - baseY(); + double inset = reach * (0.48 + 0.34 * noise(u / 13.0 + height / 17.0, v / 13.0 - height / 19.0, 149) + + 0.18 * noise(u / 4.0 - height / 5.0, v / 4.0 + height / 7.0, 151)); + return column.boundary() < inset; + } + private boolean formation(Column column, int u, int v, int y) { if (column.pathDistance() <= 3 || column.boundary() < 4) { return false; @@ -225,6 +250,9 @@ public final class SubterrainPlan { } private Column cenote(int u, int v) { + if (definition.shapeWarp() > 0) { + return erodedCenote(u, v); + } double distance = Math.hypot(u, v); int radius = definition.radius(); if (distance > radius + 2) { @@ -237,7 +265,7 @@ public final class SubterrainPlan { double slope = bank * bank * (3 - 2 * bank); int floor = baseY() + (int) Math.round((definition.fluidDepth() + 2) * slope); boolean rim = definition.fluidDepth() > 0 && ceiling <= head + 2; - return new Column(floor, ceiling, head, distance < radius, rim, + return new Column(floor, floor, ceiling, head, distance < radius, rim, radius - distance, Math.abs(v), 0); } @@ -249,12 +277,36 @@ public final class SubterrainPlan { int step = Math.max(2, definition.radius() / 4); int shelf = Math.max(0, (int) (Math.abs(footprint.offset()) - footprint.width() * 0.5) / step) * Math.max(2, definition.height() / 8); - int ceiling = vaultedCeiling(u, footprint, baseY() + shelf, baseY() - 1); - return new Column(baseY() + shelf, ceiling, baseY() - 1, + int floor = baseY() + shelf; + if (definition.shapeWarp() > 0) { + floor = baseY() + (int) Math.round((1 - definition.shapeWarp()) * shelf + + definition.shapeWarp() * definition.height() * (0.10 + 0.08 * erosion(u, v, 43))); + } + int ceiling = vaultedCeiling(u, footprint, floor, baseY() - 1); + return new Column(floor, floor, ceiling, baseY() - 1, footprint.boundary() > 0, false, footprint.boundary(), Math.abs(footprint.offset()), footprint.path()); } private Column tube(int u, int v, int y) { + if (definition.shapeWarp() > 0) { + Column column = cachedColumn(u, v); + if (column == null || definition.chimneyHeight() == 0) { + return column; + } + int halfLength = definition.length() / 2; + double chimneyRadius = Math.hypot(Math.min(Math.abs(u - halfLength / 2), Math.abs(u + halfLength / 2)), + v - column.pathOffset()); + int top = baseY() + definition.height() + definition.chimneyHeight(); + if (chimneyRadius < 3) { + return new Column(column.floor(), column.baseFloor(), top, column.head(), column.interior(), column.rim(), + column.boundary(), column.pathDistance(), column.pathOffset()); + } + if (chimneyRadius <= 5 && y >= column.ceiling() - 1) { + return new Column(column.ceiling() - 1, column.ceiling() - 1, top, column.head(), false, true, + 5 - chimneyRadius, column.pathDistance(), column.pathOffset()); + } + return column; + } int halfLength = definition.length() / 2; int path = (int) Math.round(Math.sin(u * Math.PI / Math.max(1, halfLength)) * definition.radius() * 0.15); int width = Math.max(6, (int) Math.floor(definition.radius() * 0.8)); @@ -282,12 +334,12 @@ public final class SubterrainPlan { if (chimneyRadius < 3) { ceiling = baseY() + definition.height() + definition.chimneyHeight(); } else if (y >= tubeCeiling - 1) { - return new Column(tubeCeiling - 1, baseY() + definition.height() + definition.chimneyHeight(), + return new Column(tubeCeiling - 1, tubeCeiling - 1, baseY() + definition.height() + definition.chimneyHeight(), head, false, true, 5 - chimneyRadius, Math.abs(offset), path); } } boolean rim = definition.fluidDepth() > 0 && floor >= head && floor < head + 2; - return new Column(floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(offset) < taperedWidth, + return new Column(floor, floor, ceiling, head, Math.abs(u) < halfLength && Math.abs(offset) < taperedWidth, rim, Math.min(halfLength - Math.abs(u), taperedWidth - Math.abs(offset)), Math.abs(offset), path); } @@ -298,12 +350,16 @@ public final class SubterrainPlan { } double warped = u + definition.radius() * 0.2 * Math.sin(v / (double) Math.max(6, definition.radius()) * 2.4 + shapePhase) + definition.radius() * 0.08 * Math.sin(u / 29.0 + v / 19.0 + shapePhase); + warped += definition.shapeWarp() * definition.radius() * (0.7 * noise(u / 43.0, v / 27.0, 71) + + 0.23 * noise(u / 13.0, v / 11.0, 73)); double segment = Math.max(4.0, definition.length() / (double) definition.terraceCount()); double terrace = (warped + definition.length() / 2.0) / segment; int index = Math.max(0, Math.min(definition.terraceCount() - 1, (int) Math.floor(terrace))); int rise = Math.max(1, (definition.height() / 3) / definition.terraceCount()); int floor = baseY() + index * rise; int head = floor + definition.fluidDepth(); + floor += (int) Math.round(definition.shapeWarp() * Math.max(0, definition.fluidDepth() - 1) + * (0.5 + 0.5 * erosion(u, v, 79))); double local = (terrace - Math.floor(terrace)) * segment; boolean rim = definition.fluidDepth() > 0 && (local <= 1.5 || local >= segment - 1.5 || footprint.boundary() <= 2); double pathOffset = Math.copySign(Math.max(0, footprint.width() - 4), footprint.offset()); @@ -312,7 +368,7 @@ public final class SubterrainPlan { floor = Math.max(floor, head + 1); } int ceiling = vaultedCeiling(u, footprint, floor, head); - return new Column(floor, ceiling, head, footprint.boundary() > 0, + return new Column(floor, baseFloor(floor, footprint.boundary(), u, v), ceiling, head, footprint.boundary() > 0, false, footprint.boundary(), pathDistance, footprint.path() + (int) Math.round(pathOffset)); } @@ -330,6 +386,18 @@ public final class SubterrainPlan { double bend = family() == IrisSubterrainFamily.TECTONIC_FAULT ? 0.08 : 0.12; int path = (int) Math.round(definition.radius() * bend * Math.sin(u / 47.0 + shapePhase) * taper); double vaultWave = 0.87 + 0.07 * Math.sin(u / 37.0 + shapePhase) + 0.04 * Math.sin(u / 17.0 - shapePhase); + if (definition.shapeWarp() > 0) { + double strength = definition.shapeWarp(); + double scale = Math.max(12, definition.radius()); + double erodedWidth = definition.radius() * taper * (0.45 + + 0.22 * noise(u / (scale * 1.8), 0.37, 11) + + 0.15 * noise(u / (scale * 0.48), 0.71, 13)); + double erodedPath = definition.radius() * taper * (0.72 * noise(u / (scale * 2.2), 0.19, 17) + + 0.25 * noise(u / (scale * 0.73), 0.53, 19)); + width += strength * (erodedWidth - width); + path = (int) Math.round(path + strength * (erodedPath - path)); + vaultWave += strength * (0.76 + 0.18 * noise(u / (scale * 1.1), 0.31, 23) - vaultWave); + } Profile profile = new Profile(Math.min(width, definition.radius() - Math.abs(path)), path, vaultWave); int slot = cache.profileNext; cache.profileKeys[slot] = u; @@ -348,6 +416,12 @@ public final class SubterrainPlan { double width = profile.width(); int path = profile.path(); double offset = v - path; + if (definition.shapeWarp() > 0) { + double strength = definition.shapeWarp(); + width += strength * (definition.radius() * 0.20 * noise(u / 17.0, v / 19.0, 29) + + Math.min(2.5, definition.radius() * 0.08) * noise(u / 5.0, v / 5.0, 31)); + width = Math.max(0, Math.min(width, definition.radius() - Math.abs(path))); + } double boundary = Math.min(halfLength - Math.abs(u), width - Math.abs(offset)); if (boundary < -2) { return null; @@ -362,9 +436,112 @@ public final class SubterrainPlan { double roofVariation = 0.05 * Math.sin(u / 37.0 + footprint.offset() / 17.0 + shapePhase) * arch + 0.06 * footprint.offset() / Math.max(1, footprint.width()) * Math.sin(u / 41.0 - shapePhase); int ceiling = baseY() + (int) Math.floor(definition.height() * wave * (0.4 + 0.6 * arch + roofVariation)); + if (definition.shapeWarp() > 0) { + double shoulder = 1 - smooth(Math.max(0, Math.min(1, footprint.boundary() / Math.min(8, footprint.width() * 0.6)))); + double erodedRoof = definition.height() * (wave - 0.08 - shoulder * 0.24 + + 0.32 * noise(u / 23.0, footprint.offset() / 17.0, 35) + + 0.20 * erosion(u, footprint.offset(), 37)); + ceiling = baseY() + (int) Math.floor((1 - definition.shapeWarp()) * (ceiling - baseY()) + + definition.shapeWarp() * erodedRoof); + } return Math.min(baseY() + definition.height(), Math.max(Math.max(floor, head) + 3, ceiling)); } + private Column erodedCenote(int u, int v) { + double radius = definition.radius(); + if (Math.hypot(u, v) > radius + 2) { + return null; + } + double strength = definition.shapeWarp(); + double scale = Math.max(8, radius * 0.65); + double shiftedU = u + strength * radius * 0.18 * noise(u / scale, v / scale, 101); + double shiftedV = v + strength * radius * 0.18 * noise(u / scale, v / scale, 103); + double lobes = 0.69 + 0.24 * noise(u / scale, v / scale, 105) + + 0.13 * noise(u / (scale * 0.46), v / (scale * 0.46), 106); + double outline = radius * (1 - strength + strength * lobes) + + strength * (radius * 0.07 * noise(u / 13.0, v / 13.0, 107) + + Math.min(2, radius * 0.05) * noise(u / 4.0, v / 4.0, 109)); + double distance = Math.hypot(shiftedU, shiftedV * (1 + 0.12 * strength)); + double boundary = Math.min(radius - Math.hypot(u, v), outline - distance); + if (boundary < -2) { + return null; + } + double normalized = Math.min(1, distance / Math.max(1, outline)); + double bankStart = 0.65 - strength * 0.11; + double bankWidth = 0.25 + strength * 0.15; + double bank = smooth(Math.max(0, Math.min(1, (normalized - bankStart) / bankWidth))); + double bed = Math.max(0, 0.42 + 0.58 * erosion(u, v, 113)); + double bedRelief = definition.fluidDepth() == 0 ? definition.height() * 0.30 + : Math.max(1, definition.fluidDepth() - 1); + int floor = baseY() + (int) Math.round(bank * (definition.fluidDepth() + 2 + strength) + + strength * bed * bedRelief); + double arch = Math.sqrt(Math.max(0, 1 - normalized * normalized)); + double shoulder = 1 - smooth(Math.max(0, Math.min(1, boundary / Math.max(4, radius * 0.2)))); + double roof = definition.height() * ((1 - strength) * arch + + strength * (0.64 - 0.24 * shoulder + + 0.40 * noise(u / (scale * 0.6), v / (scale * 0.6), 123) + + 0.23 * erosion(u, v, 127))); + int head = baseY() + definition.fluidDepth(); + int ceiling = Math.min(baseY() + definition.height(), Math.max(floor + 3, baseY() + (int) Math.floor(roof))); + boolean rim = definition.fluidDepth() > 0 && ceiling <= head + 2; + return new Column(floor, baseFloor(floor, boundary, u, v), ceiling, head, boundary > 0, rim, boundary, Math.abs(v), 0); + } + + private Column erodedTube(int u, int v) { + Footprint footprint = footprint(u, v); + if (footprint == null) { + return null; + } + double cross = Math.min(1, Math.abs(footprint.offset()) / Math.max(1, footprint.width())); + double bank = smooth(Math.max(0, Math.min(1, (cross - 0.38) / 0.55))); + int head = baseY() + definition.fluidDepth(); + int floor = baseY() + (int) Math.round(bank * (definition.fluidDepth() + 3) + + definition.shapeWarp() * definition.height() * 0.11 + * Math.max(0, 0.35 + 0.65 * erosion(u, v, 137))); + int ceiling = vaultedCeiling(u, footprint, floor, head); + return new Column(floor, baseFloor(floor, footprint.boundary(), u, v), ceiling, head, footprint.boundary() > 0, false, + footprint.boundary(), Math.abs(footprint.offset()), footprint.path()); + } + + private int baseFloor(int floor, double boundary, int u, int v) { + int depth = definition.baseDepth(); + if (depth == 0 || boundary <= 0) { + return floor; + } + double taper = Math.sqrt(Math.min(1, boundary / Math.max(4, definition.radius() * 0.55))); + double scale = Math.max(8, definition.radius() * 0.7); + double relief = Math.max(0, Math.min(1, 0.73 + 0.25 * noise(u / scale, v / scale, 157) + + 0.14 * noise(u / 7.0, v / 7.0, 163))); + return floor - (int) Math.round(depth * taper * relief); + } + + private double erosion(double u, double v, long salt) { + return 0.58 * noise(u / 31.0, v / 29.0, salt) + + 0.29 * noise(u / 11.0, v / 13.0, salt + 1) + + 0.13 * noise(u / 4.0, v / 4.0, salt + 2); + } + + private double noise(double u, double v, long salt) { + int x = (int) Math.floor(u); + int z = (int) Math.floor(v); + double dx = smooth(u - x); + double dz = smooth(v - z); + double near = lattice(x, z, salt); + near += dx * (lattice(x + 1, z, salt) - near); + double far = lattice(x, z + 1, salt); + far += dx * (lattice(x + 1, z + 1, salt) - far); + return near + dz * (far - near); + } + + private double lattice(int x, int z, long salt) { + return (SubterrainPlanner.mix(shapeSeed ^ x * 0x9e3779b97f4a7c15L + ^ z * 0xc2b2ae3d27d4eb4fL ^ salt * 0x165667b19e3779f9L) >>> 11) * 0x1.0p-52 - 1; + } + + private double smooth(double value) { + return value * value * (3 - 2 * value); + } + private SubterrainPosition occupiedAnchor(int x, int preferredY, int z) { if (anchorCell(sample(x, preferredY, z))) { return new SubterrainPosition(x, preferredY, z); @@ -406,7 +583,7 @@ public final class SubterrainPlan { private record Footprint(double width, double offset, double boundary, int path) { } - private record Column(int floor, int ceiling, int head, boolean interior, boolean rim, + private record Column(int floor, int baseFloor, int ceiling, int head, boolean interior, boolean rim, double boundary, double pathDistance, int pathOffset) { } } diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java index 2b739996e..be1920c7c 100644 --- a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainPlanner.java @@ -15,6 +15,7 @@ public final class SubterrainPlanner { private final int maxYExclusive; private final ThreadLocal sampleTile = new ThreadLocal<>(); private final Predicate allowedBounds; + private final RegionResolver regionResolver; public SubterrainPlanner(Options options) { this(options, bounds -> true); @@ -23,6 +24,7 @@ public final class SubterrainPlanner { public SubterrainPlanner(Options options, Predicate allowedBounds) { Objects.requireNonNull(options); this.allowedBounds = Objects.requireNonNull(allowedBounds); + regionResolver = Objects.requireNonNull(options.regionResolver()); seed = options.seed(); minY = options.minY(); maxYExclusive = options.maxYExclusive(); @@ -49,6 +51,7 @@ public final class SubterrainPlanner { private SubterrainPlanner(SubterrainPlanner source, Predicate allowedBounds) { definitions = source.definitions; + regionResolver = source.regionResolver; seed = source.seed; minY = source.minY; maxYExclusive = source.maxYExclusive; @@ -128,10 +131,10 @@ public final class SubterrainPlanner { SubterrainCell selected = SubterrainCell.OUTSIDE; for (SubterrainPlan plan : candidates) { SubterrainCell cell = plan.sample(x, y, z); - if (cell.solid()) { + if (cell.solid() && cell.room().reservedSolid()) { return cell; } - if (!selected.owned() && cell.owned()) { + if (!selected.owned() && cell.owned() || selected.solid() && cell.occupied()) { selected = cell; } } @@ -164,7 +167,7 @@ public final class SubterrainPlanner { int spacing = definition.spacing(); long x = gridX * spacing + spacing / 4L + Math.floorMod(mix(value + 1), Math.max(1, spacing / 2)); long z = gridZ * spacing + spacing / 4L + Math.floorMod(mix(value + 2), Math.max(1, spacing / 2)); - int bottomMargin = definition.height() / 2 + 3; + int bottomMargin = definition.height() / 2 + 3 + definition.baseDepth(); int topMargin = definition.height() - definition.height() / 2 + 3 + (definition.family() == IrisSubterrainFamily.LAVA_TUBE ? definition.chimneyHeight() : 0); long low = (long) Math.max(minY, definition.minY()) + bottomMargin; @@ -173,6 +176,10 @@ public final class SubterrainPlanner { || z < Integer.MIN_VALUE + spacing || z > Integer.MAX_VALUE - spacing) { return null; } + if (!definition.allowedRegions().isEmpty() + && !definition.allowedRegions().contains(regionResolver.regionAt((int) x, (int) z))) { + return null; + } int y = (int) (low + Math.floorMod(mix(value + 3), high - low + 1)); SubterrainPlan plan = new SubterrainPlan(new SubterrainPlan.Options(definition, (int) x, y, (int) z, (value & 1L) != 0, definition.id() + ":" + Long.toUnsignedString(value, 16))); @@ -189,9 +196,16 @@ public final class SubterrainPlanner { return (value >>> 11) * 0x1.0p-53; } - public record Options(List features, long seed, int minY, int maxYExclusive) { + @FunctionalInterface + public interface RegionResolver { + String regionAt(int x, int z); + } + + public record Options(List features, long seed, int minY, int maxYExclusive, + RegionResolver regionResolver) { public Options { features = List.copyOf(Objects.requireNonNull(features)); + Objects.requireNonNull(regionResolver); } } @@ -199,9 +213,14 @@ public final class SubterrainPlanner { } record Definition(String id, IrisSubterrainFamily family, IrisSubterrainFluid fluid, String biome, int spacing, double probability, - int length, int radius, int height, int fluidDepth, int chimneyHeight, int terraceCount, + int length, int radius, int height, double shapeWarp, int fluidDepth, int chimneyHeight, int terraceCount, int pillarSpacing, double formationFraction, String solid, int priority, - int minY, int maxY, long seedSalt) { + int minY, int maxY, long seedSalt, Set allowedRegions) { + int baseDepth() { + return family == IrisSubterrainFamily.TECTONIC_FAULT || fluidDepth == 0 ? 0 + : (int) Math.ceil(Math.min(24, radius * 0.45) * shapeWarp); + } + static Definition from(IrisSubterrainFeature feature) { String id = Objects.requireNonNull(feature.getId(), "Subterrain id").trim(); IrisSubterrainFamily family = Objects.requireNonNull(feature.getFamily(), "Subterrain family"); @@ -214,6 +233,7 @@ public final class SubterrainPlanner { int horizontalReach = family == IrisSubterrainFamily.CENOTE ? radius : Math.max(length / 2, radius); if (!id.matches("[a-zA-Z0-9_/-]+") || length < 16 || length > 2048 || radius < 6 || radius > 256 || height < 8 || height > 192 || chimney < 0 || chimney > 96 + || !Double.isFinite(feature.getShapeWarp()) || feature.getShapeWarp() < 0 || feature.getShapeWarp() > 1 || feature.getSpacing() < Math.max(32, 2 * horizontalReach + 8) || feature.getSpacing() > 8192 || !Double.isFinite(feature.getProbability()) || feature.getProbability() < 0 || feature.getProbability() > 1 || feature.getFluidDepth() < 0 || feature.getFluidDepth() > Math.min(32, height / 3) @@ -239,15 +259,21 @@ public final class SubterrainPlanner { if (!biome.isEmpty() && !biome.matches("[a-zA-Z0-9_/-]+")) { throw new IllegalArgumentException("Invalid subterrain biome key: " + id); } + Set allowedRegions = Set.copyOf(Objects.requireNonNull(feature.getAllowedRegions())); + for (String region : allowedRegions) { + if (!region.matches("[a-zA-Z0-9_/-]+")) { + throw new IllegalArgumentException("Invalid subterrain region key: " + id); + } + } long salt = 0xcbf29ce484222325L; for (int i = 0; i < id.length(); i++) { salt = (salt ^ id.charAt(i)) * 0x100000001b3L; } return new Definition(id, family, fluid, biome, - feature.getSpacing(), feature.getProbability(), length, radius, height, feature.getFluidDepth(), + feature.getSpacing(), feature.getProbability(), length, radius, height, feature.getShapeWarp(), feature.getFluidDepth(), chimney, feature.getTerraceCount(), feature.getPillarSpacing(), feature.getFormationFraction(), solid, feature.getPriority(), (int) Math.ceil(feature.getWorldYRange().getMin()), - (int) Math.floor(feature.getWorldYRange().getMax()), salt); + (int) Math.floor(feature.getWorldYRange().getMax()), salt, allowedRegions); } } } diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRasterizer.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRasterizer.java index 59fe4306c..08c0d5a97 100644 --- a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRasterizer.java +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainRasterizer.java @@ -74,7 +74,7 @@ public final class SubterrainRasterizer { } public static boolean protectsPlacement(SubterrainCell cell) { - return cell != null && cell.owned() && (cell.solid() || cell.room().reservedPassage()); + return cell != null && cell.owned() && (cell.room().reservedSolid() || cell.room().reservedPassage()); } @FunctionalInterface diff --git a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterials.java b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterials.java index 9973c5597..d327aa81a 100644 --- a/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterials.java +++ b/core/src/main/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterials.java @@ -1,6 +1,8 @@ package art.arcane.iris.generation.subterrain; import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.decoration.IrisDecorationPart; +import art.arcane.iris.generation.decoration.IrisDecorator; import art.arcane.iris.generation.runtime.IrisComplex; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.pack.loading.IrisData; @@ -9,8 +11,10 @@ import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.math.RNG; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; public final class SubterrainSurfaceMaterials { private final SubterrainPlanner planner; @@ -21,6 +25,7 @@ public final class SubterrainSurfaceMaterials { private final RNG random; private final int minimumY; private final Map biomes = new HashMap<>(); + private final Map> floorSupports = new HashMap<>(); public SubterrainSurfaceMaterials(SubterrainPlanner planner, List plans, IrisDimension dimension, IrisComplex complex, IrisData data, @@ -43,11 +48,7 @@ public final class SubterrainSurfaceMaterials { if (surface == Surface.INTERIOR) { return fallback; } - String key = cell.room().biome(); - if (!biomes.containsKey(key)) { - biomes.put(key, data.getBiomeLoader().load(key)); - } - IrisBiome biome = biomes.get(key); + IrisBiome biome = biome(cell.room().biome()); if (biome == null) { return fallback; } @@ -63,6 +64,31 @@ public final class SubterrainSurfaceMaterials { return SubterrainRasterizer.isRetainedSolid(candidate) ? candidate : fallback; } + public boolean retainsFloorSupport(int x, int worldY, int z, SubterrainCell cell, NativeBlockState existing) { + if (!cell.solid() || cell.room().biome().isEmpty() || !SubterrainRasterizer.isRetainedSolid(existing)) { + return false; + } + String key = cell.room().biome(); + Set supports = floorSupports.get(key); + if (supports == null) { + supports = new HashSet<>(); + IrisBiome biome = biome(key); + if (biome != null) { + for (IrisDecorator decorator : biome.getDecoratorBucket(IrisDecorationPart.NONE)) { + if (decorator.getForceBlock() == null) { + continue; + } + NativeBlockState support = decorator.getForceBlock().getBlockData(data); + if (SubterrainRasterizer.isRetainedSolid(support)) { + supports.add(support.key()); + } + } + } + floorSupports.put(key, supports); + } + return supports.contains(existing.key()) && surface(x, worldY, z, cell) == Surface.FLOOR; + } + public Surface surface(int x, int worldY, int z, SubterrainCell cell) { if (!cell.solid()) { return Surface.INTERIOR; @@ -86,6 +112,13 @@ public final class SubterrainSurfaceMaterials { return neighbor.occupied() && neighbor.room().featureId().equals(cell.room().featureId()); } + private IrisBiome biome(String key) { + if (!biomes.containsKey(key)) { + biomes.put(key, data.getBiomeLoader().load(key)); + } + return biomes.get(key); + } + public enum Surface { FLOOR, CEILING, diff --git a/core/src/main/java/art/arcane/iris/generation/terrain/RegionSelection.java b/core/src/main/java/art/arcane/iris/generation/terrain/RegionSelection.java new file mode 100644 index 000000000..99a1d2678 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/terrain/RegionSelection.java @@ -0,0 +1,80 @@ +package art.arcane.iris.generation.terrain; + +import art.arcane.iris.generation.biome.IrisBiome; +import art.arcane.iris.generation.image.IrisImageMapRuntime; +import art.arcane.iris.generation.image.IrisImageMapApplication; +import art.arcane.iris.pack.loading.IrisData; +import art.arcane.iris.studio.generation.BiomeBuffetLayout; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.math.RNG; +import art.arcane.volmlib.util.stream.ProceduralStream; +import art.arcane.volmlib.util.stream.interpolation.Interpolated; + +public final class RegionSelection { + private RegionSelection() { + } + + public static ProceduralStream select(Options options) { + if (options.focused() != null) { + return options.focused(); + } + KList regions = new KList<>(); + for (IrisRegion region : options.data().getRegionLoader().loadAll(options.dimension().getRegions())) { + if (!region.isCompatExcluded()) { + regions.add(region); + } + } + ProceduralStream selected = options.style().selectRarity(regions); + if (!options.imageMaps().has(IrisImageMapApplication.REGION)) { + return selected; + } + return selected.convertAware2D((region, x, z) -> { + IrisRegion mapped = options.imageMaps().sampleRegion(x, z); + return mapped == null ? region : mapped; + }); + } + + public static ProceduralStream restore(Snapshot snapshot) { + IrisData data = snapshot.data(); + IrisDimension dimension = snapshot.dimension(); + ProceduralStream focused = null; + if (snapshot.buffet() && dimension.getStudioMode().biomeSizeChunks() > 0) { + BiomeBuffetLayout layout = new BiomeBuffetLayout(dimension, () -> data); + if (!layout.cells().isEmpty()) { + focused = ProceduralStream.of((x, z) -> layout.terrain(x, z).region(), + Interpolated.of(value -> 0D, value -> layout.cells().getFirst().region())); + } + } + if (focused == null) { + IrisRegion focus = focusRegion(data, dimension); + if (focus != null) { + focused = ProceduralStream.of((x, z) -> focus, Interpolated.of(value -> 0D, value -> focus)); + } + } + return select(new Options(dimension, data, + dimension.getRegionStyle().create(new RNG(snapshot.complexSeed()).nextParallelRNG(883), data) + .stream().zoom(dimension.getRegionZoom()), + IrisImageMapRuntime.compile(data, dimension, dimension.getMinHeight()), focused)); + } + + private static IrisRegion focusRegion(IrisData data, IrisDimension dimension) { + if (dimension.getFocus() != null && !dimension.getFocus().isBlank()) { + IrisBiome focus = data.getBiomeLoader().load(dimension.getFocus()); + if (focus != null && !focus.isCompatExcluded()) { + return dimension.resolveFocusRegion(focus.withInferredType(InferredType.LAND), () -> data); + } + } + if (dimension.getFocusRegion() == null || dimension.getFocusRegion().isBlank()) { + return null; + } + IrisRegion focus = data.getRegionLoader().load(dimension.getFocusRegion()); + return focus == null || focus.isCompatExcluded() ? null : focus; + } + + public record Options(IrisDimension dimension, IrisData data, ProceduralStream style, + IrisImageMapRuntime imageMaps, ProceduralStream focused) { + } + + public record Snapshot(IrisDimension dimension, IrisData data, long complexSeed, boolean buffet) { + } +} diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java index 8f6107a12..84e22451d 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedBiomeRuntime.java @@ -11,6 +11,10 @@ import art.arcane.iris.generation.runtime.BiomeEnvironment; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.terrain.IrisDimension; import art.arcane.iris.generation.terrain.IrisRegion; +import art.arcane.iris.generation.terrain.RegionSelection; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.volmlib.util.cache.AtomicCache; +import art.arcane.volmlib.util.stream.ProceduralStream; import art.arcane.iris.spi.IrisLogging; import java.io.IOException; @@ -521,11 +525,16 @@ public final class SavedBiomeRuntime implements AutoCloseable { if (dimension.allowsSubterrainFeatures()) { SubterrainRasterizer.validateMaterials(dimension.getSubterrainFeatures()); } + AtomicCache> regionSelection = new AtomicCache<>(); Definitions loaded = new Definitions(data, dimension, NativeBiomeSpawnSelection.retainedDerivatives(data), resolveFocusRegions(data, dimension), new SubterrainPlanner(new SubterrainPlanner.Options( dimension.allowsSubterrainFeatures() ? dimension.getSubterrainFeatures() : List.of(), new SeedManager(epoch.worldSeed()).getBodies(), - dimension.getMinHeight(), dimension.getMaxHeight()))); + dimension.getMinHeight(), dimension.getMaxHeight(), + (x, z) -> regionSelection.aquire(() -> RegionSelection.restore(new RegionSelection.Snapshot( + dimension, data, new SeedManager(epoch.worldSeed()).getComplex(), + engine.isStudio() && "bukkit".equals(IrisPlatforms.get().platformName())))) + .get(x, z).getLoadKey()))); data.prepareBlockDropRules(); definitions.put(epochId, loaded); return loaded; diff --git a/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java b/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java index ffc092466..89016f60c 100644 --- a/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java +++ b/core/src/test/java/art/arcane/iris/generation/locator/SubterrainLocatorTest.java @@ -43,7 +43,7 @@ public class SubterrainLocatorTest { IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("wet-cave") .setProbability(1).setWorldYRange(new IrisRange(-100, 100)).setRadius(28).setHeight(40) .setFluidDepth(8).setFluid(fluid).setPillarSpacing(0).setFormationFraction(0); - return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -128, 256)); + return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -128, 256, (x, z) -> "test-region")); } @Test @@ -101,6 +101,6 @@ public class SubterrainLocatorTest { private static SubterrainPlanner planner() { IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("wet-cave") .setProbability(1).setWorldYRange(new IrisRange(-48, 48)); - return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -64, 320)); + return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 74119L, -64, 320, (x, z) -> "test-region")); } } diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java index 828b25a5a..b705818a6 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/CaveObjectPlacementTransactionTest.java @@ -131,6 +131,28 @@ public class CaveObjectPlacementTransactionTest { } } + @Test + public void drySkinProtectsExistingRockButAllowsAnAlreadyOpenCaveJoin() { + for (boolean solid : new boolean[]{true, false}) { + IObjectPlacer delegate = createPlacer(128, 80, 20, 60); + SubterrainRoom room = new SubterrainRoom("cenote", "dry-cave", IrisSubterrainFamily.CENOTE, + 4, 35, 7, 4, 21, 7, 19, 61, 0, 18, false, false, SubterrainCell.Kind.SOLID); + when(delegate.getEngine().getSubterrainCell(4, 30, 7)) + .thenReturn(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", room)); + when(delegate.isSolid(4, 30, 7)).thenReturn(solid); + CaveObjectPlacementTransaction transaction = new CaveObjectPlacementTransaction(delegate, 20, 10); + NativeBlockState decoration = mock(NativeBlockState.class); + transaction.set(4, 30, 7, decoration); + assertEquals(solid ? CaveObjectPlacementTransaction.CommitResult.REJECTED_SUBTERRAIN + : CaveObjectPlacementTransaction.CommitResult.COMMITTED, transaction.commit()); + if (solid) { + verify(delegate, never()).set(anyInt(), anyInt(), anyInt(), any()); + } else { + verify(delegate).set(4, 30, 7, decoration); + } + } + } + @Test public void ownedWaterAndLavaUseTheirExclusiveAbsoluteFluidHeadAtTheOriginalAnchor() { for (int minimumY : new int[]{-64, 32}) { diff --git a/core/src/test/java/art/arcane/iris/generation/mantle/NaturalCaveProceduralPlacementTest.java b/core/src/test/java/art/arcane/iris/generation/mantle/NaturalCaveProceduralPlacementTest.java index 03bad989e..83b7d208a 100644 --- a/core/src/test/java/art/arcane/iris/generation/mantle/NaturalCaveProceduralPlacementTest.java +++ b/core/src/test/java/art/arcane/iris/generation/mantle/NaturalCaveProceduralPlacementTest.java @@ -195,7 +195,7 @@ public class NaturalCaveProceduralPlacementTest { IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("natural-room").setProbability(1) .setRadius(32).setHeight(40).setFluid(options.fluid()).setFluidDepth(options.fluidDepth()) .setPillarSpacing(0).setFormationFraction(0).setWorldYRange(new IrisRange(20, 120)); - SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, 0, 256)); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, 0, 256, (x, z) -> "test-region")); SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst(); int x = plan.centerX() + 8; int z = plan.centerZ() + 8; diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/SubterrainBiomeOwnershipTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/SubterrainBiomeOwnershipTest.java index c22ca1dc2..8ca13bfcf 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/SubterrainBiomeOwnershipTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/SubterrainBiomeOwnershipTest.java @@ -28,7 +28,7 @@ public class SubterrainBiomeOwnershipTest { @Test public void occupiedFeatureOwnsExactVolumeAndYWithoutSurfaceDepthGate() { IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("basin-biome").setProbability(1); - SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320)); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320, (x, z) -> "test-region")); SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst(); Engine engine = mock(Engine.class, CALLS_REAL_METHODS); IrisComplex complex = mock(IrisComplex.class); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerUndergroundOwnershipTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerUndergroundOwnershipTest.java index 435c2c631..64179297e 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerUndergroundOwnershipTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/WorldEntitySpawnerUndergroundOwnershipTest.java @@ -51,7 +51,7 @@ public class WorldEntitySpawnerUndergroundOwnershipTest { Chunk chunk = mock(Chunk.class); when(chunk.getWorld()).thenReturn(world); IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("basin").setBiome("basin-biome").setProbability(1); - SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320)); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320, (x, z) -> "test-region")); SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst(); SubterrainPosition anchor = plan.anchor(); Location candidate = new Location(world, anchor.x(), anchor.y(), anchor.z()); diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java index c32ea2a51..e0bbe9760 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisCarveModifierBoundarySupportTest.java @@ -83,6 +83,31 @@ public class IrisCarveModifierBoundarySupportTest { assertSame(resolvedCeiling, ceiling); } + @Test + @SuppressWarnings("unchecked") + public void authoredRoomKeepsItsBiomeInsideNaturalTerrainOpening() { + IrisBiome room = new IrisBiome(); + IrisBiome surface = new IrisBiome(); + IrisComplex complex = mock(IrisComplex.class); + ProceduralStream biomes = mock(ProceduralStream.class); + doReturn(true).when(complex).isTerrain3DOpening(40, 42, 44); + doReturn(biomes).when(complex).getTrueBiomeStream(); + doReturn(surface).when(biomes).get(40, 44); + IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + Engine engine = mock(Engine.class); + doReturn(engine).when(modifier).getEngine(); + doReturn(complex).when(engine).getComplex(); + doReturn(room).when(engine).getSubterrainBiome(40, 42, 44); + IrisCarveModifier.CaveInputs inputs = new IrisCarveModifier.CaveInputs(engine); + + assertSame(room, modifier.resolveCaveBoundaryBiome( + new MatterCavern(true, "carving/other", (byte) 0), 40, 42, 44, + inputs, new Long2ObjectOpenHashMap<>(), new HashMap<>())); + assertSame(room, modifier.resolveCaveBoundaryBiome( + null, 40, 42, 44, + inputs, new Long2ObjectOpenHashMap<>(), new HashMap<>())); + } + @Test @SuppressWarnings("unchecked") public void terrainOpeningKeepsItsSurfaceBiomeOverCaveMatter() { diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java index 2af50e4ac..ff72a989e 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisNaturalCaveScanTest.java @@ -1,6 +1,8 @@ package art.arcane.iris.generation.stage; import art.arcane.iris.generation.runtime.IrisComplex; +import art.arcane.iris.generation.subterrain.SubterrainCell; +import art.arcane.iris.generation.subterrain.SubterrainRoom; import art.arcane.iris.generation.decoration.IrisCeilingDecorator; import art.arcane.iris.generation.decoration.IrisSurfaceDecorator; import art.arcane.iris.generation.runtime.Engine; @@ -78,6 +80,28 @@ public class IrisNaturalCaveScanTest { verifyScan(16, 16); } + @Test + public void reservedWalkingPassageKeepsFloorClearAndDecoratesHighCeiling() throws Exception { + Fixture fixture = new Fixture(); + SubterrainRoom room = mock(SubterrainRoom.class); + doReturn(true).when(room).reservedPassage(); + SubterrainCell passage = new SubterrainCell(SubterrainCell.Kind.AIR, "minecraft:cave_air", room); + Engine engine = fixture.modifier.getEngine(); + doReturn(passage).when(engine).getSubterrainCell(-32, 2, 48); + Hunk output = Hunk.newArrayHunk(1, 32, 1); + for (int y = 0; y < 32; y++) { + output.setRaw(0, y, 0, y >= 2 && y <= 12 ? fixture.air : fixture.stone); + } + + fixture.modifier.decorateNaturalCaves(-32, 48, output, null); + + assertEquals(List.of("ceiling:0:0:12:6"), fixture.calls); + assertSame(fixture.air, output.getRaw(0, 2, 0)); + assertSame(fixture.air, output.getRaw(0, 3, 0)); + assertSame(fixture.air, output.getRaw(0, 4, 0)); + assertSame(fixture.ceiling, output.getRaw(0, 12, 0)); + } + @Test public void decorationReusesOnlyTheCarveSnapshotOfItsOwnChunk() throws Exception { Fixture fixture = new Fixture(); diff --git a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java index ae60f78d3..43c8b2378 100644 --- a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainPlannerTest.java @@ -20,6 +20,87 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; public class SubterrainPlannerTest { + @Test + public void warpedFamiliesHaveAsymmetricSeededReliefAndRetainedFluids() { + for (IrisSubterrainFamily family : IrisSubterrainFamily.values()) { + IrisSubterrainFeature feature = feature(family).setShapeWarp(1).setRadius(16) + .setHeight(28).setLength(family == IrisSubterrainFamily.TECTONIC_FAULT ? 208 : 80) + .setChimneyHeight(0); + SubterrainPlanner planner = planner(feature); + for (int seed = 0; seed < 3; seed++) { + SubterrainPlan plan = new SubterrainPlan(new SubterrainPlan.Options( + SubterrainPlanner.Definition.from(feature), 0, 0, 0, false, feature.getId() + ":" + seed)); + SubterrainPlan rotated = new SubterrainPlan(new SubterrainPlan.Options( + SubterrainPlanner.Definition.from(feature), 0, 0, 0, true, plan.id())); + SubterrainPosition anchor = plan.anchor(); + assertTrue(plan.sample(anchor.x(), anchor.y(), anchor.z()).occupied()); + Set floors = new HashSet<>(); + Set ceilings = new HashSet<>(); + int asymmetric = 0; + SubterrainBounds bounds = plan.bounds(); + for (int x = bounds.minX(); x <= bounds.maxX(); x += 2) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z += 2) { + for (int y = bounds.minY(); y <= bounds.maxY(); y += 2) { + SubterrainCell cell = plan.sample(x, y, z); + assertEquals(cell.kind(), rotated.sample(z, y, x).kind()); + if (cell.occupied()) { + floors.add(cell.room().floorY()); + ceilings.add(cell.room().ceilingY()); + assertTrue(x > bounds.minX() && x < bounds.maxX()); + assertTrue(z > bounds.minZ() && z < bounds.maxZ()); + assertTrue(y > bounds.minY() && y < bounds.maxY()); + } + if (cell.kind() != plan.sample(-x, y, -z).kind()) { + asymmetric++; + } + } + } + } + assertTrue(family + " floors", floors.size() >= 3); + assertTrue(family + " ceilings", ceilings.size() >= 6); + assertTrue(family + " asymmetry", asymmetric > 100); + if (family != IrisSubterrainFamily.TECTONIC_FAULT) { + assertTrue(family + " retained fluid", assertRetained(planner, List.of(plan), + family == IrisSubterrainFamily.LAVA_TUBE ? SubterrainCell.Kind.LAVA : SubterrainCell.Kind.WATER) > 30); + } + } + } + } + + @Test + public void shapeWarpRejectsInvalidStrengthsAndSnapshotsConfiguration() { + for (double invalid : new double[]{-0.1, 1.1, Double.NaN, Double.POSITIVE_INFINITY}) { + assertThrows(IllegalArgumentException.class, + () -> planner(feature(IrisSubterrainFamily.CENOTE).setShapeWarp(invalid))); + } + IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE).setShapeWarp(0.9); + SubterrainPlanner.Definition definition = SubterrainPlanner.Definition.from(feature); + feature.setShapeWarp(0.2); + assertEquals(0.9, definition.shapeWarp(), 0); + } + + @Test + public void serializedWarpedDefinitionRestoresTheSamePlan() { + Gson gson = new Gson(); + IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE).setShapeWarp(0.93); + String saved = gson.toJson(feature); + IrisSubterrainFeature restored = gson.fromJson(saved, IrisSubterrainFeature.class); + assertEquals(0.93, restored.getShapeWarp(), 0); + assertEquals(SubterrainPlanner.Definition.from(feature), SubterrainPlanner.Definition.from(restored)); + SubterrainPlan before = direct(feature); + feature.setShapeWarp(0); + SubterrainPlan after = direct(restored); + assertEquals(before.anchor(), after.anchor()); + SubterrainBounds bounds = before.bounds(); + for (int x = bounds.minX(); x <= bounds.maxX(); x += 3) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z += 3) { + for (int y = bounds.minY(); y <= bounds.maxY(); y += 3) { + assertEquals(before.sample(x, y, z), after.sample(x, y, z)); + } + } + } + } + @Test public void terraceAnchorsUseOccupiedPathsWhenCenterIsPillarOrUpperStep() { for (IrisSubterrainFluid fluid : IrisSubterrainFluid.values()) { @@ -314,7 +395,8 @@ public class SubterrainPlannerTest { } previous = layer; } - assertTrue(plan.sample(0, 0, 0).room().reservedPassage()); + assertTrue(plan.sample(0, -10, 0).room().reservedPassage()); + assertFalse(plan.sample(0, 0, 0).room().reservedPassage()); assertFalse(plan.sample(0, 0, 0).solid()); int formationCells = 0; for (int x = -20; x <= 20; x++) { @@ -338,9 +420,9 @@ public class SubterrainPlannerTest { IrisSubterrainFeature cenote = feature(IrisSubterrainFamily.CENOTE); IrisSubterrainFeature fault = feature(IrisSubterrainFamily.TECTONIC_FAULT); List features = new ArrayList<>(List.of(cenote, fault)); - SubterrainPlanner first = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97)); + SubterrainPlanner first = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97, (x, z) -> "test-region")); Collections.reverse(features); - SubterrainPlanner reordered = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97)); + SubterrainPlanner reordered = new SubterrainPlanner(new SubterrainPlanner.Options(features, 7123, -64, 97, (x, z) -> "test-region")); List plans = first.plansForBounds(-1024, -1024, 1024, 1024); assertEquals(plans.stream().map(SubterrainPlan::id).toList(), reordered.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList()); @@ -367,7 +449,7 @@ public class SubterrainPlannerTest { feature.setRadius(8).setBiome("changed").setId("changed"); assertEquals(before, planner.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList()); assertEquals("subterranean/example", first(planner).biome()); - SubterrainPlanner anotherSeed = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 999, -64, 97)); + SubterrainPlanner anotherSeed = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 999, -64, 97, (x, z) -> "test-region")); assertNotEquals(before, anotherSeed.plansForBounds(-1024, -1024, 1024, 1024).stream().map(SubterrainPlan::id).toList()); } @@ -384,9 +466,81 @@ public class SubterrainPlannerTest { SubterrainPlanner.Definition definition = SubterrainPlanner.Definition.from(feature); SubterrainPlan upper = new SubterrainPlan(new SubterrainPlan.Options(definition, 32, 0, 0, false, "fault:other")); SubterrainPlanner planner = planner(feature); - assertTrue(upper.sample(32, -14, 0).occupied()); - assertTrue(lower.sample(32, -14, 0).solid()); - assertTrue(planner.sample(List.of(upper, lower), 32, -14, 0).solid()); + assertTrue(upper.sample(25, -13, 0).occupied()); + assertTrue(lower.sample(25, -13, 0).solid()); + assertTrue(lower.sample(25, -13, 0).room().reservedSolid()); + assertTrue(planner.sample(List.of(upper, lower), 25, -13, 0).solid()); + } + + @Test + public void dryFeatureIntersectionsJoinAcrossEitherShellPriority() { + IrisSubterrainFeature basin = feature(IrisSubterrainFamily.CENOTE).setFluidDepth(0).setShapeWarp(1); + IrisSubterrainFeature fault = feature(IrisSubterrainFamily.TECTONIC_FAULT).setShapeWarp(1); + SubterrainPlan first = direct(basin); + SubterrainPlan second = direct(fault); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(basin, fault), 7, -64, 97, (x, z) -> "test-region")); + int joined = 0; + for (int x = -32; x <= 32; x++) { + for (int z = -32; z <= 32; z++) { + for (int y = -16; y <= 16; y++) { + SubterrainCell shell = first.sample(x, y, z); + SubterrainCell passage = second.sample(x, y, z); + if (shell.solid() && !shell.room().reservedSolid() && passage.kind() == SubterrainCell.Kind.AIR) { + assertEquals(passage, planner.sample(List.of(first, second), x, y, z)); + assertEquals(passage, planner.sample(List.of(second, first), x, y, z)); + joined++; + } + } + } + } + assertTrue(joined > 100); + } + + @Test + public void exteriorCornersAndRaisedBanksDoNotExtrudeToFlatBoundingPlanes() { + SubterrainPlan plan = direct(feature(IrisSubterrainFamily.CENOTE).setFluidDepth(0)); + assertTrue(plan.sample(32, -14, 0).solid()); + assertFalse(plan.sample(33, -16, 0).owned()); + assertFalse(plan.sample(30, -17, 0).owned()); + } + + @Test + public void roundedWetBasesHaveReliefAndRemainInsideThePlacementBand() { + for (IrisSubterrainFamily family : List.of(IrisSubterrainFamily.CENOTE, + IrisSubterrainFamily.LAVA_TUBE, IrisSubterrainFamily.TRAVERTINE_TERRACES)) { + IrisSubterrainFeature feature = feature(family).setShapeWarp(1).setChimneyHeight(0); + SubterrainPlan plan = direct(feature); + SubterrainBounds bounds = plan.bounds(); + int maximumBaseDepth = SubterrainPlanner.Definition.from(feature).baseDepth(); + Set bottomHeights = new HashSet<>(); + int deepest = 0; + for (int x = bounds.minX(); x <= bounds.maxX(); x += 2) { + for (int z = bounds.minZ(); z <= bounds.maxZ(); z += 2) { + for (int y = bounds.minY(); y <= bounds.maxY(); y++) { + SubterrainCell cell = plan.sample(x, y, z); + if (!cell.solid() || !cell.room().reservedSolid()) { + continue; + } + bottomHeights.add(y); + int depth = cell.room().floorY() - y; + assertTrue(depth <= maximumBaseDepth + 2); + deepest = Math.max(deepest, depth); + break; + } + } + } + assertTrue(family + " base relief", bottomHeights.size() > 5); + assertTrue(family + " rock depth", deepest > 6); + feature.setWorldYRange(new IrisRange(0, feature.getHeight() + 6)); + assertTrue(planner(feature).plansForBounds(-512, -512, 512, 512).isEmpty()); + feature.setWorldYRange(new IrisRange(0, 80)); + List placedPlans = planner(feature).plansForBounds(-512, -512, 512, 512); + assertFalse(placedPlans.isEmpty()); + for (SubterrainPlan placed : placedPlans) { + assertTrue(placed.bounds().minY() >= 0); + assertTrue(placed.bounds().maxY() <= 80); + } + } } @Test(expected = IllegalArgumentException.class) @@ -413,7 +567,7 @@ public class SubterrainPlannerTest { @Test(expected = IllegalArgumentException.class) public void duplicateIdentifiersAreRejected() { IrisSubterrainFeature feature = feature(IrisSubterrainFamily.CENOTE); - new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature, feature), 1, -64, 97)); + new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature, feature), 1, -64, 97, (x, z) -> "test-region")); } private int assertRetained(SubterrainPlanner planner, List candidates, SubterrainCell.Kind kind) { @@ -450,7 +604,7 @@ public class SubterrainPlannerTest { } private SubterrainPlanner planner(IrisSubterrainFeature feature) { - return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 7123, -64, 97)); + return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 7123, -64, 97, (x, z) -> "test-region")); } private SubterrainPlan first(SubterrainPlanner planner) { diff --git a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRasterizerTest.java b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRasterizerTest.java index 041f8b14c..615714834 100644 --- a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRasterizerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRasterizerTest.java @@ -31,7 +31,7 @@ public class SubterrainRasterizerTest { .setFluid(IrisSubterrainFluid.LAVA).setProbability(1).setLength(64).setRadius(12) .setHeight(24).setPillarSpacing(0).setFormationFraction(0) .setBiome("underground/lava").setWorldYRange(new IrisRange(-48, 60)); - SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128)); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128, (x, z) -> "test-region")); SubterrainPlan plan = planner.plansForBounds(-512, -512, 512, 512).getFirst(); int[] fluids = {0}; SubterrainRasterizer.rasterize(planner, plan.centerX() >> 4, plan.centerZ() >> 4, (x, y, z, cell) -> { @@ -85,7 +85,7 @@ public class SubterrainRasterizerTest { IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("retained-cenote") .setProbability(1).setRadius(20).setHeight(24).setPillarSpacing(12) .setWorldYRange(new IrisRange(-48, 60)); - SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128)); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128, (x, z) -> "test-region")); SubterrainPlan plan = planner.plansForBounds(-512, -512, 512, 512).getFirst(); int firstX = plan.centerX() >> 4; int firstZ = plan.centerZ() >> 4; @@ -121,7 +121,10 @@ public class SubterrainRasterizerTest { 0, 0, 0, 0, 0, 0, -8, 12, 10, -4, false, false, SubterrainCell.Kind.AIR); assertFalse(SubterrainRasterizer.protectsPlacement(SubterrainCell.OUTSIDE)); assertFalse(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.AIR, "minecraft:cave_air", room))); - assertTrue(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", room))); + assertFalse(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", room))); + SubterrainRoom seal = new SubterrainRoom("room", "biome", IrisSubterrainFamily.CENOTE, + 0, 0, 0, 0, 0, 0, -8, 12, 10, -4, false, true, SubterrainCell.Kind.SOLID); + assertTrue(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.SOLID, "minecraft:stone", seal))); SubterrainRoom passage = new SubterrainRoom("room", "biome", IrisSubterrainFamily.CENOTE, 0, 0, 0, 0, 0, 0, -8, 12, 10, -4, true, false, SubterrainCell.Kind.AIR); assertTrue(SubterrainRasterizer.protectsPlacement(new SubterrainCell(SubterrainCell.Kind.AIR, "minecraft:cave_air", passage))); diff --git a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRegionSelectionTest.java b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRegionSelectionTest.java new file mode 100644 index 000000000..701137544 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainRegionSelectionTest.java @@ -0,0 +1,70 @@ +package art.arcane.iris.generation.subterrain; + +import art.arcane.iris.pack.value.IrisRange; +import art.arcane.volmlib.util.collection.KList; +import com.google.gson.Gson; +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.assertThrows; +import static org.junit.Assert.assertTrue; + +public class SubterrainRegionSelectionTest { + @Test + public void restrictedRoomsOnlyPlaceInTheirAllowedRegion() { + IrisSubterrainFeature feature = feature().setAllowedRegions(new KList<>("hot")); + SubterrainPlanner planner = planner(feature, (x, z) -> x < 0 ? "hot" : "frozen"); + List plans = planner.plansForBounds(-1024, -1024, 1024, 1024); + assertFalse(plans.isEmpty()); + for (SubterrainPlan plan : plans) { + assertTrue(plan.centerX() < 0); + SubterrainPosition anchor = plan.anchor(); + assertTrue(planner.sample(anchor.x(), anchor.y(), anchor.z()).occupied()); + } + assertTrue(planner(feature, (x, z) -> "frozen").plansForBounds(-1024, -1024, 1024, 1024).isEmpty()); + } + + @Test + public void unrestrictedRoomsDoNotSampleRegions() { + SubterrainPlanner planner = planner(feature(), (x, z) -> { + throw new AssertionError("Unrestricted room queried region"); + }); + assertFalse(planner.plansForBounds(-1024, -1024, 1024, 1024).isEmpty()); + } + + @Test + public void restrictionsSurviveSerializationSnapshotAndFootprintFiltering() { + IrisSubterrainFeature feature = feature().setAllowedRegions(new KList<>("hot")); + Gson gson = new Gson(); + IrisSubterrainFeature restored = gson.fromJson(gson.toJson(feature), IrisSubterrainFeature.class); + SubterrainPlanner planner = planner(restored, (x, z) -> "hot"); + List expected = planner.plansForBounds(-1024, -1024, 1024, 1024); + restored.getAllowedRegions().clear(); + restored.getAllowedRegions().add("frozen"); + SubterrainPlanner restricted = planner.restrictTo(bounds -> bounds.maxZ() < 0); + List actual = restricted.plansForBounds(-1024, -1024, 1024, 1024); + assertFalse(actual.isEmpty()); + assertEquals(expected.stream().filter(plan -> plan.bounds().maxZ() < 0).map(SubterrainPlan::id).toList(), + actual.stream().map(SubterrainPlan::id).toList()); + assertTrue(planner(restored, (x, z) -> "hot").plansForBounds(-1024, -1024, 1024, 1024).isEmpty()); + } + + @Test + public void invalidRegionKeysAreRejected() { + assertThrows(IllegalArgumentException.class, + () -> planner(feature().setAllowedRegions(new KList<>("bad region")), (x, z) -> "hot")); + } + + private IrisSubterrainFeature feature() { + return new IrisSubterrainFeature().setId("regional-room").setProbability(1).setSpacing(256) + .setRadius(16).setHeight(24).setFluidDepth(0).setPillarSpacing(0).setFormationFraction(0) + .setWorldYRange(new IrisRange(-64, 64)); + } + + private SubterrainPlanner planner(IrisSubterrainFeature feature, SubterrainPlanner.RegionResolver regions) { + return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1337L, -64, 128, regions)); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterialsTest.java b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterialsTest.java index 071beace4..81acce68c 100644 --- a/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterialsTest.java +++ b/core/src/test/java/art/arcane/iris/generation/subterrain/SubterrainSurfaceMaterialsTest.java @@ -2,6 +2,8 @@ package art.arcane.iris.generation.subterrain; import art.arcane.iris.generation.biome.IrisBiome; import art.arcane.iris.generation.biome.IrisBiomePaletteLayer; +import art.arcane.iris.generation.decoration.IrisDecorationPart; +import art.arcane.iris.generation.decoration.IrisDecorator; import art.arcane.iris.generation.block.B; import art.arcane.iris.generation.block.IrisBlockData; import art.arcane.iris.generation.runtime.IrisComplex; @@ -20,6 +22,7 @@ import org.junit.Test; import org.mockito.MockedStatic; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -60,7 +63,7 @@ public class SubterrainSurfaceMaterialsTest { default -> fixture.fallback; }; assertSame(expected, fixture.materials.state(position.x(), position.y(), position.z(), before)); - assertTrue(SubterrainRasterizer.protectsPlacement(before)); + assertEquals(before.room().reservedSolid(), SubterrainRasterizer.protectsPlacement(before)); assertEquals(before, fixture.cell(position)); int chunkX = position.x() >> 4; int chunkZ = position.z() >> 4; @@ -100,10 +103,61 @@ public class SubterrainSurfaceMaterialsTest { output.setRaw(x, y, z, null); modifier.preserveSubterrain((position.x() >> 4) << 4, (position.z() >> 4) << 4, output); NativeBlockState expected = fixture.materials.state(position.x(), position.y(), position.z(), fixture.cell(position)); - assertSame(expected, output.getRaw(x, y, z)); + assertSame(fixture.cell(position).room().reservedSolid() ? expected : null, output.getRaw(x, y, z)); output.setRaw(x, y, z, fixture.fallback); modifier.preserveSubterrain((position.x() >> 4) << 4, (position.z() >> 4) << 4, output); assertSame(expected, output.getRaw(x, y, z)); + if (!fixture.cell(position).room().reservedSolid()) { + for (String existingKey : List.of("minecraft:cave_air", "minecraft:water", "minecraft:lava")) { + NativeBlockState opening = B.getState(existingKey); + output.setRaw(x, y, z, opening); + modifier.preserveSubterrain((position.x() >> 4) << 4, (position.z() >> 4) << 4, output); + assertSame(opening, output.getRaw(x, y, z)); + } + } + } + } + } + + @Test + public void floorDecoratorSubstratesSurviveBothPreservationPasses() throws Exception { + try (Fixture fixture = new Fixture(IrisSubterrainFamily.CENOTE)) { + Engine engine = mock(Engine.class); + when(engine.getComplex()).thenReturn(fixture.complex); + when(engine.getData()).thenReturn(fixture.data); + when(engine.getDimension()).thenReturn(fixture.dimension); + when(engine.getWorld()).thenReturn(IrisWorld.builder().minHeight(-64).maxHeight(128).build()); + when(fixture.complex.getSubterrainPlanner()).thenReturn(fixture.planner); + IrisCarveModifier modifier = mock(IrisCarveModifier.class, CALLS_REAL_METHODS); + doReturn(engine).when(modifier).getEngine(); + Field random = IrisCarveModifier.class.getDeclaredField("rng"); + random.setAccessible(true); + random.set(modifier, fixture.random); + Method restore = IrisCarveModifier.class.getDeclaredMethod("restoreSubterrainSolids", Hunk.class, int.class, int.class, boolean.class); + restore.setAccessible(true); + SubterrainPosition position = fixture.surfaces().get(SubterrainSurfaceMaterials.Surface.FLOOR); + int chunkX = position.x() >> 4; + int chunkZ = position.z() >> 4; + int x = position.x() & 15; + int y = position.y() + 64; + int z = position.z() & 15; + Hunk output = Hunk.newArrayHunk(16, 192, 16); + for (String key : List.of("minecraft:moss_block", "minecraft:crimson_nylium")) { + NativeBlockState support = solid(key); + IrisBlockData forceBlock = mock(IrisBlockData.class); + when(forceBlock.getBlockData(fixture.data)).thenReturn(support); + when(fixture.biome.getDecoratorBucket(IrisDecorationPart.NONE)) + .thenReturn(new IrisDecorator[]{new IrisDecorator().setForceBlock(forceBlock)}); + output.setRaw(x, y, z, support); + + restore.invoke(modifier, output, chunkX, chunkZ, true); + assertSame(support, output.getRaw(x, y, z)); + modifier.preserveSubterrain(chunkX << 4, chunkZ << 4, output); + assertSame(support, output.getRaw(x, y, z)); + + when(support.isOccluding()).thenReturn(false); + modifier.preserveSubterrain(chunkX << 4, chunkZ << 4, output); + assertSame(fixture.floor, output.getRaw(x, y, z)); } } } @@ -183,13 +237,14 @@ public class SubterrainSurfaceMaterialsTest { IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("room").setBiome("room-biome") .setFamily(family).setProbability(1).setRadius(12).setHeight(16).setLength(200) .setChimneyHeight(8).setWorldYRange(new IrisRange(-48, 64)); - planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128)); + planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 83L, -64, 128, (x, z) -> "test-region")); plan = planner.plansForBounds(-512, -512, 512, 512).getFirst(); plans = planner.plansForBounds(plan.bounds().minX() - 1, plan.bounds().minZ() - 1, plan.bounds().maxX() + 1, plan.bounds().maxZ() + 1); ResourceLoader loader = mock(ResourceLoader.class); when(data.getBiomeLoader()).thenReturn(loader); when(loader.load("room-biome")).thenReturn(biome); + when(biome.getDecoratorBucket(IrisDecorationPart.NONE)).thenReturn(new IrisDecorator[0]); IrisBiomePaletteLayer wallPalette = mock(IrisBiomePaletteLayer.class); when(biome.getWall()).thenReturn(wallPalette); when(wallPalette.get(eq(random), anyDouble(), anyDouble(), anyDouble(), eq(data))).thenReturn(wall); @@ -240,6 +295,7 @@ public class SubterrainSurfaceMaterialsTest { Map result = new HashMap<>(); for (SubterrainPosition chunk : chunks) { Hunk output = Hunk.newArrayHunk(16, 192, 16); + output.fill(fallback); for (int draw = 0; draw < 19; draw++) { random.nextLong(); } diff --git a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java index 51a5d29a7..fb882a67f 100644 --- a/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java +++ b/core/src/test/java/art/arcane/iris/pack/schema/SchemaBuilderIdentityTest.java @@ -83,7 +83,7 @@ public class SchemaBuilderIdentityTest { @ClassRule public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); - private static final String EXPECTED_SCHEMA_DIGEST = "6f6dae0cd1077c45fe3f5ba512c1da4b0ab43bc8516f2d759bc8f7a798eb1c58"; + private static final String EXPECTED_SCHEMA_DIGEST = "cc10defa73f5288d10714cdbdef6bda866acfc656efe54e062fac9995bc5607f"; private static final List> SCHEMA_ROOTS = List.of( IrisDimension.class, @@ -138,7 +138,7 @@ public class SchemaBuilderIdentityTest { IrisDimension dimension = gson.fromJson(""" {"subterrainFeatures":[{"id":"flooded-vault","family":"CENOTE", "biome":"pack/cavern","fluid":"LAVA","worldYRange":{"min":-40,"max":24}, - "spacing":384,"probability":0.75,"radius":28,"fluidDepth":6}]} + "spacing":384,"probability":0.75,"radius":28,"fluidDepth":6,"shapeWarp":0.94,"allowedRegions":["hot"]}]} """, IrisDimension.class); assertEquals(1, dimension.getSubterrainFeatures().size()); IrisSubterrainFeature feature = dimension.getSubterrainFeatures().get(0); @@ -152,6 +152,8 @@ public class SchemaBuilderIdentityTest { assertEquals(0.75D, feature.getProbability(), 0D); assertEquals(28, feature.getRadius()); assertEquals(6, feature.getFluidDepth()); + assertEquals(0.94D, feature.getShapeWarp(), 0D); + assertEquals(List.of("hot"), feature.getAllowedRegions()); IrisDimension restored = gson.fromJson(gson.toJson(dimension), IrisDimension.class); assertEquals(feature, restored.getSubterrainFeatures().get(0)); assertTrue(new IrisDimension().getSubterrainFeatures().isEmpty()); @@ -178,11 +180,14 @@ public class SchemaBuilderIdentityTest { assertTrue(properties.has("biome")); assertTrue(properties.has("fluid")); assertTrue(properties.has("worldYRange")); + assertEquals("array", properties.getJSONObject("allowedRegions").getString("type")); assertEquals(32, properties.getJSONObject("spacing").getInt("minimum")); assertEquals(8192, properties.getJSONObject("spacing").getInt("maximum")); assertEquals(0D, properties.getJSONObject("probability").getDouble("minimum"), 0D); assertEquals(1D, properties.getJSONObject("probability").getDouble("maximum"), 0D); assertEquals(32, properties.getJSONObject("fluidDepth").getInt("maximum")); + assertEquals(0D, properties.getJSONObject("shapeWarp").getDouble("minimum"), 0D); + assertEquals(1D, properties.getJSONObject("shapeWarp").getDouble("maximum"), 0D); } private static String digest(String value) { diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeCaptureTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeCaptureTest.java index b652f046c..970db46f4 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedBiomeCaptureTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedBiomeCaptureTest.java @@ -213,7 +213,7 @@ public class SavedBiomeCaptureTest { public void capturesFeatureBoundariesAtExactBlockCoordinates() throws Exception { IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("exact-basin").setBiome("authored-biome") .setProbability(1).setPillarSpacing(0).setFormationFraction(0); - SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320)); + SubterrainPlanner planner = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320, (x, z) -> "test-region")); SubterrainPlan plan = planner.plansForBounds(0, 0, 512, 512).getFirst(); SubterrainPosition anchor = plan.anchor(); int edgeX = plan.bounds().minX(); diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java index 07dec4af6..dd71a095a 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedSubterrainQueriesTest.java @@ -33,7 +33,7 @@ public class SavedSubterrainQueriesTest { IrisSubterrainFeature feature = new IrisSubterrainFeature().setId("old-basin").setProbability(1D) .setBiome("retired-cave").setRadius(28).setHeight(40).setFluidDepth(8) .setWorldYRange(new IrisRange(-100, 100)).setFormationFraction(0).setPillarSpacing(0); - SubterrainPlanner historical = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -128, 256)); + SubterrainPlanner historical = new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -128, 256, (x, z) -> "test-region")); SubterrainPlan plan = historical.plansForBounds(0, 0, 511, 511).getFirst(); SubterrainPosition anchor = plan.anchor(); SubterrainLocator.Result target = new SubterrainLocator.Result(plan.id(), plan.family(), plan.biome(), @@ -126,6 +126,6 @@ public class SavedSubterrainQueriesTest { private static SubterrainPlanner planner(String id) { IrisSubterrainFeature feature = new IrisSubterrainFeature().setId(id).setProbability(1D); - return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320)); + return new SubterrainPlanner(new SubterrainPlanner.Options(List.of(feature), 1191L, -64, 320, (x, z) -> "test-region")); } }