diff --git a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java index e5dbed055..04976f70d 100644 --- a/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java +++ b/core/src/test/java/art/arcane/iris/engine/hydrology/HydrologyModelTest.java @@ -266,7 +266,7 @@ public class HydrologyModelTest { } @Test - public void outerGradingUsesTheExactParentBiome() { + public void outerGradingUsesTheBankBiome() { HydrologyFeatureRef feature = feature(HydrologyFeatureType.SURFACE_POOL, 3L, 70); HydrologyColumnLayer grading = layer(feature, 72, 72, 72, false, false, false); grading = new HydrologyColumnLayer( @@ -292,7 +292,7 @@ public class HydrologyModelTest { ); HydrologyColumnSample sample = new HydrologyColumnSample(0, 0, 74, 63, false, "exact_parent", List.of(grading)); - assertTrue(grading.biomeKey() == null); + assertEquals("dry", grading.biomeKey()); assertEquals("exact_parent", sample.parentBiomeKey()); assertFalse(sample.hasConnectedFluid()); } diff --git a/probe/build.gradle b/probe/build.gradle index 952062d96..0cd889d66 100644 --- a/probe/build.gradle +++ b/probe/build.gradle @@ -82,6 +82,9 @@ Provider probeMaximumChunkZ = providers.gradleProperty('probeMaximumChun Provider probeParallelism = providers.gradleProperty('probeParallelism') Provider probeShuffleSeed = providers.gradleProperty('probeShuffleSeed') Provider probeJfr = providers.gradleProperty('probeJfr') +Provider probeTileX = providers.gradleProperty('probeTileX') +Provider probeTileZ = providers.gradleProperty('probeTileZ') +Provider probeOutput = providers.gradleProperty('probeOutput') tasks.register('genProbe', JavaExec) { group = 'verification' @@ -187,7 +190,39 @@ tasks.register('generationOrderProbe', JavaExec) { } } -['hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe'].each { String taskName -> +tasks.register('riverTransectProbe', JavaExec) { + group = 'verification' + description = 'Plans one hydrology tile over a real pack and renders every surface river as a plan, cross-sections and a summary with ocean-write and containment gates.' + mainClass = 'art.arcane.iris.probe.RiverTransectProbe' + classpath = sourceSets.main.runtimeClasspath + jvmArgs('--add-modules', 'jdk.incubator.vector', '-Djava.awt.headless=true') + dependsOn(':core:compileJava') + doFirst { + Map> required = [ + probePack: probePack, + probeDimension: probeDimension, + probeSeed: probeSeed, + probeTileX: probeTileX, + probeTileZ: probeTileZ, + probeOutput: probeOutput + ] + required.each { String name, Provider value -> + if (!value.isPresent() || value.get().isBlank()) { + throw new GradleException("riverTransectProbe requires -P${name}=") + } + } + args( + probePack.get(), + probeDimension.get(), + probeSeed.get(), + probeTileX.get(), + probeTileZ.get(), + probeOutput.get(), + probeStudio.get()) + } +} + +['hydrologyProbe', 'genProbe', 'hydrologyPackProbe', 'generationOrderProbe', 'riverTransectProbe'].each { String taskName -> tasks.named(taskName, JavaExec).configure { JavaExec task -> doFirst { if (probeJfr.isPresent()) { diff --git a/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java new file mode 100644 index 000000000..6fe96d538 --- /dev/null +++ b/probe/src/main/java/art/arcane/iris/probe/RiverTransectProbe.java @@ -0,0 +1,473 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.IrisComplex; +import art.arcane.iris.engine.framework.Engine; +import art.arcane.iris.engine.hydrology.HydraulicSegment; +import art.arcane.iris.engine.hydrology.HydrologyColumnLayer; +import art.arcane.iris.engine.hydrology.HydrologyColumnSample; +import art.arcane.iris.engine.hydrology.HydrologyPoint; +import art.arcane.iris.engine.hydrology.HydrologyTile; +import art.arcane.iris.engine.hydrology.HydrologyTileKey; +import art.arcane.iris.engine.hydrology.RiverCourse; +import art.arcane.iris.engine.hydrology.RiverCourseType; +import art.arcane.iris.engine.hydrology.RiverFootprint; +import art.arcane.iris.engine.hydrology.runtime.IrisHydrologyRuntime; +import art.arcane.iris.engine.hydrology.surface.SurfaceCenterline; +import art.arcane.iris.engine.hydrology.surface.SurfaceFootprintCompiler; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +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.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * Plans one hydrology tile over a real pack and renders every exposed surface course as a + * top-down plan plus five cross-sections, with a summary of cut depth, bank steps, ocean writes + * and spilling channel cells. Exit code 1 when any course writes the ocean or spills. + */ +public final class RiverTransectProbe { + static final int NO_WATER = Integer.MIN_VALUE; + private static final String PREFIX = "[transect]"; + private static final int MARGIN = 48; + private static final int HALF_SECTION = 40; + private static final int SECTIONS = 5; + private static final int SECTION_SCALE = 4; + private static final int SECTION_ROW_HEIGHT = 120; + private static final int MAXIMUM_PLAN_COLUMNS = 6_000_000; + + private RiverTransectProbe() { + } + + enum Role { + NONE, + CHANNEL, + SHORE, + BANK, + APRON; + + boolean owned() { + return this == CHANNEL || this == SHORE || this == BANK; + } + } + + record ColumnView(int x, int z, int natural, int terrain, int water, Role role) { + int cut() { + return natural - terrain; + } + } + + record CourseSummary( + long id, + int stations, + int ownedColumns, + int minimumCut, + int maximumCut, + int maximumBankStep, + int oceanWrites, + int uncontainedWetCells + ) { + boolean passes() { + return oceanWrites == 0 && uncontainedWetCells == 0; + } + + String line() { + return String.format(Locale.ROOT, + "%s course=%d %s stations=%d owned=%d cut=%d..%d bankStep=%d oceanWrites=%d uncontained=%d", + PREFIX, id, passes() ? "PASS" : "FAIL", stations, ownedColumns, minimumCut, maximumCut, + maximumBankStep, oceanWrites, uncontainedWetCells); + } + } + + record Configuration(File pack, String dimension, long seed, int tileX, int tileZ, File output, boolean studio) { + static Configuration parse(String[] args) { + if (args.length < 6 || args.length > 7) { + throw new IllegalArgumentException( + "usage: [studio]"); + } + boolean studio = args.length == 7 && RealPackProbeSupport.parseBoolean(args[6], "studio"); + return new Configuration( + new File(args[0]), + args[1], + Long.parseLong(args[2].trim()), + Integer.parseInt(args[3].trim()), + Integer.parseInt(args[4].trim()), + new File(args[5]), + studio + ); + } + } + + private record Bounds(int minimumX, int minimumZ, int maximumX, int maximumZ) { + static Bounds of(SurfaceCenterline centerline, int margin) { + int minimumX = Integer.MAX_VALUE; + int minimumZ = Integer.MAX_VALUE; + int maximumX = Integer.MIN_VALUE; + int maximumZ = Integer.MIN_VALUE; + for (int station = 0; station < centerline.size(); station++) { + minimumX = Math.min(minimumX, centerline.x()[station]); + minimumZ = Math.min(minimumZ, centerline.z()[station]); + maximumX = Math.max(maximumX, centerline.x()[station]); + maximumZ = Math.max(maximumZ, centerline.z()[station]); + } + return new Bounds(minimumX - margin, minimumZ - margin, maximumX + margin, maximumZ + margin); + } + + int width() { + return maximumX - minimumX + 1; + } + + int depth() { + return maximumZ - minimumZ + 1; + } + } + + public static void main(String[] args) { + Configuration configuration; + try { + configuration = Configuration.parse(args); + } catch (Throwable failure) { + System.out.println(PREFIX + " FAIL: " + failure.getMessage()); + System.exit(2); + return; + } + int exitCode; + try { + exitCode = run(configuration) ? 0 : 1; + } catch (Throwable failure) { + System.out.println(PREFIX + " FAIL: probe execution failed"); + failure.printStackTrace(System.out); + exitCode = 2; + } + System.exit(exitCode); + } + + static boolean run(Configuration configuration) throws Exception { + try (RealPackProbeSupport.Workspace workspace = RealPackProbeSupport.openWorkspace( + configuration.pack(), configuration.dimension(), PREFIX); + RealPackProbeSupport.EngineSession session = workspace.openEngine( + configuration.seed(), configuration.studio(), "transect")) { + Engine engine = session.engine(); + IrisComplex complex = engine.getComplex(); + IrisHydrologyRuntime runtime = complex.getHydrologyRuntime(); + if (runtime == null) { + throw new IllegalStateException("Dimension '" + configuration.dimension() + "' has no hydrology runtime."); + } + Files.createDirectories(configuration.output().toPath()); + HydrologyTileKey key = new HydrologyTileKey(configuration.tileX(), configuration.tileZ()); + long planStart = System.nanoTime(); + HydrologyTile tile = runtime.tile(key); + double planMillis = (System.nanoTime() - planStart) / 1_000_000D; + int seaLevel = runtime.settings().seaLevel(); + System.out.println(String.format(Locale.ROOT, + "%s tile=%d,%d seed=%d courses=%d planMs=%.1f", + PREFIX, key.tileX(), key.tileZ(), configuration.seed(), tile.courses().size(), planMillis)); + + List summaries = new ArrayList<>(); + for (RiverCourse course : tile.courses()) { + if (course.type() != RiverCourseType.SURFACE) { + continue; + } + List exposedPath = exposedPath(course); + if (exposedPath.size() < 2) { + continue; + } + SurfaceCenterline centerline = SurfaceCenterline.densify(exposedPath); + Bounds bounds = Bounds.of(centerline, MARGIN); + Map columns = sampleColumns(complex, runtime, bounds); + CourseSummary summary = summarize(course.id(), centerline.size(), seaLevel, columns); + summaries.add(summary); + writePlan(new File(configuration.output(), "course-" + course.id() + ".png"), bounds, columns); + writeSections(new File(configuration.output(), "course-" + course.id() + "-sections.png"), + centerline, complex, runtime); + System.out.println(summary.line()); + } + writeSummary(new File(configuration.output(), "summary.txt"), configuration, tile, summaries); + boolean pass = summaries.stream().allMatch(CourseSummary::passes); + System.out.println(PREFIX + " " + (pass ? "PASS" : "FAIL") + + " surfaceCourses=" + summaries.size() + + " output=" + configuration.output().getAbsolutePath()); + return pass; + } + } + + static int[] sectionStations(int stations) { + int[] selected = new int[SECTIONS]; + for (int section = 0; section < SECTIONS; section++) { + double fraction = 0.1D + 0.2D * section; + selected[section] = Math.max(0, Math.min(stations - 1, (int) Math.floor(stations * fraction))); + } + return selected; + } + + static CourseSummary summarize(long id, int stations, int seaLevel, Map columns) { + int owned = 0; + int minimumCut = Integer.MAX_VALUE; + int maximumCut = Integer.MIN_VALUE; + int maximumBankStep = 0; + int oceanWrites = 0; + int uncontained = 0; + for (ColumnView column : columns.values()) { + boolean submerged = column.natural() <= seaLevel; + if (submerged && (column.terrain() != column.natural() || column.role().owned())) { + oceanWrites++; + } + if (!column.role().owned()) { + continue; + } + owned++; + minimumCut = Math.min(minimumCut, column.cut()); + maximumCut = Math.max(maximumCut, column.cut()); + if (column.role() == Role.CHANNEL) { + if (column.water() != NO_WATER && spills(column, columns)) { + uncontained++; + } + continue; + } + maximumBankStep = Math.max(maximumBankStep, bankStep(column, columns.get(RiverFootprint.pack(column.x() + 1, column.z())))); + maximumBankStep = Math.max(maximumBankStep, bankStep(column, columns.get(RiverFootprint.pack(column.x(), column.z() + 1)))); + } + if (owned == 0) { + minimumCut = 0; + maximumCut = 0; + } + return new CourseSummary(id, stations, owned, minimumCut, maximumCut, maximumBankStep, oceanWrites, uncontained); + } + + private static boolean spills(ColumnView channel, Map columns) { + int[][] offsets = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + for (int[] offset : offsets) { + ColumnView neighbour = columns.get(RiverFootprint.pack(channel.x() + offset[0], channel.z() + offset[1])); + if (neighbour == null || neighbour.role() == Role.CHANNEL) { + continue; + } + if (neighbour.terrain() < channel.water()) { + return true; + } + } + return false; + } + + private static int bankStep(ColumnView bank, ColumnView neighbour) { + if (neighbour == null || neighbour.role() == Role.CHANNEL) { + return 0; + } + return Math.abs(bank.terrain() - neighbour.terrain()); + } + + private static List exposedPath(RiverCourse course) { + ArrayList path = new ArrayList<>(); + for (HydraulicSegment segment : course.segments()) { + if (!SurfaceFootprintCompiler.exposedSegment(segment)) { + break; + } + for (HydrologyPoint point : segment.centerline()) { + if (!path.isEmpty()) { + HydrologyPoint last = path.getLast(); + if (last.x() == point.x() && last.z() == point.z()) { + continue; + } + } + path.add(point); + } + } + return path; + } + + private static Map sampleColumns(IrisComplex complex, IrisHydrologyRuntime runtime, Bounds bounds) { + long area = (long) bounds.width() * bounds.depth(); + if (area > MAXIMUM_PLAN_COLUMNS) { + throw new IllegalStateException("Course bounding box too large to render: " + area + " columns."); + } + HashMap columns = new HashMap<>((int) Math.min(Integer.MAX_VALUE, area * 2)); + for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) { + for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) { + columns.put(RiverFootprint.pack(x, z), column(complex, runtime, x, z)); + } + } + return columns; + } + + private static ColumnView column(IrisComplex complex, IrisHydrologyRuntime runtime, int x, int z) { + Optional sample = runtime.sample(x, z); + int natural = sample.map(HydrologyColumnSample::naturalHeight) + .orElseGet(() -> (int) Math.round(complex.getNaturalHeightStream().getDouble(x, z))); + int terrain = (int) Math.round(complex.getHeightStream().getDouble(x, z)); + HydrologyColumnLayer surface = sample.flatMap(HydrologyColumnSample::primarySurfaceLayer).orElse(null); + HydrologyColumnLayer fluid = sample.flatMap(HydrologyColumnSample::primarySurfaceFluidLayer).orElse(null); + int water = fluid == null ? NO_WATER : fluid.fluidHeadY(); + return new ColumnView(x, z, natural, terrain, water, role(surface)); + } + + private static Role role(HydrologyColumnLayer layer) { + if (layer == null) { + return Role.NONE; + } + if (layer.oceanApron()) { + return Role.APRON; + } + if (layer.channel()) { + return Role.CHANNEL; + } + if (layer.shore()) { + return Role.SHORE; + } + return layer.terrainOwned() ? Role.BANK : Role.NONE; + } + + private static void writePlan(File file, Bounds bounds, Map columns) throws IOException { + int minimumNatural = Integer.MAX_VALUE; + int maximumNatural = Integer.MIN_VALUE; + for (ColumnView column : columns.values()) { + minimumNatural = Math.min(minimumNatural, column.natural()); + maximumNatural = Math.max(maximumNatural, column.natural()); + } + double range = Math.max(1, maximumNatural - minimumNatural); + BufferedImage image = new BufferedImage(bounds.width(), bounds.depth(), BufferedImage.TYPE_INT_RGB); + for (int z = bounds.minimumZ(); z <= bounds.maximumZ(); z++) { + for (int x = bounds.minimumX(); x <= bounds.maximumX(); x++) { + ColumnView column = columns.get(RiverFootprint.pack(x, z)); + int gray = 40 + (int) Math.round(190D * (column.natural() - minimumNatural) / range); + int red = gray; + int green = gray; + int blue = gray; + switch (column.role()) { + case CHANNEL -> { + red = 30; + green = 90; + blue = 220; + } + case SHORE -> { + red = 214; + green = 184; + blue = 122; + } + case BANK -> { + int darken = Math.min(120, column.cut() * 12); + red = Math.max(0, gray - darken + 20); + green = Math.max(0, gray - darken); + blue = Math.max(0, gray - darken - 20); + } + case APRON -> { + red = 120; + green = 170; + blue = 230; + } + case NONE -> { + if (column.terrain() != column.natural()) { + red = 220; + green = 40; + blue = 40; + } + } + } + image.setRGB(x - bounds.minimumX(), z - bounds.minimumZ(), (red << 16) | (green << 8) | blue); + } + } + ImageIO.write(image, "png", file); + } + + private static void writeSections( + File file, + SurfaceCenterline centerline, + IrisComplex complex, + IrisHydrologyRuntime runtime + ) throws IOException { + int[] stations = sectionStations(centerline.size()); + int span = HALF_SECTION * 2 + 1; + int width = span * SECTION_SCALE; + BufferedImage image = new BufferedImage(width, SECTION_ROW_HEIGHT * SECTIONS, BufferedImage.TYPE_INT_RGB); + for (int section = 0; section < SECTIONS; section++) { + int station = stations[section]; + double normalX = centerline.normalX(station); + double normalZ = centerline.normalZ(station); + ColumnView[] profile = new ColumnView[span]; + int minimum = Integer.MAX_VALUE; + int maximum = Integer.MIN_VALUE; + for (int offset = -HALF_SECTION; offset <= HALF_SECTION; offset++) { + int x = (int) Math.round(centerline.x()[station] + normalX * offset); + int z = (int) Math.round(centerline.z()[station] + normalZ * offset); + ColumnView column = column(complex, runtime, x, z); + profile[offset + HALF_SECTION] = column; + minimum = Math.min(minimum, Math.min(column.natural(), column.terrain())); + maximum = Math.max(maximum, Math.max(column.natural(), column.terrain())); + if (column.water() != NO_WATER) { + maximum = Math.max(maximum, column.water()); + } + } + int rowTop = section * SECTION_ROW_HEIGHT; + fill(image, 0, rowTop, width, SECTION_ROW_HEIGHT, 0x1E1E1E); + double scale = (SECTION_ROW_HEIGHT - 12D) / Math.max(1, maximum - minimum + 1); + for (int index = 0; index < span; index++) { + ColumnView column = profile[index]; + int left = index * SECTION_SCALE; + int naturalY = rowY(rowTop, column.natural(), minimum, scale); + int terrainY = rowY(rowTop, column.terrain(), minimum, scale); + fill(image, left, naturalY, SECTION_SCALE, rowTop + SECTION_ROW_HEIGHT - naturalY, 0x555555); + fill(image, left, terrainY, SECTION_SCALE, rowTop + SECTION_ROW_HEIGHT - terrainY, 0x9A7B4F); + if (column.water() != NO_WATER && column.water() >= column.terrain()) { + int waterY = rowY(rowTop, column.water(), minimum, scale); + fill(image, left, waterY, SECTION_SCALE, Math.max(1, terrainY - waterY), 0x2E6BE6); + } + if (column.role() == Role.SHORE) { + fill(image, left, terrainY - 2, SECTION_SCALE, 2, 0xD6B87A); + } + } + fill(image, HALF_SECTION * SECTION_SCALE, rowTop, 1, 6, 0xFFFFFF); + } + ImageIO.write(image, "png", file); + } + + private static int rowY(int rowTop, int height, int minimum, double scale) { + return rowTop + SECTION_ROW_HEIGHT - 6 - (int) Math.round((height - minimum + 1) * scale); + } + + private static void fill(BufferedImage image, int left, int top, int width, int height, int rgb) { + int right = Math.min(image.getWidth(), left + width); + int bottom = Math.min(image.getHeight(), top + height); + for (int y = Math.max(0, top); y < bottom; y++) { + for (int x = Math.max(0, left); x < right; x++) { + image.setRGB(x, y, rgb); + } + } + } + + private static void writeSummary( + File file, + Configuration configuration, + HydrologyTile tile, + List summaries + ) throws IOException { + StringBuilder text = new StringBuilder(); + text.append("tile=").append(tile.key().tileX()).append(',').append(tile.key().tileZ()) + .append(" seed=").append(configuration.seed()) + .append(" dimension=").append(configuration.dimension()) + .append(" courses=").append(tile.courses().size()) + .append(" surfaceCourses=").append(summaries.size()) + .append('\n'); + int maximumCut = 0; + int maximumBankStep = 0; + int oceanWrites = 0; + int uncontained = 0; + for (CourseSummary summary : summaries) { + text.append(summary.line()).append('\n'); + maximumCut = Math.max(maximumCut, summary.maximumCut()); + maximumBankStep = Math.max(maximumBankStep, summary.maximumBankStep()); + oceanWrites += summary.oceanWrites(); + uncontained += summary.uncontainedWetCells(); + } + text.append("maximumCut=").append(maximumCut) + .append(" maximumBankStep=").append(maximumBankStep) + .append(" oceanWrites=").append(oceanWrites) + .append(" uncontainedWetCells=").append(uncontained) + .append('\n'); + Files.writeString(file.toPath(), text.toString(), StandardCharsets.UTF_8); + } +} diff --git a/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java b/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java new file mode 100644 index 000000000..1c3200e87 --- /dev/null +++ b/probe/src/test/java/art/arcane/iris/probe/RiverTransectProbeTest.java @@ -0,0 +1,74 @@ +package art.arcane.iris.probe; + +import art.arcane.iris.engine.hydrology.RiverFootprint; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class RiverTransectProbeTest { + @Test + public void sectionStationsSitAtTenThirtyFiftySeventyAndNinetyPercent() { + assertArrayEquals(new int[] {2, 6, 10, 14, 18}, RiverTransectProbe.sectionStations(20)); + assertArrayEquals(new int[] {0, 0, 0, 0, 0}, RiverTransectProbe.sectionStations(1)); + assertArrayEquals(new int[] {0, 0, 1, 2, 2}, RiverTransectProbe.sectionStations(3)); + } + + @Test + public void summaryCountsCutsBankStepsOceanWritesAndSpillingChannels() { + Map columns = new HashMap<>(); + put(columns, 0, 0, 72, 68, 70, RiverTransectProbe.Role.CHANNEL); + put(columns, 1, 0, 72, 71, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.BANK); + put(columns, -1, 0, 72, 69, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.BANK); + put(columns, 0, 1, 72, 70, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.SHORE); + put(columns, 0, -1, 72, 72, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.NONE); + put(columns, 2, 0, 72, 72, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.NONE); + put(columns, 5, 5, 60, 58, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.NONE); + put(columns, 6, 6, 60, 60, 63, RiverTransectProbe.Role.APRON); + + RiverTransectProbe.CourseSummary summary = RiverTransectProbe.summarize(7L, 12, 63, columns); + + assertEquals(7L, summary.id()); + assertEquals(12, summary.stations()); + assertEquals(4, summary.ownedColumns()); + assertEquals(1, summary.minimumCut()); + assertEquals(4, summary.maximumCut()); + assertEquals(1, summary.maximumBankStep()); + assertEquals(1, summary.oceanWrites()); + assertEquals(1, summary.uncontainedWetCells()); + assertFalse(summary.passes()); + } + + @Test + public void containedCourseAwayFromTheOceanPasses() { + Map columns = new HashMap<>(); + put(columns, 0, 0, 72, 68, 70, RiverTransectProbe.Role.CHANNEL); + put(columns, 1, 0, 72, 71, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.BANK); + put(columns, -1, 0, 72, 71, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.BANK); + put(columns, 0, 1, 72, 70, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.SHORE); + put(columns, 0, -1, 72, 71, RiverTransectProbe.NO_WATER, RiverTransectProbe.Role.SHORE); + + RiverTransectProbe.CourseSummary summary = RiverTransectProbe.summarize(1L, 3, 63, columns); + + assertTrue(summary.passes()); + assertEquals(0, summary.oceanWrites()); + assertEquals(0, summary.uncontainedWetCells()); + } + + private static void put( + Map columns, + int x, + int z, + int natural, + int terrain, + int water, + RiverTransectProbe.Role role + ) { + columns.put(RiverFootprint.pack(x, z), new RiverTransectProbe.ColumnView(x, z, natural, terrain, water, role)); + } +}