diff --git a/config.json b/config.json index 62fe8f19e..32dc716eb 100644 --- a/config.json +++ b/config.json @@ -1983,6 +1983,23 @@ "numbers" ], "difficulty": 3 + }, + { + "slug": "mazy-mice", + "name": "Mazy Mice", + "uuid": "1bac7473-9ee8-4cfc-928b-77792102ffc1", + "practices": [ + "chars", + "strings", + "for-loops", + "arrays" + ], + "prerequisites": [ + "strings", + "for-loops" + ], + "difficulty": 8, + "status": "beta" } ] }, diff --git a/exercises/practice/mazy-mice/.docs/hints.md b/exercises/practice/mazy-mice/.docs/hints.md new file mode 100644 index 000000000..0313b8f52 --- /dev/null +++ b/exercises/practice/mazy-mice/.docs/hints.md @@ -0,0 +1,30 @@ +# Hints + +## General + +- You can use the [Random class][random-class] to generate random numbers. +- Read more in article: [Random Number Generators in Java 17][random-number-generators]. + +## Maze generation + +You can use any algorithm to generate a perfect maze. The [recursive backtracker][recursive-backtracker] is a good choice. + +## Box drawing characters + +| Character | Name | Unicode | +|:---------:|:--------------------------------------|:--------| +| ┌ | box drawings light down and right | U+250C | +| ─ | box drawings light horizontal | U+2500 | +| ┬ | box drawings light down and horizontal| U+252C | +| ┐ | box drawings light down and left | U+2510 | +| │ | box drawings light vertical | U+2502 | +| └ | box drawings light up and right | U+2514 | +| ┴ | box drawings light up and horizontal | U+2534 | +| ┘ | box drawings light up and left | U+2518 | +| ├ | box drawings light vertical and right | U+2520 | +| ⇨ | rightwards white arrow | U+21E8 | + + +[recursive-backtracker]: https://en.wikipedia.org/wiki/Maze_generation_algorithm +[random-class]: https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Random.html +[random-number-generators]: https://www.baeldung.com/java-17-random-number-generators diff --git a/exercises/practice/mazy-mice/.docs/instructions.md b/exercises/practice/mazy-mice/.docs/instructions.md new file mode 100644 index 000000000..b217223a9 --- /dev/null +++ b/exercises/practice/mazy-mice/.docs/instructions.md @@ -0,0 +1,48 @@ +# Instructions + +Your task is to generate the perfect mazes for Mickey and Minerva — those with only one solution and no isolated sections. +Here's what you need to know: + +- The maze has a rectangular shape with an opening at the start and end. +- The maze has rooms and passages, which intersect at right angles. +- The program should accept two parameters: rows and columns. The maze should be between 5 and 100 cells in size. +- A maze which is `x` columns wide and `y` rows high should be `2x + 1` characters wide and `2y + 1` characters high. +- If no seed is provided, generate a random maze. If the same seed is provided multiple times, the resulting maze should be the same each time. +- Use [box-drawing][Box-drawing] characters to draw walls, and an arrow symbol (⇨) for the entrance on the left and exit on the right. + +It's time to create some perfect mazes for these adventurous mice! + +### Examples + +1. A small square maze 5x5 cells (or 11x11 characters) +```text + ┌───────┬─┐ + │ │ │ + │ ┌─┬── │ │ + │ │ │ │ ⇨ + │ │ │ ──┤ │ + ⇨ │ │ │ │ + ┌─┤ └── │ │ + │ │ │ │ + │ │ ────┘ │ + │ │ + └─────────┘ +``` +2. A rectangular maze 6x18 cells +```text + ┌───────────┬─────────┬───────────┬─┐ + │ │ │ │ │ + │ ┌───────┐ │ ┌─┐ ──┐ └───┐ ┌───┐ │ │ + │ │ │ │ │ │ │ │ │ │ ⇨ + │ └─┐ ┌─┐ │ │ │ ├── ├───┐ │ │ ──┼── │ + │ │ │ │ │ │ │ │ │ │ │ │ + └── │ │ ├───┴───┤ ┌─┘ ┌─┘ │ ├── │ ──┤ + ⇨ │ │ │ │ │ │ │ │ │ + ┌─┬─┴─┐ └─┐ ┌─┐ │ └─┐ │ ┌─┘ │ ──┴─┐ │ + │ │ │ │ │ │ │ │ │ │ │ │ + │ │ │ └── │ │ │ └── │ ──┘ ┌─┘ ──┐ │ │ + │ │ │ │ │ │ │ + └───┴───────┴───────┴─────┴─────┴───┘ +``` + +[Box-drawing]: https://en.wikipedia.org/wiki/Box-drawing_character diff --git a/exercises/practice/mazy-mice/.docs/introduction.md b/exercises/practice/mazy-mice/.docs/introduction.md new file mode 100644 index 000000000..96eee810c --- /dev/null +++ b/exercises/practice/mazy-mice/.docs/introduction.md @@ -0,0 +1,3 @@ +# Introduction + +Meet Mickey and Minerva, two clever mice who love to navigate their way through a maze to find cheese. They enjoy a good challenge, but with only their tiny mouse brains, they prefer if there is only one correct path to the cheese. diff --git a/exercises/practice/mazy-mice/.meta/config.json b/exercises/practice/mazy-mice/.meta/config.json new file mode 100644 index 000000000..82cb0e436 --- /dev/null +++ b/exercises/practice/mazy-mice/.meta/config.json @@ -0,0 +1,23 @@ +{ + "authors": [ + "rabestro" + ], + "files": { + "solution": [ + "src/main/java/MazeGenerator.java" + ], + "test": [ + "src/test/java/MazeGeneratorTest.java" + ], + "example": [ + ".meta/src/reference/java/MazeGenerator.java", + ".meta/src/reference/java/Dimensions.java" + ], + "invalidator": [ + "build.gradle" + ] + }, + "blurb": "Meet Mickey and Minerva, two clever mice who love to navigate their way through a maze to find cheese. They enjoy a good challenge, but with only their tiny mouse brains, they prefer if there is only one correct path to the cheese.", + "source": "Inspired by the 'Maze Generator' created by Jan Boström at Alance AB.", + "source_url": "https://mazegenerator.net/" +} diff --git a/exercises/practice/mazy-mice/.meta/src/reference/java/Dimensions.java b/exercises/practice/mazy-mice/.meta/src/reference/java/Dimensions.java new file mode 100644 index 000000000..f7e178c10 --- /dev/null +++ b/exercises/practice/mazy-mice/.meta/src/reference/java/Dimensions.java @@ -0,0 +1,25 @@ +/** + * Represents the dimensions of a maze. + *

+ * Dimensions of a grid can be represented in cells or characters. + * Rows and columns are used for cells, while width and height are used for characters. + */ +public record Dimensions(int rows, int columns) { + /** + * Returns the width of the maze in characters. + * + * @return the width of the maze + */ + int width() { + return 2 * columns + 1; + } + + /** + * Returns the height of the maze in characters. + * + * @return the height of the maze + */ + int height() { + return 2 * rows + 1; + } +} diff --git a/exercises/practice/mazy-mice/.meta/src/reference/java/MazeGenerator.java b/exercises/practice/mazy-mice/.meta/src/reference/java/MazeGenerator.java new file mode 100644 index 000000000..88b9888b7 --- /dev/null +++ b/exercises/practice/mazy-mice/.meta/src/reference/java/MazeGenerator.java @@ -0,0 +1,185 @@ +import java.util.Random; +import java.util.random.RandomGenerator; +import java.util.BitSet; +import java.util.EnumSet; +import java.util.Set; + +import static java.util.stream.IntStream.range; + +public class MazeGenerator { + + public char[][] generatePerfectMaze(int rows, int columns) { + validateDimensions(rows, columns); + return new Grid(new Dimensions(rows, columns), RandomGenerator.getDefault()) + .generateMaze() + .placeDoors() + .print(); + } + + public char[][] generatePerfectMaze(int rows, int columns, int seed) { + validateDimensions(rows, columns); + return new Grid(new Dimensions(rows, columns), new Random(seed)) + .generateMaze() + .placeDoors() + .print(); + } + + private void validateDimensions(int rows, int columns) { + if (rows < 5 || columns < 5 || rows > 100 || columns > 100) { + throw new IllegalArgumentException("Dimensions must be in range."); + } + } +} + +enum Direction { + NORTH(0, 1), + EAST(1, 0), + SOUTH(0, -1), + WEST(-1, 0); + private final int dx; + private final int dy; + + Direction(int dx, int dy) { + this.dx = dx; + this.dy = dy; + } + + public int dx() { + return dx; + } + + public int dy() { + return dy; + } +} + +final class Grid { + private final Dimensions dimensions; + private final BitSet grid; + private final RandomGenerator randomGenerator; + + Grid(Dimensions dimensions, RandomGenerator randomGenerator) { + this.dimensions = dimensions; + this.grid = new BitSet(dimensions.width() * dimensions.height()); + this.randomGenerator = randomGenerator; + } + + Grid generateMaze() { + generate(new Cell(1, 1)); + return this; + } + + private int random(int bound) { + return randomGenerator.nextInt(bound); + } + + private Direction pickRandomDirection(Set directions) { + int size = directions.size(); + int itemIndex = random(size); + var direction = directions.toArray(new Direction[size])[itemIndex]; + directions.remove(direction); + return direction; + } + + Grid placeDoors() { + new Cell(1 + 2 * random(dimensions.rows()), 0).erase(); + new Cell(1 + 2 * random(dimensions.rows()), dimensions.width() - 1).erase(); + return this; + } + + private void generate(Cell cell) { + cell.erase(); + + var directions = EnumSet.allOf(Direction.class); + do { + var direction = pickRandomDirection(directions); + var wall = cell.move(direction); + var next = wall.move(direction); + if (next.isValid() && next.isNotEmpty()) { + wall.erase(); + generate(next); + } + } while (!directions.isEmpty()); + } + + char[][] print() { + return range(0, dimensions.height()) + .mapToObj(this::line) + .toArray(char[][]::new); + } + + private char[] line(int x) { + return range(0, dimensions.width()) + .map(y -> new Cell(x, y).symbol()) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString() + .toCharArray(); + } + + private final class Cell { + final int x; + final int y; + + private Cell(int x, int y) { + this.x = x; + this.y = y; + } + + boolean isValid() { + return x > 0 && x < dimensions.height() && y > 0 && y < dimensions.width(); + } + + void erase() { + grid.set(index()); + } + + boolean isNotEmpty() { + return !isEmpty(); + } + + boolean isEmpty() { + return grid.get(index()); + } + + int index() { + return x * dimensions.width() + y; + } + + boolean isDoor() { + return isEmpty() && (y == 0 || y == dimensions.width() - 1); + } + + Cell move(Direction direction) { + return new Cell(x + direction.dx(), y + direction.dy()); + } + + char symbol() { + if (isDoor()) { + return '⇨'; + } + if (isEmpty()) { + return ' '; + } + var n = x > 0 && new Cell(x - 1, y).isNotEmpty() ? 1 : 0; + var e = y < dimensions.width() - 1 && new Cell(x, y + 1).isNotEmpty() ? 1 : 0; + var s = x < dimensions.height() - 1 && new Cell(x + 1, y).isNotEmpty() ? 1 : 0; + var w = y > 0 && new Cell(x, y - 1).isNotEmpty() ? 1 : 0; + var i = n + 2 * e + 4 * s + 8 * w; + return switch (i) { + case 0 -> ' '; + case 1, 5, 4 -> '│'; + case 2, 8, 10 -> '─'; + case 3 -> '└'; + case 6 -> '┌'; + case 7 -> '├'; + case 9 -> '┘'; + case 11 -> '┴'; + case 12 -> '┐'; + case 13 -> '┤'; + case 14 -> '┬'; + case 15 -> '┼'; + default -> throw new IllegalStateException("Unexpected value: " + i); + }; + } + } +} diff --git a/exercises/practice/mazy-mice/build.gradle b/exercises/practice/mazy-mice/build.gradle new file mode 100644 index 000000000..8bd005d42 --- /dev/null +++ b/exercises/practice/mazy-mice/build.gradle @@ -0,0 +1,24 @@ +apply plugin: "java" +apply plugin: "eclipse" +apply plugin: "idea" + +// set default encoding to UTF-8 +compileJava.options.encoding = "UTF-8" +compileTestJava.options.encoding = "UTF-8" + +repositories { + mavenCentral() +} + +dependencies { + testImplementation "junit:junit:4.13" + testImplementation "org.assertj:assertj-core:3.15.0" +} + +test { + testLogging { + exceptionFormat = 'full' + showStandardStreams = true + events = ["passed", "failed", "skipped"] + } +} diff --git a/exercises/practice/mazy-mice/src/main/java/MazeGenerator.java b/exercises/practice/mazy-mice/src/main/java/MazeGenerator.java new file mode 100644 index 000000000..48c0bdafc --- /dev/null +++ b/exercises/practice/mazy-mice/src/main/java/MazeGenerator.java @@ -0,0 +1,10 @@ +public class MazeGenerator { + + public char[][] generatePerfectMaze(int rows, int columns) { + throw new UnsupportedOperationException("Delete this statement and write your own implementation."); + } + + public char[][] generatePerfectMaze(int rows, int columns, int seed) { + throw new UnsupportedOperationException("Delete this statement and write your own implementation."); + } +} diff --git a/exercises/practice/mazy-mice/src/test/java/MazeGeneratorTest.java b/exercises/practice/mazy-mice/src/test/java/MazeGeneratorTest.java new file mode 100644 index 000000000..631d0b293 --- /dev/null +++ b/exercises/practice/mazy-mice/src/test/java/MazeGeneratorTest.java @@ -0,0 +1,246 @@ +import org.junit.Before; +import org.junit.Test; +import org.junit.Ignore; + +import java.util.Arrays; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; + +enum Direction { + NORTH(0, 1), + EAST(1, 0), + SOUTH(0, -1), + WEST(-1, 0); + private final int dx; + private final int dy; + + Direction(int dx, int dy) { + this.dx = dx; + this.dy = dy; + } + + public int dx() { + return dx; + } + + public int dy() { + return dy; + } +} + +public class MazeGeneratorTest { + private static final char EMPTY_CELL = ' '; + private static final Set ALLOWED_SYMBOLS = Set.of( + EMPTY_CELL, // space + '┌', // box drawings light down and right + '─', // box drawings light horizontal + '┬', // box drawings light down and horizontal + '┐', // box drawings light down and left + '│', // box drawings light vertical + '└', // box drawings light up and right + '┴', // box drawings light up and horizontal + '┘', // box drawings light up and left + '├', // box drawings light vertical and right + '┤', // box drawings light vertical and left + '┼', // box drawings light vertical and horizontal + '⇨' // rightwards white arrow + ); + private static final char VISITED_CELL = '.'; + private static final int RECTANGLE_ROWS = 6; + private static final int RECTANGLE_COLUMNS = 18; + private static final int SEED_ONE = 42; + private static final int SEED_TWO = 43; + private MazeGenerator sut; + + @Before + public void setup() { + sut = new MazeGenerator(); + } + + @Test + public void theDimensionsOfTheMazeAreCorrect() { + var maze = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS); + var expectedWidth = RECTANGLE_COLUMNS * 2 + 1; + var expectedHeight = RECTANGLE_ROWS * 2 + 1; + + assertThat(maze) + .as("The maze has the correct number of rows") + .hasSize(expectedHeight); + + Arrays.stream(maze).forEach(row -> + assertThat(row) + .as("The maze has the correct number of columns") + .hasSize(expectedWidth) + ); + } + + @Ignore("Remove to run test") + @Test + public void theMazeContainsOnlyValidCharacters() { + var maze = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS); + + for (var row : maze) { + for (var cell : row) { + assertThat(cell) + .as("The maze contains only valid characters") + .isIn(ALLOWED_SYMBOLS); + } + } + } + + @Ignore("Remove to run test") + @Test + public void theMazeHasOnlyOneEntranceOnTheLeftSide() { + var maze = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS); + int entranceCount = countEntrances(maze); + + assertThat(entranceCount) + .as("The maze has only one entrance on the left side") + .isOne(); + } + + @Ignore("Remove to run test") + @Test + public void theMazeHasSingleExitOnTheRightSideOfTheMaze() { + var maze = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS); + int exitCount = countExits(maze); + + assertThat(exitCount) + .as("The maze has a single exit on the right side of the maze") + .isOne(); + } + + @Ignore("Remove to run test") + @Test + public void aMazeIsDifferentEachTimeItIsGenerated() { + var maze1 = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS); + var maze2 = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS); + + assertThat(maze1) + .as("Two mazes should not be equal") + .isNotEqualTo(maze2); + } + + @Ignore("Remove to run test") + @Test + public void twoMazesWithSameSeedShouldBeEqual() { + var maze1 = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS, SEED_ONE); + var maze2 = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS, SEED_ONE); + + assertThat(maze1) + .as("Two mazes with the same seed should be equal") + .isEqualTo(maze2); + } + + @Ignore("Remove to run test") + @Test + public void twoMazesWithDifferentSeedsShouldNotBeEqual() { + var maze1 = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS, SEED_ONE); + var maze2 = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS, SEED_TWO); + + assertThat(maze1) + .as("Two mazes with different seeds should not be equal") + .isNotEqualTo(maze2); + } + + @Ignore("Remove to run test") + @Test + public void theMazeIsPerfect() { + var maze = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS); + + assertThatMazeHasSinglePath(maze); + assertThatMazeHasNoIsolatedSections(maze); + } + + @Ignore("Remove to run test") + @Test + public void theMazeIsPerfectWithSeed() { + var maze = sut.generatePerfectMaze(RECTANGLE_ROWS, RECTANGLE_COLUMNS, SEED_ONE); + + assertThatMazeHasSinglePath(maze); + assertThatMazeHasNoIsolatedSections(maze); + } + + @Ignore("Remove to run test") + @Test + public void shouldThrowExceptionWhenRowsIsLessThanFive() { + assertThatIllegalArgumentException() + .isThrownBy(() -> sut.generatePerfectMaze(0, RECTANGLE_COLUMNS)); + } + + @Ignore("Remove to run test") + @Test + public void shouldThrowExceptionWhenColumnsIsLessThanFive() { + assertThatIllegalArgumentException() + .isThrownBy(() -> sut.generatePerfectMaze(RECTANGLE_ROWS, 0)); + } + + @Ignore("Remove to run test") + @Test + public void shouldThrowExceptionWhenRowsIsMoreThenHundred() { + assertThatIllegalArgumentException() + .isThrownBy(() -> sut.generatePerfectMaze(101, RECTANGLE_COLUMNS)); + } + + @Ignore("Remove to run test") + @Test + public void shouldThrowExceptionWhenColumnsIsMoreThenHundred() { + assertThatIllegalArgumentException() + .isThrownBy(() -> sut.generatePerfectMaze(RECTANGLE_ROWS, 101)); + } + + private void assertThatMazeHasSinglePath(char[][] maze) { + assertMazeHasSinglePath(maze, 1, 1); + } + + private void assertMazeHasSinglePath(char[][] maze, int x, int y) { + var isEmptyCell = maze[x][y] == EMPTY_CELL; + + assertThat(isEmptyCell) + .as("The maze has only one path") + .withFailMessage("an extra passage detected at (%d, %d)", x, y) + .isTrue(); + + maze[x][y] = VISITED_CELL; + + for (var direction : Direction.values()) { + if (maze[x + direction.dx()][y + direction.dy()] == EMPTY_CELL) { + maze[x + direction.dx()][y + direction.dy()] = VISITED_CELL; + assertMazeHasSinglePath(maze, x + 2 * direction.dx(), y + 2 * direction.dy()); + } + } + } + + private void assertThatMazeHasNoIsolatedSections(char[][] maze) { + for (int row = 1; row < maze.length; row += 2) { + for (int col = 1; col < maze[row].length; col += 2) { + assertThat(maze[row][col]) + .as("The maze has no isolated sections") + .withFailMessage("an isolated section detected at (%d, %d)", row, col) + .isEqualTo(VISITED_CELL); + } + } + } + + private int countExits(char[][] maze) { + int exitCount = 0; + for (char[] row : maze) { + if (row[row.length - 1] == '⇨') { + exitCount++; + } + } + return exitCount; + } + + private int countEntrances(char[][] maze) { + int entranceCount = 0; + for (char[] row : maze) { + if (row[0] == '⇨') { + entranceCount++; + } + } + return entranceCount; + } +} diff --git a/exercises/settings.gradle b/exercises/settings.gradle index 0aed4b3ff..2a9394fba 100644 --- a/exercises/settings.gradle +++ b/exercises/settings.gradle @@ -80,6 +80,7 @@ include 'practice:luhn' include 'practice:markdown' include 'practice:matching-brackets' include 'practice:matrix' +include 'practice:mazy-mice' include 'practice:meetup' include 'practice:micro-blog' include 'practice:minesweeper'