Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 63 additions & 46 deletions CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
29 changes: 15 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
35 changes: 9 additions & 26 deletions src/main/java/com/frc2713/mazesolver/Cell.java
Original file line number Diff line number Diff line change
@@ -1,41 +1,24 @@
package com.frc2713.mazesolver;

/**
* One square of the maze.
*
* <p>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.
*
* <p>Directions are screen-relative, the way the maze is drawn: <b>up</b> is
* toward row 0, <b>down</b> is toward the last row, <b>left</b> is toward
* column 0, <b>right</b> 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();
}
102 changes: 102 additions & 0 deletions src/main/java/com/frc2713/mazesolver/DefaultMazeSolver.java
Original file line number Diff line number Diff line change
@@ -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<int[]> 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<int[]> 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;
}
}
Loading
Loading