From 2462330787de7abd1d28dd1e127c3ecf33682fce Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Sat, 31 Oct 2026 19:23:25 -0400 Subject: [PATCH] Transition water caps - Saved and blended columns find solid layers that cap water at the water head - Surface search passes over a water cap to the ground below it - A capped column keeps its fluid height at the top of the cap - A painted cap counts only where the blended column keeps the same blocks - Saved terrain sources report their water surface - Displacement fluid keys come from the first fluid at or below the fluid height - Empty fluid keys never win the fluid blend --- .../stage/IrisTransitionGeometryActuator.java | 45 +++++++- .../world/history/BoundaryColumnGeometry.java | 86 ++++++++++++-- .../iris/world/history/SavedTerrainChunk.java | 11 +- .../history/TransitionDisplacementField.java | 6 +- .../EngineModeTerrainGenerationTest.java | 3 + .../runtime/IrisComplexTransitionTest.java | 95 ++++++++++++++-- .../IrisTransitionGeometryActuatorTest.java | 105 +++++++++++++++++- .../history/BoundaryColumnGeometryTest.java | 102 ++++++++++++++--- .../SavedTerrainCaptureEquivalenceTest.java | 68 +++++++++++- .../TransitionDisplacementFieldTest.java | 68 ++++++++++-- .../TransitionGeometryBlenderTest.java | 2 +- 11 files changed, 533 insertions(+), 58 deletions(-) diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java index 227b3a8be..dd111927d 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuator.java @@ -43,14 +43,14 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen if (plan == null || !plan.hasTransitionAtChunk(x >> 4, z >> 4)) { return; } - GeometrySource source = new GeometrySource(blocks, biomes, getEngine().getMinHeight(), context); + GeometrySource source = new GeometrySource(x, z, blocks, biomes, getEngine().getMinHeight(), context); blendGeometry(x, z, blocks, context, source, plan); } public static SavedTerrainChunk capture(int x, int z, Hunk blocks, Hunk biomes, int minimumY, ChunkContext context, boolean boundaryOnly) throws IOException { - GeometrySource source = new GeometrySource(blocks, biomes, minimumY, context); + GeometrySource source = new GeometrySource(x, z, blocks, biomes, minimumY, context); return boundaryOnly ? SavedTerrainChunk.captureBoundary(x >> 4, z >> 4, minimumY, blocks.getHeight(), "minecraft:noise", source) : SavedTerrainChunk.capture(x >> 4, z >> 4, minimumY, blocks.getHeight(), "minecraft:noise", source); @@ -74,8 +74,10 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen if (blended == current) { continue; } - double expectedFloor = context.getRoundedHeight(localX, localZ); - int floor = blended.surfaceOffsetNear(expectedFloor); + int ground = context.getRoundedHeight(localX, localZ); + BoundaryColumnGeometry.WaterCap cap = paintedCap(current, blended, ground, + waterHead(complex, x + localX, z + localZ, current.height())); + int floor = blended.surfaceOffsetNear(ground, cap); List voxels = blended.voxels(); for (int offset = 0; offset < voxels.size(); offset++) { BoundaryColumnGeometry.Voxel voxel = voxels.get(offset); @@ -95,6 +97,9 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen && voxels.get(fluidHeight + 1).phase() == BoundaryColumnGeometry.Phase.FLUID) { fluidHeight++; } + if (blended.capsWater(cap, floor)) { + fluidHeight = cap.top(); + } if (fluidHeight == floor) { fluidHeight = -1; } @@ -105,7 +110,29 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen } } + private static BoundaryColumnGeometry.WaterCap paintedCap(BoundaryColumnGeometry painted, BoundaryColumnGeometry blended, + int ground, int waterHead) { + BoundaryColumnGeometry.WaterCap cap = painted.waterCap(ground, waterHead); + if (!cap.present()) { + return cap; + } + int top = Math.min(cap.top() + 1, painted.height() - 1); + for (int offset = cap.bottom(); offset <= top; offset++) { + int y = painted.minimumY() + offset; + if (!painted.voxelAt(y).equals(blended.voxelAt(y))) { + return BoundaryColumnGeometry.WaterCap.none(); + } + } + return cap; + } + + private static int waterHead(IrisComplex complex, int blockX, int blockZ, int height) { + return Math.clamp(Math.round(complex.getWaterHeadStream().getDouble(blockX, blockZ)), -1, height - 1); + } + private static final class GeometrySource implements SavedTerrainChunk.VoxelSource { + private final int originX; + private final int originZ; private final Hunk blocks; private final Hunk biomes; private final int minimumY; @@ -116,7 +143,10 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen private NativeBlockState lastState; private BoundaryColumnGeometry.Voxel lastVoxel; - private GeometrySource(Hunk blocks, Hunk biomes, int minimumY, ChunkContext context) { + private GeometrySource(int originX, int originZ, Hunk blocks, Hunk biomes, + int minimumY, ChunkContext context) { + this.originX = originX; + this.originZ = originZ; this.blocks = blocks; this.biomes = biomes; this.minimumY = minimumY; @@ -178,6 +208,11 @@ public final class IrisTransitionGeometryActuator extends EngineAssignedComponen return OptionalInt.of(Math.max(0, Math.min(blocks.getHeight() - 1, context.getRoundedHeight(localX, localZ)))); } + @Override + public OptionalInt waterSurface(int localX, int localZ) { + return OptionalInt.of(waterHead(context.getComplex(), originX + localX, originZ + localZ, blocks.getHeight())); + } + @Override public String biome(int localX, int worldY, int localZ) throws IOException { NativeBiome biome = biomes.getRaw(localX, worldY - minimumY, localZ); diff --git a/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java b/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java index 13272c52b..b00004f66 100644 --- a/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java +++ b/core/src/main/java/art/arcane/iris/world/history/BoundaryColumnGeometry.java @@ -139,33 +139,73 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn { return List.copyOf(values); } - public int surfaceOffsetNear(double expectedOffset) { + public WaterCap waterCap(int groundOffset, int waterSurfaceOffset) { + if (waterSurfaceOffset <= groundOffset || waterSurfaceOffset < 0 || waterSurfaceOffset >= height() + || waterSurfaceOffset + 1 < height() && voxelAtOffset(waterSurfaceOffset + 1).phase() != Phase.AIR) { + return WaterCap.none(); + } + int lowest = Math.max(0, groundOffset + 1); + int bottom = waterSurfaceOffset + 1; + for (int run = runAt(waterSurfaceOffset); run >= 0 && bottom > lowest && isCapMaterial(runVoxel(run)); run--) { + bottom = Math.max(lowest, run == 0 ? 0 : runEnds[run - 1]); + } + return bottom > waterSurfaceOffset ? WaterCap.none() : new WaterCap(bottom, waterSurfaceOffset); + } + + public boolean capsWater(WaterCap cap, int floorOffset) { + if (!cap.present() || cap.top() <= floorOffset) { + return false; + } + int below = cap.bottom() - 1; + return below == floorOffset || below >= 0 && !voxelAtOffset(below).fluidStateKey().isEmpty(); + } + + public String fluidStateKeyAtOrBelow(int surfaceOffset, int floorOffset) { + for (int offset = Math.min(surfaceOffset, height() - 1); offset > floorOffset && offset >= 0; offset--) { + String fluidStateKey = voxelAtOffset(offset).fluidStateKey(); + if (!fluidStateKey.isEmpty()) { + return fluidStateKey; + } + } + return ""; + } + + public int surfaceOffsetNear(double expectedOffset, WaterCap cap) { + Objects.requireNonNull(cap, "Water cap"); if (!Double.isFinite(expectedOffset) || height() == 0) { throw new IllegalArgumentException("A surface reference requires finite height and nonempty geometry"); } int nearest = -1; double nearestDistance = Double.POSITIVE_INFINITY; for (int run = 0; run + 1 < runEnds.length; run++) { - Voxel current = palette.get(paletteIndices[run]); - Voxel above = palette.get(paletteIndices[run + 1]); - if (current.phase() != Phase.SOLID || current.protectedContent() - || above.phase() == Phase.SOLID && !above.protectedContent()) { + int offset = runEnds[run] - 1; + if (!isTerrain(offset, runVoxel(run), cap) || isTerrain(offset + 1, runVoxel(run + 1), cap)) { continue; } - int offset = runEnds[run] - 1; double distance = Math.abs(offset - expectedOffset); if (distance < nearestDistance) { nearest = offset; nearestDistance = distance; } } + int belowCap = cap.bottom() - 1; + if (cap.present() && belowCap >= 0 && cap.bottom() < height() && isTerrain(belowCap, voxelAtOffset(belowCap), cap)) { + double distance = Math.abs(belowCap - expectedOffset); + if (distance < nearestDistance || distance == nearestDistance && belowCap < nearest) { + nearest = belowCap; + } + } if (nearest >= 0) { return nearest; } for (int run = runEnds.length - 1; run >= 0; run--) { - Voxel current = palette.get(paletteIndices[run]); - if (current.phase() == Phase.SOLID && !current.protectedContent()) { - return runEnds[run] - 1; + Voxel current = runVoxel(run); + if (current.phase() != Phase.SOLID || current.protectedContent()) { + continue; + } + int top = cap.contains(runEnds[run] - 1) ? belowCap : runEnds[run] - 1; + if (top >= (run == 0 ? 0 : runEnds[run - 1])) { + return top; } } return 0; @@ -220,6 +260,18 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn { return index >= 0 ? index : -index - 1; } + private static boolean isTerrain(int offset, Voxel voxel, WaterCap cap) { + return voxel.phase() == Phase.SOLID && !voxel.protectedContent() && !cap.contains(offset); + } + + private static boolean isCapMaterial(Voxel voxel) { + return voxel.phase() == Phase.SOLID && !voxel.protectedContent() && voxel.fluidStateKey().isEmpty(); + } + + private Voxel voxelAtOffset(int offset) { + return palette.get(paletteIndices[runAt(offset)]); + } + private void validate() { if (palette.size() > Short.MAX_VALUE + 1 || runEnds.length > MAXIMUM_HEIGHT || runEnds.length != paletteIndices.length) { @@ -333,6 +385,22 @@ public final class BoundaryColumnGeometry implements NativeBlockColumn { FLUID } + public record WaterCap(int bottom, int top) { + private static final WaterCap NONE = new WaterCap(0, -1); + + public static WaterCap none() { + return NONE; + } + + public boolean present() { + return bottom <= top; + } + + public boolean contains(int offset) { + return offset >= bottom && offset <= top; + } + } + public record Voxel(String stateKey, Phase phase, String fluidStateKey, boolean protectedContent) { public Voxel { Objects.requireNonNull(stateKey, "Block state key"); diff --git a/core/src/main/java/art/arcane/iris/world/history/SavedTerrainChunk.java b/core/src/main/java/art/arcane/iris/world/history/SavedTerrainChunk.java index a50b110ce..843a890a7 100644 --- a/core/src/main/java/art/arcane/iris/world/history/SavedTerrainChunk.java +++ b/core/src/main/java/art/arcane/iris/world/history/SavedTerrainChunk.java @@ -91,10 +91,15 @@ public final class SavedTerrainChunk { BoundaryColumnGeometry geometry = scan.runs.build(minimumY); OptionalInt groundSurface = source.groundSurface(x, z); if (groundSurface.isPresent()) { - floor = geometry.surfaceOffsetNear(groundSurface.getAsInt()); + int ground = groundSurface.getAsInt(); + BoundaryColumnGeometry.WaterCap cap = geometry.waterCap(ground, source.waterSurface(x, z).orElse(ground)); + floor = geometry.surfaceOffsetNear(ground, cap); if (floor < 0 || floor >= height) { throw new IOException("Natural terrain ground surface is outside the chunk height"); } + if (geometry.capsWater(cap, floor)) { + fluid = cap.top(); + } surface = Math.max(floor, fluid); } columns.add(new TerrainBoundarySignature( @@ -200,6 +205,10 @@ public final class SavedTerrainChunk { return OptionalInt.empty(); } + default OptionalInt waterSurface(int localX, int localZ) { + return OptionalInt.empty(); + } + BoundaryColumnGeometry.Voxel voxel(int localX, int worldY, int localZ) throws IOException; String biome(int localX, int worldY, int localZ) throws IOException; diff --git a/core/src/main/java/art/arcane/iris/world/history/TransitionDisplacementField.java b/core/src/main/java/art/arcane/iris/world/history/TransitionDisplacementField.java index d55e26f20..9e0260988 100644 --- a/core/src/main/java/art/arcane/iris/world/history/TransitionDisplacementField.java +++ b/core/src/main/java/art/arcane/iris/world/history/TransitionDisplacementField.java @@ -153,8 +153,8 @@ public final class TransitionDisplacementField { } residual = signature.oceanFloorHeight() - nativeHeight; if (wet) { - int y = signature.geometry().minimumY() + signature.fluidHeight().getAsInt(); - fluidKey = signature.geometry().voxelAt(y).fluidStateKey(); + fluidKey = signature.geometry().fluidStateKeyAtOrBelow(signature.fluidHeight().getAsInt(), + signature.oceanFloorHeight()); } } column.total += weight; @@ -164,7 +164,7 @@ public final class TransitionDisplacementField { column.wetHeight += weight * signature.fluidHeight().getAsInt(); column.minimumWetHeight = Math.min(column.minimumWetHeight, signature.fluidHeight().getAsInt()); column.maximumWetHeight = Math.max(column.maximumWetHeight, signature.fluidHeight().getAsInt()); - if (weight > column.fluidKeyWeight) { + if (weight > column.fluidKeyWeight && !fluidKey.isEmpty()) { column.fluidKeyWeight = weight; column.fluidKey = fluidKey; } diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EngineModeTerrainGenerationTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EngineModeTerrainGenerationTest.java index e7e7f93b1..fc595a293 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/EngineModeTerrainGenerationTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EngineModeTerrainGenerationTest.java @@ -210,11 +210,14 @@ public final class EngineModeTerrainGenerationTest { when(biome.key()).thenReturn("minecraft:plains"); ProceduralStream height = mock(ProceduralStream.class); when(height.getDouble(anyDouble(), anyDouble())).thenReturn(3D); + ProceduralStream waterHead = mock(ProceduralStream.class); + when(waterHead.getDouble(anyDouble(), anyDouble())).thenReturn(0D); ProceduralStream biomes = mock(ProceduralStream.class); ProceduralStream regions = mock(ProceduralStream.class); ProceduralStream materials = mock(ProceduralStream.class); when(materials.get(anyDouble(), anyDouble())).thenReturn(stone); when(complex.getRawHeightStream()).thenReturn(height); + when(complex.getWaterHeadStream()).thenReturn(waterHead); when(complex.getTrueBiomeStream()).thenReturn(biomes); when(complex.getCaveBiomeStream()).thenReturn(biomes); when(complex.getRegionStream()).thenReturn(regions); diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java index 3a0056674..b7ef9b9e8 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexTransitionTest.java @@ -23,6 +23,10 @@ import art.arcane.iris.testsupport.WaterScene; import art.arcane.iris.world.history.BoundaryColumnGeometry; import art.arcane.iris.world.history.BoundaryGeometryInfluence; import art.arcane.iris.world.history.GenerationBlend; +import art.arcane.iris.world.history.GenerationBoundary; +import art.arcane.iris.world.history.GenerationTransition; +import art.arcane.iris.world.history.SavedTerrainChunk; +import art.arcane.iris.world.history.TerrainBoundarySignatureStore; import art.arcane.iris.world.history.TransitionGenerationPlan; import art.arcane.iris.world.history.TransitionGeometryBlender; import art.arcane.volmlib.nativelib.terrain.NativeBiome; @@ -43,7 +47,9 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.junit.rules.Timeout; +import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -533,6 +539,39 @@ public class IrisComplexTransitionTest { return oldChunkWeight(x, z) == 0D ? 158.3D : 150.3D; } + @Test + public void transitionedChunkPaintsSeaLayersAtTheHeightOfTheOriginalSea() throws Exception { + NaturalHeights seabed = (x, z) -> 100D; + Hunk original; + SavedTerrainChunk receipt; + try (TransitionStage stage = new TransitionStage(TransitionStage.seamPlan((x, z) -> 1D, + TransitionStage.savedColumn(100, 100, -1)), TransitionStage.seamDisplacement((x, z) -> 1D), false, seabed, 4)) { + original = stage.paint(-1); + receipt = stage.capture(-1, original); + assertSame(stage.ice, original.getRaw(15, 127, 0)); + assertSame(stage.ice, original.getRaw(15, 124, 0)); + assertSame(stage.water, original.getRaw(15, 123, 0)); + } + GenerationBoundary boundary = GenerationBoundary.freeze("boundary", List.of(new GenerationBoundary.ChunkCoordinate(-1, 0))); + TerrainBoundarySignatureStore.Snapshot snapshot = new TerrainBoundarySignatureStore(temporaryFolder.newFolder().toPath()) + .publish(2L, boundary, receipt::column); + TransitionGenerationPlan plan = new TransitionGenerationPlan(new TransitionGenerationPlan.Specification(2L, "old", "new", + GenerationTransition.CURRENT_ALGORITHM_VERSION, 32, boundary.identity(), snapshot.identity()), boundary, snapshot); + TransitionDisplacementField displacement = new TransitionDisplacementField(plan, + new TransitionDisplacementField.Sources((x, z) -> 100D, TransitionStage.HEIGHT)); + try (TransitionStage stage = new TransitionStage(plan, displacement, false, seabed, 4)) { + Hunk transitioned = stage.blend(0, stage.paint(0)); + for (int lz = 0; lz < 16; lz++) { + for (int lx = 0; lx < 16; lx++) { + for (int y = 0; y < TransitionStage.HEIGHT; y++) { + assertEquals(lx + "," + y + "," + lz, stage.key(original.getRaw(15, y, lz)), + stage.key(transitioned.getRaw(lx, y, lz))); + } + } + } + } + } + private static void assertColumn(Hunk painted, Hunk blended, int lx, int lz) { for (int y = 0; y < TransitionStage.HEIGHT; y++) { assertSame(lx + "," + y + "," + lz + " was re-blended", painted.getRaw(lx, y, lz), blended.getRaw(lx, y, lz)); @@ -547,8 +586,10 @@ public class IrisComplexTransitionTest { private final NativeBlockState stone = block("minecraft:stone", false, false); private final NativeBlockState water = block("minecraft:water[level=0]", false, true); private final NativeBlockState air = block("minecraft:air", true, false); - private final TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); - private final TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); + private final NativeBlockState ice = block("minecraft:ice", false, false); + private final TransitionDisplacementField displacement; + private final TransitionGenerationPlan plan; + private final int seaLayers; private final IrisComplex complex; private final FakeHydrology hydrology; @@ -562,13 +603,14 @@ public class IrisComplexTransitionTest { private TransitionStage(SeamWeights weights, boolean enabled, NaturalHeights natural, BoundaryColumnGeometry oldColumn) throws Exception { - when(displacement.seamWeight(anyInt(), anyInt())).thenAnswer(invocation -> - weights.weight(invocation.getArgument(0), invocation.getArgument(1))); - when(displacement.fluidHeight(anyInt(), anyInt(), anyDouble())).thenAnswer(invocation -> invocation.getArgument(2)); - when(plan.hasTransitionAtChunk(anyInt(), anyInt())).thenReturn(true); - when(plan.geometryAt(anyInt(), anyInt())).thenAnswer(invocation -> new BoundaryGeometryInfluence( - weights.weight(invocation.getArgument(0), invocation.getArgument(1)), 0D, - List.of(new BoundaryGeometryInfluence.Contribution(oldColumn, 1D)))); + this(seamPlan(weights, oldColumn), seamDisplacement(weights), enabled, natural, 0); + } + + private TransitionStage(TransitionGenerationPlan plan, TransitionDisplacementField displacement, boolean enabled, + NaturalHeights natural, int seaLayers) throws Exception { + this.plan = plan; + this.displacement = displacement; + this.seaLayers = seaLayers; AtomicReference built = new AtomicReference<>(); hydrology = enabled ? new FakeHydrology(() -> false, (x, z) -> built.get().naturalTrueHeight(x, z), 127, 156) : null; complex = fixture.transitionComplex(enabled ? hydrology.runtime : FakeHydrology.disabled(), plan, displacement); @@ -581,7 +623,7 @@ public class IrisComplexTransitionTest { doReturn(mock(IrisImageMapRuntime.class)).when(complex).getImageMapRuntime(); doReturn(water).when(complex).resolveWaterFluid(anyInt(), anyInt()); doReturn(water).when(complex).resolveSeaFluid(anyDouble(), anyDouble()); - Map blocks = Map.of(stone.key(), stone, water.key(), water, air.key(), air); + Map blocks = Map.of(stone.key(), stone, water.key(), water, air.key(), air, ice.key(), ice); PlatformRegistries registries = mock(PlatformRegistries.class); when(registries.air()).thenReturn(air); when(registries.block("AIR")).thenReturn(air); @@ -594,6 +636,23 @@ public class IrisComplexTransitionTest { IrisPlatforms.bind(platform); } + private static TransitionGenerationPlan seamPlan(SeamWeights weights, BoundaryColumnGeometry oldColumn) { + TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); + when(plan.hasTransitionAtChunk(anyInt(), anyInt())).thenReturn(true); + when(plan.geometryAt(anyInt(), anyInt())).thenAnswer(invocation -> new BoundaryGeometryInfluence( + weights.weight(invocation.getArgument(0), invocation.getArgument(1)), 0D, + List.of(new BoundaryGeometryInfluence.Contribution(oldColumn, 1D)))); + return plan; + } + + private static TransitionDisplacementField seamDisplacement(SeamWeights weights) { + TransitionDisplacementField displacement = mock(TransitionDisplacementField.class); + when(displacement.seamWeight(anyInt(), anyInt())).thenAnswer(invocation -> + weights.weight(invocation.getArgument(0), invocation.getArgument(1))); + when(displacement.fluidHeight(anyInt(), anyInt(), anyDouble())).thenAnswer(invocation -> invocation.getArgument(2)); + return displacement; + } + private ChunkWaterRaster raster(int chunkX) { return hydrology.runtime.raster(chunkX, 0); } @@ -622,7 +681,8 @@ public class IrisComplexTransitionTest { when(engine.getSeedManager()).thenReturn(mock(SeedManager.class)); when(biome.generateLayers(eq(dimension), anyDouble(), anyDouble(), any(RNG.class), anyInt(), anyInt(), eq(data), same(complex))) .thenAnswer(invocation -> new KList<>(List.of(stone))); - when(biome.generateSeaLayers(anyDouble(), anyDouble(), any(RNG.class), anyInt(), eq(data))).thenAnswer(invocation -> new KList<>()); + when(biome.generateSeaLayers(anyDouble(), anyDouble(), any(RNG.class), anyInt(), eq(data))) + .thenAnswer(invocation -> new KList<>(Collections.nCopies(seaLayers, ice))); int[] grounds = grounds(chunkX); ChunkContext context = mock(ChunkContext.class); when(context.getRoundedHeight(anyInt(), anyInt())).thenAnswer(invocation -> @@ -712,6 +772,19 @@ public class IrisComplexTransitionTest { return blocks; } + private SavedTerrainChunk capture(int chunkX, Hunk blocks) throws IOException { + int[] grounds = grounds(chunkX); + ChunkContext context = mock(ChunkContext.class); + when(context.getComplex()).thenReturn(complex); + when(context.getRoundedHeight(anyInt(), anyInt())).thenAnswer(invocation -> + grounds[invocation.getArgument(1) << 4 | invocation.getArgument(0)]); + NativeBiome biome = mock(NativeBiome.class); + when(biome.key()).thenReturn("minecraft:frozen_ocean"); + Hunk biomes = Hunk.newArrayHunk(16, HEIGHT, 16); + biomes.fill(biome); + return IrisTransitionGeometryActuator.capture(chunkX << 4, 0, blocks, biomes, 0, context, true); + } + private BoundaryColumnGeometry geometry(Hunk blocks, int lx, int lz) { ArrayList voxels = new ArrayList<>(HEIGHT); for (int y = 0; y < HEIGHT; y++) { diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java index 79aa3ad96..a6b7ed7c9 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTransitionGeometryActuatorTest.java @@ -7,6 +7,7 @@ import art.arcane.iris.world.history.BoundaryColumnGeometry; import art.arcane.iris.world.history.BoundaryGeometryInfluence; import art.arcane.iris.world.history.NativeTerrainReceipt; import art.arcane.iris.world.history.SavedTerrainChunk; +import art.arcane.iris.world.history.TerrainBoundarySignature; import art.arcane.iris.world.history.TransitionGenerationPlan; import art.arcane.iris.spi.IrisPlatform; import art.arcane.iris.spi.IrisPlatforms; @@ -16,6 +17,7 @@ import art.arcane.iris.spi.PlatformRegistries; import art.arcane.iris.testsupport.PlatformLeakGuard; import art.arcane.iris.generation.context.ChunkContext; import art.arcane.volmlib.util.hunk.Hunk; +import art.arcane.volmlib.util.stream.ProceduralStream; import org.junit.After; import org.junit.Before; import org.junit.ClassRule; @@ -23,6 +25,7 @@ import org.junit.Test; import java.util.ArrayList; import java.util.List; +import java.util.OptionalInt; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertEquals; @@ -79,6 +82,9 @@ public class IrisTransitionGeometryActuatorTest { when(biome.key()).thenReturn("minecraft:plains"); biomes.fill(biome); ChunkContext context = mock(ChunkContext.class); + IrisComplex complex = mock(IrisComplex.class); + when(complex.getWaterHeadStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> 3D)); + when(context.getComplex()).thenReturn(complex); when(context.getRoundedHeight(anyInt(), anyInt())).thenReturn(3); SavedTerrainChunk captured = IrisTransitionGeometryActuator.capture(0, 0, blocks, biomes, 0, context, true); @@ -146,6 +152,98 @@ public class IrisTransitionGeometryActuatorTest { Terrain3DColumn.fromOccupancy(3, 16, y -> y == 0 || y == 3), 6); } + @Test + public void boundaryReceiptRecordsSeaLayerCapsAtTheWaterHeadAboveTheSeabed() throws Exception { + NativeBlockState water = fluid("minecraft:water[level=0]"); + NativeBlockState ice = state("minecraft:ice", false); + Hunk blocks = filled(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + int head = z == 15 ? 6 : 12; + int capBottom = z == 0 ? 9 : z == 15 ? 4 : head + 1; + for (int y = 4; y < 16; y++) { + blocks.setRaw(x, y, z, y > head ? air : y >= capBottom ? ice : water); + } + } + } + ChunkContext context = mock(ChunkContext.class); + IrisComplex complex = mock(IrisComplex.class); + when(complex.getWaterHeadStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> z == 15D ? 6D : 12D)); + when(context.getComplex()).thenReturn(complex); + when(context.getRoundedHeight(anyInt(), anyInt())).thenReturn(3); + + SavedTerrainChunk captured = IrisTransitionGeometryActuator.capture(0, 0, blocks, biomes(), 0, context, true); + SavedTerrainChunk restored = NativeTerrainReceipt.decode(NativeTerrainReceipt.encode(captured, 7, "epoch"), + "minecraft:noise").terrain(); + + assertWaterColumn(restored.column(0, 0), 3, 12); + assertWaterColumn(restored.column(0, 15), 3, 6); + assertWaterColumn(restored.column(0, 7), 3, 12); + } + + @Test + public void transitionKeepsSeaLayerCapsAtTheWaterHeadForDecoration() { + NativeBlockState water = fluid("minecraft:water[level=0]"); + NativeBlockState ice = state("minecraft:ice", false); + when(registries.blockOrNull(ice.key())).thenReturn(ice); + for (int capBottom : new int[]{9, 4}) { + ArrayList historical = new ArrayList<>(16); + for (int y = 0; y < 16; y++) { + historical.add(y <= 3 ? voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "") + : y > 12 ? voxel("minecraft:air", BoundaryColumnGeometry.Phase.AIR, "") + : y >= capBottom ? voxel(ice.key(), BoundaryColumnGeometry.Phase.SOLID, "") + : voxel(water.key(), BoundaryColumnGeometry.Phase.FLUID, water.key())); + } + ChunkContext context = context(BoundaryColumnGeometry.fromVoxels(0, historical), 12D); + Hunk blocks = filled(); + for (int x = 0; x < 16; x++) { + for (int z = 0; z < 16; z++) { + for (int y = 1; y < 16; y++) { + blocks.setRaw(x, y, z, y < 3 || y > 12 ? air : y == 3 ? stone : y >= capBottom ? ice : water); + } + } + } + + new IrisTransitionGeometryActuator(mock(Engine.class)).generate(16, 0, blocks, + Hunk.newArrayHunk(16, 16, 16), false, context); + + assertSame(stone, blocks.getRaw(0, 2, 0)); + assertSame(ice, blocks.getRaw(0, 12, 0)); + assertSame(capBottom == 4 ? ice : water, blocks.getRaw(0, 4, 0)); + assertSame(air, blocks.getRaw(0, 13, 0)); + int iceBottom = capBottom; + verify(context).setTerrainHeight(0, 0, 3); + verify(context).setTransitionTerrain(0, 0, + Terrain3DColumn.fromOccupancy(3, 16, y -> y <= 3 || y >= iceBottom && y <= 12), 12); + } + } + + private static void assertWaterColumn(TerrainBoundarySignature signature, int seabed, int waterSurface) { + assertEquals(seabed, signature.oceanFloorHeight()); + assertEquals(OptionalInt.of(waterSurface), signature.fluidHeight()); + assertEquals(waterSurface, signature.surfaceHeight()); + } + + private static BoundaryColumnGeometry.Voxel voxel(String key, BoundaryColumnGeometry.Phase phase, String fluid) { + return new BoundaryColumnGeometry.Voxel(key, phase, fluid, false); + } + + private NativeBlockState fluid(String key) { + NativeBlockState state = state(key, false); + when(state.isSolid()).thenReturn(false); + when(state.isFluid()).thenReturn(true); + when(registries.blockOrNull(key)).thenReturn(state); + return state; + } + + private static Hunk biomes() { + Hunk biomes = Hunk.newArrayHunk(16, 16, 16); + NativeBiome biome = mock(NativeBiome.class); + when(biome.key()).thenReturn("minecraft:frozen_ocean"); + biomes.fill(biome); + return biomes; + } + private ChunkContext context(String material) { return context(material, 3); } @@ -159,8 +257,12 @@ public class IrisTransitionGeometryActuatorTest { solid ? BoundaryColumnGeometry.Phase.SOLID : fluid ? BoundaryColumnGeometry.Phase.FLUID : BoundaryColumnGeometry.Phase.AIR, fluid ? "minecraft:water[level=0]" : "", false)); } + return context(BoundaryColumnGeometry.fromVoxels(0, voxels), 3D); + } + + private ChunkContext context(BoundaryColumnGeometry historical, double waterHead) { BoundaryGeometryInfluence influence = new BoundaryGeometryInfluence(0D, 0D, - List.of(new BoundaryGeometryInfluence.Contribution(BoundaryColumnGeometry.fromVoxels(0, voxels), 1D))); + List.of(new BoundaryGeometryInfluence.Contribution(historical, 1D))); TransitionGenerationPlan plan = mock(TransitionGenerationPlan.class); when(plan.geometryAt(anyInt(), anyInt())).thenReturn(influence); when(plan.hasTransitionAtChunk(anyInt(), anyInt())).thenReturn(true); @@ -169,6 +271,7 @@ public class IrisTransitionGeometryActuatorTest { when(plan.terrainSampleAt(anyInt(), anyInt())).thenReturn(sample); IrisComplex complex = mock(IrisComplex.class); when(complex.getTransitionGenerationPlan()).thenReturn(plan); + when(complex.getWaterHeadStream()).thenReturn(ProceduralStream.ofDouble((x, z) -> waterHead)); ChunkContext context = mock(ChunkContext.class); when(context.getComplex()).thenReturn(complex); when(context.getRoundedHeight(anyInt(), anyInt())).thenReturn(3); diff --git a/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java b/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java index 545086801..d02625bb8 100644 --- a/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/BoundaryColumnGeometryTest.java @@ -23,6 +23,8 @@ public class BoundaryColumnGeometryTest { "minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false); private static final BoundaryColumnGeometry.Voxel AIR = new BoundaryColumnGeometry.Voxel( "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); + private static final BoundaryColumnGeometry.Voxel WATER = new BoundaryColumnGeometry.Voxel( + "minecraft:water[level=0]", BoundaryColumnGeometry.Phase.FLUID, "minecraft:water[level=0]", false); @Test public void solidAboveMatchesDenseColumnsWithProtectionAndExtremeWorldCoordinates() { @@ -92,8 +94,65 @@ public class BoundaryColumnGeometryTest { } BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-64, voxels); - assertEquals(50, geometry.surfaceOffsetNear(50D)); - assertEquals(50, geometry.surfaceOffsetNear(70D)); + assertEquals(50, geometry.surfaceOffsetNear(50D, BoundaryColumnGeometry.WaterCap.none())); + assertEquals(50, geometry.surfaceOffsetNear(70D, BoundaryColumnGeometry.WaterCap.none())); + } + + @Test + public void waterCapSpansSolidLayersFromTheWaterSurfaceDownToWaterOrTheSeabed() { + BoundaryColumnGeometry.Voxel ice = new BoundaryColumnGeometry.Voxel( + "minecraft:ice", BoundaryColumnGeometry.Phase.SOLID, "", false); + BoundaryColumnGeometry.Voxel packedIce = new BoundaryColumnGeometry.Voxel( + "minecraft:packed_ice", BoundaryColumnGeometry.Phase.SOLID, "", false); + BoundaryColumnGeometry iced = column(STONE, STONE, STONE, STONE, WATER, WATER, WATER, WATER, WATER, + packedIce, packedIce, ice, ice, AIR, AIR, AIR); + BoundaryColumnGeometry.WaterCap cap = iced.waterCap(3, 12); + + assertEquals(new BoundaryColumnGeometry.WaterCap(9, 12), cap); + assertEquals(3, iced.surfaceOffsetNear(3D, cap)); + assertTrue(iced.capsWater(cap, 3)); + assertEquals(WATER.fluidStateKey(), iced.fluidStateKeyAtOrBelow(12, 3)); + + BoundaryColumnGeometry frozen = column(STONE, STONE, STONE, ice, ice, ice, ice, AIR); + BoundaryColumnGeometry.WaterCap frozenCap = frozen.waterCap(2, 6); + + assertEquals(new BoundaryColumnGeometry.WaterCap(3, 6), frozenCap); + assertEquals(6, frozen.surfaceOffsetNear(2D, BoundaryColumnGeometry.WaterCap.none())); + assertEquals(2, frozen.surfaceOffsetNear(2D, frozenCap)); + assertTrue(frozen.capsWater(frozenCap, 2)); + assertEquals("", frozen.fluidStateKeyAtOrBelow(6, 2)); + + BoundaryColumnGeometry frozenBed = column(STONE, ice, ice, ice, ice, AIR); + BoundaryColumnGeometry.WaterCap bedCap = frozenBed.waterCap(1, 4); + assertEquals(new BoundaryColumnGeometry.WaterCap(2, 4), bedCap); + assertEquals(1, frozenBed.surfaceOffsetNear(1D, bedCap)); + assertEquals(1, frozenBed.surfaceOffsetNear(4D, bedCap)); + } + + @Test + public void waterCapIgnoresOpenWaterLandAndSolidsThatContinueAboveTheSurface() { + BoundaryColumnGeometry.Voxel ice = new BoundaryColumnGeometry.Voxel( + "minecraft:ice", BoundaryColumnGeometry.Phase.SOLID, "", false); + BoundaryColumnGeometry.Voxel waterlogged = new BoundaryColumnGeometry.Voxel( + "minecraft:oak_slab[type=bottom,waterlogged=true]", BoundaryColumnGeometry.Phase.SOLID, + WATER.fluidStateKey(), false); + BoundaryColumnGeometry.Voxel protectedSolid = new BoundaryColumnGeometry.Voxel( + "minecraft:oak_log[axis=y]", BoundaryColumnGeometry.Phase.SOLID, "", true); + + assertFalse(column(STONE, STONE, WATER, WATER, AIR).waterCap(1, 3).present()); + assertFalse(column(STONE, STONE, STONE, STONE, AIR).waterCap(3, 3).present()); + assertFalse(column(STONE, STONE, WATER, ice, ice, AIR).waterCap(1, 3).present()); + assertFalse(column(STONE, STONE, WATER, waterlogged, AIR).waterCap(1, 3).present()); + assertFalse(column(STONE, STONE, WATER, protectedSolid, AIR).waterCap(1, 3).present()); + assertFalse(column(STONE, STONE, WATER, ice).waterCap(1, 4).present()); + assertFalse(column(STONE, STONE, WATER, ice, AIR).waterCap(1, -1).present()); + assertTrue(column(STONE, STONE, WATER, ice).waterCap(1, 3).present()); + + BoundaryColumnGeometry overAir = column(STONE, STONE, WATER, AIR, ice, AIR); + BoundaryColumnGeometry.WaterCap cap = overAir.waterCap(1, 4); + assertEquals(new BoundaryColumnGeometry.WaterCap(4, 4), cap); + assertFalse(overAir.capsWater(cap, 1)); + assertFalse(overAir.capsWater(BoundaryColumnGeometry.WaterCap.none(), 1)); } @Test @@ -114,24 +173,29 @@ public class BoundaryColumnGeometryTest { } } BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-64, voxels); - for (double expected : new double[]{-10D, 0D, 1.5D, 3D, 5.5D, 8D, 12D, 100D}) { - assertEquals(denseSurface(voxels, expected), geometry.surfaceOffsetNear(expected)); + for (BoundaryColumnGeometry.WaterCap cap : List.of(BoundaryColumnGeometry.WaterCap.none(), + new BoundaryColumnGeometry.WaterCap(0, 0), new BoundaryColumnGeometry.WaterCap(2, 4), + new BoundaryColumnGeometry.WaterCap(5, 9), new BoundaryColumnGeometry.WaterCap(11, 17))) { + for (double expected : new double[]{-10D, 0D, 1.5D, 3D, 5.5D, 8D, 12D, 100D}) { + assertEquals(denseSurface(voxels, expected, cap), geometry.surfaceOffsetNear(expected, cap)); + } } } - assertThrows(IllegalArgumentException.class, () -> BoundaryColumnGeometry.empty().surfaceOffsetNear(0D)); + assertThrows(IllegalArgumentException.class, () -> BoundaryColumnGeometry.empty() + .surfaceOffsetNear(0D, BoundaryColumnGeometry.WaterCap.none())); BoundaryColumnGeometry solid = BoundaryColumnGeometry.fromVoxels(-64, List.of(STONE)); - assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.NaN)); - assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.POSITIVE_INFINITY)); + assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.NaN, + BoundaryColumnGeometry.WaterCap.none())); + assertThrows(IllegalArgumentException.class, () -> solid.surfaceOffsetNear(Double.POSITIVE_INFINITY, + BoundaryColumnGeometry.WaterCap.none())); } - private static int denseSurface(List voxels, double expected) { + private static int denseSurface(List voxels, double expected, + BoundaryColumnGeometry.WaterCap cap) { int selected = -1; double distance = Double.POSITIVE_INFINITY; for (int index = 0; index + 1 < voxels.size(); index++) { - BoundaryColumnGeometry.Voxel current = voxels.get(index); - BoundaryColumnGeometry.Voxel above = voxels.get(index + 1); - if (current.phase() == BoundaryColumnGeometry.Phase.SOLID && !current.protectedContent() - && (above.phase() != BoundaryColumnGeometry.Phase.SOLID || above.protectedContent()) + if (denseTerrain(voxels, index, cap) && !denseTerrain(voxels, index + 1, cap) && Math.abs(index - expected) < distance) { selected = index; distance = Math.abs(index - expected); @@ -141,14 +205,24 @@ public class BoundaryColumnGeometryTest { return selected; } for (int index = voxels.size() - 1; index >= 0; index--) { - BoundaryColumnGeometry.Voxel voxel = voxels.get(index); - if (voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent()) { + if (denseTerrain(voxels, index, cap)) { return index; } } return 0; } + private static boolean denseTerrain(List voxels, int index, + BoundaryColumnGeometry.WaterCap cap) { + BoundaryColumnGeometry.Voxel voxel = voxels.get(index); + return voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent() + && (index < cap.bottom() || index > cap.top()); + } + + private static BoundaryColumnGeometry column(BoundaryColumnGeometry.Voxel... voxels) { + return BoundaryColumnGeometry.fromVoxels(-64, List.of(voxels)); + } + @Test public void enclosedOpenQueriesIncludeUpperCavesAndExcludeOpenSky() { BoundaryColumnGeometry geometry = BoundaryColumnGeometry.fromVoxels(-4, diff --git a/core/src/test/java/art/arcane/iris/world/history/SavedTerrainCaptureEquivalenceTest.java b/core/src/test/java/art/arcane/iris/world/history/SavedTerrainCaptureEquivalenceTest.java index be2d60d4d..279234414 100644 --- a/core/src/test/java/art/arcane/iris/world/history/SavedTerrainCaptureEquivalenceTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/SavedTerrainCaptureEquivalenceTest.java @@ -29,6 +29,7 @@ public class SavedTerrainCaptureEquivalenceTest { private static final List STATES = List.of( voxel("minecraft:stone", BoundaryColumnGeometry.Phase.SOLID, "", false), voxel("minecraft:deepslate", BoundaryColumnGeometry.Phase.SOLID, "", false), + voxel("minecraft:ice", BoundaryColumnGeometry.Phase.SOLID, "", false), voxel("minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false), voxel("minecraft:cave_air", BoundaryColumnGeometry.Phase.AIR, "", false), voxel("minecraft:water[level=0]", BoundaryColumnGeometry.Phase.FLUID, "minecraft:water[level=0]", false), @@ -153,7 +154,14 @@ public class SavedTerrainCaptureEquivalenceTest { BoundaryColumnGeometry geometry = referenceGeometry(MINIMUM_Y, voxels); OptionalInt groundSurface = source.groundSurface(x, z); if (groundSurface.isPresent()) { - floor = geometry.surfaceOffsetNear(groundSurface.getAsInt()); + int ground = groundSurface.getAsInt(); + int waterSurface = source.waterSurface(x, z).orElse(ground); + int capBottom = referenceCapBottom(voxels, ground, waterSurface); + floor = referenceFloor(voxels, ground, capBottom, waterSurface); + if (capBottom <= waterSurface && waterSurface > floor && (capBottom - 1 == floor + || capBottom > 0 && !voxels.get(capBottom - 1).fluidStateKey().isEmpty())) { + fluid = waterSurface; + } surface = Math.max(floor, fluid); } columns.add(new TerrainBoundarySignature( @@ -168,6 +176,50 @@ public class SavedTerrainCaptureEquivalenceTest { return new SavedTerrainChunk(chunkX, chunkZ, "minecraft:noise", columns); } + private static int referenceCapBottom(List voxels, int ground, int waterSurface) { + if (waterSurface <= ground || waterSurface < 0 || waterSurface >= voxels.size() + || waterSurface + 1 < voxels.size() && voxels.get(waterSurface + 1).phase() != BoundaryColumnGeometry.Phase.AIR) { + return waterSurface + 1; + } + int bottom = waterSurface + 1; + while (bottom - 1 >= Math.max(0, ground + 1) && capMaterial(voxels.get(bottom - 1))) { + bottom--; + } + return bottom; + } + + private static boolean capMaterial(BoundaryColumnGeometry.Voxel voxel) { + return voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent() + && voxel.fluidStateKey().isEmpty(); + } + + private static int referenceFloor(List voxels, int ground, int capBottom, int capTop) { + int selected = -1; + int distance = Integer.MAX_VALUE; + for (int offset = 0; offset + 1 < voxels.size(); offset++) { + if (referenceTerrain(voxels, offset, capBottom, capTop) && !referenceTerrain(voxels, offset + 1, capBottom, capTop) + && Math.abs(offset - ground) < distance) { + selected = offset; + distance = Math.abs(offset - ground); + } + } + if (selected >= 0) { + return selected; + } + for (int offset = voxels.size() - 1; offset >= 0; offset--) { + if (referenceTerrain(voxels, offset, capBottom, capTop)) { + return offset; + } + } + return 0; + } + + private static boolean referenceTerrain(List voxels, int offset, int capBottom, int capTop) { + BoundaryColumnGeometry.Voxel voxel = voxels.get(offset); + return voxel.phase() == BoundaryColumnGeometry.Phase.SOLID && !voxel.protectedContent() + && (offset < capBottom || offset > capTop); + } + private static BoundaryColumnGeometry referenceGeometry(int minimumY, List voxels) { TreeSet used = new TreeSet<>(VOXEL_ORDER); BoundaryColumnGeometry.Voxel previous = null; @@ -216,6 +268,11 @@ public class SavedTerrainCaptureEquivalenceTest { return source.groundSurface(localX, localZ); } + @Override + public OptionalInt waterSurface(int localX, int localZ) { + return source.waterSurface(localX, localZ); + } + @Override public BoundaryColumnGeometry.Voxel voxel(int localX, int worldY, int localZ) { return source.voxel(localX, worldY, localZ); @@ -246,6 +303,7 @@ public class SavedTerrainCaptureEquivalenceTest { private final BoundaryColumnGeometry.Voxel[][] voxels = new BoundaryColumnGeometry.Voxel[256][HEIGHT]; private final String[][] biomes = new String[256][HEIGHT]; private final int[] ground = new int[256]; + private final int[] water = new int[256]; private RandomSource(Random random, int sample) { for (int column = 0; column < 256; column++) { @@ -270,6 +328,8 @@ public class SavedTerrainCaptureEquivalenceTest { } } ground[column] = sample % 4 == 0 ? -1 : random.nextInt(HEIGHT); + water[column] = ground[column] < 0 || random.nextInt(3) == 0 ? -1 + : Math.min(HEIGHT - 1, ground[column] + random.nextInt(48)); } } @@ -279,6 +339,12 @@ public class SavedTerrainCaptureEquivalenceTest { return value < 0 ? OptionalInt.empty() : OptionalInt.of(value); } + @Override + public OptionalInt waterSurface(int localX, int localZ) { + int value = water[localX * 16 + localZ]; + return value < 0 ? OptionalInt.empty() : OptionalInt.of(value); + } + @Override public BoundaryColumnGeometry.Voxel voxel(int localX, int worldY, int localZ) { return voxels[localX * 16 + localZ][worldY - MINIMUM_Y]; diff --git a/core/src/test/java/art/arcane/iris/world/history/TransitionDisplacementFieldTest.java b/core/src/test/java/art/arcane/iris/world/history/TransitionDisplacementFieldTest.java index cf20f2d1d..06ec7433d 100644 --- a/core/src/test/java/art/arcane/iris/world/history/TransitionDisplacementFieldTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/TransitionDisplacementFieldTest.java @@ -4,6 +4,7 @@ import org.junit.Test; import java.util.ArrayList; import java.util.List; +import java.util.OptionalInt; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -22,6 +23,8 @@ public class TransitionDisplacementFieldTest extends GenerationHistorySupport { "minecraft:air", BoundaryColumnGeometry.Phase.AIR, "", false); private static final BoundaryColumnGeometry.Voxel WATER = new BoundaryColumnGeometry.Voxel( "minecraft:water[level=0]", BoundaryColumnGeometry.Phase.FLUID, "minecraft:water[level=0]", false); + private static final BoundaryColumnGeometry.Voxel ICE = new BoundaryColumnGeometry.Voxel( + "minecraft:ice", BoundaryColumnGeometry.Phase.SOLID, "", false); @Test public void matchesFlatSeamAndReturnsToNativeTerrainInBothDirections() throws Exception { @@ -108,22 +111,63 @@ public class TransitionDisplacementFieldTest extends GenerationHistorySupport { } } + @Test + public void seaLayerCapsKeepTheHistoricalSeaSurfaceAboveTheSeabed() throws Exception { + for (int capBottom : new int[]{37, 25}) { + TransitionDisplacementField field = field(new SavedTerrainChunk.VoxelSource() { + @Override + public OptionalInt groundSurface(int localX, int localZ) { + return OptionalInt.of(24); + } + + @Override + public OptionalInt waterSurface(int localX, int localZ) { + return OptionalInt.of(40); + } + + @Override + public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { + return y <= 24 ? STONE : y < capBottom ? WATER : y <= 40 ? ICE : AIR; + } + + @Override + public String biome(int x, int y, int z) { + return "minecraft:frozen_ocean"; + } + }, (x, z) -> 24D); + for (int x = 0; x < 32; x++) { + assertEquals(24D, field.height(x, 8, 24D), 0D); + assertEquals(40D, field.fluidHeight(x, 8, 40D), 0.0000001D); + } + assertEquals(40D, field.fluidHeight(0, 8, 48D), 0.0000001D); + if (capBottom == 37) { + assertEquals("minecraft:water[level=0]", field.fluidStateKey(0, 8)); + } else { + assertNull(field.fluidStateKey(0, 8)); + } + } + } + private TransitionDisplacementField field(int ground, int fluid, TransitionDisplacementField.HeightSource source) throws Exception { + return field(new SavedTerrainChunk.VoxelSource() { + @Override + public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { + return y <= ground ? STONE : y <= fluid ? WATER : AIR; + } + + @Override + public String biome(int x, int y, int z) { + return "minecraft:plains"; + } + }, source); + } + + private TransitionDisplacementField field(SavedTerrainChunk.VoxelSource voxels, TransitionDisplacementField.HeightSource source) + throws Exception { GenerationBoundary boundary = GenerationBoundary.freeze("boundary", List.of( new GenerationBoundary.ChunkCoordinate(-1, 0))); - SavedTerrainChunk terrain = SavedTerrainChunk.captureBoundary(-1, 0, 0, 128, "minecraft:noise", - new SavedTerrainChunk.VoxelSource() { - @Override - public BoundaryColumnGeometry.Voxel voxel(int x, int y, int z) { - return y <= ground ? STONE : y <= fluid ? WATER : AIR; - } - - @Override - public String biome(int x, int y, int z) { - return "minecraft:plains"; - } - }); + SavedTerrainChunk terrain = SavedTerrainChunk.captureBoundary(-1, 0, 0, 128, "minecraft:noise", voxels); TerrainBoundarySignatureStore store = new TerrainBoundarySignatureStore(temporaryFolder.newFolder().toPath()); TerrainBoundarySignatureStore.Snapshot snapshot = store.publish(2L, boundary, terrain::column); TransitionGenerationPlan plan = new TransitionGenerationPlan(new TransitionGenerationPlan.Specification( diff --git a/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryBlenderTest.java b/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryBlenderTest.java index add92231c..af91fffdf 100644 --- a/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryBlenderTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/TransitionGeometryBlenderTest.java @@ -67,7 +67,7 @@ public class TransitionGeometryBlenderTest { BoundaryColumnGeometry blended = TransitionGeometryBlender.blendColumn(influence, 31, 0, current); - assertEquals(200, blended.surfaceOffsetNear(200D)); + assertEquals(200, blended.surfaceOffsetNear(200D, BoundaryColumnGeometry.WaterCap.none())); assertEquals(STONE, blended.voxelAt(416)); assertEquals(STONE, blended.voxelAt(447)); }