diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..f56dd76 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frc2713-skills@frc2713": true + } +} diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index b0ea505..881f1af 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -19,6 +19,26 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + + # Build the maze-solver library and vendor its jar into site/public/ so + # the Java playground can load the com.frc2713.mazesolver API. Built fresh + # each deploy from the library's main; not committed (see site/.gitignore). + - uses: actions/checkout@v4 + with: + repository: FRC2713/maze-solver-java + ref: main + path: maze-solver-java + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + - name: Build maze-solver jar + run: | + mvn -B -q -DskipTests package + cp target/maze-solver.jar "$GITHUB_WORKSPACE/site/public/maze-solver.jar" + working-directory: maze-solver-java + - uses: actions/setup-node@v4 with: node-version: 20 diff --git a/CLAUDE.md b/CLAUDE.md index 88da418..1f299f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,3 +166,28 @@ folder until it's restarted. - **Components**: `LessonCard` (index listing), `PageNav` (in-lesson page navigation), `JavaRunner` (the editable/runnable code block UI backed by `javaRuntime.ts`). +- **Maze round-trip** (`site/src/lib/mazeHarness.ts` + the `'java'` mode of + `MazePlayground`): the `solver: java` maze fence renders an editor where a + student writes a `solve(Robot)` method. `buildMazeHarness` interpolates the + current JS maze in as an `int[][]` literal, splices the student's method into a + `MazeRun` class, and drives the robot; the **Maze Trail** is emitted as a + sentinel-tagged stdout line (`__TRAIL__ [[row,col],…]`), which `parseTrail` + reads back (the single `[row,col]→[x,y]` swap) and `MazePlayground` animates. + `docs/maze-roundtrip.md` documents the seams. `new GridMaze(grid)`, the + `Robot`/`Cell`/`Maze` types, and the `Direction` enum all resolve against + `site/public/maze-solver.jar` — the `FRC2713/maze-solver-java` library, which + ships the concrete `GridMaze`/`GridRobot`/`GridCell` itself (built fresh by CI + on deploy and by `scripts/vendor-maze-solver.sh` locally). The robot API is + sensor/drivetrain-shaped: `robot.readWallSensor(Direction.UP)` (true = wall), + `robot.drive(Direction.RIGHT)` (no-op into a wall), and `robot.facing()`, which + the robot auto-updates on each successful drive; `Direction` is an + `enum {UP,DOWN,LEFT,RIGHT}` with `bit()`/`opposite()`/`clockwise()`/ + `counterClockwise()`. Earlier revisions vendored a separate `maze-engine.jar` + shim while the library shipped interfaces only — that shim (and its + `ENGINE_JAR` classpath entry, build script, and `scripts/maze-engine/` sources) + has been removed; the library and site share the contract in the library's + `CONTRACT.md`. The Algorithms section runs four lessons: `algorithms` (visual — + what an algorithm is, three robots), then `algorithms-maze-data` (the maze as a + 2D `int[][]`), `algorithms-robot-api` (reading the library's API, writing + `tryMove`), and `algorithms-maze-solver` (the wall follower + the round-trip + playground). diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..5cdb13b --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,80 @@ +# Maze Solver Module + +Glossary for the maze-solving module of the FRC 2713 training curriculum — a +series of lessons in which students write Java that solves a maze. This file is +the shared language for that module only; it is not a spec. + +## Language + +**Maze**: +A fixed puzzle the robot must traverse from Start to Goal. The student's program +is handed the whole maze up front (see _Grid_). + +**Grid**: +The god's-eye representation of a Maze the program receives — the entire layout, +known in full before the robot moves, as a rectangular 2D array of Cells. Each +Cell carries its own Wall flags, so the Grid is a `Cell[][]`, not a grid of +blocked/open squares. +_Avoid_: map (reserve "map" for a robot-built model, which this module does not use) + +**Cell**: +One square of the Grid — always open and standable. A Cell records a **Wall** on +each of its four sides (up / down / left / right); those flags, not the Cells +themselves, are what block movement. Every Cell is reachable to stand on; what +varies is which of its edges are walled. + +**Wall**: +A **thin** barrier on the edge between two adjacent Cells (or on a Cell's outer +edge), blocking movement across that edge. Walls live on edges, not whole Cells: +a Cell is never "a wall". Wall flags are consistent between neighbours — the down +edge of a Cell is the up edge of the Cell below it. + +**Robot**: +The thing that traverses the Maze by executing a Solution. It has a position on +the Grid (a Cell) and **no orientation** — it does not face a direction, so every +Move is an absolute step, not a turn. Orientation is a property of the robot's +_interface_, not of every algorithm: a wall-follower may choose to *remember* +which way it last stepped (a `facing` variable it owns), but that state lives in +the algorithm, not the robot — the robot still only ever takes absolute Moves. + +**Start**: +The cell where the Robot begins. + +**Goal**: +The target cell. A Solution **succeeds** if and only if executing it from Start +leaves the Robot standing on the Goal. + +**Solution**: +The ordered sequence of Moves the student's program emits. Success is judged +only by whether it finishes the Maze (reaches the Goal). A Move blocked by a Wall +is not a modeled failure — it simply does nothing; nothing is penalized. The +module is about *finishing*, not collision avoidance. + +**Maze Trail**: +The ordered sequence of Cells the Robot has occupied while executing its +Solution, from Start onward. It is the *consequence* of a Solution, not the same +thing: a Solution is the Moves the algorithm emits, whereas the Maze Trail is +where the Robot actually ended up standing after each. A Move blocked by a Wall +adds no Maze Trail entry, so an algorithm that drives into a wall leaves a Trail +that simply stays put there. +_Avoid_: path (ambiguous — could mean the open corridors of the Maze itself) + +**Maze Battle**: +The module's finale. A student submits one program; it is scored by how many of a +set of *unseen* Mazes it finishes (a gauntlet), tie-broken by total Moves. The +format rewards a general algorithm (which clears the whole set) over a hard-coded +Solution (which finishes only the one Maze it was written for). + +**Move** (also **Command**): +One instruction to the Robot. Exactly four exist: **UP**, **DOWN**, **LEFT**, +**RIGHT** — absolute, screen-relative directions. Each Move steps the Robot +**one Cell** in that direction *if that edge is open*; a Move blocked by a Wall +(or the grid edge) does nothing. There are no turns and no orientation to track. +_Avoid_: turn, heading, facing, North/South/East/West, "drive to the next junction" + +**Helper** (injected): +Convenience methods the harness puts in scope so students query the Maze without +hand-indexing Wall flags: `robot.moveUp/moveDown/moveLeft/moveRight()`, +`robot.canGoUp/canGoDown/canGoLeft/canGoRight()`, `robot.atGoal()`, +`robot.row()/col()`, plus god's-eye access to the whole `Cell[][]` grid for route +planning. diff --git a/docs/maze-roundtrip.md b/docs/maze-roundtrip.md new file mode 100644 index 0000000..d64e03b --- /dev/null +++ b/docs/maze-roundtrip.md @@ -0,0 +1,227 @@ +# The maze round-trip: JS → Java → animated steps + +**Status: built.** The round-trip is wired end to end and drives the interactive +"write your own solver" playground (the `solver: java` maze fence, last page of +`lessons/algorithms/README.md`). The glue lives in `site/src/lib/mazeHarness.ts` +(`buildMazeHarness`, `parseTrail`, `retargetErrorLines`) and the `'java'` mode of +`site/src/components/MazePlayground.tsx`. This document describes the seams and +data contracts; a few assumptions in the original spec were corrected against the +real library and are noted inline below. + +**Library.** `new GridMaze(grid)` and the `Robot`/`Cell`/`Direction` API resolve +against `site/public/maze-solver.jar` — the `FRC2713/maze-solver-java` library, +built fresh by CI on deploy and by `scripts/vendor-maze-solver.sh` locally. The +library ships the concrete `GridMaze`/`GridRobot`/`GridCell` itself; there is no +longer a separate engine shim. (Earlier revisions vendored a `maze-engine.jar` +while the library shipped interfaces only — that shim has been removed.) + +Audience: developers working on the site. This is not student-facing lesson +content. + +## What we're building and why + +A student writes a maze-solving **algorithm** in Java. They press **Run**, and +the maze currently shown in the playground — generated in JavaScript — is handed +to their algorithm, which drives a `Robot` through it. The path the robot took +is then **animated back** in the same playground. + +The motivating win is *removing duplicated logic*. Today +`site/src/components/MazePlayground.tsx` animates a wall follower that is +**re-implemented in TypeScript** (`runSolver`, mode `'wall'`) purely so there's +something to animate. That algorithm already exists in Java and in the +`maze-solver` library. The round-trip makes the **Java side the single source of +truth for the path**: JS generates the maze and plays back a result, but never +re-simulates solving. + +The library's job in this is narrow and deliberate: it turns the raw bitmask +grid into a friendly `Maze`/`Robot`/`Cell` API so the *student* writes an +algorithm, not file I/O or bitmask arithmetic. See `CONTEXT.md` for the domain +vocabulary (Maze, Grid, Cell, Robot, Move, Solution, **Maze Trail**). + +## Data flow + +```mermaid +sequenceDiagram + participant JS as MazePlayground (JS) + participant RT as javaRuntime.runJava + participant H as Harness (generated Java) + participant Stu as student solve(Robot) + participant Lib as maze-solver library + + JS->>JS: generateMaze() → int[][] bitmask grid + JS->>RT: runJava(harnessSource) // grid interpolated as literal + solve() spliced in + RT->>H: compile + run (CheerpJ) + H->>Lib: new GridMaze(grid); maze.robot() + H->>Stu: solve(robot) + Stu->>Lib: robot.readWallSensor/drive/atGoal() // library records the Maze Trail + H->>H: print "__TRAIL__ [[row,col],…]" (sentinel line) + RT-->>JS: RunOutcome.output (captured stdout) + JS->>JS: scan for sentinel, JSON.parse, [row,col]→[x,y] + JS->>JS: replay Trail — place robot dot at each cell +``` + +## The seams, one at a time + +### 1. Generate the maze (JS) — *exists* + +`generateMaze(SIZE)` in `MazePlayground.tsx` produces the `number[][]` N/S/E/W +bitmask grid (`N=1, S=2, E=4, W=8`; a set bit means that side is **open**). This +is unchanged. + +### 2. Pass the maze down (new) + +`runJava(code)` accepts **only a source string** — there is no data channel. So +the current grid is carried in by *interpolating it into the harness source* as +an `int[][]` literal, reusing the exact format `serializeToJava` already emits +(the bitmask grid plus `startRow/startCol/goalRow/goalCol`). No VFS file, no +Java-side parsing. + +`serializeToJava` currently targets the clipboard ("Copy for Java"); the new glue +factors its literal-building out so the harness builder can call it directly. + +### 3. Deserialize (Java / library) — *exists in the library* + +The harness constructs the friendly API from the literal: + +```java +Maze maze = new GridMaze(grid); +Robot robot = maze.robot(); // positioned at Start +``` + +The student never sees the bitmask grid or `main`. + +### 4. Execute the student's algorithm (Java) + +The harness calls the student's `solve(Robot)`. The student drives the robot with +the library's sensor/drivetrain API — `robot.readWallSensor(Direction)`, +`robot.drive(Direction)`, `robot.facing()`, `robot.atGoal()`. Each successful +drive extends the **Maze Trail** the library records; a drive blocked by a wall +does nothing and adds no Trail entry (so a buggy algorithm that drives into a +wall shows the robot *actually stuck there* when animated). + +### 5. Emit the steps (new) + +After `solve` returns, the **harness** — not the student — prints the Trail as a +single **sentinel-tagged line**: + +``` +__TRAIL__ [[0,0],[0,1],[1,1],[1,2]] +``` + +- The `__TRAIL__` prefix makes the line unambiguously extractable even when the + student's own `System.out.println` debugging is interleaved in stdout. +- The payload is a **JSON array of `[row, col]` pairs**, in `row`-major + (Java-native) coordinates — see the coordinate note below. +- It is the full `robot.trail()`, including the Start cell as the first entry, so + the animation has a complete path from `trail[0]`. + +### 6. Parse the steps back (new) + +The JS glue takes `RunOutcome.output`, finds the line beginning with the +sentinel, and `JSON.parse`s the remainder. `runJava` already returns captured +stdout in `RunOutcome.output`, and already flags compile/runtime failure via +`RunOutcome.ok` — the parser only runs on `ok === true` and simply reports "no +trail found" if the sentinel line is absent. + +### 7. Animate the steps (new wiring) + +`MazePlayground` replays the parsed Trail by placing the robot dot at each cell +in sequence (the same interval-driven loop shape as today's `runSolver`, but +*reading* cells instead of *computing* moves). Because the Trail is exactly what +happened in Java, the JS side needs **no wall logic at all** — it does not check +openings, it just plays the cells back. + +## The wire contract (authoritative) + +| Direction | Payload | Encoding | +| --- | --- | --- | +| JS → Java | current maze grid + start/goal | `int[][]` literal interpolated into harness source (`serializeToJava` format) | +| Java → JS | Maze Trail | one line: `__TRAIL__ ` + JSON `[[row,col],…]` on stdout | + +**Coordinate convention — the one conversion point.** Java is row-major +(`maze[row][col]`, `Cell.row()/col()`), but `MazePlayground`'s robot state is +`[x, y]` = `[col, row]`. They are transposed. The wire carries **`[row, col]`** +(Java-native); the JS parser performs the single `[row, col] → [x, y]` swap on +the way in. Do the swap in exactly one place and nowhere else. + +## Harness shape (illustrative) + +The app builds a full compilation unit; `prepareSource` in `javaRuntime.ts` +already runs a declared class with a `main`, so no changes to the runtime's +wrapping are needed. Roughly: + +```java +import com.frc2713.mazesolver.*; + +public class MazeRun { + public static void main(String[] args) { + int[][] grid = { /* interpolated from the current JS maze */ }; + Maze maze = new GridMaze(grid); + Robot robot = maze.robot(); + + solve(robot); // <-- student's algorithm runs here + + // Emit the Maze Trail as a sentinel-tagged JSON line. + StringBuilder sb = new StringBuilder("__TRAIL__ ["); + Cell[] trail = robot.trail(); + for (int i = 0; i < trail.length; i++) { + if (i > 0) sb.append(','); + sb.append('[').append(trail[i].row()).append(',').append(trail[i].col()).append(']'); + } + System.out.println(sb.append(']')); + } + + // ===== student-authored, spliced in by the harness builder ===== + static void solve(Robot robot) { + // e.g. wall follower, or whatever the student wrote + } +} +``` + +## Edge cases the implementation must handle + +- **Algorithm never reaches the Goal.** The Trail is still valid — animate the + partial path. `atGoal()` at the end tells you whether it succeeded; the + animation can show "stuck"/"gave up" the same way the current playground does. +- **Student debug output.** Tolerated by design — the sentinel line is found + regardless of other stdout. Show the rest of the output as-is if useful. +- **Compile or runtime error.** Handled by the existing `RunOutcome.ok === false` + path (see `simplifyCompileErrors`/`simplifyRuntimeError`); no Trail is parsed. +- **No sentinel line on success.** Treat as "algorithm produced no trail" and + surface a clear message rather than silently animating nothing. +- **Empty/one-cell Trail.** Robot never left Start — animate nothing / a no-op. + +## Exists vs. to-build + +**Exists (reuse):** +- `generateMaze` and the bitmask grid (`MazePlayground.tsx`). +- `serializeToJava`'s literal format (`MazePlayground.tsx`). +- `runJava` / `RunOutcome`, stdout capture, compile+run, error simplification, + `prepareSource` class-with-`main` handling (`javaRuntime.ts`). +- The `maze-solver` library API — `GridMaze`, `Robot`, `Cell`, `Direction` + (external repo `FRC2713/maze-solver-java`, vendored as + `site/public/maze-solver.jar`). +- `MazePlayground`'s interval-driven robot animation loop. + +**To build (the new glue):** +- A **harness builder** that composes the interpolated grid + spliced `solve` + into the `MazeRun` source (factor the literal out of `serializeToJava`). +- The **`__TRAIL__` sentinel emit** convention in the harness. +- A **trail parser** on the JS side (sentinel scan → `JSON.parse` → `[row,col]→ + [x,y]` swap). +- Wiring `MazePlayground` to call `runJava` and replay the parsed Trail (a new + solver "mode" alongside the existing `'random' | 'naive' | 'wall'`). + +## Integration details (confirmed against the library) + +Resolved against the vendored interface jar's bytecode while wiring this up: +- `robot.trail()` returns **`int[][]`** — already `[row, col]` pairs, not `Cell[]`. + So the harness emits the Trail by iterating the `int[][]` directly (simpler + than the `Cell.row()/col()` the earlier draft assumed). +- `new GridMaze(grid)` returns a `Maze`; `maze.robot()` returns a Start-positioned + `Robot`. `GridMaze` now lives in the library itself, with Start = (0,0) and + Goal = (rows-1, cols-1). +- The sensor/drivetrain methods are **`readWallSensor(Direction)`** and + **`drive(Direction)`**, with `facing()` remembering the last drive; `Direction` + is an `enum { UP, DOWN, LEFT, RIGHT }` with `bit()`, `opposite()`, + `clockwise()`, `counterClockwise()`. diff --git a/lessons/algorithms-maze-data/README.md b/lessons/algorithms-maze-data/README.md new file mode 100644 index 0000000..b25d447 --- /dev/null +++ b/lessons/algorithms-maze-data/README.md @@ -0,0 +1,114 @@ +--- +title: "The maze as data" +goal: "See a maze the way a program does — a grid of numbers — and learn to read a 2D array (an array of arrays) with maze[row][col], the shape almost every robot's-eye view of the field takes." +order: 196 +section: "Algorithms" +--- + +# A maze is just a grid of numbers + +You've driven the maze by hand and watched three robots cross it. Now for the +move that lets a *computer* do it: seeing the maze as **data** — plain numbers a +program can hold in a variable. + +Back in the [Arrays](/lesson/12-arrays) lesson, an array was a row of boxes: a +single line of values, one after another. A maze isn't a single row, though — +it's a **grid**, with rows *and* columns. So we reach for the natural next step: +an array **whose elements are themselves arrays**. One array holds the rows; +each row is an array of cells. That's a **2D array**, and in Java its type is +`int[][]` — "an array of `int` arrays." + +Here's a small 5×5 maze written exactly that way: + +```java +int[][] maze = { + {4, 12, 12, 12, 10}, + {6, 12, 10, 6, 9}, + {5, 10, 3, 3, 2}, + {6, 9, 3, 5, 11}, + {5, 8, 5, 12, 9} +}; + +System.out.println("The maze has " + maze.length + " rows."); +System.out.println("Row 0 has " + maze[0].length + " columns."); +``` + +Read it top to bottom and it lays out as five rows of five cells — the same shape +as the mazes you've been crossing. The outer `{ }` holds the whole maze; each +inner `{ }` is one row. `maze.length` is the number of rows; `maze[0].length` is +how many columns are in the first row. Press **Run** and check those counts. + +# Reaching one cell: maze[row][col] + +A single index reached into a plain array — `scores[2]` was the third score. A +2D array takes **two** indices, in a fixed order that's worth burning into +memory: **row first, then column.** + +```java +int[][] maze = { + {4, 12, 12, 12, 10}, + {6, 12, 10, 6, 9}, + {5, 10, 3, 3, 2}, + {6, 9, 3, 5, 11}, + {5, 8, 5, 12, 9} +}; + +// maze[row][col] — row first, then column. Both start counting at 0. +System.out.println("Top-left cell: " + maze[0][0]); +System.out.println("Top-right cell: " + maze[0][4]); +System.out.println("Bottom-right cell: " + maze[4][4]); +System.out.println("Middle cell: " + maze[2][2]); +``` + +`maze[0][0]` is the **start** (top-left — the green cell in the maze pictures). +`maze[4][4]` is the **goal** (bottom-right, the red cell) — the last row, the +last column. +Row `0` is the top and column `0` is the left, so as the row index grows you move +*down* and as the column index grows you move *right*. Getting `[row][col]` the +right way round is the single most common place people trip: `maze[4][0]` is the +bottom-left cell, but `maze[0][4]` is the top-right — swap them and you're in a +completely different corner. + +# Walking the whole grid + +To *look at every cell*, you nest one loop inside another: the outer loop walks +the rows, the inner loop walks the columns of that row. This double loop is the +bread-and-butter way to touch every square of a grid, and you'll write it +constantly. + +```java +int[][] maze = { + {4, 12, 12, 12, 10}, + {6, 12, 10, 6, 9}, + {5, 10, 3, 3, 2}, + {6, 9, 3, 5, 11}, + {5, 8, 5, 12, 9} +}; + +for (int row = 0; row < maze.length; row++) { + for (int col = 0; col < maze[row].length; col++) { + System.out.print(maze[row][col] + "\t"); + } + System.out.println(); // newline at the end of each row +} +``` + +Run it and the output is the maze laid back out as a grid — because the loops +visit the cells in exactly the order they're stored: all of row 0 left to right, +then all of row 1, and so on. + +## What do the numbers *mean*? + +Fair question — why is the start cell a `4` and the goal a `9`? Each number packs +in **which sides of that cell are open** (carved through) versus walled. The +start cell is `4`, which happens to mean "only the right side is open" — which +fits: the green start cell has just one way out, to the right. + +Here's the good news, and it's the whole point of the next lesson: **you will +never do that decoding by hand.** Squeezing four walls into one number is a +clever trick, but it's a distraction from actually *solving* the maze. So instead +of asking you to do bit-by-bit arithmetic on these values, we'll hand the grid to +a small **library** that reads it for you and gives you a friendly robot to +drive. The maze stays this same `int[][]` — that's how it travels between the +website and your Java code — but from here on you get to think in *"is there a +wall to my right?"*, not in numbers. diff --git a/lessons/algorithms-maze-solver/README.md b/lessons/algorithms-maze-solver/README.md new file mode 100644 index 0000000..d7c09fb --- /dev/null +++ b/lessons/algorithms-maze-solver/README.md @@ -0,0 +1,119 @@ +--- +title: "Writing a maze solver" +goal: "Turn the wall follower from an idea into working Java: use the robot's facing() and the Direction turns to try right, straight, left, then back, and watch your own algorithm drive a robot out of a freshly generated maze." +order: 198 +section: "Algorithms" +--- + +# From a helper to an algorithm + +You have a robot you understand and a `tryMove` helper that moves it one cell +*if it can*. Time to put them in a loop and cross the maze — the **wall +follower** from the visual lesson, now as real code. + +The rule, in words, was: *relative to the way I'm facing, take the first opening +in this order — right, straight, left, then (only as a last resort) back.* The +one fact it leans on is the robot's **heading**, and that's exactly the fact the +robot already remembers for you: `robot.facing()`. Because `drive` updates +`facing()` every time the robot actually moves, you never have to track the +heading yourself. + +To turn "right, straight, left, back" into real `Direction`s, ask the heading to +rotate. `Direction` offers three turns: + +```text +Direction.RIGHT.clockwise() → Direction.DOWN (a right turn) +Direction.RIGHT.counterClockwise() → Direction.UP (a left turn) +Direction.RIGHT.opposite() → Direction.LEFT (turn around) +``` + +So if the robot is `facing()` right, its own right-hand side is `clockwise()` of +that (down), straight ahead is the heading itself, its left is +`counterClockwise()`, and back is `opposite()`. Build that list of four and +`tryMove` the first one that's open: + +```java +import com.frc2713.mazesolver.*; + +public class WallFollower { + public static void main(String[] args) { + int[][] grid = { + {4, 12, 12, 12, 10}, + {6, 12, 10, 6, 9}, + {5, 10, 3, 3, 2}, + {6, 9, 3, 5, 11}, + {5, 8, 5, 12, 9} + }; + Maze maze = new GridMaze(grid); + Robot robot = maze.robot(); + + int maxSteps = 1000; // a safety stop, so a bad rule can't loop forever + for (int i = 0; i < maxSteps && !robot.atGoal(); i++) { + Direction ahead = robot.facing(); + // Right, straight, left, back — relative to the way we're facing. + Direction[] order = { + ahead.clockwise(), ahead, ahead.counterClockwise(), ahead.opposite() + }; + for (Direction dir : order) { + if (tryMove(robot, dir)) break; // took the first opening + } + } + + if (robot.atGoal()) { + System.out.println("Reached the goal in " + (robot.trail().length - 1) + " steps!"); + } else { + System.out.println("Gave up after " + maxSteps + " steps."); + } + } + + // Drive one step in dir if that side is open; report whether we moved. + static boolean tryMove(Robot robot, Direction dir) { + if (!robot.readWallSensor(dir)) { + robot.drive(dir); + return true; + } + return false; + } +} +``` + +Press **Run**: the robot hugs the right-hand wall all the way from the green cell +to the red one and prints how many steps it took. Everything the visual lesson +promised is here in a dozen lines — and the only state the algorithm keeps is the +heading, which the robot hands you for free. + +# Now you try: your solver in a real maze + +You just read a whole program you could run but not *change*. Now it's your turn, +and this time the maze is real — freshly generated, and different every time. + +Notice how much of that program was the same boilerplate every run: `main`, +building the `Maze`, the safety cap, printing the result. The only part that's +truly *the algorithm* is the decision inside the loop — so that's the only part +the playground asks you for. It hands you a `Robot` standing at the start and +asks for one method, `solve`: + +The maze on the right is handed to your Java `solve` method, your algorithm drives +the `Robot`, and the exact path it took is animated right back here — the full +round-trip. You have the same API you've been using all along: + +- `robot.readWallSensor(Direction.UP/DOWN/LEFT/RIGHT)` — is that side a wall? +- `robot.drive(Direction.UP/DOWN/LEFT/RIGHT)` — drive one cell (a wall stops you). +- `robot.facing()` — the direction it last drove; `Direction` also has + `clockwise()`, `counterClockwise()`, and `opposite()`. +- `robot.atGoal()`, `robot.row()`, `robot.col()` — where am I, am I done? + +The starter is the **wall follower** you just studied. Run it first and watch it +solve. Then make it yours: + +- Hit **Generate new maze** and run again — a good algorithm clears *any* maze, + not just one. +- Break it on purpose: swap `clockwise()` and `counterClockwise()` (a *left*-hand + follower — it still works!), or delete the wall check and drive blindly into + walls, and watch where your robot gets stuck. +- Throw out the wall follower entirely and write your own rule. Anything that + reaches red counts. + +```maze +solver: java +``` diff --git a/lessons/algorithms-robot-api/README.md b/lessons/algorithms-robot-api/README.md new file mode 100644 index 0000000..a140e33 --- /dev/null +++ b/lessons/algorithms-robot-api/README.md @@ -0,0 +1,147 @@ +--- +title: "Reading the robot's API" +goal: "Meet the maze-solver library — the Maze, Robot, and Direction it hands you — and practice the real-world skill of reading an unfamiliar API by writing a small helper method, tryMove, out of the robot's sense and drive abilities." +order: 197 +section: "Algorithms" +--- + +# Meet the robot + +Last lesson you saw the maze as an `int[][]` — and promised yourself you'd never +decode those numbers by hand. This is where that promise pays off. We hand the +grid to a small **library** (a bundle of ready-made code someone else wrote for +you), and it gives back two friendly objects: + +```java +Maze maze = new GridMaze(grid); // turn the raw grid into a maze +Robot robot = maze.robot(); // a robot standing on the start cell +``` + +That's it — no bitmasks. From here you think about a **robot**, and a robot can +do exactly what the visual lessons described: **sense** what's around it and +**move**. Learning precisely what it can do means reading its **API** — the list +of methods it offers. Reading an API you didn't write is one of the most useful +skills in all of programming (it's most of what working with real robot code +*is*), so here's the robot's, in full: + +```text +robot.readWallSensor(Direction.UP) is that side a wall? → true / false +robot.drive(Direction.RIGHT) drive one cell that way (does nothing if walled) +robot.facing() the Direction it last drove +robot.atGoal() standing on the goal (red) cell? +robot.row(), robot.col() where it is now +``` + +`Direction` is the four ways it can face or move: `Direction.UP`, +`Direction.DOWN`, `Direction.LEFT`, `Direction.RIGHT`. Two things are worth +underlining before you use them: + +- `readWallSensor(dir)` reports a **wall**: it's `true` when that side is + *blocked*. So an *opening* is when it comes back `false`. +- `drive(dir)` is honest about the walls: if you aim it at a wall, the robot just + stays put. And when it *does* move, it remembers that direction — so right + after `robot.drive(Direction.UP)`, `robot.facing()` is `Direction.UP`. A robot + that just drove up is now facing up. + +Here's the robot actually doing these things. Read the code, predict the output, +then press **Run**: + +```java +import com.frc2713.mazesolver.*; + +public class MeetTheRobot { + public static void main(String[] args) { + int[][] grid = { + {4, 12, 12, 12, 10}, + {6, 12, 10, 6, 9}, + {5, 10, 3, 3, 2}, + {6, 9, 3, 5, 11}, + {5, 8, 5, 12, 9} + }; + Maze maze = new GridMaze(grid); + Robot robot = maze.robot(); + + // What's around the robot at the start? + System.out.println("Wall above me? " + robot.readWallSensor(Direction.UP)); + System.out.println("Wall to right? " + robot.readWallSensor(Direction.RIGHT)); + + // Drive one cell to the right, then look again. + robot.drive(Direction.RIGHT); + System.out.println("Drove right. Now at row " + robot.row() + ", col " + robot.col()); + System.out.println("Facing: " + robot.facing()); + } +} +``` + +At the start the robot is boxed in on every side but the right — that lone +opening is why the start cell's number was `4`. Try changing that first `drive` +to `Direction.UP` (into a wall) and re-run: the robot doesn't move, because the +drivetrain can't push through a wall. + +# Build a helper: tryMove + +Look at the two abilities together and a small annoyance appears. To *safely* +move a direction you always do the same two-step dance: **check the sensor, and +only drive if it's open.** Drive without checking and you might just grind into a +wall. + +That pattern — "move that way *if you can*, and tell me whether you did" — is +worth bottling into one method of your own. Call it `tryMove`: + +```text +tryMove(robot, dir): + if that side is NOT a wall: + drive that way + return true (yes, I moved) + otherwise: + return false (no, blocked) +``` + +It's only a few lines, but writing it is the exercise: you're turning the raw API +into a tool shaped like the way you actually think about the problem. Here it is +in code. **Read the body of `tryMove` and make sure you can explain each line** — +then, to really learn it, delete the body and rewrite it from the pseudocode +above without looking. + +```java +import com.frc2713.mazesolver.*; + +public class TryMove { + public static void main(String[] args) { + int[][] grid = { + {4, 12, 12, 12, 10}, + {6, 12, 10, 6, 9}, + {5, 10, 3, 3, 2}, + {6, 9, 3, 5, 11}, + {5, 8, 5, 12, 9} + }; + Maze maze = new GridMaze(grid); + Robot robot = maze.robot(); + + // Try each direction once and report what happened. + Direction[] toTry = { Direction.UP, Direction.RIGHT, Direction.DOWN, Direction.LEFT }; + for (Direction dir : toTry) { + boolean moved = tryMove(robot, dir); + System.out.println("tryMove " + dir + " -> " + moved + + " (now at row " + robot.row() + ", col " + robot.col() + ")"); + } + } + + // Drive one step in dir if that side is open; report whether we moved. + static boolean tryMove(Robot robot, Direction dir) { + if (!robot.readWallSensor(dir)) { // not a wall → there's an opening + robot.drive(dir); + return true; + } + return false; + } +} +``` + +Run it and read the trace: from the start only `RIGHT` comes back `true`, so +that's the only line where the robot's column changes. Every other direction is a +wall, so `tryMove` reports `false` and the robot holds its position — no crashing +into walls, exactly as designed. + +You now have a robot you understand and one helper of your own. That's everything +you need to write a *real* maze-solving algorithm — which is the next lesson. diff --git a/lessons/algorithms/README.md b/lessons/algorithms/README.md new file mode 100644 index 0000000..94b056d --- /dev/null +++ b/lessons/algorithms/README.md @@ -0,0 +1,210 @@ +--- +title: "Navigating a maze" +goal: "See a maze as a robot's world — a grid it must cross — and learn what makes something an algorithm by watching three robots try to solve it: a random walk (no plan), a fixed rule that loops (an algorithm, but a bad one), and the wall follower (sense, decide, move, repeat)." +order: 195 +section: "Algorithms" +--- + +# A maze is a robot's world + +On the right is a maze. It's fun to solve by hand — but for us it's really a +tiny, simplified version of the problem every FRC robot faces: **"I'm here, I +need to get there, and there are things in my way."** + +Strip a competition field down to its essentials and you get exactly this: a +space divided into a **grid** of cells, some paths open, some blocked by +**walls**, a place you **start**, and a place you're trying to **reach**. + +- The **grid of cells** is the field, chopped into squares the robot can occupy. +- The **walls** are obstacles — you can't drive through them. +- The **green** cell is the **start**; the **red** cell is the **goal**. +- The **blue dot** is the **robot**. + +Before we talk about *how* a robot crosses this, get a feel for it: use the +arrow buttons (or your arrow keys) and drive the robot from green to red. Notice +you can't move through walls — only through the openings. + +```maze +``` + +# The robot only knows what's around it + +Here's the crucial thing about robots — and it's easy to miss because *you* can +see the whole maze at once. **The robot can't.** + +A real robot doesn't get a bird's-eye view. It knows two things: + +1. **Where it is right now** — which cell it's sitting in. That's its + **state**: a single fact, "I am at column 3, row 5." +2. **What's immediately around it** — which of its four sides (up, down, left, + right) are open, and which are walls. That's its **sensor reading** for this + moment. + +That's it. From one cell, the robot can only see its own four walls. It has to +make its next decision using just that — then move, look again, and decide +again. + +When *you* press an arrow, you're being the robot's decision-maker for one step: +you pick a direction, and it either moves (opening) or stays put (wall). Each +press is one **"sense → decide → move"** cycle. + +```maze +``` + +# The dumbest possible robot: move at random + +You just drove the maze by hand, making a real choice at every cell. So here's a +fair question — what if the robot *didn't* choose? What if, at each step, it just +picked one of the open directions **at random** and went? + +Hit **Move at random** and watch. + +```maze +solver: random +``` + +Sometimes it stumbles onto the goal. Sometimes it wanders forever. And here's the +part that matters: **run it again and it does something completely different.** +There's no plan — just a coin flip at every cell. + +That's exactly why this is *not an algorithm*. An algorithm is something you can +write down, hand to someone else, and have them get the **same result you did**. +You can't write down "flip a coin" and call that a plan. Notice, too, that +nothing guarantees it ever finishes — it could bounce around and simply never +happen to land on red. + +(Random isn't worthless — real robots sometimes add a pinch of it on purpose. But +as *the whole strategy*, it's nothing you can count on, and that's the point.) + +# A rule you can repeat + +Let's give the robot an actual rule — the simplest one imaginable. At every cell, +always try the same directions in the same order, and take the first one that's +open: + +```text +at each cell, try these in order and take the first open one: + up, then left, then down, then right +``` + +That's the entire rule. No randomness, no cleverness. Press **Run the naive +rule**: + +```maze +solver: naive +``` + +Two things to notice. + +First, it's **completely repeatable**. Run it again on this same maze and the +robot retraces the *exact* same steps — every time. That's what random didn't +have, and it's what makes this an **algorithm**: a precise rule that produces the +same result on every run. + +Second… it barely gets anywhere. Watch it take a step and then immediately walk +**right back** the way it came, jittering between two cells forever. (A rule with +*no memory* that returns to a cell it's already seen will make the identical +choice it made last time — so it's provably doomed to loop there. We let it +bounce a few times so you can see it, then stop it.) + +Why does it trap itself so fast? Because "up" is first in its list, and once +it steps down into a cell, the very next thing it does is look up — back where it +came from — and take it. It has **no memory** of having just been there. It +doesn't know which way it was heading, and it doesn't know the goal exists. + +So being an algorithm doesn't make a rule *good*. This one is perfectly precise +and perfectly repeatable — and perfectly useless for crossing the maze. The bar +for "algorithm" is just *definiteness and repeatability*; **whether it actually +works is a separate question**, and it's the interesting one. Give this robot +just *one* fact to remember, and something surprising happens. + +# An algorithm that actually crosses the maze + +Here's the surprise: the **wall follower** is almost the same rule as the naive +one — a fixed order of directions, take the first open one. It adds exactly **one +remembered fact**: which way the robot is currently facing. And it tries its +directions *relative to that heading* instead of relative to the screen: + +```text +at each cell, relative to the way I'm facing, take the first open one: +1. turn right — is there an opening to my right? take it. +2. go straight — else, is the way ahead open? take it. +3. turn left — else, is there an opening left? take it. +4. turn around — else, it's a dead end. go back. +``` + +That's the whole idea behind **"keep your right hand on the wall."** And look at +what it does to the jitter: "turn around" is now **dead last**, so the robot only +reverses at a true dead end — never on the very next step. Because "right" and +"straight" are measured from a heading the robot *remembers* between steps, it +hugs the wall and keeps moving instead of bouncing in place. That single +remembered fact — its facing — is the *only* thing it adds to the naive rule. No +map, no list of visited cells, no idea where the goal is. Press **Run wall +follower**: + +```maze +solver: wall +``` + +## Why it works + +Here's the surprising part. The walls of this maze aren't a scattering of +separate obstacles — they're all **one single connected piece**. The generator +carved the maze by knocking out walls without ever sealing off a loop, so what's +left is one continuous wall with no islands (mazes like this are called *simply +connected*, or "perfect" mazes). + +Now picture tracing your finger along the edge of one connected shape — say, the +outline of a single puzzle piece. Keep going and you always come back around; you +can't get stranded, because there's only one border to follow. The wall follower +does exactly that: it traces the boundary of that one giant wall. Since the start +and the goal both sit on that same connected boundary, faithfully following it +**must** eventually walk the robot from one to the other. It might wander down +dead ends and back out — but it can never get permanently lost. + +That's why the naive rule fails and this one doesn't: it isn't luck, it's the +memory. Facing is just enough state to keep the robot *committed to a wall* +rather than making the same local choice from scratch each time. + +But "works" still isn't "good." The wall follower cheerfully explores every dead +end, and the path it finds is usually far from the **shortest** one. And the +whole guarantee rests on that one assumption — one connected wall. Add a loop to +the maze, a wall island floating in the middle, and the robot can circle that +island forever, hugging a border that never touches the goal. For our perfect +mazes, though, it always gets there. + +## The same skeleton, over and over + +Look back at all three robots and you'll see they're built from the *same loop* — +they only disagree about one step: + +```text +start at the green cell +repeat until you reach the red cell: + look at which directions are open (sense) + choose one of them (decide) + step that way (move) +``` + +Everything interesting lives in that middle line — **"choose one."** Random rolls +a die there; the naive rule reads a fixed list; the wall follower consults its +remembered heading. Swap in a smarter "decide" and you get smarter algorithms: + +- **Wall follower** — "keep your right hand on the wall." One remembered fact, + and it clears any perfect maze. +- **Depth-first search** — "keep pushing into new cells; when you hit a dead end, + back up to the last spot with an untried opening and try that." (Fun fact: this + maze was *built* by that exact idea, running in reverse.) +- **Breadth-first search** — "explore all the cells one step away, then all the + cells two steps away…" — more to write, but it finds the *shortest* path. + +Same skeleton — **sense, decide, move, repeat** — every time, differing only in +"decide." That skeleton is the shape of an enormous amount of robot code: read +your sensors, decide what to do, act, and loop. + +So far this has all been visual — you drove the robot, you watched the three +robots run. But the whole promise of an algorithm is that it's a plan precise +enough to *hand to a computer*. Over the next few lessons you'll do exactly that: +first see the maze as plain **data** a program can hold, then meet the **robot** +your Java code gets to drive, and finally write the wall follower yourself and +watch *your* code solve a maze. diff --git a/site/.gitignore b/site/.gitignore index a547bf3..f1734a1 100644 --- a/site/.gitignore +++ b/site/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# Built by CI on deploy / scripts/vendor-maze-solver.sh locally, not committed. +public/maze-solver.jar + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/site/package-lock.json b/site/package-lock.json index c427862..bbf0ca6 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -19,6 +19,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.23.0", + "maze-generator": "^0.1.2", "radix-ui": "^1.6.1", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -3894,6 +3895,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/maze-generator": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/maze-generator/-/maze-generator-0.1.2.tgz", + "integrity": "sha512-pO2vFebwwRsKnztceR0VcgLMNaYynUO6XSNJepke8t8zJewQbB3D8Mltf+ZSDOFxhXba+4aeleM/nR3pDbxxeQ==", + "license": "MIT" + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", diff --git a/site/package.json b/site/package.json index 1a78c47..6c47cc3 100644 --- a/site/package.json +++ b/site/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", - "preview": "vite preview" + "preview": "vite preview", + "vendor:maze-solver": "bash scripts/vendor-maze-solver.sh" }, "dependencies": { "@codemirror/lang-java": "^6.0.2", @@ -21,6 +22,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.23.0", + "maze-generator": "^0.1.2", "radix-ui": "^1.6.1", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/site/scripts/vendor-maze-solver.sh b/site/scripts/vendor-maze-solver.sh new file mode 100755 index 0000000..4d86c17 --- /dev/null +++ b/site/scripts/vendor-maze-solver.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Build the FRC2713/maze-solver-java jar and vendor it into public/ so the +# Java playground can load its com.frc2713.mazesolver API locally. +# +# Production doesn't use this script — the Pages workflow builds the jar fresh +# on every deploy (see .github/workflows/deploy-pages.yml). Run this once for +# local `npm run dev`/`npm run build`. Requires a JDK (>= 8, capable of +# --release 8) and Maven on your PATH. +set -euo pipefail + +REPO="${MAZE_SOLVER_REPO:-https://github.com/FRC2713/maze-solver-java}" +REF="${MAZE_SOLVER_REF:-main}" +here="$(cd "$(dirname "$0")/.." && pwd)" + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "Cloning $REPO@$REF ..." +git clone --depth 1 --branch "$REF" "$REPO" "$tmp/lib" +echo "Building jar ..." +( cd "$tmp/lib" && mvn -B -q -DskipTests package ) +cp "$tmp/lib/target/maze-solver.jar" "$here/public/maze-solver.jar" +echo "Vendored public/maze-solver.jar ($(wc -c < "$here/public/maze-solver.jar") bytes)" diff --git a/site/src/components/MazePlayground.tsx b/site/src/components/MazePlayground.tsx new file mode 100644 index 0000000..95fda12 --- /dev/null +++ b/site/src/components/MazePlayground.tsx @@ -0,0 +1,551 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import CodeMirror, { EditorView } from '@uiw/react-codemirror' +import { java } from '@codemirror/lang-java' +import { oneDark } from '@codemirror/theme-one-dark' +import { getJavaRuntimeStatus, runJava } from '@/lib/javaRuntime' +import { editorTheme, usePrefersDark } from '@/lib/editorTheme' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { + buildMazeHarness, + mazeGridLiteral, + parseTrail, + retargetErrorLines, + studentLineOffset, +} from '@/lib/mazeHarness' + +// NOTE: the `maze-generator` npm package is broken — its internal `shuffle` is +// written as `for (...; ...; update) return o`, so it returns on the first +// iteration and the Math.random in the update slot never runs. The direction +// order is therefore always ['N','E','S','W'], producing the identical +// degenerate maze every call (and it schedules via a Node-only `setImmediate` +// on top of that). So we generate the same N/S/E/W bitmask grid it was meant +// to produce with a correct recursive-backtracking walk. + +// Cell bitmask: which sides of a cell have been carved open. +const N = 1 +const S = 2 +const E = 4 +const W = 8 +const DX: Record = { E: 1, W: -1, N: 0, S: 0 } +const DY: Record = { E: 0, W: 0, N: -1, S: 1 } +const BIT: Record = { N, S, E, W } +const OPP: Record = { E: W, W: E, N: S, S: N } +const DIRS = ['N', 'E', 'S', 'W'] + +function generateMaze(size: number): number[][] { + const grid = Array.from({ length: size }, () => Array(size).fill(0)) + const seen = Array.from({ length: size }, () => Array(size).fill(false)) + const stack: [number, number][] = [[0, 0]] + seen[0][0] = true + while (stack.length) { + const [x, y] = stack[stack.length - 1] + const dirs = [...DIRS].sort(() => Math.random() - 0.5) + let moved = false + for (const d of dirs) { + const nx = x + DX[d] + const ny = y + DY[d] + if (nx >= 0 && nx < size && ny >= 0 && ny < size && !seen[ny][nx]) { + grid[y][x] |= BIT[d] + grid[ny][nx] |= OPP[d] + seen[ny][nx] = true + stack.push([nx, ny]) + moved = true + break + } + } + if (!moved) stack.pop() + } + return grid +} + +const SIZE = 14 // maze is SIZE x SIZE cells +const CELL = 22 // px per cell +const PAD = 12 + +const START: [number, number] = [0, 0] +const GOAL: [number, number] = [SIZE - 1, SIZE - 1] + +// Which wall bit must be open to step in each direction, keyed by arrow. +const MOVES: Record<'up' | 'down' | 'left' | 'right', { bit: number; dx: number; dy: number }> = { + up: { bit: N, dx: 0, dy: -1 }, + down: { bit: S, dx: 0, dy: 1 }, + left: { bit: W, dx: -1, dy: 0 }, + right: { bit: E, dx: 1, dy: 0 }, +} + +// Center of a cell in svg coordinates. +const cx = (x: number) => PAD + x * CELL + CELL / 2 +const cy = (y: number) => PAD + y * CELL + CELL / 2 + +// Serialize the maze as a ready-to-paste Java `int[][]` literal, wired into the +// maze-solver library so it runs without any bitmask arithmetic. Each cell is +// the same N/S/E/W bitmask the grid uses, but the library reads it for you. +function serializeToJava(grid: number[][]): string { + const rows = mazeGridLiteral(grid) + return `import com.frc2713.mazesolver.*; + +// The maze as a grid of numbers (each cell an N/S/E/W bitmask). Hand it to a +// GridMaze and you get a robot on the start cell — no bitmask math needed. +int[][] grid = { +${rows} +}; + +Maze maze = new GridMaze(grid); +Robot robot = maze.robot(); +// Now drive it: robot.readWallSensor(Direction.UP), robot.drive(Direction.RIGHT), +// robot.facing(), robot.atGoal(), robot.row(), robot.col().` +} + +// Headings for the wall follower, clockwise: 0=up, 1=right, 2=down, 3=left. +const DIR4 = [ + { bit: N, dx: 0, dy: -1 }, // up + { bit: E, dx: 1, dy: 0 }, // right + { bit: S, dx: 0, dy: 1 }, // down + { bit: W, dx: -1, dy: 0 }, // left +] + +// The naive rule's fixed direction priority: up, then left, then down, then +// right. It biases toward the top-left, away from the bottom-right goal, so on +// most mazes the memory-less robot marches into a corner and loops. +const NAIVE_ORDER = [0, 3, 2, 1] + +// The three canned JS-animated demos, plus `java` — the student writes a real +// solver run through the maze round-trip (see lib/mazeHarness.ts). +type CannedMode = 'random' | 'naive' | 'wall' +export type SolverMode = CannedMode | 'java' + +const SOLVER_LABEL: Record = { + random: 'Move at random', + naive: 'Run the naive rule', + wall: 'Run wall follower', +} + +// The editor's starting point for `solver: java`: a right-hand wall follower, +// the same algorithm the maze lessons build up to — written against the +// library's Robot API, using the facing() the robot remembers for itself. +const DEFAULT_JAVA_SOLVE = `// Drive the robot from start (green) to goal (red). Your robot can: +// robot.readWallSensor(Direction.UP/DOWN/LEFT/RIGHT) — is that side a wall? +// robot.drive(Direction.UP/DOWN/LEFT/RIGHT) — move one cell that way +// robot.facing() — the way it last drove +// robot.atGoal(), robot.row(), robot.col() +// +// This starter is the "keep your right hand on the wall" follower. +void solve(Robot robot) { + int maxSteps = 1000; + for (int i = 0; i < maxSteps && !robot.atGoal(); i++) { + // Relative to the way we're facing, try right, straight, left, then + // back — and drive the first opening. drive() updates facing() for us. + Direction ahead = robot.facing(); + Direction[] order = { + ahead.clockwise(), ahead, ahead.counterClockwise(), ahead.opposite() + }; + for (Direction dir : order) { + if (!robot.readWallSensor(dir)) { + robot.drive(dir); + break; + } + } + } +}` + +export function MazePlayground({ solver = null }: { solver?: SolverMode | null }) { + const [grid, setGrid] = useState(() => generateMaze(SIZE)) + const [robot, setRobot] = useState<[number, number]>(START) + const [running, setRunning] = useState(false) + const [stuck, setStuck] = useState(false) + const timer = useRef | null>(null) + const [copied, setCopied] = useState(false) + + // `solver: java` state — the editable Java solver and its run output. + const isJava = solver === 'java' + const [code, setCode] = useState(DEFAULT_JAVA_SOLVE) + const [javaOutput, setJavaOutput] = useState(null) + const [javaOk, setJavaOk] = useState(true) + const dark = usePrefersDark() + const editorExtensions = useMemo(() => [java(), editorTheme, EditorView.lineWrapping], []) + + const copyForJava = () => { + const java = serializeToJava(grid) + navigator.clipboard?.writeText(java).then( + () => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }, + () => {}, + ) + } + + const stopSolver = useCallback(() => { + if (timer.current) clearInterval(timer.current) + timer.current = null + setRunning(false) + }, []) + + // Animate one of the three solvers, one step every 180ms: + // random — step to a uniformly-random open neighbour. No rule, so no two + // runs match; it only reaches the goal by luck. + // naive — take the first open direction in a fixed priority order, with no + // memory of where it came from. Deterministic, so the moment it + // re-enters a cell it will forever repeat the same choices — that + // first revisit is a provable infinite loop, and we stop there. + // wall — the right-hand rule: relative to a *remembered* heading, try + // right, straight, left, back. That one remembered fact (facing) + // is the whole difference from `naive`; in a perfect maze it + // always reaches the goal. + const runSolver = useCallback( + (mode: CannedMode) => { + stopSolver() + let x = START[0] + let y = START[1] + let dir = 1 // wall follower's remembered heading (start facing right) + let steps = 0 + const maxSteps = SIZE * SIZE * 4 + const visits = new Map([[`${x},${y}`, 1]]) + setRobot([x, y]) + setRunning(true) + setStuck(false) + timer.current = setInterval(() => { + if ((x === GOAL[0] && y === GOAL[1]) || steps++ > maxSteps) { + stopSolver() + return + } + if (mode === 'wall') { + for (const nd of [(dir + 1) % 4, dir, (dir + 3) % 4, (dir + 2) % 4]) { + const { bit, dx, dy } = DIR4[nd] + if (grid[y][x] & bit) { + dir = nd + x += dx + y += dy + break + } + } + } else if (mode === 'naive') { + for (const nd of NAIVE_ORDER) { + const { bit, dx, dy } = DIR4[nd] + if (grid[y][x] & bit) { + x += dx + y += dy + break + } + } + } else { + const opens = [0, 1, 2, 3].filter((nd) => grid[y][x] & DIR4[nd].bit) + const { dx, dy } = DIR4[opens[Math.floor(Math.random() * opens.length)]] + x += dx + y += dy + } + setRobot([x, y]) + // A memory-less rule that revisits a cell is doomed to loop forever + // (same cell -> same choice). We don't stop on the *first* revisit + // though — we let the robot visibly bounce a few times so the loop is + // something you can watch, then call it once a cell has been hit 4×. + if (mode === 'naive' && !(x === GOAL[0] && y === GOAL[1])) { + const key = `${x},${y}` + const n = (visits.get(key) ?? 0) + 1 + visits.set(key, n) + if (n >= 4) { + setStuck(true) + stopSolver() + } + } + }, 180) + }, + [grid, stopSolver], + ) + + // Replay a Maze Trail computed in Java: place the robot at each cell in turn, + // same 180ms cadence as the canned solvers but *reading* cells rather than + // computing moves. The trail is exactly what happened in the JVM, so there is + // no wall logic here at all. If it doesn't end on the goal, flag it stuck. + const animateTrail = useCallback( + (cells: [number, number][]) => { + stopSolver() + const last = cells[cells.length - 1] ?? START + const reachedGoal = last[0] === GOAL[0] && last[1] === GOAL[1] + setRobot(cells[0] ?? START) + setStuck(false) + if (cells.length <= 1) { + // Robot never left Start — nothing to animate. + setStuck(!reachedGoal) + return + } + setRunning(true) + let i = 1 + timer.current = setInterval(() => { + if (i >= cells.length) { + stopSolver() + setStuck(!reachedGoal) + return + } + setRobot(cells[i++]) + }, 180) + }, + [stopSolver], + ) + + const runJavaSolver = useCallback(async () => { + stopSolver() + setStuck(false) + setRobot(START) + setJavaOk(true) + setJavaOutput(getJavaRuntimeStatus() === 'ready' ? 'Running…' : 'Loading Java…') + const harness = buildMazeHarness(grid, code) + const result = await runJava(harness) + if (!result.ok) { + const offset = studentLineOffset(grid) + setJavaOk(false) + setJavaOutput(retargetErrorLines(result.output, offset) || '(error)') + return + } + const trail = parseTrail(result.output) + if (!trail) { + setJavaOk(false) + setJavaOutput( + 'Ran, but produced no Maze Trail. Make sure solve(robot) moves the robot.', + ) + return + } + // Show any debug output the student printed, minus the sentinel line. + const debug = result.output + .split('\n') + .filter((l) => !l.startsWith('__TRAIL__ ')) + .join('\n') + .trim() + setJavaOk(true) + setJavaOutput(debug || null) + animateTrail(trail.cells) + }, [grid, code, stopSolver, animateTrail]) + + const generate = () => { + stopSolver() + setStuck(false) + setJavaOutput(null) + setGrid(generateMaze(SIZE)) + setRobot(START) + } + + const move = useCallback( + (dir: keyof typeof MOVES) => { + stopSolver() + setStuck(false) + setRobot(([x, y]) => { + const { bit, dx, dy } = MOVES[dir] + // Can only move through a carved opening (no wall on that side). + if (grid[y][x] & bit) return [x + dx, y + dy] + return [x, y] + }) + }, + [grid, stopSolver], + ) + + useEffect(() => { + setGrid(generateMaze(SIZE)) + setRobot(START) + }, []) + + // Arrow keys drive the robot too. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + const map: Record = { + ArrowUp: 'up', + ArrowDown: 'down', + ArrowLeft: 'left', + ArrowRight: 'right', + } + const dir = map[e.key] + if (dir) { + e.preventDefault() + move(dir) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [move]) + + // Stop any running animation when the component goes away. + useEffect(() => stopSolver, [stopSolver]) + + const w = SIZE * CELL + PAD * 2 + const stroke = 'var(--foreground, #e5e5e5)' + const solved = robot[0] === GOAL[0] && robot[1] === GOAL[1] + + const arrow = (dir: keyof typeof MOVES, label: string) => ( + + ) + + return ( +
+ + {/* Start (green) and goal (red) cells. */} + + + + {grid.map((row, y) => + row.map((cell, x) => { + const px = PAD + x * CELL + const py = PAD + y * CELL + const line = (x1: number, y1: number, x2: number, y2: number, k: string) => ( + + ) + // Draw a wall on every side that was NOT carved open. + return ( + + {!(cell & N) && line(px, py, px + CELL, py, 'n')} + {!(cell & S) && line(px, py + CELL, px + CELL, py + CELL, 's')} + {!(cell & W) && line(px, py, px, py + CELL, 'w')} + {!(cell & E) && line(px + CELL, py, px + CELL, py + CELL, 'e')} + + ) + }), + )} + + {/* The robot. */} + + + +

+ start   + goal   + robot + {solved && — solved! 🎉} + {stuck && !solved && ( + — stuck in a loop 🔁 + )} +

+ + {/* Directional pad. */} +
+
+ {arrow('up', '↑')} +
+ {arrow('left', '←')} + {arrow('down', '↓')} + {arrow('right', '→')} +
+ +
+ {solver && !isJava && ( + + )} + + {!isJava && ( + + )} +
+ + {isJava && ( +
+
+ +
+ + +
+ {javaOutput !== null && ( +
+                {javaOutput}
+              
+ )} +
+
+ )} +
+ ) +} diff --git a/site/src/lib/javaRuntime.ts b/site/src/lib/javaRuntime.ts index 828b9df..29a4aca 100644 --- a/site/src/lib/javaRuntime.ts +++ b/site/src/lib/javaRuntime.ts @@ -12,7 +12,14 @@ const CHEERPJ_LOADER_URL = 'https://cjrtnc.leaningtech.com/4.3/loader.js' // CheerpJ mounts the site's HTTP origin at /app/, so the vendored compiler // lives under the Vite base path (/software_training/ in production). const TOOLS_JAR = `/app${import.meta.env.BASE_URL}tools.jar` -const CLASS_PATH = `${TOOLS_JAR}:/files/` +// FRC2713/maze-solver-java, built into a jar and vendored the same way. Gives +// lesson snippets the com.frc2713.mazesolver API — the Direction enum, the +// Maze/Cell/Robot interfaces, the concrete GridMaze students build with +// `new GridMaze(grid)`, and MazeSolver — so they write solving algorithms +// against it instead of raw bitmasks. The jar is produced by CI on deploy and +// by scripts/vendor-maze-solver.sh locally. +const SOLVER_JAR = `/app${import.meta.env.BASE_URL}maze-solver.jar` +const CLASS_PATH = `${TOOLS_JAR}:${SOLVER_JAR}:/files/` // Globals installed by loader.js (a classic script, not an ES module). declare global { diff --git a/site/src/lib/lessons.ts b/site/src/lib/lessons.ts index 6724ef9..c3d1c06 100644 --- a/site/src/lib/lessons.ts +++ b/site/src/lib/lessons.ts @@ -144,6 +144,32 @@ export function blocksPreset(markdown: string): string | null { export function stripBlocksFence(markdown: string): string { return markdown .replace(/```blocks\r?\n[\s\S]*?```/g, '') + .replace(/```maze[\s\S]*?```/g, '') .replace(/\n{3,}/g, '\n\n') .trim() } + +// A page can swap the playground for the maze-generator demo with a ```maze +// fence. Like ```blocks it's a directive, not content, so it's stripped from +// the prose. +const MAZE_FENCE = /```maze\r?\n?([\s\S]*?)```/ + +export function hasMaze(markdown: string): boolean { + return MAZE_FENCE.test(markdown) +} + +// The maze fence can carry a `solver:` directive to reveal a solver control. +// The three canned modes drive the "what is an algorithm?" progression: +// random — move to a random open neighbour (not an algorithm: unrepeatable) +// naive — fixed direction priority, no memory (an algorithm, but it loops) +// wall — the wall follower (an algorithm that works, if not optimally) +// The fourth, `java`, swaps the canned button for an editor where the student +// writes their own solver in real Java, run through the maze round-trip. +export type SolverMode = 'random' | 'naive' | 'wall' | 'java' + +export function mazeSolver(markdown: string): SolverMode | null { + const match = MAZE_FENCE.exec(markdown) + if (!match) return null + const m = /solver:\s*(random|naive|wall|java)/.exec(match[1]) + return m ? (m[1] as SolverMode) : null +} diff --git a/site/src/lib/mazeHarness.ts b/site/src/lib/mazeHarness.ts new file mode 100644 index 0000000..117fee9 --- /dev/null +++ b/site/src/lib/mazeHarness.ts @@ -0,0 +1,93 @@ +// The JS → Java → animated-steps maze round-trip (see docs/maze-roundtrip.md). +// +// A student writes a `solve(Robot)` algorithm in the maze playground. To run it +// we build a full Java compilation unit here: the current maze is interpolated +// in as an `int[][]` literal, the student's method is spliced into the class +// body, and a `main` drives the robot then prints the Maze Trail as a single +// sentinel-tagged line for the JS side to parse and animate back. +// +// `new GridMaze(grid)` resolves against the maze-solver library on the +// classpath (see lib/javaRuntime.ts). Nothing maze-specific is bundled into +// the harness source itself. + +// The maze as a ready-to-embed Java `int[][]` literal body (just the rows, no +// surrounding `int[][] x = {...}`). Each cell is the same N/S/E/W bitmask the JS +// grid uses — a set bit means that side is OPEN — so Java reads it with the +// identical constants. This is the one source of truth for the literal format; +// both the harness and "Copy for Java" build on it. +export function mazeGridLiteral(grid: number[][]): string { + return grid.map((row) => ' {' + row.join(', ') + '}').join(',\n') +} + +// Everything the harness puts *above* the student's spliced-in code. Kept as its +// own string so we can measure its line count and translate compile-error line +// numbers back to what the student sees in their editor. +function harnessPrefix(grid: number[][]): string { + return `import com.frc2713.mazesolver.*; + +public class MazeRun { + static final int[][] GRID = { +${mazeGridLiteral(grid)} + }; + + public static void main(String[] args) { + Maze maze = new GridMaze(GRID); + Robot robot = maze.robot(); + + new MazeRun().solve(robot); + + // Emit the Maze Trail as one sentinel-tagged JSON line of [row,col] pairs. + int[][] trail = robot.trail(); + StringBuilder sb = new StringBuilder("__TRAIL__ ["); + for (int i = 0; i < trail.length; i++) { + if (i > 0) sb.append(','); + sb.append('[').append(trail[i][0]).append(',').append(trail[i][1]).append(']'); + } + System.out.println(sb.append(']')); + } + + // ===== your algorithm ===== +` +} + +// The harness closes the class after the student's spliced-in code. +const HARNESS_SUFFIX = '\n}\n' + +// How many lines the harness inserts above the student's first line. Compile +// errors come back as "line N: ..." counted in the assembled file; subtracting +// this maps them to the line the student actually typed. +export function studentLineOffset(grid: number[][]): number { + return harnessPrefix(grid).split('\n').length - 1 +} + +// Assemble the full compilation unit: harness prefix + student code + close. +export function buildMazeHarness(grid: number[][], studentCode: string): string { + return harnessPrefix(grid) + studentCode.trimEnd() + HARNESS_SUFFIX +} + +// Rewrite the "line N:" prefixes in a compile-error message so they point at the +// student's editor lines instead of the assembled harness. Runtime errors have +// no line numbers under CheerpJ, so they pass through untouched. +export function retargetErrorLines(output: string, offset: number): string { + return output.replace(/^line (\d+):/gm, (_m, n) => `line ${Math.max(1, Number(n) - offset)}:`) +} + +export interface TrailResult { + // Cells in [x, y] = [col, row] order (the swap from Java-native [row, col] + // happens here and nowhere else), including the Start cell first. + cells: [number, number][] +} + +// Find the __TRAIL__ sentinel line in captured stdout and parse it. Returns null +// if no sentinel line is present (treat as "algorithm produced no trail"). +export function parseTrail(output: string): TrailResult | null { + const line = output.split('\n').find((l) => l.startsWith('__TRAIL__ ')) + if (!line) return null + try { + const rowCol = JSON.parse(line.slice('__TRAIL__ '.length)) as [number, number][] + // The one conversion point: Java is [row, col]; the robot state is [x, y]. + return { cells: rowCol.map(([row, col]) => [col, row]) } + } catch { + return null + } +} diff --git a/site/src/maze-generator.d.ts b/site/src/maze-generator.d.ts new file mode 100644 index 0000000..61dfcee --- /dev/null +++ b/site/src/maze-generator.d.ts @@ -0,0 +1,9 @@ +// The `maze-generator` npm package ships no types. It exports a single function +// that returns a grid of cells, each an N/S/E/W bitmask (N=1, S=2, E=4, W=8) +// marking which sides are carved open. +declare module 'maze-generator' { + export default function mazeGenerator( + size: [number, number], + algorithm?: string, + ): number[][] +} diff --git a/site/src/routes/LessonView.tsx b/site/src/routes/LessonView.tsx index 0ddd746..ff639f6 100644 --- a/site/src/routes/LessonView.tsx +++ b/site/src/routes/LessonView.tsx @@ -7,6 +7,8 @@ import { blocksPreset, firstJavaSnippet, getLesson, + hasMaze, + mazeSolver, lessonNumber, nextLesson, stripBlocksFence, @@ -14,6 +16,7 @@ import { import { JavaRunner } from '@/components/JavaRunner' import { CodeBlock } from '@/components/CodeBlock' import { BlockPlayground } from '@/components/BlockPlayground' +import { MazePlayground } from '@/components/MazePlayground' import { StatePlayground } from '@/components/StatePlayground' import { isStatePreset } from '@/lib/statePresets' import { PageNav } from '@/components/PageNav' @@ -87,9 +90,11 @@ export function LessonView() { : 0 const currentPage = lesson.pages[pageIndex] const preset = blocksPreset(currentPage.markdown) - const prose = preset ? stripBlocksFence(currentPage.markdown) : currentPage.markdown + const maze = hasMaze(currentPage.markdown) + const prose = + preset || maze ? stripBlocksFence(currentPage.markdown) : currentPage.markdown const javaSnippet = firstJavaSnippet(currentPage.markdown) - const hasPlayground = preset != null || javaSnippet != null + const hasPlayground = preset != null || maze || javaSnippet != null const goTo = (index: number) => navigate(`/lesson/${lesson.slug}/${index + 1}`) @@ -114,7 +119,15 @@ export function LessonView() { {hasPlayground && (
- {preset && isStatePreset(preset) ? ( + {maze ? ( + <> +

▶ Maze

+ + + ) : preset && isStatePreset(preset) ? ( <>

▶ State machine