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'