diff --git a/CONTRACT.md b/CONTRACT.md index 2f4b5d9..4656be2 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -8,79 +8,83 @@ There are two layers, and keeping them separate is the whole point: - **The wire format** — a raw `int[][]` bitmask — is how a maze crosses from the site (which generates mazes in JavaScript) into Java. It is an implementation - detail. Only the library's *implementer* (Owen) touches it. -- **The student-facing API** — `Maze`, `Cell`, `Robot`, `MazeSolver` — is what - new students write code against. It is bitmask-free by design. + detail; students never see it. +- **The student-facing API** — `Maze`, `Cell`, `Robot`, `Direction`, and the + concrete `GridMaze` — is what students write code against. It is bitmask-free + by design. ## Wire format (implementer-facing) A maze arrives as `int[][] maze`, indexed `maze[row][col]`. Every row has the same length. Each cell's value is a bitmask of which sides are **open** (carved -through, i.e. passable), using the same convention as the `maze-generator` npm -package the lesson site uses to build mazes: - -| Direction | Bit | Value | -|-----------|------|-------| -| NORTH | 0001 | 1 | -| SOUTH | 0010 | 2 | -| EAST | 0100 | 4 | -| WEST | 1000 | 8 | - -A cell open to the north and east only is `NORTH \| EAST == 5`. A cell open on -all four sides is `15`. These constants are exposed as -`com.frc2713.mazesolver.Direction.{NORTH,SOUTH,EAST,WEST}`. - -The maze is assumed internally consistent: if `maze[r][c]` has `EAST` set, then -`maze[r][c + 1]` has `WEST` set, and so on. Implementations need not validate -this beyond basic shape checks. +through, i.e. passable), using the same bit values as the `maze-generator` npm +package the lesson site uses to build mazes. The bits are named the way they +read on screen (`UP`/`DOWN`/`LEFT`/`RIGHT`), which correspond to the generator's +compass directions: + +| Direction | Compass | Bit | Value | +|-----------|---------|------|-------| +| UP | north | 0001 | 1 | +| DOWN | south | 0010 | 2 | +| RIGHT | east | 0100 | 4 | +| LEFT | west | 1000 | 8 | + +A cell open upward and to the right only is `UP.bit() \| RIGHT.bit() == 5`. A +cell open on all four sides is `15`. `com.frc2713.mazesolver.Direction` is an +`enum { UP, DOWN, LEFT, RIGHT }`; each constant's `bit()` returns its value +above. + +The maze is assumed internally consistent: if `maze[r][c]` has `RIGHT` set, +then `maze[r][c + 1]` has `LEFT` set, and so on. Implementations need not +validate this beyond basic shape checks. Start is always the top-left cell (`[0][0]`); the goal is always the -bottom-right cell (`[rows - 1][cols - 1]`). Screen-relative directions map to -the bitmask as: **up** = NORTH, **down** = SOUTH, **right** = EAST, -**left** = WEST. +bottom-right cell (`[rows - 1][cols - 1]`). ## Student-facing API (what the site depends on) -These interfaces are shipped by the library. New students never see the bitmask; -they use these types. See the Javadoc in each interface for details. +These types are shipped by the library. Students never see the bitmask; they use +these. See the Javadoc in each type for details. ```java package com.frc2713.mazesolver; +public enum Direction { // UP=1, DOWN=2, RIGHT=4, LEFT=8 + UP, DOWN, LEFT, RIGHT; + int bit(); // its bit in a cell's open-sides mask + Direction opposite(); // turn around + Direction clockwise(); // turn right (UP→RIGHT→DOWN→LEFT) + Direction counterClockwise(); // turn left +} + public interface Cell { // one square; plain yes/no queries int row(); int col(); - boolean wallUp(); boolean wallDown(); - boolean wallLeft(); boolean wallRight(); + boolean wall(Direction dir); // is that side a wall? boolean isStart(); boolean isGoal(); } public interface Robot { // walks the maze one cell at a time + boolean readWallSensor(Direction dir); // true if that side is a wall + void drive(Direction dir); // move one cell; no-op into a wall + Direction facing(); // the way it last drove (starts RIGHT) + boolean atGoal(); int row(); int col(); Cell cell(); - boolean canMoveUp(); boolean canMoveDown(); - boolean canMoveLeft(); boolean canMoveRight(); - void moveUp(); void moveDown(); - void moveLeft(); void moveRight(); // throw IllegalStateException if blocked - boolean atGoal(); - int[][] trail(); // {row,col} pairs, start..current + int[][] trail(); // {row,col} pairs, start..current } public interface Maze { // the maze, hiding the int[][] int rows(); int cols(); Cell cellAt(int row, int col); - Robot robot(); // fresh robot at the start cell + Robot robot(); // fresh robot at the start cell boolean isGoalCell(int row, int col); } - -public interface MazeSolver { // the ALGORITHM — a student writes this - void solve(Robot robot); // move the robot until robot.atGoal() -} ``` ### Entry point the site relies on -The library **must** provide a concrete `Maze` the site can build directly from -the wire format: +The library ships the concrete `Maze` the site builds directly from the wire +format: ```java public final class GridMaze implements Maze { @@ -89,15 +93,28 @@ public final class GridMaze implements Maze { ``` The site constructs one per lesson snippet (`new GridMaze(grid)`), hands -`maze.robot()` to the student's `MazeSolver`, then reads `robot.trail()` to draw -the path. `GridMaze` (and the `Cell`/`Robot` it returns) is the implementer's -job; everything else in the student-facing API is an interface the student's -solver is written against. +`maze.robot()` to the student's algorithm, then reads `robot.trail()` to draw +the path. A `drive` into a wall is a no-op (no move, no trail entry, `facing()` +unchanged) — the site harness and lessons rely on that. `GridMaze` and the +`Cell`/`Robot` it returns are shipped by the library, not the site. + +### The library's own solver + +Separately from the interactive API, the library ships a batch solver: + +```java +public interface MazeSolver { int[][] solve(int[][] maze); } +``` + +`com.frc2713.mazesolver.DefaultMazeSolver` implements it as a breadth-first +search, returning the shortest path from start to goal as `{row, col}` pairs +(starting `{0, 0}`), or `new int[0][]` if none exists. It throws +`IllegalArgumentException` for a null, empty, or non-rectangular maze. ## Changing this contract If either side needs the shape of these types, the bitmask values, the screen-relative direction mapping, or the start/goal convention to change, update this file first and treat it as the single source of truth — the site's -`maze-generator` usage and this library's `Direction` constants both need to -move together. +`maze-generator` usage and this library's `Direction` enum both need to move +together. diff --git a/README.md b/README.md index cd67c40..98cd5e2 100644 --- a/README.md +++ b/README.md @@ -40,22 +40,23 @@ CI (`.github/workflows/build.yml`) additionally verifies, on every push: ## Project layout -The student-facing API is a set of interfaces; the concrete implementations -(the parts that read the bitmask) are the implementer's job. +The student-facing API is a set of interfaces plus the concrete `GridMaze` that +reads the bitmask so students never have to. ``` src/main/java/com/frc2713/mazesolver/ - Cell.java interface — one square, plain wallUp()/wallRight() queries - Robot.java interface — walks the maze; canMoveRight()/moveRight()/atGoal() - Maze.java interface — the maze, hands out Cells and a Robot - MazeSolver.java interface — the ALGORITHM a student writes: solve(Robot) - Direction.java N/S/E/W bitmask constants (implementer-facing only) -src/test/java/... unit tests - - -- to implement (see CONTRACT.md) -- - GridMaze + the Cell/Robot it returns the concrete side, reading int[][] + Direction.java enum — UP/DOWN/LEFT/RIGHT, with bit()/opposite()/turns + Cell.java interface — one square; wall(Direction) queries + Robot.java interface — walks the maze; readWallSensor/drive/facing + Maze.java interface — the maze, hands out Cells and a Robot + GridMaze.java concrete Maze built from an int[][] (the site's entry point) + GridRobot/GridCell.java the Robot/Cell a GridMaze returns + MazeSolver.java interface — a batch solver: int[][] solve(int[][]) + DefaultMazeSolver.java shipped MazeSolver (breadth-first, shortest path) +src/test/java/... unit tests ``` -New students write a `MazeSolver` against `Robot`/`Cell` and never touch a -bitmask. See [`CONTRACT.md`](CONTRACT.md) for the full API contract shared with -the lesson site, including the `GridMaze` entry point the site depends on. +Students drive a `Robot` (from `new GridMaze(grid).robot()`) with +`readWallSensor(Direction)` / `drive(Direction)` and never touch a bitmask. See +[`CONTRACT.md`](CONTRACT.md) for the full API contract shared with the lesson +site. diff --git a/src/main/java/com/frc2713/mazesolver/Cell.java b/src/main/java/com/frc2713/mazesolver/Cell.java index 7ab3f4f..2a8aadb 100644 --- a/src/main/java/com/frc2713/mazesolver/Cell.java +++ b/src/main/java/com/frc2713/mazesolver/Cell.java @@ -1,41 +1,24 @@ package com.frc2713.mazesolver; /** - * One square of the maze. - * - *

This is the beginner-facing view of a cell: instead of asking "what is the - * bitmask of this cell?", you ask plain yes/no questions like "is there a wall - * to my right?". Implementations (see the library README) translate the - * underlying N/S/E/W bitmask into these methods so the students using this - * library never touch a bitmask. - * - *

Directions are screen-relative, the way the maze is drawn: up is - * toward row 0, down is toward the last row, left is toward - * column 0, right is toward the last column. + * One square of a {@link Maze}. A cell knows where it sits and which of its four + * sides are walls — the same questions a robot standing on it can ask, without + * any bitmask arithmetic. */ public interface Cell { - /** The row (0-based) this cell sits in; row 0 is the top. */ + /** This cell's row (0 is the top row). */ int row(); - /** The column (0-based) this cell sits in; column 0 is the left edge. */ + /** This cell's column (0 is the left column). */ int col(); - /** {@code true} if a wall blocks movement off the top of this cell. */ - boolean wallUp(); + /** Is the given side of this cell a wall (i.e. blocked, not carved open)? */ + boolean wall(Direction dir); - /** {@code true} if a wall blocks movement off the bottom of this cell. */ - boolean wallDown(); - - /** {@code true} if a wall blocks movement off the left of this cell. */ - boolean wallLeft(); - - /** {@code true} if a wall blocks movement off the right of this cell. */ - boolean wallRight(); - - /** {@code true} if this is the maze's start cell (top-left). */ + /** Is this the start cell (top-left)? */ boolean isStart(); - /** {@code true} if this is the maze's goal cell (bottom-right). */ + /** Is this the goal cell (bottom-right)? */ boolean isGoal(); } diff --git a/src/main/java/com/frc2713/mazesolver/DefaultMazeSolver.java b/src/main/java/com/frc2713/mazesolver/DefaultMazeSolver.java new file mode 100644 index 0000000..8f4f967 --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/DefaultMazeSolver.java @@ -0,0 +1,102 @@ +package com.frc2713.mazesolver; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; + +/** + * The library's {@link MazeSolver} implementation. Finds the shortest path from + * the top-left start to the bottom-right goal with a breadth-first search over + * the cells reachable through carved (open) sides. + */ +public final class DefaultMazeSolver implements MazeSolver { + + @Override + public int[][] solve(int[][] maze) { + if (maze == null || maze.length == 0 || maze[0] == null || maze[0].length == 0) { + throw new IllegalArgumentException("maze must be a non-empty rectangular grid"); + } + int rows = maze.length; + int cols = maze[0].length; + for (int[] row : maze) { + if (row == null || row.length != cols) { + throw new IllegalArgumentException("maze must be rectangular: every row the same length"); + } + } + + int goalR = rows - 1; + int goalC = cols - 1; + + // parent[r][c] encodes the cell we arrived from as r*cols + c; -1 means + // unvisited, -2 marks the start (which has no parent). + int[][] parent = new int[rows][cols]; + for (int[] row : parent) { + Arrays.fill(row, -1); + } + + ArrayDeque queue = new ArrayDeque<>(); + queue.add(new int[] { 0, 0 }); + parent[0][0] = -2; + + while (!queue.isEmpty()) { + int[] cur = queue.poll(); + int r = cur[0]; + int c = cur[1]; + if (r == goalR && c == goalC) { + return reconstruct(parent, cols, goalR, goalC); + } + for (Direction dir : Direction.values()) { + if ((maze[r][c] & dir.bit()) == 0) { + continue; // that side is walled off + } + int nr = r; + int nc = c; + switch (dir) { + case UP: + nr -= 1; + break; + case DOWN: + nr += 1; + break; + case LEFT: + nc -= 1; + break; + case RIGHT: + nc += 1; + break; + } + if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) { + continue; + } + if (parent[nr][nc] != -1) { + continue; // already reached by an equal-or-shorter path + } + parent[nr][nc] = r * cols + c; + queue.add(new int[] { nr, nc }); + } + } + + return new int[0][]; // goal unreachable + } + + // Walk the parent pointers back from the goal to the start, then reverse. + private static int[][] reconstruct(int[][] parent, int cols, int goalR, int goalC) { + ArrayList reversed = new ArrayList<>(); + int r = goalR; + int c = goalC; + while (parent[r][c] != -2) { + reversed.add(new int[] { r, c }); + int p = parent[r][c]; + r = p / cols; + c = p % cols; + } + reversed.add(new int[] { 0, 0 }); + + int n = reversed.size(); + int[][] path = new int[n][]; + for (int i = 0; i < n; i++) { + path[i] = reversed.get(n - 1 - i); + } + return path; + } +} diff --git a/src/main/java/com/frc2713/mazesolver/Direction.java b/src/main/java/com/frc2713/mazesolver/Direction.java index 12f5b30..937578a 100644 --- a/src/main/java/com/frc2713/mazesolver/Direction.java +++ b/src/main/java/com/frc2713/mazesolver/Direction.java @@ -1,16 +1,77 @@ package com.frc2713.mazesolver; /** - * The N/S/E/W bitmask constants used by {@link MazeSolver}. Values match the - * {@code maze-generator} npm package's convention: N=1, S=2, E=4, W=8. + * One of the four ways a {@link Robot} can face or move, named the way a student + * looking at the maze on screen would name them: {@code UP}, {@code DOWN}, + * {@code LEFT}, {@code RIGHT}. + * + *

Each direction carries the single bit it occupies in a cell's N/S/E/W + * bitmask (the maze wire format shared with the {@code maze-generator} npm + * package): {@code UP == 1}, {@code DOWN == 2}, {@code RIGHT == 4}, + * {@code LEFT == 8}. On screen those are north/south/east/west respectively — a + * cell open to the north and east only is {@code UP.bit() | RIGHT.bit() == 5}. + * See {@link MazeSolver} for the maze format and {@code CONTRACT.md}. */ -public final class Direction { +public enum Direction { - public static final int NORTH = 1; - public static final int SOUTH = 2; - public static final int EAST = 4; - public static final int WEST = 8; + UP(1), + DOWN(2), + RIGHT(4), + LEFT(8); - private Direction() { + private final int bit; + + Direction(int bit) { + this.bit = bit; + } + + /** + * This direction's bit in a cell's open-sides bitmask + * ({@code UP=1, DOWN=2, RIGHT=4, LEFT=8}). + */ + public int bit() { + return bit; + } + + /** The opposite heading — where you'd point after turning all the way around. */ + public Direction opposite() { + switch (this) { + case UP: + return DOWN; + case DOWN: + return UP; + case LEFT: + return RIGHT; + default: + return LEFT; + } + } + + /** The heading 90° clockwise from this one (UP→RIGHT→DOWN→LEFT→UP). */ + public Direction clockwise() { + switch (this) { + case UP: + return RIGHT; + case RIGHT: + return DOWN; + case DOWN: + return LEFT; + default: + return UP; + } + } + + /** The heading 90° counter-clockwise from this one (UP→LEFT→DOWN→RIGHT→UP). */ + public Direction counterClockwise() { + switch (this) { + case UP: + return LEFT; + case LEFT: + return DOWN; + case DOWN: + return RIGHT; + default: + return UP; + } } } diff --git a/src/main/java/com/frc2713/mazesolver/GridCell.java b/src/main/java/com/frc2713/mazesolver/GridCell.java new file mode 100644 index 0000000..f51a51d --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/GridCell.java @@ -0,0 +1,45 @@ +package com.frc2713.mazesolver; + +/** One square of a {@link GridMaze}. Wall flags come from the cell's bitmask. */ +final class GridCell implements Cell { + + private final GridMaze maze; + private final int row; + private final int col; + + GridCell(GridMaze maze, int row, int col) { + this.maze = maze; + this.row = row; + this.col = col; + } + + private int mask() { + return maze.mask(row, col); + } + + @Override + public int row() { + return row; + } + + @Override + public int col() { + return col; + } + + @Override + public boolean wall(Direction dir) { + // A set bit means that side is OPEN, so a wall is the bit being clear. + return (mask() & dir.bit()) == 0; + } + + @Override + public boolean isStart() { + return row == 0 && col == 0; + } + + @Override + public boolean isGoal() { + return maze.isGoalCell(row, col); + } +} diff --git a/src/main/java/com/frc2713/mazesolver/GridMaze.java b/src/main/java/com/frc2713/mazesolver/GridMaze.java new file mode 100644 index 0000000..6d54621 --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/GridMaze.java @@ -0,0 +1,58 @@ +package com.frc2713.mazesolver; + +/** + * A concrete {@link Maze} backed by an N/S/E/W bitmask grid (a set bit means + * that side of the cell is OPEN). Start is the top-left cell {@code (0, 0)}; + * goal is the bottom-right cell {@code (rows - 1, cols - 1)} — the convention + * the whole maze module uses. + * + *

This is the entry point lesson snippets use: {@code new GridMaze(grid)} + * turns a raw {@code int[][]} into a maze you can hand a {@link Robot}. + */ +public class GridMaze implements Maze { + + private final int[][] grid; + private final int rows; + private final int cols; + private final int goalRow; + private final int goalCol; + private final GridRobot bot; + + public GridMaze(int[][] grid) { + this.grid = grid; + this.rows = grid.length; + this.cols = grid.length == 0 ? 0 : grid[0].length; + this.goalRow = rows - 1; + this.goalCol = cols - 1; + this.bot = new GridRobot(this); + } + + int mask(int row, int col) { + return grid[row][col]; + } + + @Override + public int rows() { + return rows; + } + + @Override + public int cols() { + return cols; + } + + @Override + public Cell cellAt(int row, int col) { + return new GridCell(this, row, col); + } + + @Override + public boolean isGoalCell(int row, int col) { + return row == goalRow && col == goalCol; + } + + @Override + public Robot robot() { + return bot; + } +} diff --git a/src/main/java/com/frc2713/mazesolver/GridRobot.java b/src/main/java/com/frc2713/mazesolver/GridRobot.java new file mode 100644 index 0000000..2d9b121 --- /dev/null +++ b/src/main/java/com/frc2713/mazesolver/GridRobot.java @@ -0,0 +1,90 @@ +package com.frc2713.mazesolver; + +import java.util.ArrayList; + +/** + * A {@link Robot} that walks a {@link GridMaze}. It records a trail: every cell + * it has stood on, start first. A drive blocked by a wall does nothing and adds + * no trail entry, so an algorithm that drives into a wall leaves a trail that + * simply stays put there. + * + *

The robot remembers one fact about itself — {@link #facing()} — and updates + * it every time it actually moves, because a robot that just drove up is now + * facing up. It starts facing {@link Direction#RIGHT}. + */ +final class GridRobot implements Robot { + + private final GridMaze maze; + private int row = 0; + private int col = 0; + private Direction facing = Direction.RIGHT; + private final ArrayList path = new ArrayList<>(); + + GridRobot(GridMaze maze) { + this.maze = maze; + path.add(new int[] { row, col }); + } + + private int mask() { + return maze.mask(row, col); + } + + @Override + public boolean readWallSensor(Direction dir) { + // A set bit means that side is OPEN, so a wall is the bit being clear. + return (mask() & dir.bit()) == 0; + } + + @Override + public void drive(Direction dir) { + if (readWallSensor(dir)) { + return; // wall ahead — the drivetrain can't push through it + } + switch (dir) { + case UP: + row -= 1; + break; + case DOWN: + row += 1; + break; + case LEFT: + col -= 1; + break; + case RIGHT: + col += 1; + break; + } + facing = dir; + path.add(new int[] { row, col }); + } + + @Override + public Direction facing() { + return facing; + } + + @Override + public boolean atGoal() { + return maze.isGoalCell(row, col); + } + + @Override + public int row() { + return row; + } + + @Override + public int col() { + return col; + } + + @Override + public Cell cell() { + return maze.cellAt(row, col); + } + + @Override + public int[][] trail() { + return path.toArray(new int[0][]); + } +} diff --git a/src/main/java/com/frc2713/mazesolver/Maze.java b/src/main/java/com/frc2713/mazesolver/Maze.java index 13c10bd..973f0f5 100644 --- a/src/main/java/com/frc2713/mazesolver/Maze.java +++ b/src/main/java/com/frc2713/mazesolver/Maze.java @@ -1,35 +1,24 @@ package com.frc2713.mazesolver; /** - * A maze the student can explore. - * - *

A {@code Maze} is built from the raw {@code int[][]} bitmask grid the - * lesson site produces (see {@code CONTRACT.md}), but it hides that entirely: - * once you have a {@code Maze} you work with {@link Cell}s and a {@link Robot}, - * never bitmasks. - * - *

The library must provide a concrete implementation constructable from an - * {@code int[][]} grid so the site can build one — see {@code CONTRACT.md} for - * the agreed entry point. + * A rectangular grid maze. Build one from an N/S/E/W bitmask grid with + * {@link GridMaze}. The start is always the top-left cell {@code (0, 0)} and the + * goal the bottom-right cell {@code (rows - 1, cols - 1)}. */ public interface Maze { - /** Number of rows in the maze. */ + /** Number of rows. */ int rows(); - /** Number of columns in the maze. */ + /** Number of columns. */ int cols(); - /** - * The cell at the given position. - * - * @throws IndexOutOfBoundsException if the position is off the grid - */ + /** The cell at the given row and column. */ Cell cellAt(int row, int col); - /** A fresh robot standing on the start cell (top-left), ready to solve. */ - Robot robot(); - - /** {@code true} if the given position is the goal cell (bottom-right). */ + /** Is the given row/column the goal cell (bottom-right)? */ boolean isGoalCell(int row, int col); + + /** A fresh {@link Robot} standing on the start cell of this maze. */ + Robot robot(); } diff --git a/src/main/java/com/frc2713/mazesolver/MazeSolver.java b/src/main/java/com/frc2713/mazesolver/MazeSolver.java index ce5a737..e270b7c 100644 --- a/src/main/java/com/frc2713/mazesolver/MazeSolver.java +++ b/src/main/java/com/frc2713/mazesolver/MazeSolver.java @@ -1,35 +1,45 @@ package com.frc2713.mazesolver; /** - * A maze-solving algorithm — this is what a student writes. + * Solves a maze expressed as a grid of cells, where each cell is an + * N/S/E/W bitmask marking which sides are open (carved) rather than walled. * - *

The library hands you a {@link Robot} sitting at the start of a maze; your - * job is to move it until it reaches the goal. You decide how — that - * decision is the algorithm. A simple "wall follower" might look like: + *

Bitmask convention (matches the {@code maze-generator} npm package used + * to author lesson content, so a maze can round-trip between the site and + * this library unchanged). The bits are named the way they read on screen — + * see {@link Direction}: + *

+ *   UP    = 1  (0b0001)   (north)
+ *   DOWN  = 2  (0b0010)   (south)
+ *   RIGHT = 4  (0b0100)   (east)
+ *   LEFT  = 8  (0b1000)   (west)
+ * 
+ * A cell's value is the bitwise OR of every open side, e.g. a cell open + * upward and to the right only is {@code UP.bit() | RIGHT.bit() == 5}. * - *
{@code
- * public class MySolver implements MazeSolver {
- *     public void solve(Robot robot) {
- *         while (!robot.atGoal()) {
- *             if (robot.canMoveRight())      robot.moveRight();
- *             else if (robot.canMoveDown())  robot.moveDown();
- *             else if (robot.canMoveLeft())  robot.moveLeft();
- *             else                           robot.moveUp();
- *         }
- *     }
- * }
- * }
- * - *

The path the robot took is available afterwards via {@link Robot#trail()}. + *

Implementations must be pure: no networking, no threads, no filesystem, + * no AWT/Swing. This library runs inside a browser JVM (CheerpJ, OpenJDK 8), + * where none of those are available. */ public interface MazeSolver { /** - * Drive the robot from the start cell to the goal cell. + * Finds a path through the maze. * - * @param robot a robot positioned at the maze's start; move it with - * {@link Robot#moveUp()} and friends until - * {@link Robot#atGoal()} is {@code true} + * @param maze a rectangular grid, indexed {@code maze[row][col]}, where + * each value is an N/S/E/W bitmask (see {@link Direction}) + * describing which sides of that cell are open. The maze is + * assumed to be a valid grid: every row the same length, and + * every "open" side agreed upon by both adjacent cells (e.g. + * if {@code maze[r][c]} has {@code RIGHT} set, then + * {@code maze[r][c + 1]} has {@code LEFT} set). + * @return the path from the top-left cell ({@code [0][0]}) to the + * bottom-right cell ({@code [rows - 1][cols - 1]}), as an ordered + * list of {@code [row, col]} coordinate pairs starting with + * {@code {0, 0}} and ending with the destination cell. Returns + * an empty array ({@code new int[0][]}) if no path exists. + * @throws IllegalArgumentException if {@code maze} is null, empty, or + * not a valid rectangular grid */ - void solve(Robot robot); + int[][] solve(int[][] maze); } diff --git a/src/main/java/com/frc2713/mazesolver/Robot.java b/src/main/java/com/frc2713/mazesolver/Robot.java index a532597..bc32f5e 100644 --- a/src/main/java/com/frc2713/mazesolver/Robot.java +++ b/src/main/java/com/frc2713/mazesolver/Robot.java @@ -1,69 +1,55 @@ package com.frc2713.mazesolver; /** - * A robot that walks through the maze, one cell at a time. + * A robot that walks a {@link Maze} one cell at a time. It starts on the maze's + * start cell and knows only what's around it — never a bird's-eye view of the + * whole maze. * - *

This is the main tool a student uses to write a solving algorithm. The - * robot always sits in exactly one cell (its position). You ask whether it - * can move a direction, and if so you tell it to move — for example: - * - *

{@code
- * while (!robot.atGoal()) {
- *     if (robot.canMoveRight())      robot.moveRight();
- *     else if (robot.canMoveDown())  robot.moveDown();
- *     else                           robot.moveUp();
- * }
- * }
- * - *

Directions are screen-relative (see {@link Cell}). A robot never needs to - * know about bitmasks — it only knows where it is and which ways it can go. + *

The robot has two abilities, mirroring a real one: it can sense a + * wall on any side ({@link #readWallSensor}) and it can drive one cell + * in a direction ({@link #drive}). It also remembers one fact about itself — + * which way it last drove, its {@link #facing()} — because a robot that just + * moved up is now facing up. */ public interface Robot { - /** The robot's current row (0-based); row 0 is the top. */ - int row(); - - /** The robot's current column (0-based); column 0 is the left edge. */ - int col(); - - /** The cell the robot is currently standing on. */ - Cell cell(); - - /** {@code true} if there is no wall above and a cell to move into. */ - boolean canMoveUp(); - - /** {@code true} if there is no wall below and a cell to move into. */ - boolean canMoveDown(); - - /** {@code true} if there is no wall to the left and a cell to move into. */ - boolean canMoveLeft(); + /** + * Reads the wall sensor on one side of the robot's current cell. + * + * @return {@code true} if that side is a wall (the robot cannot drive that + * way), {@code false} if it is open + */ + boolean readWallSensor(Direction dir); - /** {@code true} if there is no wall to the right and a cell to move into. */ - boolean canMoveRight(); + /** + * Drives one cell in the given direction. If that side is a wall the robot + * stays put and nothing happens; otherwise it moves and its + * {@link #facing()} becomes {@code dir}. + */ + void drive(Direction dir); /** - * Step one cell up. Implementations should throw - * {@link IllegalStateException} if {@link #canMoveUp()} is {@code false}, - * so a student learns to check before moving. + * The direction the robot last drove — the one fact it remembers about + * itself. Before it has moved, this is its initial heading. */ - void moveUp(); + Direction facing(); - /** Step one cell down. See {@link #moveUp()} for the blocked-move rule. */ - void moveDown(); + /** Is the robot standing on the goal cell? */ + boolean atGoal(); - /** Step one cell left. See {@link #moveUp()} for the blocked-move rule. */ - void moveLeft(); + /** The robot's current row (0 is the top row). */ + int row(); - /** Step one cell right. See {@link #moveUp()} for the blocked-move rule. */ - void moveRight(); + /** The robot's current column (0 is the left column). */ + int col(); - /** {@code true} once the robot reaches the goal cell (bottom-right). */ - boolean atGoal(); + /** The {@link Cell} the robot is currently standing on. */ + Cell cell(); /** - * Every cell the robot has stood on, in order, as {@code {row, col}} pairs — - * starting with the start cell and ending with its current cell. Handy for - * drawing the path the algorithm took. + * The trail of every cell the robot has stood on, in order, as + * {@code [row, col]} pairs starting with the start cell. A drive blocked by + * a wall adds no entry. */ int[][] trail(); } diff --git a/src/test/java/com/frc2713/mazesolver/DirectionTest.java b/src/test/java/com/frc2713/mazesolver/DirectionTest.java index c6af0fa..895a259 100644 --- a/src/test/java/com/frc2713/mazesolver/DirectionTest.java +++ b/src/test/java/com/frc2713/mazesolver/DirectionTest.java @@ -8,15 +8,36 @@ class DirectionTest { @Test void bitmaskValuesMatchMazeGeneratorConvention() { - assertEquals(1, Direction.NORTH); - assertEquals(2, Direction.SOUTH); - assertEquals(4, Direction.EAST); - assertEquals(8, Direction.WEST); + assertEquals(1, Direction.UP.bit()); + assertEquals(2, Direction.DOWN.bit()); + assertEquals(4, Direction.RIGHT.bit()); + assertEquals(8, Direction.LEFT.bit()); } @Test void directionsAreDistinctBits() { - int all = Direction.NORTH | Direction.SOUTH | Direction.EAST | Direction.WEST; + int all = Direction.UP.bit() | Direction.DOWN.bit() + | Direction.RIGHT.bit() | Direction.LEFT.bit(); assertEquals(15, all); } + + @Test + void oppositeFlipsTheHeading() { + assertEquals(Direction.DOWN, Direction.UP.opposite()); + assertEquals(Direction.UP, Direction.DOWN.opposite()); + assertEquals(Direction.RIGHT, Direction.LEFT.opposite()); + assertEquals(Direction.LEFT, Direction.RIGHT.opposite()); + } + + @Test + void turnsRotateThroughAllFour() { + assertEquals(Direction.RIGHT, Direction.UP.clockwise()); + assertEquals(Direction.DOWN, Direction.RIGHT.clockwise()); + assertEquals(Direction.LEFT, Direction.DOWN.clockwise()); + assertEquals(Direction.UP, Direction.LEFT.clockwise()); + + for (Direction d : Direction.values()) { + assertEquals(d, d.clockwise().counterClockwise()); + } + } } diff --git a/src/test/java/com/frc2713/mazesolver/GridMazeTest.java b/src/test/java/com/frc2713/mazesolver/GridMazeTest.java new file mode 100644 index 0000000..8f3c065 --- /dev/null +++ b/src/test/java/com/frc2713/mazesolver/GridMazeTest.java @@ -0,0 +1,105 @@ +package com.frc2713.mazesolver; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class GridMazeTest { + + // A 3x3 maze with every interior wall carved open (fully connected). Bits: + // UP=1, DOWN=2, RIGHT=4, LEFT=8. Each cell is open toward every neighbor it + // has, so adjacent cells always agree. + private static int[][] openGrid() { + return new int[][] { + { 6, 14, 10 }, + { 7, 15, 11 }, + { 5, 13, 9 }, + }; + } + + @Test + void robotSensesWallsAndOpenings() { + Robot robot = new GridMaze(openGrid()).robot(); + // Top-left corner: walls up and left, openings down and right. + assertTrue(robot.readWallSensor(Direction.UP)); + assertTrue(robot.readWallSensor(Direction.LEFT)); + assertFalse(robot.readWallSensor(Direction.DOWN)); + assertFalse(robot.readWallSensor(Direction.RIGHT)); + } + + @Test + void driveMovesAndUpdatesFacing() { + Robot robot = new GridMaze(openGrid()).robot(); + assertEquals(Direction.RIGHT, robot.facing()); // initial heading + + robot.drive(Direction.DOWN); + assertEquals(1, robot.row()); + assertEquals(0, robot.col()); + assertEquals(Direction.DOWN, robot.facing()); + + robot.drive(Direction.RIGHT); + assertEquals(1, robot.row()); + assertEquals(1, robot.col()); + assertEquals(Direction.RIGHT, robot.facing()); + } + + @Test + void driveIntoWallDoesNothing() { + Robot robot = new GridMaze(openGrid()).robot(); + robot.drive(Direction.UP); // walled — should be a no-op + assertEquals(0, robot.row()); + assertEquals(0, robot.col()); + assertEquals(1, robot.trail().length); // no new trail entry + assertEquals(Direction.RIGHT, robot.facing()); // heading unchanged + } + + @Test + void robotReachesGoalAndRecordsTrail() { + Robot robot = new GridMaze(openGrid()).robot(); + robot.drive(Direction.DOWN); + robot.drive(Direction.DOWN); + robot.drive(Direction.RIGHT); + robot.drive(Direction.RIGHT); + assertTrue(robot.atGoal()); + assertEquals(5, robot.trail().length); + assertArrayEquals(new int[] { 0, 0 }, robot.trail()[0]); + assertArrayEquals(new int[] { 2, 2 }, robot.trail()[4]); + } + + @Test + void solverFindsShortestPath() { + int[][] path = new DefaultMazeSolver().solve(openGrid()); + assertArrayEquals(new int[] { 0, 0 }, path[0]); + assertArrayEquals(new int[] { 2, 2 }, path[path.length - 1]); + assertEquals(5, path.length); // 4 steps is the shortest across a 3x3 + // Every consecutive pair is an adjacent, carved move. + int[][] grid = openGrid(); + for (int i = 1; i < path.length; i++) { + int dr = path[i][0] - path[i - 1][0]; + int dc = path[i][1] - path[i - 1][1]; + assertEquals(1, Math.abs(dr) + Math.abs(dc), "steps must be to an adjacent cell"); + } + } + + @Test + void solverReturnsEmptyWhenGoalUnreachable() { + // Start cell sealed off (no open sides); goal can't be reached. + int[][] sealed = { + { 0, 8 }, + { 0, 1 }, + }; + assertEquals(0, new DefaultMazeSolver().solve(sealed).length); + } + + @Test + void solverRejectsMalformedMazes() { + assertThrows(IllegalArgumentException.class, () -> new DefaultMazeSolver().solve(null)); + assertThrows(IllegalArgumentException.class, () -> new DefaultMazeSolver().solve(new int[0][])); + assertThrows(IllegalArgumentException.class, + () -> new DefaultMazeSolver().solve(new int[][] { { 1, 2 }, { 3 } })); + } +}