feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
Audit pass: - Frame rule counts frames since the last move, so every step lasts exactly movesEvery(score) frames, also right after a speed-up - clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap - Pong Restart keeps held keys - Fact corrections (mulberry32 output coverage, Haskell IO, uniform) - New fixture cases from a mutation analysis Extensions (all three tracks, shared fixtures, course text): - Turn queue (Exercise 10.6), persisted high score (10.7), replay viewer (10.8), play() end-to-end helper (9.5) - Optional State (4.7) and optional Module 7b, stack-safe IO - Module 12, property-based testing with generators, shrinking, invariants and a model-based oracle - Module 13, Conway's Life with a Click event - JS type checking via jsconfig, JSDoc and types.d.ts - Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh, Playwright end-to-end suite (e2e/, scripts/e2e.sh) Docs: docs/contract.md is the canonical contract; specs and build log under docs/superpowers. Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Pure Snake — a functional programming course
|
||||
|
||||
Build a complete, playable **Snake** game in which *everything* is pure: the rules, the randomness, the drawing, the clock, the keyboard, and the program itself. Then convert a second game, **Pong**, on your own, using the method you practised.
|
||||
Build a complete, playable **Snake** game in which *everything* is pure: the rules, the randomness, the drawing, the clock, the keyboard, and the program itself. Then convert a second game, **Pong**, on your own, using the method you practised, and a third, **Conway's Game of Life**, which you play with the mouse. A bonus module turns your own random generator into a property-based testing tool and uses it to hunt bugs in the game.
|
||||
|
||||
You can follow the course in **JavaScript** (the primary track), **Scala 3** or **F#**. Each track has its own test suites, and all three share the same text and the same test cases.
|
||||
|
||||
@@ -51,17 +51,17 @@ Run the tests (from the repository root):
|
||||
|
||||
In a fresh checkout, Module 0's tests pass and every other module's tests fail: the files in `src/` are stubs for you to fill in.
|
||||
|
||||
Run the game (it is playable once you have finished Module 9; Pong after Module 11):
|
||||
Run the game (it is playable once you have finished Module 9; the replay viewer after Exercise 10.8, Pong after Module 11, Life after Module 13):
|
||||
|
||||
```sh
|
||||
node scripts/serve.mjs # from the repository root; serves on http://localhost:8000
|
||||
```
|
||||
|
||||
- **JavaScript:** open <http://localhost:8000/js/> (Pong: <http://localhost:8000/js/pong.html>). No build step.
|
||||
- **Scala:** first `cd scala && ./build-web.sh` (the first run downloads the Scala.js linker), then open <http://localhost:8000/scala/> (Pong: `/scala/pong.html`).
|
||||
- **F#:** first `cd fsharp && ./build-web.sh` (the first run restores Fable), then open <http://localhost:8000/fsharp/> (Pong: `/fsharp/pong.html`).
|
||||
- **JavaScript:** open <http://localhost:8000/js/> (Pong: <http://localhost:8000/js/pong.html>, the replay viewer: <http://localhost:8000/js/replay.html>, Life: <http://localhost:8000/js/life.html>). No build step.
|
||||
- **Scala:** first `cd scala && ./build-web.sh` (the first run downloads the Scala.js linker), then open <http://localhost:8000/scala/> (Pong: `/scala/pong.html`, replay: `/scala/replay.html`, Life: `/scala/life.html`).
|
||||
- **F#:** first `cd fsharp && ./build-web.sh` (the first run restores Fable), then open <http://localhost:8000/fsharp/> (Pong: `/fsharp/pong.html`, replay: `/fsharp/replay.html`, Life: `/fsharp/life.html`).
|
||||
|
||||
Rebuild with `build-web.sh` after changing Scala or F# code. Arrows or WASD move, Space pauses, R restarts (pause and restart arrive in Module 10).
|
||||
Rebuild with `build-web.sh` after changing Scala or F# code. Arrows or WASD move, Space pauses, R restarts (pause and restart arrive in Module 10). In Life, click cells; Space runs, N steps, R randomizes, C clears.
|
||||
|
||||
## Module map
|
||||
|
||||
@@ -75,10 +75,13 @@ Rebuild with `build-web.sh` after changing Scala or F# code. Arrows or WASD move
|
||||
| 5 | [Folding time](course/05-folding-time.md) | a game is a fold of `update` over its actions; replays for free | `game.js` · `Game.scala` · `Game.fs` |
|
||||
| 6 | [Rendering as data](course/06-rendering-as-data.md) | `view` returns a description of the picture | `view.js` · `View.scala` · `View.fs` |
|
||||
| 7 | [The IO type](course/07-the-io-type.md) | effects as values; interpreters; monad laws | `io.js` · `IO.scala` · `IO.fs` |
|
||||
| 7b | [Stack-safe IO (optional)](course/07b-stack-safe-io.md) | `flatMap` as data; re-association; a trampolined interpreter; no later module needs it | `safeio.js` · `SafeIO.scala` · `SafeIO.fs` |
|
||||
| 8 | [Event streams](course/08-event-streams.md) | streams as pure automata (Mealy machines) | `stream.js` · `Stream.scala` · `Stream.fs` |
|
||||
| 9 | [Wiring it together](course/09-wiring-it-together.md) | the program is one value; the runtime is the only impure file | `input.js`, `app.js`, `runtime.js`, `browser.js` · `Input.scala`, `App.scala`, `Runtime.scala` · `Input.fs`, `App.fs`, `Runtime.fs` |
|
||||
| 10 | [Extensions, all pure](course/10-extensions.md) | pause, restart, speed-up, added without touching the edge | edits `game`, `input`, `app` |
|
||||
| 10 | [Extensions, all pure](course/10-extensions.md) | pause, restart, speed-up, surviving a background tab and a turn queue without touching the edge; then a persisted high score, the first new effect; and a replay viewer | edits `game`, `input`, `stream`, `app`; for the high score also `io`, `runtime` (and `browser.js` in JS); new `replay.js` · `Replay.scala` · `Replay.fs` |
|
||||
| 11 | [Capstone: Pong, by the method](course/11-capstone-pong.md) | convert a second game on your own | `pong/` · `pong/` · `Pong/` |
|
||||
| 12 | [Testing with properties (bonus)](course/12-property-testing.md) | generators are `Random` values; a failing game is a replay you can shrink | `check.js` · `Check.scala` · `Check.fs` |
|
||||
| 13 | [Capstone: Conway's Life, with the mouse](course/13-capstone-life.md) | a third conversion; the next generation from the whole old board; a new kind of input at the edge | `life/` · `life/` · `Life/`; edits `runtime` (and `browser.js` in JS) |
|
||||
| — | [The method](course/method.md) | the conversion recipe, a worked example, a checklist | read after Module 9, before Module 11 |
|
||||
| — | [Reference](course/reference.md) | every API in the three languages side by side | look things up here |
|
||||
|
||||
@@ -112,19 +115,22 @@ The files of each module (this mapping is also what `scripts/check-progression.s
|
||||
| 01 | `util.js` | `Util.scala` | `Util.fs` |
|
||||
| 02 | `model.js` | `Model.scala` | `Model.fs` |
|
||||
| 03 | `rules.js` | `Rules.scala` | `Rules.fs` |
|
||||
| 04 | `random.js`, `spawn.js` | `Random.scala`, `Spawn.scala` | `Random.fs`, `Spawn.fs` |
|
||||
| 04 | `random.js`, `spawn.js`, `state.js` (optional Exercise 4.7) | `Random.scala`, `Spawn.scala`, `State.scala` | `Random.fs`, `Spawn.fs`, `State.fs` |
|
||||
| 05 | `game.js` | `Game.scala` | `Game.fs` |
|
||||
| 06 | `view.js` | `View.scala` | `View.fs` |
|
||||
| 07 | `io.js` | `IO.scala` | `IO.fs` |
|
||||
| 07b | `safeio.js` (optional module) | `SafeIO.scala` | `SafeIO.fs` |
|
||||
| 08 | `stream.js` | `Stream.scala` | `Stream.fs` |
|
||||
| 09 | `input.js`, `app.js`, `runtime.js`, `browser.js` (`main.js` is already complete in `src/`) | `Input.scala`, `App.scala`, `Runtime.scala` | `Input.fs`, `App.fs`, `Runtime.fs` |
|
||||
| 10 | no new files: Module 10 extends `game`, `input` and `app` | same | same |
|
||||
| 10 | `replay.js` (`replay-main.js` is already complete in `src/`); Module 10 also extends `game`, `input`, `stream`, `app`, `io`, `runtime` and (JS) `browser.js` | `Replay.scala` | `Replay.fs` |
|
||||
| 11 | the folder `pong/` | the folder `pong/` | the folder `Pong/` |
|
||||
| 12 | `check.js` | `Check.scala` | `Check.fs` |
|
||||
| 13 | the folder `life/`; Module 13 also adds the click to `runtime.js` and `browser.js` | the folder `life/` (and the click in `Runtime.scala`) | the folder `Life/` (and the click in `Runtime.fs`) |
|
||||
|
||||
Two things to know:
|
||||
|
||||
- A solution file is the **finished** file. `game`, `input` and `app` already contain the Module 10 extensions, so taking the Module 5 or Module 9 checkpoint shows you those parts early. If you take one, skip reading the Module 10 parts, or take the Module 10 checkpoint later the same way.
|
||||
- The F# project files (`Snake.fsproj`) and the browser glue (`scala/web/`, `fsharp/web/`, `js/src/main.js`, `js/src/pong/main.js`) are not exercises; never copy them.
|
||||
- A solution file is the **finished** file. `game`, `input`, `stream`, `app`, `io`, `runtime` and `browser.js` already contain the Module 10 extensions (and `runtime` and `browser.js` the Module 13 click), so taking the Module 5, 7, 8 or 9 checkpoint shows you those parts early. If you take one, skip reading the Module 10 and 13 parts, or take the later checkpoint the same way. Copy `runtime.js` and `browser.js` together: the finished `runtime.js` asks the platform for `onClick`, which a Module 9 `browser.js` does not have yet.
|
||||
- The F# project files (`Snake.fsproj`), the browser glue (`scala/web/`, `fsharp/web/`, `js/src/main.js`, `js/src/pong/main.js`, `js/src/replay-main.js`, `js/src/life/main.js`) and the JS type declarations (`js/src/types.d.ts`, the same in both trees) are not exercises; never copy them.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -132,18 +138,21 @@ Two things to know:
|
||||
README.md this page
|
||||
MENTOR.md instructions for an AI acting as a Socratic mentor
|
||||
AGENTS.md, CLAUDE.md pointers to MENTOR.md for coding assistants
|
||||
course/ the text: 00-setup.md ... 11-capstone-pong.md, method.md, reference.md
|
||||
fixtures/ shared test cases as JSON (mNN-<function>.json), read by all three tracks
|
||||
js/ src/ your code (stubs), one file per topic; src/pong/ for Module 11
|
||||
course/ the text: 00-setup.md ... 13-capstone-life.md, method.md, reference.md
|
||||
fixtures/ shared test cases as JSON (mNN-<function>.json), read by all three tracks; replay-sample.json, the recording the replay viewer plays
|
||||
js/ src/ your code (stubs), one file per topic; src/pong/ for Module 11, src/life/ for Module 13
|
||||
solutions/ reference solutions, same file names
|
||||
test/ mNN-*.test.js, purity.test.js; test.sh
|
||||
index.html, pong.html
|
||||
index.html, pong.html, replay.html, life.html
|
||||
jsconfig.json, src/types.d.ts optional type checking in the editor (given, not exercises)
|
||||
scala/ src/, solutions/, test/ (MNN*.test.scala, Purity.test.scala), test.sh, project.scala
|
||||
web/ browser glue (given) and build output; build-web.sh; index.html, pong.html
|
||||
fsharp/ src/, solutions/ (with Snake.fsproj), tests/ (Snake.Tests.fsproj, MNN*.fs, Purity.fs), test.sh
|
||||
web/ Fable project and browser glue (given), build output; build-web.sh; index.html, pong.html
|
||||
scripts/ serve.mjs a tiny static web server (port 8000) for playing the game
|
||||
verify.sh, check-stubs.sh, check-progression.sh, smoke.mjs, check-links.mjs maintainer tools
|
||||
verify.sh, check-stubs.sh, check-progression.sh, smoke.mjs, check-links.mjs,
|
||||
mutate.mjs (+ mutate-allow.txt), typecheck-js.sh, e2e.sh maintainer tools
|
||||
e2e/ maintainer tooling: Playwright end-to-end tests of every track's pages against the JS model
|
||||
```
|
||||
|
||||
## For maintainers
|
||||
@@ -153,10 +162,13 @@ These scripts check the course itself; students do not need them. Run them from
|
||||
| Script | What it checks |
|
||||
|---|---|
|
||||
| `scripts/verify.sh <js\|scala\|fsharp>` | Copies the repo to a temp dir, replaces `src/` with `solutions/`, runs the track's whole suite. Must pass. |
|
||||
| `scripts/check-stubs.sh <track>` | Runs each module's tests (M01–M11) against the stubs in `src/`. Every module must fail; tooling or build errors exit 2 so they cannot pass for "fails". |
|
||||
| `scripts/check-stubs.sh <track>` | Runs each module's tests (M01–M13 and the optional M07b) against the stubs in `src/`. Every module must fail; tooling or build errors exit 2 so they cannot pass for "fails". |
|
||||
| `scripts/check-progression.sh <track>` | For each module N, puts the solutions of modules ≤ N into `src/` (the rest stay stubs) and runs the tests of M01..MN. Proves that each checkpoint is self-contained. |
|
||||
| `node scripts/smoke.mjs <url>` | Opens a page in headless Chrome (DevTools protocol, no dependencies) and checks that it throws no errors and paints the canvas. Serve a copy of the repo whose `src/` holds the solutions (Scala/F#: after `build-web.sh`) and check `js/`, `scala/`, `fsharp/` `index.html` and `pong.html`. Set `CHROME` if Chrome is not at `/usr/bin/google-chrome`. |
|
||||
| `node scripts/smoke.mjs <url>` | Opens a page in headless Chrome (DevTools protocol, no dependencies) and checks that it throws no errors and paints the canvas. Serve a copy of the repo whose `src/` holds the solutions (Scala/F#: after `build-web.sh`) and check `js/`, `scala/`, `fsharp/` `index.html`, `pong.html`, `replay.html` and `life.html`. Set `CHROME` if Chrome is not at `/usr/bin/google-chrome`. |
|
||||
| `node scripts/check-links.mjs` | Checks every http(s) link in the Markdown files (HEAD, then GET, following redirects) and every relative link and anchor. `--offline` checks only the relative ones. |
|
||||
| `scripts/typecheck-js.sh` | Type-checks the JS track with TypeScript (`js/jsconfig.json`, types in `js/src/types.d.ts`, the `@type` lines in the doc comments): once the untouched stubs in `js/src` (a student's starting point shows no errors), once a temp copy with the solutions as `src`. Both must be clean. Nothing is installed in the repo; it runs `npx -y -p typescript tsc`, so it needs the network the first time and exits 2 with a message when it cannot run. `TS_PACKAGE=typescript@5.9` pins a version. |
|
||||
| `scripts/e2e.sh [--tracks js,scala,fsharp] [-- <playwright args>]` | End-to-end tests with Playwright (`e2e/`, maintainer tooling; the course tracks stay free of dependencies). Copies the repo to a temp dir with the solutions as `src/`, runs `build-web.sh` for Scala and F#, serves the copy on a free port and runs every track's four pages in Chromium and WebKit (Firefox too if a build is installed; browsers are never downloaded, a missing one is skipped with a message). Each page gets a fixed seed, a paused fake clock and a recorder of every `fillRect`/`fillText`; the pure JS solutions, run in Node on the same seed and inputs, say what must be drawn, and the page must match them frame by frame and shape by shape. Snake: first frame, step timing, a scripted game past the third apple, the turn queue, pause, game over and restart, the 250 ms time clamp, the best score (saved, reloaded, garbage or throwing storage); Pong: held keys, a whole match, restart; replay viewer: keys and autoplay; Life: clicks on a CSS-scaled canvas, N, Space, C, R; no page or console errors anywhere. The first run installs `@playwright/test` into `e2e/node_modules` (network once). `--base-url <url>` tests a copy you serve yourself. Exit code is Playwright's; a failure prints the first frame and shape where page and model differ. It does not replace a person playing. |
|
||||
| `node scripts/mutate.mjs [file...]` | Mutation analysis of the JS suite: makes single-token mutants of the `js/solutions` files (default: all but the browser glue; or the files named, relative to `js/solutions`), runs the whole JS suite against each in a temp copy, and prints killed/survived with every survivor's file, line, original and mutated text. Exits 1 if a survivor is not in `scripts/mutate-allow.txt`, the list of known equivalent mutants with a reason per line; exits 2 if the suite already fails without mutants (`--skip-failing` skips those tests). `--jobs N` sets the parallelism. |
|
||||
|
||||
Conventions: student code uses no FP libraries; `src/` and `solutions/` contain the same file names; the purity audit test of each track bans mutation, reassignment, randomness, clocks and I/O outside the runtime files; pure-logic test cases live in `fixtures/` so the three tracks cannot drift apart.
|
||||
|
||||
@@ -173,4 +185,6 @@ The ideas in this course are not new; each module ends with links to where they
|
||||
- Gabriel Gonzalez, [You could have invented free monads](https://www.haskellforall.com/2012/06/you-could-have-invented-free-monads.html).
|
||||
- [Mostly Adequate Guide to Functional Programming](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) (FP in JavaScript).
|
||||
- The mulberry32 generator, by Tommy Ettinger ([gist](https://gist.github.com/tommyettinger/46a874533244883189143505d203312c)); the course's numbers agree with [bryc's JavaScript reference](https://github.com/bryc/code/blob/master/jshash/PRNGs.md).
|
||||
- Koen Claessen and John Hughes, [QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs](https://www.cs.tufts.edu/~nr/cs257/archive/john-hughes/quick.pdf) (Module 12).
|
||||
- Glenn Fiedler, [Fix Your Timestep!](https://gafferongames.com/post/fix_your_timestep/) (the frame clock and the time clamp of Module 10).
|
||||
- Tools: [Node.js test runner](https://nodejs.org/api/test.html), [munit](https://scalameta.org/munit/), [Expecto](https://github.com/haf/expecto), [Scala.js](https://www.scala-js.org/), [Fable](https://fable.io/).
|
||||
Reference in new issue
Block a user