diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/WindowPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/WindowPlanner.java index 5a120d456..82df753de 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/plan/WindowPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/plan/WindowPlanner.java @@ -11,6 +11,7 @@ import art.arcane.iris.generation.hydrology.grid.FlowAccumulator; import art.arcane.iris.generation.hydrology.grid.HeightLattice; import art.arcane.iris.generation.hydrology.grid.HydrologySampleBuffer; import art.arcane.iris.generation.hydrology.grid.PriorityFlood; +import art.arcane.iris.generation.hydrology.raster.PondChannelSettlement; import art.arcane.iris.generation.hydrology.underground.SeaInlet; import art.arcane.iris.generation.hydrology.underground.SeaInletPlanner; import art.arcane.iris.generation.hydrology.water.CupSet; @@ -108,16 +109,16 @@ public final class WindowPlanner { } List water = new ArrayList<>(ponds.ponds(raw, drainage.filled(), drainage.basins(), lakes, fine, cups)); water.addAll(lakes); - ShapedNetwork network = shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(), drainage.channels(), water, - fine, settings.seed()); - int passes = water.size() + 1; - for (int pass = 1; lowered(water, network.ponds()); pass++) { + ShapedNetwork network = PondChannelSettlement.settle(shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(), + drainage.channels(), water, fine, settings.seed()), settings.seaLevel()); + long passes = settlementPasses(water); + for (long pass = 1; lowered(water, network.ponds()); pass++) { if (pass >= passes) { throw new IllegalStateException("Window " + key + " ponds did not settle within " + passes + " shaping passes"); } water = replanned(settled(raw, cups, water, network.ponds())); - network = shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(), drainage.channels(), water, fine, - settings.seed()); + network = PondChannelSettlement.settle(shaper.shape(drainage.surface(), drainage.receivers(), drainage.accumulation(), + drainage.channels(), water, fine, settings.seed()), settings.seaLevel()); } List reaches = network.reaches(); List windowPonds = windowPonds(network.ponds()); @@ -263,6 +264,16 @@ public final class WindowPlanner { return settled; } + private long settlementPasses(List water) { + long passes = 1L; + for (Pond pond : water) { + if (pond.kind() != PondKind.LAKE) { + passes += Math.max(0L, (long) pond.level() - settings.seaLevel()) + 1L; + } + } + return passes; + } + private static boolean lowered(List planned, List shaped) { for (int index = 0; index < shaped.size(); index++) { if (shaped.get(index).level() < planned.get(index).level()) { diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/BedProfile.java b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/BedProfile.java new file mode 100644 index 000000000..1cb9e16d6 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/BedProfile.java @@ -0,0 +1,55 @@ +package art.arcane.iris.generation.hydrology.raster; + +import art.arcane.iris.generation.hydrology.grid.CellHash; + +final class BedProfile { + private static final long SALT_BROAD = 0x42454442524f4144L; + private static final long SALT_DETAIL = 0x4245444445544149L; + private static final long SALT_TEXTURE = 0x4245445445585455L; + + private BedProfile() { + } + + static double variation(long seed, int x, int z) { + return 0.65D * noise(seed, SALT_BROAD, x, z, 32) + 0.35D * noise(seed, SALT_DETAIL, x, z, 8); + } + + static double detail(long seed, int x, int z) { + return 0.35D * noise(seed, SALT_TEXTURE, x, z, 2); + } + + static double position(double u, double variation) { + double bounded = Math.clamp(u, 0D, 1D); + return bounded + 0.8D * variation * bounded * (1D - bounded); + } + + static int channelDepth(double depth, double ratio, double variation, double detail) { + double across = position(ratio, -variation); + double bowl = 1D - across * across; + double cut = bowl * (depth + variation * Math.min(2D, depth * 0.3D) + detail); + return (int) Math.clamp(Math.round(cut), 1L, Math.max(1L, Math.round(depth))); + } + + static int pondDepth(int maximum, double u, double variation, double detail) { + double across = position(u, variation); + double bowl = across * across * (3D - 2D * across); + double cut = 1D + bowl * (maximum - 1D + variation * Math.min(2D, (maximum - 1D) * 0.3D) + detail); + return (int) Math.clamp(Math.round(cut), 1L, maximum); + } + + private static double noise(long seed, long salt, int x, int z, int scale) { + int ix = Math.floorDiv(x, scale); + int iz = Math.floorDiv(z, scale); + double u = fade(Math.floorMod(x, scale) / (double) scale); + double v = fade(Math.floorMod(z, scale) / (double) scale); + double a = CellHash.unit(CellHash.mix(seed, salt, ix, iz)); + double b = CellHash.unit(CellHash.mix(seed, salt, ix + 1, iz)); + double c = CellHash.unit(CellHash.mix(seed, salt, ix, iz + 1)); + double d = CellHash.unit(CellHash.mix(seed, salt, ix + 1, iz + 1)); + return 2D * ((a + (b - a) * u) * (1D - v) + (c + (d - c) * u) * v) - 1D; + } + + private static double fade(double value) { + return value * value * value * (value * (value * 6D - 15D) + 10D); + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ChunkFeatures.java b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ChunkFeatures.java index 9d6188072..3a0eb132b 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ChunkFeatures.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ChunkFeatures.java @@ -50,7 +50,10 @@ final class ChunkFeatures { List inlets = new ArrayList<>(); for (IndexedWindow window : windows) { for (SeaInlet inlet : window.plan().inlets()) { - if (SeaInletRasterizer.touches(inlet, chunkX, chunkZ)) { + int[] bounds = SeaInletRasterizer.bounds(inlet); + int padding = RiffleProfile.RADIUS + 1; + if (bounds[0] <= chunkX * CHUNK + CHUNK - 1 + padding && bounds[2] >= chunkX * CHUNK - padding + && bounds[1] <= chunkZ * CHUNK + CHUNK - 1 + padding && bounds[3] >= chunkZ * CHUNK - padding) { inlets.add(inlet); } } @@ -83,6 +86,25 @@ final class ChunkFeatures { return segments; } + boolean hasOnlyLevel(int head, int seaLevel) { + for (Pond pond : ponds) { + if (pond.level() - Math.floorMod(pond.level() - seaLevel, 2) != head) { + return false; + } + } + for (Pond lake : lakes) { + if (lake.level() - Math.floorMod(lake.level() - seaLevel, 2) != head) { + return false; + } + } + for (SeaInlet inlet : inlets) { + if (inlet.waterLevel() - Math.floorMod(inlet.waterLevel() - seaLevel, 2) != head) { + return false; + } + } + return segments.hasOnlyLevel(head, seaLevel); + } + int ponds() { return ponds.size(); } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ChunkRasterizer.java b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ChunkRasterizer.java index f2a4a2b35..369be027d 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ChunkRasterizer.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ChunkRasterizer.java @@ -28,7 +28,9 @@ public final class ChunkRasterizer { } public static List lakeRegions(int chunkX, int chunkZ) { - return RegionKey.lakeOwners(chunkX * CHUNK, chunkZ * CHUNK, chunkX * CHUNK + CHUNK - 1, chunkZ * CHUNK + CHUNK - 1); + int halo = RiffleProfile.RADIUS + 1; + return RegionKey.lakeOwners(chunkX * CHUNK - halo, chunkZ * CHUNK - halo, + chunkX * CHUNK + CHUNK - 1 + halo, chunkZ * CHUNK + CHUNK - 1 + halo); } public ChunkWaterRaster rasterize(int chunkX, int chunkZ, IntBinaryOperator naturalHeight, List windows, diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/PondChannelSettlement.java b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/PondChannelSettlement.java new file mode 100644 index 000000000..a9286a83c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/PondChannelSettlement.java @@ -0,0 +1,129 @@ +package art.arcane.iris.generation.hydrology.raster; + +import art.arcane.iris.generation.hydrology.water.Pond; +import art.arcane.iris.generation.hydrology.water.PondKind; +import art.arcane.iris.generation.hydrology.water.PondMask; +import art.arcane.iris.generation.hydrology.water.Reach; +import art.arcane.iris.generation.hydrology.water.ShapedNetwork; + +import java.util.ArrayList; +import java.util.BitSet; +import java.util.List; + +public final class PondChannelSettlement { + private static final int[][] SIDES = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + + private PondChannelSettlement() { + } + + public static ShapedNetwork settle(ShapedNetwork network, int seaLevel) { + int[] initial = new int[network.ponds().size()]; + for (int index = 0; index < initial.length; index++) { + initial[index] = network.ponds().get(index).level(); + } + int[] levels = levels(network.reaches(), network.ponds(), initial, seaLevel); + List water = new ArrayList<>(initial.length); + for (int index = 0; index < initial.length; index++) { + Pond pond = network.ponds().get(index); + water.add(levels[index] == initial[index] ? pond : new Pond(pond.id(), pond.kind(), levels[index], pond.floor(), pond.spillX(), + pond.spillZ(), pond.spillHeight(), pond.mask(), pond.outletReachId(), pond.damRaise(), pond.fluid(), pond.floorX(), pond.floorZ())); + } + return new ShapedNetwork(network.reaches(), water); + } + + static int[] levels(List reaches, List ponds, int[] initial, int seaLevel) { + int[] levels = initial.clone(); + if (reaches.isEmpty() || ponds.isEmpty()) { + return levels; + } + boolean eligible = false; + for (int pond = 0; pond < ponds.size() && !eligible; pond++) { + eligible = ponds.get(pond).kind() != PondKind.LAKE && levels[pond] > seaLevel; + } + if (!eligible) { + return levels; + } + ReachIndex index = ReachIndex.build(reaches, List.of()); + List nearby = new ArrayList<>(); + List unused = new ArrayList<>(); + for (int pond = 0; pond < ponds.size(); pond++) { + Pond water = ponds.get(pond); + if (water.kind() == PondKind.LAKE || levels[pond] <= seaLevel || water.mask().width() == 0 || water.mask().depth() == 0) { + continue; + } + Bounds bounds = Bounds.of(water.mask()); + int chunkX = (bounds.minX() + (bounds.maxX() - bounds.minX()) / 2) >> 4; + int chunkZ = (bounds.minZ() + (bounds.maxZ() - bounds.minZ()) / 2) >> 4; + int halo = Math.max(0, Math.max(Math.max((chunkX << 4) - bounds.minX(), bounds.maxX() - (chunkX << 4) - 15), + Math.max((chunkZ << 4) - bounds.minZ(), bounds.maxZ() - (chunkZ << 4) - 15))) + 1; + nearby.clear(); + index.candidates(chunkX, chunkZ, halo, nearby, unused); + if (!nearby.isEmpty()) { + levels[pond] = lower(nearby, water.mask(), bounds, levels[pond], seaLevel); + } + } + return levels; + } + + private static int lower(List reaches, PondMask mask, Bounds bounds, int level, int seaLevel) { + ReachSegments segments = new ReachSegments(64); + for (Reach reach : reaches) { + segments.addNear(reach, bounds.minX() - 1, bounds.minZ() - 1, bounds.maxX() + 1, bounds.maxZ() + 1, 0f); + } + ReachCursor cursor = new ReachCursor(); + int width = bounds.maxX() - bounds.minX() + 1; + BitSet visited = new BitSet(width * (bounds.maxZ() - bounds.minZ() + 1)); + for (Reach reach : reaches) { + for (int segment = 0; segment + 1 < reach.xs().length; segment++) { + if (Math.min(head(reach.grade()[segment], seaLevel), head(reach.grade()[segment + 1], seaLevel)) >= head(level, seaLevel)) { + continue; + } + float radius = Math.max(reach.halfWidth()[segment], reach.halfWidth()[segment + 1]) + 1f; + int minX = Math.max(bounds.minX(), (int) Math.ceil(Math.min(reach.xs()[segment], reach.xs()[segment + 1]) - radius)); + int minZ = Math.max(bounds.minZ(), (int) Math.ceil(Math.min(reach.zs()[segment], reach.zs()[segment + 1]) - radius)); + int maxX = Math.min(bounds.maxX(), (int) Math.floor(Math.max(reach.xs()[segment], reach.xs()[segment + 1]) + radius)); + int maxZ = Math.min(bounds.maxZ(), (int) Math.floor(Math.max(reach.zs()[segment], reach.zs()[segment + 1]) + radius)); + for (int z = minZ; z <= maxZ; z++) { + for (int x = minX; x <= maxX; x++) { + int cell = (z - bounds.minZ()) * width + x - bounds.minX(); + if (visited.get(cell) || !mask.contains(x, z)) { + continue; + } + visited.set(cell); + level = Math.min(level, touchingHead(segments, cursor, x, z, seaLevel, level)); + for (int[] side : SIDES) { + int nx = x + side[0]; + int nz = z + side[1]; + if (!mask.contains(nx, nz)) { + level = Math.min(level, touchingHead(segments, cursor, nx, nz, seaLevel, level)); + } + } + } + } + } + } + return level; + } + + private static int touchingHead(ReachSegments segments, ReachCursor cursor, int x, int z, int seaLevel, int level) { + segments.nearest(x, z, ReachSegments.Scope.CHANNEL, cursor); + ReachCursor.Choice channel = cursor.channel; + if (channel.reach == null) { + return level; + } + int head = head(channel.reach.grade()[channel.point()], seaLevel); + return head < head(level, seaLevel) ? head : level; + } + + private static int head(int level, int seaLevel) { + return level - Math.floorMod(level - seaLevel, 2); + } + + private record Bounds(int minX, int minZ, int maxX, int maxZ) { + private static Bounds of(PondMask mask) { + int minX = mask.originX() - (mask.step() >> 1); + int minZ = mask.originZ() - (mask.step() >> 1); + return new Bounds(minX, minZ, minX + mask.width() * mask.step() - 1, minZ + mask.depth() * mask.step() - 1); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/RasterComposer.java b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/RasterComposer.java index 56dbd4d07..36dec7012 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/RasterComposer.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/RasterComposer.java @@ -67,6 +67,7 @@ final class RasterComposer { column(cell); } } + gradeRiffles(); raiseDams(); bermEdges(); measureLakeShores(); @@ -80,8 +81,8 @@ final class RasterComposer { int ground = natural[cell]; SeaInlet inlet = features.openInletAt(x, z, inletColumn); if (inlet != null) { - head[cell] = inlet.waterLevel(); - terrain[cell] = Math.min(ground, inletColumn[0]); + head[cell] = level(inlet.waterLevel()); + terrain[cell] = Math.min(Math.min(ground, inletColumn[0]), head[cell] - 1); role[cell] = WaterRole.INLET; feature[cell] = inlet.id(); fluid[cell] = HydrologyCaveCell.SEA_FLUID; @@ -91,72 +92,77 @@ final class RasterComposer { int pondIndex = lake >= 0 ? -1 : features.pondAt(x, z); boolean shore = interior(cell) && lake < 0 && pondIndex < 0; features.segments().nearest(x, z, shore ? ReachSegments.Scope.SHORE : ReachSegments.Scope.CHANNEL, cursor); + double variation = BedProfile.variation(settings.seed(), x, z); if (lake >= 0) { Pond water = features.lake(lake); - standing(cell, water, trench(ground, water.level(), ground)); + standing(cell, water, trench(ground, level(water.level()), ground, variation, BedProfile.detail(settings.seed(), x, z))); return; } if (pondIndex >= 0) { Pond water = features.pond(pondIndex); pond[cell] = pondIndex; - int floor = interior(cell) ? pondFloor(pondIndex, water.level(), x, z, ground) : ground; - standing(cell, water, trench(floor, water.level(), ground)); + double detail = BedProfile.detail(settings.seed(), x, z); + int floor = interior(cell) ? pondFloor(pondIndex, level(water.level()), x, z, ground, variation, detail) : ground; + standing(cell, water, trench(floor, level(water.level()), ground, variation, detail)); return; } if (cursor.channel.reach != null) { - channel(cell, ground); + channel(cell, ground, variation, BedProfile.detail(settings.seed(), x, z)); } else if (interior(cell)) { - dry(cell, x, z, ground); + dry(cell, x, z, ground, variation); } } private void standing(int cell, Pond water, int floor) { - head[cell] = water.level(); - terrain[cell] = floor; + head[cell] = level(water.level()); + terrain[cell] = Math.min(floor, head[cell] - 1); role[cell] = WaterRole.POND; feature[cell] = water.id(); fluid[cell] = water.fluid(); } - private int trench(int floor, int level, int ground) { + private int trench(int floor, int level, int ground, double variation, double detail) { ReachCursor.Choice hit = cursor.channel; if (hit.reach == null) { return floor; } - return Math.min(floor, bed(hit, Math.min(level, hit.reach.grade()[hit.point()]), ground)); + return Math.min(floor, bed(hit, Math.min(level, level(hit.reach.grade()[hit.point()])), ground, variation, detail)); } - private void channel(int cell, int ground) { + private void channel(int cell, int ground, double variation, double detail) { ReachCursor.Choice hit = cursor.channel; Reach reach = hit.reach; int point = hit.point(); - head[cell] = reach.grade()[point]; - terrain[cell] = bed(hit, head[cell], ground); + head[cell] = level(reach.grade()[point]); + terrain[cell] = bed(hit, head[cell], ground, variation, detail); role[cell] = role(reach.style()[point]); feature[cell] = reach.id(); tier[cell] = reach.tier(); fluid[cell] = reach.tier().fluid(); } - private static int bed(ReachCursor.Choice hit, int surface, int ground) { + private static int bed(ReachCursor.Choice hit, int surface, int ground, double variation, double detail) { double width = hit.halfWidth(); double ratio = width > 0D ? hit.distance / width : 0D; - long cut = Math.max(1L, Math.round(hit.depth() * (1D - ratio * ratio))); + long cut = BedProfile.channelDepth(hit.depth(), ratio, variation, detail); return (int) Math.min(ground, surface - cut); } - private void dry(int cell, int x, int z, int ground) { + private void dry(int cell, int x, int z, int ground, double variation) { int cut = ground; int raise = Integer.MIN_VALUE; double bankEdge = Double.POSITIVE_INFINITY; ReachCursor.Choice bank = cursor.bank; if (bank.reach != null) { HydrologyTier bankTier = bank.reach.tier(); - int surface = bank.reach.grade()[bank.point()]; + int surface = level(bank.reach.grade()[bank.point()]); bankEdge = bank.distance - bank.halfWidth(); double u = bankEdge / bankTier.bankBand(); + if (bankTier.bankStyle() != IrisBankStyle.CLIFF && bankTier.bankStyle() != IrisBankStyle.TERRACED) { + u = BedProfile.position(u, -variation); + } if (ground >= surface) { - cut = BankProfile.cut(bankTier.bankStyle(), surface, ground, u, coneSlope(cell) * bankEdge, noise(x, z)); + cut = BankProfile.cut(bankTier.bankStyle(), surface, ground, u, coneSlope(cell) * u * bankTier.bankBand(), noise(x, z)); } else { raise = BankProfile.levee(bankTier.bankStyle(), surface, ground, u, noise(x, z), settings.deformation().maxFill()); } @@ -172,8 +178,8 @@ final class RasterComposer { } int taper = settings.ponds().shore().taper(); boolean tapered = nearPond >= 0 && pondEdge <= taper; - if (tapered && ground > features.pond(nearPond).level()) { - cut = Math.min(cut, taperCut(features.pond(nearPond).level(), ground, pondEdge / taper)); + if (tapered && ground > level(features.pond(nearPond).level())) { + cut = Math.min(cut, taperCut(level(features.pond(nearPond).level()), ground, BedProfile.position(pondEdge / taper, -variation))); } terrain[cell] = raise == Integer.MIN_VALUE ? cut : Math.max(cut, raise); if (bank.reach != null && (!tapered || bankEdge <= pondEdge)) { @@ -215,11 +221,58 @@ final class RasterComposer { return (int) Math.clamp((long) Math.floor(level + rise), level, ground); } - private int pondFloor(int index, int level, int x, int z, int ground) { + private int level(int planned) { + return planned - Math.floorMod(planned - settings.seaLevel(), 2); + } + + private void gradeRiffles() { + int surface = Integer.MIN_VALUE; + for (int cell = 0; cell < CELLS && surface == Integer.MIN_VALUE; cell++) { + if (interior(cell)) { + surface = head[cell]; + } + } + if (surface == Integer.MIN_VALUE || features.hasOnlyLevel(surface, settings.seaLevel())) { + return; + } + RiffleProfile riffles = new RiffleProfile((originX + 1) >> 4, (originZ + 1) >> 4, this::headAt); + for (int cell = 0; cell < CELLS; cell++) { + if (interior(cell) && head[cell] != Integer.MIN_VALUE) { + int x = originX + cell % GRID; + int z = originZ + cell / GRID; + terrain[cell] = riffles.bed(cell % GRID - 1, cell / GRID - 1, terrain[cell], head[cell], + BedProfile.variation(settings.seed(), x, z)); + } + } + } + + private int headAt(int x, int z) { + int gx = x - originX; + int gz = z - originZ; + if (gx >= 0 && gx < GRID && gz >= 0 && gz < GRID && !corner(gz * GRID + gx)) { + return head[gz * GRID + gx]; + } + SeaInlet inlet = features.openInletAt(x, z, inletColumn); + if (inlet != null) { + return level(inlet.waterLevel()); + } + int lake = features.lakeCovers(x, z) ? features.lakeAt(x, z, naturalAt(x, z)) : -1; + if (lake >= 0) { + return level(features.lake(lake).level()); + } + int pond = features.pondAt(x, z); + if (pond >= 0) { + return level(features.pond(pond).level()); + } + features.segments().nearest(x, z, ReachSegments.Scope.CHANNEL, cursor); + return cursor.channel.reach == null ? Integer.MIN_VALUE : level(cursor.channel.reach.grade()[cursor.channel.point()]); + } + + private int pondFloor(int index, int level, int x, int z, int ground, double variation, double detail) { HydrologySettings.PondShore shore = settings.ponds().shore(); double bankDistance = features.pondBankDistance(index, x, z); double u = shore.shelf() <= 0 ? 1D : Math.clamp((bankDistance - 1D) / shore.shelf(), 0D, 1D); - long bowl = Math.max(1L, Math.round(1D + (shore.maxDepth() - 1D) * BankProfile.shape(IrisBankStyle.SMOOTH, u))); + long bowl = BedProfile.pondDepth(shore.maxDepth(), u, variation, detail); return (int) Math.min(ground, level - bowl); } @@ -236,8 +289,9 @@ final class RasterComposer { continue; } Pond held = features.pond(index); - if (held.damRaise() > 0 && terrain[cell] < held.level() && held.level() + 1 > crest) { - crest = held.level() + 1; + int level = level(held.level()); + if (held.damRaise() > 0 && terrain[cell] < level && level + 1 > crest) { + crest = level + 1; dam = held.id(); } } diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ReachSegments.java b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ReachSegments.java index dbaeaa19f..7661088e5 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ReachSegments.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/ReachSegments.java @@ -45,6 +45,18 @@ final class ReachSegments { } } + boolean hasOnlyLevel(int head, int seaLevel) { + for (int index = 0; index < size; index++) { + int[] grade = reaches[index].grade(); + int start = grade[segments[index]]; + int end = grade[segments[index] + 1]; + if (start - Math.floorMod(start - seaLevel, 2) != head || end - Math.floorMod(end - seaLevel, 2) != head) { + return false; + } + } + return true; + } + void addNear(Reach reach, float minX, float minZ, float maxX, float maxZ, float outward) { float[] xs = reach.xs(); float[] zs = reach.zs(); diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/raster/RiffleProfile.java b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/RiffleProfile.java new file mode 100644 index 000000000..75587a877 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/raster/RiffleProfile.java @@ -0,0 +1,177 @@ +package art.arcane.iris.generation.hydrology.raster; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.TreeSet; +import java.util.function.IntBinaryOperator; + +final class RiffleProfile { + static final int RADIUS = 16; + private static final int CHUNK = 16; + private static final int PADDING = RADIUS + 1; + private static final int GRID = CHUNK + 2 * PADDING; + private static final int OUTPUT = CHUNK + 2; + private static final int LIMIT = 10 * RADIUS; + private static final int DRY = Integer.MIN_VALUE; + private static final int[] SIDES = {1, -1, GRID, -GRID}; + + private final List fields = new ArrayList<>(); + + RiffleProfile(int chunkX, int chunkZ, IntBinaryOperator headAt) { + int[] heads = new int[GRID * GRID]; + int[] crests = new int[GRID * GRID]; + int originX = (chunkX << 4) - PADDING; + int originZ = (chunkZ << 4) - PADDING; + Arrays.fill(crests, DRY); + for (int cell = 0; cell < heads.length; cell++) { + heads[cell] = headAt.applyAsInt(originX + cell % GRID, originZ + cell / GRID); + } + TreeSet levels = new TreeSet<>(); + for (int z = 1; z < GRID - 1; z++) { + for (int x = 1; x < GRID - 1; x++) { + int cell = z * GRID + x; + if (heads[cell] == DRY) { + continue; + } + for (int side : SIDES) { + if (heads[cell + side] != DRY && heads[cell + side] <= heads[cell] - 2) { + crests[cell] = heads[cell] - 1; + levels.add(crests[cell]); + break; + } + } + } + } + if (levels.isEmpty()) { + return; + } + DistanceField distances = new DistanceField(heads); + for (int crest : levels) { + distances.reset(); + for (int cell = 0; cell < crests.length; cell++) { + if (crests[cell] == crest) { + distances.offer(cell, 0); + } + } + distances.spread(); + int[] output = distances.output(); + if (output != null) { + fields.add(new Field(crest, output)); + } + } + } + + int bed(int localX, int localZ, int original, int head, double variation) { + if (head == DRY) { + return original; + } + int cell = (localZ + 1) * OUTPUT + localX + 1; + double raised = original; + for (Field field : fields) { + int crest = Math.min(head - 1, field.crest()); + if (crest <= original) { + continue; + } + double reach = 10D * Math.min(RADIUS, Math.max(4D, 2D * (crest - (double) original))) + * (0.925D + 0.075D * variation); + if (field.distances()[cell] >= reach) { + continue; + } + double u = field.distances()[cell] / reach; + double blend = 1D - u * u * (3D - 2D * u); + raised = Math.max(raised, original + (crest - (double) original) * blend); + } + return (int) Math.max(original, Math.min(head - 1L, Math.round(raised))); + } + + private record Field(int crest, int[] distances) { + } + + private static final class DistanceField { + private final int[] heads; + private final int[] distances = new int[GRID * GRID]; + private final int[] next = new int[GRID * GRID]; + private final int[] previous = new int[GRID * GRID]; + private final int[] first = new int[LIMIT]; + + private DistanceField(int[] heads) { + this.heads = heads; + } + + private void reset() { + Arrays.fill(distances, LIMIT); + Arrays.fill(next, -1); + Arrays.fill(previous, -1); + Arrays.fill(first, -1); + } + + private void offer(int cell, int distance) { + if (distances[cell] < LIMIT) { + remove(cell); + } + distances[cell] = distance; + next[cell] = first[distance]; + previous[cell] = -1; + if (next[cell] >= 0) { + previous[next[cell]] = cell; + } + first[distance] = cell; + } + + private void remove(int cell) { + if (previous[cell] < 0) { + first[distances[cell]] = next[cell]; + } else { + next[previous[cell]] = next[cell]; + } + if (next[cell] >= 0) { + previous[next[cell]] = previous[cell]; + } + next[cell] = -1; + previous[cell] = -1; + } + + private void spread() { + for (int distance = 0; distance < LIMIT; distance++) { + while (first[distance] >= 0) { + int cell = first[distance]; + remove(cell); + int x = cell % GRID; + int z = cell / GRID; + for (int dz = -1; dz <= 1; dz++) { + for (int dx = -1; dx <= 1; dx++) { + int nx = x + dx; + int nz = z + dz; + if (dx == 0 && dz == 0 || nx < 0 || nx >= GRID || nz < 0 || nz >= GRID) { + continue; + } + int neighbour = nz * GRID + nx; + int candidate = distance + (dx != 0 && dz != 0 ? 14 : 10); + if (heads[neighbour] == DRY || candidate >= distances[neighbour]) { + continue; + } + if (dx != 0 && dz != 0 && (heads[z * GRID + nx] == DRY || heads[nz * GRID + x] == DRY)) { + continue; + } + offer(neighbour, candidate); + } + } + } + } + } + + private int[] output() { + int[] output = new int[OUTPUT * OUTPUT]; + boolean affected = false; + for (int z = 0; z < OUTPUT; z++) { + for (int x = 0; x < OUTPUT; x++) { + int distance = distances[(z + PADDING - 1) * GRID + x + PADDING - 1]; + output[z * OUTPUT + x] = distance; + affected |= distance < LIMIT; + } + } + return affected ? output : null; + } + } +} diff --git a/core/src/main/java/art/arcane/iris/generation/hydrology/water/PondPlanner.java b/core/src/main/java/art/arcane/iris/generation/hydrology/water/PondPlanner.java index 936299820..6b4b71531 100644 --- a/core/src/main/java/art/arcane/iris/generation/hydrology/water/PondPlanner.java +++ b/core/src/main/java/art/arcane/iris/generation/hydrology/water/PondPlanner.java @@ -126,8 +126,10 @@ public final class PondPlanner { public Pond reflood(DemGrid raw, HeightLattice fine, CupSet cups, Pond pond) { PondMask mask = pond.mask(); int step = mask.step(); - Box box = box(raw, fine, mask.originX() - step, mask.originZ() - step, mask.originX() + mask.width() * step, - mask.originZ() + mask.depth() * step, cups, Math.max(1, step / fine.cell())); + int margin = Math.max(step, fine.cell()); + Box box = box(raw, fine, mask.originX() - margin, mask.originZ() - margin, + mask.originX() + (mask.width() - 1) * step + margin, mask.originZ() + (mask.depth() - 1) * step + margin, + cups, Math.max(1, step / fine.cell())); if (box == null) { return null; } @@ -669,6 +671,9 @@ public final class PondPlanner { } int i = node % box.width(); int j = node / box.width(); + if (i == 0 || j == 0 || i == box.width() - 1 || j == box.depth() - 1) { + return false; + } for (int axis = 0; axis < AXIS_X.length; axis++) { int neighbour = (j + AXIS_Z[axis]) * box.width() + i + AXIS_X[axis]; if (!wet[neighbour] && ground[neighbour] < level) { diff --git a/core/src/main/java/art/arcane/iris/generation/runtime/EffectiveWater.java b/core/src/main/java/art/arcane/iris/generation/runtime/EffectiveWater.java index 1e593cb4c..fae5b74c0 100644 --- a/core/src/main/java/art/arcane/iris/generation/runtime/EffectiveWater.java +++ b/core/src/main/java/art/arcane/iris/generation/runtime/EffectiveWater.java @@ -29,11 +29,7 @@ final class EffectiveWater { if (head == DRY) { return highest == DRY ? column : column(Math.max(ground, highest + 1), DRY); } - int oneAbove = oneAbove(head, east) + oneAbove(head, west) + oneAbove(head, south) + oneAbove(head, north); - if (oneAbove >= 2) { - return column(Math.max(ground, highest), DRY); - } - return dropsBelow(head, east, west, south, north) ? column(Math.max(ground, head - 1), head) : column; + return column; } static void settleGrid(int originX, int originZ, int width, int depth, Columns columns, long[] settled) { @@ -61,18 +57,6 @@ final class EffectiveWater { } } - private static int oneAbove(int head, long neighbour) { - return head(neighbour) != DRY && head(neighbour) == head + 1 ? 1 : 0; - } - - private static boolean dropsBelow(int head, long east, long west, long south, long north) { - return dropsBelow(head, east) || dropsBelow(head, west) || dropsBelow(head, south) || dropsBelow(head, north); - } - - private static boolean dropsBelow(int head, long neighbour) { - return head(neighbour) != DRY && head(neighbour) <= head - 2; - } - interface Columns { long pre(int x, int z); diff --git a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java index f87b01546..882fe0310 100644 --- a/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java +++ b/core/src/main/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuator.java @@ -48,6 +48,8 @@ import lombok.Getter; public class IrisTerrainNormalActuator extends EngineAssignedActuator { private static final BoundBlockState BEDROCK = BoundBlockState.of("BEDROCK"); private static final BoundBlockState AIR = BoundBlockState.of("AIR"); + private static final BoundBlockState DIRT = BoundBlockState.of("minecraft:dirt"); + private static final BoundBlockState NETHERRACK = BoundBlockState.of("minecraft:netherrack"); private static final int SLOT_SEA_LAYERS = 1; @Getter private final RNG rng; @@ -132,6 +134,17 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator DIRT.get(); + case "minecraft:crimson_nylium", "minecraft:warped_nylium" -> NETHERRACK.get(); + default -> block; + }; + } + private final class ColumnPainter { private final int x; private final int z; @@ -214,6 +227,7 @@ public class IrisTerrainNormalActuator extends EngineAssignedActuator>> 6]; + for (int z = -20; z <= 20; z++) { + for (int x = -20; x <= 20; x++) { + if (x * x + z * z < 400) { + int cell = (z + 20) * width + x + 20; + bits[cell >>> 6] |= 1L << cell; + } + } + } + Pond initial = new Pond(19L, PondKind.NATURAL, 100, 80, 84, 64, 100f, + new PondMask(44, 44, 1, width, width, bits), 0L, 0, "water", 64, 64); + PondPlanner ponds = spy(new PondPlanner(settings)); + doReturn(CupSet.EMPTY).when(ponds).subtractCups(any(), any(), any(), any()); + doReturn(List.of(initial)).when(ponds).ponds(any(), any(), any(), any(), any(), any()); + ReachShaper shaper = mock(ReachShaper.class); + AtomicInteger passes = new AtomicInteger(); + doAnswer(call -> { + List water = call.getArgument(4); + Pond pond = single(water); + passes.incrementAndGet(); + int head = pond.level() > 96 ? 96 : 94; + Reach outlet = new Reach(20L, settings.tiers().get(0), 0L, new float[]{64f, 64f}, new float[]{32f, 96f}, + new int[]{head, head}, new float[]{2f, 2f}, new float[]{2f, 2f}, new ReachStyle[]{ReachStyle.RUN, ReachStyle.RUN}, 1000f); + return new ShapedNetwork(List.of(outlet), List.of(pond.withOutletReachId(outlet.id()))); + }).when(shaper).shape(any(), any(), any(), any(), any(), any(), anyLong()); + WindowPlanner planner = new WindowPlanner(settings, new HeightLattice(terrain, WindowPlanner.CELL, false, 1 << 18), + new HeightLattice(terrain, WindowPlanner.FINE_CELL, false, 1 << 18), ponds, shaper); + + WindowComputation settled = planner.compute(ORIGIN, noRegions(ORIGIN)); + + Pond pond = single(settled.allPonds()); + assertEquals(3, passes.get()); + assertEquals(94, pond.level()); + assertEquals(20L, pond.outletReachId()); + assertTrue(initial.mask().contains(83, 64)); + assertFalse(pond.mask().contains(83, 64)); + assertTrue(pond.mask().contains(64, 64)); + assertEquals(94, settled.allReaches().get(0).grade()[0]); + } + @Test public void burnedTrunkCapturesLocalDrainage() { HydrologySettings settings = settings(new IrisHydrology()); diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/raster/BedProfileTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/raster/BedProfileTest.java new file mode 100644 index 000000000..9ffd25bd1 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/raster/BedProfileTest.java @@ -0,0 +1,96 @@ +package art.arcane.iris.generation.hydrology.raster; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class BedProfileTest { + @Test + public void fineDetailHasBoundedAmplitudeAndAdjacentSteps() { + double minimum = Double.POSITIVE_INFINITY; + double maximum = Double.NEGATIVE_INFINITY; + for (long seed : new long[]{0L, 1337L, Long.MIN_VALUE, Long.MAX_VALUE}) { + for (int x = -48; x <= 48; x++) { + for (int z = -48; z <= 48; z++) { + double detail = BedProfile.detail(seed, x, z); + minimum = Math.min(minimum, detail); + maximum = Math.max(maximum, detail); + assertTrue(Math.abs(detail) <= 0.35D); + assertTrue(Math.abs(detail - BedProfile.detail(seed, x + 1, z)) <= 0.35D); + assertTrue(Math.abs(detail - BedProfile.detail(seed, x, z + 1)) <= 0.35D); + } + } + } + assertTrue(minimum < -0.3D); + assertTrue(maximum > 0.3D); + } + + @Test + public void fineDetailBreaksUpLevelContoursWithoutTallFaces() { + int changedChannels = 0; + int changedPonds = 0; + for (int x = -64; x <= 64; x++) { + for (int z = -32; z <= 32; z++) { + double detail = BedProfile.detail(1337L, x, z); + int channel = BedProfile.channelDepth(6D, 0.5D, 0D, detail); + int pond = BedProfile.pondDepth(8, 0.5D, 0D, detail); + changedChannels += channel != BedProfile.channelDepth(6D, 0.5D, 0D, 0D) ? 1 : 0; + changedPonds += pond != BedProfile.pondDepth(8, 0.5D, 0D, 0D) ? 1 : 0; + assertTrue(channel == 4 || channel == 5); + assertTrue(pond == 4 || pond == 5); + } + } + assertTrue(changedChannels > 1000); + assertTrue(changedPonds > 1000); + } + + @Test + public void depthLimitsAndShallowEdgesSurviveFineDetail() { + for (int maximum : new int[]{1, 2, 6, 8, 16, 64}) { + for (double variation : new double[]{-1D, -0.5D, 0D, 0.5D, 1D}) { + for (double detail : new double[]{-0.35D, 0D, 0.35D}) { + assertEquals(1, BedProfile.channelDepth(maximum, 1D, variation, detail)); + assertEquals(1, BedProfile.pondDepth(maximum, 0D, variation, detail)); + for (int step = 0; step <= 100; step++) { + double position = step / 100D; + int channel = BedProfile.channelDepth(maximum, position, variation, detail); + int pond = BedProfile.pondDepth(maximum, position, variation, detail); + assertTrue(channel >= 1 && channel <= maximum); + assertTrue(pond >= 1 && pond <= maximum); + assertTrue(Math.abs(channel - BedProfile.channelDepth(maximum, position, variation, 0D)) <= 1); + assertTrue(Math.abs(pond - BedProfile.pondDepth(maximum, position, variation, 0D)) <= 1); + } + } + } + } + } + + @Test + public void detailUsesWorldCoordinatesAcrossNegativeChunkBoundaries() { + double[][] expected = new double[66][66]; + for (int x = -33; x <= 32; x++) { + for (int z = -33; z <= 32; z++) { + expected[x + 33][z + 33] = BedProfile.detail(1337L, x, z); + } + } + int differentSeeds = 0; + for (int chunkX = 2; chunkX >= -3; chunkX--) { + for (int chunkZ = 2; chunkZ >= -3; chunkZ--) { + for (int localX = 16; localX >= -1; localX--) { + for (int localZ = 16; localZ >= -1; localZ--) { + int x = (chunkX << 4) + localX; + int z = (chunkZ << 4) + localZ; + if (x < -33 || x > 32 || z < -33 || z > 32) { + continue; + } + double detail = BedProfile.detail(1337L, x, z); + assertEquals(expected[x + 33][z + 33], detail, 0D); + differentSeeds += detail != BedProfile.detail(1338L, x, z) ? 1 : 0; + } + } + } + } + assertTrue(differentSeeds > 4000); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/raster/ChunkWaterRasterTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/raster/ChunkWaterRasterTest.java index cccc0824a..74f4cab03 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/raster/ChunkWaterRasterTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/raster/ChunkWaterRasterTest.java @@ -47,6 +47,41 @@ public class ChunkWaterRasterTest { private static final float SPACING = 2f; private static final int GENTLE_STEP = 3; + @Test + public void flatLakesSkipExtraTerrainSamplingForRiffles() { + AtomicInteger samples = new AtomicInteger(); + Scene scene = new Scene(settings(new IrisHydrology()), (x, z) -> { + samples.incrementAndGet(); + return 70; + }).lake(pond(8L, PondKind.LAKE, 80, circle(8, 8, 100, 1), 8, 8, 0)); + + assertEquals(80, scene.raster(0, 0).waterHead(8, 8)); + assertEquals(320, samples.get()); + } + + @Test + public void shallowStandingWaterRetainsItsFootprintWhenItsHeadLowersOneBlock() { + int desiredLevel = 75; + Pond water = pond(9L, PondKind.NATURAL, desiredLevel, circle(-16, -16, 12, 1), -16, -16, 0); + HydrologySettings settings = settings(new IrisHydrology()); + for (Scene scene : List.of(new Scene(settings, (x, z) -> desiredLevel - 1).pond(water), + new Scene(settings, (x, z) -> desiredLevel - 1).lake(water))) { + int wet = 0; + for (int x = -32; x <= 0; x++) { + for (int z = -32; z <= 0; z++) { + if (!water.mask().contains(x, z)) { + continue; + } + wet++; + assertEquals("head at " + x + "," + z, desiredLevel - 1, scene.head(x, z)); + assertTrue("bed at " + x + "," + z, scene.terrain(x, z) < scene.head(x, z)); + } + } + assertTrue("wet footprint " + wet, wet > 400); + assertEquals(0, scene.leaks(-32, -32, 0, 0)); + } + } + @Test public void channelBedBelowWaterHead() { IntBinaryOperator natural = (x, z) -> x >= 40 && x < 50 ? 82 : 90 + (int) Math.round(2D * Math.sin(x / 9D)); @@ -62,15 +97,15 @@ public class ChunkWaterRasterTest { channel++; int bed = scene.terrain(x, z); int ground = natural.applyAsInt(x, z); - assertEquals(87, scene.head(x, z)); - assertTrue("bed " + bed + " below the water head at " + x + "," + z, bed < 87); + assertEquals(86, scene.head(x, z)); + assertTrue("bed " + bed + " below the water head at " + x + "," + z, bed < 86); assertTrue("bed never above natural at " + x + "," + z, bed <= ground); if (ground < 84) { pooled++; assertEquals("a depression under the channel keeps its natural floor at " + x + "," + z, ground, bed); } } - assertTrue("full depth on the centre line at x=" + x, 87 - scene.terrain(x, 8) >= 3); + assertTrue("the centre line keeps a submerged bed at x=" + x, 86 - scene.terrain(x, 8) >= 2); } assertTrue("channel columns " + channel, channel > 900); assertTrue("pooled columns " + pooled, pooled > 50); @@ -99,6 +134,69 @@ public class ChunkWaterRasterTest { assertEquals("far columns sit at the cap", ChunkWaterRaster.MAX_SHORE_DISTANCE, scene.shore(0, -40)); } + @Test + public void channelBedsAndBanksVaryAlongConstantWidthReaches() { + IrisHydrology config = new IrisHydrology(); + config.getDeformation().setBankNoise(0D); + HydrologySettings settings = settings(config); + HydrologyTier river = tier("river", 8f, 0.75f, 24, IrisBankStyle.SMOOTH); + Reach reach = line(15L, river, 0L, -112f, 0f, 112f, 0f, (x, z) -> 100); + Scene scene = new Scene(settings, (x, z) -> 120).reach(reach); + Set bedHeights = new HashSet<>(); + Set bankHeights = new HashSet<>(); + int previousBed = scene.terrain(-80, 4); + int previousBank = scene.terrain(-80, 20); + for (int x = -80; x <= 80; x++) { + int bed = scene.terrain(x, 4); + int bank = scene.terrain(x, 20); + bedHeights.add(bed); + bankHeights.add(bank); + assertEquals(100, scene.head(x, 4)); + assertEquals(Integer.MIN_VALUE, scene.head(x, 20)); + assertTrue("depth stays within the tier limit", bed >= 94 && bed < 100); + assertTrue("bank stays between the water and terrain", bank >= 100 && bank <= 120); + assertTrue("bed variation remains coherent across chunk boundaries", Math.abs(bed - previousBed) <= 1); + assertTrue("bank variation remains coherent across chunk boundaries", Math.abs(bank - previousBank) <= 1); + previousBed = bed; + previousBank = bank; + } + assertTrue("constant-width bed has varying contours", bedHeights.size() >= 3); + assertTrue("constant-distance bank has varying contours", bankHeights.size() >= 3); + } + + @Test + public void pondShelvesAndTapersVaryWithoutChangingWaterCoverage() { + HydrologySettings settings = settings(new IrisHydrology()); + long[] bits = new long[192 * 64 / 64]; + Arrays.fill(bits, -1L); + Pond water = pond(16L, PondKind.NATURAL, 100, new PondMask(-96, -32, 1, 192, 64, bits), 0, 0, 0); + Scene scene = new Scene(settings, (x, z) -> 110).pond(water); + Set shelfHeights = new HashSet<>(); + Set deepHeights = new HashSet<>(); + Set taperHeights = new HashSet<>(); + for (int x = -64; x <= 64; x++) { + shelfHeights.add(scene.terrain(x, -28)); + deepHeights.add(scene.terrain(x, 0)); + taperHeights.add(scene.terrain(x, -40)); + for (int z = -48; z <= 47; z++) { + assertEquals("the shoreline remains fixed", water.mask().contains(x, z), scene.water(x, z)); + if (scene.water(x, z)) { + assertEquals(100, scene.head(x, z)); + assertTrue("pond depth remains bounded", scene.terrain(x, z) >= 92 && scene.terrain(x, z) < 100); + } else { + assertTrue("dry taper stays above water", scene.terrain(x, z) >= 100); + } + } + } + assertTrue("shelf contours vary along a straight shore", shelfHeights.size() >= 3); + assertTrue("deep floor has relief", deepHeights.size() >= 2); + assertTrue("dry taper varies along a straight shore", taperHeights.size() >= 2); + Scene reverse = new Scene(settings, (x, z) -> 110).pond(water); + for (int x = 64; x >= -64; x--) { + assertEquals("generation order leaves the floor unchanged", scene.terrain(x, -28), reverse.terrain(x, -28)); + } + } + @Test public void lakeRimShoreDistanceMatchesNearestLakeWater() { IntBinaryOperator natural = (x, z) -> 60 + (int) (Math.hypot(x - 24, z - 24) / 2D); @@ -189,14 +287,14 @@ public class ChunkWaterRasterTest { wet++; chunks.add(pack(Math.floorDiv(x, 16), Math.floorDiv(z, 16))); assertEquals(pond.id(), scene.feature(x, z)); - assertEquals("pond level at " + x + "," + z, 75, scene.head(x, z)); - assertTrue("pond floor below the level at " + x + "," + z, scene.terrain(x, z) < 75); + assertEquals("pond level at " + x + "," + z, 74, scene.head(x, z)); + assertTrue("pond floor below the level at " + x + "," + z, scene.terrain(x, z) < 74); assertEquals("water", scene.fluid(x, z)); } } assertTrue("pond columns " + wet, wet > 400); assertEquals("the pond spans four chunks", 4, chunks.size()); - assertEquals("open water reaches full depth", 75 - 8, scene.terrain(16, 16)); + assertTrue("open water stays within the maximum depth", scene.terrain(16, 16) >= 74 - 8 && scene.terrain(16, 16) < 74); assertEquals(WaterRole.POND_SHORE, scene.role(16, 30)); } @@ -206,7 +304,7 @@ public class ChunkWaterRasterTest { float startZ = -20f; float endX = 80f; float endZ = 60f; - IntBinaryOperator natural = (x, z) -> 80 + (int) Math.floor(0.35D * perpendicular(x, z, startX, startZ, endX, endZ)); + IntBinaryOperator natural = (x, z) -> 79 + (int) Math.floor(0.35D * perpendicular(x, z, startX, startZ, endX, endZ)); HydrologyTier river = tier("river", 3f, 0.5f, 12, IrisBankStyle.SMOOTH); Scene scene = new Scene(settings(new IrisHydrology()), natural).reach(line(31L, river, 0L, startX, startZ, endX, endZ, (x, z) -> 79)); int interior = 0; @@ -253,12 +351,12 @@ public class ChunkWaterRasterTest { .reach(line(44L, stream, 0L, 8f, 8f, 56f, 8f, (x, z) -> Math.clamp(eastSlope.applyAsInt((int) x, (int) z) - 1, 94, 99))); assertContained("chained ponds", chained, -16, -8, 80, 24, true); - IntBinaryOperator cliff = (x, z) -> (x < 24 ? 100 : 80) - Math.max(0, z - 8); + IntBinaryOperator cliff = (x, z) -> (x < 24 ? 99 : 79) - Math.max(0, z - 8); Scene waterfall = new Scene(settings, cliff).reach(line(45L, stream, 0L, -24f, 8f, 72f, 8f, (x, z) -> x < 24 ? 99 : 79)); assertContained("waterfall", waterfall, -16, -8, 64, 24, true); assertEquals(WaterRole.WATERFALL, waterfall.role(24, 8)); - assertEquals(99, waterfall.head(22, 8)); - assertEquals(79, waterfall.head(24, 8)); + assertEquals(98, waterfall.head(22, 8)); + assertEquals(78, waterfall.head(24, 8)); } @Test @@ -455,7 +553,7 @@ public class ChunkWaterRasterTest { IrisHydrology config = new IrisHydrology(); config.getDeformation().setBankSlope(0D).setBankSteepFactor(0D).setBankNoise(0D); HydrologySettings settings = settings(config); - IntBinaryOperator natural = (x, z) -> z >= 8 ? 70 + (z - 8) : 68 - (8 - z) / 4; + IntBinaryOperator natural = (x, z) -> z >= 8 ? 69 + (z - 8) : 67 - (8 - z) / 4; Map cuts = new EnumMap<>(IrisBankStyle.class); for (IrisBankStyle style : IrisBankStyle.values()) { Scene scene = new Scene(settings, natural).reach(line(90L + style.ordinal(), tier("river", 2f, 0.5f, 16, style), 0L, -48f, 8f, 64f, 8f, @@ -466,7 +564,7 @@ public class ChunkWaterRasterTest { int height = scene.terrain(8, z); assertFalse(style + " bank is dry at z=" + z, scene.water(8, z)); assertTrue(style + " bank role at z=" + z, scene.role(8, z) == WaterRole.BANK || scene.role(8, z) == WaterRole.SHORE); - assertTrue(style + " bank between water and natural at z=" + z + ": " + height, height >= 69 && height <= natural.applyAsInt(8, z)); + assertTrue(style + " bank between water and natural at z=" + z + ": " + height, height >= 68 && height <= natural.applyAsInt(8, z)); assertTrue(style + " bank never falls toward the ground at z=" + z, outward == 1 || height >= cut[outward - 2]); cut[outward - 1] = height; int levee = scene.terrain(8, 6 - outward); @@ -475,7 +573,7 @@ public class ChunkWaterRasterTest { levee >= ground && levee <= ground + settings.deformation().maxFill()); } assertEquals(style + " meets natural at the band edge", natural.applyAsInt(8, 26), cut[15]); - assertTrue(style + " levee holds the water", scene.terrain(8, 5) >= 70); + assertTrue(style + " levee holds the water", scene.terrain(8, 5) >= 69); assertEquals(style + " levee meets natural at the band edge", natural.applyAsInt(8, -10), scene.terrain(8, -10)); assertEquals(style + " leaks", 0, scene.leaks(-16, -16, 32, 32)); cuts.put(style, cut); @@ -494,16 +592,16 @@ public class ChunkWaterRasterTest { assertTrue(ordered); int[] cliff = cuts.get(IrisBankStyle.CLIFF); for (int index = 0; index < 8; index++) { - assertEquals("cliff bench at outward " + (index + 1), 69, cliff[index]); + assertEquals("cliff bench at outward " + (index + 1), 68, cliff[index]); } assertEquals("cliff wall reaches natural", natural.applyAsInt(8, 22), cliff[11]); int[] terraced = cuts.get(IrisBankStyle.TERRACED); boolean level = false; for (int index = 0; index < 15; index++) { if (terraced[index] < natural.applyAsInt(8, 11 + index)) { - assertEquals("terrace step height at outward " + (index + 1), 0, (terraced[index] - 69) % 3); + assertEquals("terrace step height at outward " + (index + 1), 0, (terraced[index] - 68) % 3); } - level |= terraced[index] > 69 && terraced[index] == terraced[index + 1]; + level |= terraced[index] > 68 && terraced[index] == terraced[index + 1]; } assertTrue("terraces hold level steps", level); List styles = List.of(IrisBankStyle.values()); @@ -521,13 +619,13 @@ public class ChunkWaterRasterTest { int maxFill = settings.deformation().maxFill(); HydrologyTier stream = tier("stream", 1.5f, 0.5f, 0, IrisBankStyle.SMOOTH); Reach perchedReach = line(101L, stream, 0L, -32f, 8f, 48f, 8f, (x, z) -> 79); - IntBinaryOperator slope = (x, z) -> 80 - Math.max(0, z - 8); + IntBinaryOperator slope = (x, z) -> 79 - Math.max(0, z - 8); Scene scene = new Scene(settings, slope).reach(perchedReach); for (int x = -24; x < 40; x++) { assertTrue("channel water at x=" + x, scene.water(x, 9)); assertFalse("dry downhill column at x=" + x, scene.water(x, 10)); - assertEquals("natural sits below the water at x=" + x, 78, slope.applyAsInt(x, 10)); - assertEquals("berm one block above the water at x=" + x, 80, scene.terrain(x, 10)); + assertEquals("natural sits below the water at x=" + x, 77, slope.applyAsInt(x, 10)); + assertEquals("berm one block above the water at x=" + x, 79, scene.terrain(x, 10)); assertEquals(WaterRole.SHORE, scene.role(x, 10)); assertEquals(perchedReach.id(), scene.feature(x, 10)); assertEquals(stream, scene.tier(x, 10)); @@ -536,11 +634,11 @@ public class ChunkWaterRasterTest { assertEquals(0, scene.leaks(-24, -8, 40, 24)); Scene steep = new Scene(settings, (x, z) -> z > 9 ? 60 : 80).reach(perchedReach); - assertEquals("past the fill limit the guard berms to the water itself", 79, steep.terrain(0, 10)); + assertEquals("past the fill limit the guard berms to the water itself", 78, steep.terrain(0, 10)); assertTrue("the guard rises past the fill limit", steep.terrain(0, 10) > 60 + maxFill); assertEquals(0, steep.leaks(-24, -8, 40, 24)); Scene below = new Scene(settings, (x, z) -> z > 9 ? 30 : 80).reach(perchedReach); - assertEquals("a column under the sea surface is bermed when the water stands above it", 79, below.terrain(0, 10)); + assertEquals("a column under the sea surface is bermed when the water stands above it", 78, below.terrain(0, 10)); } @Test @@ -559,7 +657,7 @@ public class ChunkWaterRasterTest { } dams++; assertEquals(dammed.id(), scene.feature(x, z)); - assertEquals("dam crest one block above the level at " + x + "," + z, 96, scene.terrain(x, z)); + assertEquals("dam crest one block above the level at " + x + "," + z, 95, scene.terrain(x, z)); if (scene.terrain(x, z) - slope.applyAsInt(x, z) > maxFill) { tall++; } @@ -596,7 +694,7 @@ public class ChunkWaterRasterTest { drawn.catchment()); Scene scene = new Scene(settings(new IrisHydrology()), coast).reach(coastal); assertEquals("the lip carries the fall", WaterRole.WATERFALL, scene.role(38, 8)); - assertEquals(59, scene.head(38, 8)); + assertEquals(58, scene.head(38, 8)); int mouths = 0; for (int x = -20; x <= 56; x++) { for (int z = 0; z <= 16; z++) { diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/raster/PondChannelSettlementTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/raster/PondChannelSettlementTest.java new file mode 100644 index 000000000..0c597d767 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/raster/PondChannelSettlementTest.java @@ -0,0 +1,128 @@ +package art.arcane.iris.generation.hydrology.raster; + +import art.arcane.iris.generation.hydrology.config.IrisBankStyle; +import art.arcane.iris.generation.hydrology.water.HydrologyTier; +import art.arcane.iris.generation.hydrology.water.Pond; +import art.arcane.iris.generation.hydrology.water.PondKind; +import art.arcane.iris.generation.hydrology.water.PondMask; +import art.arcane.iris.generation.hydrology.water.Reach; +import art.arcane.iris.generation.hydrology.water.ReachStyle; +import org.junit.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +public class PondChannelSettlementTest { + private static final int SEA_LEVEL = 40; + + @Test + public void aLowerChannelDrainsThePondWhereOnlyItsWidthOverlaps() { + for (int offset : new int[]{-512, -19, 0, 513}) { + Pond pond = pond(1L, PondKind.CHAINED, 100, circle(offset, offset, 8)); + Reach river = line(2L, offset + 12, offset - 20, offset + 12, offset + 20, 5f, 95, 95); + assertEquals(94, settle(List.of(river), pond)); + } + } + + @Test + public void cardinalWaterContactDrainsButDiagonalProximityDoesNot() { + Pond pond = pond(1L, PondKind.NATURAL, 100, circle(0, 0, 0)); + Reach cardinal = line(2L, 1, 0, 1, 1, 0.25f, 94, 94); + Reach diagonal = line(2L, 1, 1, 1, 2, 0.25f, 94, 94); + + assertEquals(94, settle(List.of(cardinal), pond)); + assertEquals(100, settle(List.of(diagonal), pond)); + } + + @Test + public void drySeparationHigherChannelsAndFixedLakesKeepTheirLevels() { + PondMask mask = circle(0, 0, 8); + Pond pond = pond(1L, PondKind.NATURAL, 100, mask); + Pond lake = pond(1L, PondKind.LAKE, 100, mask); + Reach separate = line(2L, 15, -20, 15, 20, 4f, 94, 94); + Reach higher = line(2L, 0, -20, 0, 20, 4f, 104, 104); + Reach lower = line(2L, 0, -20, 0, 20, 4f, 94, 94); + + assertEquals(100, settle(List.of(separate), pond)); + assertEquals(100, settle(List.of(higher), pond)); + assertEquals(100, settle(List.of(lower), lake)); + } + + @Test + public void equalCanonicalHeadsDoNotTriggerAnotherReflood() { + Pond pond = pond(1L, PondKind.CHAINED, 101, circle(0, 0, 8)); + Reach river = line(2L, 0, -20, 0, 20, 4f, 100, 100); + + assertEquals(101, settle(List.of(river), pond)); + } + + @Test + public void nearerChannelsAndIdentityTiesMatchTheRasterWinner() { + Pond pond = pond(1L, PondKind.CHAINED, 100, circle(0, 0, 4)); + Reach lower = line(4L, 2, -20, 2, 20, 8f, 94, 94); + Reach nearer = line(3L, 0, -20, 0, 20, 8f, 100, 100); + Pond single = pond(2L, PondKind.NATURAL, 100, circle(0, 0, 0)); + Reach tied = line(3L, 2, -20, 2, 20, 8f, 100, 100); + + assertEquals(100, settle(List.of(lower, nearer), single)); + assertEquals(100, settle(List.of(lower, tied), pond)); + assertEquals(100, settle(List.of(tied, lower), pond)); + } + + @Test + public void projectedStationChoosesOnlyTheTouchingSideOfADrop() { + Reach river = line(2L, -20, 0, 20, 0, 2f, 100, 94); + + assertEquals(100, settle(List.of(river), pond(1L, PondKind.NATURAL, 100, circle(-5, 0, 0)))); + assertEquals(94, settle(List.of(river), pond(1L, PondKind.NATURAL, 100, circle(5, 0, 0)))); + } + + @Test + public void adjacentPondsSettleIndependentlyOfReachAndPondOrder() { + Pond first = pond(1L, PondKind.CHAINED, 104, circle(0, 0, 8)); + Pond second = pond(2L, PondKind.CHAINED, 102, circle(17, 0, 8)); + Reach across = line(3L, -20, 0, 40, 0, 4f, 96, 96); + Reach lower = line(4L, 17, -20, 17, 20, 4f, 94, 94); + + assertArrayEquals(new int[]{96, 94}, PondChannelSettlement.levels(List.of(across, lower), List.of(first, second), + new int[]{104, 102}, SEA_LEVEL)); + assertArrayEquals(new int[]{94, 96}, PondChannelSettlement.levels(List.of(lower, across), List.of(second, first), + new int[]{102, 104}, SEA_LEVEL)); + } + + private static int settle(List reaches, Pond pond) { + return PondChannelSettlement.levels(reaches, List.of(pond), new int[]{pond.level()}, SEA_LEVEL)[0]; + } + + private static Pond pond(long id, PondKind kind, int level, PondMask mask) { + return new Pond(id, kind, level, 90, mask.originX(), mask.originZ(), level + 1f, mask, 0L, 0, "water", + mask.originX(), mask.originZ()); + } + + private static PondMask circle(int x, int z, int radius) { + int size = 2 * radius + 1; + long[] bits = new long[(size * size + 63) >>> 6]; + for (int dz = -radius; dz <= radius; dz++) { + for (int dx = -radius; dx <= radius; dx++) { + if (dx * dx + dz * dz <= radius * radius) { + int cell = (dz + radius) * size + dx + radius; + bits[cell >>> 6] |= 1L << cell; + } + } + } + return new PondMask(x - radius, z - radius, 1, size, size, bits); + } + + private static Reach line(long id, int x0, int z0, int x1, int z1, float width, int first, int last) { + HydrologyTier tier = new HydrologyTier("river", 0f, width, width, 0f, 0.5f, 2, true, 6, 0, IrisBankStyle.SMOOTH, + false, null, "water", List.of()); + float[] widths = new float[]{width, width}; + float[] depth = new float[]{2f, 2f}; + ReachStyle[] styles = new ReachStyle[2]; + Arrays.fill(styles, ReachStyle.RUN); + return new Reach(id, tier, 0L, new float[]{x0, x1}, new float[]{z0, z1}, new int[]{first, last}, widths, depth, styles, 1000f); + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/raster/RiffleProfileTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/raster/RiffleProfileTest.java new file mode 100644 index 000000000..1cf73cfa6 --- /dev/null +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/raster/RiffleProfileTest.java @@ -0,0 +1,135 @@ +package art.arcane.iris.generation.hydrology.raster; + +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.IntBinaryOperator; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class RiffleProfileTest { + private static final int DRY = Integer.MIN_VALUE; + + @Test + public void flatPoolsRetainEveryBedDepth() { + IntBinaryOperator heads = (x, z) -> 100; + Surface surface = new Surface(heads); + for (int x = -32; x <= 32; x++) { + for (int z = -16; z <= 16; z++) { + int bed = 64 + Math.floorMod(x + z, 30); + assertEquals(bed, surface.bed(x, z, bed)); + } + } + } + + @Test + public void twoBlockDropsHaveGradedApproachesAndNoTallSubmergedFace() { + Surface surface = new Surface((x, z) -> x < 0 ? 100 : 98); + for (int z = -24; z <= 24; z++) { + assertEquals(99, surface.bed(-1, z, 92)); + assertEquals(97, surface.bed(0, z, 92)); + for (int x = -24; x <= 24; x++) { + int bed = surface.bed(x, z, 92); + int next = surface.bed(x + 1, z, 92); + assertTrue(x + "," + z + " bed " + bed, bed < (x < 0 ? 100 : 98)); + int headDrop = x == -1 ? 2 : 0; + assertTrue(x + "," + z + " face " + bed + " to " + next, Math.abs(bed - next) <= Math.max(1, headDrop)); + } + assertEquals(92, surface.bed(-18, z, 92)); + assertEquals(92, surface.bed(17, z, 92)); + } + } + + @Test + public void deeperBedsFadeWithoutACliffAtTheRadiusBoundary() { + Surface surface = new Surface((x, z) -> x < 0 ? 100 : 98); + for (int z = -8; z <= 8; z++) { + for (int x : new int[]{-18, -17, -16, 14, 15, 16, 17}) { + assertTrue(x + "," + z, Math.abs(surface.bed(x, z, 64) - surface.bed(x + 1, z, 64)) <= 1); + } + assertEquals(64, surface.bed(-18, z, 64)); + assertEquals(64, surface.bed(17, z, 64)); + assertEquals(99, surface.bed(-1, z, 64)); + } + } + + @Test + public void lowerPoolsRecoverTheirDepthWithoutAWideFlatShelf() { + Surface surface = new Surface((x, z) -> x < 0 ? 104 : 100); + for (int z = -8; z <= 8; z++) { + assertEquals(103, surface.bed(-1, z, 96)); + assertEquals(99, surface.bed(0, z, 96)); + assertTrue(surface.bed(3, z, 96) < 99); + assertEquals(96, surface.bed(6, z, 96)); + assertTrue(surface.bed(-6, z, 96) > 96); + } + } + + @Test + public void nearbyDisconnectedPoolsKeepTheirDepth() { + Surface surface = new Surface((x, z) -> z == 0 ? DRY : z > 0 ? 100 : x < 0 ? 100 : 98); + for (int x = -24; x <= 24; x++) { + assertEquals(92, surface.bed(x, 1, 92)); + } + assertEquals(99, surface.bed(-1, -1, 92)); + } + + @Test + public void aNearerLowerDropNeverCutsOffAHigherRiffle() { + Surface mixed = new Surface((x, z) -> x < 0 ? 104 : z < 0 ? 102 : 100); + Surface high = new Surface((x, z) -> x < 0 ? 104 : 100); + Surface low = new Surface((x, z) -> z < 0 ? 102 : 100); + for (int x = 0; x <= 20; x++) { + for (int z = 0; z <= 20; z++) { + int bed = mixed.bed(x, z, 94); + assertTrue(x + "," + z + " lost the higher influence", bed >= high.bed(x, z, 94)); + assertTrue(x + "," + z + " lost the lower influence", bed >= low.bed(x, z, 94)); + assertTrue(x + "," + z + " east face", Math.abs(bed - mixed.bed(x + 1, z, 94)) <= 1); + assertTrue(x + "," + z + " south face", Math.abs(bed - mixed.bed(x, z + 1, 94)) <= 1); + } + } + } + + @Test + public void diagonalJoinsHaveTheSameProfileAcrossChunkBoundaries() { + IntBinaryOperator heads = (x, z) -> x + z < -7 ? 100 : 98; + for (int chunkX = -2; chunkX <= 1; chunkX++) { + for (int chunkZ = -2; chunkZ <= 1; chunkZ++) { + RiffleProfile first = new RiffleProfile(chunkX, chunkZ, heads); + RiffleProfile east = new RiffleProfile(chunkX + 1, chunkZ, heads); + RiffleProfile south = new RiffleProfile(chunkX, chunkZ + 1, heads); + for (int edge = 0; edge < 16; edge++) { + int x = (chunkX + 1) << 4; + int z = (chunkZ << 4) + edge; + double variation = BedProfile.variation(1337L, x, z); + assertEquals(first.bed(16, edge, 92, heads.applyAsInt(x, z), variation), + east.bed(0, edge, 92, heads.applyAsInt(x, z), variation)); + x = (chunkX << 4) + edge; + z = (chunkZ + 1) << 4; + variation = BedProfile.variation(1337L, x, z); + assertEquals(first.bed(edge, 16, 92, heads.applyAsInt(x, z), variation), + south.bed(edge, 0, 92, heads.applyAsInt(x, z), variation)); + } + } + } + } + + private static final class Surface { + private final IntBinaryOperator heads; + private final Map chunks = new HashMap<>(); + + private Surface(IntBinaryOperator heads) { + this.heads = heads; + } + + private int bed(int x, int z, int original) { + int cx = x >> 4; + int cz = z >> 4; + long key = ((long) cx << 32) | (cz & 0xFFFFFFFFL); + RiffleProfile profile = chunks.computeIfAbsent(key, ignored -> new RiffleProfile(cx, cz, heads)); + return profile.bed(x & 15, z & 15, original, heads.applyAsInt(x, z), BedProfile.variation(1337L, x, z)); + } + } +} diff --git a/core/src/test/java/art/arcane/iris/generation/hydrology/water/PondPlannerTest.java b/core/src/test/java/art/arcane/iris/generation/hydrology/water/PondPlannerTest.java index a26fe4c4c..df82518cf 100644 --- a/core/src/test/java/art/arcane/iris/generation/hydrology/water/PondPlannerTest.java +++ b/core/src/test/java/art/arcane/iris/generation/hydrology/water/PondPlannerTest.java @@ -35,6 +35,7 @@ import java.util.function.DoubleBinaryOperator; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @@ -177,6 +178,51 @@ public class PondPlannerTest { assertNull(planner.reflood(sampled.grid(), fine, cups, drained)); } + @Test + public void refloodingBlockMasksKeepsADryFineLatticeMargin() { + HydrologySettings settings = settings(config -> config.getPonds().getShore().setRoundness(0D), MIN_Y, MAX_Y); + int[][] origins = {{0, 0}, {-533, -303}, {529, -301}}; + for (int[] origin : origins) { + int x = origin[0]; + int z = origin[1]; + DoubleBinaryOperator height = (px, pz) -> 100D + 2D * Math.max(Math.abs(px - x - 4), Math.abs(pz - z - 4)); + Terrain terrain = new Terrain(height, 1f, ALLOW_ALL, null, MIN_Y, MAX_Y); + HeightLattice fine = new HeightLattice(terrain, 4, false, 4096); + BitSet wet = new BitSet(81); + wet.set(0, 81); + PondMask mask = new PondMask(x, z, 1, 9, 9, wet.toLongArray()); + Pond pond = new Pond(1L, PondKind.NATURAL, 109, 100, x + 12, z + 4, 112f, mask, 7L, 0, "water", x + 4, z + 4); + DemGrid raw = new DemGrid((Math.floorDiv(x, 8) - 1) * 8, (Math.floorDiv(z, 8) - 1) * 8, 8, 6, 6); + + Pond reflooded = new PondPlanner(settings).reflood(raw, fine, CupSet.EMPTY, pond); + + assertNotNull("reflooded pond at " + x + "," + z, reflooded); + assertEquals(pond.level(), reflooded.level()); + assertEquals(pond.outletReachId(), reflooded.outletReachId()); + assertTrue(reflooded.mask().contains(x + 4, z + 4)); + for (int[] node : wetNodes(reflooded.mask())) { + assertTrue("reflood remains within its previous mask", mask.contains(node[0], node[1])); + } + assertContained(reflooded, height); + } + } + + @Test + public void refloodingRejectsWetRimsClippedByThePlanningGrid() { + HydrologySettings settings = settings(config -> config.getPonds().getShore().setRoundness(0D), MIN_Y, MAX_Y); + Terrain terrain = new Terrain((x, z) -> x >= 0 && x <= 8 && z >= 0 && z <= 8 ? 100D : 112D, + 1f, ALLOW_ALL, null, MIN_Y, MAX_Y); + BitSet wet = new BitSet(81); + wet.set(0, 81); + Pond pond = new Pond(1L, PondKind.NATURAL, 109, 100, 12, 4, 112f, new PondMask(0, 0, 1, 9, 9, wet.toLongArray()), + 0L, 0, "water", 4, 4); + + Pond reflooded = new PondPlanner(settings).reflood(new DemGrid(0, 0, 8, 2, 2), + new HeightLattice(terrain, 4, false, 4096), CupSet.EMPTY, pond); + + assertNull(reflooded); + } + @Test public void seededCupOnSlopeIsPerchedAndContained() { HydrologySettings settings = settings(config -> { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/EffectiveWaterTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/EffectiveWaterTest.java index 2cd0577e1..dc4654c73 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/EffectiveWaterTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/EffectiveWaterTest.java @@ -5,6 +5,7 @@ import art.arcane.iris.generation.hydrology.engine.HydrologyRuntime; import art.arcane.iris.generation.hydrology.raster.WaterRole; import art.arcane.iris.generation.hydrology.water.Pond; import art.arcane.iris.generation.hydrology.water.Reach; +import art.arcane.iris.generation.hydrology.water.ReachStyle; import art.arcane.iris.generation.mantle.EngineMantle; import art.arcane.iris.generation.mantle.MantleHydrologyComponent; import art.arcane.iris.generation.mantle.MantleWriter; @@ -84,23 +85,18 @@ public class EffectiveWaterTest { } @Test - public void cornerColumnsBelowTwoOneBlockHigherNeighboursBecomeRimsAtTheUpperHead() { - long corner = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(80), wet(80), wet(79), NONE); - assertEquals(DRY, EffectiveWater.head(corner)); - assertEquals(80, EffectiveWater.ground(corner)); + public void lowerCornerColumnsRemainWetBetweenHigherNeighbours() { + long corner = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(81), wet(81), wet(79), NONE); + assertEquals(EffectiveWater.column(77, 79), corner); - long mixed = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(80), wet(80), wet(86), NONE); - assertEquals(DRY, EffectiveWater.head(mixed)); - assertEquals(86, EffectiveWater.ground(mixed)); - - long edge = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(80), wet(79), NONE, NONE); - assertEquals(EffectiveWater.column(77, 79), edge); + long mixed = EffectiveWater.settle(EffectiveWater.column(77, 79), wet(81), wet(81), wet(87), NONE); + assertEquals(EffectiveWater.column(77, 79), mixed); } @Test - public void columnsAboveADropOfTwoOrMoreHoldWaterOnlyInTheirTopBlock() { - assertEquals(EffectiveWater.column(98, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), wet(79), wet(99), NONE, NONE)); - assertEquals(EffectiveWater.column(98, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), NONE, wet(97), NONE, NONE)); + public void wetColumnsKeepTheirCanonicalBedAcrossDrops() { + assertEquals(EffectiveWater.column(94, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), wet(79), wet(99), NONE, NONE)); + assertEquals(EffectiveWater.column(94, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), NONE, wet(97), NONE, NONE)); assertEquals(EffectiveWater.column(94, 99), EffectiveWater.settle(EffectiveWater.column(94, 99), wet(98), NONE, NONE, NONE)); assertEquals(EffectiveWater.column(98, 99), EffectiveWater.settle(EffectiveWater.column(98, 99), wet(70), NONE, NONE, NONE)); } @@ -197,33 +193,27 @@ public class EffectiveWaterTest { } @Test(timeout = 180_000L) - public void staircaseStepsTurnExactlyTheirCornerColumnsIntoRims() throws Exception { + public void diagonalStaircaseStepsPreserveEveryWetColumnWithoutSourceConversion() throws Exception { Scene scene = staircase(); try (HydrologyFixture fixture = fixture(); SceneRun run = scene.install(fixture)) { Drops drops = new Drops(run.complex(), 0, 7, 0, 7); - int rims = 0; + int wet = 0; for (int x = 24; x <= 104; x++) { for (int z = 24; z <= 104; z++) { int head = rasterHead(run.hydrology(), x, z); if (head == DRY) { continue; } - int above = 0; - int highest = DRY; + wet++; + assertEquals("water head at " + x + "," + z, head, drops.head(x, z)); + assertTrue("submerged bed at " + x + "," + z, drops.ground(x, z) < head); for (int[] side : SIDES) { int neighbour = rasterHead(run.hydrology(), x + side[0], z + side[1]); - above += neighbour == head + 1 ? 1 : 0; - highest = Math.max(highest, neighbour); - } - boolean rim = above >= 2; - assertEquals("rim at " + x + "," + z, rim, drops.head(x, z) == DRY); - if (rim) { - rims++; - assertEquals("rim ground at " + x + "," + z, highest, drops.ground(x, z)); + assertFalse("one-block drop at " + x + "," + z, neighbour != DRY && Math.abs(neighbour - head) == 1); } } } - assertTrue("rims " + rims, rims > 10); + assertTrue("wet columns " + wet, wet > 200); drops.assertSealed(24, 104, 24, 104); } } @@ -234,7 +224,6 @@ public class EffectiveWaterTest { try (HydrologyFixture fixture = fixture(); SceneRun run = scene.install(fixture)) { Drops drops = new Drops(run.complex(), 0, 7, 0, 5); int lips = 0; - int raised = 0; int falls = 0; for (int x = 16; x <= 112; x++) { for (int z = 30; z <= 50; z++) { @@ -250,17 +239,22 @@ public class EffectiveWaterTest { if (lip) { lips++; assertEquals("lip ground at " + x + "," + z, head - 1, drops.ground(x, z)); - raised += rasterGround(run.hydrology(), x, z) < head - 1 ? 1 : 0; + assertEquals("canonical lip at " + x + "," + z, rasterGround(run.hydrology(), x, z), drops.ground(x, z)); } if (drops.falling(x, head + 1, z)) { falls++; - assertTrue(drops.falling(x, head + 3, z)); - assertFalse(drops.falling(x, head + 4, z)); + int highest = head; + for (int[] side : SIDES) { + highest = Math.max(highest, drops.head(x + side[0], z + side[1])); + } + for (int y = head + 1; y <= highest; y++) { + assertTrue("fall column at " + x + "," + y + "," + z, drops.falling(x, y, z)); + } + assertFalse(drops.falling(x, highest + 1, z)); } } } assertTrue("lips " + lips, lips > 10); - assertTrue("lips raised from deeper beds " + raised, raised > 0); assertTrue("falls " + falls, falls > 10); drops.assertSealed(16, 112, 30, 50); } @@ -268,11 +262,24 @@ public class EffectiveWaterTest { @Test(timeout = 180_000L) public void noFallAnywhereMeetsTheSourceConversionCondition() throws Exception { - for (Scene scene : List.of(staircase(), cascade(), outlet())) { + for (Scene scene : List.of(staircase(), cascade(), outlet(), mouth())) { try (HydrologyFixture fixture = fixture(); SceneRun run = scene.install(fixture)) { Drops drops = new Drops(run.complex(), 0, 7, 0, 7); + int wet = 0; + for (int x = 8; x <= 120; x++) { + for (int z = 8; z <= 120; z++) { + int head = rasterHead(run.hydrology(), x, z); + if (head != DRY) { + wet++; + assertEquals(scene.name + " water stays connected at " + x + "," + z, head, drops.head(x, z)); + assertTrue(scene.name + " bed stays submerged at " + x + "," + z, drops.ground(x, z) < head); + } + } + } + assertTrue(scene.name + " wet columns " + wet, wet > 100); assertEquals(scene.name + " conversions", 0, drops.conversions(8, 120, 8, 120)); assertEquals(scene.name + " unsupported falls", 0, drops.unsupported(8, 120, 8, 120)); + drops.assertSealed(8, 120, 8, 120); } } } @@ -324,6 +331,21 @@ public class EffectiveWaterTest { List.of(WaterScene.pond(74L, 191, 48, 48, 10))); } + private static Scene mouth() { + IntBinaryOperator natural = (x, z) -> x < 64 ? SEA + 12 : SEA - 8; + Reach reach = WaterScene.line(75L, WaterScene.stream(), 8, 40, 120, 40, + (x, z) -> x < 64 ? SEA + 11 : SEA); + ReachStyle[] styles = reach.style().clone(); + for (int point = 0; point < styles.length; point++) { + if (reach.xs()[point] >= 64) { + styles[point] = ReachStyle.MOUTH; + } + } + Reach coastal = new Reach(reach.id(), reach.tier(), reach.downstreamId(), reach.xs(), reach.zs(), reach.grade(), + reach.halfWidth(), reach.depth(), styles, reach.catchment()); + return new Scene("mouth", natural, List.of(coastal), List.of()); + } + private HydrologyFixture fixture() throws Exception { return new HydrologyFixture(HydrologyFixture.pack(temporaryFolder.newFolder().toPath(), null, null, true)); } @@ -410,9 +432,11 @@ public class EffectiveWaterTest { } private int[] column(int x, int z) { - return columns.computeIfAbsent(((long) x << 32) | (z & 0xFFFFFFFFL), key -> new int[]{ - (int) Math.round(complex.resolveHydrologyTerrainHeight(x, z)), - complex.resolveWaterRole(x, z).wet() ? (int) Math.round(complex.resolveWaterHead(x, z)) : DRY}); + return columns.computeIfAbsent(((long) x << 32) | (z & 0xFFFFFFFFL), key -> { + int ground = (int) Math.round(complex.resolveHydrologyTerrainHeight(x, z)); + int head = (int) Math.round(complex.resolveWaterHead(x, z)); + return new int[]{ground, head > ground ? head : DRY}; + }); } private boolean still(int x, int y, int z) { diff --git a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java index 9f8ed00e7..3305ca694 100644 --- a/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java +++ b/core/src/test/java/art/arcane/iris/generation/runtime/IrisComplexHeightStreamTest.java @@ -206,7 +206,7 @@ public class IrisComplexHeightStreamTest { assertTrue(fixture.complex.isHydrologyPlanned(8, 8)); bulk.fillChunkDoubles(0, 0, values); assertBits(landed.terrainHeight(8, 8), values[8 << 4 | 8]); - assertBits(HydrologyFixture.POND_LEVEL, fixture.complex.getHeightFluidStream().getDouble(8, 8)); + assertBits(HydrologyFixture.POND_LEVEL - 1, fixture.complex.getHeightFluidStream().getDouble(8, 8)); fixture.mainThread.set(false); bulk.fillChunkDoubles(0, 0, values); assertBits(landed.terrainHeight(8, 8), values[8 << 4 | 8]); @@ -247,14 +247,14 @@ public class IrisComplexHeightStreamTest { public void waterStreamsFollowTheRaster() throws Exception { try (HydrologyFixture fixture = new HydrologyFixture(hydrologyPack())) { ChunkWaterRaster raster = fixture.hydrology.runtime.raster(0, 0); - assertBits(HydrologyFixture.POND_LEVEL, fixture.complex.getWaterHeadStream().getDouble(8, 8)); + assertBits(HydrologyFixture.POND_LEVEL - 1, fixture.complex.getWaterHeadStream().getDouble(8, 8)); assertSame(WaterRole.POND, fixture.complex.getWaterRoleStream().get(8, 8)); assertSame(raster.role(15, 15), fixture.complex.getWaterRoleStream().get(15.5, 15.5)); assertBits(HydrologyFixture.SEA_LEVEL, fixture.complex.getWaterHeadStream().getDouble(640, 640)); assertSame(WaterRole.NONE, fixture.complex.getWaterRoleStream().get(640, 640)); EngineMantle mantle = mock(EngineMantle.class, CALLS_REAL_METHODS); doReturn(fixture.complex).when(mantle).getComplex(); - assertEquals(HydrologyFixture.POND_LEVEL, mantle.getFluidHeight(8, 8)); + assertEquals(HydrologyFixture.POND_LEVEL - 1, mantle.getFluidHeight(8, 8)); assertEquals(HydrologyFixture.SEA_LEVEL, mantle.getFluidHeight(640, 640)); } } @@ -266,7 +266,7 @@ public class IrisComplexHeightStreamTest { doReturn(fixture.engine).when(placer).getEngine(); doReturn(fixture.engine.getDimension().getFluidHeight()).when(placer).getFluidHeight(); - assertEquals(HydrologyFixture.POND_LEVEL, placer.getFluidHeight(8, 8)); + assertEquals(HydrologyFixture.POND_LEVEL - 1, placer.getFluidHeight(8, 8)); assertEquals(HydrologyFixture.SEA_LEVEL, placer.getFluidHeight(640, 640)); } } 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 64811d5f9..3a0056674 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 @@ -165,7 +165,7 @@ public class IrisComplexTransitionTest { if (terrain > 150) { berms++; assertEquals(terrain, ground, 0D); - assertTrue(ground >= 156); + assertTrue(ground >= 155); } assertContained(complex, x, z, ground); } @@ -195,13 +195,13 @@ public class IrisComplexTransitionTest { double ground = complex.resolveHydrologyTerrainHeight(x, z); assertTrue(raster.terrainHeight(x, z) < 150); assertContained(complex, x, z, ground); - if (ground >= 148) { + if (ground >= 147) { dried++; assertEquals(127D, complex.resolveWaterHead(x, z), 0D); assertSame(WaterRole.NONE, complex.resolveWaterRole(x, z)); } else { wet++; - assertEquals(148D, complex.resolveWaterHead(x, z), 0D); + assertEquals(147D, complex.resolveWaterHead(x, z), 0D); assertSame(WaterRole.POND, complex.resolveWaterRole(x, z)); } } @@ -220,7 +220,7 @@ public class IrisComplexTransitionTest { FakeHydrology hydrology = seam.hydrology; IrisComplex complex = seam.natural((x, z) -> 150D).complex(); for (int z = 7; z <= 9; z++) { - assertEquals(148, hydrology.runtime.raster(1, 0).waterHead(0, z)); + assertEquals(147, hydrology.runtime.raster(1, 0).waterHead(0, z)); assertSame(WaterRole.NONE, complex.resolveWaterRole(16, z)); assertEquals(150D, complex.resolveHydrologyTerrainHeight(16, z), 0D); } @@ -308,9 +308,9 @@ public class IrisComplexTransitionTest { } if (stage.raster(1).waterHead(lx, lz) != Integer.MIN_VALUE && oldChunkWeight(x, lz) > 0D && oldChunkWeight(x - 1, lz) == 0D) { dams++; - assertTrue(x + "," + lz + " dam lost its crest", blended.getRaw(lx, 157, lz) == stage.stone); + assertTrue(x + "," + lz + " dam lost its crest", blended.getRaw(lx, 156, lz) == stage.stone); } - if (blended.getRaw(lx, 156, lz) != stage.water) { + if (blended.getRaw(lx, 155, lz) != stage.water) { continue; } wet++; @@ -318,7 +318,7 @@ public class IrisComplexTransitionTest { int nx = lx + side[0]; int nz = lz + side[1]; if (nx >= 0 && nx < 16 && nz >= 0 && nz < 16) { - assertFalse(x + "," + lz + " leaks toward " + (16 + nx) + "," + nz, stage.open(blended.getRaw(nx, 156, nz))); + assertFalse(x + "," + lz + " leaks toward " + (16 + nx) + "," + nz, stage.open(blended.getRaw(nx, 155, nz))); } } } @@ -339,13 +339,13 @@ public class IrisComplexTransitionTest { int dams = 0; int water = 0; for (int lz = 0; lz < 16; lz++) { - assertTrue(stage.complex.naturalTrueHeight(16, lz) >= 156); + assertTrue(stage.complex.naturalTrueHeight(16, lz) >= 155); if (raster.waterHead(1, lz) != Integer.MIN_VALUE) { dams++; assertSame(WaterRole.NONE, stage.complex.resolveWaterRole(17, lz)); assertTrue(17 + "," + lz + " dam at " + stage.complex.resolveHydrologyTerrainHeight(17, lz), - stage.complex.resolveHydrologyTerrainHeight(17, lz) >= 157D); - assertSame(stage.stone, blended.getRaw(1, 157, lz)); + stage.complex.resolveHydrologyTerrainHeight(17, lz) >= 156D); + assertSame(stage.stone, blended.getRaw(1, 156, lz)); } for (int lx = 0; lx < 16; lx++) { for (int y = 0; y < TransitionStage.HEIGHT; y++) { @@ -373,7 +373,7 @@ public class IrisComplexTransitionTest { public void seamDamHoldsNewWaterBesideAnOpenOldRiverOrLakeEdge() throws Exception { try (TransitionStage stage = new TransitionStage(IrisComplexTransitionTest::oldChunkWeight, true, (x, z) -> 150.3D, TransitionStage.savedColumn(140, 160, -1))) { - assertTrue(stage.complex.resolveWaterHead(16, 4) < 156D); + assertTrue(stage.complex.resolveWaterHead(16, 4) < 155D); Hunk blended = stage.blend(1, stage.paint(1)); assertTrue(stage.dams(blended) > 0); stage.assertNoWaterTouchesAir(blended); @@ -421,9 +421,9 @@ public class IrisComplexTransitionTest { if (stage.raster(1).waterHead(1, lz) != Integer.MIN_VALUE) { wet++; assertSame(WaterRole.POND, stage.complex.resolveWaterRole(17, lz)); - assertEquals(156D, stage.complex.resolveWaterHead(17, lz), 0D); - assertSame(stage.water, blended.getRaw(1, 156, lz)); - assertSame(stage.stone, blended.getRaw(0, 156, lz)); + assertEquals(155D, stage.complex.resolveWaterHead(17, lz), 0D); + assertSame(stage.water, blended.getRaw(1, 155, lz)); + assertSame(stage.stone, blended.getRaw(0, 155, lz)); } } assertTrue(wet > 0); @@ -455,7 +455,7 @@ public class IrisComplexTransitionTest { boolean beside = oldChunkWeight(x + 1, z) == 0D || oldChunkWeight(x - 1, z) == 0D || oldChunkWeight(x, z + 1) == 0D || oldChunkWeight(x, z - 1) == 0D; if (raster.waterHead(index & 15, index >> 4) != Integer.MIN_VALUE && oldChunkWeight(x, z) > 0D && beside - && stage.complex.resolveHydrologyTerrainHeight(x, z) < 156D + 1D) { + && stage.complex.resolveHydrologyTerrainHeight(x, z) < 155D + 1D) { fail(x + "," + z + " stays wet beside a saved column that opens below its head"); } if (role == WaterRole.NONE && raster.waterHead(index & 15, index >> 4) != Integer.MIN_VALUE && beside @@ -663,8 +663,8 @@ public class IrisComplexTransitionTest { for (int lz = 0; lz < 16; lz++) { if (raster(1).waterHead(1, lz) != Integer.MIN_VALUE && complex.resolveWaterRole(17, lz) == WaterRole.NONE) { dams++; - assertTrue(17 + "," + lz + " dam below the crest", complex.resolveHydrologyTerrainHeight(17, lz) >= 157D); - assertSame(stone, blended.getRaw(1, 157, lz)); + assertTrue(17 + "," + lz + " dam below the crest", complex.resolveHydrologyTerrainHeight(17, lz) >= 156D); + assertSame(stone, blended.getRaw(1, 156, lz)); } } return dams; @@ -821,7 +821,7 @@ public class IrisComplexTransitionTest { dams++; assertEquals(127D, head, 0D); assertSame(WaterRole.NONE, role); - assertTrue(x + "," + z + " dam at " + ground, ground >= 157D); + assertTrue(x + "," + z + " dam at " + ground, ground >= 156D); } } } diff --git a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java index 77cc69a0d..7c848fb9d 100644 --- a/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java +++ b/core/src/test/java/art/arcane/iris/generation/stage/IrisTerrainNormalActuatorHydrologyTest.java @@ -24,6 +24,7 @@ import art.arcane.volmlib.util.stream.ProceduralStream; import art.arcane.volmlib.util.stream.interpolation.Interpolated; import org.junit.Rule; import org.junit.Test; +import org.junit.Before; import java.util.ArrayList; import java.util.List; @@ -51,6 +52,14 @@ public class IrisTerrainNormalActuatorHydrologyTest { @Rule public final PlatformBinding platform = PlatformBinding.mockPlatform(); + private final NativeBlockState submergedDirt = state("minecraft:dirt"); + private final NativeBlockState submergedNetherrack = state("minecraft:netherrack"); + + @Before + public void bindSubmergedSoils() { + when(platform.registries().block("minecraft:dirt")).thenReturn(submergedDirt); + when(platform.registries().block("minecraft:netherrack")).thenReturn(submergedNetherrack); + } @Test public void hydrologyOwnedFluidBypassesBiomeSeaLayers() { @@ -100,7 +109,7 @@ public class IrisTerrainNormalActuatorHydrologyTest { Hunk output = painter.paint(); - assertSame(role.name(), painter.grass, output.get(0, GROUND, 0)); + assertSame(role.name(), submergedDirt, output.get(0, GROUND, 0)); for (int y = GROUND + 1; y <= HEAD; y++) { assertSame(role + " y " + y, painter.channelFluid, output.get(0, y, 0)); } @@ -117,7 +126,7 @@ public class IrisTerrainNormalActuatorHydrologyTest { Hunk sea = ocean.paint(); - assertSame("the sea floor keeps the biome's own top", ocean.grass, sea.get(0, GROUND, 0)); + assertSame("the sea floor keeps a submerged soil top", submergedDirt, sea.get(0, GROUND, 0)); assertSame(ocean.dirt, sea.get(0, GROUND - 1, 0)); assertSame("the slot takes the sea layers", ocean.ice, sea.get(0, HEAD, 0)); for (int y = GROUND + 1; y < HEAD; y++) { @@ -178,7 +187,7 @@ public class IrisTerrainNormalActuatorHydrologyTest { NativeBlockState expectedTop = switch (role) { case NONE -> painter.grass; case CHANNEL, POND, MOUTH, CASCADE, WATERFALL -> painter.bed; - case INLET -> painter.grass; + case INLET -> submergedDirt; case SHORE, POND_SHORE, DAM -> painter.shore; case BANK -> painter.bank; }; @@ -192,11 +201,68 @@ public class IrisTerrainNormalActuatorHydrologyTest { } materials.setBank(null).setShore(null).setBed(null); output = painter.paint(); - assertSame(role.name(), painter.grass, output.get(0, GROUND, 0)); + assertSame(role.name(), role.wet() ? submergedDirt : painter.grass, output.get(0, GROUND, 0)); assertSame(role.name(), painter.dirt, output.get(0, GROUND - 1, 0)); } } + @Test + public void submergedSoilLayersAndExplicitPalettesUseStableSubstrates() { + for (String key : List.of("grass_block[snowy=false]", "mycelium", "dirt_path", "farmland[moisture=7]", + "crimson_nylium", "warped_nylium")) { + NativeBlockState livingSoil = state("minecraft:" + key); + NativeBlockState expected = key.endsWith("nylium") ? submergedNetherrack : submergedDirt; + for (WaterRole role : WET) { + Painter painter = new Painter(role, HEAD); + painter.layers(livingSoil, livingSoil); + painter.materials(true).setBed(painter.palette(livingSoil)); + + Hunk output = painter.paint(); + + assertSame(role + " " + key, expected, output.get(0, GROUND, 0)); + assertSame(role + " submerged side " + key, expected, output.get(0, GROUND - 1, 0)); + assertSame(painter.channelFluid, output.get(0, GROUND + 1, 0)); + } + for (WaterRole role : List.of(WaterRole.NONE, WaterRole.BANK, WaterRole.SHORE, WaterRole.POND_SHORE)) { + Painter painter = new Painter(role, GROUND); + painter.layers(livingSoil, livingSoil); + assertSame(role + " " + key, livingSoil, painter.paint().get(0, GROUND, 0)); + } + } + } + + @Test + public void submergedImageMapSoilUsesStableSubstrate() { + Painter painter = new Painter(WaterRole.CHANNEL, HEAD); + when(painter.complex.getImageMapRuntime().sampleSurfaceBlock(11, -4)).thenReturn(painter.grass); + + assertSame(submergedDirt, painter.paint().get(0, GROUND, 0)); + } + + @Test + public void submergedPodzolAndMossKeepTheirAuthoredMaterials() { + for (String key : List.of("podzol[snowy=false]", "moss_block", "mud", "clay")) { + Painter painter = new Painter(WaterRole.POND, HEAD); + NativeBlockState stable = state("minecraft:" + key); + painter.layers(stable, stable); + Hunk output = painter.paint(); + assertSame(key, stable, output.get(0, GROUND, 0)); + assertSame(key, stable, output.get(0, GROUND - 1, 0)); + } + } + + @Test + public void dryLowerTerrainSpansKeepTheirLivingSoilsBelowAWetBed() { + Painter painter = new Painter(WaterRole.POND, HEAD); + painter.carved(); + + Hunk output = painter.paint(); + + assertSame(submergedDirt, output.get(0, GROUND, 0)); + assertSame(painter.grass, output.get(0, 40, 0)); + assertSame(painter.dirt, output.get(0, 39, 0)); + } + @Test public void sandSurvivesUnlessTheMaterialsKeepFallingBlocksAwayFromTheWater() { for (WaterRole role : List.of(WaterRole.CHANNEL, WaterRole.POND, WaterRole.SHORE, WaterRole.BANK)) { diff --git a/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticCaptureTest.java b/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticCaptureTest.java index 1b0fe315f..02ec818a0 100644 --- a/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticCaptureTest.java +++ b/core/src/test/java/art/arcane/iris/world/history/GenerationSemanticCaptureTest.java @@ -484,7 +484,7 @@ public final class GenerationSemanticCaptureTest { ChunkGenerationSemantics pond = GenerationSemanticCapture.capture(fixture.engine, 0, 0, 5L); for (ChunkGenerationSemantics.RiverFeatureOccurrence occurrence : pond.riverFeatures()) { assertEquals("water", occurrence.profileKey()); - assertEquals(63 - 64, occurrence.position().y()); + assertEquals(62 - 64, occurrence.position().y()); assertEquals(WaterRole.POND, hydrology.runtime.raster(0, 0).role(occurrence.position().x() & 15, occurrence.position().z() & 15)); } diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockMaps.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockMaps.java new file mode 100644 index 000000000..b68bcd53c --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockMaps.java @@ -0,0 +1,92 @@ +package art.arcane.iris.probe; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.BufferedWriter; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class HydrologyBlockMaps { + private HydrologyBlockMaps() { + } + + static List write(HydrologyBlockProbe.Configuration configuration, + Map columns, HydrologyVoxels.Volume volume, + int worldHeight) throws IOException { + int minimumX = configuration.minimumChunkX() << 4; + int minimumZ = configuration.minimumChunkZ() << 4; + int width = (configuration.maximumChunkX() - configuration.minimumChunkX() + 1) << 4; + int depth = (configuration.maximumChunkZ() - configuration.minimumChunkZ() + 1) << 4; + BufferedImage raw = new BufferedImage(width, depth, BufferedImage.TYPE_INT_RGB); + BufferedImage effective = new BufferedImage(width, depth, BufferedImage.TYPE_INT_RGB); + BufferedImage loss = new BufferedImage(width, depth, BufferedImage.TYPE_INT_RGB); + BufferedImage fill = new BufferedImage(width, depth, BufferedImage.TYPE_INT_RGB); + File csv = new File(configuration.output(), "columns.csv"); + try (BufferedWriter writer = Files.newBufferedWriter(csv.toPath(), StandardCharsets.UTF_8)) { + writer.write("x,z,role,natural_height,raster_terrain,effective_terrain,raster_head,effective_head,water_lost,terrain_delta,wet_bed_raise,generated_fill_solids,lowest_neighbor_head,known_neighbors\n"); + for (int z = 0; z < depth; z++) { + for (int x = 0; x < width; x++) { + int blockX = minimumX + x; + int blockZ = minimumZ + z; + HydrologyBlockProbe.PlannedColumn column = columns.get(HydrologyVoxels.columnKey(blockX, blockZ)); + boolean lost = column.rawHead() != HydrologyVoxels.NO_WATER && column.head() == HydrologyVoxels.NO_WATER; + int raised = column.wetBedRaise(); + HydrologyBlockProbe.BedNeighbours neighbours = HydrologyBlockProbe.bedNeighbours(columns, blockX, blockZ); + raw.setRGB(x, z, relief(columns, column, blockX, blockZ, false, worldHeight)); + effective.setRGB(x, z, relief(columns, column, blockX, blockZ, true, worldHeight)); + loss.setRGB(x, z, lost ? 0xD33B31 : column.head() != HydrologyVoxels.NO_WATER ? 0x247AC4 : 0xC4C4C4); + fill.setRGB(x, z, raised > 0 ? (0xFF0000 | (Math.max(0, 224 - 28 * raised) << 8) | 0x20) + : column.head() != HydrologyVoxels.NO_WATER ? 0x247AC4 : 0xC4C4C4); + writer.write(blockX + "," + blockZ + "," + column.role() + "," + column.natural() + "," + column.terrain() + "," + + column.effectiveTerrain() + "," + waterHead(column.rawHead()) + "," + waterHead(column.head()) + "," + lost + "," + + (column.effectiveTerrain() - column.terrain()) + "," + raised + "," + + HydrologyBlockProbe.raisedBedSolids(column, volume, blockX, blockZ) + "," + + waterHead(neighbours.lowestHead()) + "," + neighbours.known() + "\n"); + } + } + } + List artifacts = new ArrayList<>(); + artifacts.add(csv.getAbsolutePath()); + artifacts.add(writeImage(configuration.output(), "raster-bed.png", raw)); + artifacts.add(writeImage(configuration.output(), "effective-bed.png", effective)); + artifacts.add(writeImage(configuration.output(), "water-loss.png", loss)); + artifacts.add(writeImage(configuration.output(), "bed-fill.png", fill)); + return List.copyOf(artifacts); + } + + private static String waterHead(int head) { + return head == HydrologyVoxels.NO_WATER ? "" : Integer.toString(head); + } + + private static String writeImage(File output, String name, BufferedImage image) throws IOException { + File file = new File(output, name); + ImageIO.write(image, "png", file); + return file.getAbsolutePath(); + } + + private static int relief(Map columns, HydrologyBlockProbe.PlannedColumn column, + int x, int z, boolean effective, int worldHeight) { + int height = effective ? column.effectiveTerrain() : column.terrain(); + int head = effective ? column.head() : column.rawHead(); + double dx = height(columns, x + 1, z, effective, height) - height(columns, x - 1, z, effective, height); + double dz = height(columns, x, z + 1, effective, height) - height(columns, x, z - 1, effective, height); + double light = Math.clamp((dx + dz + 4D) / Math.sqrt(6D * (dx * dx + dz * dz + 4D)), 0D, 1D); + double shade = (0.5D + 0.5D * light) * (Math.floorMod(height, 4) == 0 ? 0.82D : 1D); + double altitude = Math.clamp(height / (double) Math.max(1, worldHeight - 1), 0D, 1D); + boolean wet = head != HydrologyVoxels.NO_WATER; + int red = (int) Math.round(shade * (wet ? 50D + 60D * altitude : 160D + 50D * altitude)); + int green = (int) Math.round(shade * (wet ? 130D + 65D * altitude : 145D + 60D * altitude)); + int blue = (int) Math.round(shade * (wet ? 190D + 50D * altitude : 110D + 80D * altitude)); + return (red << 16) | (green << 8) | blue; + } + + private static int height(Map columns, int x, int z, boolean effective, int fallback) { + HydrologyBlockProbe.PlannedColumn column = columns.get(HydrologyVoxels.columnKey(x, z)); + return column == null ? fallback : effective ? column.effectiveTerrain() : column.terrain(); + } +} diff --git a/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockProbe.java b/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockProbe.java index fedc6e7ad..30bb10574 100644 --- a/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockProbe.java +++ b/probe/src/main/java/art/arcane/iris/probe/HydrologyBlockProbe.java @@ -66,6 +66,14 @@ public final class HydrologyBlockProbe { static final class Evidence { long plannedWetColumns; + long effectiveWetColumns; + long lostWetColumns; + long raisedWetBedColumns; + long raisedWetBedBlocks; + int maximumWetBedRaise; + long solidRaisedBedBlocks; + long dropAdjacentRaisedWetBedColumns; + long censoredRaisedWetBedColumns; long channelColumns; long pondColumns; long bankColumns; @@ -88,7 +96,16 @@ public final class HydrologyBlockProbe { List artifacts) { } - record PlannedColumn(WaterRole role, int terrain, int natural, int head) { + record PlannedColumn(WaterRole role, int terrain, int natural, int rawHead, int effectiveTerrain, int head) { + int wetBedRaise() { + return rawHead == HydrologyVoxels.NO_WATER || head == HydrologyVoxels.NO_WATER ? 0 : Math.max(0, effectiveTerrain - terrain); + } + } + + record BedNeighbours(int lowestHead, int known) { + boolean dropBelow(int head) { + return lowestHead != HydrologyVoxels.NO_WATER && lowestHead <= head - 2; + } } public static void main(String[] args) { @@ -96,8 +113,10 @@ public final class HydrologyBlockProbe { try { Result result = run(Configuration.parse(args)); Evidence evidence = result.evidence(); - System.out.println("IRIS_HYDROLOGY_BLOCK_RESULT version=2 status=" + result.status() + " seed=" + result.configuration().seed() + System.out.println("IRIS_HYDROLOGY_BLOCK_RESULT version=4 status=" + result.status() + " seed=" + result.configuration().seed() + " plannedWetColumns=" + evidence.plannedWetColumns + " missingSurfaceColumns=" + evidence.scan.missingSurfaces + + " effectiveWetColumns=" + evidence.effectiveWetColumns + " lostWetColumns=" + evidence.lostWetColumns + + " raisedWetBedColumns=" + evidence.raisedWetBedColumns + " raisedWetBedBlocks=" + evidence.raisedWetBedBlocks + " containmentLeaks=" + evidence.scan.containmentLeaks + " tickConversionCells=" + evidence.scan.tickConversionCells + " cane=" + evidence.validCaneRoots + " failures=" + result.failures().size() + " output=" + result.configuration().output().getAbsolutePath()); @@ -135,7 +154,8 @@ public final class HydrologyBlockProbe { failures.add("The engine reported " + reports.size() + " generation errors."); } Files.createDirectories(configuration.output().toPath()); - List artifacts = writeImages(configuration, volume, engine); + List artifacts = new ArrayList<>(writeImages(configuration, volume, engine)); + artifacts.addAll(HydrologyBlockMaps.write(configuration, planned, volume, engine.getHeight())); Result result = new Result(failures.isEmpty() ? "PASS" : "FAIL", configuration, seaLevel, evidence, List.copyOf(failures), artifacts); Files.writeString(new File(configuration.output(), "hydrology-blocks.json").toPath(), json(result), StandardCharsets.UTF_8); return result; @@ -155,7 +175,9 @@ public final class HydrologyBlockProbe { int head = heads[(lz << 4) | lx]; int natural = complex.naturalTrueHeight(x, z); int terrain = own.empty() ? natural : own.terrainHeight(lx, lz); - planned.put(HydrologyVoxels.columnKey(x, z), new PlannedColumn(own.role(lx, lz), terrain, natural, head)); + int effectiveTerrain = (int) Math.round(complex.getRawHeightStream().getDouble(x, z)); + planned.put(HydrologyVoxels.columnKey(x, z), new PlannedColumn(own.role(lx, lz), terrain, natural, + own.waterHead(lx, lz), effectiveTerrain, head)); } } } @@ -203,8 +225,15 @@ public final class HydrologyBlockProbe { default -> { } } - if (column.head() != HydrologyVoxels.NO_WATER) { - evidence.plannedWetColumns++; + inspectWater(column, x, z, evidence); + if (column.wetBedRaise() > 0) { + evidence.solidRaisedBedBlocks += raisedBedSolids(column, volume, x, z); + BedNeighbours neighbours = bedNeighbours(planned, x, z); + if (neighbours.dropBelow(column.head())) { + evidence.dropAdjacentRaisedWetBedColumns++; + } else if (neighbours.known() < CARDINALS.length) { + evidence.censoredRaisedWetBedColumns++; + } } if (HydrologyBlockMetrics.scanned(columns, x, z)) { evidence.scan.column(volume, x, z, HydrologyBlockMetrics.scanFloor(columns, seaLevel, x, z), height - 1); @@ -214,6 +243,58 @@ public final class HydrologyBlockProbe { return evidence; } + static void inspectWater(PlannedColumn column, int x, int z, Evidence evidence) { + if (column.head() != HydrologyVoxels.NO_WATER) { + evidence.effectiveWetColumns++; + } + if (column.rawHead() == HydrologyVoxels.NO_WATER) { + return; + } + evidence.plannedWetColumns++; + if (column.head() == HydrologyVoxels.NO_WATER) { + evidence.lostWetColumns++; + evidence.example("lost planned water at " + x + "," + column.rawHead() + "," + z + " role=" + column.role() + + " bed=" + column.terrain() + " effectiveGround=" + column.effectiveTerrain()); + } else if (column.wetBedRaise() > 0) { + int raised = column.wetBedRaise(); + evidence.raisedWetBedColumns++; + evidence.raisedWetBedBlocks += raised; + evidence.maximumWetBedRaise = Math.max(evidence.maximumWetBedRaise, raised); + evidence.example("raised wet bed at " + x + "," + z + " role=" + column.role() + " rasterGround=" + column.terrain() + + " effectiveGround=" + column.effectiveTerrain() + " fill=" + raised + " remainingDepth=" + + (column.head() - column.effectiveTerrain())); + } + } + + static int raisedBedSolids(PlannedColumn column, HydrologyVoxels.Volume volume, int x, int z) { + if (column.wetBedRaise() == 0) { + return 0; + } + int solids = 0; + for (int y = column.terrain() + 1; y <= column.effectiveTerrain(); y++) { + if (volume.at(x, y, z) == HydrologyVoxels.SOLID) { + solids++; + } + } + return solids; + } + + static BedNeighbours bedNeighbours(Map columns, int x, int z) { + int lowest = Integer.MAX_VALUE; + int known = 0; + for (int[] side : CARDINALS) { + PlannedColumn neighbour = columns.get(HydrologyVoxels.columnKey(x + side[0], z + side[1])); + if (neighbour == null) { + continue; + } + known++; + if (neighbour.head() != HydrologyVoxels.NO_WATER) { + lowest = Math.min(lowest, neighbour.head()); + } + } + return new BedNeighbours(lowest == Integer.MAX_VALUE ? HydrologyVoxels.NO_WATER : lowest, known); + } + private static void inspectCane(HydrologyVoxels.Volume volume, int x, int z, int height, Evidence evidence) { for (int y = 1; y < height; y++) { if (volume.at(x, y, z) != HydrologyVoxels.CANE || volume.at(x, y - 1, z) == HydrologyVoxels.CANE) { @@ -249,6 +330,13 @@ public final class HydrologyBlockProbe { if (evidence.invalidCaneSubstrates > 0 || evidence.dryCaneRoots > 0) { failures.add("Generated sugar cane has invalid substrate or no adjacent water."); } + if (evidence.lostWetColumns > 0) { + failures.add("Water settling removes planned water from " + evidence.lostWetColumns + " columns in fresh generation."); + } + if (evidence.raisedWetBedColumns > 0) { + failures.add("Water settling fills " + evidence.raisedWetBedBlocks + " blocks above the raster bed in " + + evidence.raisedWetBedColumns + " wet columns in fresh generation."); + } if (evidence.scan.missingSurfaces > 0) { failures.add("Generated blocks miss the planned water surface in " + evidence.scan.missingSurfaces + " columns."); } @@ -288,6 +376,14 @@ public final class HydrologyBlockProbe { Evidence evidence = result.evidence(); Map counts = new LinkedHashMap<>(); counts.put("plannedWetColumns", evidence.plannedWetColumns); + counts.put("effectiveWetColumns", evidence.effectiveWetColumns); + counts.put("lostWetColumns", evidence.lostWetColumns); + counts.put("raisedWetBedColumns", evidence.raisedWetBedColumns); + counts.put("raisedWetBedBlocks", evidence.raisedWetBedBlocks); + counts.put("maximumWetBedRaise", evidence.maximumWetBedRaise); + counts.put("solidRaisedBedBlocks", evidence.solidRaisedBedBlocks); + counts.put("dropAdjacentRaisedWetBedColumns", evidence.dropAdjacentRaisedWetBedColumns); + counts.put("censoredRaisedWetBedColumns", evidence.censoredRaisedWetBedColumns); counts.put("missingSurfaceColumns", evidence.scan.missingSurfaces); counts.put("channelColumns", evidence.channelColumns); counts.put("pondColumns", evidence.pondColumns); @@ -313,6 +409,9 @@ public final class HydrologyBlockProbe { report.put("artifacts", result.artifacts()); report.put("coordinateConvention", "Block X/Z are world coordinates; Y is relative to the world minimum height."); report.put("imageLegend", "open=light blue,water=blue,lava=orange,cane=lime,solid=gray"); + report.put("bedMapLegend", "raster-bed and effective-bed show elevation, slope shading and four-block contours on the same world-height scale; water-loss: red=planned wet made dry, blue=retained water, gray=dry"); + report.put("bedFillLegend", "bed-fill: yellow to red=one to eight or more blocks of wet bed fill, blue=wet bed retained, gray=dry; dry bank and dam fill is excluded"); + report.put("columnData", "columns.csv is ordered by Z then X; blank water heads mean dry; heights are relative to the world minimum height"); return new GsonBuilder().setPrettyPrinting().create().toJson(report) + "\n"; } diff --git a/probe/src/test/java/art/arcane/iris/probe/HydrologyBlockProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/HydrologyBlockProbeTest.java index 1927020eb..e481c5946 100644 --- a/probe/src/test/java/art/arcane/iris/probe/HydrologyBlockProbeTest.java +++ b/probe/src/test/java/art/arcane/iris/probe/HydrologyBlockProbeTest.java @@ -1,13 +1,28 @@ package art.arcane.iris.probe; +import art.arcane.iris.generation.hydrology.raster.WaterRole; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.File; +import java.nio.file.Files; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; public class HydrologyBlockProbeTest { + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + @Test public void failuresNameMissingSurfaceLeaksTicksAndCoverage() { HydrologyBlockProbe.Evidence evidence = new HydrologyBlockProbe.Evidence(); @@ -21,4 +36,110 @@ public class HydrologyBlockProbeTest { "Fluid ticks would move generated water in 4 cells.", "Required generated coverage is absent: pond"), HydrologyBlockProbe.failures(evidence, Set.of("channel", "pond"))); } + + @Test + public void dryRidgesFailEvenWhenTheRemainingWaterIsSealed() { + HydrologyBlockProbe.Evidence evidence = new HydrologyBlockProbe.Evidence(); + HydrologyBlockProbe.inspectWater(new HydrologyBlockProbe.PlannedColumn(WaterRole.CHANNEL, 77, 82, 79, 80, + HydrologyVoxels.NO_WATER), -103, -1391, evidence); + HydrologyBlockProbe.inspectWater(new HydrologyBlockProbe.PlannedColumn(WaterRole.WATERFALL, 70, 82, 79, 70, 79), + -102, -1391, evidence); + HydrologyBlockProbe.inspectWater(new HydrologyBlockProbe.PlannedColumn(WaterRole.BANK, 82, 82, HydrologyVoxels.NO_WATER, + 82, HydrologyVoxels.NO_WATER), -104, -1391, evidence); + + assertEquals(2L, evidence.plannedWetColumns); + assertEquals(1L, evidence.effectiveWetColumns); + assertEquals(1L, evidence.lostWetColumns); + assertEquals(List.of("Water settling removes planned water from 1 columns in fresh generation."), + HydrologyBlockProbe.failures(evidence, Set.of())); + assertEquals(List.of("lost planned water at -103,79,-1391 role=CHANNEL bed=77 effectiveGround=80"), evidence.examples); + } + + @Test + public void submergedChannelAndPondFillFailsWithoutAnyLostSurfaceWater() { + HydrologyBlockProbe.PlannedColumn channel = new HydrologyBlockProbe.PlannedColumn(WaterRole.CHANNEL, 318, 330, 326, 324, 326); + HydrologyBlockProbe.PlannedColumn pond = new HydrologyBlockProbe.PlannedColumn(WaterRole.POND, 316, 330, 326, 324, 326); + HydrologyBlockProbe.Evidence evidence = new HydrologyBlockProbe.Evidence(); + HydrologyBlockProbe.inspectWater(channel, -372, -441, evidence); + HydrologyBlockProbe.inspectWater(pond, -371, -441, evidence); + HydrologyBlockProbe.inspectWater(new HydrologyBlockProbe.PlannedColumn(WaterRole.DAM, 318, 318, HydrologyVoxels.NO_WATER, + 330, HydrologyVoxels.NO_WATER), -373, -441, evidence); + + assertEquals(0L, evidence.lostWetColumns); + assertEquals(2L, evidence.effectiveWetColumns); + assertEquals(2L, evidence.raisedWetBedColumns); + assertEquals(14L, evidence.raisedWetBedBlocks); + assertEquals(8, evidence.maximumWetBedRaise); + assertEquals(List.of("Water settling fills 14 blocks above the raster bed in 2 wet columns in fresh generation."), + HydrologyBlockProbe.failures(evidence, Set.of())); + assertEquals("raised wet bed at -372,-441 role=CHANNEL rasterGround=318 effectiveGround=324 fill=6 remainingDepth=2", + evidence.examples.getFirst()); + + HydrologyVoxels.ChunkBlocks chunk = HydrologyVoxels.ChunkBlocks.empty(-24, -28, 384); + for (int y = 319; y <= 324; y++) { + chunk.set(12, y, 7, HydrologyVoxels.SOLID); + } + chunk.set(12, 325, 7, HydrologyVoxels.WATER); + HydrologyVoxels.Volume volume = new HydrologyVoxels.Volume(); + volume.put(chunk); + assertEquals(6, HydrologyBlockProbe.raisedBedSolids(channel, volume, -372, -441)); + assertEquals(0, HydrologyBlockProbe.raisedBedSolids(pond, volume, -371, -441)); + + HydrologyBlockProbe.BedNeighbours neighbours = HydrologyBlockProbe.bedNeighbours(Map.of( + HydrologyVoxels.columnKey(-373, -441), new HydrologyBlockProbe.PlannedColumn(WaterRole.CHANNEL, 316, 330, 324, 316, 324)), + -372, -441); + assertTrue(neighbours.dropBelow(channel.head())); + assertEquals(1, neighbours.known()); + assertEquals(324, neighbours.lowestHead()); + } + + @Test + public void columnDataAndBedImagesExposeHeightChangesBeneathOneWaterSurface() throws Exception { + File output = folder.newFolder("columns"); + HydrologyBlockProbe.Configuration configuration = new HydrologyBlockProbe.Configuration(new File("/synthetic-pack"), "overworld", + 1337L, -1, -1, -1, -1, Set.of(), output, false); + Map columns = new HashMap<>(); + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + boolean lost = x == 4 && z == 5; + boolean dry = x == 0 && z == 0; + boolean raised = x == 6 && z == 5; + columns.put(HydrologyVoxels.columnKey(x - 16, z - 16), new HydrologyBlockProbe.PlannedColumn( + dry ? WaterRole.BANK : WaterRole.CHANNEL, 60 + x, 120, dry ? HydrologyVoxels.NO_WATER : 100, + lost ? 101 : raised ? 72 : 60 + x, lost || dry ? HydrologyVoxels.NO_WATER : 100)); + } + } + HydrologyVoxels.ChunkBlocks chunk = HydrologyVoxels.ChunkBlocks.empty(-1, -1, 128); + for (int y = 67; y <= 72; y++) { + chunk.set(6, y, 5, HydrologyVoxels.SOLID); + } + HydrologyVoxels.Volume volume = new HydrologyVoxels.Volume(); + volume.put(chunk); + + List artifacts = HydrologyBlockMaps.write(configuration, columns, volume, 320); + + assertEquals(5, artifacts.size()); + List csv = Files.readAllLines(new File(output, "columns.csv").toPath()); + assertEquals(257, csv.size()); + assertEquals("x,z,role,natural_height,raster_terrain,effective_terrain,raster_head,effective_head,water_lost,terrain_delta,wet_bed_raise,generated_fill_solids,lowest_neighbor_head,known_neighbors", csv.getFirst()); + assertEquals("-16,-16,BANK,120,60,60,,,false,0,0,0,100,2", csv.get(1)); + assertTrue(csv.contains("-12,-11,CHANNEL,120,64,101,100,,true,37,0,0,100,4")); + assertTrue(csv.contains("-10,-11,CHANNEL,120,66,72,100,100,false,6,6,6,100,4")); + assertFalse(String.join("\n", csv).contains(Integer.toString(HydrologyVoxels.NO_WATER))); + BufferedImage raw = ImageIO.read(new File(output, "raster-bed.png")); + BufferedImage effective = ImageIO.read(new File(output, "effective-bed.png")); + BufferedImage loss = ImageIO.read(new File(output, "water-loss.png")); + BufferedImage fill = ImageIO.read(new File(output, "bed-fill.png")); + assertEquals(16, raw.getWidth()); + assertEquals(16, raw.getHeight()); + assertNotEquals(raw.getRGB(1, 10), raw.getRGB(13, 10)); + assertNotEquals(raw.getRGB(4, 5), effective.getRGB(4, 5)); + assertEquals(raw.getRGB(10, 10), effective.getRGB(10, 10)); + assertEquals(0xD33B31, loss.getRGB(4, 5) & 0xFFFFFF); + assertEquals(0x247AC4, loss.getRGB(10, 10) & 0xFFFFFF); + assertEquals(0xC4C4C4, loss.getRGB(0, 0) & 0xFFFFFF); + assertEquals(0xFF3820, fill.getRGB(6, 5) & 0xFFFFFF); + assertEquals(0x247AC4, fill.getRGB(10, 10) & 0xFFFFFF); + assertEquals(0xC4C4C4, fill.getRGB(0, 0) & 0xFFFFFF); + } }