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:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 12:05:29 +01:00
1 parent 876f5aabb1
commit edd57f0148
253 files changed
+21753 -503

No files matched your search

+6
View File
@@ -13,3 +13,9 @@ obj/
# build outputs # build outputs
.fable/ .fable/
*/web/node_modules/ */web/node_modules/
# end-to-end tests (e2e/): dependencies, reports, traces
e2e/node_modules/
e2e/playwright-report/
e2e/test-results/
e2e/blob-report/
+32 -18
View File
@@ -1,6 +1,6 @@
# Pure Snake — a functional programming course # 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. 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. 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 ```sh
node scripts/serve.mjs # from the repository root; serves on http://localhost:8000 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. - **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`). - **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`). - **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 ## 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` | | 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` | | 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` | | 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` | | 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` | | 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/` | | 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 | | — | [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 | | — | [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` | | 01 | `util.js` | `Util.scala` | `Util.fs` |
| 02 | `model.js` | `Model.scala` | `Model.fs` | | 02 | `model.js` | `Model.scala` | `Model.fs` |
| 03 | `rules.js` | `Rules.scala` | `Rules.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` | | 05 | `game.js` | `Game.scala` | `Game.fs` |
| 06 | `view.js` | `View.scala` | `View.fs` | | 06 | `view.js` | `View.scala` | `View.fs` |
| 07 | `io.js` | `IO.scala` | `IO.fs` | | 07 | `io.js` | `IO.scala` | `IO.fs` |
| 07b | `safeio.js` (optional module) | `SafeIO.scala` | `SafeIO.fs` |
| 08 | `stream.js` | `Stream.scala` | `Stream.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` | | 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/` | | 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: 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. - 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`) and the browser glue (`scala/web/`, `fsharp/web/`, `js/src/main.js`, `js/src/pong/main.js`) are not exercises; never copy them. - 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 ## Repository layout
@@ -132,18 +138,21 @@ Two things to know:
README.md this page README.md this page
MENTOR.md instructions for an AI acting as a Socratic mentor MENTOR.md instructions for an AI acting as a Socratic mentor
AGENTS.md, CLAUDE.md pointers to MENTOR.md for coding assistants 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 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 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 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 solutions/ reference solutions, same file names
test/ mNN-*.test.js, purity.test.js; test.sh 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 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 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 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 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 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 ## For maintainers
@@ -153,10 +162,13 @@ These scripts check the course itself; students do not need them. Run them from
| Script | What it checks | | 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/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. | | `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. | | `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. 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). - 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). - [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). - 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/). - 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/).
+39 -3
View File
@@ -49,6 +49,10 @@ First **Snake**: a 20 x 20 board, a snake that moves one cell per tick, apples t
Then, in the capstone (Module 11), **Pong**: you convert a second game on your own, applying a recipe you have practised through the whole course. Then, in the capstone (Module 11), **Pong**: you convert a second game on your own, applying a recipe you have practised through the whole course.
A bonus module (Module 12) comes back to testing: you build a small property-based testing tool out of your own random generator and use it to find bugs that example tests miss.
A second capstone (Module 13) converts **Conway's Game of Life**, played with the mouse: a new kind of input, and the first change to the edge of the program since Module 9. Along the way, Module 10 also gives you a **replay viewer** that plays back a recorded game of Snake, forwards and backwards.
### The big picture ### The big picture
Here is the whole program you are going to build. Keep coming back to this picture; every module fills in one box. Here is the whole program you are going to build. Keep coming back to this picture; every module fills in one box.
@@ -126,16 +130,43 @@ dotnet --version # 10.x
An editor with language support helps a lot: VS Code with the Metals extension (Scala) or Ionide (F#), or IntelliJ / Rider. For JavaScript any editor is fine. An editor with language support helps a lot: VS Code with the Metals extension (Scala) or Ionide (F#), or IntelliJ / Rider. For JavaScript any editor is fine.
#### If your editor understands types (JavaScript, optional)
Scala and F# check the shape of your data before the program runs: pass a direction where a point is expected and the compiler refuses. Plain JavaScript only finds out when that line runs, often far from the mistake. You can get much of the compiler's help in JavaScript without installing anything and without changing how the course works.
The `js/` folder contains two given files for this: `js/jsconfig.json` switches on type checking for the files in `js/src/`, and `js/src/types.d.ts` writes down the course's data types (`GameState`, `Action`, `IO`, `Stream` and the rest; the model types themselves are the `@typedef` comments at the top of `model.js`). Every function in `src/` carries one extra line in its doc comment that names its type, for example:
```js
/** One step of the game. ...
* @type {(state: GameState) => GameState} */
export const advance = (state) => todo('m03')
```
The `@type` line is a comment: Node ignores it, and the tests do not care whether it is there. An editor with TypeScript support reads it. **VS Code** does this out of the box: open the repository folder and the JS files in `js/src/` are checked as you type. A misspelled field (`state.nextdir`), a forgotten `null` check on `state.apple`, or a `Turn` built without its direction gets a red underline, and hovering over a function shows its type. The untouched stubs show no errors; red only appears where your code disagrees with the types.
From the command line, the same check is one command (it needs the network the first time, because `npx` downloads TypeScript into npm's cache, not into the repository):
```sh
npx -y -p typescript tsc -p js/jsconfig.json # no output means no type errors
```
Helpers you add yourself have no `@type` line, so the checker guesses their types from their bodies, and sometimes it guesses too wide. The usual case: a helper that returns the pair `[outputs, next]` is read as "an array of outputs-or-streams", not as a pair, and the export that uses it gets a red line although the code is right. Give such a helper a `@type` line of its own, written like the ones on the exports, for example `/** @type {(ms: number, last: number) => Stream<number>} */`; the names it uses come from the `@import` line at the top of the file.
This is a convenience, not a requirement. The tests remain the judge of whether your code is right; the type checker only catches a class of mistakes earlier. If you do not use VS Code or do not want the help, ignore both files. Module 2 shows the one trick worth knowing: how to make the checker tell you about a forgotten `case`.
> **From everyday programming:** this is the "linter in the editor" experience: problems are underlined while you type instead of being reported by a failing run. TypeScript projects get the same checks from `.ts` files; here the types live in comments, so the files stay plain JavaScript that Node runs as they are.
### A tour of the repository ### A tour of the repository
``` ```
README.md front page README.md front page
MENTOR.md instructions for an AI mentor (see "Solo or with a mentor") MENTOR.md instructions for an AI mentor (see "Solo or with a mentor")
course/ this text: 00-setup.md ... 11-capstone-pong.md, reference.md, method.md course/ this text: 00-setup.md ... 13-capstone-life.md (plus the optional 07b), reference.md, method.md
fixtures/ shared test cases as JSON, used by all three tracks fixtures/ shared test cases as JSON, used by all three tracks
js/ src/ YOUR code: stubs to fill in, one file per topic js/ src/ YOUR code: stubs to fill in, one file per topic
solutions/ reference solutions, same file names (try first!) solutions/ reference solutions, same file names (try first!)
test/ tests, one file per module: m03-rules.test.js, ... test/ tests, one file per module: m03-rules.test.js, ...
jsconfig.json, src/types.d.ts optional type checking (see "If your editor understands types")
scala/ src/ solutions/ test/ (same idea; files are PascalCase: Rules.scala) scala/ src/ solutions/ test/ (same idea; files are PascalCase: Rules.scala)
fsharp/ src/ solutions/ tests/ (same idea: Rules.fs) fsharp/ src/ solutions/ tests/ (same idea: Rules.fs)
scripts/serve.mjs a tiny web server for playing the game in the browser scripts/serve.mjs a tiny web server for playing the game in the browser
@@ -155,14 +186,17 @@ Each module tells you, in its header, which files you will edit. The map is the
| 01 | `util.js` | `Util.scala` | `Util.fs` | | 01 | `util.js` | `Util.scala` | `Util.fs` |
| 02 | `model.js` | `Model.scala` | `Model.fs` | | 02 | `model.js` | `Model.scala` | `Model.fs` |
| 03 | `rules.js` | `Rules.scala` | `Rules.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` (optional: `state.js`) | `Random.scala`, `Spawn.scala` (optional: `State.scala`) | `Random.fs`, `Spawn.fs` (optional: `State.fs`) |
| 05 | `game.js` | `Game.scala` | `Game.fs` | | 05 | `game.js` | `Game.scala` | `Game.fs` |
| 06 | `view.js` | `View.scala` | `View.fs` | | 06 | `view.js` | `View.scala` | `View.fs` |
| 07 | `io.js` | `IO.scala` | `IO.fs` | | 07 | `io.js` | `IO.scala` | `IO.fs` |
| 07b (optional) | `safeio.js` | `SafeIO.scala` | `SafeIO.fs` |
| 08 | `stream.js` | `Stream.scala` | `Stream.fs` | | 08 | `stream.js` | `Stream.scala` | `Stream.fs` |
| 09 | `input.js`, `app.js`, `runtime.js`, `browser.js` (`main.js` is given) | `Input.scala`, `App.scala`, `Runtime.scala` | `Input.fs`, `App.fs`, `Runtime.fs` | | 09 | `input.js`, `app.js`, `runtime.js`, `browser.js` (`main.js` is given) | `Input.scala`, `App.scala`, `Runtime.scala` | `Input.fs`, `App.fs`, `Runtime.fs` |
| 10 | edits `game`, `input`, `app` | same | same | | 10 | edits `game`, `input`, `stream`, `app`, `io`, `runtime` (JS: also `browser.js`); new: `replay.js` (`replay-main.js` is given) | same, without the browser file; new: `Replay.scala` | same, without the browser file; new: `Replay.fs` |
| 11 | `pong/` | `pong/` | `Pong/` | | 11 | `pong/` | `pong/` | `Pong/` |
| 12 (bonus) | `check.js` | `Check.scala` | `Check.fs` |
| 13 | `life/` (`life/main.js` is given); edits `runtime.js`, `browser.js` | `life/`; edits `Runtime.scala` | `Life/`; edits `Runtime.fs` |
### Running the tests ### Running the tests
@@ -284,6 +318,8 @@ You can play the game once Module 9 is done. For reference, this is how:
- **Scala:** `cd scala && ./build-web.sh` (the first run downloads the Scala.js linker), then serve as above and open http://localhost:8000/scala/ - **Scala:** `cd scala && ./build-web.sh` (the first run downloads the Scala.js linker), then serve as above and open http://localhost:8000/scala/
- **F#:** `cd fsharp && ./build-web.sh` (the first run restores the Fable compiler), serve, and open http://localhost:8000/fsharp/ - **F#:** `cd fsharp && ./build-web.sh` (the first run restores the Fable compiler), serve, and open http://localhost:8000/fsharp/
The other pages sit next to `index.html` in each track's folder: `pong.html` (after Module 11), `replay.html` (the replay viewer, after Exercise 10.8) and `life.html` (after Module 13).
## Exercises ## Exercises
### Exercise 0.1 — Green lights ### Exercise 0.1 — Green lights
+33 -13
View File
@@ -55,8 +55,9 @@ Here is the model the course uses. (Now you may open your stub file: in Scala an
/** @typedef {'Up'|'Down'|'Left'|'Right'} Direction */ /** @typedef {'Up'|'Down'|'Left'|'Right'} Direction */
/** @typedef {'Playing'|'Paused'|'GameOver'|'Won'} Status */ /** @typedef {'Playing'|'Paused'|'GameOver'|'Won'} Status */
/** @typedef {{width: number, height: number, snake: Point[], dir: Direction, nextDir: Direction, /** @typedef {{width: number, height: number, snake: Point[], dir: Direction, nextDir: Direction,
* apple: Point|null, seed: number, score: number, status: Status, frame: number}} GameState */ * apple: Point|null, seed: number, score: number, status: Status, frame: number,
// Action: {tag:'Tick'} | {tag:'Turn', dir} | {tag:'TogglePause'} | {tag:'Restart'} | {tag:'Frame'} * queued: Direction|null}} GameState */
/** @typedef {{tag: 'Tick'} | {tag: 'Turn', dir: Direction} | {tag: 'TogglePause'} | {tag: 'Restart'} | {tag: 'Frame'}} Action */
``` ```
**Scala** **Scala**
@@ -78,7 +79,8 @@ final case class GameState(
dir: Direction, nextDir: Direction, dir: Direction, nextDir: Direction,
apple: Option[Point], apple: Option[Point],
seed: Long, // unsigned 32-bit, 0 until 2^32 seed: Long, // unsigned 32-bit, 0 until 2^32
score: Int, status: Status, frame: Int) score: Int, status: Status, frame: Int,
queued: Option[Direction])
``` ```
**F#** **F#**
@@ -98,10 +100,11 @@ type GameState =
Dir: Direction; NextDir: Direction Dir: Direction; NextDir: Direction
Apple: Point option Apple: Point option
Seed: uint32 Seed: uint32
Score: int; Status: Status; Frame: int } Score: int; Status: Status; Frame: int
Queued: Direction option }
``` ```
A fresh game is 20 x 20, the snake is `(10,10), (9,10), (8,10)` heading `Right` (both `dir` and `nextDir`), there is no apple yet, the seed is whatever you were given, the score is 0, the status `Playing`, and `frame` is 0. The `y` axis grows **downward**, as on a screen: `Up` is `{x: 0, y: -1}`. A fresh game is 20 x 20, the snake is `(10,10), (9,10), (8,10)` heading `Right` (both `dir` and `nextDir`), there is no apple yet, the seed is whatever you were given, the score is 0, the status `Playing`, `frame` is 0 and nothing is queued (`queued` is none). The `y` axis grows **downward**, as on a screen: `Up` is `{x: 0, y: -1}`.
Now compare with your design, question by question. Now compare with your design, question by question.
@@ -123,7 +126,7 @@ Now compare with your design, question by question.
**8. Events as data.** Everything that can happen to the game is a value of type `Action`: `Tick` (time passes one step), `Turn(dir)` (the player asks to turn), `TogglePause`, `Restart`, and `Frame` (a finer-grained clock you will use in Module 10 for speed-ups). Module 5 shows why this is powerful: a whole game becomes "a start state plus a list of actions". **8. Events as data.** Everything that can happen to the game is a value of type `Action`: `Tick` (time passes one step), `Turn(dir)` (the player asks to turn), `TogglePause`, `Restart`, and `Frame` (a finer-grained clock you will use in Module 10 for speed-ups). Module 5 shows why this is powerful: a whole game becomes "a start state plus a list of actions".
There is one more field, `frame`. It counts small clock frames so that the snake can speed up later without changing the clock (Module 10). Until then it stays 0. There are two more fields, both dormant until Module 10. `frame` counts small clock frames since the snake last moved, so that the snake can speed up later without changing the clock; until then it stays 0. `queued` remembers one key press that came too early to be used yet, so that a quick Up-then-Left is not lost (Module 10, Exercise 10.6); until then it stays none. They are in the model from the start so that every fixture, from Module 2 to Module 12, has the same shape: a fixture state always carries `"frame"` and `"queued": null`, even in modules that never look at them.
Did your design differ? Common alternatives and their costs: Did your design differ? Common alternatives and their costs:
@@ -204,7 +207,24 @@ The `match` in Scala and F# is called **pattern matching**: it takes a sum apart
Note the JS lookup-table trick in `nextLight`: an object literal used as a function from a fixed set of strings to values. It is often the clearest way to write a function over a nullary sum type. Note the JS lookup-table trick in `nextLight`: an object literal used as a function from a fixed set of strings to values. It is often the clearest way to write a function over a nullary sum type.
The important difference between the languages is **exhaustiveness checking**. If you add `case Triangle` to Scala's `Shape` and forget to handle it in `area`, the compiler warns you. F# does the same. JavaScript will silently return `undefined`. In JS you must be your own exhaustiveness checker; the fixtures, which cover every case, help. The important difference between the languages is **exhaustiveness checking**. If you add `case Triangle` to Scala's `Shape` and forget to handle it in `area`, the compiler warns you. F# does the same. JavaScript itself will silently return `undefined`. Node never checks; the fixtures, which cover every case, are what catches the mistake when the tests run.
If your editor understands types (Module 0, "If your editor understands types"), you can get the warning in JavaScript too, before running anything. The checker already knows that `Action` is a union of five tagged objects, and inside `case 'Turn':` it knows that `action` has a `dir`. To make it complain about a *forgotten* case, end the `switch` with a `default` that claims nothing is left:
```js
switch (action.tag) {
case 'Tick': return ...
case 'Turn': return ...
// ... the other cases ...
default: {
/** @type {never} */
const unhandled = action // red if some case is missing: "Type '{ tag: "Frame"; }' is not assignable to type 'never'"
return state
}
}
```
`never` is the type with no values. After every handled `case` the checker removes that alternative from the type of `action`; if all five were handled, nothing is left and `action` has type `never` in the `default`, so the assignment is fine. If you forgot `Frame`, `action` can still be `{tag: 'Frame'}` there, and the checker names the missing case. So the precise statement is: in JavaScript the language does not check exhaustiveness, but a type checker reading your comments can, if you ask it to in this way. The stubs do not contain this `default`; add it yourself when you want the check.
### Making illegal states unrepresentable ### Making illegal states unrepresentable
@@ -330,7 +350,7 @@ def initialState(seed: Long): GameState
let initialState (seed: uint32) : GameState let initialState (seed: uint32) : GameState
``` ```
A 20 x 20 board; snake `(10,10), (9,10), (8,10)`; `dir` and `nextDir` both `Right`; no apple; the given seed, stored as given; score 0; status `Playing`; frame 0. A 20 x 20 board; snake `(10,10), (9,10), (8,10)`; `dir` and `nextDir` both `Right`; no apple; the given seed, stored as given; score 0; status `Playing`; frame 0; nothing queued.
Fixture: `fixtures/m02-initial-state.json` (cases `seed-7` and `seed-is-stored-as-given`, which uses the largest 32-bit seed, 4294967295). Watch out: the order of the snake's points matters (head first), and in JS use your `point` function so that every point has the same shape. Fixture: `fixtures/m02-initial-state.json` (cases `seed-7` and `seed-is-stored-as-given`, which uses the largest 32-bit seed, 4294967295). Watch out: the order of the snake's points matters (head first), and in JS use your `point` function so that every point has the same shape.
@@ -433,7 +453,7 @@ This is a single record literal. Read the fixture `m02-initial-state.json`: the
<details><summary>Exercise 2.5 (initialState) — stronger hint</summary> <details><summary>Exercise 2.5 (initialState) — stronger hint</summary>
Ten fields: `width`, `height`, `snake`, `dir`, `nextDir`, `apple`, `seed`, `score`, `status`, `frame`. In Scala/F#, "no apple" is `None`; in JS it is `null`. In Scala the enum values are `Direction.Right` and `Status.Playing`. Eleven fields: `width`, `height`, `snake`, `dir`, `nextDir`, `apple`, `seed`, `score`, `status`, `frame`, `queued`. In Scala/F#, "no apple" and "nothing queued" are `None`; in JS they are `null`. In Scala the enum values are `Direction.Right` and `Status.Playing`.
</details> </details>
@@ -444,19 +464,19 @@ export const initialState = (seed) => ({
width: 20, height: 20, width: 20, height: 20,
snake: [point(10, 10), ___, ___], snake: [point(10, 10), ___, ___],
dir: ___, nextDir: ___, dir: ___, nextDir: ___,
apple: ___, seed, score: ___, status: ___, frame: ___, apple: ___, seed, score: ___, status: ___, frame: ___, queued: ___,
}) })
``` ```
```scala ```scala
def initialState(seed: Long): GameState = def initialState(seed: Long): GameState =
GameState(20, 20, List(Point(10, 10), ___, ___), Direction.Right, ___, ___, seed, ___, ___, ___) GameState(20, 20, List(Point(10, 10), ___, ___), Direction.Right, ___, ___, seed, ___, ___, ___, ___)
``` ```
```fsharp ```fsharp
let initialState (seed: uint32) : GameState = let initialState (seed: uint32) : GameState =
{ Width = 20; Height = 20 { Width = 20; Height = 20
Snake = [ { X = 10; Y = 10 }; ___; ___ ] Snake = [ { X = 10; Y = 10 }; ___; ___ ]
Dir = Right; NextDir = ___ Dir = Right; NextDir = ___
Apple = ___; Seed = seed; Score = ___; Status = ___; Frame = ___ } Apple = ___; Seed = seed; Score = ___; Status = ___; Frame = ___; Queued = ___ }
``` ```
</details> </details>
@@ -475,7 +495,7 @@ let initialState (seed: uint32) : GameState =
## In production ## In production
- **Elm** models every application as a single immutable `Model` record plus a `Msg` custom type for everything that can happen, exactly like `GameState` and `Action`. See [custom types in the Elm guide](https://guide.elm-lang.org/types/custom_types.html). - **Elm** models every application as a single immutable `Model` record plus a `Msg` custom type for everything that can happen, exactly like `GameState` and `Action`. See [custom types in the Elm guide](https://guide.elm-lang.org/types/custom_types.html).
- **TypeScript** checks JavaScript tagged unions at compile time: a `switch` on `tag` can be made exhaustive. See [discriminated unions](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions). (We use plain JS in this course, so you are the type checker.) - **TypeScript** checks JavaScript tagged unions at compile time: a `switch` on `tag` can be made exhaustive. See [discriminated unions](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions). (The course stays in plain JS; its optional `@type` comments give you the same check in an editor, see Module 0.)
- **Redux** actions are tagged objects (`{ type: 'todos/added', payload }`), the same idea as our `Action`. - **Redux** actions are tagged objects (`{ type: 'todos/added', payload }`), the same idea as our `Action`.
- Domain modelling with types is a whole discipline in the F# and Scala communities; see Scott Wlaschin's work below. - Domain modelling with types is a whole discipline in the F# and Scala communities; see Scott Wlaschin's work below.
+3 -2
View File
@@ -163,7 +163,7 @@ The check compared against a direction the snake *had not moved in yet*. The sna
That is why the model has two directions (Module 2, question 3): `dir` is a fact about the body (where the neck is), `nextDir` is a wish. Turns are validated against the fact. The fixture `turn-against-last-moved-dir` is exactly this situation: `dir = Right`, `nextDir = Up`, turn `Left`, expected result: unchanged. That is why the model has two directions (Module 2, question 3): `dir` is a fact about the body (where the neck is), `nextDir` is a wish. Turns are validated against the fact. The fixture `turn-against-last-moved-dir` is exactly this situation: `dir = Right`, `nextDir = Up`, turn `Left`, expected result: unchanged.
Notice the consequences of the rule: several allowed turns within one tick overwrite each other, and "the last allowed one wins" (fixture `turn-replaces-pending-turn`: with `dir = Right` and `nextDir = Up`, pressing `Down` gives `nextDir = Down`, which is allowed because Down is not the opposite of Right). And the player's U-turn intention (Up, then Left) is partly lost: only Up happens. Some games keep a small queue of pending turns to honour both; see the questions at the end. Notice the consequences of the rule: several allowed turns within one tick overwrite each other, and "the last allowed one wins" (fixture `turn-replaces-pending-turn`: with `dir = Right` and `nextDir = Up`, pressing `Down` gives `nextDir = Down`, which is allowed because Down is not the opposite of Right). And the player's U-turn intention (Up, then Left) is partly lost: only Up happens. Some games keep a small queue of pending turns to honour both; see question 4 at the end, and Module 10, where you build one (Exercise 10.6) without changing `turn`.
</details> </details>
@@ -441,11 +441,12 @@ let advance (state: GameState) : GameState =
1. Why does `turn` return the state unchanged when paused, instead of throwing? Who would have to deal with the exception, and how would that change the rest of the program? 1. Why does `turn` return the state unchanged when paused, instead of throwing? Who would have to deal with the exception, and how would that change the rest of the program?
2. Without looking: on a 5 x 5 board, the snake is `(1,1) (1,2) (2,2) (2,1)` heading Up with `nextDir = Right` and an apple at (2,1). What does `advance` return? Now the same with no apple. 2. Without looking: on a 5 x 5 board, the snake is `(1,1) (1,2) (2,2) (2,1)` heading Up with `nextDir = Right` and an apple at (2,1). What does `advance` return? Now the same with no apple.
3. Explain to a friend, with a drawing, why `turn` must compare against `dir` and not `nextDir`. What sequence of key presses kills the snake in the wrong version? 3. Explain to a friend, with a drawing, why `turn` must compare against `dir` and not `nextDir`. What sequence of key presses kills the snake in the wrong version?
4. With the correct `turn`, the player presses Up then Left within one tick, intending a quick U-turn, and only Up happens. Sketch a different model that would honour both presses. What would you add to `GameState`, and how would `advance` change? 4. With the correct `turn`, the player presses Up then Left within one tick, intending a quick U-turn, and only Up happens. Sketch a different model that would honour both presses. What would you add to `GameState`, and how would `advance` change? (Keep your sketch: Module 10, Exercise 10.6 builds the course's answer, and you can compare. Note that the course's version leaves `turn` and `advance` exactly as they are here.)
5. After `advance` crashes into a wall, the snake is unchanged but `dir` is updated. What invariant does that keep? Would anything visible break if `dir` were left unchanged? 5. After `advance` crashes into a wall, the snake is unchanged but `dir` is updated. What invariant does that keep? Would anything visible break if `dir` were left unchanged?
6. `advance` does not place a new apple after eating. Why is it a good idea to keep that out of `advance`? (Think about what `advance` would need as input.) 6. `advance` does not place a new apple after eating. Why is it a good idea to keep that out of `advance`? (Think about what `advance` would need as input.)
7. Predict: `advance(advance(s))` where `s` is a `GameOver` state. And `turn(advance(s), 'Up')` for the same `s`. 7. Predict: `advance(advance(s))` where `s` is a `GameOver` state. And `turn(advance(s), 'Up')` for the same `s`.
8. Why are the fixture boards 5 x 5 rather than the real 20 x 20? What does that tell you about the decision to store `width` and `height` in the state? 8. Why are the fixture boards 5 x 5 rather than the real 20 x 20? What does that tell you about the decision to store `width` and `height` in the state?
9. Build this state by hand: a two-cell snake `(1,1) (0,1)` with `dir = Right` and `nextDir = Left`, no apple. What does `advance` return, by the rules of this module? Is that a bug in `advance`? Explain why no sequence of `turn` and `advance` calls from a new game can ever produce such a state. (A statement like "`nextDir` is never the opposite of `dir`" is called an *invariant*; Module 12 checks invariants on thousands of random games.)
## In production ## In production
+126 -7
View File
@@ -1,6 +1,6 @@
# Module 4 — Randomness without side effects # Module 4 — Randomness without side effects
> **Time:** ~4 h · **You'll edit:** JS `js/src/random.js`, `js/src/spawn.js` · Scala `scala/src/Random.scala`, `scala/src/Spawn.scala` · F# `fsharp/src/Random.fs`, `fsharp/src/Spawn.fs` > **Time:** ~4 h (+1 h for the optional Exercise 4.7) · **You'll edit:** JS `js/src/random.js`, `js/src/spawn.js` (optional: `js/src/state.js`) · Scala `scala/src/Random.scala`, `scala/src/Spawn.scala` (optional: `scala/src/State.scala`) · F# `fsharp/src/Random.fs`, `fsharp/src/Spawn.fs` (optional: `fsharp/src/State.fs`)
> **Tests:** JS `cd js && node --test "test/m04-*.test.js"` · Scala `cd scala && ./test.sh M04` · F# `cd fsharp && ./test.sh M04` > **Tests:** JS `cd js && node --test "test/m04-*.test.js"` · Scala `cd scala && ./test.sh M04` · F# `cd fsharp && ./test.sh M04`
> **Prerequisites:** Modules 1–3 > **Prerequisites:** Modules 1–3
@@ -30,7 +30,8 @@ After this module you can:
- **describe** at a high level how mulberry32 turns a seed into a number and a new seed, and why 32-bit arithmetic must be handled carefully in each language; - **describe** at a high level how mulberry32 turns a seed into a number and a new seed, and why 32-bit arithmetic must be handled carefully in each language;
- **implement** `Random<A> = seed → (A, seed)` with `pure`, `map` and `flatMap`, and recognise it as the State monad; - **implement** `Random<A> = seed → (A, seed)` with `pure`, `map` and `flatMap`, and recognise it as the State monad;
- **use** Scala's for-comprehension and F#'s computation expressions as syntax for the same chaining; - **use** Scala's for-comprehension and F#'s computation expressions as syntax for the same chaining;
- **implement** apple spawning, including the full-board edge case, and explain why "same seed ⇒ same game". - **implement** apple spawning, including the full-board edge case, and explain why "same seed ⇒ same game";
- *(optional)* **generalise** `Random` to `State<S, A>` with `get`, `put` and `modify`, and use it for state other than a seed.
## Concepts ## Concepts
@@ -71,7 +72,9 @@ value = t xor (t >>> 14) -- as an unsigned 32-bit number
return (value, s) return (value, s)
``` ```
(`imul` is 32-bit multiplication, keeping only the low 32 bits; `>>>` is an unsigned right shift.) mulberry32 is fine for games and simulations, and **not** suitable for anything security-related: given one output, the next ones can be predicted. (`imul` is 32-bit multiplication, keeping only the low 32 bits; `>>>` is an unsigned right shift.) mulberry32 is fine for games, and **not** suitable for anything security-related: given one output, the next ones can be predicted.
One more honest detail. The counter visits every 32-bit value once, but the scramble is not one-to-one: different counters can give the same output. Run the generator through its whole period and count (it takes a minute in any of the three languages): only 1 893 145 848 of the 4 294 967 296 possible values ever come out, about 44 %, and those that do come out are not equally frequent. For placing apples this is irrelevant. For a simulation whose results you intend to publish, pick a generator whose output step is a bijection, such as SplitMix or PCG (see "Further reading").
For example, from seed `0` the first draw gives the value `1144304738` and the new seed `1831565813` (which is `0x6D2B79F5`: zero plus the constant). The next draw, from seed `1831565813`, gives `1416247` and seed `3663131626`. These are the first two cases of `fixtures/m04-random.json`. For example, from seed `0` the first draw gives the value `1144304738` and the new seed `1831565813` (which is `0x6D2B79F5`: zero plus the constant). The next draw, from seed `1831565813`, gives `1416247` and seed `3663131626`. These are the first two cases of `fixtures/m04-random.json`.
@@ -260,11 +263,41 @@ JavaScript has no such syntax for user-defined types, so you write the nested `f
> **From everyday programming:** `async`/`await` in JavaScript, C# or Python is the same idea for promises and tasks. `const res = await fetch(url)` reads like an ordinary assignment, but behind the scenes it is a `then` whose callback is the rest of the function. A for-comprehension or a computation expression gives `Random` (or any monad) that same "looks like sequential code" syntax. > **From everyday programming:** `async`/`await` in JavaScript, C# or Python is the same idea for promises and tasks. `const res = await fetch(url)` reads like an ordinary assignment, but behind the scenes it is a `then` whose callback is the rest of the function. A for-comprehension or a computation expression gives `Random` (or any monad) that same "looks like sequential code" syntax.
### Going further: `State` for any kind of state (optional)
This section and Exercise 4.7 are optional: nothing in the game depends on them. If you want to get to the apples, skip to "Drawing a number below `n`" and come back after Exercise 4.6.
The previous section claimed that `Random<A>` is `State<Seed, A>`. A claim like that is worth checking, and the way to check it is to write `State` itself and then rebuild the dice with it.
**Why bother, if `Random` already works?** Because the seed is not the only thing a program threads through by hand. A counter that hands out fresh labels (`L0`, `L1`, `L2`, ...) has the same problem as the seed: each step needs the number the previous step left behind, and reusing an old number is a bug that nothing catches. So does a parser that reads a string piece by piece, where each step returns a value and "the rest of the input". Every one of these is `S -> (A, S)` for some `S`. Write the plumbing once, for every `S`, and each of them gets `map` and `flatMap` for free.
The code of `pure`, `map`, `flatMap` and `run` does not change at all: look at your `random.js` and rename "seed" to "s". What is new are three small building blocks that talk about the state itself. `Random` never needed them, because only `nextUint32` ever touched the seed directly; for a general state, you need a way to read it and to change it:
```
get : State<S, S> s gives [s, s] the value is the state; the state is unchanged
put(s2) : State<S, unit> s gives [nothing, s2] throw the old state away, store s2
modify(f) : State<S, unit> s gives [nothing, f(s)] store f of the old state
```
("unit" is the type with a single value that carries no information: the value a step gives when only its effect on the state matters. JavaScript uses `undefined` for it, Scala `()` of type `Unit`, F# `()` of type `unit`.)
With these, the label counter is one line, and three labels in a row are a chain of `flatMap`s with no counter in sight:
```js
const fresh = flatMap(get, (n) => map(put(n + 1), () => `L${n}`)) // read n, store n + 1, give "Ln"
const three = flatMap(fresh, (a) => flatMap(fresh, (b) => map(fresh, (c) => [a, b, c])))
run(three, 0) // [['L0', 'L1', 'L2'], 3]
```
And a single draw of the generator can be written with nothing but `get` and `put`: read the seed, compute the value and the next seed, store the next seed. Build the dice of the previous section on top of that draw, and you get exactly the values and seeds that `Random` gives. That is the precise meaning of "`Random` is `State` with the seed as `S`", and it is what the last test of Exercise 4.7 checks.
> **From everyday programming:** the usual way to hand out fresh ids is a field and an increment: `this.nextId++` in a class, or a module-level counter. It works until two parts of the program share the counter by accident, or a test needs it to start at 0 again. `State` makes the counter an explicit input and output of every step, so it can never be shared by accident and every run starts where you say. Redux reducers are the same idea in a different shape: `(state, action) => newState` returns the next state instead of changing the current one.
### Drawing a number below `n` ### Drawing a number below `n`
To pick one of `n` free cells, you need a number in `[0, n)`. `intBelow(n)` is `nextUint32` with its value taken modulo `n`. That is a `map`. To pick one of `n` free cells, you need a number in `[0, n)`. `intBelow(n)` is `nextUint32` with its value taken modulo `n`. That is a `map`.
(Strictly, `v % n` is slightly biased when 2^32 is not a multiple of `n`: small results are a tiny bit more likely. For `n` up to 400 the bias is below one in ten million, far below anything a player could notice. Libraries that need exact uniformity reject and redraw; we keep it simple and identical across tracks.) (Strictly, `v % n` is slightly biased when 2^32 is not a multiple of `n`: even for a perfectly uniform `v`, small results would be a tiny bit more likely. For `n` up to 400 that effect is below one in ten million, much smaller than the unevenness of mulberry32 itself, and both are far below anything a player could notice. Libraries that need exact uniformity reject and redraw, see question 9; we keep it simple and identical across tracks.)
### Spawning an apple ### Spawning an apple
@@ -317,7 +350,7 @@ And now the payoff: `newGame(42)` is the same game every time you call it. Any s
## Exercises ## Exercises
> **About test names.** The test titles quoted under "Test"/"Tests" are the JavaScript ones; the JS runner prints them with the module in front (`m04 intBelow always lands in [0, n)`). The Scala and F# suites test the same things one-to-one with the same titles, except that F# says `bind` where JS and Scala say `flatMap` (`bind threads the seed through both steps`). Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M04Random.test.scala` and `M04Spawn.test.scala`, or `fsharp/tests/M04Random.fs` and `M04Spawn.fs`. Fixture-based tests have the same case names in every track (JS `m04 nextUint32: seed-0-step-1`, Scala/F# `nextUint32: seed-0-step-1`). > **About test names.** The test titles quoted under "Test"/"Tests" are the JavaScript ones; the JS runner prints them with the module in front (`m04 intBelow always lands in [0, n)`). The Scala and F# suites test the same things one-to-one with the same titles, except that F# says `bind` where JS and Scala say `flatMap` (`bind threads the seed through both steps`). Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M04Random.test.scala`, `M04Spawn.test.scala` and `M04State.test.scala`, or `fsharp/tests/M04Random.fs`, `M04Spawn.fs` and `M04State.fs`. Fixture-based tests have the same case names in every track (JS `m04 nextUint32: seed-0-step-1`, Scala/F# `nextUint32: seed-0-step-1`).
### Exercise 4.1 — `nextUint32` ### Exercise 4.1 — `nextUint32`
@@ -448,9 +481,52 @@ let newGame (seed: uint32) : GameState
Fixtures: `fixtures/m04-tick.json` (`eat-then-respawn`, `plain-move-no-respawn`, `wall-hit-is-game-over-no-spawn`, `game-over-state-unchanged`) and `fixtures/m04-new-game.json` (`seed-0`, `seed-42`). Tests: `tick is spawnApple after advance`, `newGame is reproducible: same seed, same game`. Fixtures: `fixtures/m04-tick.json` (`eat-then-respawn`, `plain-move-no-respawn`, `wall-hit-is-game-over-no-spawn`, `game-over-state-unchanged`) and `fixtures/m04-new-game.json` (`seed-0`, `seed-42`). Tests: `tick is spawnApple after advance`, `newGame is reproducible: same seed, same game`.
### Exercise 4.7 (optional) — `State`
Read "Going further: `State` for any kind of state" first. The file is new: JS `js/src/state.js`, Scala `scala/src/State.scala`, F# `fsharp/src/State.fs`. Nothing else in the course imports it.
**JavaScript**
```js
export const pure = (a) => ... // State<S, A>: s => [a, s]
export const map = (st, f) => ... // State<S, A>, (A -> B) -> State<S, B>
export const flatMap = (st, f) => ... // State<S, A>, (A -> State<S, B>) -> State<S, B>
export const get = ... // State<S, S> (a value, like nextUint32)
export const put = (s) => ... // State<S, undefined>
export const modify = (f) => ... // State<S, undefined>, f : S -> S
export const run = (st, s) => ... // [A, S]
```
**Scala**
```scala
final case class State[S, A](run: S => (A, S)):
def map[B](f: A => B): State[S, B]
def flatMap[B](f: A => State[S, B]): State[S, B]
object State:
def pure[S, A](a: A): State[S, A]
def get[S]: State[S, S]
def put[S](s: S): State[S, Unit]
def modify[S](f: S => S): State[S, Unit]
```
**F#** (in `module Snake.State`, which also defines the type)
```fsharp
type State<'s, 'a> = State of ('s -> 'a * 's)
let run (s: 's) (State f: State<'s, 'a>) : 'a * 's
let pure (a: 'a) : State<'s, 'a>
let map (f: 'a -> 'b) (st: State<'s, 'a>) : State<'s, 'b>
let bind (f: 'a -> State<'s, 'b>) (st: State<'s, 'a>) : State<'s, 'b>
let get () : State<'s, 's> // a function of unit, so it can be generic
let put (s: 's) : State<'s, unit>
let modify (f: 's -> 's) : State<'s, unit>
```
Scala's `get[S]` needs the state type at the call site when nothing else fixes it: `State.get[Int]`. In the tests, the Random comparison turns a `Random` step into a `State` by hand: Scala `State[Long, Long](seed => Random.nextUint32.run(seed))`, F# `State(fun seed -> Random.run seed Random.nextUint32)`. In JS there is nothing to turn: a `Random` already *is* a function `seed => [value, seed]`.
Tests (in `m04-state.test.js` / `M04State`): `State pure gives its value and leaves the state alone`, `State map applies f to the value and keeps the state from the underlying step`, `State flatMap threads the state through both steps`, `State get gives the state as the value and leaves it alone`, `State put replaces the state and gives undefined` (Scala/F#: "gives unit"), `State modify applies f to the state and gives undefined` (Scala/F#: "gives unit"), the three laws `State law: left identity, ...`, `State law: right identity, ...`, `State law: associativity, ...`, `State a counter hands out fresh labels in order`, and `State Random is State with the seed as S: the dice give the same values and seeds`.
Watch out: like `Random`, every function here returns a State (a function, or a value wrapping one) and runs nothing. `put(s)` ignores the incoming state, but it must still *be* a function of it.
### Run ### Run
- JS: `cd js && node --test "test/m04-*.test.js"` (runs both `m04-random` and `m04-spawn`) - JS: `cd js && node --test "test/m04-*.test.js"` (runs `m04-random`, `m04-spawn` and the optional `m04-state`; until you do Exercise 4.7, the `m04 State ...` tests fail; if you decide to skip it, copy the finished file instead, `cp js/solutions/state.js js/src/state.js`, and the same for `State.scala` / `State.fs`, so that your suite stays green)
- Scala: `cd scala && ./test.sh M04` - Scala: `cd scala && ./test.sh M04`
- F#: `cd fsharp && ./test.sh M04` - F#: `cd fsharp && ./test.sh M04`
@@ -659,6 +735,42 @@ let newGame (seed: uint32) : GameState = seed |> ___ |> ___
</details> </details>
<details><summary>Exercise 4.7 (State, optional) — nudge</summary>
Four of the seven functions are already written: they are in your `random.js` (`Random.scala`, `Random.fs`) under the same names, with "seed" where you now write "s". For the other three, write down what each must return when run with a state `s`, as a pair, and the code follows from the pair.
</details>
<details><summary>Exercise 4.7 (State, optional) — stronger hint</summary>
- `get` run with `s` gives `[s, s]`. In JS, `get` is itself that function (not a function that returns one), just as `nextUint32` is a `Random`.
- `put(s2)` run with any `s` gives `[undefined, s2]`: it ignores `s`, but it is still a function of it.
- `modify(f)` run with `s` gives `[undefined, f(s)]`. (You can also write it with `flatMap`, `get` and `put`: read, then store `f` of what you read.)
- Scala: `get`, `put` and `modify` live in the companion object and wrap their function with `State(...)`; in `put` and `modify` the value is `()`. F#: same shape with `State(fun s -> ...)`; `get` takes `()` so that it can stay generic.
</details>
<details><summary>Exercise 4.7 (State, optional) — near-solution (try the others first)</summary>
```js
export const get = (s) => [___, ___]
export const put = (s) => () => [undefined, ___]
export const modify = (f) => (s) => [___, ___]
```
```scala
def get[S]: State[S, S] = State(s => (___, ___))
def put[S](s: S): State[S, Unit] = State(_ => ((), ___))
def modify[S](f: S => S): State[S, Unit] = State(s => (___, ___))
```
```fsharp
let get () : State<'s, 's> = State(fun s -> (___, ___))
let put (s: 's) : State<'s, unit> = State(fun _ -> ((), ___))
let modify (f: 's -> 's) : State<'s, unit> = State(fun s -> (___, ___))
```
`pure`, `map`, `flatMap`/`bind` and `run` are the near-solution of Exercise 4.2 with `Random` renamed to `State` and the seed renamed to `s`.
</details>
## Check your understanding ## Check your understanding
1. Why is `const roll = () => Math.floor(Math.random() * 6)` impure, while `d6 = map(nextUint32, v => 1 + v % 6)` is pure? Where did the "randomness" go? 1. Why is `const roll = () => Math.floor(Math.random() * 6)` impure, while `d6 = map(nextUint32, v => 1 + v % 6)` is pure? Where did the "randomness" go?
@@ -669,10 +781,14 @@ let newGame (seed: uint32) : GameState = seed |> ___ |> ___
6. What would go wrong, across tracks, if `freeCells` listed cells column-major in JS but row-major in Scala? 6. What would go wrong, across tracks, if `freeCells` listed cells column-major in JS but row-major in Scala?
7. What does `spawnApple` do on a full board, and why is "status becomes `Won`" better than letting `intBelow(0)` decide? 7. What does `spawnApple` do on a full board, and why is "status becomes `Won`" better than letting `intBelow(0)` decide?
8. Why does `tick` leave the seed unchanged when the snake did not eat? What would break in replays if every tick consumed a random number anyway? (Hint: would it break anything? Think about determinism versus "which apple appears".) 8. Why does `tick` leave the seed unchanged when the snake did not eat? What would break in replays if every tick consumed a random number anyway? (Hint: would it break anything? Think about determinism versus "which apple appears".)
9. **Challenge: a draw without bias.** `intBelow(n)` is very slightly biased (see "Drawing a number below `n`"). The standard cure is *rejection*: let `limit` be the largest multiple of `n` that is at most 2^32; draw a value; if it is below `limit`, use `v % n`; otherwise draw again. Write `intBelowUniform(n)` on paper with the functions of this module. Why do you need `flatMap` here, where `intBelow` only needed `map`? What does that tell you about the difference between the two? (Do not put it in the course code: the fixtures expect the simple version.)
10. Two generators that start from seeds `s` and `s + 0x6D2B79F5` produce the same sequence, shifted by one draw. Explain why from the structure of mulberry32. Why does that make "seed the second player's generator with `seed + 1`" a reasonable idea and "seed it with the first player's *next seed*" a bad one?
11. *(Exercise 4.7)* **A label counter with `State`.** A game log numbers its lines: `1: ate an apple`, `2: turned left`, ... Write `label(text)` as a `State<number, string>` (Scala `State[Int, String]`, F# `State<int, string>`) using `get` and `put` (or `modify`), so that running the chain of `label('ate an apple')` and then `label('turned left')` from `1` gives both lines and leaves the counter at `3`. What is `S` here, and what bug can no longer happen that `this.line++` would allow? (When you reach Module 11, try the same with the Pong serve: write the choice of `dy` as a `State` over the seed, with `get` and `put`, and compare it with the `Random` version.)
12. *(Exercise 4.7)* In JavaScript, `Random` and `State` are the same plain functions, so `random.js` could import `pure`, `map` and `flatMap` from `state.js`. In Scala and F#, `Random[A]` and `State[Long, A]` are different types, and you had to convert one into the other by hand in the tests. What does the separate `Random` type buy you there, and what does it cost? Which of `get`, `put`, `modify` would you *not* want to offer on `Random`, and why? (Think about what `put(0)` would do to a game's apples.)
## In production ## In production
- **Haskell's** [`random`](https://hackage.haskell.org/package/random) package uses pure generators: `uniformR :: g -> (a, g)`, exactly our shape. The **State monad** pattern is described on the [Haskell wiki](https://wiki.haskell.org/State_Monad). - **Haskell's** [`random`](https://hackage.haskell.org/package/random) package uses pure generators: `uniform :: g -> (a, g)` (leaving out the type-class constraints), exactly our shape. The **State monad** pattern is described on the [Haskell wiki](https://wiki.haskell.org/State_Monad).
- **cats** (Scala) has a general [`State`](https://typelevel.org/cats/datatypes/state.html) data type: `State[S, A]` with `map`, `flatMap`, `get`, `set`. *Functional Programming in Scala* builds a `RNG`/`State` very much like this module in its chapter on purely functional state. - **cats** (Scala) has a general [`State`](https://typelevel.org/cats/datatypes/state.html) data type: `State[S, A]` with `map`, `flatMap`, `get`, `set`. *Functional Programming in Scala* builds a `RNG`/`State` very much like this module in its chapter on purely functional state.
- **Property-based testing** libraries such as [ScalaCheck](https://scalacheck.org/) and [fast-check](https://fast-check.dev/) generate random test inputs from a seed, so that a failing case can be reproduced by re-running with the same seed. Their generators are monads like `Random`. - **Property-based testing** libraries such as [ScalaCheck](https://scalacheck.org/) and [fast-check](https://fast-check.dev/) generate random test inputs from a seed, so that a failing case can be reproduced by re-running with the same seed. Their generators are monads like `Random`.
- **Java's** [`SplittableRandom`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/SplittableRandom.html) is a mutable object, but its algorithm (a counter plus a mixing function) has the same structure as mulberry32. - **Java's** [`SplittableRandom`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/SplittableRandom.html) is a mutable object, but its algorithm (a counter plus a mixing function) has the same structure as mulberry32.
@@ -688,3 +804,6 @@ let newGame (seed: uint32) : GameState = seed |> ___ |> ___
- [Functional Programming in Scala, 2nd edition](https://www.manning.com/books/functional-programming-in-scala-second-edition) — chapter 6, "Purely functional state", is this module in book form. - [Functional Programming in Scala, 2nd edition](https://www.manning.com/books/functional-programming-in-scala-second-edition) — chapter 6, "Purely functional state", is this module in book form.
- [Scala: for-comprehensions](https://docs.scala-lang.org/tour/for-comprehensions.html) and [F# computation expressions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions) — the official docs for the syntax you saw. - [Scala: for-comprehensions](https://docs.scala-lang.org/tour/for-comprehensions.html) and [F# computation expressions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions) — the official docs for the syntax you saw.
- [Mostly Adequate Guide](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) — chapters 8 and 9 build `map` and `chain` (flatMap) for containers in JavaScript. - [Mostly Adequate Guide](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) — chapters 8 and 9 build `map` and `chain` (flatMap) for containers in JavaScript.
- [Philip Wadler, "Monads for functional programming"](https://homepages.inf.ed.ac.uk/wadler/papers/marktoberdorf/baastad.pdf) — the classic tutorial paper; its section 2 threads a state through an evaluator by hand and then removes the plumbing, exactly the path of this module.
- [Guy Steele, Doug Lea and Christine Flood, "Fast Splittable Pseudorandom Number Generators"](https://gee.cs.oswego.edu/dl/papers/oopsla14.pdf) — the design behind "a counter plus a mixing function" (SplitMix), the family mulberry32 belongs to.
- [Daniel Lemire, "Fast Random Integer Generation in an Interval"](https://arxiv.org/abs/1805.10941) — how libraries draw a number below `n` without the modulo bias, and without a division.
+2 -1
View File
@@ -137,7 +137,7 @@ List.scan apply 100 txs // [100; 150; 130; 135]
Because a game is `fold(update, start, actions)` and `update` is pure: Because a game is `fold(update, start, actions)` and `update` is pure:
- **Replays.** To store a whole game you do not need a video. You need the start state, which is fully determined by the seed (`newGame(seed)`), and the list of actions. Replaying them through `update` reproduces every state exactly, apples included, because the random seed is in the state (Module 4). - **Replays.** To store a whole game you do not need a video. You need the start state, which is fully determined by the seed (`newGame(seed)`), and the list of actions. Replaying them through `update` reproduces every state exactly, apples included, because the random seed is in the state (Module 4). In Module 10 (section 9, Exercise 10.8) you build a replay viewer on exactly this: `history` of a recorded game, shown frame by frame, forwards and backwards.
- **Deterministic tests.** A test is "this start, these actions, this expected end". No timers, no mocks, no sleeping. The fixture `twelve-actions-eat-one-apple` is a whole little game in one test case: - **Deterministic tests.** A test is "this start, these actions, this expected end". No timers, no mocks, no sleeping. The fixture `twelve-actions-eat-one-apple` is a whole little game in one test case:
``` ```
@@ -361,5 +361,6 @@ let history (state: GameState) (actions: Action list) : GameState list = List.sc
- [Array.prototype.reduce (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce) — JavaScript's fold, including the empty-array pitfall. - [Array.prototype.reduce (MDN)](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce) — JavaScript's fold, including the empty-array pitfall.
- [F# List module](https://fsharp.github.io/fsharp-core-docs/reference/fsharp-collections-listmodule.html) and [Scala List API](https://www.scala-lang.org/api/current/scala/collection/immutable/List.html) — `fold`/`scan` and `foldLeft`/`scanLeft`. - [F# List module](https://fsharp.github.io/fsharp-core-docs/reference/fsharp-collections-listmodule.html) and [Scala List API](https://www.scala-lang.org/api/current/scala/collection/immutable/List.html) — `fold`/`scan` and `foldLeft`/`scanLeft`.
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — model, update, view: the structure this course is building, from Elm's own guide. - [The Elm Architecture](https://guide.elm-lang.org/architecture/) — model, update, view: the structure this course is building, from Elm's own guide.
- [Martin Kleppmann, "Turning the database inside-out"](https://martin.kleppmann.com/2015/03/04/turning-the-database-inside-out.html) — a talk (with transcript) on treating the log of events as the source of truth and every table as a fold over it.
- [Mealy machine (Wikipedia)](https://en.wikipedia.org/wiki/Mealy_machine) — a state machine whose step produces outputs from the current state and input; a preview of how Module 8 will model streams. - [Mealy machine (Wikipedia)](https://en.wikipedia.org/wiki/Mealy_machine) — a state machine whose step produces outputs from the current state and input; a preview of how Module 8 will model streams.
- [Conal Elliott and Paul Hudak, "Functional Reactive Animation"](http://conal.net/papers/icfp97/) — the paper that started treating time-varying values and events as first-class values, the idea behind Modules 8 and 9. - [Conal Elliott and Paul Hudak, "Functional Reactive Animation"](http://conal.net/papers/icfp97/) — the paper that started treating time-varying values and events as first-class values, the idea behind Modules 8 and 9.
+10 -3
View File
@@ -302,7 +302,7 @@ There are exactly two ways to notice the difference, and you should know them:
- **Exceptions.** `flatMap(pure(1), _ => { throw new Error('boom') })` throws while *building*, whereas `flatMap(randomSeed, _ => { throw ... })` only throws when *interpreted*. - **Exceptions.** `flatMap(pure(1), _ => { throw new Error('boom') })` throws while *building*, whereas `flatMap(randomSeed, _ => { throw ... })` only throws when *interpreted*.
- **Non-termination or very expensive work.** If `f` loops forever or takes a minute, building the program hangs or takes a minute. - **Non-termination or very expensive work.** If `f` loops forever or takes a minute, building the program hangs or takes a minute.
Both are, in a sense, impurities in `f`, and our `f`s won't have them. Production libraries do care: cats-effect, ZIO and Haskell's `IO` add a case like `Suspend(() => io)` / `Delay` so that even the first step can be postponed until interpretation. Our tiny `IO` doesn't need it, and it is good to understand *why* it doesn't. Both are, in a sense, impurities in `f`, and our `f`s won't have them. Production libraries do care: cats-effect and ZIO add a case like `Suspend(() => io)` / `Delay` so that even the first step can be postponed until interpretation. (Haskell does not need such a case: the language is lazy, so `f a` is not evaluated until the runtime asks for it.) Our tiny `IO` doesn't need it, and it is good to understand *why* it doesn't.
### 10. The monad laws ### 10. The monad laws
@@ -393,7 +393,7 @@ Our `IO` is made of nested functions, and calling nested functions uses the call
That is the answer to the question from section 8: **build `sequence` right-nested** (fold from the right), so each continuation does a constant amount of work before handing back the next `Effect`. Module 9 has a test that runs `sequence` of 100 000 draws through `runIO`; a left-nested `sequence` will fail it with a stack overflow. That is the answer to the question from section 8: **build `sequence` right-nested** (fold from the right), so each continuation does a constant amount of work before handing back the next `Effect`. Module 9 has a test that runs `sequence` of 100 000 draws through `runIO`; a left-nested `sequence` will fail it with a stack overflow.
Production libraries solve this once and for all with a technique called **trampolining**: they represent `flatMap` itself as a data constructor (`FlatMap(io, f)`) and the interpreter re-associates left-nested chains on the fly, in a loop, so no shape of program can overflow the stack. Rúnar Bjarnason's paper ["Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) explains it beautifully; see also [Trampoline (computing)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29). We keep the simpler representation and the rule "fold right". Production libraries solve this once and for all with a technique called **trampolining**: they represent `flatMap` itself as a data constructor (`FlatMap(io, f)`) and the interpreter re-associates left-nested chains on the fly, in a loop, so no shape of program can overflow the stack. Rúnar Bjarnason's paper ["Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) explains it beautifully; see also [Trampoline (computing)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29). We keep the simpler representation and the rule "fold right" for the rest of the course. If you want to see the trampoline built, measured and tested, the optional [Module 7b — Stack-safe IO](07b-stack-safe-io.md) does it in this course's terms, right after this module or at any later point.
### 13. What we have built, and its family name ### 13. What we have built, and its family name
@@ -401,7 +401,7 @@ Step back and look at the type: a program is either *done*, or *one instruction
Consequences you will use in the rest of the course: Consequences you will use in the rest of the course:
- **Adding an effect is adding an `Op`.** Want to play a sound, or save a high score? Add `{tag:'PlaySound', name}` or `{tag:'Save', key, value}` and teach the interpreters what to do with it (you'll think through exactly this in Module 10). - **Adding an effect is adding an `Op`.** Want to play a sound, or save a high score? Add `{tag:'PlaySound', name}` or `{tag:'SaveBest', score}` and teach the interpreters what to do with it. Module 10 does exactly this for a best score that survives a reload ([section 8, Exercise 10.7](10-extensions.md#8-a-high-score-that-survives-a-reload)): two new `Op`s, one new answer in `trace`, two new cases in `runIO`, and not one change to `pure`, `map`, `flatMap` or `sequence`.
- **Many interpreters.** `trace` for tests, `runIO` for the browser, and you could write a third that logs every op, or one that answers `RandomSeed` from a recorded file to replay a bug — the way you would swap in a different implementation of an interface with dependency injection, except that here nothing needs to be injected into the program at all. - **Many interpreters.** `trace` for tests, `runIO` for the browser, and you could write a third that logs every op, or one that answers `RandomSeed` from a recorded file to replay a bug — the way you would swap in a different implementation of an interface with dependency injection, except that here nothing needs to be injected into the program at all.
## Exercises ## Exercises
@@ -523,6 +523,8 @@ let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = failwith "TODO m07"
Answer every `RandomSeed` with the seed (`Seed(seed)` in Scala/F#), every `Draw` with unit (`Done`), and collect the ops in the order they were performed. Use recursion, not mutation. Answer every `RandomSeed` with the seed (`Seed(seed)` in Scala/F#), every `Draw` with unit (`Done`), and collect the ops in the order they were performed. Use recursion, not mutation.
(The JS stub already shows a third parameter, `best = 0`. Ignore it for now: Module 10 adds a stored best score as a new operation, and `best` will answer it the way `seed` answers `RandomSeed`.)
**Proved by:** `trace of pure(3) has result 3 and no ops`, and every other test in the module (they all observe programs through `trace`). **Proved by:** `trace of pure(3) has result 3 and no ops`, and every other test in the module (they all observe programs through `trace`).
**Run (all of Module 7):** **Run (all of Module 7):**
@@ -686,6 +688,8 @@ let rec trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
6. State the associativity law in words. Which everyday refactoring (think "extract function") would be unsafe if it didn't hold? 6. State the associativity law in words. Which everyday refactoring (think "extract function") would be unsafe if it didn't hold?
7. You build `sequence` with a *left* fold and every m07 test passes. What breaks later, and why doesn't the loop in the interpreter save you? 7. You build `sequence` with a *left* fold and every m07 test passes. What breaks later, and why doesn't the loop in the interpreter save you?
8. To add sound to the game you'd add `{tag:'PlaySound', name}`. List every place in the code that has to change. Which place does *not* have to change at all, and why is that the point of the design? 8. To add sound to the game you'd add `{tag:'PlaySound', name}`. List every place in the code that has to change. Which place does *not* have to change at all, and why is that the point of the design?
9. **Challenge: the cost of nesting on the left.** Take `io = sequence` of `n` draws built with a *left* fold. When an interpreter runs it, how many times in total is `flatMap` called on an `Effect`? (Count for `n = 3` by hand, then generalise.) Compare with the right fold. You should find that left nesting is not only a stack problem but also a quadratic amount of work; the papers under "Further reading" on freer monads show how libraries avoid both.
10. **Challenge: laws from laws.** Using only the three monad laws and the definition `map(m, f) = flatMap(m, a => pure(f(a)))`, show that `map(m, x => x) ≡ m` and that `map(map(m, f), g) ≡ map(m, x => g(f(x)))`. (These two are called the *functor laws*; every monad gets them for free.)
## In production ## In production
@@ -700,6 +704,9 @@ let rec trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
- [Gabriel Gonzalez, "You Could Have Invented Free Monads"](https://www.haskellforall.com/2012/06/you-could-have-invented-free-monads.html) — the same derivation as this module, in Haskell; read it to see the idea in its native habitat. - [Gabriel Gonzalez, "You Could Have Invented Free Monads"](https://www.haskellforall.com/2012/06/you-could-have-invented-free-monads.html) — the same derivation as this module, in Haskell; read it to see the idea in its native habitat.
- [Rúnar Bjarnason, "Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) — how to make this structure stack-safe for arbitrarily long programs. - [Rúnar Bjarnason, "Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) — how to make this structure stack-safe for arbitrarily long programs.
- [Haskell wiki: Monad laws](https://wiki.haskell.org/Monad_laws) — the three laws with examples and the intuition behind each. - [Haskell wiki: Monad laws](https://wiki.haskell.org/Monad_laws) — the three laws with examples and the intuition behind each.
- [Simon Peyton Jones, "Tackling the Awkward Squad"](https://www.microsoft.com/en-us/research/wp-content/uploads/2016/07/mark.pdf) — how Haskell does input/output, concurrency and exceptions with monadic `IO`, by one of the language's designers; sections 1–2 are the background to this module.
- [Eugenio Moggi, "Notions of computation and monads"](https://www.cs.cmu.edu/~crary/819-f09/Moggi91.pdf) — the 1991 paper that proposed monads as a model of effects; hard going, but worth a look to see where the idea came from.
- [Oleg Kiselyov and Hiromi Ishii, "Freer Monads, More Extensible Effects"](https://okmij.org/ftp/Haskell/extensible/more.pdf) — the "operation plus continuation" representation this module uses, and how to make long chains of `flatMap` cheap.
- [Mostly Adequate Guide to Functional Programming, chapters 8–9](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) — functors, `IO` and monads, in JavaScript. - [Mostly Adequate Guide to Functional Programming, chapters 8–9](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) — functors, `IO` and monads, in JavaScript.
- [Scala: for-comprehensions](https://docs.scala-lang.org/tour/for-comprehensions.html) and [F#: Computation expressions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions) — the syntax for writing `flatMap` chains in each language. - [Scala: for-comprehensions](https://docs.scala-lang.org/tour/for-comprehensions.html) and [F#: Computation expressions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions) — the syntax for writing `flatMap` chains in each language.
- [Referential transparency (Wikipedia)](https://en.wikipedia.org/wiki/Referential_transparency) — the property this whole module protects. - [Referential transparency (Wikipedia)](https://en.wikipedia.org/wiki/Referential_transparency) — the property this whole module protects.
+472
View File
@@ -0,0 +1,472 @@
# Module 7b — Stack-safe IO (optional)
> **Time:** ~3 h · **You'll edit:** JS `js/src/safeio.js` · Scala `scala/src/SafeIO.scala` · F# `fsharp/src/SafeIO.fs`
> **Tests:** JS `cd js && node --test "test/m07b-*.test.js"` · Scala `cd scala && ./test.sh M07b` · F# `cd fsharp && ./test.sh M07b`
> **Prerequisites:** all of Module 7, especially sections 8 (`sequence`), 10 (the monad laws) and 12 (stack safety). No later module needs this one: you can do it straight after Module 7, after the whole course, or never.
## Why this module
Module 7 ended with a rule: **build `sequence` with a right fold.** Fold from the left and every Module 7 test still passes, but the 100 000-draw test of Module 9 dies with a stack overflow. Section 12 explained why in two sentences and moved on.
A rule like that is fragile. Nothing in the type says "nest me to the right", and left nesting is what you get naturally whenever you build a program step by step, adding to "the program so far":
```js
// A perfectly reasonable way to build a program: start from nothing, add one step per item.
const drawAll = (shapes) => shapes.reduce((soFar, s) => flatMap(soFar, () => draw(s)), pure(undefined))
```
Here is what that costs with Module 7's `IO`, measured with Node on the machine this module was written on, with a `while` loop as the interpreter (the loop of Module 9's `runIO`):
| Draws, left-nested | Time | Draws, right-nested | Time |
|---|---|---|---|
| 1 000 | 11 ms | 100 000 | 38 ms |
| 5 000 | 223 ms | | |
| 10 000 | 1 082 ms | | |
| 100 000 | stack overflow | | |
Two different problems hide in that table. Going from 5 000 to 10 000 draws multiplies the time by almost five: the work grows with the *square* of the length. And at 100 000 the stack runs out, even though the interpreter is a loop. On top of that, Module 7's `trace` is recursive, one level per operation, so it overflows at around 10 000 operations whichever way the program is nested.
This module removes both problems by changing the representation, not the rule. You will add a third constructor, `FlatMap`, so that "and then" becomes a piece of data; write the one rewrite rule that makes left nesting harmless; and build an interpreter that never grows the stack, out of tiny steps. At the end, 100 000 left-nested `flatMap`s run in a fraction of a second, and the programs behave exactly as they did under Module 7's `IO`, which the tests check.
## Learning objectives
By the end of this module you can:
- **explain** why a left-nested chain of `flatMap`s is both deep (stack) and quadratic (time) with the `IO` of Module 7;
- **implement** an `IO` whose `flatMap` only builds a `FlatMap` node;
- **state** the re-association rule, draw it as a picture, and **justify** it with the associativity law;
- **implement** `step`, a single rewrite with a constant amount of work, and **explain** how an interpreter built from such steps (a *trampoline*) keeps the stack flat;
- **explain** how a loop can be written without a loop in a language that does not eliminate tail calls, and why the ops must not be collected with `[...ops, op]`;
- **describe** what production effect libraries add on top of this.
## Concepts
### 1. Where the stack goes
Take Module 7's `flatMap` again:
```js
export const flatMap = (io, f) =>
io.tag === 'Pure'
? f(io.value)
: { tag: 'Effect', op: io.op, k: (result) => flatMap(io.k(result), f) }
```
Build a left-nested chain of three steps, `flatMap(flatMap(flatMap(e, f1), f2), f3)`, where `e` is one `Effect(op1, k)`. Work from the inside out:
```
flatMap(e, f1) = Effect(op1, k1) where k1(r) = flatMap(k(r), f1)
flatMap(that, f2) = Effect(op1, k2) where k2(r) = flatMap(k1(r), f2)
flatMap(that, f3) = Effect(op1, k3) where k3(r) = flatMap(k2(r), f3)
```
The outermost program still starts with `op1`, but its continuation is a **tower**: `k3` calls `k2`, which calls `k1`, which calls `k`. When the interpreter answers `op1` and calls `k3(r)`, all of those calls are on the stack at the same time before anything comes back. With `n` steps, the tower is `n` calls high. A `while` loop in the interpreter does not help, because the depth is *inside one call of a continuation*, not between the loop's iterations.
With right nesting, `flatMap(e, (r) => flatMap(e2, (r2) => …))`, the inner `flatMap` sits inside a function that has not been called yet. Each continuation does one small thing and returns the next `Effect`. Nothing piles up.
### 2. Why it is also quadratic
Follow the tower one level further. When `k3(r)` finally returns, what does it return? `k(r)` gave the rest of `e` (say `Pure(r)`); `flatMap(Pure(r), f1)` gave `f1(r)`, which is the program's second step, say `Effect(op2, …)`; then `flatMap(…, f2)` wrapped that in a new continuation, and `flatMap(…, f3)` wrapped it again. So the second operation comes out with a tower of `n − 1` levels, the third with `n − 2`, and so on. The total number of calls is about `n + (n − 1) + … + 1`, which is `n²/2`. That is the answer to the challenge question 9 of Module 7, and the reason the table above grows by a factor of four to five each time `n` doubles.
> **From everyday programming:** this is the same trap as appending to the end of a singly linked list by walking from the head every time: each append is cheap on its own, but the walk gets longer with every element, and building a list of `n` elements costs `n²/2` steps. The classic fixes are the ones this module uses: keep a better structure (a pointer to the end, or here a node that remembers "and then"), or reorganise the work so that you only ever touch the front. String building has the same shape: `s = s + piece` in a loop copies everything so far each time, which is why languages offer string builders and `join`.
### 3. The fix: make "and then" a piece of data
The trouble starts in the `Effect` case of `flatMap`, which immediately builds a new continuation around the old one. What if `flatMap` did nothing clever at all, and just *remembered* what it was asked to do?
```
FlatMap(io, f) "run io; then, with its result, continue with f(result)"
```
A program is now one of three things:
- **`Pure(value)`**: done, the result is `value`;
- **`Effect(op, k)`**: perform `op`, give its answer to `k`, continue with what `k` returns;
- **`FlatMap(io, f)`**: run `io`; when it produces a value `x`, continue with `f(x)`.
`flatMap(io, f)` is now one line: build `FlatMap(io, f)`. It costs the same for any `io`, and it never calls `f`, not even when `io` is a `Pure`. That also quietly removes the eagerness of Module 7 section 9: `flatMap(pure(1), () => { throw … })` no longer throws while building.
All the cleverness moves into the interpreter, which meets `FlatMap` nodes and has to decide what to do with them. That is the subject of the next two sections. First, the type in the three languages.
**JavaScript**
```js
// SafeIO<A> =
// { tag: 'Pure', value } value : A
// { tag: 'Effect', op, k } op : Op, k : (answer) => SafeIO<A>
// { tag: 'FlatMap', io, f } io : SafeIO<X>, f : (x: X) => SafeIO<A> for some type X
```
**Scala**
```scala
// scala/src/SafeIO.scala. Op, OpResult and Traced are the ones from IO.scala.
enum SafeIO[+A]:
case Pure(a: A)
case Effect(op: Op, k: OpResult => SafeIO[A])
case FlatMap[X, B](io: SafeIO[X], f: X => SafeIO[B]) extends SafeIO[B]
```
**F#**
```fsharp
// fsharp/src/SafeIO.fs, module Snake.SafeIO, compiled after IO.fs.
// Op, OpResult and Traced are the ones from Snake.IO: write them IO.Op, IO.OpResult, IO.Traced.
// (Do not `open Snake.IO`: its cases are also called Pure and Effect.)
type SafeIO<'a> =
| Pure of 'a
| Effect of IO.Op * (IO.OpResult -> SafeIO<'a>)
| FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>)
```
**Language note: the hidden type `X`.** Look at `FlatMap(io, f)`: the whole program produces an `A`, but the inner `io` produces something else, an `X`, which only `f` cares about. Different `FlatMap` nodes have different `X`s, and nobody outside the node needs to know which. JS has no types to worry about. The Scala `enum` can say it directly: the case has its own type parameters, `FlatMap[X, B]`, and declares that it `extends SafeIO[B]`; `X` appears in no other place, so it stays hidden inside the node (a so-called existential type). An F# union case cannot introduce a type parameter of its own, so the F# track stores the inner program as a `SafeIO<obj>` and the continuation as `obj -> SafeIO<'a>`: the type `X` is *erased* to `obj`. The stub gives you a small helper, `erase : SafeIO<'a> -> SafeIO<obj>`, which views a program as a `SafeIO<obj>` with constant work (it boxes a `Pure`'s value and wraps continuations without calling them). `bind` is then `FlatMap (erase io, fun x -> f (unbox<'a> x))`. The `unbox` cannot fail, because only `bind` builds a `FlatMap`, and it always pairs a program of `'a` with a function expecting `'a`. Keep `erase` and `unbox` inside the module and the rest of the world never sees an `obj`.
### 4. The re-association rule, as a picture
An interpreter can only do something with a `Pure` (finished) or an `Effect` (perform it). So when it meets a `FlatMap`, it rewrites the program until the top is one of those two. Look at what can be inside a `FlatMap(io, f)`; there are three cases, one per constructor of `io`:
**Rule 1.** `FlatMap(Pure(a), f)` becomes `f(a)`. The first part is finished with `a`, so continue with `f(a)`. (This is the left identity law of Module 7.)
**Rule 2.** `FlatMap(Effect(op, k), f)` becomes `Effect(op, r => FlatMap(k(r), f))`. The first thing to do is `op`; afterwards, run the rest of the first part, `k(r)`, and then `f`. Note that the new continuation only *builds* a `FlatMap` node; it does not call `flatMap` recursively the way Module 7 did, so no tower can form.
**Rule 3.** `FlatMap(FlatMap(m, g), f)` becomes `FlatMap(m, x => FlatMap(g(x), f))`. This is the important one. As a picture:
```
FlatMap FlatMap
/ \ / \
FlatMap f ==> m x => FlatMap
/ \ / \
m g g(x) f
```
Read it in words: "(run `m`, then `g`), then `f`" is the same program as "run `m`, then (`g`, then `f`)". It is the associativity law of Module 7 section 10, used as an algorithm: the law says both shapes behave the same, so the interpreter is free to replace the left one by the right one. Each application moves one `FlatMap` from the left spine of the tree to the right side, where it waits inside a function until it is needed. A left-nested chain of `n` `flatMap`s needs `n` applications of rule 3 to expose its first `Effect`, then the continuations unwind one at a time. Every node is rewritten a constant number of times, so the total work is proportional to `n`, not `n²`.
Each rule looks at most two levels into the tree, builds at most two new nodes, and calls at most one function (`f` in rule 1). That is what makes the next idea possible.
### 5. Trampolining: an interpreter made of small moves
Put the three rules into one function, **`step(io)`**: given a `FlatMap`, apply the rule that matches and return the result; given a `Pure` or an `Effect`, return it unchanged. `step` is not recursive. After one `step`, the top of the program may still be a `FlatMap` (rule 3 produces one); that is fine, it will be stepped again.
An interpreter is now a loop of small moves:
- if the program is a `Pure`, stop;
- if it is an `Effect(op, k)`, perform `op` and continue with `k(answer)`;
- if it is a `FlatMap`, continue with `step(program)`.
In a runtime file, where a loop is allowed, that is a few lines (this is an illustration; the course's runtime keeps Module 7's `IO`):
```js
// What a stack-safe runIO would look like in runtime.js, where loops and `let` are allowed.
const runSafeIO = (io, platform) => {
let current = io
while (current.tag !== 'Pure') {
current = current.tag === 'FlatMap' ? step(current) : current.k(perform(current.op, platform))
}
return current.value
}
```
Every move returns to the loop before the next one starts, so the stack is the same height after a million moves as after one. This technique is called a **trampoline**: instead of one function calling the next from inside itself (which piles frames on the stack), each move *bounces back* to a driver, which makes the next call. ([Trampoline (computing)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29) on Wikipedia; Rúnar Bjarnason's ["Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) builds exactly this structure.)
> **From everyday programming:** you may have converted a recursive tree walk into a loop with an explicit stack, because a deep tree crashed the recursive version. That is the same move: the work that the call stack used to remember is now held in a data structure that the loop manages. Job runners do it too: instead of each job calling the next job directly, which would make an ever deeper chain of calls, each job returns "what to do next" to a dispatcher that runs it. Here the data structure is the program tree itself, and rule 3 is what keeps its left side shallow.
### 6. A loop without a loop: the pure `trace`
The tests need a *pure* interpreter, `trace(io, seed)`, with the same contract as in Module 7: answer every `RandomSeed` with `seed` and every `Draw` with unit, and return `{result, ops}`. Any other op, such as Module 10's `LoadBest`, is an error: the given `answer` throws rather than inventing a reply. It must survive 100 000 operations. How it achieves that depends on the language.
**Scala and F#** turn a function that calls itself as its very last action (a *tail call*) into a loop: Scala checks it for you with `@tailrec`, F# does it for any `let rec` function that calls itself in tail position ([Tail call](https://en.wikipedia.org/wiki/Tail_call)). So `trace` is an inner function `loop(current, performed)` with one case per constructor, each ending in a call to `loop`: the stack stays flat and nothing is mutated. Collect the ops by putting each one at the *front* of an immutable list (constant work) and reverse the list once at the end.
**JavaScript** is the awkward one. The language standard has asked for proper tail calls since 2015, but only Safari implements them ([WebKit: ECMAScript 6 proper tail calls](https://webkit.org/blog/6240/ecmascript-6-proper-tail-calls-in-webkit/)); in Node and Chrome every call takes a stack frame, so a recursive `trace` overflows. A `while` loop would work, but this file follows the purity rules: no loops, no `let`. The stub therefore gives you a driver, `drive(start, isDone, next)`, which applies a "move" function `next` until `isDone`, using recursion that is only **logarithmically** deep:
- doing `n` moves is doing `n/2` moves and then `n/2` more; each half is split again, so the recursion is `log2(n)` levels deep (17 levels for 100 000 moves);
- the driver does not know in advance how many moves the program needs, so it tries 1 move, then 2 more, then 4 more, doubling the budget each round until the program is finished (another `log2` levels).
Your part is the move: **`traceStep(io, seed)`** returns `[nextProgram, ops]`, where `ops` is `[]` for a `FlatMap` (rewritten with `step`) and `[op]` for an `Effect` (answered and continued). `trace` then runs `drive` with `traceStep` until the program is a `Pure`.
**Collecting the ops.** The obvious way, `ops = [...ops, op]` at every move, copies the whole array each time: `n²/2` copies, exactly the quadratic cost this module set out to remove. Putting each op at the front of a linked list is cheap, but turning that list back into an array needs either a loop or a recursion `n` levels deep. `drive` instead keeps the outputs in a balanced tree that follows the halving above, and flattens it once at the end: every op is copied once per level of the tree, and the tree is only logarithmically deep (a few dozen levels for 100 000 ops). That is `n log n` work, a little more than linear and nowhere near quadratic, with no loop, no mutation and a shallow stack. You don't have to write `drive`, but read it: it is twenty lines.
### 7. What production libraries add
The three-constructor `IO` with a re-associating interpreter is the core of every serious effect library. Real ones add:
- **A faster run loop.** Instead of rewriting the tree, the interpreter keeps the pending continuations of rule 3 on a stack of its own (a mutable array, safely hidden inside the runtime): meeting `FlatMap(io, f)` pushes `f` and continues with `io`; reaching a `Pure(a)` pops the top `f` and continues with `f(a)`. Same behaviour, fewer allocations. cats-effect's run loop works this way; other libraries use variations of the same idea.
- **Suspended side effects.** A constructor such as `Delay(() => a)` turns any side-effecting code into a description, so a library needs no fixed list of operations like our `Op`.
- **Errors, cancellation and concurrency.** Constructors for raising and handling errors, for running programs on lightweight threads (*fibers*), for waiting on asynchronous callbacks, and for cancelling a fiber safely; a scheduler that interleaves thousands of fibers on a few operating-system threads, letting a busy fiber pause regularly so that others get a turn ([cats-effect: thread model](https://typelevel.org/cats-effect/docs/thread-model)).
- **The same trick elsewhere.** Free monads get a `FlatMapped` constructor and a stack-safe interpreter in the same way ([cats: Free monads](https://typelevel.org/cats/datatypes/freemonad.html)), and Scala's standard library ships a small general-purpose trampoline, [`scala.util.control.TailCalls`](https://www.scala-lang.org/api/current/scala/util/control/TailCalls$.html).
## Exercises
> **About test names.** The test titles quoted under "Proved by" are the JavaScript ones (in `js/test/m07b-safeio.test.js`). The Scala and F# suites (`scala/test/M07bSafeIO.test.scala`, `fsharp/tests/M07bSafeIO.fs`) test the same things in each track's vocabulary. Two differences: `traceStep` and `drive` exist only in JS (section 6), and functions cannot be compared, so where the JS tests check that a node holds the very same `f`, the Scala and F# tests check the node's shape and then observe the program through `trace`.
All exercises are in the one file per track. Write them in order. The purity rules apply as usual (no `let`, no loops, no mutation; Scala/F#: no `var`/`mutable`); the given `drive` follows them too.
### Exercise 7b.1 — `pure`, `effect`, `flatMap`, `map`, `sequence`
**JavaScript**
```js
export const pure = (value) => /* SafeIO<A>: { tag: 'Pure', value } */
export const effect = (op) => /* SafeIO<answer>: { tag: 'Effect', op, k: pure } */
export const flatMap = (io, f) => /* SafeIO<B>: only builds { tag: 'FlatMap', io, f } */
export const map = (io, f) => /* SafeIO<B>, from flatMap and pure */
export const sequence = (ios) => /* SafeIO<undefined>, left to right; either fold is fine now */
```
**Scala**
```scala
enum SafeIO[+A]:
...
def flatMap[B](f: A => SafeIO[B]): SafeIO[B] = ???
def map[B](f: A => B): SafeIO[B] = ???
object SafeIO:
def pure[A](a: A): SafeIO[A] = ???
def effect(op: Op): SafeIO[OpResult] = ???
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = ???
```
**F#** (data-last; the helper `erase` is given)
```fsharp
let pure (a: 'a) : SafeIO<'a> = failwith "TODO m07b"
let effect (op: IO.Op) : SafeIO<IO.OpResult> = failwith "TODO m07b"
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = failwith "TODO m07b"
```
**Proved by:** `pure builds a Pure`, `effect(op) is an Effect whose continuation is pure`, `flatMap only builds a FlatMap node`, `building flatMap(pure(1), throwingF) does not throw`, `building map(io, throwingF) does not throw`. `sequence` is checked through `trace` in Exercise 7b.3.
**Watch out for:** writing `flatMap` the Module 7 way. If your `flatMap` looks at `io.tag` at all, it is doing the interpreter's job.
### Exercise 7b.2 — `step`, the re-association step
**JavaScript**
```js
export const step = (io) => /* SafeIO<A>: one rewrite (section 4); Pure and Effect come back unchanged */
```
**Scala**
```scala
object SafeIO:
def step[A](io: SafeIO[A]): SafeIO[A] = ???
```
**F#**
```fsharp
let step (io: SafeIO<'a>) : SafeIO<'a> = failwith "TODO m07b"
```
**Proved by:** `step leaves a Pure unchanged`, `step leaves an Effect unchanged`, `step of FlatMap(Pure(a), f) is f(a)`, `step of FlatMap(Effect(op, k), f) is an Effect with the same op that continues with FlatMap(k(r), f)`, `step re-associates FlatMap(FlatMap(m, g), f) into FlatMap(m, x => FlatMap(g(x), f))`, `step does not call the functions it re-associates`, `step of a left-nested chain is one rewrite, not a whole walk`, and `a while loop over step is a complete interpreter for 100 000 left-nested flatMaps` (the loop of section 5, written in the test file, where loops are allowed).
**Watch out for:** recursion. `step` applies *one* rule and returns. If it calls itself, a left-nested chain of 100 000 overflows the stack inside `step`, and the "one rewrite" test sees that the top is no longer a `FlatMap`.
### Exercise 7b.3 — the pure interpreter
**JavaScript** (`drive` is given)
```js
export const traceStep = (io, seed) => /* [nextProgram, ops]: ops is [] or [op] */
export const trace = (io, seed) => /* { result, ops }, using drive and traceStep */
```
**Scala** (a `@tailrec` inner function)
```scala
object SafeIO:
def trace[A](io: SafeIO[A], seed: Long): Traced[A] = ???
```
**F#** (an inner `let rec` that calls itself only in tail position)
```fsharp
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> = failwith "TODO m07b"
```
Answer every `RandomSeed` with the seed (`Seed(seed)` in Scala/F#) and every `Draw` with unit (`Done`), as in Module 7.
**Proved by:** `traceStep answers an Effect and records its op`, `traceStep rewrites a FlatMap and records nothing`, `traceStep leaves a Pure unchanged`, `trace of pure(3) has result 3 and no ops`, `trace answers RandomSeed with the seed and Draw with undefined`, `tracing map(io, throwingF) runs f and throws`, `trace refuses operations of later modules`, `sequence of nothing has no ops`, `sequence runs effects left to right`; the stack-safety tests `100 000 left-nested flatMaps of draws run, in order`, `100 000 right-nested flatMaps of draws run, in order`, `100 000 left-nested maps run without any op`, `sequence of 100 000 draws runs`; the nine monad-law tests (`left identity holds for …`, `right identity holds for …`, `associativity holds for …`, for `a pure value`, `one effect` and `a draw followed by reading the seed`); and `trace equals Module 7's trace for …` on four programs, each written once and built with both `IO`s. (`drive collects every output in order over 200 000 moves` and `drive of a state that is already done makes no move` test the given driver; they pass from the start.)
**Run (all of Module 7b):**
- JS: `cd js && node --test "test/m07b-*.test.js"`
- Scala: `cd scala && ./test.sh M07b`
- F#: `cd fsharp && ./test.sh M07b`
## Hints
<details><summary>Exercise 7b.1 — nudge</summary>
`pure` and `effect` are the same as in Module 7. `flatMap` is shorter than in Module 7: it does not inspect anything. `map` and `sequence` can be written exactly as in Module 7, on top of the new `flatMap` and `pure`.
</details>
<details><summary>Exercise 7b.1 — stronger hint</summary>
`flatMap(io, f)` returns an object with three fields, `tag`, `io` and `f`, and nothing else happens. `map` is "and then finish with `f` of the value". For `sequence`, a left fold that starts from `pure(undefined)` and glues each program on with `flatMap(acc, () => io)` is fine now: that is the shape that broke Module 7, and the tests run 100 000 of them.
</details>
<details><summary>Exercise 7b.1 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const flatMap = (io, f) => ({ tag: ___, io, f })
export const map = (io, f) => flatMap(io, (a) => ___)
export const sequence = (ios) => ios.reduce((acc, io) => flatMap(___, () => ___), pure(undefined))
```
**Scala**
```scala
def flatMap[B](f: A => SafeIO[B]): SafeIO[B] = FlatMap(___, f)
def map[B](f: A => B): SafeIO[B] = flatMap(a => ___)
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = ios.foldLeft(pure(()))((acc, io) => ___)
```
**F#**
```fsharp
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = FlatMap(erase ___, fun x -> f (unbox<'a> ___))
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = io |> bind (fun a -> ___)
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = List.fold (fun acc io -> ___) (pure ()) ios
```
</details>
<details><summary>Exercise 7b.2 — nudge</summary>
First decide what to do when `io` is not a `FlatMap` (the doc comment says). Otherwise look one level down, at `io.io`: it is a `Pure`, an `Effect` or a `FlatMap`, and section 4 has one rule for each. Write each rule's right-hand side as an object literal or a `flatMap` call.
</details>
<details><summary>Exercise 7b.2 — stronger hint</summary>
Name the parts: the outer node is `FlatMap(inner, f)`. Rule 1 needs `inner.value`; rule 2 needs `inner.op` and `inner.k`; rule 3 needs `inner.io` (the `m` of the picture) and `inner.f` (the `g`). Building a `FlatMap` node is what your `flatMap` does, so you can use it on the right-hand sides. Nothing in `step` calls `step`.
</details>
<details><summary>Exercise 7b.2 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const step = (io) => {
if (io.tag !== 'FlatMap') return ___
const { io: inner, f } = io
if (inner.tag === 'Pure') return ___
if (inner.tag === 'Effect') return { tag: 'Effect', op: inner.op, k: (r) => flatMap(___, f) }
return flatMap(inner.io, (x) => flatMap(___, ___))
}
```
**Scala**
```scala
def step[A](io: SafeIO[A]): SafeIO[A] = io match
case FlatMap(Pure(x), f) => ___
case FlatMap(Effect(op, k), f) => Effect(op, r => FlatMap(___, f))
case FlatMap(FlatMap(m, g), f) => FlatMap(m, x => FlatMap(___, ___))
case other => other
```
**F#**
```fsharp
let step (io: SafeIO<'a>) : SafeIO<'a> =
match io with
| FlatMap(Pure x, f) -> ___
| FlatMap(Effect(op, k), f) -> Effect(op, fun r -> FlatMap(___, f))
| FlatMap(FlatMap(m, g), f) -> FlatMap(m, fun x -> FlatMap(___, ___))
| other -> other
```
In F# the inner parts already have the erased types (`m : SafeIO<obj>`, `g : obj -> SafeIO<obj>`), so no `erase` or `unbox` is needed here.
</details>
<details><summary>Exercise 7b.3 — nudge</summary>
`traceStep` has three cases, one per constructor; section 6 lists what each returns. For `trace`: what is the start state, when is the driver done, and what is the move? `drive` returns `[last, ops]`; the result is in `last`.
</details>
<details><summary>Exercise 7b.3 — stronger hint</summary>
The `Effect` case of `traceStep` is the body of Module 7's `trace` without the recursion: answer the op, call `k`, and return the new program together with `[op]` instead of tracing it further. The driver is done when the program is a `Pure`. In Scala and F#, the same three cases go into the inner tail-recursive function, with the list of performed ops as an extra argument that grows at the front; the `Pure` case returns the result with that list reversed.
</details>
<details><summary>Exercise 7b.3 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const traceStep = (io, seed) => {
if (io.tag === 'FlatMap') return [___, []]
if (io.tag === 'Effect') return [io.k(answer(io.op, seed)), ___]
return [io, []]
}
export const trace = (io, seed) => {
const [last, ops] = drive(io, (p) => ___, (p) => traceStep(p, seed))
return { result: ___, ops }
}
```
**Scala**
```scala
def trace[A](io: SafeIO[A], seed: Long): Traced[A] =
@tailrec def loop(current: SafeIO[A], performed: List[Op]): Traced[A] = current match
case Pure(a) => Traced(a, ___)
case Effect(op, k) => loop(k(___), op :: performed)
case other => loop(___, performed)
loop(io, Nil)
```
**F#**
```fsharp
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> =
let rec loop current performed =
match current with
| Pure a -> { IO.Result = a; IO.Ops = ___ }
| Effect(op, k) -> loop (k ___) (op :: performed)
| FlatMap _ -> loop ___ performed
loop io []
```
</details>
## Check your understanding
1. With Module 7's `IO`, why does a `while` loop in the interpreter not save a left-nested chain from overflowing the stack? Where exactly is the depth?
2. Count, for Module 7's `IO`, the calls needed to run a left-nested chain of 4 effects, then of 8. How does the count grow? Now count the `step`s that `SafeIO` needs for the same chains.
3. Draw the tree of `flatMap(flatMap(flatMap(e, f1), f2), f3)` and apply rule 3 until the top is `FlatMap(e, …)`. How many rewrites did it take? Then write out the continuation you ended with.
4. Rule 3 replaces one program by another. Which law of Module 7 says that this does not change what the program does? What would go wrong if an interpreter applied rule 3 to an `IO` type that did not obey that law?
5. `step` never calls itself. Why is that essential in JavaScript, and why would it still be a good idea in Scala or F#?
6. Module 7 section 9 said that `flatMap(pure(1), f)` calls `f` while building. Is that still true for `SafeIO`? When does `f` run now?
7. Why doesn't `trace` collect its ops with `[...ops, op]`? Estimate how many element copies that would make for 100 000 ops, and compare with what `drive` does.
8. Rules 1 and 3 never look at an `Op`. Suppose you add a new operation (say `PlaySound`): which parts of `safeio.js` change, and which don't?
9. **Challenge.** If you knew the exact number of moves in advance, you could write a driver with no recursion at all, using `Array.from` and `reduce`. How? Why doesn't that work for `trace`, and how does the doubling budget of `drive` get around it?
## In production
- **cats-effect** (Scala) and **ZIO** (Scala) represent `flatMap` as a data constructor and interpret programs in a run loop that makes any shape of program stack-safe; they add suspended side effects, error handling, fibers, cancellation and a fair scheduler ([cats-effect](https://typelevel.org/cats-effect/), [ZIO](https://zio.dev/)).
- **Free monads** in libraries such as cats get the same treatment: a `FlatMapped` constructor and a stack-safe interpreter ([cats: Free monads](https://typelevel.org/cats/datatypes/freemonad.html)).
- **Haskell** handles left-nested binds of free monads with *type-aligned sequences*, which keep a chain of continuations in a queue that can be extended at either end in constant time ([van der Ploeg and Kiselyov, "Reflection without Remorse"](https://okmij.org/ftp/Haskell/zseq.pdf)).
- **General-purpose trampolines** appear in many standard libraries; Scala's [`scala.util.control.TailCalls`](https://www.scala-lang.org/api/current/scala/util/control/TailCalls$.html) is one, and in JavaScript you meet the same idea whenever a long recursion is turned into a loop that calls "the next step" returned by the previous one.
## Further reading
- [Rúnar Bjarnason, "Stackless Scala With Free Monads"](http://blog.higher-order.com/assets/trampolines.pdf) — trampolines, the `FlatMap` constructor and the re-association rule, step by step.
- [Atze van der Ploeg and Oleg Kiselyov, "Reflection without Remorse"](https://okmij.org/ftp/Haskell/zseq.pdf) — why left-nested binds are quadratic in general, and a data structure that fixes it.
- [Oleg Kiselyov and Hiromi Ishii, "Freer Monads, More Extensible Effects"](https://okmij.org/ftp/Haskell/extensible/more.pdf) — the "operation plus continuation" representation of Module 7, with the continuations kept in such a queue.
- [Trampoline (computing)](https://en.wikipedia.org/wiki/Trampoline_%28computing%29) and [Tail call](https://en.wikipedia.org/wiki/Tail_call) on Wikipedia.
- [WebKit: ECMAScript 6 proper tail calls](https://webkit.org/blog/6240/ecmascript-6-proper-tail-calls-in-webkit/) — what JavaScript was supposed to get, and why most engines did not ship it.
- [Scala 3: algebraic data types with enums](https://docs.scala-lang.org/scala3/reference/enums/adts.html) and [F#: recursive functions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/functions/recursive-functions-the-rec-keyword) — the language features the Scala and F# tracks rely on here.
+8 -2
View File
@@ -77,6 +77,8 @@ type Event =
**Naming note.** The JS doc comments call this type `Input`; Scala and F# call it `Event`, because their m09 module is already named `Input` and the two names would clash. Same thing. **Naming note.** The JS doc comments call this type `Input`; Scala and F# call it `Event`, because their m09 module is already named `Input` and the two names would clash. Same thing.
(A fourth kind, a mouse click on the canvas, arrives in [Module 13](13-capstone-life.md), the second capstone. It is one more case of this type and one more source next to `keyDowns`; nothing in this module changes for it.)
Notice that time arrives as an event too. The program never *asks* for the time (`Date.now()` is banned by the purity audit); the outside world *tells* it what time it is, the same way it tells it about keys. Hidden time becomes an explicit input. Keep that in mind; it's the most important design decision in this module. Notice that time arrives as an event too. The program never *asks* for the time (`Date.now()` is banned by the purity audit); the outside world *tells* it what time it is, the same way it tells it about keys. Hidden time becomes an explicit input. Keep that in mind; it's the most important design decision in this module.
### 2. A stream is a machine you feed one event at a time ### 2. A stream is a machine you feed one event at a time
@@ -169,7 +171,7 @@ Take a minute and work out by hand why the second list is `[10, 30, 130, 160, 20
**`interval(ms)`** is the interesting one. It turns the clock into regular beats: on `Time t`, it emits every multiple of `ms` that has been *crossed* since the last time event — every `k*ms` with `last < k*ms ≤ t` — in ascending order. It starts with `last = 0` and afterwards `last` becomes `t`. The values it emits are those multiples themselves. **`interval(ms)`** is the interesting one. It turns the clock into regular beats: on `Time t`, it emits every multiple of `ms` that has been *crossed* since the last time event — every `k*ms` with `last < k*ms ≤ t` — in ascending order. It starts with `last = 0` and afterwards `last` becomes `t`. The values it emits are those multiples themselves.
Why "every multiple crossed" rather than "one beat per time event"? Because the outside world does not call us on a perfect schedule. The browser reports time about 60 times per second, but if the tab was in the background for two seconds, the next time event might jump from `t = 1000` to `t = 3000`. The game should then catch up on all the ticks it missed, not silently skip them. (This idea, separating game time from the rate at which the outside world calls you, is the classic "fixed timestep" pattern; see [Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html).) Why "every multiple crossed" rather than "one beat per time event"? Because the outside world does not call us on a perfect schedule. The browser reports time about 60 times per second, but one slow frame can make the next time event jump from `t = 1000` to `t = 1070`. The game should then catch up on the ticks it missed, not silently skip them, so that game time does not drift away from real time. (This idea, separating game time from the rate at which the outside world calls you, is the classic "fixed timestep" pattern; see [Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html).) Catching up has a limit, though: if the tab was in the background for a whole minute, replaying that minute in one go is not what anybody wants. `interval` stays simple and always catches up; Module 10 adds a separate combinator that limits how far the clock may jump.
Here is how `last` threads through a sequence of events for `interval(10)`: Here is how `last` threads through a sequence of events for `interval(10)`:
@@ -492,8 +494,10 @@ let rec map (f: 'a -> 'b) (Stream step) : Stream<'b> =
4. Why does `map(s, f)` have to return `map(next, f)` and not just `next`? Write down what `run` would produce with the wrong version on two key presses. 4. Why does `map(s, f)` have to return `map(next, f)` and not just `next`? Write down what `run` would produce with the wrong version on two key presses.
5. The test "running the same stream value twice gives the same outputs" would fail for a typical RxJS pipeline with a `scan`. Explain why in terms of where the accumulator lives. 5. The test "running the same stream value twice gives the same outputs" would fail for a typical RxJS pipeline with a `scan`. Explain why in terms of where the accumulator lives.
6. `merge(a, b)` steps *both* streams on *every* event. What would go wrong with an `interval` inside `b` if `merge` only stepped `b` on events that `a` ignored? 6. `merge(a, b)` steps *both* streams on *every* event. What would go wrong with an `interval` inside `b` if `merge` only stepped `b` on events that `a` ignored?
7. The browser reports time about 60 times per second, and the snake ticks every 150 ms. Using `interval`, explain what happens if the browser stops calling us for 1 second (say, the tab was hidden) and then resumes. Is that what you want for this game? 7. The browser reports time about 60 times per second, and the snake ticks every 150 ms. Using `interval`, explain what happens if the browser stops calling us for 1 second (say, the tab was hidden) and then resumes. Is that what you want for this game? And after a whole minute? (Module 10 comes back to this.)
8. Our streams never ask what time it is. Why is that important for testing? Where *will* the time come from? 8. Our streams never ask what time it is. Why is that important for testing? Where *will* the time come from?
9. **Challenge: laws for streams.** Two streams "behave the same" when `run` gives the same outputs for every list of events. Argue from the definitions: (a) `map(map(s, f), g)` behaves like `map(s, x => g(f(x)))`; (b) `merge(merge(a, b), c)` behaves like `merge(a, merge(b, c))`; (c) `merge(a, b)` does *not* in general behave like `merge(b, a)`: give two streams and one event that show the difference; (d) is `filter(map(s, f), p)` the same as `map(filter(s, p), f)`? When?
10. Every combinator in this module changes a stream's *outputs*. Sketch one that changes its *inputs*: `mapEvents(s, g)`, which behaves like `s` but shows it `g(event)` instead of each event. What could you build with it? (Module 10 needs a stateful version of this idea.)
## In production ## In production
@@ -510,3 +514,5 @@ let rec map (f: 'a -> 'b) (Stream step) : Stream<'b> =
- [Functional reactive programming (Wikipedia)](https://en.wikipedia.org/wiki/Functional_reactive_programming) — an overview of the family of ideas and implementations. - [Functional reactive programming (Wikipedia)](https://en.wikipedia.org/wiki/Functional_reactive_programming) — an overview of the family of ideas and implementations.
- [Robert Nystrom, Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html) — why games decouple game time from the rate at which the outside world calls them; the reason `interval` catches up. - [Robert Nystrom, Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html) — why games decouple game time from the rate at which the outside world calls them; the reason `interval` catches up.
- [RxJS: Overview](https://rxjs.dev/guide/overview) — the push-based alternative, to compare with what you built. - [RxJS: Overview](https://rxjs.dev/guide/overview) — the push-based alternative, to compare with what you built.
- [Evan Czaplicki, "Elm: Concurrent FRP for Functional GUIs"](https://elm-lang.org/assets/papers/concurrent-frp.pdf) — the thesis behind Elm; its survey of earlier FRP designs explains the trade-offs between signals, events and automata.
- [Conal Elliott, "Push-Pull Functional Reactive Programming"](http://conal.net/papers/push-pull-frp/) — how the two strategies of section 7 can be combined.
+102 -3
View File
@@ -35,7 +35,8 @@ By the end of this module you can:
- **explain** the type `IO<Stream<IO<unit>>>` layer by layer, and why `main` is a value; - **explain** the type `IO<Stream<IO<unit>>>` layer by layer, and why `main` is a value;
- **implement** an interpreter `runIO` as a loop and a driver `runApp` that holds the only mutable variable of the program; - **implement** an interpreter `runIO` as a loop and a driver `runApp` that holds the only mutable variable of the program;
- **explain** how a `Platform` interface lets the runtime be tested with a fake instead of a browser; - **explain** how a `Platform` interface lets the runtime be tested with a fake instead of a browser;
- **run** the game in a browser in your track, and **trace** one key press from the keyboard to the pixels. - **run** the game in a browser in your track, and **trace** one key press from the keyboard to the pixels;
- **implement** `play`, which states the behaviour of the whole program, for a list of inputs, as one comparison of plain data.
## Concepts ## Concepts
@@ -124,11 +125,13 @@ And `main` is a **value**. Defining it performs nothing — no seed is asked for
```js ```js
const { ops, result: stream } = trace(main, 42) const { ops, result: stream } = trace(main, 42)
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }]) // main asks for a seed, nothing else assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' }) // main asks for a seed first
const [outs] = stream.step(time(0)) // the first event… const [outs] = stream.step(time(0)) // the first event…
assert.equal(outs.length, 1) // …produces exactly one drawing: the new game assert.equal(outs.length, 1) // …produces exactly one drawing: the new game
``` ```
(The real test also allows exactly one more operation after the seed, and lets the first drawing carry shapes after the new game's view. Both are for Module 10, where `main` also loads the stored best score and draws it; in this module, `ops` is simply `[{ tag: 'RandomSeed' }]`.)
The whole program, with its randomness, time and keyboard, is tested with the pure interpreter from Module 7 and `step` from Module 8. No browser. The whole program, with its randomness, time and keyboard, is tested with the pure interpreter from Module 7 and `step` from Module 8. No browser.
**About `classicActions` and Module 10.** In Module 10, `main` will switch from `classicActions` to `speedActions`. The m09 tests that check the 150 ms behaviour therefore use a copy of `main` built from `classicActions` (`classicMain` in the test helpers), so they keep passing after Module 10. **About `classicActions` and Module 10.** In Module 10, `main` will switch from `classicActions` to `speedActions`. The m09 tests that check the 150 ms behaviour therefore use a copy of `main` built from `classicActions` (`classicMain` in the test helpers), so they keep passing after Module 10.
@@ -181,6 +184,8 @@ type Platform =
OnKeyUp: (string -> unit) -> unit } OnKeyUp: (string -> unit) -> unit }
``` ```
(Module 10 adds two more methods, `loadBest` and `saveBest`, for a best score that survives a reload.)
The runtime only ever talks to a `Platform`. The browser implementation (`browser.js` in JS, `web/BrowserPlatform.scala`, `fsharp/web/BrowserPlatform.fs`) wraps a canvas, `requestAnimationFrame` and `crypto.getRandomValues`. The tests use a **fake platform** that records every call in a list and lets the test fire events by hand — a test double in the sense of [Martin Fowler's "Test Double"](https://martinfowler.com/bliki/TestDouble.html): The runtime only ever talks to a `Platform`. The browser implementation (`browser.js` in JS, `web/BrowserPlatform.scala`, `fsharp/web/BrowserPlatform.fs`) wraps a canvas, `requestAnimationFrame` and `crypto.getRandomValues`. The tests use a **fake platform** that records every call in a list and lets the test fire events by hand — a test double in the sense of [Martin Fowler's "Test Double"](https://martinfowler.com/bliki/TestDouble.html):
```js ```js
@@ -226,6 +231,8 @@ Remember section 12 of Module 7: the loop only helps if each continuation does a
- store `next` in the variable; - store `next` in the variable;
- run each IO in `ios` with `runIO`, in order. - run each IO in `ios` with `runIO`, in order.
(Module 13 adds a fourth registration, for mouse clicks on the canvas, and a matching `onClick` method to the platform. Write the three now; the tests of this module do not expect the fourth.)
That's the whole runtime. Go back to the stateful code at the start of Module 8, with its `let state` and logic in callbacks, and compare. There, the mutable variable held the *game state*, and game logic lived in the callbacks. Here, the variable holds the *current stream* — the runtime doesn't even know there's a game. It has no idea what a snake, a tick or a score is. All the game logic is in pure values; the runtime is a few lines of plumbing. That is what "pushing effects to the edge" means in practice. That's the whole runtime. Go back to the stateful code at the start of Module 8, with its `let state` and logic in callbacks, and compare. There, the mutable variable held the *game state*, and game logic lived in the callbacks. Here, the variable holds the *current stream* — the runtime doesn't even know there's a game. It has no idea what a snake, a tick or a score is. All the game logic is in pure values; the runtime is a few lines of plumbing. That is what "pushing effects to the edge" means in practice.
``` ```
@@ -276,6 +283,30 @@ The first run restores Fable (a local .NET tool listed in `fsharp/.config/dotnet
Use the arrow keys or WASD. **You built a game in which every line of logic is pure.** Play a round. Then, before moving on, do the trace exercise in "Check your understanding": follow one key press from the keyboard to the pixels, naming each function it passes through. Use the arrow keys or WASD. **You built a game in which every line of logic is pure.** Play a round. Then, before moving on, do the trace exercise in "Check your understanding": follow one key press from the keyboard to the pixels, naming each function it passes through.
### 10. Testing the program end to end without a browser
The tests so far check the pieces: `trace(main, 42)` for the first operation, one `step` for the first drawing, the runtime with a fake platform. Can you state the behaviour of the *whole* program in one line, the way a fixture states the behaviour of `update`?
Everything you need is already a value. `trace` interprets `main` and gives the stream; `run` (Module 8) feeds the stream a list of inputs and collects every IO it emits; `trace` again turns each emitted IO into the list of operations it would perform. Put together:
```
play(main, seed, events) = run(trace(main, seed).result, events).map(io => trace(io, seed).ops)
```
The result is a list with one entry per drawing, and each entry is the list of operations of that drawing. For our program each entry is a scene turned into `Draw` operations, so the whole game, with its seed, clock and keyboard, collapses into one comparison:
```js
const events = [time(150), down('ArrowUp'), down('x'), time(300), time(310)]
play(classicMain, 42, events)
// equals: history(newGame(42), [Tick, Turn('Up'), Tick]).map(state => the Draw ops of view(state))
```
Read that equality slowly. On the left is the program as the browser would run it, event by event. On the right is the game as Module 5 described it, a fold over actions, and the picture of each state as Module 6 described it. The test says they are the same thing. Nothing is mocked: `play` uses the same `main` that `main.js` hands to the runtime, and the only difference from a real session is the interpreter that answers the operations.
A test like this has a name in industry: a **golden** (or snapshot) test. You record the output of a known-good run once, keep it in a file, and from then on check that the program still produces exactly that. Module 10 keeps one in `fixtures/m10-golden.json`: a seed, a scripted list of inputs and the scene of the last drawing of the finished game. When `main` grows in Module 10 (it will also load the stored best score), `play` grows one parameter, the number that answers that load, exactly like `seed` answers `RandomSeed`.
> **From everyday programming:** this is an end-to-end test that needs no browser automation. Tools such as Playwright or Selenium start a real browser, click, wait and take screenshots, which is slow and sometimes flaky because real time passes. Here time is just an input value, so the "recording" of a whole game session is a list of events, the "screenshot" is a list of shapes, and the test runs in milliseconds and gives the same answer every time.
## Exercises ## Exercises
> **About test names.** The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, but their titles use each track's vocabulary: "events" instead of "inputs", `None` instead of `null`, `()` instead of `undefined`, `bind`/`choose` instead of `flatMap`/`filterMap`, `IO.map throwingF io` instead of `map(io, throwingF)`, and so on. Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M09*.test.scala` or `fsharp/tests/M09*.fs`. Fixture-based tests have the same case names in every track. > **About test names.** The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, but their titles use each track's vocabulary: "events" instead of "inputs", `None` instead of `null`, `()` instead of `undefined`, `bind`/`choose` instead of `flatMap`/`filterMap`, `IO.map throwingF io` instead of `map(io, throwingF)`, and so on. Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M09*.test.scala` or `fsharp/tests/M09*.fs`. Fixture-based tests have the same case names in every track.
@@ -379,7 +410,36 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit = failwith "TODO m0
export const browserPlatform = (canvas, win) => /* Platform */ export const browserPlatform = (canvas, win) => /* Platform */
``` ```
Build a `Platform` from a canvas and a window: `fillRect`/`fillText` use `canvas.getContext('2d')` (font `` `${size}px monospace` ``, `textAlign` from `align`, `fillStyle` from `color`); `startClock` calls `onTime(t)` on every `win.requestAnimationFrame`, with `t` = milliseconds since the first frame; `onKeyDown`/`onKeyUp` listen on `win` and call `preventDefault()` for arrow keys and space so the page doesn't scroll; `randomSeed` returns `crypto.getRandomValues(new Uint32Array(1))[0]`. There is no automated test for this file; the test is that the game runs. Scala and F# students: read the given `BrowserPlatform` in your track and compare it with this description. Build a `Platform` from a canvas and a window: `fillRect`/`fillText` use `canvas.getContext('2d')` (font `` `${size}px monospace` ``, `textAlign` from `align`, `fillStyle` from `color`); `startClock` calls `onTime(t)` on every `win.requestAnimationFrame`, with `t` = milliseconds since the first frame; `onKeyDown`/`onKeyUp` listen on `win` and call `preventDefault()` for arrow keys and space so the page doesn't scroll; `randomSeed` returns `crypto.getRandomValues(new Uint32Array(1))[0]`. There is no automated test for this file in this module; the test is that the game runs. (Module 10 adds two methods for a stored best score, and tests for those two with a fake window.) Scala and F# students: read the given `BrowserPlatform` in your track and compare it with this description.
### Exercise 9.5 — `play`: the whole program in one equality
**JavaScript** (`js/src/app.js`)
```js
export const play = (main, seed, events) => /* one list of ops per drawing */
// Module 10 adds a fourth parameter: (main, seed, events, best = 0)
```
**Scala** (`scala/src/App.scala`)
```scala
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event]): List[List[Op]] = ???
// Module 10 adds a fourth parameter: best: Int = 0
```
**F#** (`fsharp/src/App.fs`)
```fsharp
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = failwith "TODO m09"
// Module 10 adds playWith seed best events main, and play becomes playWith seed 0 events main
```
`play` is a pure helper: it takes the program as an argument, so it works for `main`, for `classicMain` in the tests, and for Pong's program in Module 11.
**Proved by:** `play on the classic program equals the scenes of the expected states`, `play with no events gives no drawings`, `play uses the seed for the program's RandomSeed`.
**Watch out for:** `trace` returns `{result, ops}`; the stream is the `result` of tracing `main`, but for each emitted IO you want its `ops`. Use the same `seed` for both traces.
**Run:** **Run:**
- JS: `cd js && node --test "test/m09-*.test.js"`, then `node scripts/serve.mjs` (from the repo root) and open <http://localhost:8000/js/> - JS: `cd js && node --test "test/m09-*.test.js"`, then `node scripts/serve.mjs` (from the repo root) and open <http://localhost:8000/js/>
@@ -506,6 +566,42 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
</details> </details>
<details><summary>Exercise 9.5 — nudge</summary>
Three steps, each one a function you have: get the stream out of `main`, feed it the events, and turn each IO it emitted into its list of operations. Which function feeds a stream a whole list of inputs and collects the outputs?
</details>
<details><summary>Exercise 9.5 — stronger hint</summary>
`trace(main, seed).result` is the stream. `run(stream, events)` is the list of IOs emitted, in order. `map` over that list with `io => trace(io, seed).ops`.
</details>
<details><summary>Exercise 9.5 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const play = (main, seed, events) => run(trace(main, ___).result, ___).map((io) => ___)
```
**Scala**
```scala
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event]): List[List[Op]] =
IO.trace(main, seed).result.run(___).map(io => ___)
```
**F#**
```fsharp
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list =
(IO.trace seed main).Result |> Stream.run ___ |> List.map (fun io -> ___)
```
</details>
## Check your understanding ## Check your understanding
1. Read `IO<Stream<IO<unit>>>` aloud and explain each layer. Why can't the outer `IO` be removed? Why isn't the inner one a `Scene`? 1. Read `IO<Stream<IO<unit>>>` aloud and explain each layer. Why can't the outer `IO` be removed? Why isn't the inner one a `Scene`?
@@ -516,6 +612,8 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
6. What makes the runtime testable without a browser? What would you have to do to test it if it called `canvas.getContext('2d')` directly? 6. What makes the runtime testable without a browser? What would you have to do to test it if it called `canvas.getContext('2d')` directly?
7. Count the impure lines in your track (runtime + browser glue + main). Compare with the number of pure lines. What does that ratio tell you? 7. Count the impure lines in your track (runtime + browser glue + main). Compare with the number of pure lines. What does that ratio tell you?
8. `main` is defined at module load time. Prove to yourself that defining it performs no effect. (Hint: what does `trace(main, 1).ops` contain, and when does the platform's `randomSeed` get called?) 8. `main` is defined at module load time. Prove to yourself that defining it performs no effect. (Hint: what does `trace(main, 1).ops` contain, and when does the platform's `randomSeed` get called?)
9. `play(classicMain, 42, events)` equals the scenes of `history(newGame(42), actions)`. Which part of the program does the left side exercise that the right side does not? Name a bug in `classicActions` or `states` that this equality would catch and the Module 5 fixtures would not.
10. A golden test fails after you change the colour of the apple. Is that a bug in the program or in the test? What should you do, and what does that tell you about when golden tests are useful?
## In production ## In production
@@ -528,6 +626,7 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — compare `init`/`update`/`view`/`subscriptions` with `newGame`/`update`/`view`/`classicActions`. - [The Elm Architecture](https://guide.elm-lang.org/architecture/) — compare `init`/`update`/`view`/`subscriptions` with `newGame`/`update`/`view`/`classicActions`.
- [Gary Bernhardt, "Functional Core, Imperative Shell"](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) — the architecture you just built, explained in 13 minutes. - [Gary Bernhardt, "Functional Core, Imperative Shell"](https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell) — the architecture you just built, explained in 13 minutes.
- [Gary Bernhardt, "Boundaries"](https://www.destroyallsoftware.com/talks/boundaries) — the longer talk on the same idea: values as the boundaries between parts of a system.
- [MDN: `requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) — where the browser platform's clock comes from. - [MDN: `requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame) — where the browser platform's clock comes from.
- [MDN: `Crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) — where the seed comes from. - [MDN: `Crypto.getRandomValues`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) — where the seed comes from.
- [Martin Fowler, "Test Double"](https://martinfowler.com/bliki/TestDouble.html) — the vocabulary for fakes, stubs and mocks; our fake platform is a "fake". - [Martin Fowler, "Test Double"](https://martinfowler.com/bliki/TestDouble.html) — the vocabulary for fakes, stubs and mocks; our fake platform is a "fake".
+806 -36
View File
@@ -1,6 +1,6 @@
# Module 10 — Extensions, all pure # Module 10 — Extensions, all pure
> **Time:** ~2–3 h · **You'll edit:** JS `js/src/game.js`, `js/src/input.js`, `js/src/app.js` · Scala `scala/src/Game.scala`, `scala/src/Input.scala`, `scala/src/App.scala` · F# `fsharp/src/Game.fs`, `fsharp/src/Input.fs`, `fsharp/src/App.fs` > **Time:** ~5–6 h · **You'll edit:** JS `js/src/game.js`, `js/src/input.js`, `js/src/stream.js`, `js/src/app.js`, for the high score `js/src/io.js`, `js/src/runtime.js`, `js/src/browser.js`, and for the replay viewer the new `js/src/replay.js` · Scala `scala/src/Game.scala`, `scala/src/Input.scala`, `scala/src/Stream.scala`, `scala/src/App.scala`, `scala/src/IO.scala`, `scala/src/Runtime.scala`, `scala/src/Replay.scala` · F# `fsharp/src/Game.fs`, `fsharp/src/Input.fs`, `fsharp/src/Stream.fs`, `fsharp/src/App.fs`, `fsharp/src/IO.fs`, `fsharp/src/Runtime.fs`, `fsharp/src/Replay.fs`
> **Tests:** JS `cd js && node --test "test/m10-*.test.js"` · Scala `cd scala && ./test.sh M10` · F# `cd fsharp && ./test.sh M10` — and afterwards **all** tests: JS `./test.sh` · Scala `./test.sh` · F# `./test.sh` > **Tests:** JS `cd js && node --test "test/m10-*.test.js"` · Scala `cd scala && ./test.sh M10` · F# `cd fsharp && ./test.sh M10` — and afterwards **all** tests: JS `./test.sh` · Scala `./test.sh` · F# `./test.sh`
> **Prerequisites:** Modules 0–9 (a playable game) > **Prerequisites:** Modules 0–9 (a playable game)
@@ -32,15 +32,20 @@ Every feature adds another mutable variable, another `if` in a callback, and ano
In our design, the same three features are **new cases in the `Action` data type, new cases in `update`, two more keys in `keyToAction`, and one different input stream**. The runtime doesn't change at all. The m09 tests keep passing. And every new behaviour is pinned down by fixtures that run without a browser. In our design, the same three features are **new cases in the `Action` data type, new cases in `update`, two more keys in `keyToAction`, and one different input stream**. The runtime doesn't change at all. The m09 tests keep passing. And every new behaviour is pinned down by fixtures that run without a browser.
Then comes a bug that no fixture so far can see, because it only happens when the player *leaves*: switch to another browser tab for a minute, come back, and the snake is dead. You will fix it without touching the edge, with one new stream combinator. A turn queue makes quick key presses count. Then comes the first feature that needs a *new effect*: a best score that survives reloading the page. You will see exactly how much of the program a new effect touches, and how much it leaves alone. And the module ends by keeping a promise from Module 5: a **replay viewer** that plays back a recorded game, forwards, backwards and on its own, built from nothing but `history` and a second, tiny program.
## Learning objectives ## Learning objectives
By the end of this module you can: By the end of this module you can:
- **implement** `TogglePause`, `Restart` and `Frame` as pure cases of `update`; - **implement** `TogglePause`, `Restart` and `Frame` as pure cases of `update`;
- **explain** why speed-up is done with a frame counter and `movesEvery` instead of changing the clock, and **compute** the move interval for a given score; - **explain** why speed-up is done with a frame counter and `movesEvery` instead of changing the clock, and **compute** the move interval for a given score;
- **predict**, using the fixtures, exactly which `Frame` actions move the snake; - **predict**, using the fixtures, exactly which `Frame` actions move the snake, and **explain** why the counter counts frames since the last move and not frames since the start;
- **implement** `clampTime`, a combinator that changes the *inputs* a stream sees, and **explain** why a long gap in the clock must not be caught up in full;
- **implement** a one-slot turn queue in `update`, and **explain** why the Module 3 rules `turn` and `advance` did not have to change for it;
- **explain** why features added this way cannot break earlier modules' tests, and which tests *would* catch a mistake; - **explain** why features added this way cannot break earlier modules' tests, and which tests *would* catch a mistake;
- **design** (on paper) a persisted high score as a new effect, without writing impure code in the game logic. - **design** a new effect by answering six questions first, and **implement** a persisted high score as two new operations, two interpreter cases and a `Session` value that wraps the game, without writing impure code in the game logic or changing `GameState`;
- **build** a replay viewer as a second program over the same game, and **explain** why stepping *backwards* through a game costs nothing when the game is a fold.
## Concepts ## Concepts
@@ -69,8 +74,10 @@ How do you make the snake move faster as the score grows? The stateful answer is
Here's the alternative that keeps everything pure: **run one fast clock that never changes, and let the game decide on which beats to move.** Here's the alternative that keeps everything pure: **run one fast clock that never changes, and let the game decide on which beats to move.**
- A new action, `Frame`, arrives every `FRAME_MS = 25` ms. - A new action, `Frame`, arrives every `FRAME_MS = 25` ms.
- `GameState` has a `frame` counter (since Module 2, waiting for this moment). - `GameState` has a `frame` counter (since Module 2, waiting for this moment). It holds **the number of frames since the snake last moved**.
- On `Frame`: if the game isn't `Playing`, nothing happens. Otherwise, `frame` goes up by one, and **if the new frame number is a multiple of `movesEvery(score)`, the game also does a `tick`** (on the state with the incremented frame). - On `Frame`: if the game isn't `Playing`, nothing happens. Otherwise count the frame: `n = frame + 1`.
- While `n` is below `movesEvery(score)`, the result is the state with `frame: n`.
- When `n` reaches `movesEvery(score)` (write the test as `n >= movesEvery(score)`), the count starts again and the game takes a step: the result is **`tick` of the state with `frame: 0`**.
- `movesEvery(score) = max(2, 8 - floor(score / 3))`. - `movesEvery(score) = max(2, 8 - floor(score / 3))`.
So the speed is a pure function of the score: So the speed is a pure function of the score:
@@ -87,18 +94,47 @@ So the speed is a pure function of the score:
This is a general and very useful trick: **instead of changing the rate of an external effect, observe a fixed-rate effect and decide in pure code.** The clock stays dumb; the decision is in `update`, where it can be tested with fixtures. It's also how many real games work: a fixed simulation step, with the game deciding what happens on each one ([Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html), [Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/)). This is a general and very useful trick: **instead of changing the rate of an external effect, observe a fixed-rate effect and decide in pure code.** The clock stays dumb; the decision is in `update`, where it can be tested with fixtures. It's also how many real games work: a fixed simulation step, with the game deciding what happens on each one ([Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html), [Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/)).
Predict with the fixtures before you code. Each line is "frame before → score → does this `Frame` tick?": Predict with the fixtures before you code. Each line is "frame before, score → what this `Frame` does":
``` ```
frame 0, score 0 → new frame 1: 1 % 8 ≠ 0 → no tick (frame-below-threshold-only-counts-the-frame) frame 0, score 0 → n = 1 < 8 → only counts: frame 1 (frame-below-threshold-only-counts-the-frame)
frame 6, score 0 → new frame 7: 7 % 8 ≠ 0 → no tick (frame-just-before-threshold-…) frame 6, score 0 → n = 7 < 8 → only counts: frame 7 (frame-just-before-threshold-…)
frame 7, score 0 → new frame 8: 8 % 8 = 0 → tick (frame-at-threshold-ticks-the-game) frame 7, score 0 → n = 8 ≥ 8 → tick, frame 0 (frame-at-threshold-ticks-the-game-and-restarts-the-count)
frame 15, score 0 → new frame 16: 16 % 8 = 0 → tick (frame-at-second-multiple-…) frame 5, score 3 → n = 6 < 7 → only counts: frame 6 (frame-just-before-the-shrunk-threshold-…)
frame 6, score 3 → new frame 7: 7 % 7 = 0 → tick (frame-threshold-shrinks-as-score-grows) frame 6, score 3 → n = 7 ≥ 7 → tick, frame 0 (frame-threshold-shrinks-as-score-grows)
frame 7, score 3 → new frame 8: 8 % 7 ≠ 0 → no tick (frame-after-threshold-shrinks-does-not-tick) frame 0, score 18 → n = 1 < 2 → only counts: frame 1 (frame-at-top-speed-first-frame-does-not-tick)
frame 1, score 18 → n = 2 ≥ 2 → tick, frame 0 (frame-at-top-speed-ticks-every-second-frame)
``` ```
The last two lines show that the multiple is checked against the *current* score's `movesEvery`. When the score crosses a threshold, the next move may come a little earlier or later than a "clean" schedule would suggest; for a game this is invisible, and the rule is simple and testable. One case shows the order of things inside a single `Frame`: in `frame-tick-that-eats-restarts-the-count` the state has `frame 7, score 2` and the apple is right in front of the head. `n = 8` reaches `movesEvery(2) = 8`, so the game ticks; the tick eats, the score becomes 3, and the result has `frame 0`. The threshold was computed from the score *before* the tick. The new speed, `movesEvery(3) = 7`, applies to the step that starts now.
Why `>=` and not `==`? In a game played from `newGame`, `n` never jumps past the threshold, so both would do. But `update` should be total and sensible for *every* state, including one built by hand in a test. With `==`, a state with `frame: 9` and score 0 would count on forever and never move. The fixture `frame-count-past-the-threshold-still-ticks` pins this down.
### Puzzle 3: the tempting rule
There is a shorter rule, and it is the one most people write first (the first version of this course did too): let `frame` count *all* frames since the start of the game, and step whenever the count is a multiple of the current interval.
```
tempting rule: frame' = frame + 1; tick when frame' % movesEvery(score) == 0
```
It passes the obvious tests: at score 0 the snake moves on frames 8, 16, 24, …, every 200 ms. **Before reading on, trace this situation by hand:** the game is at frame 47 with score 2, and the apple is right in front of the head. Which of the next frames move the snake, and how many milliseconds lie between those moves?
<details><summary>Think about it first, then open the explanation</summary>
- Frame 48: `48 % 8 == 0`, tick. The snake eats; the score is now 3, so the interval is 7.
- Frame 49: `49 % 7 == 0`, tick. **One frame later.**
The snake moves two cells within 25 ms, right after eating, when the player has had no time to react. If there is a wall two cells ahead, the game is over.
The multiples of 8 and the multiples of 7 are two unrelated grids. The moment the score crosses a threshold, the game jumps from one grid to the other, and the distance to the next line of the new grid can be anything from 1 frame to a full interval. It is not a rare corner case either: it happens, with a random gap, at *every* speed-up.
Two lessons are in this bug:
1. **Ask what the state must remember.** The requirement is "every step lasts exactly `movesEvery(score)` frames". To check it you need to know how long the *current* step has lasted, that is, the frames since the last move. The total number of frames since the start of the game is a different quantity; it happens to give the right answer as long as the interval never changes. Choosing the state so that the rule can be stated directly is the same design move as `dir` and `nextDir` in Module 3.
2. **Example-based tests check the cases you thought of.** Six fixtures covered the tempting rule, and all six passed, because nobody wrote the case "the score changes on this very frame". In Module 12 you will state the requirement itself as a *property* ("the gap between two moves always equals `movesEvery` of the score") and let the computer search for a counterexample; it finds this bug in a few milliseconds.
</details>
### 4. The clock stream changes, the runtime doesn't ### 4. The clock stream changes, the runtime doesn't
@@ -112,30 +148,220 @@ speedActions = merge(map(interval(FRAME_MS), _ => Frame), filterMap(keyDowns, ke
and `main` switches from `classicActions` to `speedActions`. Compare it with `classicActions`: the same shape, a different clock and action. The runtime still holds one stream, steps it, runs the IOs. It has no idea that the game can now pause or speed up. and `main` switches from `classicActions` to `speedActions`. Compare it with `classicActions`: the same shape, a different clock and action. The runtime still holds one stream, steps it, runs the IOs. It has no idea that the game can now pause or speed up.
### 5. Why the earlier tests keep passing ### 5. The bug you only find by leaving
Play your game, then switch to another browser tab, wait ten seconds, and come back. (If you have not built the frame clock yet, do it after Exercise 10.4.) The snake has crashed into a wall while you were away. Why?
Browsers stop calling `requestAnimationFrame` for a tab that is not visible ([MDN: `requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame)). When you come back, the next `Time` event carries a `t` that is ten seconds later than the previous one. `interval(25)` does what Module 8 specified: it emits every multiple of 25 that was crossed, which is 400 `Frame`s in one event. `scan` folds all 400 into the state, and the runtime draws 400 scenes in one go. Ten seconds of game have been played in a few milliseconds, with nobody steering.
Catching up is the right behaviour for a *small* hiccup: if one browser frame takes 60 ms instead of 16, the game should take the two or three steps it missed, so that game time and wall-clock time stay together. It is the wrong behaviour for a *long* silence. Every real-time game has to pick a limit, and Glenn Fiedler's ["Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/) names the usual one: never accept more than about a quarter of a second of time at once. Whatever is beyond that is dropped: the game falls behind the wall clock, which is exactly what a player who looked away wants.
Where does this limit go? There are two candidates.
- **At the edge.** The browser glue could clamp the time before it calls the runtime. But that is logic in the one place the tests cannot easily reach, repeated in each track's browser file, and invisible in `main`.
- **In the program.** The limit is a decision about how the game treats time, so it belongs with the other decisions, in pure code, as a value.
We take the second. So far every combinator has transformed the *outputs* of a stream (`map`, `filter`, `scan`). This one transforms the *inputs* that a stream gets to see:
```
clampTime(s, maxGap) : Stream<A> a stream that behaves like s, but shows s a clock that never jumps by more than maxGap
```
`clampTime` gives the inner stream `s` **a clock of its own**, starting at 0. It remembers two numbers: `last`, the real time of the previous `Time` event (0 at the start), and `clock`, the time it has told `s` so far (also 0 at the start). On each event:
- `Time t`: the clock advances by `t - last`, but **by at most `maxGap`**, and by **no less than 0** (if the outside world ever reports an earlier time, the clock stands still; it never runs backwards). Then `s` is stepped with `Time clock`, not with `Time t`. Afterwards `last` is `t`.
- A key event is passed to `s` unchanged, and `last` and `clock` stay as they are.
The outputs are whatever `s` produced, and the next stream is `clampTime` of the next `s` with the new `last` and `clock`: the same "wrap the next stream again" pattern as every combinator in Module 8, with two numbers carried along like `interval` carries one.
Trace `clampTime(interval(10), 25)`:
```
event last clock advance = min(max(t - last, 0), 25) s is stepped with outputs last' clock'
────────── ──── ───── ─────────────────────────────────── ───────────────── ───────── ───── ──────
Time 10 0 0 min(10, 25) = 10 Time 10 [10] 10 10
Time 5000 10 10 min(4990, 25) = 25 Time 35 [20, 30] 5000 35
Time 5010 5000 35 min(10, 25) = 10 Time 45 [40] 5010 45
```
After the long gap the inner clock is simply behind the real one (45 instead of 5010) and continues from there. Nothing inside `s` can tell that time was dropped, so nothing inside needs to change: not `interval`, not `speedActions`, not `update`.
In `main`, wrap the whole stream, with `MAX_GAP_MS = 250`:
```
main = map(randomSeed, seed => clampTime(map(states(seed, speedActions), render), MAX_GAP_MS))
```
After ten seconds in a background tab the game now advances by 250 ms, which is 10 frames, and carries on (test: `main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds`).
> **From everyday programming:** this is the wrapper (or decorator) idea: an object that has the same interface as the thing it wraps and adjusts the calls on their way in, like a rate limiter in front of a service or a `BufferedReader` around a stream of bytes. The wrapped stream is not modified and does not know it is wrapped. A middleware function in a web framework that rewrites a request before the handler sees it does the same job.
Two more things to notice. First, the frame-counting trick of section 3 and this fix are the same idea applied twice: do not ask the outside world to behave differently, decide in pure code what to make of what it tells you. Second, there are other reasonable policies, and each is a small change in pure code: pause the game automatically when a long gap is seen, or show a "welcome back" countdown. Question 9 below asks you to design one.
### 6. Why the earlier tests keep passing
Run all the tests of your track after this module. They should all be green, including Modules 5 and 9. Why? Run all the tests of your track after this module. They should all be green, including Modules 5 and 9. Why?
- `update` still handles `Tick` and `Turn` exactly as before; you only added cases. The m05 fixtures don't contain the new actions. - `update` still handles `Tick` and `Turn` as before on every state the m05 fixtures contain; you only added cases. The m05 fixtures don't contain the new actions. (The turn queue of section 7 does change `Tick` and `Turn`, but only when a turn is already pending and another key arrives before the next step, and no m05 fixture does that. Check it: which m05 cases contain a `Turn`, and what is `nextDir` in their start state?)
- `classicActions` is unchanged. The m09 tests that check "a tick every 150 ms" use `classicMain`, a copy of the program built from `classicActions`, defined in the test helpers. The one m09 test that uses the real `main` only checks that "time passing produces more drawings", which is true for both versions. - `classicActions` is unchanged. The m09 tests that check "a tick every 150 ms" use `classicMain`, a copy of the program built from `classicActions`, defined in the test helpers. The m09 tests that use the real `main` check only what is true of every version: it asks for the seed first (and at most the stored best after it), its first drawing begins with the new game's view, and time passing produces more drawings.
- The runtime is unchanged, so the runtime tests can't be affected. - `clampTime` changes nothing as long as time moves in steps of at most 250 ms, and the earlier tests on `main` never jump further than that.
- The runtime gains two cases for the two new operations of the high score (section 8) and nothing else; the m09 runtime tests use programs that never perform them. `trace` gains a third parameter with a default, so every earlier call means what it meant.
This is the "open for extension" property you get from data + a fold: a new feature is a new case, and old cases don't move. When something does break, it breaks in a fixture that names the exact situation (`restart-from-paused`, `frame-tick-can-end-the-game`), not in a browser session. This is the "open for extension" property you get from data + a fold: a new feature is a new case, and old cases don't move. When something does break, it breaks in a fixture that names the exact situation (`restart-from-paused`, `frame-tick-can-end-the-game`), not in a browser session.
**Language note.** In the Scala track, write `update` as a `match` with one case per `Action` and **no `case _`**. Then if you add a case to `Action` and forget it in `update`, the compiler warns you ("match may not be exhaustive"). The stub's doc comment asks for this. F# gives the same warning for incomplete matches (`FS0025`). JS has no such check; the fixtures are your safety net. **Language note.** In the Scala track, write `update` as a `match` with one case per `Action` and **no `case _`**. Then if you add a case to `Action` and forget it in `update`, the compiler warns you ("match may not be exhaustive"). The stub's doc comment asks for this. F# gives the same warning for incomplete matches (`FS0025`). JS has no such check; the fixtures are your safety net.
### 6. A thought exercise: a high score that survives a reload ### 7. A turn queue: two keys within one step
Suppose you want to keep the best score in the browser's `localStorage` ([MDN: `localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)) so it survives reloading the page. Reading and writing storage are effects. Where would they go? Work through these questions on paper; no code is required (and none is tested). Module 3 ended with an honest admission. The snake is heading `Right`; the player presses Up and then Left, quickly, within one step, meaning "go up one row and come back the other way". `turn` accepts Up, rejects Left (it is the opposite of `dir`, where the snake last moved), and the snake goes Up and keeps going Up. The rejection is right: accepting Left would have reversed the snake into its own neck (Puzzle 2). But the player's second key is simply lost, and at high speed, where a step lasts 50 ms, that happens often.
1. **New operations.** Which `Op`s do you need? Perhaps `{tag:'LoadHigh'}` (answer: a number) and `{tag:'SaveHigh', score}` (answer: unit). In Scala/F#, which new case does `OpResult` need? The fix is to *remember* the key that came too early and use it one step later. That needs one more piece of state, and `GameState` has carried it since Module 2: `queued`, none until now.
2. **New interpreter cases.** What does `trace` answer to `LoadHigh` — a fixed number passed in, like the seed? What does `runIO` do — and which new `Platform` methods does that need?
3. **Loading.** The high score must be known before the first frame is drawn. Where in `main` does it go? (Hint: `main` already starts with one effect, `randomSeed`. How do you do two effects, the second after the first? Which function from Module 7 combines them?)
4. **State.** Where does the high score live during the game? Is it a field of `GameState`? What does `Restart` do with it?
5. **Saving.** When should it be saved — on every frame, or only when a game ends with a new record? Our stream turns *each state* into an `IO`; to notice "the game just ended", you need to know the previous state too. Which combinator from Module 8 could carry the previous state along? (Hint: `scan` can accumulate pairs.)
6. **The test.** Write down, in words, a test that checks "after a game over with score 7 and a previous best of 5, a `SaveHigh 7` op is performed". Which interpreter would that test use?
Notice what you did *not* have to do: touch the rules of the game, or let `update` call `localStorage`. The effect enters the program as one more `Op`; the decision of *when* to save is pure; and only the interpreter knows about the browser. That's the method of the whole course, applied to a feature you designed yourself. **Where does the change go?** Not in `turn` or `advance`. They are the rules of one key press and one step, they are pinned down by the Module 3 fixtures, and they are still right. The queue is a policy about *how key presses are fed to those rules over time*, so it lives in `update`, next to the other "over time" decision of this module, the frame counter. Two places in `update` change:
- **`Turn(dir)`** now goes to a new function `steer(state, dir)` instead of straight to `turn`:
- not `Playing` → unchanged (as always);
- **no turn pending** (`nextDir == dir`, the snake will keep going the way it last moved) → `turn(state, dir)`, the Module 3 rule, unchanged;
- **a turn is pending** (`nextDir != dir`) → if the key is neither `nextDir` nor its opposite, the result is the state with `queued = dir`; a later key replaces an earlier queued one. Otherwise the state is unchanged.
- **After every game step** that `update` takes (the `Tick` case and the stepping branch of `Frame`): if the result is still `Playing` and something is queued, the result gets `nextDir = queued` and `queued = none`. A step that ends the game leaves `queued` alone (nothing will read it again), and `Restart` gives a fresh game with nothing queued.
Trace the motivating example from a fresh game (head at (10,10), heading `Right`):
```
action dir nextDir queued head why
─────────── ───── ─────── ────── ─────── ─────────────────────────────────────────────────────
(start) Right Right none (10,10)
Turn Up Right Up none (10,10) nothing pending: turn accepts Up
Turn Left Right Up Left (10,10) Up is pending; Left is neither Up nor Down: queued
Tick Up Left none (10,9) the step moves Up; then the queued Left becomes nextDir
Tick Left Left none (9,9) the step moves Left
```
Both keys are honoured, one step apart, and the snake never reverses: when Left becomes `nextDir`, the snake's last move was Up, so Left is a legal turn. That is not luck. A queued key is never `nextDir` and never its opposite, so after the step makes `dir` equal to the old `nextDir`, the queued key is exactly perpendicular to the new `dir`. Module 12 states this as an invariant (`queued-not-reversed`) and checks it on random games.
Why only one slot, and not a list of pending keys? Each queued key is played one step later than it was pressed. With a long queue a player who mashes keys would see the snake executing presses from half a second ago, which feels worse than losing one. One slot fixes the common case (two keys within one step) and keeps the snake responsive. Notice also what the rule *refuses*: with Up pending, a Down press is ignored (it would be a U-turn one step later), and pressing Up again is ignored (it is already going to happen). In Module 3, Down would have *replaced* the pending Up; `turn` still does that, but `steer` never asks it to when a turn is pending.
> **From everyday programming:** this is *input buffering*, and many games do it. Fighting games keep a short buffer so that a button pressed a few frames before a move becomes possible still counts; a text field that is busy keeps your keystrokes and applies them in order. The design question is always the same: how long may an input wait before it is more surprising to honour it than to drop it? Here the answer is "one step, one key".
The method is the one from the rest of this module: a new field that the old rules ignore, a new decision in `update`, fixtures that name each branch. The m05 fixtures still pass with the finished `update`, because none of them presses a second key while a turn is pending.
### 8. A high score that survives a reload
Players want to see their best score, and they want it still there after they reload the page. The browser offers `localStorage` for that ([MDN: `localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage)): a small store of strings per web site that survives reloads. Reading and writing it are effects, and this time the effect is not drawing. Where do they go?
Before you read the design, work through these six questions on paper. They are the design questions of any new effect, and answering them yourself first is worth more than reading the answers.
1. **New operations.** Which `Op`s do you need, and what does each one answer? In Scala and F#, which new case does `OpResult` need?
2. **New interpreter cases.** What does `trace` answer to the new read: a fixed number passed in, like the seed? What does `runIO` do, and which new `Platform` methods does that need?
3. **Loading.** The best score must be known before the first frame is drawn. Where in `main` does it go? (`main` already starts with one effect, `randomSeed`. How do you do two effects, the second after the first? Which function from Module 7 combines them?)
4. **State.** Where does the best score live during the game? Is it a field of `GameState`? What does `Restart` do with it?
5. **Saving.** When should it be saved: on every frame, only when a game ends with a new record, or at some other moment? Our stream turns *each state* into an `IO`; what must that state know so that its `IO` can decide whether to save?
6. **The test.** Write down, in words, a test that checks "with a stored best of 5, the action that takes the score to 6 saves 6, and the next action saves nothing". Which interpreter does that test use?
Here is the design the course builds. Compare it with yours; where they differ, decide which you prefer and why.
**1. Two new operations.** `{tag:'LoadBest'}` answers a number, the stored best. `{tag:'SaveBest', score}` answers nothing, like a draw. In `io` they become `loadBest`, a value like `randomSeed`, and `saveBest(score)`, a function like `draw`. Scala and F# need one more answer, `OpResult.Best(value)`, because `Seed` carries a seed and the best score is a different kind of number.
**2. Interpreters.** `trace` answers `LoadBest` the way it answers `RandomSeed`: with a number the test passes in. It becomes `trace(io, seed, best = 0)`. The default keeps every existing call valid: no Module 7 test has to change, and the optional `safeio` of Module 7b, whose tests compare its traces with those of `trace` on programs that never load anything, is untouched. `runIO` gets two cases, `LoadBest` → `platform.loadBest()` and `SaveBest` → `platform.saveBest(score)`, so the `Platform` gets two methods. The browser platform keeps the number under the key `pure-snake-best`, treats anything it cannot read as 0 (no entry yet, a value that is not a non-negative whole number, storage blocked by privacy settings) and never throws: a record that cannot be stored is a small loss, a game that crashes on start is a large one.
**3. Loading.** One more effect after the seed, chained with `flatMap`:
```
main = flatMap(randomSeed, seed =>
map(loadBest, best =>
clampTime(map(sessions(seed, best, speedActions), renderSession), MAX_GAP_MS)))
```
`main` now performs two operations before its stream exists, `[RandomSeed, LoadBest]`, and that is the test that pins it.
**4. State: next to the game, not inside it.** The tempting answer is a `best` field in `GameState`. Look at what it would cost. `Restart` is `newGame(state.seed)` (section 2), so it would forget the best unless it learnt to copy it over; every game state in every fixture would grow a field; and `view` and the rules would carry a number that has nothing to do with one game of Snake. The best score belongs to the *player's session*, which spans many games. So it gets its own value, which wraps the game:
```
Session = { game: GameState, best: number, improved: boolean }
sessionUpdate(session, action):
game' = update(session.game, action)
best' = max(session.best, game'.score)
improved = best' > session.best
→ { game: game', best: best', improved }
sessions(seed, best, actions) = startWith(scan(actions, sessionUpdate, s0), s0)
where s0 = { game: newGame(seed), best, improved: false }
```
`GameState`, `update` and `view` do not change at all, and neither does a single fixture of Modules 2–6 or of this module's earlier exercises. `Restart` needs no special case: it replaces `game`, and `best` is outside it. `sessions` is `states` one level up: the same running fold, with `sessionUpdate` in place of `update`.
**5. Saving: on the action that raises the record.** Saving only at game over sounds economical, but a player who closes the tab during a record run loses it, and noticing "the game just ended" needs the previous state as well as the current one. The course saves at the moment the best goes up. That moment is a fact about one step, "the best before" against "the best after", and `sessionUpdate` is the one place that sees both. So it records the answer in the session, as `improved`, and the drawing of each session can decide on its own:
```
renderSession(session) = drawScene(sessionView(session)), followed by saveBest(session.best) when session.improved
```
How often is that? Once per point beyond the old record: a player whose record was 5 and who now scores 8 causes three saves, on the steps that reach 6, 7 and 8. Equalling the record is not an improvement (`best' > best`, strictly), and the action after an improvement has `improved` false again, because it compares the new best with itself.
**Output.** `sessionView(session)` is `view(session.game)` followed by one text, `Best: <best>`, at the top right of the board, level with the score at the top left: `{tag:'Text', x: width * CELL - 6, y: 18, text: 'Best: 3', color: '#ffffff', size: 16, align: 'right'}`. It comes *after* the game's view, so it is drawn on top, and the game's own shapes are exactly what they were.
**6. The tests.** All with the pure interpreter, none with a browser: a traced `renderSession` contains `SaveBest` exactly when `improved`; `main` traced performs `[RandomSeed, LoadBest]`; a scripted game played with `play` (Module 9, section 10) from a stored best saves once per improvement, with the scores in order; and the runtime with a fake platform asks for the best once, at start.
> **From everyday programming:** this is the split between a domain model and a storage adapter, as in the repository pattern. The game code never mentions `localStorage`; it asks for "the best score" through an operation, and one adapter (the browser platform) decides what that means. Swapping it for a server-side leaderboard, a file in a desktop version or an in-memory fake in a test changes the adapter, not the game. Wrapping `GameState` in a `Session` instead of growing it is the same move as keeping a user's preferences next to a document rather than inside it.
Notice what you did *not* have to do: touch the rules of the game, or let `update` call `localStorage`. The effect enters the program as two more `Op`s; the decision of *when* to save is pure and lives in `sessionUpdate`; and only the interpreter knows about the browser. That's the method of the whole course, applied to a feature you designed yourself.
### 9. A replay viewer: a second program over the same game
Module 5 made a claim: a whole game is its seed plus its list of actions, so a replay needs no video. Nothing in the course has shown one yet. Here it is.
A **recording** is a plain value:
```
Recording = { seed: number, actions: Action[] }
```
`fixtures/replay-sample.json` holds one: a game of 226 actions (only `Tick` and `Turn`) in which the snake eats 19 apples and then runs into itself. (It was played by a small program that heads for the apple and avoids dying on the very next step, which is why it eventually traps itself.)
Think first about how a replay viewer is usually written. The game runs forwards; to step *back*, the viewer must either undo each change (every rule needs an inverse, and "an apple was eaten" has none that is easy) or save a copy of the whole game every frame, by hand, before each change. In our design the second option is free and already written: `history(newGame(seed), actions)` from Module 5 *is* the list of every state of the game, and none of them can change afterwards. So:
```
frames(recording) = history(newGame(recording.seed), recording.actions)
```
The viewer never runs the game. It only decides **which frame to show**. Its whole state is three small values:
```
Viewer = { index, last, playing } index: the frame shown (0 = the new game, k = after k actions)
last: the index of the last frame (the number of actions)
playing: is autoplay on?
```
It is a second program over the same game: its own state, its own actions, its own `update`, `view` and `main`, built with exactly the pieces you built for Snake. The viewer's actions are:
| key | action | what `viewerUpdate` does |
|---|---|---|
| `ArrowRight`, `d`, `D` | `Next` | one frame forward, never past `last` |
| `ArrowLeft`, `a`, `A` | `Prev` | one frame back, never below 0 |
| `Home` | `First` | frame 0 |
| `End` | `Last` | frame `last` |
| Space | `TogglePlay` | stop autoplay; or start it (at the last frame: start again from frame 0) |
| (clock, every `REPLAY_MS = 100` ms) | `Beat` | while playing, one frame forward; otherwise nothing |
and one rule after every action: **a viewer at the last frame is not playing**. That single rule makes autoplay stop by itself at the end, and it keeps the state honest: there is no "playing, but nothing left to play" state to think about. `viewerUpdate` is total (every action on every viewer gives a sensible viewer) and clamps at both ends; the fixture `m10-viewer-update.json` names each case.
The picture is the game's own `view` of the current frame, with two texts on top: `step 7 / 226` at the top right and a line of key hints at the bottom.
```
viewerView(frames, viewer) = view(frames[viewer.index]) ++ [step text, hint text]
replayActions = merge(map(interval(REPLAY_MS), _ => Beat), filterMap(keyDowns, viewerKey))
viewerStates(last, actions) = startWith(scan(actions, viewerUpdate, viewerStart(last)), viewerStart(last))
replayMain(recording) = pure(clampTime(map(viewerStates(last, replayActions), v => drawScene(viewerView(fs, v))), MAX_GAP_MS))
where fs = frames(recording), last = fs.length - 1
```
Two things in `replayMain` deserve a look. It starts with `pure`, not with `randomSeed`: the viewer needs nothing from the world, because the recording carries its own seed. And it is a *function* of the recording: the page loads the JSON file and passes it in. That loading is the only new thing at the edge, and it lives in the given entry file `js/src/replay-main.js`, next to `main.js`.
> **From everyday programming:** this is the undo history of an editor, done the easy way. An editor that keeps immutable snapshots of the document can jump to any point in its history by index; one that mutates the document needs a reversible command for every edit. Version control works on the same idea: `git checkout` of an old commit does not "undo" the later commits, it shows a snapshot that never changed.
Notice what the viewer did *not* need: no change to `update`, `view`, the rules, the runtime or the browser glue. It reuses `history`, `view`, `drawScene`, the stream combinators and `clampTime` as they are. And stepping backwards shows **exactly** the earlier picture, pixel for pixel, because it *is* the earlier state (test: `stepping back shows exactly the earlier scene`).
## Exercises ## Exercises
@@ -185,9 +411,9 @@ def update(state: GameState, action: Action): GameState = ???
let update (state: GameState) (action: Action) : GameState = failwith "TODO m05/m10" let update (state: GameState) (action: Action) : GameState = failwith "TODO m05/m10"
``` ```
**Proved by:** `fixtures/m10-update.json`, 22 cases: `toggle-pause-pauses-a-playing-game`, `toggle-pause-resumes-a-paused-game`, `toggle-pause-ignored-when-game-over`, `toggle-pause-ignored-when-won`, `turn-while-paused-is-ignored`, `tick-while-paused-is-ignored`, the four `restart-from-…` cases, the `frame-…` cases from section 3, `frame-at-top-speed-ticks-every-second-frame`, `frame-at-top-speed-odd-frame-does-not-tick`, `frame-tick-can-end-the-game`, and `frame-when-paused-is-unchanged` / `…-game-over-…` / `…-won-…`. **Proved by:** the first 24 cases of `fixtures/m10-update.json` (the rest belong to Exercise 10.6): `toggle-pause-pauses-a-playing-game`, `toggle-pause-resumes-a-paused-game`, `toggle-pause-ignored-when-game-over`, `toggle-pause-ignored-when-won`, `turn-while-paused-is-ignored`, `tick-while-paused-is-ignored`, the four `restart-from-…` cases, the `frame-…` cases from section 3, `frame-after-a-move-counts-from-zero-again`, `frame-count-past-the-threshold-still-ticks`, `frame-tick-can-end-the-game`, and `frame-when-paused-is-unchanged` / `…-game-over-…` / `…-won-…`.
**Watch out for:** in `Frame`, the tick happens on the state **with the incremented frame**, so the result has the new `frame` *and* the moved snake. And `movesEvery` uses the score *before* the tick (a tick that eats an apple raises the score for the *next* frame, not this one). **Watch out for:** in `Frame`, the tick happens on the state **with `frame` set back to 0**, so the result has `frame: 0` *and* the moved snake. `movesEvery` uses the score *before* the tick (a tick that eats an apple changes the interval of the *next* step, not of this one). And `frame` counts frames since the last move, not since the start of the game (Puzzle 3).
### Exercise 10.3 — two more keys ### Exercise 10.3 — two more keys
@@ -223,12 +449,250 @@ let speedActions : Stream<Action> = (* ... *)
**Proved by:** `the animation clock runs every 25 ms`, `speedActions turns the clock into Frame actions every 25 ms`, `speedActions still turns keys into actions, including pause and restart`, `main (seed 42) draws a moved snake after eight 25 ms frames`. **Proved by:** `the animation clock runs every 25 ms`, `speedActions turns the clock into Frame actions every 25 ms`, `speedActions still turns keys into actions, including pause and restart`, `main (seed 42) draws a moved snake after eight 25 ms frames`.
### Exercise 10.5 — `clampTime`: surviving a background tab
Implement `clampTime` as specified in section 5, in your stream file (it is one more combinator). Then add the constant `MAX_GAP_MS = 250` next to `FRAME_MS` and wrap the stream in `main`.
**JavaScript** (`js/src/stream.js`, then `js/src/app.js`)
```js
export const clampTime = (s, maxGap) => /* Stream<A> */
export const MAX_GAP_MS = 250
// and in main: clampTime(<the stream of drawings>, MAX_GAP_MS)
```
**Scala** (`scala/src/Stream.scala`, a method on `Stream[A]`; then `scala/src/App.scala`)
```scala
def clampTime(maxGap: Long): Stream[A] = ???
val MAX_GAP_MS: Long = 250
```
**F#** (`fsharp/src/Stream.fs`, data-last; then `fsharp/src/App.fs`)
```fsharp
let clampTime (maxGap: int64) (s: Stream<'a>) : Stream<'a> = failwith "TODO m10"
let MAX_GAP_MS = 250L
```
**Proved by:** `clampTime passes small steps of time through unchanged`, `clampTime lets a long gap count as maxGap, and carries on from there` (the trace in section 5), `clampTime clamps the very first step too (the clock starts at 0)`, `clampTime never moves the clock backwards`, `clampTime passes key inputs through, and they do not disturb the clock`, `running the same clamped stream twice gives the same outputs`, `the game accepts at most 250 ms of time at once`, `main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds`.
**Watch out for:** stepping the inner stream with the *real* time instead of the clamped clock (then nothing is clamped); storing the clamped clock as `last` (then the second event after a long gap sees a huge difference again; `last` is the real time, `clock` the told time, and you need both); and forgetting to wrap the next stream (then only the first event is clamped).
### Exercise 10.6 — a turn queue
Implement `steer` as specified in section 7, in your game file, and change `update`: `Turn` uses `steer`, and every step `update` takes (in `Tick` and in `Frame`) is followed by "take the queued turn". Do not change `turn` or `advance`.
**JavaScript** (`js/src/game.js`)
```js
export const steer = (state, dir) => /* GameState */
// and in update: Turn -> steer; after tick (in Tick and in Frame) take the queued turn
```
**Scala** (`scala/src/Game.scala`)
```scala
def steer(state: GameState, dir: Direction): GameState = ???
```
**F#** (`fsharp/src/Game.fs`)
```fsharp
let steer (state: GameState) (dir: Direction) : GameState = failwith "TODO m10"
```
**Proved by:** the turn-queue cases of `fixtures/m10-update.json`: `turn-with-no-turn-pending-sets-next-dir`, `turn-straight-back-with-no-turn-pending-is-ignored`, `perpendicular-key-while-a-turn-is-pending-is-queued`, `key-in-the-current-direction-while-a-turn-is-pending-is-queued`, `a-later-key-replaces-the-queued-one`, `repeating-the-pending-turn-changes-nothing`, `reversing-the-pending-turn-changes-nothing`, `turn-while-paused-with-a-turn-pending-is-not-queued`, `tick-moves-then-takes-the-queued-turn`, `tick-with-nothing-queued-leaves-next-dir`, `frame-step-takes-the-queued-turn`, `frame-without-a-step-keeps-the-queue`, `tick-that-ends-the-game-keeps-the-queue`, `tick-while-paused-keeps-the-queue`, `toggle-pause-keeps-the-queue`, `restart-clears-the-queue`; and the tests `steer is what update does with a Turn` and `Turn Up then Turn Left within one step: the snake goes Up on the next step and Left on the one after`. All of `m05-update.json`, `m05-replay.json` and `m05-history.json` must still pass.
**Watch out for:** "pending" compares `nextDir` with `dir`, but the queue check compares the key with `nextDir`, not with `dir` (with Up pending on a Right-moving snake, Left *is* allowed into the queue). Take the queued turn *after* the step, never before it (question 12 asks what goes wrong otherwise). Take it in both stepping places; the cleanest way is one helper `step(state)` that both `Tick` and `Frame` call. And a step that ends the game keeps `queued`, so the check is on the status of the *result*.
### Exercise 10.7 — a persisted high score
Build the design of section 8, in this order, running the tests after each step: the operations and `trace`; `runIO` and the browser platform; `sessionUpdate`, `sessions`, `sessionView`, `renderSession`; then `main` and the extra parameter of `play`.
**JavaScript**
```js
// js/src/io.js
export const loadBest = /* IO<number>: effect({tag:'LoadBest'}) */
export const saveBest = (score) => /* IO<undefined>: effect({tag:'SaveBest', score}) */
export const trace = (io, seed, best = 0) => /* {result, ops}; LoadBest is answered with best */
// js/src/runtime.js: two more cases in runIO, LoadBest -> platform.loadBest(), SaveBest -> platform.saveBest(score)
// js/src/browser.js: loadBest() and saveBest(score) on win.localStorage, key 'pure-snake-best'
// js/src/app.js
export const sessionUpdate = (session, action) => /* Session = {game, best, improved} */
export const sessions = (seed, best, actions) => /* Stream<Session> */
export const sessionView = (session) => /* Scene */
export const renderSession = (session) => /* IO<undefined> */
export const main = /* IO<Stream<IO<undefined>>>: seed, then best, then the sessions drawn with renderSession */
export const play = (main, seed, events, best = 0) => /* pass best to both traces */
```
**Scala**
```scala
// scala/src/IO.scala
enum Op:
case Draw(shape: Shape)
case RandomSeed
case LoadBest
case SaveBest(score: Int)
enum OpResult:
case Done
case Seed(value: Long)
case Best(value: Int)
lazy val loadBest: IO[Int] = ??? // in object IO, next to randomSeed
def saveBest(score: Int): IO[Unit] = ???
def trace[A](io: IO[A], seed: Long, best: Int = 0): Traced[A] = ???
// scala/src/Runtime.scala: trait Platform gains
// def loadBest(): Int
// def saveBest(score: Int): Unit
// and runIO performs the two new ops (the browser platform in scala/web/ is given)
// scala/src/App.scala
final case class Session(game: GameState, best: Int, improved: Boolean) // top level in package snake
def sessionUpdate(session: Session, action: Action): Session = ???
def sessions(seed: Long, best: Int, actions: Stream[Action]): Stream[Session] = ???
def sessionView(session: Session): Scene = ???
def renderSession(session: Session): IO[Unit] = ???
lazy val main: IO[Stream[IO[Unit]]] = ???
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event], best: Int = 0): List[List[Op]] = ???
```
**F#**
```fsharp
// fsharp/src/IO.fs
type Op =
| Draw of Shape
| RandomSeed
| LoadBest
| SaveBest of int
type OpResult =
| Done
| Seed of uint32
| Best of int
let loadBest () : IO<int> = failwith "TODO m10" // a function of unit, like randomSeed ()
let saveBest (score: int) : IO<unit> = failwith "TODO m10"
let traceWith (seed: uint32) (best: int) (io: IO<'a>) : Traced<'a> = failwith "TODO m10"
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = traceWith seed 0 io
// fsharp/src/Runtime.fs: the Platform record gains
// LoadBest: unit -> int
// SaveBest: int -> unit
// and runIO performs the two new ops (the browser platform in fsharp/web/ is given)
// fsharp/src/App.fs
type Session = { Game: GameState; Best: int; Improved: bool }
let sessionUpdate (session: Session) (action: Action) : Session = failwith "TODO m10"
let sessions (seed: uint32) (best: int) (actions: Stream<Action>) : Stream<Session> = failwith "TODO m10"
let sessionView (session: Session) : Scene = failwith "TODO m10"
let renderSession (session: Session) : IO<unit> = failwith "TODO m10"
let main () : IO<Stream<IO<unit>>> = failwith "TODO m10"
let playWith (seed: uint32) (best: int) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = failwith "TODO m10"
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = playWith seed 0 events main
```
(In F#, `trace` and `play` keep their Module 7 and Module 9 shapes and become the "best is 0" case of `traceWith` and `playWith`, so no earlier call changes.)
**Proved by:** `fixtures/m10-session-update.json` (`a-step-without-eating-keeps-the-best`, `the-first-apple-with-no-record-is-an-improvement`, `eating-past-the-record-raises-the-best`, `eating-below-the-record-keeps-the-best`, `equalling-the-record-is-not-an-improvement`, `the-next-action-clears-improved`, `a-frame-that-steps-and-eats-raises-the-best`, `a-frame-that-only-counts-keeps-the-best-and-clears-improved`, `game-over-keeps-the-best`, `restart-keeps-the-best`, `pausing-keeps-the-best`); `fixtures/m10-session-view.json` (`playing-adds-the-best-at-the-top-right`, `a-best-of-zero-is-shown-too`, `game-over-shows-the-banner-and-then-the-best`); `fixtures/m10-golden.json` (`seed-42-eats-three-apples-with-no-stored-best`, `seed-7-beats-a-stored-best-of-2-and-hits-the-wall`, `seed-42-restart-after-game-over-keeps-the-best`), each checked twice, by `play(main): …` (the last scene) and `play(main) saves once per improvement: …`; and the tests `loadBest records a LoadBest op and is answered with the best given to trace`, `trace answers LoadBest with 0 when no best is given`, `saveBest records a SaveBest op with the score and produces undefined`, `a program can load the best, then save a higher one`, `sessions starts with the new game and the stored best, then yields one session per action`, `renderSession draws the session view and saves nothing when the best did not improve`, `renderSession draws the session view, then saves the best, when the best improved`, `main asks for the seed, then the stored best, and draws the first session`, `play passes the best to the program: the first scene shows it`, `runIO answers LoadBest with platform.loadBest() and continues with it`, `runIO maps SaveBest to platform.saveBest(score)`, `runApp asks for the seed and loads the best once, at start, and draws nothing before the first input`. JS only, for `browser.js`: `browserPlatform loads the best from localStorage key pure-snake-best`, `browserPlatform treats a missing or unreadable best as 0`, `browserPlatform saves the best under pure-snake-best`, `browserPlatform never throws when storage is blocked`. Everything of Modules 7 and 9 must still pass.
**Watch out for:** comparing with `>=` in `improved` (then equalling the record saves, and the fixture `equalling-the-record-is-not-an-improvement` fails); computing `best'` from the score *before* `update` (then the save comes one action late); putting the save before the drawing (the tests expect the scene first, then `SaveBest`); and, in the browser, `Number(null)` and `Number('')` are 0 but `Number('lots')` is `NaN`, so check the parsed number, and wrap *both* storage calls in `try`, because some browsers throw as soon as `localStorage` is touched when storage is blocked.
**Run:** **Run:**
- JS: `cd js && node --test "test/m10-*.test.js"`, then `./test.sh` for everything - JS: `cd js && node --test "test/m10-*.test.js"`, then `./test.sh` for everything
- Scala: `cd scala && ./test.sh M10`, then `./test.sh` - Scala: `cd scala && ./test.sh M10`, then `./test.sh`
- F#: `cd fsharp && ./test.sh M10`, then `./test.sh` - F#: `cd fsharp && ./test.sh M10`, then `./test.sh`
Then play: JS — reload <http://localhost:8000/js/>; Scala — `./build-web.sh` again and reload <http://localhost:8000/scala/>; F# — `./build-web.sh` again and reload <http://localhost:8000/fsharp/>. Press Space, press R, and eat apples until it gets hard. Then play: JS — reload <http://localhost:8000/js/>; Scala — `./build-web.sh` again and reload <http://localhost:8000/scala/>; F# — `./build-web.sh` again and reload <http://localhost:8000/fsharp/>. Press Space, press R, and eat apples until it gets hard. Then switch to another tab for ten seconds and come back: the snake should be about where you left it. Finally, beat your best score, reload the page, and check that `Best:` at the top right still shows it.
### Exercise 10.8 — a replay viewer
Build the viewer of section 9 in the new file, in this order, running the tests after each step: `frames` and `viewerStart`; `ViewerAction` and `viewerUpdate` (the fixture first); `viewerKey`; `viewerView`; then `replayActions`, `viewerStates` and `replayMain`. The viewer has no high score: it never loads or saves anything.
**JavaScript** (`js/src/replay.js`; the page is `js/replay.html`, its entry file `js/src/replay-main.js` is given)
```js
export const REPLAY_MS = 100
export const frames = (recording) => /* GameState[]: history(newGame(seed), actions) */
export const viewerStart = (last) => /* {index: 0, last, playing: false} */
export const ViewerAction = { Next: ..., Prev: ..., First: ..., Last: ..., TogglePlay: ..., Beat: ... } // {tag} values
export const viewerUpdate = (viewer, action) => /* Viewer */
export const viewerKey = (key) => /* ViewerAction or null */
export const viewerView = (frames, viewer) => /* Scene */
export const replayActions = /* Stream<ViewerAction> */
export const viewerStates = (last, actions) => /* Stream<Viewer> */
export const replayMain = (recording) => /* IO<Stream<IO<undefined>>> */
```
**Scala** (`scala/src/Replay.scala`, package `snake`)
```scala
final case class Recording(seed: Long, actions: List[Action])
final case class Viewer(index: Int, last: Int, playing: Boolean)
enum ViewerAction:
case Next, Prev, First, Last, TogglePlay, Beat
object Replay:
val REPLAY_MS: Long = 100
def frames(recording: Recording): List[GameState] = ???
def viewerStart(last: Int): Viewer = ???
def viewerUpdate(viewer: Viewer, action: ViewerAction): Viewer = ???
def viewerKey(key: String): Option[ViewerAction] = ???
def viewerView(frames: List[GameState], viewer: Viewer): Scene = ???
lazy val replayActions: Stream[ViewerAction] = ???
def viewerStates(last: Int, actions: Stream[ViewerAction]): Stream[Viewer] = ???
def replayMain(recording: Recording): IO[Stream[IO[Unit]]] = ???
```
**F#** (`fsharp/src/Replay.fs`, module `Snake.Replay`)
```fsharp
type Recording = { Seed: uint32; Actions: Action list }
type Viewer = { Index: int; Last: int; Playing: bool }
[<RequireQualifiedAccess>]
type ViewerAction =
| Next
| Prev
| First
| Last
| TogglePlay
| Beat
let REPLAY_MS = 100L
let frames (recording: Recording) : GameState list = failwith "TODO m10"
let viewerStart (last: int) : Viewer = failwith "TODO m10"
let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer = failwith "TODO m10"
let viewerKey (key: string) : ViewerAction option = failwith "TODO m10"
let viewerView (frames: GameState list) (viewer: Viewer) : Scene = failwith "TODO m10"
let replayActions : Stream<ViewerAction> = (* ... *)
let viewerStates (last: int) (actions: Stream<ViewerAction>) : Stream<Viewer> = failwith "TODO m10"
let replayMain (recording: Recording) : IO<Stream<IO<unit>>> = failwith "TODO m10"
```
(In F#, `ViewerAction` is `[<RequireQualifiedAccess>]` for the reason Module 11 gives for Pong's unions: `ViewerAction.Last` must never be confused with anything else in scope.)
The picture, exactly (it is fixed by `fixtures/m10-viewer-view.json`): `view(frame)`, then, with `w = width * CELL` of that frame, `{tag:'Text', x: w - 6, y: 18, text: 'step <index> / <last>', color: '#ffffff', size: 16, align: 'right'}` and `{tag:'Text', x: w / 2, y: height * CELL - 8, text: <hint>, color: '#aaaaaa', size: 12, align: 'center'}`, where the hint is `'Space: pause'` while playing and `'Left/Right: step Home/End: jump Space: play'` (three spaces between the parts) otherwise.
**Proved by:** `fixtures/m10-viewer-update.json` (18 cases: `next-moves-one-frame-forward`, `next-at-the-last-frame-stays`, `prev-moves-one-frame-back`, `prev-at-the-first-frame-stays`, `first-goes-to-frame-zero`, `last-goes-to-the-last-frame`, `toggle-play-starts-autoplay`, `toggle-play-stops-autoplay`, `toggle-play-at-the-end-starts-again-from-the-first-frame`, `beat-while-playing-moves-one-frame-forward`, `beat-while-not-playing-is-ignored`, `beat-that-reaches-the-last-frame-stops-autoplay`, `next-while-playing-keeps-playing`, `prev-while-playing-keeps-playing`, `last-while-playing-stops-autoplay`, `first-while-playing-keeps-playing`, `a-recording-with-no-actions-cannot-play`, `a-recording-with-no-actions-stays-at-frame-zero`); `fixtures/m10-viewer-key.json` (14 cases, with the `constructor`/`toString` traps); `fixtures/m10-viewer-view.json` (`first-frame-not-playing`, `middle-frame-while-playing`, `last-frame`); and the tests `the sample recording is worth watching: Tick and Turn only, under 400 actions, at least five apples, then game over`, `frames replays the recording with history, from the new game of its seed`, `viewerStart is the first frame, not playing`, `ViewerAction has the six actions as plain values` (JS only), `viewerUpdate keeps the index in [0, last] whatever the actions`, `the autoplay clock beats every 100 ms`, `replayActions turns the clock into Beats and keys into viewer actions`, `autoplay: Space, then the clock steps one frame per 100 ms and stops at the end`, `without Space nothing plays: the clock alone never moves the viewer`, `replayMain asks for nothing and draws the first frame of the recording first`, `replayMain: End shows the last frame of the game, the game over`, `stepping back shows exactly the earlier scene`, `replayMain: after ten seconds in a background tab autoplay moves 2 frames (250 ms), not 100`.
**Watch out for:** clamping *before* moving (clamp the new index, not the old one); forgetting the end-of-recording rule after `Beat` (then autoplay "plays" at the last frame forever, and `beat-that-reaches-the-last-frame-stops-autoplay` fails); computing `frames` inside the stream for every drawing instead of once in `replayMain` (correct, but it replays the whole game on every key press); and indexing frames from 1 (frame 0 is the new game, before any action).
**Run and watch:** JS `cd js && node --test "test/m10-*.test.js"`, then (from the repository root) `node scripts/serve.mjs` and open <http://localhost:8000/js/replay.html>. Scala: `./test.sh M10`, `./build-web.sh`, then <http://localhost:8000/scala/replay.html>. F#: `./test.sh M10`, `./build-web.sh`, then <http://localhost:8000/fsharp/replay.html>. Press Space and watch the snake eat its nineteen apples; press `End`, then `Left` a few times, and watch it back out of the crash.
## Hints ## Hints
@@ -240,7 +704,7 @@ Write each new case as its own small function (`togglePause(state)`, `frame(stat
<details><summary>Exercise 10.2 — stronger hint</summary> <details><summary>Exercise 10.2 — stronger hint</summary>
For `TogglePause`, match on the *status*: two statuses change, the other two don't. For `Frame`, the guard "not Playing → unchanged" comes first. Then: `counted = { ...state, frame: state.frame + 1 }`, and the result is `counted.frame % movesEvery(state.score) === 0 ? tick(counted) : counted`. For `TogglePause`, match on the *status*: two statuses change, the other two don't. For `Frame`, the guard "not Playing → unchanged" comes first. Then compute `n = state.frame + 1` and compare it with `movesEvery(state.score)`: below it, the result is the state with `frame: n`; otherwise it is `tick` applied to the state with `frame: 0`.
</details> </details>
@@ -256,8 +720,8 @@ const togglePause = (state) =>
const frame = (state) => { const frame = (state) => {
if (state.status !== 'Playing') return state if (state.status !== 'Playing') return state
const counted = /* ? */ const n = /* ? */
return /* tick(counted) or counted? */ return n >= /* ? */ ? tick({ ...state, frame: /* ? */ }) : { ...state, frame: /* ? */ }
} }
export const update = (state, action) => { export const update = (state, action) => {
@@ -302,6 +766,293 @@ let update (state: GameState) (action: Action) : GameState =
</details> </details>
<details><summary>Exercise 10.5 — nudge</summary>
Look at your `interval`: a private helper that takes the thing to remember as a parameter, and whose `step` returns the helper again with the new value. `clampTime` is built the same way, with two numbers to remember (`last` and `clock`) and an inner stream to step.
</details>
<details><summary>Exercise 10.5 — stronger hint</summary>
Two cases in `step`. A key event: step the inner stream with the same event; return its outputs and the helper around its next stream, with `last` and `clock` unchanged. A `Time t` event: compute `now = clock + min(max(t - last, 0), maxGap)`; step the inner stream with a *new* `Time now` event; return its outputs and the helper around its next stream, with `last = t` and `clock = now`. `clampTime(s, maxGap)` is the helper started with `last = 0` and `clock = 0`.
</details>
<details><summary>Exercise 10.5 — near-solution (try the others first)</summary>
**JavaScript**
```js
const clampFrom = (s, maxGap, last, clock) => ({
step: (i) => {
if (i.tag !== 'Time') {
const [out, next] = s.step(i)
return [out, clampFrom(next, maxGap, /* ? */, /* ? */)]
}
const now = clock + Math.min(Math.max(/* ? */, 0), maxGap)
const [out, next] = s.step({ tag: 'Time', t: /* ? */ })
return [out, clampFrom(next, maxGap, /* ? */, /* ? */)]
},
})
export const clampTime = (s, maxGap) => clampFrom(s, maxGap, 0, 0)
```
**Scala**
```scala
def clampTime(maxGap: Long): Stream[A] =
def from(s: Stream[A], last: Long, clock: Long): Stream[A] = Stream {
case Event.Time(t) =>
val now = clock + math.min(math.max(/* ? */, 0L), maxGap)
val (out, next) = s.step(Event.Time(/* ? */))
(out, from(next, /* ? */, /* ? */))
case key =>
val (out, next) = s.step(key)
(out, from(next, last, clock))
}
from(this, 0L, 0L)
```
**F#**
```fsharp
let clampTime (maxGap: int64) (s: Stream<'a>) : Stream<'a> =
let rec from (Stream step) (last: int64) (clock: int64) =
Stream(fun ev ->
match ev with
| Time t ->
let now = clock + min (max ((* ? *)) 0L) maxGap
let (out, next) = step (Time ((* ? *)))
(out, from next ((* ? *)) ((* ? *)))
| key ->
let (out, next) = step key
(out, from next last clock))
from s 0L 0L
```
</details>
<details><summary>Exercise 10.6 — nudge</summary>
Two small pieces. `steer` is three cases, checked in the order of section 7 (status, then "is a turn pending?", then "may this key wait in the queue?"). "Take the queued turn" is a function from state to state that you apply to the result of `tick`. Give "tick, then take the queued turn" a name, and use it in both places that step the game.
</details>
<details><summary>Exercise 10.6 — stronger hint</summary>
`steer`: if not `Playing`, return the state. If `state.nextDir` equals `state.dir`, return `turn(state, dir)`. Otherwise, if `dir` differs from `state.nextDir` and from `opposite(state.nextDir)`, return the state with `queued` set to `dir`; else return the state. `takeQueued(state)`: if the status is `Playing` and `queued` is not none, return the state with `nextDir` set to the queued direction and `queued` set to none; otherwise the state. `step(state) = takeQueued(tick(state))`. In `update`, `Tick` becomes `step(state)`, `Turn` becomes `steer(state, dir)`, and inside `frame` the `tick({ ...state, frame: 0 })` becomes `step(...)` of the same state.
</details>
<details><summary>Exercise 10.6 — near-solution (try the others first)</summary>
**JavaScript**
```js
export const steer = (state, dir) => {
if (state.status !== 'Playing') return state
if (state.nextDir === state.dir) return ___
return dir !== state.nextDir && dir !== ___ ? { ...state, queued: ___ } : state
}
const takeQueued = (state) =>
state.status === 'Playing' && state.queued !== null ? { ...state, nextDir: ___, queued: ___ } : state
const step = (state) => takeQueued(___)
```
**Scala**
```scala
def steer(state: GameState, dir: Direction): GameState =
if state.status != Status.Playing then state
else if state.nextDir == state.dir then ___
else if dir != state.nextDir && dir != ___ then state.copy(queued = ___)
else state
private def takeQueued(state: GameState): GameState = state.queued match
case Some(d) if state.status == Status.Playing => state.copy(nextDir = ___, queued = ___)
case _ => state
private def step(state: GameState): GameState = takeQueued(___)
```
**F#**
```fsharp
let steer (state: GameState) (dir: Direction) : GameState =
if state.Status <> Playing then state
elif state.NextDir = state.Dir then ___
elif dir <> state.NextDir && dir <> ___ then { state with Queued = ___ }
else state
let private takeQueued (state: GameState) : GameState =
match state.Queued with
| Some d when state.Status = Playing -> { state with NextDir = ___; Queued = ___ }
| _ -> state
let private step (state: GameState) : GameState = takeQueued (___)
```
</details>
<details><summary>Exercise 10.7 — nudge</summary>
Most of it is a copy of something you already have. `loadBest` is built like `randomSeed`, `saveBest` like `draw`. The new answer in `trace` sits next to the seed's. The two runtime cases sit next to `RandomSeed`'s. `sessions` is `states` with `sessionUpdate` and a session as the start value. And `main` has two effects in a row before the stream: the first one's result is needed to build the rest, so it is chained with `flatMap`; the second one only needs `map`.
</details>
<details><summary>Exercise 10.7 — stronger hint</summary>
`sessionUpdate`: call `update` on the session's game first; then the new best is the larger of the old best and the *new* game's score; `improved` is "new best greater than old best". `sessionView`: spread the game's view into a new list and add one `Text` at the end, with `x = game.width * CELL - 6`. `renderSession`: two cases on `improved`; the improved one is `sequence` of the scene's drawing and `saveBest(best)`. `play`: the only change is passing `best` as the third argument of both `trace` calls. In the browser: read the entry, convert it with `Number`, and return it only if it is a non-negative whole number (`Number.isSafeInteger(n) && n >= 0`); `catch` returns 0.
</details>
<details><summary>Exercise 10.7 — near-solution (try the others first)</summary>
**JavaScript**
```js
// io.js
export const loadBest = effect({ tag: ___ })
export const saveBest = (score) => effect({ tag: 'SaveBest', ___ })
const answer = (op, seed, best) => op.tag === 'RandomSeed' ? seed : op.tag === 'LoadBest' ? ___ : undefined
// app.js
export const sessionUpdate = (session, action) => {
const game = update(___, action)
const best = Math.max(session.best, ___)
return { game, best, improved: ___ }
}
export const renderSession = (session) =>
session.improved ? sequence([drawScene(sessionView(session)), ___]) : drawScene(sessionView(session))
export const main = flatMapIO(randomSeed, (seed) =>
mapIO(___, (best) => clampTime(map(sessions(seed, best, speedActions), ___), MAX_GAP_MS)))
```
**Scala**
```scala
// IO.scala, in trace's answer
case Op.LoadBest => OpResult.Best(___)
case Op.SaveBest(_) => ___
// App.scala
def sessionUpdate(session: Session, action: Action): Session =
val game = Game.update(___, action)
val best = math.max(session.best, ___)
Session(game, best, improved = ___)
lazy val main: IO[Stream[IO[Unit]]] =
IO.randomSeed.flatMap(seed => IO.loadBest.map(best => sessions(seed, best, speedActions).map(___).clampTime(MAX_GAP_MS)))
```
**F#**
```fsharp
// IO.fs, in traceWith's answer
| LoadBest -> Best ___
| SaveBest _ -> ___
// App.fs
let sessionUpdate (session: Session) (action: Action) : Session =
let game = Game.update ___ action
let best = max session.Best ___
{ Game = game; Best = best; Improved = ___ }
let main () =
IO.randomSeed ()
|> IO.bind (fun seed ->
IO.loadBest () |> IO.map (fun best -> sessions seed best speedActions |> Stream.map ___ |> Stream.clampTime MAX_GAP_MS))
```
</details>
<details><summary>Exercise 10.8 — nudge</summary>
Everything here is a copy of something you have. `frames` is one call. `viewerUpdate` is `update` for a much smaller state: one small case per action, then one rule applied to every result. `viewerKey` is `keyToAction` with other keys. `replayActions`, `viewerStates` and `replayMain` are `speedActions`, `states` and `main` with other names, except that `replayMain` starts with `pure`.
</details>
<details><summary>Exercise 10.8 — stronger hint</summary>
Write `viewerUpdate` in two steps. First a helper `move(viewer, action)` that handles each action on its own, using `clamp(i) = min(last, max(0, i))` for `Next`, `Prev` and `Beat` (`Beat` only when playing), and for `TogglePlay`: playing → not playing; not playing → playing, with `index` set to 0 if it was `last`. Then `viewerUpdate` returns `move(...)` with `playing` replaced by `playing && index < last`. In `replayMain`, compute `fs = frames(recording)` once, before the stream, and use `fs.length - 1` as `last`.
</details>
<details><summary>Exercise 10.8 — near-solution (try the others first)</summary>
**JavaScript**
```js
const move = (viewer, action) => {
const clamp = (i) => Math.min(viewer.last, Math.max(0, i))
switch (action.tag) {
case 'Next': return { ...viewer, index: clamp(___) }
case 'Prev': return { ...viewer, index: clamp(___) }
case 'First': return { ...viewer, index: ___ }
case 'Last': return { ...viewer, index: ___ }
case 'TogglePlay': return viewer.playing ? { ...viewer, playing: false }
: { ...viewer, index: viewer.index === viewer.last ? ___ : viewer.index, playing: true }
case 'Beat': return viewer.playing ? ___ : viewer
default: return viewer
}
}
export const viewerUpdate = (viewer, action) => {
const next = move(viewer, action)
return { ...next, playing: next.playing && ___ }
}
export const replayMain = (recording) => {
const fs = frames(recording)
return pure(clampTime(map(viewerStates(___, replayActions), (v) => drawScene(viewerView(___, v))), MAX_GAP_MS))
}
```
**Scala**
```scala
private def move(v: Viewer, action: ViewerAction): Viewer =
def clamp(i: Int) = math.min(v.last, math.max(0, i))
action match
case ViewerAction.Next => v.copy(index = clamp(___))
case ViewerAction.Prev => v.copy(index = clamp(___))
case ViewerAction.First => v.copy(index = ___)
case ViewerAction.Last => v.copy(index = ___)
case ViewerAction.TogglePlay =>
if v.playing then v.copy(playing = false)
else v.copy(index = if v.index == v.last then ___ else v.index, playing = true)
case ViewerAction.Beat => if v.playing then ___ else v
def viewerUpdate(viewer: Viewer, action: ViewerAction): Viewer =
val next = move(viewer, action)
next.copy(playing = next.playing && ___)
```
**F#**
```fsharp
let private move (v: Viewer) (action: ViewerAction) : Viewer =
let clamp i = min v.Last (max 0 i)
match action with
| ViewerAction.Next -> { v with Index = clamp (___) }
| ViewerAction.Prev -> { v with Index = clamp (___) }
| ViewerAction.First -> { v with Index = ___ }
| ViewerAction.Last -> { v with Index = ___ }
| ViewerAction.TogglePlay ->
if v.Playing then { v with Playing = false }
else { v with Index = (if v.Index = v.Last then ___ else v.Index); Playing = true }
| ViewerAction.Beat -> if v.Playing then ___ else v
let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer =
let next = move viewer action
{ next with Playing = next.Playing && ___ }
```
</details>
## Check your understanding ## Check your understanding
1. List every file you changed in this module. Which important file did you *not* change, and why didn't you need to? 1. List every file you changed in this module. Which important file did you *not* change, and why didn't you need to?
@@ -310,18 +1061,37 @@ let update (state: GameState) (action: Action) : GameState =
4. A player presses Space, then ArrowUp, then Space again, all within one move interval. Which way is the snake heading afterwards? Use the rules from Modules 3 and 10 to justify your answer. 4. A player presses Space, then ArrowUp, then Space again, all within one move interval. Which way is the snake heading afterwards? Use the rules from Modules 3 and 10 to justify your answer.
5. `Restart` uses `newGame(state.seed)`. What would change if it used the seed of the very first game instead? Which would you prefer for a speedrun leaderboard, and which for casual play? 5. `Restart` uses `newGame(state.seed)`. What would change if it used the seed of the very first game instead? Which would you prefer for a speedrun leaderboard, and which for casual play?
6. The m09 test "main draws again once time has passed" passes both before and after this module, but "the program built from classicActions draws the new game first, then the ticked game after 150 ms" uses a copy of `main`. Why was the test suite designed this way? 6. The m09 test "main draws again once time has passed" passes both before and after this module, but "the program built from classicActions draws the new game first, then the ticked game after 150 ms" uses a copy of `main`. Why was the test suite designed this way?
7. For the high-score thought exercise: which parts of your design are pure, which are interpreter code, and how would you test that a new record gets saved without a browser? 7. In the high score of section 8, which parts are pure, which are interpreter code, and which test would fail if `renderSession` saved on *every* frame instead of only when `improved`?
8. With the tempting rule of Puzzle 3, the game is at frame 62 with score 5 and the apple is right in front of the head. On which frames does the snake make its next two moves? What is the shortest gap the tempting rule can produce at the change from interval 7 to interval 6, and at which frame numbers does it happen?
9. `clampTime` drops the time beyond `maxGap`. Design the other policy on paper: when a `Time` event arrives more than 250 ms after the previous one, the game should **pause itself**. Which stream would you write (what does it emit, and what does it remember)? Where does it enter `speedActions`? Why is a plain `TogglePause` action not quite right for it, and which new `Action` would be? Which test would prove it?
10. `clampTime(map(s, f), n)` and `map(clampTime(s, n), f)`: do they behave the same? What about `clampTime(merge(a, b), n)` and `merge(clampTime(a, n), clampTime(b, n))`? Argue from the definitions. Then explain why `clampTime(clampTime(s, 100), 50)` behaves like `clampTime(s, 50)`.
11. With the turn queue, a snake heading `Right` gets Up, Left, Down within one step. Trace `dir`, `nextDir` and `queued` through those three keys and the next two steps. Which keys were honoured, and why is the result still safe?
12. The queue is taken *after* the step. Write down a key sequence for which taking it *before* the step (at the start of `Tick`) would behave differently, and say which of the two the player would prefer.
13. Why does a step that ends the game leave `queued` as it is instead of clearing it? Could any later action observe the difference? (Look at what `steer`, `TogglePause` and `Restart` do in `GameOver`.)
14. Section 7 put the queue in `update` and left `turn` and `advance` alone. Name one benefit for the tests and one for a reader of the code. What would you have had to change in Module 3's fixtures if the queue had gone into `turn`?
15. The best score lives in a `Session` next to the game, not in `GameState`. List everything you would have had to change (functions, fixtures, `Restart`) if it had been a field of `GameState`.
16. With a stored best of 5, a player scores 8, restarts, and scores 3. Which `SaveBest` ops are performed, in which order, and on which actions? Why does the restart itself save nothing?
17. `renderSession` saves on the action that raises the record. Design the other policy, "save once, when a game ends with a new record": what must the session remember for that, and which test of Exercise 10.7 would change?
18. The browser platform turns every failure of `localStorage` into 0 or into nothing. Which failure would a player notice, and why is that still better than letting the exception reach `runIO`?
19. The viewer keeps every frame of the game in memory (`frames` is a list of 227 states). Estimate the memory for a ten-minute game at top speed. Then design the cheaper alternative: keep only every hundredth state and replay from the nearest one when the viewer jumps. Which law of Module 5 makes that correct?
20. Stepping backwards shows exactly the earlier picture. Explain why that is *guaranteed* here, and name two things a stateful game would have to get right to promise the same (think of the apple's position and the random seed).
21. The last frame of the sample shows the game's "Game Over — Press R to restart" banner, but R does nothing in the viewer. Where would you change that: in `view`, in `viewerView`, or somewhere else? What would your choice cost the Snake fixtures?
22. Turn the viewer into a recorder: while you play Snake, the program should keep the recording so that it can be saved at game over. Which value already holds everything you need, and which new `Op` (if any) would saving it need?
## In production ## In production
- **Elm** programs add features exactly like this: a new `Msg` variant, a new branch in `update`; the compiler's exhaustiveness check lists every place that must handle it ([The Elm Architecture](https://guide.elm-lang.org/architecture/)). - **Elm** programs add features exactly like this: a new `Msg` variant, a new branch in `update`; the compiler's exhaustiveness check lists every place that must handle it ([The Elm Architecture](https://guide.elm-lang.org/architecture/)).
- **Redux** reducers grow by new action types; the Redux docs recommend keeping reducers pure and putting effects in middleware, the same split as our `update` vs. interpreter ([Redux fundamentals: state, actions and reducers](https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers)). - **Redux** reducers grow by new action types; the Redux docs recommend keeping reducers pure and putting effects in middleware, the same split as our `update` vs. interpreter ([Redux fundamentals: state, actions and reducers](https://redux.js.org/tutorials/fundamentals/part-3-state-actions-reducers)).
- **Fixed-timestep game loops** separate the rate at which the platform calls you from the rate at which the simulation advances; our `Frame` + `movesEvery` is a minimal version ([Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/)). - **Fixed-timestep game loops** separate the rate at which the platform calls you from the rate at which the simulation advances; our `Frame` + `movesEvery` is a minimal version, and the article's `if (frameTime > 0.25) frameTime = 0.25` is our `clampTime` ([Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/)). Nystrom's chapter calls the failure a game that can never catch up (the "spiral of death") and gives the same remedy: bail out after a maximum number of updates.
- **Page visibility.** Browsers throttle or stop timers and animation callbacks in hidden tabs to save battery; games usually listen for `visibilitychange` and pause ([MDN: Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API)).
- **Event sourcing** stores the list of events and derives state by folding them — the same "state = fold(update, events)" idea, used for databases and audit logs; it is also why pause/restart cost nothing extra to replay ([Martin Fowler, "Event Sourcing"](https://martinfowler.com/eaaDev/EventSourcing.html)). - **Event sourcing** stores the list of events and derives state by folding them — the same "state = fold(update, events)" idea, used for databases and audit logs; it is also why pause/restart cost nothing extra to replay ([Martin Fowler, "Event Sourcing"](https://martinfowler.com/eaaDev/EventSourcing.html)).
## Further reading ## Further reading
- [Robert Nystrom, Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html) — fixed and variable time steps, and why games separate them. - [Robert Nystrom, Game Programming Patterns: Game Loop](https://gameprogrammingpatterns.com/game-loop.html) — fixed and variable time steps, and why games separate them.
- [Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/) — the classic article on decoupling simulation from the clock. - [Glenn Fiedler, "Fix Your Timestep!"](https://gafferongames.com/post/fix_your_timestep/) — the classic article on decoupling simulation from the clock, including the clamp on long frames.
- [MDN: Page Visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API) — what browsers do to hidden tabs, and how a page can find out.
- [Martin Fowler, "Event Sourcing"](https://martinfowler.com/eaaDev/EventSourcing.html) — what happens when you take "a program is a fold over events" seriously in business software. - [Martin Fowler, "Event Sourcing"](https://martinfowler.com/eaaDev/EventSourcing.html) — what happens when you take "a program is a fold over events" seriously in business software.
- [MDN: `Window.localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) — the API you would wrap in a new `Op` for the high-score exercise. - [MDN: `Window.localStorage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) — the API that the browser platform wraps behind `LoadBest` and `SaveBest`.
- [Evan Czaplicki, "The Perfect Bug Report"](https://elm-lang.org/news/the-perfect-bug-report) — Elm's debugger records a session as messages and replays it on another machine: the replay viewer of section 9, built into a language's tooling.
- Next: [Module 11](11-capstone-pong.md) converts a second game on your own; [Module 13](13-capstone-life.md), the second capstone, adds a new kind of input (the mouse) to the edge.
+8 -6
View File
@@ -79,7 +79,7 @@ The tests are shared fixtures, so the rules must be exact. Here they are in pros
6. **Scoring:** if `nx < 0`, the computer scores; if `nx >= W`, the player scores. A point **serves** a new ball: position `(40, 25)`, `dx` toward the side that conceded (computer scored → `-1`, player scored → `+1`), and `dy = 1` if `nextUint32(seed)` is even, `-1` otherwise — and the seed advances to the one `nextUint32` returned. 6. **Scoring:** if `nx < 0`, the computer scores; if `nx >= W`, the player scores. A point **serves** a new ball: position `(40, 25)`, `dx` toward the side that conceded (computer scored → `-1`, player scored → `+1`), and `dy = 1` if `nextUint32(seed)` is even, `-1` otherwise — and the seed advances to the one `nextUint32` returned.
7. **End:** when a score reaches `WIN`, the status becomes `Over`. 7. **End:** when a score reaches `WIN`, the status becomes `Over`.
When the match is `Over`, frames change nothing. Key presses and releases are still recorded (so that a key held across a restart behaves sensibly). **Restart** gives `initialPong(state.seed)` at any time. When the match is `Over`, frames change nothing. Key presses and releases are still recorded. **Restart** gives `initialPong(state.seed)` at any time, **except that `held` is kept**: the keys are a fact about the player's fingers, not about the match, and a player who restarts while holding a key is still holding it. (A restart that cleared `held` would leave the paddle standing still until the key is released and pressed again; the fixture `restart-keeps-the-held-keys` checks this.)
**The picture** (`pongView`), drawn in order, all text `'#ffffff'` and `'center'`-aligned: **The picture** (`pongView`), drawn in order, all text `'#ffffff'` and `'center'`-aligned:
@@ -217,7 +217,7 @@ Question: why are `Press` and `Release` separate actions rather than one `SetHel
### Step 3 — Write `update` as a fold ### Step 3 — Write `update` as a fold
`pongUpdate(state, action)` is your Pong `update`. `Press`/`Release` set a field of `held`; `Restart` is one call; `Frame` is the seven-step pipeline from [the rules](#the-rules-precisely). `pongUpdate(state, action)` is your Pong `update`. `Press`/`Release` set a field of `held`; `Restart` is one call plus the kept `held`; `Frame` is the seven-step pipeline from [the rules](#the-rules-precisely).
The `Frame` case is the biggest function you'll write in the course. The way to keep it readable is the way you kept Snake readable: **split it into small, pure, separately-understandable steps**, each taking a state and returning a state, applied in the order of the rules. You'll want a `clamp(lo, hi, x)` helper (it's tested on its own). The `Frame` case is the biggest function you'll write in the course. The way to keep it readable is the way you kept Snake readable: **split it into small, pure, separately-understandable steps**, each taking a state and returning a state, applied in the order of the rules. You'll want a `clamp(lo, hi, x)` helper (it's tested on its own).
@@ -241,11 +241,11 @@ Like Snake's `app`:
- `pongActions`: `Frame` every `FRAME_MS_PONG = 30` ms (from `interval`), merged with `pongKey(true, key)` of every key press (`keyDowns`) and `pongKey(false, key)` of every key release (`keyUps`). That's three streams merged — in which order? (The test `pongActions turns the clock into Frames and keys into Press, Release and Restart` tells you what it expects; read it.) - `pongActions`: `Frame` every `FRAME_MS_PONG = 30` ms (from `interval`), merged with `pongKey(true, key)` of every key press (`keyDowns`) and `pongKey(false, key)` of every key release (`keyUps`). That's three streams merged — in which order? (The test `pongActions turns the clock into Frames and keys into Press, Release and Restart` tells you what it expects; read it.)
- `pongStates(seed, actions)`: start from `initialPong(seed)`, fold with `pongUpdate`, emit the initial state first. - `pongStates(seed, actions)`: start from `initialPong(seed)`, fold with `pongUpdate`, emit the initial state first.
- `pongMain`: ask for a seed; turn the stream of states into a stream of drawings of `pongView`. - `pongMain`: ask for a seed; turn the stream of states into a stream of drawings of `pongView`; and wrap that stream in `clampTime(…, MAX_GAP_MS)` as you did for Snake in Module 10 (a ball that has played on alone for a minute while the tab was hidden is no more fun than a dead snake). `MAX_GAP_MS` is the constant from the Snake app module.
### Step 7 — One runtime at the edge ### Step 7 — One runtime at the edge
Nothing to write. `js/src/pong/main.js` is one line: `runApp(pongMain, browserPlatform(document.getElementById('game'), window))`. In Scala, `scala/web/Main.scala` has a `PongMain` object; `build-web.sh` already compiles it to `web/dist/pong.js`. F# has the equivalent in `fsharp/web/PongMain.fs`: `Runtime.runApp (BrowserPlatform.create (BrowserPlatform.gameCanvas ())) (Pong.App.pongMain ())`. Your m09 runtime runs a game it has never heard of. That's the reward for keeping it ignorant. Nothing to write. `js/src/pong/main.js` is one line: `runApp(pongMain, browserPlatform(document.getElementById('game'), window))`. In Scala, `scala/web/Main.scala` has a `PongMain` object; `build-web.sh` already compiles it to `web/dist/pong.js`. F# has the equivalent in `fsharp/web/PongMain.fs`: `Runtime.runApp (BrowserPlatform.create (BrowserPlatform.gameCanvas ())) (Pong.App.pongMain ())`. Your m09 runtime runs a game it has never heard of. That's the reward for keeping it ignorant. (It will not stay untouched for ever: Module 13 needs a fourth kind of input, the mouse, and adds one registration to it.)
## Exercises ## Exercises
@@ -299,7 +299,7 @@ let clamp (lo: int) (hi: int) (x: int) : int = failwith "TODO m11"
let pongUpdate (state: PongState) (action: PongAction) : PongState = failwith "TODO m11" let pongUpdate (state: PongState) (action: PongAction) : PongState = failwith "TODO m11"
``` ```
**Proved by:** `clamp keeps a number inside [lo, hi]`, `pongUpdate does not modify the state it is given`, `an idle match stays on the court and ends once someone reaches five`, and `fixtures/m11-pong-update.json` — 43 named cases. Read the case names: they're the specification broken into single facts (`player-stops-at-the-top-edge`, `cpu-does-not-move-on-an-odd-frame`, `paddle-bounce-uses-the-player-position-after-it-moved`, `serve-direction-comes-from-the-seed-odd-value-serves-upward`, `cpu-reaching-five-ends-the-match`, …). When one fails, open the fixture, find the case, and work it out by hand. **Proved by:** `clamp keeps a number inside [lo, hi]`, `pongUpdate does not modify the state it is given`, `an idle match stays on the court and ends once someone reaches five`, and `fixtures/m11-pong-update.json` — 49 named cases. Read the case names: they're the specification broken into single facts (`player-stops-at-the-top-edge`, `cpu-does-not-move-on-an-odd-frame`, `paddle-bounce-uses-the-player-position-after-it-moved`, `serve-direction-comes-from-the-seed-odd-value-serves-upward`, `cpu-reaching-five-ends-the-match`, …). When one fails, open the fixture, find the case, and work it out by hand.
**Strategy:** don't write all seven steps and then run the tests. Write `Press`/`Release`/`Restart` and run; then step 1 and 2 of `Frame` and run (watch the paddle cases go green); then the cpu; then the ball. Let the case names guide you. **Strategy:** don't write all seven steps and then run the tests. Write `Press`/`Release`/`Restart` and run; then step 1 and 2 of `Frame` and run (watch the paddle cases go green); then the cpu; then the ball. Let the case names guide you.
@@ -361,7 +361,7 @@ let pongMain () : IO<Stream<IO<unit>>> = failwith "TODO m11"
In F#, `main` and `pongMain` are **functions of unit** (`pongMain ()`), not values. F# evaluates a module's top-level values when the module is first used, so a value whose definition fails (like a stub) would break every test that touches the module; a function of unit is only evaluated when called. The value it returns is still the same pure description. In F#, `main` and `pongMain` are **functions of unit** (`pongMain ()`), not values. F# evaluates a module's top-level values when the module is first used, so a value whose definition fails (like a stub) would break every test that touches the module; a function of unit is only evaluated when called. The value it returns is still the same pure description.
**Proved by:** `fixtures/m11-pong-key.json` (21 cases, including `r-up-does-nothing` and the `constructor`/`toString` traps from Module 9), `the clock runs every 30 ms`, `pongActions turns the clock into Frames and keys into Press, Release and Restart`, `pongStates starts with the new match, then yields one state per action`, `pongMain asks for a seed, draws the new match first, and then the updated one`. **Proved by:** `fixtures/m11-pong-key.json` (21 cases, including `r-up-does-nothing` and the `constructor`/`toString` traps from Module 9), `the clock runs every 30 ms`, `pongActions turns the clock into Frames and keys into Press, Release and Restart`, `pongStates starts with the new match, then yields one state per action`, `pongMain asks for a seed, draws the new match first, and then the updated one`, `pongMain: after ten seconds in a background tab the match advances by 250 ms, not by ten seconds`.
**Run and play:** **Run and play:**
- JS: `cd js && node --test "test/m11-*.test.js"`; then, **from the repository root**, `node scripts/serve.mjs` and open <http://localhost:8000/js/pong.html> - JS: `cd js && node --test "test/m11-*.test.js"`; then, **from the repository root**, `node scripts/serve.mjs` and open <http://localhost:8000/js/pong.html>
@@ -419,3 +419,5 @@ Three sources, one `merge` chain: `merge(merge(frames, presses), releases)`. `pr
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — read it again now; every part should look familiar. - [The Elm Architecture](https://guide.elm-lang.org/architecture/) — read it again now; every part should look familiar.
- [Glenn Fiedler, "Deterministic Lockstep"](https://gafferongames.com/post/deterministic_lockstep/) — what pure, deterministic game updates make possible in networked games. - [Glenn Fiedler, "Deterministic Lockstep"](https://gafferongames.com/post/deterministic_lockstep/) — what pure, deterministic game updates make possible in networked games.
- [John Hughes, "Why Functional Programming Matters"](https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf) — reread the conclusion with the whole course behind you. - [John Hughes, "Why Functional Programming Matters"](https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf) — reread the conclusion with the whole course behind you.
**Next:** [Module 12](12-property-testing.md) (bonus) turns your random generator into a property-based testing tool. [Module 13](13-capstone-life.md), the second capstone, converts Conway's Game of Life, which needs a kind of input Pong did not: the mouse. It is the first time since Module 9 that the edge itself changes.
+783
View File
@@ -0,0 +1,783 @@
# Module 12 — Testing with properties (bonus)
> **Time:** ~4 h · **You'll edit:** JS `js/src/check.js` · Scala `scala/src/Check.scala` · F# `fsharp/src/Check.fs`
> **Tests:** JS `cd js && node --test "test/m12-*.test.js"` · Scala `cd scala && ./test.sh M12` · F# `cd fsharp && ./test.sh M12`
> **Prerequisites:** Modules 1–5 and Module 10, including the turn queue of Exercise 10.6 (you need `Random`, `update`, `history`, the `Frame` action and `queued`). Modules 6–9 and 11 are not needed.
## Why this module
Every test you have run in this course is an **example**: this state, this action, this expected state. Examples are precise, and they are easy to read. They also have one weakness, which Puzzle 3 of Module 10 showed: an example checks a case that somebody thought of. The first version of this course's speed-up rule had six fixture cases, all green, and still made the snake jump two cells in 25 ms after every third apple. Nobody had written the case "the score changes on this very frame", because nobody had thought of it. That is the kind of bug you are least likely to write an example for.
A **property** is a different kind of test. Instead of one input and its expected output, you state something that must be true for *every* input:
- every state of every game has the snake on the board;
- the snake never overlaps itself;
- the time between two moves is always `movesEvery(score)` frames.
Then you let the computer look for an input that breaks the statement, by trying hundreds of random ones. If it finds one, it hands you a **counterexample**.
Random tests have a bad reputation: they fail on Tuesday, pass on Wednesday, and nobody can reproduce the failure. That reputation comes from *hidden* randomness. You solved that problem in Module 4. A random test input is a value drawn from a `Random<A>` with a seed, so a failing test is reproduced by one number. And because the game is a fold over a list of actions (Module 5), a counterexample is a *replay*: a list you can run again, print, and cut down to the two key presses that matter.
In this module you build a small property-testing library from the parts you already have, about forty lines of code. Then you point it at deliberately broken versions of the game and watch it find each bug and reduce it to its essence. At the end you generate whole game states instead of games, and catch a bug that no statement about a single state can see, by comparing two implementations of the same rule.
## Learning objectives
After this module you can:
- **distinguish** an example-based test from a property, and **state** properties of several kinds (invariants, relations between runs, laws);
- **implement** generators of test data as `Random` values (`elementOf`, `listOf`) and recognise `listOf` as the same pattern as `sequence` from Module 7;
- **implement** a pure test runner `forAll` that returns a report with the counterexample and the seed that reproduces it;
- **implement** shrinking for lists (`removals`, `minimize`) and **explain** what "1-minimal" means and why total rules make shrinking easy;
- **write** an invariant checker for the game state and **explain** how it relates to "making illegal states unrepresentable";
- **judge** whether a property can detect anything at all, using deliberately broken implementations (mutants);
- **implement** a generator of structured data whose parts depend on each other (`genSnake`), and **explain** why the order of the draws is part of its contract;
- **use** a second, independently written implementation as a test oracle (`advanceSpec`), and **explain** which bugs it can and cannot find.
## Concepts
### 1. From examples to properties
Take `dropLast` from Module 1. An example test says:
```
dropLast([1, 2, 3]) is [1, 2]
```
A property says something about every list `xs`:
```
for every list xs: dropLast(xs) has length max(0, length(xs) - 1)
for every list xs: dropLast(xs) is the beginning of xs
```
Neither property mentions a particular list, and together they pin `dropLast` down completely. To *check* a property you need three things: a way to produce many inputs, a way to run the check on each of them and report what happened, and, when it fails, a way to make the failing input small enough to understand. Those are the three parts of this module: **generators**, **`forAll`** and **shrinking**.
> **From everyday programming:** you may know *fuzzing*: throwing large amounts of random input at a parser or a network service to see whether it crashes. A property test is a fuzzer with an opinion. It does not only ask "did it crash?" but "is this statement about the result still true?", and it runs in milliseconds as part of the unit tests.
### 2. A generator is a `Random`
What is "a way to produce test inputs"? A function from a seed to a value and a new seed, so that the next input can be drawn from where the previous one stopped. You built exactly that type in Module 4:
```
Random<A> = seed -> [A, seed]
```
So there is no new type in this module. A generator of numbers below 10 is `intBelow(10)`. Two small combinators give you everything else the game needs:
- **`elementOf(xs)`** picks one element of a non-empty list: draw an index with `intBelow(length)` and look it up. That is a `map`.
- **`listOf(gen, n)`** draws `n` values from `gen`, one after another, and collects them in a list. The first draw uses the incoming seed, the second draw the seed the first one left behind, and so on.
`listOf` is worth a second look. "Run these computations one after another and collect the results" is what `sequence` did for `IO` in Module 7, and it is built the same way: from `flatMap` and `pure`, by recursion.
```
listOf(gen, 0) = pure([])
listOf(gen, n) = flatMap(gen, a => map(listOf(gen, n - 1), rest => [a, ...rest]))
```
Read the second line as: draw one value `a`; then draw the remaining `n - 1` values as a list `rest`; the result is `a` in front of `rest`. Nowhere do you see a seed. `flatMap` threads it, which is the reason you wrote `flatMap` in the first place.
With these two, a generator of random games is one line, and it is given in your stub:
```
genActions(n) = listOf(elementOf([Tick, Tick, Tick, Turn Up, Turn Down, Turn Left, Turn Right]), n)
```
`Tick` appears three times in the list, so about three actions in seven are ticks. Repeating an element is the simplest way to make it more likely.
**Language note.** JS: `listOf(gen, n)`, data first. Scala: `Check.listOf(gen, n)`. F#: `Check.listOf n gen`, data last, so that it reads `gen |> Check.listOf 40`.
### 3. `forAll`: a test runner that is a pure function
`forAll(gen, prop, runs, seed)` draws `runs` values from `gen`, starting from `seed`, and calls the property `prop` (a function from a value to a boolean) on each. It stops at the first value for which `prop` is false.
Most test frameworks report a failure by throwing an exception or printing to the console. Ours **returns a value**:
```
all runs passed: Passed(runs)
a run failed: Failed(run, seed, value)
```
- `run` is the number of the failing draw, counted from 0;
- `value` is the counterexample;
- `seed` is the seed *that draw started from*, so drawing from `gen` with `seed` gives `value` again.
In JS the report is a plain object, `{ ok: true, runs }` or `{ ok: false, run, seed, value }`. In Scala and F# it is a small sum type, `CheckResult`, given in your stub.
`forAll` has no loop counter and no "current seed" variable. It is a recursion that carries the run number and the seed as arguments, the same way `interval` carried `last` in Module 8.
Because `forAll` is pure, the test of a test runner is an ordinary equality: the same generator, property, number of runs and seed give the same report, every time, on every machine. A failure in one track's test suite can be reproduced in another track with the same seed, because all three tracks draw the same numbers (Module 4).
> **From everyday programming:** a *flaky* test is a test whose result depends on something it does not control: the clock, the order of a hash map, a random number. The usual cure is to hunt down each hidden input and pin it. Here there is nothing to hunt down. The only source of variation is the seed, and the seed is an argument.
### 4. Shrinking: from forty actions to two
Suppose `forAll` reports that this list of 40 random actions breaks a property:
```
Tick, Turn Down, Tick, Tick, Turn Right, Turn Up, Tick, Turn Left, Tick, Tick, Turn Up, Tick, ... (28 more)
```
Somewhere in there is a bug, but which of the 40 actions matter? The idea of **shrinking** is to let the computer find out: try smaller inputs, and keep any that still fails.
For a list, the simplest "smaller input" is the list with one element removed:
```
removals([a, b, c]) = [[b, c], [a, c], [a, b]]
```
`minimize(xs, fails)` repeats one step: look through `removals(xs)` for the first list that still fails. If there is one, continue from it. If there is none, stop and return `xs`.
The result is called **1-minimal**: it fails, and removing any *single* element makes it pass. Every element of it is needed. It is not always the smallest failing list that exists. Take the (artificial) failure "the list has exactly one or exactly three elements": `minimize` stops at three elements, because every list of two passes, although a list of one would fail too. For bugs in real programs, 1-minimal is almost always good enough, and it is cheap: at most `n` rounds of at most `n` tries.
Why is this so easy for our game? Because of two decisions made long ago:
- **A game is a list of actions** (Module 5). The test input is plain data, so "a smaller input" has an obvious meaning.
- **The rules are total** (Module 3). *Every* list of actions is a valid game. If `turn` threw an exception for an illegal turn, most of the shortened lists would crash for reasons that have nothing to do with the bug, and the shrinker would be led astray. Because "does not apply" means "unchanged", you can delete any action from any game and still have a game.
> **From everyday programming:** this is what you do by hand with a bug report. A user sends twenty steps to reproduce a crash; you leave out a step, try again, and keep going until every remaining step is needed. `git bisect` automates the same search over a list of commits. The technique has a name, *delta debugging*, and `minimize` is its simplest form.
### 5. What to check: invariants
The most useful properties of a program with state are its **invariants**: statements that hold for every state the program can ever reach.
Module 2 was honest about the model: the types rule out some nonsense (a status that is paused *and* over), but a `GameState` can still be built with a snake outside the board, an apple inside the snake, or a snake whose cells are scattered across the board. Types could not exclude those states, at least not conveniently. The *rules* are supposed to: starting from `newGame(seed)`, no sequence of actions should ever produce one.
An invariant checker turns that claim into a function:
```
violations(state) = the names of the invariants this state breaks (an empty list: the state is sane)
```
| Name | The state is sane when... |
|---|---|
| `snake-not-empty` | the snake has at least one cell |
| `snake-in-bounds` | every cell of the snake is on the board |
| `snake-no-overlap` | no two cells of the snake are equal |
| `snake-connected` | each cell of the snake is exactly one step (up, down, left or right) from the one before it |
| `apple-in-bounds` | the apple, if there is one, is on the board |
| `apple-not-on-snake` | the apple, if there is one, is not on a cell of the snake |
| `turn-not-reversed` | `nextDir` is not the opposite of `dir` |
| `queued-not-reversed` | `queued` is none, or is not the opposite of `nextDir` (the turn queue of Module 10) |
`violations` returns the names in this order. It returns names and not a single boolean, so that a failing test says *what* is wrong. Each row is one small predicate; the function is a `filter` over a table of (name, predicate) pairs.
The property is then one line:
```
for every list of 40 random actions: every state in history(newGame(1), actions) has no violations
```
Think of invariants as the part of "make illegal states unrepresentable" that the type system left over. Where a type can exclude a bad state, use the type: the compiler checks it for all programs, for free. Where it cannot, write the invariant down as a function and check it on thousands of random games.
### 6. What to check: relations and laws
Invariants look at one state at a time. Some requirements are about how states relate to each other.
**A relation between moments of one run.** "Every step of the snake lasts exactly `movesEvery(score)` frames" is a statement about a whole history. To check it, you need the *gaps* between consecutive moves:
```
moveGaps(states) = the distances between the positions in the list where the head moved
```
The snake "moves at index `i`" when the head of `states[i]` differs from the head of `states[i - 1]`. If it moves at indices 2, 5 and 12, the gaps are `[3, 7]`. With `moveGaps` the requirement of Module 10 becomes checkable, and the tests of this module run it against the tempting rule of Puzzle 3.
**Laws.** Some equations hold between whole computations, for example the replay law of Module 5:
```
replay(s, a ++ b) == replay(replay(s, a), b)
```
Here is a question that separates a useful property from a useless one: **could this property ever fail?** Look at the replay law again. It follows from the definition of a fold, for *any* step function whatsoever. A game in which the snake teleports, an `update` that ignores every action, a version full of bugs: all of them satisfy it. So a test of this law tests `replay` (is it really a left fold?) and tells you nothing about the game.
There is a practical way to answer "could it fail?": break the program on purpose and see whether the property notices. A deliberately broken copy is called a **mutant**, and a mutant that fails a test is said to be *killed*. The last tests of this module do exactly that, with these mutants:
| Mutant | The bug | Caught by |
|---|---|---|
| `turn` checks against `nextDir` | Puzzle 2 of Module 3: the quick double turn | `turn-not-reversed` |
| `advance` forgets the walls | the snake walks off the board | `snake-in-bounds` |
| `Tick` forgets the turn queue | Exercise 10.6 done in `Frame` but not in `Tick` | `queued-not-reversed` |
| the queue accepts a reversal | Exercise 10.6 without the "not the opposite of `nextDir`" check | `queued-not-reversed` |
| the tempting frame rule | Puzzle 3 of Module 10: steps of uneven length | the gaps of `moveGaps` |
| `advance` checks the *old* snake | Puzzle 1 of Module 3: death by the tail that is moving away | comparison with `advanceSpec` (section 8) |
For the first four, the tests also shrink the counterexample, and the results are worth predicting. What is the shortest list of actions that makes a snake reverse into itself if `turn` checks the wrong direction? What is the shortest list that walks the snake of `newGame(1)` off a board without walls? (The head starts at `x = 10` on a board 20 wide, heading `Right`.) Decide before you run the tests; the test titles give the answers.
### 7. Generating states, not only games
Every test input so far has been a list of actions, played from `newGame`. That only reaches the states that random play reaches, and random play is a poor explorer: the snake grows only when it eats, and forty random actions rarely eat more than one apple. A long snake coiled up so that its head is right next to its own tail, the situation of Puzzle 1, practically never happens.
When the function under test takes *one state*, as `advance` does, you can skip the game and generate the state directly. The difficulty is that the parts of a state depend on each other. The snake's cells must be neighbours and must not repeat; `dir` must agree with the first two cells; the apple must be on a free cell. A generator that drew each field independently would produce almost nothing but nonsense, and a test of `advance` on nonsense says nothing about the game.
The way out is to **draw the parts in the order in which they depend on each other**, each draw using what the earlier ones produced. That is exactly what `flatMap` does: the next generator is chosen *from the value of the previous one*. For the snake:
```
genSnake(width, height, maxLen):
draw the head: x with intBelow(width), then y with intBelow(height)
then, until the snake has maxLen cells:
the candidates are the neighbours of the LAST cell, in the order Up, Down, Left, Right,
that are on the board and not yet in the snake
if there are none, stop early; otherwise append elementOf(candidates) and go on
```
From seed 0 on a 5 x 5 board with `maxLen` 6, the head is (3,2) and the walk continues up and then left along the top row:
```
x=0 x=1 x=2 x=3 x=4
y=0 5 4 3 2 . snake (head first): (3,2) (3,1) (3,0) (2,0) (1,0) (0,0)
y=1 . . . 1 . the numbers give the order in which the cells were drawn
y=2 . . . H .
y=3 . . . . .
y=4 . . . . .
```
The order of the draws, and the order Up, Down, Left, Right of the candidates, are part of `genSnake`'s contract, written in the stub. They decide which snake a seed gives. Because all three tracks follow them, a counterexample found from a seed in one track is the same counterexample in the others.
`genState(width, height)` is given in your stub and builds on `genSnake`: a length limit between 1 and `width * height`, a snake, `dir` read off the first two cells (`Right` for a one-cell snake), `nextDir` equal to `dir` or a perpendicular direction, an apple on a random free cell or none, and nothing queued. Read it: it is four `flatMap`s, each one choosing the next generator from what came before.
A generator is code, and code has bugs. If `genState` produced a disconnected snake, every later test would complain about states the game can never reach, and you would hunt for bugs that are not there. So the first test of a generator is a property *of the generator*: `every generated state is sane: 500 states of a 5x5 board have no violations`.
> **From everyday programming:** test-data builders and factories (a `UserFactory` that fills in a valid email, a hashed password and a creation date before an account object is saved) solve the same problem: produce objects whose fields satisfy the constraints between them, so that the test can concentrate on what it wants to check. A property-testing generator is a factory that picks *random* valid objects, and can therefore find the valid object nobody thought of.
### 8. A second implementation as an oracle
Generated states make it possible to test `advance` on hundreds of coiled-up snakes. But test it against *what*? Invariants say whether a state is sane, and the bug of Puzzle 1 produces only sane states: it ends the game one step too early, and a finished game is a perfectly valid state. To catch a result that is valid but *wrong*, you need to know the right result. Something that knows it is called a **test oracle**.
For one hand-written example, the oracle is you: you write the expected state into a fixture. For 500 random states, you need a program. The usual answer is **a second implementation**, written independently, and the property
```
for every generated state s: advance(s) == advanceSpec(s)
```
The second implementation is only useful if it does not share the first one's mistakes, so it should be written in a different way. Module 3's `advance` decides about the *new head*: compute the body that remains, then ask whether the head lands on the board and outside that body. `advanceSpec` decides about the *resulting snake*: build the whole snake the step would produce (the new head in front, then the old snake without its last cell unless eating), and ask `violations`, the checker you wrote in Exercise 12.4, whether that snake is on the board and free of overlap. If it is not, the step is deadly, and the result is Module 3's game over. It is the rule stated as a requirement on the outcome: "after a step, the snake must still be a legal snake".
Now look at the wrong check of Puzzle 1 again: "the new head must not be on the *old* snake". It is a statement about the old snake, and it disagrees with "the new snake must not overlap" exactly when the head moves onto the cell the tail is leaving. The comparison test finds such a state in the 22nd draw (run 21) from seed 2024:
```
x=0 x=1 x=2 x=3 x=4
y=0 1 2 . . . snake (head first): (0,1) (0,0) (1,0) (1,1) (1,2) (1,3) (0,3) (0,2)
y=1 H 3 . . . heading Down; the head's next cell (0,2) is the tail T
y=2 T 4 . . . A is the apple
y=3 6 5 A . .
y=4 . . . . .
```
The wrong check says "game over"; `advanceSpec` says the snake moves on, its tail having left (0,2) at the same moment. No invariant could have said which one is right, and the test `no invariant sees the wrong collision check of Puzzle 1: every state it produces is sane` shows that, too.
Two caveats. An oracle written by the same person on the same afternoon can share a *misunderstanding* of the rules with the code it checks; then the two agree and are both wrong. That is what the example fixtures, written from the specification, are for: `advanceSpec` must also pass Module 3's twelve `advance` cases. And an oracle has to be simpler or more obviously right than the code it checks, or you have only doubled the code you must trust. `advanceSpec` is simpler in one sense: it delegates the whole question "is this a legal snake?" to a function that was already tested on its own.
> **From everyday programming:** this is *differential testing*. When a team rewrites a slow service, they often run the old and the new version side by side on the same requests and compare the answers before switching. Compiler writers generate random programs and compare the output of two compilers. A refactoring is safe to the extent that the code after it agrees with the code before it, and keeping the old version around as an oracle turns that into a test.
### 9. What random testing does not find
Be as honest about this tool as Module 2 was about the model.
- **A property only sees what it states.** The invariants of section 5 cannot see the wrong collision check of Puzzle 1: it ends the game too early, and a game that has ended is still a sane state. Catching it took a different kind of property, the comparison with a second implementation of section 8. That one has its own blind spot: it cannot see a mistake that both implementations make.
- **Random inputs reach the likely states.** Forty random actions from a new game almost never eat three apples. So random play from `newGame` would practically never reach the first speed-up, and would not find the bug of Puzzle 3. The tests therefore start from a hand-made state: a long corridor with the third apple right in front of the head, and a random number of frames already counted. Generating states directly (section 7) is the other answer. Choosing where to start and what to generate is the real craft of property testing.
- **Passing is evidence, not proof.** 300 random games that keep every invariant make a bug less likely. They do not show that there is none. A proof needs reasoning (as in question 3 of Module 5) or a tool that explores *every* reachable state.
## Exercises
> **About test names.** The test titles quoted under "Tests" are the JavaScript ones; the JS runner prints them with the module in front (`m12 listOf threads the seed from each draw to the next`). The Scala and F# suites test the same things one-to-one, in each track's vocabulary (`Passed`/`Failed` instead of `ok`). Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M12Check.test.scala` or `fsharp/tests/M12Check.fs`. Fixture-based tests have the same case names in every track.
All functions are pure: no loops, no reassignment, no mutation. The purity audit checks this file like every other.
### Exercise 12.1 — `elementOf` and `listOf`
**JavaScript**
```js
export const elementOf = (xs) => ... // Random<A>, for a non-empty array xs
export const listOf = (gen, n) => ... // Random<A[]>
```
**Scala**
```scala
def elementOf[A](xs: List[A]): Random[A]
def listOf[A](gen: Random[A], n: Int): Random[List[A]]
```
**F#**
```fsharp
let elementOf (xs: 'a list) : Random<'a>
let listOf (n: int) (gen: Random<'a>) : Random<'a list>
```
Tests: `elementOf picks the element at the index drawn by intBelow`, `elementOf of a one-element array always gives that element, and still uses up a draw`, `listOf(gen, 0) is the empty list and leaves the seed alone`, `listOf threads the seed from each draw to the next`, `listOf of a constant generator repeats the value and leaves the seed alone`, `genActions(n) gives n actions, the same ones for the same seed`.
Watch out: write both with `map`, `flatMap` and `pure` from Module 4. If you find yourself unpacking a `[value, seed]` pair, you are doing by hand what `flatMap` does for you.
### Exercise 12.2 — `forAll`
**JavaScript**
```js
export const forAll = (gen, prop, runs, seed) => ...
// { ok: true, runs } or { ok: false, run, seed, value }
```
**Scala**
```scala
enum CheckResult[+A]: // given
case Passed(runs: Int)
case Failed(run: Int, seed: Long, value: A)
def forAll[A](gen: Random[A], runs: Int, seed: Long)(prop: A => Boolean): CheckResult[A]
```
**F#**
```fsharp
type CheckResult<'a> = // given
| Passed of runs: int
| Failed of run: int * seed: uint32 * value: 'a
let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a>
```
Tests: `forAll reports success with the number of runs`, `forAll with zero runs succeeds without drawing anything`, `forAll reports the first failing draw, its value and the seed it started from`, `forAll stops at the first failure: the property is not asked about later values`, `forAll is reproducible: same generator, property, runs and seed give the same report`.
Watch out: the `seed` in a failure report is the seed the failing draw *started from*, not the one it left behind. And each draw must continue from the previous draw's leftover seed; if every draw starts from the same seed, you test the same value `runs` times.
### Exercise 12.3 — `removals` and `minimize`
**JavaScript**
```js
export const removals = (xs) => ... // removals([1, 2, 3]) -> [[2, 3], [1, 3], [1, 2]]
export const minimize = (xs, fails) => ... // fails: list -> boolean, and fails(xs) is true
```
**Scala**
```scala
def removals[A](xs: List[A]): List[List[A]]
def minimize[A](xs: List[A])(fails: List[A] => Boolean): List[A]
```
**F#**
```fsharp
let removals (xs: 'a list) : 'a list list
let minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list
```
Tests: `removals lists every way to drop one element, in order`, `removals of an empty list is empty, and of a single element is one empty list`, `minimize keeps exactly the elements the failure needs`, `minimize keeps the order and the duplicates the failure needs`, `minimize of a list that fails whatever you remove is the empty list`, `minimize returns a list that still fails and has no failing removal (1-minimal)`.
Watch out: continue with the **first** failing removal, in the order of `removals`. The tracks must agree on which minimal list they report.
### Exercise 12.4 — `violations`
**JavaScript**
```js
export const violations = (state) => ... // string[], in the order of the table in section 5
```
**Scala**
```scala
def violations(state: GameState): List[String]
```
**F#**
```fsharp
let violations (state: GameState) : string list
```
Fixture: `fixtures/m12-violations.json`, 21 cases: six sane states (`a-sane-state-has-no-violations`, `no-apple-is-fine`, `a-one-cell-snake-is-fine`, `a-queued-turn-is-fine`, ...), one or two cases per invariant (`head-off-the-right-edge`, `snake-with-a-diagonal-step`, `apple-on-the-tail`, `pending-turn-straight-back`, `queued-turn-straight-back-from-the-pending-turn`, ...), `several-violations-are-listed-in-order` and `reversed-pending-turn-and-reversed-queue-are-both-listed`.
Watch out: `snake-no-overlap` and `snake-connected` are different. A snake with the same cell twice in a row (`doubled-cell-counts-as-overlap-and-as-disconnected`) breaks both: the cells overlap, and they are zero steps apart, not one. Reuse `inBounds` from Module 3 and `contains` from Module 1. `queued-not-reversed` compares with `nextDir`, not with `dir`: in `a-queued-turn-is-fine` the snake moves `Right`, `Up` is pending and `Left` is queued, which is fine.
### Exercise 12.5 — `moveGaps`
**JavaScript**
```js
export const moveGaps = (states) => ... // number[]
```
**Scala**
```scala
def moveGaps(states: List[GameState]): List[Int]
```
**F#**
```fsharp
let moveGaps (states: GameState list) : int list
```
Tests: `moveGaps measures the distance between consecutive moves of the head`, `moveGaps of fewer than two moves is empty`.
### Exercise 12.6 — `genSnake`
**JavaScript**
```js
export const genSnake = (width, height, maxLen) => ... // Random<Point[]>, head first; maxLen >= 1
```
**Scala**
```scala
def genSnake(width: Int, height: Int, maxLen: Int): Random[List[Point]]
```
**F#**
```fsharp
let genSnake (width: int) (height: int) (maxLen: int) : Random<Point list>
```
Draw exactly as section 7 and the stub's doc comment say: the head's `x`, then its `y`, then one `elementOf` per further cell among the free in-bounds neighbours of the *last* cell, in the order Up, Down, Left, Right; stop at `maxLen` cells or when there is no candidate.
Tests: `genSnake draws the head first, then walks from the tail (seed 0, 5x5, at most 6 cells)`, `genSnake is pinned for a few more seeds`, `genSnake with maxLen 1 is just the head, drawn with two draws`, `genSnake stops early when the walk is stuck: a 1x1 board holds one cell`, `genSnake always gives a snake on the board, connected, without overlap, at most maxLen long`, and, through the given `genState`, `every generated state is sane: 500 states of a 5x5 board have no violations` and `generated states are Playing, have nothing queued, and vary in length and apple`.
Watch out: the walk extends the snake at its *tail* (the last cell), and the snake is head first, so new cells go at the end of the list. Stopping early must not use up a draw: when there are no candidates, return the snake with `pure`. And do not draw the head as one random cell from a list of all cells: that is a different draw, and the pinned tests will tell you so.
### Exercise 12.7 — `advanceSpec`
**JavaScript**
```js
export const advanceSpec = (state) => ... // GameState; must equal advance(state) on every sane state
```
**Scala**
```scala
def advanceSpec(state: GameState): GameState
```
**F#**
```fsharp
let advanceSpec (state: GameState) : GameState
```
Write it as section 8 describes, without calling `advance`: build the snake the step would produce, then let `violations` decide whether it contains `snake-in-bounds` or `snake-no-overlap`.
Tests: `advanceSpec agrees with Module 3's advance fixture: …` (one test per case of `fixtures/m03-advance.json`), `advance and advanceSpec agree on 500 generated states of a 5x5 board`, `no invariant sees the wrong collision check of Puzzle 1: every state it produces is sane`, `comparing with advanceSpec catches the wrong collision check: the head was moving onto the tail`.
Watch out: ask `violations` about the state *with the new snake*, and look only at the two snake invariants. When the step eats, the new head is on the apple, so `apple-not-on-snake` is in the list too, and that does not make the step deadly. Game over keeps the old snake and records `dir = nextDir`, exactly as in Module 3.
### The payoff
When 12.1–12.5 are green, the remaining tests of the module run your library against the game. They need no code from you; read them in the test file, because they show how the pieces are used together:
- `the real game keeps every invariant on 300 random games of 40 actions`;
- `the quick double turn bug is found and shrunk to its two key presses`;
- `a game without walls is found and shrunk to the shortest way off the board: ten ticks`;
- `a Tick that forgets the queued turn is caught by queued-not-reversed and shrunk to four actions`;
- `a queue that accepts a reversal is found and shrunk to its two key presses`;
- `the real game moves exactly every movesEvery(score) frames, also across a speed-up`;
- `counting frames from the start of the game makes some steps too short, and the property finds one`.
If the first of these fails, do not assume that your library is wrong. It may have found a real bug in *your* rules that the fixtures missed. Shrink the counterexample and look at it.
### Run
- JS: `cd js && node --test "test/m12-*.test.js"`
- Scala: `cd scala && ./test.sh M12`
- F#: `cd fsharp && ./test.sh M12`
## Hints
<details><summary>Exercise 12.1 (elementOf, listOf) — nudge</summary>
`elementOf` is "a random index, transformed into the element at that index". `listOf` is recursion on `n`: what is a list of zero draws, and how do you get a list of `n` draws from one draw and a list of `n - 1` draws?
</details>
<details><summary>Exercise 12.1 (elementOf, listOf) — stronger hint</summary>
`elementOf(xs)`: `map` over `intBelow(length of xs)`. `listOf(gen, n)`: if `n` is 0 (or less), `pure` of the empty list. Otherwise `flatMap` over `gen`; inside, you have the first value `a` and need a `Random` of the whole list: `map` over `listOf(gen, n - 1)` and put `a` in front. In F# remember that the function is `bind` and that the data comes last.
</details>
<details><summary>Exercise 12.1 (elementOf, listOf) — near-solution (try the others first)</summary>
```js
export const elementOf = (xs) => map(intBelow(___), (i) => ___)
export const listOf = (gen, n) =>
n <= 0 ? pure(___) : flatMap(gen, (a) => map(listOf(gen, ___), (rest) => [___, ...___]))
```
```scala
def elementOf[A](xs: List[A]): Random[A] = Random.intBelow(___).map(i => ___)
def listOf[A](gen: Random[A], n: Int): Random[List[A]] =
if n <= 0 then Random.pure(___)
else gen.flatMap(a => listOf(gen, ___).map(rest => ___ :: ___))
```
```fsharp
let elementOf (xs: 'a list) : Random<'a> = Random.intBelow ___ |> Random.map (fun i -> List.item i xs)
let rec listOf (n: int) (gen: Random<'a>) : Random<'a list> =
if n <= 0 then Random.pure ___
else gen |> Random.bind (fun a -> listOf ___ gen |> Random.map (fun rest -> ___ :: ___))
```
</details>
<details><summary>Exercise 12.2 (forAll) — nudge</summary>
Write a helper that takes the number of the current run and the current seed. It has three outcomes: all runs are done; this value passes, so go on with the next run and the leftover seed; this value fails, so report.
</details>
<details><summary>Exercise 12.2 (forAll) — stronger hint</summary>
`go(i, s)`: if `i` has reached `runs`, the result is the success report. Otherwise run `gen` with `s` to get the value and the next seed. If `prop(value)` holds, the result is `go(i + 1, nextSeed)`. If not, the result is the failure report with `i`, `s` (not `nextSeed`) and the value. `forAll` is `go(0, seed)`. In Scala, mark the helper `@annotation.tailrec`; in F#, `let rec` with the recursive call in tail position.
</details>
<details><summary>Exercise 12.2 (forAll) — near-solution (try the others first)</summary>
```js
export const forAll = (gen, prop, runs, seed) => {
const go = (i, s) => {
if (i >= runs) return { ok: true, runs }
const [value, next] = run(gen, s)
return prop(value) ? go(___, ___) : { ok: false, run: ___, seed: ___, value }
}
return go(0, seed)
}
```
```scala
def forAll[A](gen: Random[A], runs: Int, seed: Long)(prop: A => Boolean): CheckResult[A] =
@annotation.tailrec
def go(i: Int, s: Long): CheckResult[A] =
if i >= runs then CheckResult.Passed(runs)
else
val (value, next) = gen.run(s)
if prop(value) then go(___, ___) else CheckResult.Failed(___, ___, value)
go(0, seed)
```
```fsharp
let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a> =
let rec go i s =
if i >= runs then Passed runs
else
let (value, next) = Random.run s gen
if prop value then go ___ ___ else Failed(___, ___, value)
go 0 seed
```
</details>
<details><summary>Exercise 12.3 (removals, minimize) — nudge</summary>
`removals`: for each index, the list without the element at that index. `minimize`: find the first removal that still fails; what do you do with it, and what do you do when there is none?
</details>
<details><summary>Exercise 12.3 (removals, minimize) — stronger hint</summary>
`removals` in JS: `map` with the index, and build each list from two `slice`s. In Scala, `xs.indices.toList.map(i => xs.patch(i, Nil, 1))`; in F#, `List.mapi` with `List.removeAt` (or recursion on the list: removing from `x :: rest` is either `rest`, or `x` in front of each removal of `rest`). `minimize`: `find` (`List.tryFind`) the first failing removal. If there is one, the answer is `minimize` of it. If not, the answer is `xs`.
</details>
<details><summary>Exercise 12.3 (removals, minimize) — near-solution (try the others first)</summary>
```js
export const removals = (xs) => xs.map((_, i) => [...xs.slice(0, ___), ...xs.slice(___)])
export const minimize = (xs, fails) => {
const smaller = removals(xs).find(___)
return smaller === undefined ? ___ : minimize(___, fails)
}
```
```scala
def removals[A](xs: List[A]): List[List[A]] = xs.indices.toList.map(i => xs.patch(i, Nil, ___))
def minimize[A](xs: List[A])(fails: List[A] => Boolean): List[A] =
removals(xs).find(___) match
case Some(smaller) => minimize(___)(fails)
case None => ___
```
```fsharp
let removals (xs: 'a list) : 'a list list = xs |> List.mapi (fun i _ -> List.removeAt ___ xs)
let rec minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list =
match removals xs |> List.tryFind ___ with
| Some smaller -> minimize fails ___
| None -> ___
```
</details>
<details><summary>Exercise 12.4 (violations) — nudge</summary>
Do not write eight `if`s that push onto a list. Write a table: a list of pairs (name, a function that says whether the state is fine). Then the result is "the names of the pairs whose function says no".
</details>
<details><summary>Exercise 12.4 (violations) — stronger hint</summary>
The predicates, on a state `s`: the snake's length is above 0; every snake cell satisfies `inBounds`; for every cell, the cells *after* it do not contain it; every cell from the second on is a neighbour of the cell before it (the sum of the absolute differences of `x` and `y` is exactly 1); the apple is absent or in bounds; the apple is absent or not contained in the snake; `nextDir` differs from `opposite(dir)`; `queued` is absent or differs from `opposite(nextDir)`. For "each cell and the one before it", pair the snake with itself shifted by one: in JS use the index argument of `every`, in Scala `snake.zip(snake.drop(1))`, in F# `List.pairwise`.
</details>
<details><summary>Exercise 12.4 (violations) — near-solution (try the others first)</summary>
```js
const neighbours = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1
const invariants = [
['snake-not-empty', (s) => s.snake.length > 0],
['snake-in-bounds', (s) => s.snake.every((p) => ___)],
['snake-no-overlap', (s) => s.snake.every((p, i) => !contains(s.snake.slice(___), p, pointEquals))],
['snake-connected', (s) => s.snake.slice(1).every((p, i) => neighbours(s.snake[___], p))],
['apple-in-bounds', (s) => s.apple === null || ___],
['apple-not-on-snake', (s) => s.apple === null || ___],
['turn-not-reversed', (s) => ___],
['queued-not-reversed', (s) => s.queued === null || ___],
]
export const violations = (state) => invariants.filter(([, holds]) => ___).map(([name]) => name)
```
The Scala and F# versions have the same shape: a `List` of `(String, GameState => Boolean)` pairs, then `filterNot`/`List.filter` and `map`. With `snake.zip(snake.drop(1)).forall { case (a, b) => ... }` or `List.pairwise snake |> List.forall (fun (a, b) -> ...)` for the connected check, and `apple.forall(p => ...)` / `Option.forall (fun p -> ...) apple` for "absent or fine".
</details>
<details><summary>Exercise 12.5 (moveGaps) — nudge</summary>
Two steps. First the list of indices at which the head moved. Then the differences between neighbours in that list.
</details>
<details><summary>Exercise 12.5 (moveGaps) — stronger hint</summary>
Step 1: pair each state with the one before it (JS: `map` with the index, look at `states[i - 1]`; Scala: `states.zip(states.drop(1)).zipWithIndex`; F#: `List.pairwise` and `List.indexed`), keep the indices where the two heads differ. Mind the off-by-one: the pair of states 0 and 1 is a move *at index 1*. Step 2: pair the list of indices with itself shifted by one and subtract.
</details>
<details><summary>Exercise 12.5 (moveGaps) — near-solution (try the others first)</summary>
```js
export const moveGaps = (states) => {
const moved = states
.map((s, i) => (i > 0 && !pointEquals(s.snake[0], states[___].snake[0]) ? i : -1))
.filter((i) => i >= 0)
return moved.slice(1).map((i, k) => i - moved[___])
}
```
```scala
def moveGaps(states: List[GameState]): List[Int] =
val moved = states.zip(states.drop(1)).zipWithIndex.collect {
case ((before, after), i) if before.snake.headOption != after.snake.headOption => ___
}
moved.zip(moved.drop(1)).map((a, b) => ___)
```
```fsharp
let moveGaps (states: GameState list) : int list =
states
|> List.pairwise
|> List.indexed
|> List.filter (fun (_, (before, after)) -> List.tryHead before.Snake <> List.tryHead after.Snake)
|> List.map (fun (i, _) -> ___)
|> List.pairwise
|> List.map (fun (a, b) -> ___)
```
</details>
<details><summary>Exercise 12.6 (genSnake) — nudge</summary>
Write a helper that takes the snake built so far and the number of cells still allowed, and returns a `Random` of the finished snake. It has three outcomes: no cells left to add; no candidate cell; or pick a candidate and continue. `genSnake` draws the head and then calls the helper.
</details>
<details><summary>Exercise 12.6 (genSnake) — stronger hint</summary>
`grow(snake, left)`: if `left` is 0 (or less), `pure(snake)`. Otherwise take the last cell, `move` it in each of Up, Down, Left, Right (in that order), and keep the cells for which `inBounds` holds (it only reads `width` and `height`, so a small record with those two fields will do) and which the snake does not `contain`. No candidates: `pure(snake)`. Otherwise `flatMap` over `elementOf(candidates)` and continue with the snake plus that cell and `left - 1`. `genSnake` is `flatMap` over `intBelow(width)`, inside it `flatMap` over `intBelow(height)`, and inside that `grow([head], maxLen - 1)`.
</details>
<details><summary>Exercise 12.6 (genSnake) — near-solution (try the others first)</summary>
```js
const directions = ['Up', 'Down', 'Left', 'Right']
const grow = (width, height, snake, left) => {
if (left <= 0) return ___
const last = snake[snake.length - 1]
const options = directions
.map((d) => move(last, d))
.filter((p) => inBounds({ width, height }, p) && !___)
return options.length === 0 ? ___ : flatMap(elementOf(options), (p) => grow(width, height, [___, p], ___))
}
export const genSnake = (width, height, maxLen) =>
flatMap(intBelow(width), (x) => flatMap(intBelow(___), (y) => grow(width, height, [point(x, y)], ___)))
```
```scala
def genSnake(width: Int, height: Int, maxLen: Int): Random[List[Point]] =
def grow(snake: List[Point], left: Int): Random[List[Point]] =
val options = List(Direction.Up, Direction.Down, Direction.Left, Direction.Right)
.map(d => Rules.move(snake.last, d))
.filter(p => p.x >= 0 && p.x < width && p.y >= 0 && p.y < height && ___)
if left <= 0 || options.isEmpty then Random.pure(___)
else elementOf(options).flatMap(p => grow(snake :+ ___, ___))
Random.intBelow(width).flatMap(x => Random.intBelow(___).flatMap(y => grow(List(Point(x, y)), ___)))
```
```fsharp
let genSnake (width: int) (height: int) (maxLen: int) : Random<Point list> =
let rec grow (snake: Point list) (left: int) : Random<Point list> =
let options =
[ Up; Down; Left; Right ]
|> List.map (fun d -> Rules.move (List.last snake) d)
|> List.filter (fun p -> p.X >= 0 && p.X < width && p.Y >= 0 && p.Y < height && ___)
if left <= 0 || List.isEmpty options then Random.pure ___
else elementOf options |> Random.bind (fun p -> grow (snake @ [ ___ ]) ___)
Random.intBelow width |> Random.bind (fun x -> Random.intBelow ___ |> Random.bind (fun y -> grow [ { X = x; Y = y } ] ___))
```
</details>
<details><summary>Exercise 12.7 (advanceSpec) — nudge</summary>
Follow the Module 3 shape of compute, decide, build. Compute the new head, whether it eats, and the whole new snake. Decide by asking `violations` about the state with that snake. Build one of the two results.
</details>
<details><summary>Exercise 12.7 (advanceSpec) — stronger hint</summary>
Not `Playing`: return the state. `head = move(snake[0], nextDir)`; `eating` = the apple is there and equals `head`; `snake' = [head, ...(eating ? snake : dropLast(snake))]`. `broken` = `violations` of the state with `snake'` contains `'snake-in-bounds'` or `'snake-no-overlap'`. If broken: the state with `dir: nextDir` and `status: 'GameOver'`. Otherwise: the state with `snake'`, `dir: nextDir`, the apple gone if eating, and the score plus one if eating.
</details>
<details><summary>Exercise 12.7 (advanceSpec) — near-solution (try the others first)</summary>
```js
export const advanceSpec = (state) => {
if (state.status !== 'Playing') return state
const head = move(state.snake[0], state.nextDir)
const eating = state.apple !== null && pointEquals(head, state.apple)
const snake = [head, ...(eating ? ___ : ___)]
const broken = violations({ ...state, snake }).some((v) => v === ___ || v === ___)
return broken
? { ...state, dir: state.nextDir, status: ___ }
: { ...state, snake, dir: state.nextDir, apple: ___, score: ___ }
}
```
```scala
def advanceSpec(state: GameState): GameState =
if state.status != Status.Playing then state
else
val head = Rules.move(state.snake.head, state.nextDir)
val eating = state.apple.contains(head)
val snake = head :: (if eating then ___ else ___)
val broken = violations(state.copy(snake = snake)).exists(v => v == ___ || v == ___)
if broken then state.copy(dir = state.nextDir, status = ___)
else state.copy(snake = snake, dir = state.nextDir, apple = ___, score = ___)
```
```fsharp
let advanceSpec (state: GameState) : GameState =
if state.Status <> Playing then state
else
let head = Rules.move (List.head state.Snake) state.NextDir
let eating = state.Apple = Some head
let snake = head :: (if eating then ___ else ___)
let broken = violations { state with Snake = snake } |> List.exists (fun v -> v = ___ || v = ___)
if broken then { state with Dir = state.NextDir; Status = ___ }
else { state with Snake = snake; Dir = state.NextDir; Apple = ___; Score = ___ }
```
</details>
## Check your understanding
1. `forAll` returns a report instead of throwing an exception. Name two things you can do with a report that you could not do if the test runner printed "FAILED" and exited.
2. A colleague's random test fails once a week on the build server and never on their laptop. Which hidden inputs would you look for? Why can that not happen to `forAll(genActions(40), prop, 300, 2024)`?
3. Write `listOf` for `IO` instead of `Random` (on paper): `replicate(io, n) : IO<A[]>`. Which lines change? What does that tell you about `flatMap` and `pure`?
4. Predict, without running anything: `minimize([1, 2, 3, 4], xs => xs.length >= 2)` and `minimize([1, 2, 3], xs => xs.length === 1 || xs.length === 3)`. Which of the two results is the smallest failing list that exists?
5. The replay law `replay(s, a ++ b) == replay(replay(s, a), b)` holds for every `update` function. Explain why, and then decide for each of these whether a *broken game* could fail it: (a) `history(s, as)` ends with `replay(s, as)`; (b) `update(s, Tick)` never changes `width` or `height`; (c) the score never goes down; (d) `update(update(s, TogglePause), TogglePause) == s` for every state `s`. Is (d) even true?
6. The wrong collision check of Puzzle 1 (Module 3), "the new head must not be on the *old* snake", keeps every invariant of this module, and Exercise 12.7 catches it by comparison with `advanceSpec`. Explain why no invariant of a *single* state can catch it. Then design the other kind of property the original version of this question suggested: a sequence of actions from a hand-built start state on a small board that must be survivable. Which of the two would still find the bug if `advanceSpec` had been written with the same mistake?
7. Random play from `newGame` almost never reaches score 3. Describe two different ways to get test games that do: one that changes the *generator*, one that changes the *start state*. Which did the tests of this module choose, and why?
8. `violations` checks eight invariants at run time. Pick one of them and sketch a data model in which a state that breaks it cannot be built at all. What does your model cost in the rest of the code? (Example to start from: a snake stored as a head plus a list of *directions* cannot be disconnected.)
9. In the test `the quick double turn bug is found and shrunk to its two key presses`, the first key is `Up` or `Down` and the second is `Left`. Why can the shrunk list not contain a `Tick`? And why is the second key never `Right`?
10. Trace the counterexample of `a Tick that forgets the queued turn is caught by queued-not-reversed and shrunk to four actions` (`Turn Up`, `Turn Right`, `Tick`, `Turn Left`) state by state: `dir`, `nextDir`, `queued` after each action. Which invariant breaks, and after which action? What would the snake have done on the next step?
11. `genState` never produces a state with something queued. Extend it on paper so that it also produces states with a queued turn that the real game could reach. Which constraint must the queued direction satisfy, and which further constraint links it to `dir`?
12. With `maxLen = 25` on a 5 x 5 board, is the snake from `genSnake` always 25 cells long? Why not? Does the answer matter for the comparison test of Exercise 12.7?
13. Why does the comparison test run on generated states rather than on the states of random games from `newGame`? Estimate roughly how often a random game of 40 actions brings the head next to its own tail with `nextDir` pointing at it.
## In production
- **QuickCheck** (Haskell, 1999) is where this style of testing comes from; its generators are a monad over a splittable random seed, as ours are over a seed. Every mainstream language now has a descendant: [fast-check](https://fast-check.dev/) for JavaScript and TypeScript, [ScalaCheck](https://scalacheck.org/) for Scala, [FsCheck](https://fscheck.github.io/FsCheck/) for F# and C#, [Hypothesis](https://hypothesis.readthedocs.io/) for Python.
- **Shrinking** in those libraries is more refined than `minimize`: they also make numbers smaller and strings shorter, and newer designs such as [Hedgehog](https://hedgehog.qa/) and Hypothesis shrink the *random choices* instead of the generated value, so that a shrunk value is always one the generator could have produced.
- **Mutation testing** tools such as [Stryker](https://stryker-mutator.io/) (JavaScript, C#, Scala) change your code automatically, one operator at a time, and report the changes that no test notices. (This course's own test suite was checked that way; the surviving mutants became new fixture cases, for example the Pong cases `ball-in-column-zero-is-still-in-play` and `player-paddle-catches-the-ball-at-its-first-row`.)
- **Deterministic simulation testing** takes "same seed, same run" to the scale of a distributed database: the whole cluster, with its network and disks, runs inside one deterministic simulation driven by a seed, so that a failure found after millions of simulated hours can be replayed exactly. FoundationDB was built this way ([Simulation and Testing](https://apple.github.io/foundationdb/testing.html)). It is the idea of Module 5, applied to a data centre.
- **Model checkers** explore *every* reachable state of a model instead of a random sample, and are the next step when "300 random games passed" is not enough ([TLA+](https://lamport.azurewebsites.net/tla/tla.html)).
## Further reading
- [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) — the original paper (ICFP 2000); sections 1–3 are readable with this course behind you, and its `Gen` is your `Random`.
- [Scott Wlaschin, "Choosing properties for property-based testing"](https://fsharpforfunandprofit.com/posts/property-based-testing-2/) — a catalogue of property shapes ("there and back again", "some things never change", "the test oracle") with pictures.
- [Andreas Zeller, "Reducing Failure-Inducing Inputs" (The Debugging Book)](https://www.debuggingbook.org/html/DeltaDebugger.html) — delta debugging, the general form of `minimize`, by its inventor.
- [fast-check documentation](https://fast-check.dev/docs/introduction/) — what a production library adds: many generators, integrated shrinking, replay of a failure from its seed.
- [Mutation testing (Wikipedia)](https://en.wikipedia.org/wiki/Mutation_testing) and [Metamorphic testing (Wikipedia)](https://en.wikipedia.org/wiki/Metamorphic_testing) — testing the tests, and testing relations between runs when no expected output is known.
- [Philip Wadler, "Theorems for free!"](https://people.mpi-sws.org/~dreyer/tor/papers/wadler.pdf) — why some properties (like the replay law) hold for every function of a given type, and so cannot fail.
- [Hillel Wayne, "Learn TLA+"](https://learntla.com/) — an introduction to checking all states of a design instead of sampling them.
+559
View File
@@ -0,0 +1,559 @@
# Module 13 — Capstone: Conway's Life, with the mouse
> **Time:** ~6–8 h (spread it over several sessions) · **You'll edit:** JS `js/src/life/model.js`, `rules.js`, `view.js`, `app.js`, and at the edge `js/src/runtime.js` and `js/src/browser.js` · Scala `scala/src/life/Model.scala`, `Rules.scala`, `View.scala`, `App.scala`, and `scala/src/Runtime.scala` · F# `fsharp/src/Life/Model.fs`, `Rules.fs`, `View.fs`, `App.fs`, and `fsharp/src/Runtime.fs`
> **Tests:** JS `cd js && node --test "test/m13-*.test.js"` · Scala `cd scala && ./test.sh M13` · F# `cd fsharp && ./test.sh M13` — and afterwards **all** tests of your track (`./test.sh`)
> **Prerequisites:** Modules 0–11 and **[the method](method.md)**. Module 12 is not needed.
## Why this module
Pong was a second game, but a close cousin of Snake: a board, a clock, keys. Everything it needed from the outside world was already wired. This capstone is different in two ways.
First, the player uses the **mouse**: clicking a cell switches it on or off. Nothing in the course so far has received a click. The runtime registers three callbacks (keys down, keys up, the clock), the platform offers three ways to listen, and the stream inputs have three cases. A fourth kind of input means changing the edge itself, the one place you have not touched since Module 9 except for the high score. You will see exactly how much that change costs, and why Snake and Pong do not notice it.
Second, the rules of the game look trivial and hide the classic bug of stateful simulations: updating the cells **one by one, in place**, so that later cells see a board that is half old and half new. In this course you cannot write that bug, and the reason why is worth understanding precisely.
The game is **Conway's Game of Life** ([Wikipedia](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life)): not really a game, but a "zero-player" world of cells on a grid that live, die and are born by three rules, invented by John Conway in 1970 ([Martin Gardner's column in Scientific American, October 1970](https://web.stanford.edu/class/sts145/Library/life.pdf)). You draw a starting pattern with the mouse, start the clock, and watch.
As in Module 11, the scaffolding is light: the rules and tests are complete, the hints ask questions and stop before a skeleton. Look at your Snake and Pong code first; you have written every pattern you need, except one.
## Learning objectives
By the end of this module you can:
- **apply** the conversion method to a simulation whose state is a whole board;
- **explain** why the next generation must be computed from the whole old board, **demonstrate** the bug of the in-place loop on a blinker, and **explain** why immutable data rules it out;
- **choose** a canonical representation for a set of cells, and **justify** it with the needs of tests in three languages;
- **implement** a neighbour count that is fast enough without mutation;
- **add** a new kind of input to the edge (a stream source, a runtime registration, a platform method) without changing any existing game;
- **reuse** `Random`, `IO`, streams, `clampTime` and the runtime for a third program.
## The game, as you'd find it
Here is a typical stateful implementation sketch. Read it the way you read the Pong sketch: count the hidden things.
```js
// The usual, stateful way (a sketch to convert; do NOT write this in the course)
const W = 40, H = 30, SIZE = 16
let grid = Array.from({ length: H }, () => Array(W).fill(false))
let running = false, generation = 0
canvas.addEventListener('click', (e) => {
const x = Math.floor(e.offsetX / SIZE), y = Math.floor(e.offsetY / SIZE)
grid[y][x] = !grid[y][x]
draw()
})
window.addEventListener('keydown', (e) => {
if (e.key === ' ') running = !running
if (e.key === 'n' && !running) { step(); draw() }
if (e.key === 'c') { grid = grid.map((row) => row.map(() => false)); generation = 0; draw() }
if (e.key === 'r') { grid = grid.map((row) => row.map(() => Math.random() < 0.25)); generation = 0; draw() }
})
setInterval(() => { if (running) { step(); draw() } }, 150)
function step() {
const next = grid.map((row) => row.slice()) // forget this line and see the next section
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const n = countNeighbours(grid, x, y)
next[y][x] = n === 3 || (n === 2 && grid[y][x])
}
grid = next
generation++
}
```
Hidden state: `grid`, `running`, `generation`, and the timer's phase. Hidden randomness: `Math.random`. Hidden effects: `draw()` in four places. And one thing that is new: an event (`click`) that arrives with a *position*, in the coordinates of the page, which the code turns into a cell with `offsetX` (and which breaks as soon as the canvas is scaled by CSS or has a border; we will come back to that at the edge).
## The rules, precisely
The tests are shared fixtures, so the rules must be exact.
**The board** is `LW = 40` cells wide and `LH = 30` cells high; each cell is `LCELL = 16` pixels. Under the board the picture has a status strip `STRIP = 24` pixels high, so the canvas is 640 × 504 pixels. A cell is a point `{x, y}` with `0 <= x < 40` and `0 <= y < 30`.
**The state** is `{cells, running, seed, generation}`: the live cells, whether the clock makes generations, the seed for `Randomize`, and how many generations have been computed since the last `Clear` or `Randomize`. `cells` is **canonical**: sorted row-major (by `y`, then by `x`), each cell at most once, all on the board. A new world (`initialLife(seed)`) has no live cells, is paused, and is at generation 0.
**One generation** (`lifeStep(cells)`), the classic rules, called B3/S23:
- a **live** cell with **2 or 3** live neighbours stays alive;
- a **dead** cell with **exactly 3** live neighbours is born;
- every other cell is dead in the next generation.
The neighbours of a cell are the up to eight cells around it **that are on the board**. The edges are dead: nothing wraps around, so a cell in column 39 is not a neighbour of a cell in column 0. Every cell is judged by the **old** board only. The result is canonical again.
**Actions:**
- `Frame` (every `GEN_MS = 150` ms): when running, the next generation (`lifeStep`) and `generation + 1`; when paused, nothing.
- `Step` (key `n`/`N`): when paused, the next generation and `generation + 1`; when running, nothing.
- `TogglePause` (Space): `running` flips.
- `Toggle(cell)` (a click on that cell): a dead cell becomes alive and a live one dead, **whether the world is running or paused**. `cells` stays canonical; `generation` does not change. A cell off the board changes nothing.
- `Clear` (key `c`/`C`): no live cells, generation 0; `running` and `seed` stay.
- `Randomize` (key `r`/`R`): for every cell of the board in row-major order (row 0 from x = 0 to 39, then row 1, …), draw `intBelow(4)` from Module 4, threading the seed from `state.seed`; a cell is alive when its draw is 0 (so about one cell in four lives). The state gets those cells, the seed after the 1200th draw, and generation 0; `running` stays.
A generation on an empty running board still counts (`frame-on-an-empty-running-board-still-counts-the-generation`): an empty world ages like any other.
**Keys:** Space → `TogglePause`, `n`/`N` → `Step`, `c`/`C` → `Clear`, `r`/`R` → `Randomize`; everything else is ignored. **Clicks:** a click at canvas pixel `(x, y)` is `Toggle` of the cell `(floor(x / 16), floor(y / 16))` when that pixel is on the board; a click left of, above, right of or below the board (the status strip) is ignored.
**The picture** (`lifeView`), drawn in order, all text `'#ffffff'`, size 14:
- the board `Rect(0, 0, 640, 480, '#111111')`;
- one `Rect(x*16 + 1, y*16 + 1, 14, 14, '#7cb342')` per live cell, in the order of `cells`;
- the status strip `Rect(0, 480, 640, 24, '#222222')`;
- `Text(6, 497, 'Generation <n>', …, 'left')`;
- `Text(634, 497, 'Running: Space pauses' when running, else 'Paused: Space runs, N steps', …, 'right')`.
## Why the whole old board: the in-place loop
Look again at the sketch's `step`. Its first line copies the grid into `next`, the loop reads only `grid` and writes only `next`, and the last line swaps them. Here is the version that people write first, because it looks like the same thing with one variable fewer:
```js
// The classic bug: one grid, updated in place
function step() {
for (let y = 0; y < H; y++)
for (let x = 0; x < W; x++) {
const n = countNeighbours(grid, x, y)
grid[y][x] = n === 3 || (n === 2 && grid[y][x])
}
}
```
**Before reading on, run it by hand** on a horizontal blinker: live cells `(4,5)`, `(5,5)`, `(6,5)`. The right answer is the vertical blinker `(5,4)`, `(5,5)`, `(5,6)` (fixture `a-horizontal-blinker-turns-vertical`). What does the loop produce?
<details><summary>Think about it first, then open the trace</summary>
The loop visits row 4 before row 5, and within a row, left to right.
```
cell live neighbours it counts decision correct?
(5,4) (4,5) (5,5) (6,5) = 3 born yes
(6,4) (5,4)* (5,5) (6,5) = 3 born no: (5,4) was dead in the old board
(4,5) (5,4)* (5,5) = 2 stays alive no: it had 1 neighbour
(5,5) (5,4)* (6,4)* (4,5) (6,5) = 4 dies no: it had 2 and should stay
(6,5) (5,4)* (6,4)* = 2 stays alive no: (5,5) is already dead, and it had 1
(5,6) (4,5) (6,5) = 2 stays dead no: it had 3 and should be born
(* = a value written during this very loop)
```
The result is `(5,4)`, `(6,4)`, `(4,5)`, `(6,5)`: four cells, lopsided, and not a blinker at all. Each cell was judged by a board in which the cells above it and to its left were *already* the next generation and the others were still the old one. The answer even depends on the order of the loops: scan the columns from right to left and you get a different wrong picture.
</details>
The rules say "every cell is judged by the old board". The in-place loop breaks that sentence, and nothing warns you: it runs, it draws something plausible, and still-lifes like the block even come out right, so a quick test passes.
The stateful fix is discipline: keep two grids, read one, write the other, swap. In the pure version there is nothing to remember, because **you cannot write the in-place loop at all**. `cells` is an immutable value; `lifeStep(cells)` can only *read* it and *return* a new list. There is no "half-updated board" anywhere, because the old board never changes while the new one is built. What the stateful programmer must remember to do (copy first, write to the copy) is the only thing the pure version *can* do.
> **From everyday programming:** this is the reason spreadsheets do not recalculate cells in the order you typed them, and why a database transaction reads a consistent snapshot ("snapshot isolation") instead of rows that other writers are changing underneath it. Image filters have the same trap: a blur that writes each blurred pixel back into the image it is still reading smears in one direction. The fix is always the same: read from a version that does not change while you write the next one.
## Applying the method
Open [method.md](method.md) next to this page. Each step names the step of the method, asks you questions to answer **before** reading the course's choice, and then gives the canonical choice (fixed, because the fixtures depend on it). Write your own answer first, then compare.
### Step 1 — Identify the state; make it one value
List every `let` of the sketch. Then ask:
- The sketch stores a 40 × 30 grid of booleans. What else could store "which cells are alive"? Think of at least two other representations and what each makes easy or hard (counting neighbours, toggling one cell, comparing two boards in a test, writing a fixture).
- If two states describe the same live cells, should they be *equal* in a test? What does that demand of your representation?
- Is `generation` state, or could it be computed?
- Where must the randomness live so that `Randomize` is a pure function?
**Canonical choice:** the live cells as a **list of points, sorted row-major, without duplicates**.
**JavaScript** (`js/src/life/model.js`)
```js
// LifeState = { cells: Point[] (canonical), running: boolean, seed: number, generation: number }
// constants: LW = 40, LH = 30, LCELL = 16, STRIP = 24 (given)
```
**Scala** (`scala/src/life/Model.scala`, given; package `snake.life`)
```scala
final case class LifeState(cells: List[Point], running: Boolean, seed: Long, generation: Int)
object LifeModel:
val LW = 40
val LH = 30
val LCELL = 16
val STRIP = 24
def initialLife(seed: Long): LifeState = ???
```
**F#** (`fsharp/src/Life/Model.fs`, module `Snake.Life.Model`, the record given)
```fsharp
type LifeState =
{ Cells: Point list
Running: bool
Seed: uint32
Generation: int }
// LW = 40, LH = 30, LCELL = 16, STRIP = 24 (given)
let initialLife (seed: uint32) : LifeState = failwith "TODO m13"
```
Why a sorted list and not a grid or a set? A grid of 1200 booleans makes every fixture 1200 values long, and a glider would be hard to see in it. A set says exactly what we mean ("these cells are alive"), but sets are printed and compared differently in the three languages, and the order in which you walk through one is not the same in JavaScript, Scala and F#. A list in one agreed order has neither problem: two states with the same live cells are *equal* (`deepStrictEqual`, `==`, `=`) without any special comparison, a fixture is a short readable list, and the three tracks agree on every byte. The price is a rule every function must keep: **whatever goes in canonical comes out canonical.** That rule is a small version of something databases do all the time (a primary key that keeps rows in order), and it is tested: every fixture compares lists, so a result in the wrong order fails.
Nothing stops `lifeStep` from building a set *inside* itself for fast lookups (step 3). Canonical is a promise about the values that cross function boundaries and land in fixtures, not about how a function works inside.
### Step 2 — Identify the events; make them data
What can happen to the world? List the sketch's callbacks. Then decide: should a click be an action `Click(x, y)` in pixels, or something else? Where should "which cell is under the mouse" be worked out?
**Canonical choice:** six actions. A click becomes `Toggle(cell)`, a *meaning*, as keys became `Turn(dir)` in Snake.
**JavaScript**
```js
// LifeAction = {tag:'Frame'} | {tag:'Toggle', cell} | {tag:'TogglePause'} | {tag:'Step'} | {tag:'Clear'} | {tag:'Randomize'}
// constructors: LifeAction.Toggle(cell), and the plain values LifeAction.Frame, .TogglePause, .Step, .Clear, .Randomize
```
**Scala** (given)
```scala
enum LifeAction:
case Frame
case Toggle(cell: Point)
case TogglePause, Step, Clear, Randomize
```
**F#** (given)
```fsharp
[<RequireQualifiedAccess>]
type LifeAction =
| Frame
| Toggle of Point
| TogglePause
| Step
| Clear
| Randomize
```
(`[<RequireQualifiedAccess>]` for the reason Module 11 gives: Snake's `Action` also has `Frame` and `TogglePause`.)
The translation from pixels to a cell is a pure function, `lifeClick(x, y)`, next to `lifeKey(key)`. The update never sees a pixel; the input stream never sees a cell. Each side speaks its own language, and one small, fixture-tested function translates.
Question: why is `Toggle` better than separate `Birth(cell)` and `Kill(cell)` actions for a click? When would the second pair be the better design?
### Step 3 — Write `update` as a fold
`lifeUpdate(state, action)` is your Life `update`, and `lifeStep(cells)` is the heart of it. Questions to answer before writing code:
- How will you count the live neighbours of a cell? How many operations does your way take per generation, for an empty board and for a full one? Is that fast enough to stay well under 150 ms?
- How do you produce the next generation **in canonical order** without sorting? (Hint: which list of cells is already in row-major order, before you know anything about the board?)
- `Toggle` must insert a cell at its row-major place, or remove it. How do you do that with `filter` alone?
- `Randomize` needs 1200 draws in a fixed order, each with the seed the previous one left behind. Which tool from Module 4 (or from Module 12, if you did it) threads a seed through a list of draws?
- Should clicking toggle cells only while paused, or always?
**The canonical choices:**
- **Counting.** Two ways are fast enough, and both are pure. The straightforward one: for each of the 1200 board cells, count the live cells in the list that are one step away in both directions. That is 1200 × (number of live cells) comparisons, at most about 1.4 million when the board is full, which every language in the course does in a few milliseconds. The faster one: first build a **lookup set** of the old live cells (a `Set` of numbers `y * LW + x` in JS, because a JS `Set` of objects compares them by identity; a `Set[Point]` in Scala; a `Set<Point>` in F#), then ask it about each of the eight neighbours: 1200 × 8 lookups, however many cells live. The reference solution does the second. The set is built once per generation from the old list and only read afterwards, so it is as immutable as everything else.
- **Order for free.** Make a constant list of all 1200 board cells, row-major, once. The next generation is that list *filtered* by the rules. A filter keeps order, so the result is canonical without a sort.
- **Toggle.** If the cell is in the list, filter it out. Otherwise the new list is "the cells before it", the cell, "the cells after it", where before and after compare `y * LW + x`. Off the board, nothing changes.
- **Randomize.** A fold over the 1200 board cells that carries the seed and collects the draws (or `listOf(intBelow(4), 1200)` if you built it in Module 12), then the board cells whose draw is 0. The order of the draws is part of the contract: it is the only way three languages can produce the same random board from the same seed, and the test `Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0` checks it against Module 4's generator.
- **Clicks while running.** The course's choice is: **always**. A player who clicks a running world and sees nothing happen thinks the program is broken; drawing into a running world is part of the fun of Life; and it is perfectly well defined here, because a click is one action *between* two generations, never in the middle of one (the fold applies one action at a time). In the stateful sketch the same question has a subtler answer, since the click handler and the timer callback both write to the same `grid`. The other policy, "only while paused", is a one-line guard in `update`; question 3 asks you to compare.
### Step 4 — Write the view as data
`lifeView(state)` returns a `Scene`, exactly as specified in [the rules](#the-rules-precisely). Because `cells` is canonical, the order of the cell rectangles is fixed too, and the view fixture can compare the scene as a list.
### Step 5 — Effects as values
Which effects does Life need? Drawing, and a seed at the start, which `randomSeed` already describes; `Randomize` itself is pure. So: **no new `Op`**.
But what about the click? Ask yourself which direction it travels. An `Op` is something the program asks the world to do, or to tell it *when asked* (a seed, the stored best score). A click is the world telling the program something *whenever it likes*, like a key press or the clock. That is not an effect the program performs; it is an **input** the program receives. Inputs do not go into `IO`. They go into the stream, and they enter through the runtime. That is steps 6 and 7.
### Step 6 — Wire the streams, with a fourth kind of input
The stream inputs get a fourth case, and the stream module a fourth source:
**JavaScript**
```js
// input: { tag: 'Click', x, y } x, y: canvas pixels, whole numbers
// source: clicks emits {x, y} for every Click input and ignores everything else
```
**Scala** (`scala/src/Stream.scala`, given)
```scala
enum Event:
case Time(t: Long)
case KeyDown(key: String)
case KeyUp(key: String)
case Click(x: Int, y: Int)
// object Stream:
// val clicks: Stream[(Int, Int)]
```
**F#** (`fsharp/src/Stream.fs`, given)
```fsharp
type Event =
| Time of int64
| KeyDown of string
| KeyUp of string
| Click of int * int
// let clicks : Stream<int * int>
```
`clicks` is **already in your stream file**: it was given there from the start, as one more source next to the two you wrote in Module 8, and it is built exactly like `keyDowns`. Read it. Then answer: why do `keyDowns`, `keyUps`, `interval` and `clampTime` not need a single change to cope with a new kind of input? (Each source looks at the tag of the input and ignores what it does not know; `clampTime` passes everything that is not `Time` straight through. That is why Snake and Pong cannot even tell that clicks exist: test `Snake and Pong ignore clicks: their actions and drawings are the same with and without them`.)
Then wire Life like Snake and Pong:
- `lifeActions`: `Frame` every `GEN_MS = 150` ms (from `interval`), merged with `lifeKey` of every key press, merged with `lifeClick` of every click (from `clicks`), in that order;
- `lifeStates(seed, actions)`: start from `initialLife(seed)`, fold with `lifeUpdate`, emit the initial state first;
- `lifeMain`: ask for a seed, turn the stream of states into a stream of drawings of `lifeView`, and wrap it in `clampTime(…, MAX_GAP_MS)`.
### Step 7 — The edge: this time you change it
In Snake and Pong this step said "nothing to write". Now the edge must learn about the mouse, and it is the first time since Module 9 that you change its *shape* (the high score only added two operations). Two changes, both small:
1. **The runtime** registers a fourth callback. In `runApp`, next to the key and clock registrations:
```js
platform.onClick((x, y) => feed({ tag: 'Click', x, y }))
```
In Scala `p.onClick((x, y) => feed(Event.Click(x, y)))`, in F# `p.OnClick(fun x y -> feed (Click(x, y)))`. The runtime still knows nothing about any game: it turns a click into an input and feeds it, as it does with keys.
2. **The platform** gets a method `onClick(cb)`, which calls `cb(x, y)` with canvas pixels.
- **JavaScript:** `browserPlatform` is yours, from Module 9. Add `onClick` to it: listen for `'click'` on the **canvas** (not on the window, or a click anywhere on the page would toggle a cell), and convert the event's position with `canvasPoint(canvas, e.clientX, e.clientY)`, a helper that is given at the bottom of `js/src/browser.js`. Read it: the page may show the canvas larger or smaller than its `width` and `height` (CSS, zoom) and draws a 2-pixel border round it, so the window coordinates of the mouse must be made relative to the drawing area and then scaled. That is the bug the sketch's `offsetX` had. Add the listener inside `onClick`, not when the platform is built.
- **Scala and F#:** the `Platform` type in your runtime file already declares `onClick` (Scala `def onClick(cb: (Int, Int) => Unit): Unit`) / `OnClick: (int -> int -> unit) -> unit` (F#), given and unused until now, and the browser glue in `scala/web/` and `fsharp/web/` (given) implements it. Your only change at the edge is the registration in `runApp`.
Why do the Snake and Pong pages keep working? Their platform now has one more method, the runtime calls it once at start, and clicks flow into streams that ignore them. A runtime from Module 9 that does not register clicks also keeps working for Snake and Pong; it just never feeds a click, so Life does not react to the mouse until you make this change. The tests for this step say exactly that: `runApp registers a click handler with the platform`, `runApp feeds clicks to the stream`, and (JS) `browserPlatform listens for clicks on the canvas, not before onClick is called` and `browserPlatform converts a click to canvas pixels, through the border and the CSS scaling`.
Compare this with what a click costs in the stateful sketch: there, the click handler is also where the game logic lives (`grid[y][x] = !grid[y][x]; draw()`). Here, the edge gained four lines that know nothing about Life, and Life gained one pure function, `lifeClick`, tested without a browser.
The entry file is given: `js/src/life/main.js` is `runApp(lifeMain, browserPlatform(document.getElementById('game'), window))`, and the page is `js/life.html`. Scala and F# have the same entry points in their web glue, built by `build-web.sh`, with the pages `scala/life.html` and `fsharp/life.html`.
## Exercises
> **About test names.** The test titles quoted under "Proved by" are the JavaScript ones. The Scala and F# suites test the same things one-to-one, in each track's vocabulary ("events" instead of "inputs", `None` instead of `null`). To read all titles, open `scala/test/M13Life.test.scala` or `fsharp/tests/M13Life.fs`. Fixture-based tests have the same case names in every track.
### Exercise 13.1 — model
**JavaScript** (`js/src/life/model.js`; the constants are given)
```js
export const initialLife = (seed) => /* LifeState */
export const LifeAction = { Toggle: (cell) => ..., Frame: ..., TogglePause: ..., Step: ..., Clear: ..., Randomize: ... }
```
**Scala** (`scala/src/life/Model.scala`)
```scala
object LifeModel:
def initialLife(seed: Long): LifeState = ???
```
**F#** (`fsharp/src/Life/Model.fs`)
```fsharp
let initialLife (seed: uint32) : LifeState = failwith "TODO m13"
```
**Proved by:** `the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below`, `initialLife is an empty, paused world at generation 0 with the given seed`, `LifeAction builds Toggle with a cell, and has the other actions as plain values` (JS only).
### Exercise 13.2 — rules
**JavaScript** (`js/src/life/rules.js`)
```js
export const lifeStep = (cells) => /* Point[], canonical */
export const lifeUpdate = (state, action) => /* LifeState */
```
**Scala** (`scala/src/life/Rules.scala`)
```scala
object LifeRules:
def lifeStep(cells: List[Point]): List[Point] = ???
def lifeUpdate(state: LifeState, action: LifeAction): LifeState = ???
```
**F#** (`fsharp/src/Life/Rules.fs`)
```fsharp
let lifeStep (cells: Point list) : Point list = failwith "TODO m13"
let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState = failwith "TODO m13"
```
**Proved by:** `fixtures/m13-life-step.json` (`an-empty-board-stays-empty`, `a-lonely-cell-dies`, `two-cells-die-of-loneliness`, `a-block-is-stable`, `a-horizontal-blinker-turns-vertical`, `a-vertical-blinker-turns-horizontal`, `three-cells-in-an-l-give-birth-to-a-block`, `a-cell-with-four-neighbours-dies-of-overcrowding`, `a-glider-moves-and-the-result-is-sorted-row-major`, `a-block-in-the-corner-is-stable`, `a-block-in-the-far-corner-is-stable`, `a-blinker-on-the-top-edge-cannot-grow-beyond-it`, `a-blinker-on-the-right-edge-does-not-wrap-to-the-left-edge`, `cells-on-opposite-edges-are-not-neighbours`, `a-blinker-on-the-bottom-edge-cannot-grow-beyond-it`); `fixtures/m13-life-update.json` (20 cases: `frame-…`, `step-…`, `toggle-pause-…`, the `toggle-…` cases including `toggle-adds-a-cell-between-two-on-the-same-row`, `toggle-works-while-running-too` and `toggle-off-the-board-is-ignored`, `clear-empties-the-board-and-restarts-the-count`, `randomize-fills-the-board-from-the-seed-and-advances-it`, `randomize-replaces-the-cells-restarts-the-count-and-keeps-running`); and the tests `lifeUpdate does not modify the state it is given`, `a glider moves one cell diagonally every four generations`, `the next generation is computed from the whole old board: a blinker blinks forever`, `Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0`, `every generation of a random board stays sorted row-major, on the board and without duplicates`, `lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms`.
**Strategy:** `lifeStep` first, case by case: the empty board, then the lonely cell, then the block, then the blinkers (if the blinker fails while the block passes, read [the in-place section](#why-the-whole-old-board-the-in-place-loop) again), then the edges. Then `lifeUpdate`, one action at a time.
**Watch out for:** a neighbour list that includes the cell itself (then a live cell counts itself, and the block "dies of overcrowding"); off-board neighbours that wrap round (`cells-on-opposite-edges-are-not-neighbours`); a result in the order the cells were *found* instead of row-major; and, in Scala and F#, a seed that changes type on the way through the 1200 draws.
### Exercise 13.3 — view
**JavaScript** (`js/src/life/view.js`)
```js
export const lifeView = (state) => /* Scene */
```
**Scala** (`scala/src/life/View.scala`)
```scala
object LifeView:
def lifeView(state: LifeState): Scene = ???
```
**F#** (`fsharp/src/Life/View.fs`)
```fsharp
let lifeView (state: LifeState) : Scene = failwith "TODO m13"
```
**Proved by:** `fixtures/m13-life-view.json` (`a-new-world-is-an-empty-paused-board`, `live-cells-in-row-major-order-while-running`, `paused-after-some-generations`).
### Exercise 13.4 — keys, clicks and wiring
**JavaScript** (`js/src/life/app.js`)
```js
export const GEN_MS = 150
export const lifeKey = (key) => /* LifeAction or null */
export const lifeClick = (x, y) => /* LifeAction (a Toggle) or null */
export const lifeActions = /* Stream<LifeAction> */
export const lifeStates = (seed, actions) => /* Stream<LifeState> */
export const lifeMain = /* IO<Stream<IO<undefined>>> */
```
**Scala** (`scala/src/life/App.scala`)
```scala
object LifeApp:
val GEN_MS: Long = 150
def lifeKey(key: String): Option[LifeAction] = ???
def lifeClick(x: Int, y: Int): Option[LifeAction] = ???
lazy val lifeActions: Stream[LifeAction] = ???
def lifeStates(seed: Long, actions: Stream[LifeAction]): Stream[LifeState] = ???
lazy val lifeMain: IO[Stream[IO[Unit]]] = ???
```
**F#** (`fsharp/src/Life/App.fs`)
```fsharp
let GEN_MS = 150L
let lifeKey (key: string) : LifeAction option = failwith "TODO m13"
let lifeClick (x: int) (y: int) : LifeAction option = failwith "TODO m13"
let lifeActions : Stream<LifeAction> = (* ... *)
let lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState> = failwith "TODO m13"
let lifeMain () : IO<Stream<IO<unit>>> = failwith "TODO m13"
```
**Proved by:** `fixtures/m13-life-key.json` (12 cases, with the `constructor`/`toString` traps); `fixtures/m13-life-click.json` (`the-top-left-pixel-is-cell-0-0`, `the-last-pixel-of-the-first-cell`, `the-next-pixel-is-the-next-cell`, `the-row-below`, `a-pixel-in-the-middle`, `the-bottom-right-pixel-of-the-board`, `right-of-the-board-is-nothing`, `the-status-strip-under-the-board-is-nothing`, `left-of-the-board-is-nothing`, `above-the-board-is-nothing`); `clicks emits the position of every Click input and ignores everything else`, `the other sources ignore clicks`, `a generation every 150 ms`, `lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles`, `lifeStates starts with the new world, then yields one state per action`, `lifeMain asks for a seed, draws the empty world first, and then a click as a live cell`, `lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds`, `Snake and Pong ignore clicks: their actions and drawings are the same with and without them`.
**Watch out for:** in Scala and F#, integer division rounds *towards zero*, so `-1 / 16` is `0`, not `-1`: a click one pixel left of the board would toggle cell 0. Check that the pixel is on the board before you divide (`left-of-the-board-is-nothing`).
### Exercise 13.5 — the click at the edge
Make the two changes of step 7: the fourth registration in `runApp` (all tracks) and, in JS, `onClick` in `browserPlatform`.
**JavaScript** (`js/src/runtime.js`, `js/src/browser.js`)
```js
// runtime.js, in runApp: platform.onClick((x, y) => feed({ tag: 'Click', x, y }))
// browser.js, in the platform object:
// onClick: (cb) => canvas.addEventListener('click', (e) => { /* canvasPoint(canvas, e.clientX, e.clientY), then cb(x, y) */ })
```
**Scala** (`scala/src/Runtime.scala`)
```scala
// trait Platform (given): def onClick(cb: (Int, Int) => Unit): Unit
// in runApp: p.onClick((x, y) => feed(Event.Click(x, y)))
```
**F#** (`fsharp/src/Runtime.fs`)
```fsharp
// type Platform (given): OnClick: (int -> int -> unit) -> unit
// in runApp: p.OnClick(fun x y -> feed (Click(x, y)))
```
**Proved by:** `runApp registers a click handler with the platform`, `runApp feeds clicks to the stream`, `runApp runs Life: a click on the canvas draws the world with that cell alive`; JS only: `browserPlatform listens for clicks on the canvas, not before onClick is called`, `browserPlatform converts a click to canvas pixels, through the border and the CSS scaling`. All tests of Modules 9–12 must still pass.
**Run and play:**
- JS: `cd js && node --test "test/m13-*.test.js"`, then `./test.sh`; from the repository root `node scripts/serve.mjs`, and open <http://localhost:8000/js/life.html>
- Scala: `cd scala && ./test.sh M13`, then `./test.sh`; `./build-web.sh`, serve from the repository root, open <http://localhost:8000/scala/life.html>
- F#: `cd fsharp && ./test.sh M13`, then `./test.sh`; `./build-web.sh`, serve from the repository root, open <http://localhost:8000/fsharp/life.html>
Draw a glider near the top left (three cells in a row, one cell above the right one, and one cell two rows up above the middle one), press Space and watch it walk down to the bottom edge, where it turns into a block. Press R, then Space, and watch a random world settle down. Then open Snake and Pong again and check that they still play.
## Hints
As in Module 11, the hints are deliberately lighter: a nudge and a stronger hint, no skeleton. Look at your Snake and Pong code first.
<details><summary>Exercise 13.2 — nudge</summary>
Build `lifeStep` from three small functions: `onBoard(p)`, `neighbours(p)` (the eight offsets, moved to `p`, filtered by `onBoard`) and `count(p)` (how many neighbours are alive in the *old* list). Then filter a list of all board cells. For `lifeUpdate`, a flat `switch` on the action, one small function per action, as in `pongUpdate`.
</details>
<details><summary>Exercise 13.2 — stronger hint</summary>
- All board cells, row-major: `Array.from({length: LW * LH}, (_, i) => ({x: i % LW, y: Math.floor(i / LW)}))` (Scala: `for y <- 0 until LH; x <- 0 until LW yield Point(x, y)`, F#: `[ for y in 0 .. LH - 1 do for x in 0 .. LW - 1 -> { X = x; Y = y } ]`). Filter it with "`n === 3 || (n === 2 && alive(p))`".
- `alive(p)`: either `cells.some(...)` (fine), or a set built once at the start of `lifeStep` from the old cells (faster). In JS, put numbers `p.y * LW + p.x` in the set, not points.
- `Toggle`: compare cells by that same number `p.y * LW + p.x`: `[...before, cell, ...after]`.
- `Randomize`: a `reduce` over the board cells whose accumulator is `[draws, seed]`; each step runs `intBelow(4)` on the seed it carries. Then keep the board cells whose draw is 0.
</details>
<details><summary>Exercise 13.4 — nudge</summary>
`lifeKey` is `keyToAction` with other keys. `lifeClick`: two divisions and a check. `lifeActions` is `pongActions` with `clicks` in the place of `keyUps`; `lifeMain` is `pongMain` with other names.
</details>
<details><summary>Exercise 13.4 — stronger hint</summary>
`lifeClick(x, y)`: return `null` unless `0 <= x < LW * LCELL` and `0 <= y < LH * LCELL`; otherwise `Toggle({x: floor(x / LCELL), y: floor(y / LCELL)})`. The clicks source emits `{x, y}`, so the third branch of the merge is `filterMap(clicks, (p) => lifeClick(p.x, p.y))`.
</details>
<details><summary>Exercise 13.5 — nudge</summary>
The runtime change is one line, a copy of the `onKeyDown` registration. In `browserPlatform`, the listener goes on `canvas`, and the handler does two things: convert, then call `cb`.
</details>
<details><summary>Exercise 13.5 — stronger hint</summary>
`onClick: (cb) => canvas.addEventListener('click', (e) => { const p = canvasPoint(canvas, e.clientX, e.clientY); cb(p.x, p.y) })`. If the test "not before onClick is called" fails, you added the listener while building the platform object instead of inside `onClick`.
</details>
## Check your understanding
1. Apply steps 0–2 of the method to the stateful sketch: its hidden state (including time and randomness), its events and its effects.
2. Run the in-place loop by hand on a 2 × 2 block. Why does it give the right answer there, and what does that tell you about testing a simulation with still-lifes only?
3. The course lets clicks toggle cells while the world runs. Write the one-line change for the other policy (only while paused). Which fixture would fail, and which new one would you write? Which policy do you prefer, and why?
4. `cells` is a sorted list, not a set. Name two things that would get harder in the tests if each track used its own language's set type in the state, and one thing that would get easier in the code.
5. Your `lifeStep` builds a set inside itself. Is `lifeStep` still pure? Is the set "state"? Argue from what a caller can observe.
6. Estimate the work per generation of the "scan the list for each board cell" method on a quarter-full board, and compare it with the lookup set. At which board size would the first stop being fast enough at 150 ms?
7. Why is a click an *input* (an event in the stream) and not an `Op`? Give an example of a mouse feature that *would* need a new `Op`.
8. List every file the click changed, in your track. Which of them are pure? Why did no Snake or Pong file change, and which test proves that their behaviour did not change either?
9. A runtime from Module 9 never registers `onClick`. Why do the Module 9–12 tests still pass with it, and which Module 13 tests fail?
10. `canvasPoint` subtracts the border and scales by `width / clientWidth`. What goes wrong with `e.offsetX` alone when the page shows the canvas at twice its size? Which test would catch it?
11. `Randomize` fixes the order of 1200 draws. What would go wrong between the three tracks if each one drew in the order its own data structure happened to iterate?
12. Design wrap-around edges (a torus) as an option: which function changes, which fixtures would you add, and does anything outside `lifeStep` need to know?
## In production
- **Double buffering** is the stateful world's fix for the in-place bug: two buffers, read one, write the other, swap. Graphics cards do the same with the picture itself so that the screen never shows a half-drawn frame ([Game Programming Patterns: Double Buffer](https://gameprogrammingpatterns.com/double-buffer.html)). Immutable data gives you the same guarantee without the swap.
- **Hashlife** computes Life on enormous patterns by caching the next generation of every square block it has seen, which only works because the step is a pure function of the old board: the same block always has the same future ([Hashlife on Wikipedia](https://en.wikipedia.org/wiki/Hashlife); the free Life program Golly uses it).
- **Hit testing.** Every UI toolkit has one place that turns a raw pointer position into "what was hit", and the rest of the program only sees the answer: a button, a list row, a cell. `canvasPoint` plus `lifeClick` is that split in miniature: the edge converts window coordinates to canvas pixels, a pure function converts pixels to a cell ([MDN: `MouseEvent.clientX`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/clientX)).
## Further reading
- [The method](method.md) — the recipe, now applied three times.
- [Martin Gardner, "The fantastic combinations of John Conway's new solitaire game 'life'"](https://web.stanford.edu/class/sts145/Library/life.pdf) — the 1970 column that introduced Life.
- [Glider (Wikipedia)](https://en.wikipedia.org/wiki/Glider_%28Conway%27s_Game_of_Life%29) — the pattern you drew, why it moves, and why it matters to the theory of Life.
- [Robert Nystrom, Game Programming Patterns: Double Buffer](https://gameprogrammingpatterns.com/double-buffer.html) — the in-place bug from the stateful side, and the two-buffer discipline that avoids it.
- [MDN: `Element.getBoundingClientRect()`](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) — what `canvasPoint` measures, and why CSS size and canvas size differ.
+7 -6
View File
@@ -1,6 +1,6 @@
# The method: from a stateful game to a functional one # The method: from a stateful game to a functional one
> **When to read this:** after Module 9 (you need `IO`, streams and the runtime), and before Module 11, which asks you to apply it to Pong on your own. Come back to it whenever you convert something. > **When to read this:** after Module 9 (you need `IO`, streams and the runtime), and before Module 11, which asks you to apply it to Pong on your own (Module 13 applies it once more, to Conway's Life). Come back to it whenever you convert something.
> **What it is:** the course, compressed into a recipe you can carry to other programs — with a small worked example, a checklist and a list of the mistakes people make most often. > **What it is:** the course, compressed into a recipe you can carry to other programs — with a small worked example, a checklist and a list of the mistakes people make most often.
## The recipe in one page ## The recipe in one page
@@ -338,12 +338,16 @@ Use this when you convert anything. Each line is a yes/no question about your re
**Mutation in loops.** `for (…) result.push(…)`, `board[y][x] = …`, `list.sort()` (which sorts in place). It's tempting when the rule is naturally "go through all the bricks and remove the hit ones". *Fix:* `map`/`filter`/`reduce`, building new arrays; `[...xs].sort()` or a non-mutating `toSorted()` if you must sort; for grids, a list of rows rebuilt with `map`. **Mutation in loops.** `for (…) result.push(…)`, `board[y][x] = …`, `list.sort()` (which sorts in place). It's tempting when the rule is naturally "go through all the bricks and remove the hit ones". *Fix:* `map`/`filter`/`reduce`, building new arrays; `[...xs].sort()` or a non-mutating `toSorted()` if you must sort; for grids, a list of rows rebuilt with `map`.
**Effects in update.** A `console.log` for debugging that stays, a `localStorage.setItem` "just when the game ends", a sound played on eating. Each one makes `update` untestable and order-dependent. *Fix:* return data. For a sound or a save, add an `Op` and produce the `IO` from the state stream (see Module 10's high-score thought exercise). For debugging, use `history` and `trace` instead of logging. **Effects in update.** A `console.log` for debugging that stays, a `localStorage.setItem` "just when the game ends", a sound played on eating. Each one makes `update` untestable and order-dependent. *Fix:* return data. For a sound or a save, add an `Op` and produce the `IO` from the state stream (Module 10 builds one: the persisted high score of section 8, Exercise 10.7). For debugging, use `history` and `trace` instead of logging.
**State in the runtime.** "I'll just keep `upHeld` in the runtime and pass it in." Then the runtime knows about the game and half the behaviour can't be replayed. *Fix:* the runtime holds only the current stream; held keys are state derived from `Press`/`Release` actions (Module 11). **State in the runtime.** "I'll just keep `upHeld` in the runtime and pass it in." Then the runtime knows about the game and half the behaviour can't be replayed. *Fix:* the runtime holds only the current stream; held keys are state derived from `Press`/`Release` actions (Module 11).
**Changing the clock instead of counting.** `clearInterval`/`setInterval` to speed up. *Fix:* one fixed clock; decide in `update` which frames matter (Module 10). **Changing the clock instead of counting.** `clearInterval`/`setInterval` to speed up. *Fix:* one fixed clock; decide in `update` which frames matter (Module 10).
**Counting the wrong thing.** "Step when the total frame count is a multiple of the interval" works until the interval changes; then one step is cut short (Module 10, Puzzle 3). *Fix:* store what the rule talks about, here the frames since the last step, and state the requirement as a property you can test (Module 12).
**Trusting the clock to be regular.** A hidden browser tab, a laptop that sleeps or a debugger breakpoint makes the next `Time` event arrive seconds late, and a stream that faithfully catches up plays those seconds in one go. *Fix:* decide how much time the program accepts at once and clamp in pure code (`clampTime`, Module 10).
**Forgetting to wrap the next stream.** A combinator that returns the inner `next` instead of `combinator(next, …)` works for exactly one event. *Fix:* every combinator's next stream is the same combinator around the inner next stream(s) (Module 8). **Forgetting to wrap the next stream.** A combinator that returns the inner `next` instead of `combinator(next, …)` works for exactly one event. *Fix:* every combinator's next stream is the same combinator around the inner next stream(s) (Module 8).
**Left-nested `flatMap` chains.** Building a long sequence with a left fold overflows the stack in the interpreter's continuations. *Fix:* fold right (Module 7, section 12). **Left-nested `flatMap` chains.** Building a long sequence with a left fold overflows the stack in the interpreter's continuations. *Fix:* fold right (Module 7, section 12).
@@ -362,10 +366,7 @@ Each of these is a good exercise in the method. Start from the inventory (step 0
- *Events:* `Press`/`Release` (left/right), `Frame`, `Launch`, `Restart`. - *Events:* `Press`/`Release` (left/right), `Frame`, `Launch`, `Restart`.
- *Hints:* reuse Pong's held-key pattern and its step-by-step frame pipeline. Represent bricks as a list (or a set) of positions; a collision step returns the remaining bricks and the bounced velocity together. Decide, and write a fixture for, what happens when the ball hits two bricks in one frame. - *Hints:* reuse Pong's held-key pattern and its step-by-step frame pipeline. Represent bricks as a list (or a set) of positions; a collision step returns the remaining bricks and the bounced velocity together. Decide, and write a fixture for, what happens when the ball hits two bricks in one frame.
**Conway's Game of Life** ([Wikipedia](https://en.wikipedia.org/wiki/Conway%27s_Game_of_Life)). **Conway's Game of Life** is now built: it is the second capstone, [Module 13](13-capstone-life.md). It adds the one thing the other games did not need, a new kind of input (mouse clicks), and with it a change to the stream inputs, the runtime and the browser platform, but not to any game. If you have done Module 13, try this extension of it: let the player drag to draw several cells in one gesture (`MouseDown`, `MouseMove`, `MouseUp` inputs, and a "drawing" flag in the state, like Pong's held keys).
- *State:* the set of live cells (a list of points, or a set of `"x,y"` strings in JS), generation, paused flag, speed.
- *Events:* `Frame`, `TogglePause`, `Step` (one generation while paused), `Clear`, `Randomize` (needs a seed), and — for editing — clicking a cell.
- *Hints:* `step(cells)` is the purest function you'll ever write: count neighbours with `map`/`filter`, keep cells with 2–3 neighbours, birth cells with exactly 3. Clicking needs a new input type and a new `Platform` method (`onClick(cb: (x, y) => void)`): that's a change to the stream inputs, the runtime and the browser platform, but *not* to the game logic. Doing it will teach you exactly where the edge is.
## Further reading ## Further reading
+111 -9
View File
@@ -44,7 +44,7 @@ F#'s `pure` triggers warning FS0046 ("reserved for future use"); the project fil
| reverse direction | `opposite(dir)` | `Model.opposite(dir: Direction): Direction` | `Model.opposite (dir: Direction) : Direction` | | reverse direction | `opposite(dir)` | `Model.opposite(dir: Direction): Direction` | `Model.opposite (dir: Direction) : Direction` |
| status | `'Playing'`, `'Paused'`, `'GameOver'`, `'Won'` | `enum Status`: `Status.Playing`, ... | `type Status = Playing \| Paused \| GameOver \| Won` | | status | `'Playing'`, `'Paused'`, `'GameOver'`, `'Won'` | `enum Status`: `Status.Playing`, ... | `type Status = Playing \| Paused \| GameOver \| Won` |
| actions | `Action.Tick`, `Action.Turn(dir)`, `Action.TogglePause`, `Action.Restart`, `Action.Frame`; values `{tag: 'Tick'}`, `{tag: 'Turn', dir}`, ... | `enum Action`: `Action.Tick`, `Action.Turn(dir)`, `Action.TogglePause`, `Action.Restart`, `Action.Frame` | `type Action = Tick \| Turn of Direction \| TogglePause \| Restart \| Frame` | | actions | `Action.Tick`, `Action.Turn(dir)`, `Action.TogglePause`, `Action.Restart`, `Action.Frame`; values `{tag: 'Tick'}`, `{tag: 'Turn', dir}`, ... | `enum Action`: `Action.Tick`, `Action.Turn(dir)`, `Action.TogglePause`, `Action.Restart`, `Action.Frame` | `type Action = Tick \| Turn of Direction \| TogglePause \| Restart \| Frame` |
| game state | `{width, height, snake, dir, nextDir, apple, seed, score, status, frame}` | `GameState(width, height, snake: List[Point], dir, nextDir, apple: Option[Point], seed: Long, score, status, frame)` | `{ Width; Height; Snake: Point list; Dir; NextDir; Apple: Point option; Seed: uint32; Score; Status; Frame }` | | game state | `{width, height, snake, dir, nextDir, apple, seed, score, status, frame, queued}` (`queued`: a direction or `null`) | `GameState(width, height, snake: List[Point], dir, nextDir, apple: Option[Point], seed: Long, score, status, frame, queued: Option[Direction])` | `{ Width; Height; Snake: Point list; Dir; NextDir; Apple: Point option; Seed: uint32; Score; Status; Frame; Queued: Direction option }` |
| fresh game | `initialState(seed)` | `Model.initialState(seed: Long): GameState` | `Model.initialState (seed: uint32) : GameState` | | fresh game | `initialState(seed)` | `Model.initialState(seed: Long): GameState` | `Model.initialState (seed: uint32) : GameState` |
The snake is a list of points, **head first**. `apple` is `null` / `None` when there is no apple. The snake is a list of points, **head first**. `apple` is `null` / `None` when there is no apple.
@@ -72,6 +72,19 @@ The snake is a list of points, **head first**. `apple` is `null` / `None` when t
In F#, do every `*` and `+` of the generator through `uint64` helpers so it also works in the browser (Module 4, "F# in the browser"). In F#, do every `*` and `+` of the generator through `uint64` helpers so it also works in the browser (Module 4, "F# in the browser").
Optional (Exercise 4.7): the same pattern for any state type `S`. Nothing in the game uses it.
| Concept | JavaScript (`js/src/state.js`) | Scala (`scala/src/State.scala`) | F# (`fsharp/src/State.fs`) |
|---|---|---|---|
| the type | a function `s => [value, nextS]` | `final case class State[S, A](run: S => (A, S))` | `type State<'s, 'a> = State of ('s -> 'a * 's)` |
| run it | `run(st, s)` → `[a, s2]` | `st.run(s)` → `(a, s2)` (the field) | `State.run s st` → `(a, s2)` |
| constant | `pure(a)` | `State.pure(a)` | `State.pure a` |
| transform the value | `map(st, f)` | `st.map(f)` | `State.map f st` |
| chain | `flatMap(st, f)` | `st.flatMap(f)` | `State.bind f st` |
| read the state | `get` (a value) | `State.get[S]: State[S, S]` | `State.get () : State<'s, 's>` |
| replace the state | `put(s)` (value `undefined`) | `State.put(s): State[S, Unit]` | `State.put s : State<'s, unit>` |
| change the state | `modify(f)` (value `undefined`) | `State.modify(f: S => S): State[S, Unit]` | `State.modify (f: 's -> 's) : State<'s, unit>` |
| Concept | JavaScript (`js/src/spawn.js`) | Scala (`scala/src/Spawn.scala`) | F# (`fsharp/src/Spawn.fs`) | | Concept | JavaScript (`js/src/spawn.js`) | Scala (`scala/src/Spawn.scala`) | F# (`fsharp/src/Spawn.fs`) |
|---|---|---|---| |---|---|---|---|
| empty cells, row-major | `freeCells(state)` | `Spawn.freeCells(state): List[Point]` | `Spawn.freeCells (state: GameState) : Point list` | | empty cells, row-major | `freeCells(state)` | `Spawn.freeCells(state): List[Point]` | `Spawn.freeCells (state: GameState) : Point list` |
@@ -87,6 +100,7 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| final state of a game | `replay(state, actions)` | `Game.replay(state, actions: List[Action]): GameState` | `Game.replay (state: GameState) (actions: Action list) : GameState` | | final state of a game | `replay(state, actions)` | `Game.replay(state, actions: List[Action]): GameState` | `Game.replay (state: GameState) (actions: Action list) : GameState` |
| every state of a game | `history(state, actions)` (length `actions.length + 1`) | `Game.history(state, actions): List[GameState]` | `Game.history (state: GameState) (actions: Action list) : GameState list` | | every state of a game | `history(state, actions)` (length `actions.length + 1`) | `Game.history(state, actions): List[GameState]` | `Game.history (state: GameState) (actions: Action list) : GameState list` |
| frames per step (m10) | `movesEvery(score)` | `Game.movesEvery(score: Int): Int` | `Game.movesEvery (score: int) : int` | | frames per step (m10) | `movesEvery(score)` | `Game.movesEvery(score: Int): Int` | `Game.movesEvery (score: int) : int` |
| turn with the queue (m10) | `steer(state, dir)` | `Game.steer(state: GameState, dir: Direction): GameState` | `Game.steer (state: GameState) (dir: Direction) : GameState` |
## Module 6 — view ## Module 6 — view
@@ -102,8 +116,8 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| Concept | JavaScript (`js/src/io.js`) | Scala (`scala/src/IO.scala`) | F# (`fsharp/src/IO.fs`) | | Concept | JavaScript (`js/src/io.js`) | Scala (`scala/src/IO.scala`) | F# (`fsharp/src/IO.fs`) |
|---|---|---|---| |---|---|---|---|
| operations | `{tag: 'Draw', shape}`, `{tag: 'RandomSeed'}` | `enum Op`: `Op.Draw(shape)`, `Op.RandomSeed` | `type Op = Draw of Shape \| RandomSeed` | | operations | `{tag: 'Draw', shape}`, `{tag: 'RandomSeed'}`; m10: `{tag: 'LoadBest'}`, `{tag: 'SaveBest', score}` | `enum Op`: `Op.Draw(shape)`, `Op.RandomSeed`; m10: `Op.LoadBest`, `Op.SaveBest(score: Int)` | `type Op = Draw of Shape \| RandomSeed`; m10: `\| LoadBest \| SaveBest of int` |
| answers to operations | plain values: `undefined` for a draw, a number for a seed | `enum OpResult`: `OpResult.Done`, `OpResult.Seed(value: Long)` | `type OpResult = Done \| Seed of uint32` | | answers to operations | plain values: `undefined` for a draw or a save, a number for a seed or a best score | `enum OpResult`: `OpResult.Done`, `OpResult.Seed(value: Long)`; m10: `OpResult.Best(value: Int)` | `type OpResult = Done \| Seed of uint32`; m10: `\| Best of int` |
| the IO type | `{tag: 'Pure', value}` or `{tag: 'Effect', op, k}` | `enum IO[+A]`: `IO.Pure(a)`, `IO.Effect(op, k: OpResult => IO[A])` | `type IO<'a> = Pure of 'a \| Effect of Op * (OpResult -> IO<'a>)` | | the IO type | `{tag: 'Pure', value}` or `{tag: 'Effect', op, k}` | `enum IO[+A]`: `IO.Pure(a)`, `IO.Effect(op, k: OpResult => IO[A])` | `type IO<'a> = Pure of 'a \| Effect of Op * (OpResult -> IO<'a>)` |
| finished program | `pure(a)` | `IO.pure(a)` | `IO.pure a` | | finished program | `pure(a)` | `IO.pure(a)` | `IO.pure a` |
| one operation | `effect(op)` | `IO.effect(op): IO[OpResult]` | `IO.effect op : IO<OpResult>` | | one operation | `effect(op)` | `IO.effect(op): IO[OpResult]` | `IO.effect op : IO<OpResult>` |
@@ -113,16 +127,36 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| draw one shape | `draw(shape)` | `IO.draw(shape): IO[Unit]` | `IO.draw shape : IO<unit>` | | draw one shape | `draw(shape)` | `IO.draw(shape): IO[Unit]` | `IO.draw shape : IO<unit>` |
| ask for a seed | `randomSeed` (a value) | `IO.randomSeed: IO[Long]` (a value) | `IO.randomSeed () : IO<uint32>` (a function of unit) | | ask for a seed | `randomSeed` (a value) | `IO.randomSeed: IO[Long]` (a value) | `IO.randomSeed () : IO<uint32>` (a function of unit) |
| draw a scene | `drawScene(scene)` | `IO.drawScene(scene): IO[Unit]` | `IO.drawScene scene : IO<unit>` | | draw a scene | `drawScene(scene)` | `IO.drawScene(scene): IO[Unit]` | `IO.drawScene scene : IO<unit>` |
| pure test interpreter | `trace(io, seed)` → `{result, ops}` | `IO.trace(io, seed): Traced[A]`, `Traced(result, ops)` | `IO.trace seed io : Traced<'a>`, `{ Result; Ops }` | | load the best score (m10) | `loadBest` (a value) | `IO.loadBest: IO[Int]` (a value) | `IO.loadBest () : IO<int>` (a function of unit) |
| save the best score (m10) | `saveBest(score)` | `IO.saveBest(score: Int): IO[Unit]` | `IO.saveBest (score: int) : IO<unit>` |
| pure test interpreter | `trace(io, seed)` → `{result, ops}`; m10: `trace(io, seed, best = 0)` answers `LoadBest` with `best` | `IO.trace(io, seed): Traced[A]`, `Traced(result, ops)`; m10: `IO.trace(io, seed, best: Int = 0)` | `IO.trace seed io : Traced<'a>`, `{ Result; Ops }`; m10: `IO.traceWith (seed: uint32) (best: int) io`, with `trace seed io = traceWith seed 0 io` |
## Module 7b — stack-safe IO (optional)
Uses the `Op`, `OpResult` and `Traced` of Module 7; independent of the `IO` type itself.
| Concept | JavaScript (`js/src/safeio.js`) | Scala (`scala/src/SafeIO.scala`) | F# (`fsharp/src/SafeIO.fs`, module `Snake.SafeIO`) |
|---|---|---|---|
| the type | `{tag: 'Pure', value}`, `{tag: 'Effect', op, k}` or `{tag: 'FlatMap', io, f}` | `enum SafeIO[+A]`: `Pure(a: A)`, `Effect(op: Op, k: OpResult => SafeIO[A])`, `FlatMap[X, B](io: SafeIO[X], f: X => SafeIO[B]) extends SafeIO[B]` | `type SafeIO<'a> = Pure of 'a \| Effect of IO.Op * (IO.OpResult -> SafeIO<'a>) \| FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>)` |
| finished program | `pure(value)` | `SafeIO.pure(a)` | `SafeIO.pure a` |
| one operation | `effect(op)` | `SafeIO.effect(op): SafeIO[OpResult]` | `SafeIO.effect op : SafeIO<IO.OpResult>` |
| chain (only builds a node) | `flatMap(io, f)` | `io.flatMap(f)` | `SafeIO.bind f io` (via the given `erase`, private) |
| transform the result | `map(io, f)` | `io.map(f)` | `SafeIO.map f io` |
| run in order | `sequence(ios)` | `SafeIO.sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit]` | `SafeIO.sequence (ios: SafeIO<unit> list) : SafeIO<unit>` |
| one rewrite (re-association) | `step(io)` | `SafeIO.step(io: SafeIO[A]): SafeIO[A]` | `SafeIO.step (io: SafeIO<'a>) : SafeIO<'a>` |
| loop-free driver (given) | `drive(start, isDone, next)` → `[last, outs]` | none (`@tailrec` instead) | none (tail recursion instead) |
| one interpreter move | `traceStep(io, seed)` → `[next, ops]` | none | none |
| pure test interpreter | `trace(io, seed)` → `{result, ops}` | `SafeIO.trace(io, seed): Traced[A]` | `SafeIO.trace seed io : IO.Traced<'a>` |
## Module 8 — streams ## Module 8 — streams
| Concept | JavaScript (`js/src/stream.js`) | Scala (`scala/src/Stream.scala`) | F# (`fsharp/src/Stream.fs`) | | Concept | JavaScript (`js/src/stream.js`) | Scala (`scala/src/Stream.scala`) | F# (`fsharp/src/Stream.fs`) |
|---|---|---|---| |---|---|---|---|
| inputs | plain objects `{tag: 'Time', t}`, `{tag: 'KeyDown', key}`, `{tag: 'KeyUp', key}` | `enum Event`: `Event.Time(t: Long)`, `Event.KeyDown(key)`, `Event.KeyUp(key)` | `type Event = Time of int64 \| KeyDown of string \| KeyUp of string` | | inputs | plain objects `{tag: 'Time', t}`, `{tag: 'KeyDown', key}`, `{tag: 'KeyUp', key}`; m13: `{tag: 'Click', x, y}` (canvas pixels) | `enum Event`: `Event.Time(t: Long)`, `Event.KeyDown(key)`, `Event.KeyUp(key)`; m13: `Event.Click(x: Int, y: Int)` | `type Event = Time of int64 \| KeyDown of string \| KeyUp of string`; m13: `\| Click of int * int` |
| the stream type | `{ step: input => [outputs, next] }` | `final case class Stream[A](step: Event => (List[A], Stream[A]))` | `type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>)` | | the stream type | `{ step: input => [outputs, next] }` | `final case class Stream[A](step: Event => (List[A], Stream[A]))` | `type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>)` |
| feed one input | `s.step(input)` | `s.step(event)` | `Stream.step e s` | | feed one input | `s.step(input)` | `s.step(event)` | `Stream.step e s` |
| key presses / releases | `keyDowns`, `keyUps` | `Stream.keyDowns`, `Stream.keyUps` | `Stream.keyDowns`, `Stream.keyUps` | | key presses / releases | `keyDowns`, `keyUps` | `Stream.keyDowns`, `Stream.keyUps` | `Stream.keyDowns`, `Stream.keyUps` |
| mouse clicks (m13, given) | `clicks` (emits `{x, y}`) | `Stream.clicks: Stream[(Int, Int)]` | `Stream.clicks : Stream<int * int>` |
| a clock | `interval(ms)` | `Stream.interval(ms: Long): Stream[Long]` | `Stream.interval (ms: int64) : Stream<int64>` | | a clock | `interval(ms)` | `Stream.interval(ms: Long): Stream[Long]` | `Stream.interval (ms: int64) : Stream<int64>` |
| transform outputs | `map(s, f)` | `s.map(f)` | `Stream.map f s` | | transform outputs | `map(s, f)` | `s.map(f)` | `Stream.map f s` |
| keep some outputs | `filter(s, p)` | `s.filter(p)` | `Stream.filter p s` | | keep some outputs | `filter(s, p)` | `s.filter(p)` | `Stream.filter p s` |
@@ -131,6 +165,7 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| running fold | `scan(s, f, z)` | `s.scan(z)(f)` | `Stream.scan f z s` | | running fold | `scan(s, f, z)` | `s.scan(z)(f)` | `Stream.scan f z s` |
| emit a first value | `startWith(s, v)` | `s.startWith(v)` | `Stream.startWith v s` | | emit a first value | `startWith(s, v)` | `s.startWith(v)` | `Stream.startWith v s` |
| run on a list of inputs | `run(s, inputs)` | `s.run(events)` | `Stream.run events s` | | run on a list of inputs | `run(s, inputs)` | `s.run(events)` | `Stream.run events s` |
| limit clock jumps (m10) | `clampTime(s, maxGap)` | `s.clampTime(maxGap: Long)` | `Stream.clampTime (maxGap: int64) s` |
## Module 9 — input, app, runtime (and Module 10 additions) ## Module 9 — input, app, runtime (and Module 10 additions)
@@ -139,17 +174,44 @@ In F#, do every `*` and `+` of the generator through `uint64` helpers so it also
| key to action | `keyToAction(key)` → action or `null` (`input.js`) | `Input.keyToAction(key: String): Option[Action]` | `Input.keyToAction (key: string) : Action option` | | key to action | `keyToAction(key)` → action or `null` (`input.js`) | `Input.keyToAction(key: String): Option[Action]` | `Input.keyToAction (key: string) : Action option` |
| ms per step | `TICK_MS` = 150 (`app.js`) | `App.TICK_MS: Long` | `App.TICK_MS` (`150L`) | | ms per step | `TICK_MS` = 150 (`app.js`) | `App.TICK_MS: Long` | `App.TICK_MS` (`150L`) |
| ms per frame (m10) | `FRAME_MS` = 25 | `App.FRAME_MS: Long` | `App.FRAME_MS` (`25L`) | | ms per frame (m10) | `FRAME_MS` = 25 | `App.FRAME_MS: Long` | `App.FRAME_MS` (`25L`) |
| longest accepted time step (m10) | `MAX_GAP_MS` = 250 | `App.MAX_GAP_MS: Long` | `App.MAX_GAP_MS` (`250L`) |
| actions, classic clock | `classicActions` | `App.classicActions: Stream[Action]` | `App.classicActions : Stream<Action>` | | actions, classic clock | `classicActions` | `App.classicActions: Stream[Action]` | `App.classicActions : Stream<Action>` |
| actions, frame clock (m10) | `speedActions` | `App.speedActions: Stream[Action]` | `App.speedActions : Stream<Action>` | | actions, frame clock (m10) | `speedActions` | `App.speedActions: Stream[Action]` | `App.speedActions : Stream<Action>` |
| states over time | `states(seed, actions)` | `App.states(seed: Long, actions): Stream[GameState]` | `App.states (seed: uint32) (actions: Stream<Action>) : Stream<GameState>` | | states over time | `states(seed, actions)` | `App.states(seed: Long, actions): Stream[GameState]` | `App.states (seed: uint32) (actions: Stream<Action>) : Stream<GameState>` |
| draw one state | `render(state)` | `App.render(state): IO[Unit]` | `App.render (state: GameState) : IO<unit>` | | draw one state | `render(state)` | `App.render(state): IO[Unit]` | `App.render (state: GameState) : IO<unit>` |
| a session (m10) | `{game, best, improved}` | `final case class Session(game: GameState, best: Int, improved: Boolean)` | `type Session = { Game: GameState; Best: int; Improved: bool }` |
| one session step (m10) | `sessionUpdate(session, action)` | `App.sessionUpdate(session: Session, action: Action): Session` | `App.sessionUpdate (session: Session) (action: Action) : Session` |
| sessions over time (m10) | `sessions(seed, best, actions)` | `App.sessions(seed: Long, best: Int, actions: Stream[Action]): Stream[Session]` | `App.sessions (seed: uint32) (best: int) (actions: Stream<Action>) : Stream<Session>` |
| picture of a session (m10) | `sessionView(session)` | `App.sessionView(session: Session): Scene` | `App.sessionView (session: Session) : Scene` |
| draw a session, save a new best (m10) | `renderSession(session)` | `App.renderSession(session: Session): IO[Unit]` | `App.renderSession (session: Session) : IO<unit>` |
| the whole program | `main` (a value) | `App.main: IO[Stream[IO[Unit]]]` (a value) | `App.main () : IO<Stream<IO<unit>>>` (a function of unit) | | the whole program | `main` (a value) | `App.main: IO[Stream[IO[Unit]]]` (a value) | `App.main () : IO<Stream<IO<unit>>>` (a function of unit) |
| the outside world | `Platform` object: `fillRect(x, y, w, h, color)`, `fillText(x, y, text, color, size, align)`, `randomSeed()`, `startClock(onTime)`, `onKeyDown(cb)`, `onKeyUp(cb)` | `trait Platform` with the same method names; `randomSeed(): Long`, `startClock(onTime: Long => Unit)` | `type Platform` record of functions: `FillRect`, `FillText`, `RandomSeed: unit -> uint32`, `StartClock: (int64 -> unit) -> unit`, `OnKeyDown`, `OnKeyUp` | | the program as data | `play(main, seed, events)` → one list of ops per drawing; m10: `play(main, seed, events, best = 0)` | `App.play(main, seed: Long, events: List[Event]): List[List[Op]]`; m10: `best: Int = 0` added | `App.play (seed: uint32) (events: Event list) main : Op list list`; m10: `App.playWith (seed: uint32) (best: int) events main`, with `play seed events main = playWith seed 0 events main` |
| the outside world | `Platform` object: `fillRect(x, y, w, h, color)`, `fillText(x, y, text, color, size, align)`, `randomSeed()`, `startClock(onTime)`, `onKeyDown(cb)`, `onKeyUp(cb)`; m10: `loadBest()` → number, `saveBest(score)`; m13: `onClick(cb)`, `cb(x, y)` in canvas pixels | `trait Platform` with the same method names; `randomSeed(): Long`, `startClock(onTime: Long => Unit)`; m10: `loadBest(): Int`, `saveBest(score: Int): Unit`; m13: `onClick(cb: (Int, Int) => Unit): Unit` (given) | `type Platform` record of functions: `FillRect`, `FillText`, `RandomSeed: unit -> uint32`, `StartClock: (int64 -> unit) -> unit`, `OnKeyDown`, `OnKeyUp`; m10: `LoadBest: unit -> int`, `SaveBest: int -> unit`; m13: `OnClick: (int -> int -> unit) -> unit` (given) |
| interpret an IO | `runIO(io, platform)` (`runtime.js`) | `Runtime.runIO(io, p)` | `Runtime.runIO p io` | | interpret an IO | `runIO(io, platform)` (`runtime.js`) | `Runtime.runIO(io, p)` | `Runtime.runIO p io` |
| run the program | `runApp(main, platform)` | `Runtime.runApp(main, p)` | `Runtime.runApp p main` | | run the program | `runApp(main, platform)`; m13: also registers `onClick` | `Runtime.runApp(main, p)` | `Runtime.runApp p main` |
| browser platform | `browserPlatform(canvas, win)` (`browser.js`, you write it) | `BrowserPlatform(canvas)` (`scala/web/`, given) | `BrowserPlatform.create canvas`, `BrowserPlatform.gameCanvas ()` (`fsharp/web/`, given) | | browser platform | `browserPlatform(canvas, win)` (`browser.js`, you write it; m10: the best score in `localStorage` key `pure-snake-best`; m13: `onClick`, with the given helper `canvasPoint(canvas, clientX, clientY)` → `{x, y}`) | `BrowserPlatform(canvas)` (`scala/web/`, given) | `BrowserPlatform.create canvas`, `BrowserPlatform.gameCanvas ()` (`fsharp/web/`, given) |
| entry point | `js/src/main.js` (given): `runApp(main, browserPlatform(...))` | `scala/web/Main.scala` (given): `SnakeMain` | `fsharp/web/SnakeMain.fs` (given) | | entry point | `js/src/main.js` (given): `runApp(main, browserPlatform(...))` | `scala/web/Main.scala` (given): `SnakeMain` | `fsharp/web/SnakeMain.fs` (given) |
## Module 10 — replay viewer (`replay`)
JS `js/src/replay.js`, Scala `scala/src/Replay.scala` (types at the top level of package `snake`, functions in `object Replay`), F# `fsharp/src/Replay.fs` (module `Snake.Replay`; `ViewerAction` is `[<RequireQualifiedAccess>]`).
| Concept | JavaScript | Scala | F# |
|---|---|---|---|
| a recording | `{seed, actions}` (e.g. `fixtures/replay-sample.json`) | `final case class Recording(seed: Long, actions: List[Action])` | `type Recording = { Seed: uint32; Actions: Action list }` |
| the viewer | `{index, last, playing}` | `final case class Viewer(index: Int, last: Int, playing: Boolean)` | `type Viewer = { Index: int; Last: int; Playing: bool }` |
| viewer actions | `ViewerAction.Next`, `.Prev`, `.First`, `.Last`, `.TogglePlay`, `.Beat`; values `{tag: 'Next'}`, ... | `enum ViewerAction { Next, Prev, First, Last, TogglePlay, Beat }` | `ViewerAction.Next`, ... `ViewerAction.Beat` |
| ms per autoplay frame | `REPLAY_MS` = 100 | `Replay.REPLAY_MS: Long` | `Replay.REPLAY_MS` (`100L`) |
| every state of the game | `frames(recording)` | `Replay.frames(recording: Recording): List[GameState]` | `Replay.frames (recording: Recording) : GameState list` |
| the first viewer | `viewerStart(last)` | `Replay.viewerStart(last: Int): Viewer` | `Replay.viewerStart (last: int) : Viewer` |
| one viewer step | `viewerUpdate(viewer, action)` | `Replay.viewerUpdate(viewer: Viewer, action: ViewerAction): Viewer` | `Replay.viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer` |
| key to action | `viewerKey(key)` → action or `null` | `Replay.viewerKey(key: String): Option[ViewerAction]` | `Replay.viewerKey (key: string) : ViewerAction option` |
| picture | `viewerView(frames, viewer)` | `Replay.viewerView(frames: List[GameState], viewer: Viewer): Scene` | `Replay.viewerView (frames: GameState list) (viewer: Viewer) : Scene` |
| actions over time | `replayActions` | `Replay.replayActions: Stream[ViewerAction]` | `Replay.replayActions : Stream<ViewerAction>` |
| viewers over time | `viewerStates(last, actions)` | `Replay.viewerStates(last: Int, actions: Stream[ViewerAction]): Stream[Viewer]` | `Replay.viewerStates (last: int) (actions: Stream<ViewerAction>) : Stream<Viewer>` |
| the whole viewer | `replayMain(recording)` | `Replay.replayMain(recording: Recording): IO[Stream[IO[Unit]]]` | `Replay.replayMain (recording: Recording) : IO<Stream<IO<unit>>>` |
| entry point (given) | `js/src/replay-main.js` (fetches the recording), page `js/replay.html` | in `scala/web/`, page `scala/replay.html` | in `fsharp/web/`, page `fsharp/replay.html` |
## Module 11 — Pong ## Module 11 — Pong
JS files are in `js/src/pong/`, Scala files in `scala/src/pong/` (objects `PongModel`, `PongRules`, `PongView`, `PongApp` in package `snake.pong`), F# files in `fsharp/src/Pong/` (modules `Snake.Pong.Model`, `Snake.Pong.Rules`, `Snake.Pong.View`, `Snake.Pong.App`). In F#, the Pong unions are marked `[<RequireQualifiedAccess>]`, so you always write `PongStatus.Playing`, `Paddle.Up`, `PongAction.Frame`; that keeps them apart from the snake's `Playing`, `Up` and `Frame`. JS files are in `js/src/pong/`, Scala files in `scala/src/pong/` (objects `PongModel`, `PongRules`, `PongView`, `PongApp` in package `snake.pong`), F# files in `fsharp/src/Pong/` (modules `Snake.Pong.Model`, `Snake.Pong.Rules`, `Snake.Pong.View`, `Snake.Pong.App`). In F#, the Pong unions are marked `[<RequireQualifiedAccess>]`, so you always write `PongStatus.Playing`, `Paddle.Up`, `PongAction.Frame`; that keeps them apart from the snake's `Playing`, `Up` and `Frame`.
@@ -175,6 +237,45 @@ JS files are in `js/src/pong/`, Scala files in `scala/src/pong/` (objects `PongM
| the whole program | `pongMain` (a value) | `PongApp.pongMain: IO[Stream[IO[Unit]]]` | `Pong.App.pongMain () : IO<Stream<IO<unit>>>` | | the whole program | `pongMain` (a value) | `PongApp.pongMain: IO[Stream[IO[Unit]]]` | `Pong.App.pongMain () : IO<Stream<IO<unit>>>` |
| entry point (given) | `js/src/pong/main.js`, page `js/pong.html` | `PongMain` in `scala/web/Main.scala`, page `scala/pong.html` | `fsharp/web/PongMain.fs`, page `fsharp/pong.html` | | entry point (given) | `js/src/pong/main.js`, page `js/pong.html` | `PongMain` in `scala/web/Main.scala`, page `scala/pong.html` | `fsharp/web/PongMain.fs`, page `fsharp/pong.html` |
## Module 12 — property-based testing (bonus)
| Concept | JavaScript (`js/src/check.js`) | Scala (`scala/src/Check.scala`) | F# (`fsharp/src/Check.fs`) |
|---|---|---|---|
| a generator | a `Random<A>` (Module 4) | `Random[A]` | `Random<'a>` |
| pick an element | `elementOf(xs)` | `Check.elementOf(xs: List[A]): Random[A]` | `Check.elementOf (xs: 'a list) : Random<'a>` |
| `n` draws in a row | `listOf(gen, n)` | `Check.listOf(gen, n): Random[List[A]]` | `Check.listOf (n: int) gen : Random<'a list>` |
| random actions (given) | `genActions(n)` | `Check.genActions(n): Random[List[Action]]` | `Check.genActions (n: int) : Random<Action list>` |
| the report | `{ok: true, runs}` or `{ok: false, run, seed, value}` | `CheckResult.Passed(runs)`, `CheckResult.Failed(run, seed, value)` | `Passed runs`, `Failed(run, seed, value)` |
| check a property | `forAll(gen, prop, runs, seed)` | `Check.forAll(gen, runs, seed)(prop)` | `Check.forAll runs seed prop gen` |
| one element removed | `removals(xs)` | `Check.removals(xs): List[List[A]]` | `Check.removals xs : 'a list list` |
| shrink a failing list | `minimize(xs, fails)` | `Check.minimize(xs)(fails)` | `Check.minimize fails xs` |
| broken invariants | `violations(state)` → names | `Check.violations(state): List[String]` | `Check.violations state : string list` |
| frames between moves | `moveGaps(states)` | `Check.moveGaps(states): List[Int]` | `Check.moveGaps states : int list` |
| a random snake | `genSnake(width, height, maxLen)` | `Check.genSnake(width: Int, height: Int, maxLen: Int): Random[List[Point]]` | `Check.genSnake (width: int) (height: int) (maxLen: int) : Random<Point list>` |
| a random sane state (given) | `genState(width, height)` | `Check.genState(width: Int, height: Int): Random[GameState]` | `Check.genState (width: int) (height: int) : Random<GameState>` |
| the step, as an oracle | `advanceSpec(state)` | `Check.advanceSpec(state: GameState): GameState` | `Check.advanceSpec (state: GameState) : GameState` |
## Module 13 — Conway's Life
JS files are in `js/src/life/`, Scala files in `scala/src/life/` (objects `LifeModel`, `LifeRules`, `LifeView`, `LifeApp` in package `snake.life`), F# files in `fsharp/src/Life/` (modules `Snake.Life.Model`, `Snake.Life.Rules`, `Snake.Life.View`, `Snake.Life.App`; `LifeAction` is `[<RequireQualifiedAccess>]`). The click at the edge is in the Module 8 and 9 tables above.
| Concept | JavaScript | Scala | F# |
|---|---|---|---|
| constants | `LW` = 40, `LH` = 30, `LCELL` = 16, `STRIP` = 24 (`model.js`) | `LifeModel.LW`, ... `LifeModel.STRIP` | `Life.Model.LW`, ... `Life.Model.STRIP` |
| world state | `{cells, running, seed, generation}`; `cells` sorted row-major, no duplicates | `LifeState(cells: List[Point], running: Boolean, seed: Long, generation: Int)` | `{ Cells: Point list; Running: bool; Seed: uint32; Generation: int }` |
| actions | `LifeAction.Toggle(cell)`, `LifeAction.Frame`, `.TogglePause`, `.Step`, `.Clear`, `.Randomize`; values `{tag: 'Toggle', cell}`, `{tag: 'Frame'}`, ... | `enum LifeAction`: `Frame`, `Toggle(cell: Point)`, `TogglePause`, `Step`, `Clear`, `Randomize` | `LifeAction.Frame`, `LifeAction.Toggle of Point`, ... |
| fresh world | `initialLife(seed)` | `LifeModel.initialLife(seed: Long): LifeState` | `Life.Model.initialLife (seed: uint32) : LifeState` |
| one generation | `lifeStep(cells)` (`rules.js`) | `LifeRules.lifeStep(cells: List[Point]): List[Point]` | `Life.Rules.lifeStep (cells: Point list) : Point list` |
| the update function | `lifeUpdate(state, action)` | `LifeRules.lifeUpdate(state: LifeState, action: LifeAction): LifeState` | `Life.Rules.lifeUpdate (state: LifeState) (action: LifeAction) : LifeState` |
| state to picture | `lifeView(state)` (`view.js`) | `LifeView.lifeView(state: LifeState): Scene` | `Life.View.lifeView (state: LifeState) : Scene` |
| ms per generation | `GEN_MS` = 150 (`app.js`) | `LifeApp.GEN_MS: Long` | `Life.App.GEN_MS` (`150L`) |
| key to action | `lifeKey(key)` → action or `null` | `LifeApp.lifeKey(key: String): Option[LifeAction]` | `Life.App.lifeKey (key: string) : LifeAction option` |
| click to action | `lifeClick(x, y)` → `Toggle` or `null` | `LifeApp.lifeClick(x: Int, y: Int): Option[LifeAction]` | `Life.App.lifeClick (x: int) (y: int) : LifeAction option` |
| actions over time | `lifeActions` | `LifeApp.lifeActions: Stream[LifeAction]` | `Life.App.lifeActions : Stream<LifeAction>` |
| states over time | `lifeStates(seed, actions)` | `LifeApp.lifeStates(seed: Long, actions: Stream[LifeAction]): Stream[LifeState]` | `Life.App.lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState>` |
| the whole program | `lifeMain` (a value) | `LifeApp.lifeMain: IO[Stream[IO[Unit]]]` | `Life.App.lifeMain () : IO<Stream<IO<unit>>>` |
| entry point (given) | `js/src/life/main.js`, page `js/life.html` | in `scala/web/`, page `scala/life.html` | in `fsharp/web/`, page `fsharp/life.html` |
## Tests and tools ## Tests and tools
| Task | JavaScript (`cd js`) | Scala (`cd scala`) | F# (`cd fsharp`) | | Task | JavaScript (`cd js`) | Scala (`cd scala`) | F# (`cd fsharp`) |
@@ -183,7 +284,8 @@ JS files are in `js/src/pong/`, Scala files in `scala/src/pong/` (objects `PongM
| all tests (with the purity audit) | `./test.sh` | `./test.sh` | `./test.sh` | | all tests (with the purity audit) | `./test.sh` | `./test.sh` | `./test.sh` |
| test files | `test/m03-rules.test.js` | `test/M03Rules.test.scala` | `tests/M03Rules.fs` | | test files | `test/m03-rules.test.js` | `test/M03Rules.test.scala` | `tests/M03Rules.fs` |
| purity audit | `test/purity.test.js` | `test/Purity.test.scala` (`PuritySuite`) | `tests/Purity.fs` | | purity audit | `test/purity.test.js` | `test/Purity.test.scala` (`PuritySuite`) | `tests/Purity.fs` |
| type checking (optional) | `npx -y -p typescript tsc -p js/jsconfig.json` from the repository root, or VS Code as it is; types in `js/src/types.d.ts` (see [Module 0](00-setup.md#if-your-editor-understands-types-javascript-optional)) | the compiler, on every test run | the compiler, on every test run |
| build for the browser | nothing to build | `./build-web.sh` (Scala.js, writes `web/dist/`) | `./build-web.sh` (Fable, writes `web/dist/`) | | build for the browser | nothing to build | `./build-web.sh` (Scala.js, writes `web/dist/`) | `./build-web.sh` (Fable, writes `web/dist/`) |
| play | `node scripts/serve.mjs` from the repository root, then <http://localhost:8000/js/> (Pong: `/js/pong.html`) | same server, <http://localhost:8000/scala/> | same server, <http://localhost:8000/fsharp/> | | play | `node scripts/serve.mjs` from the repository root, then <http://localhost:8000/js/> (Pong: `/js/pong.html`, replay viewer: `/js/replay.html`, Life: `/js/life.html`) | same server, <http://localhost:8000/scala/> | same server, <http://localhost:8000/fsharp/> |
See also: [Module 0](00-setup.md) for setup, [the method](method.md) for the conversion recipe, the [README](../README.md) for the module map, and [MENTOR.md](../MENTOR.md) for AI-mentor mode. See also: [Module 0](00-setup.md) for setup, [the method](method.md) for the conversion recipe, the [README](../README.md) for the module map, and [MENTOR.md](../MENTOR.md) for AI-mentor mode.
+198
View File
@@ -0,0 +1,198 @@
# Pure Snake — the contract
The canonical description of what the course code must do, as of 2026-11-11. It replaces the "contract" section of `docs/superpowers/plans/2026-10-28-pure-snake-course.md`, which is out of date.
## What is normative, in order
1. **The shared fixtures** in `fixtures/*.json`. All three tracks read the same files.
2. **The doc comments of the stubs** in `js/src`, `scala/src`, `fsharp/src`. They state each function's behaviour; solutions and course text must agree with them.
3. **This document**, for everything that crosses files: data shapes, orders, constants, pinned values, conventions.
4. `course/reference.md` gives every name in all three languages side by side. It is a lookup table, not a specification.
If two of these disagree, that is a bug: fix the lower one, and say so in the build log.
## Conventions
- **Tracks.** JavaScript is the reference track. Scala 3 and F# are ports that must produce the same values for the same inputs, including every random draw.
- **Files.** Student stubs in `<track>/src`, finished code in `<track>/solutions`, same file names. A stub compiles, carries the specifying doc comment and fails per test (`todo('mNN')`, `???`, `failwith "TODO mNN"`). In F#, anything that would fail at module load is a unit function (`main ()`, `randomSeed ()`, `loadBest ()`, `pongMain ()`, `lifeMain ()`, `State.get ()`).
- **Given code** (present and complete in the stubs): types and constants in Scala/F#; `todo`; `genActions`, `genState`; `CheckResult`, `Session`, `Recording`, `Viewer`, the action types; the stream source `clicks`; `canvasPoint`; `drive`/`answer` in `safeio`; all `main` entry files and `types.d.ts`; the browser glue of Scala and F# (`scala/web`, `fsharp/web`).
- **Calling style.** JS: data first, plain objects with a `tag`. Scala: methods and objects in `package snake`; `Option`, `List`; `Long` for seeds and time. F#: data last; `bind` for `flatMap`, `choose` for `filterMap`; PascalCase fields; `uint32` seeds, `int64` time.
- **Purity.** Outside the edge files there is no reassignment, mutation, loop with a counter, randomness, clock or DOM. Edge files: JS `runtime.js`, `browser.js`, `main.js`, `replay-main.js`, `*/main.js`; Scala `Runtime.scala` and `scala/web`; F# `Runtime.fs` and `fsharp/web`. Each track's purity test enforces this on `src`.
- **Module tags.** `M00`–`M13` plus the optional `M07b`. `./test.sh MNN` in each track runs exactly that module's tests.
- **Coordinates.** `x` grows right, `y` grows **down**. "Row-major" always means: by `y`, then by `x`.
## Data
```
Point {x, y}
Direction Up | Down | Left | Right delta: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0)
Status Playing | Paused | GameOver | Won
Action Tick | Turn(dir) | TogglePause | Restart | Frame
GameState {width, height, snake (head first), dir, nextDir, apple (or none), seed, score, status, frame, queued (or none)}
Shape Rect {x, y, w, h, color} | Text {x, y, text, color, size, align}
Scene list of Shape, drawn in order
Op Draw(shape) | RandomSeed | LoadBest | SaveBest(score)
Input Time(t ms) | KeyDown(key) | KeyUp(key) | Click(x, y) (called Event in Scala and F#)
Session {game, best, improved}
```
- JSON field order of a game state is as listed; `queued` is last. "None" is `null` in JSON and JS.
- `dir` is the direction the snake last moved; `nextDir` the direction of its next step; `queued` a second pending turn (Module 10); `frame` the number of frames since the snake last moved (Module 10). `frame` and `queued` are dormant (0 and none) until Module 10.
- `initialState(seed)`: 20×20, snake `(10,10) (9,10) (8,10)`, `dir = nextDir = Right`, no apple, score 0, `Playing`, frame 0, nothing queued.
## Modules
### M01 `util`
`range(n)` = `[0..n-1]`; `dropLast` (total: empty stays empty); `contains(xs, p, eq)` calls `eq(x, p)`. JS only: `pipe`, `compose`, `deepFreeze` (cycle-safe, descends into already-frozen parents, leaves `null` and primitives alone).
### M02 `model`
`point`, `pointEquals`, `delta`, `opposite`, `Action`, `initialState`.
### M03 `rules`
- `move(p, dir)`: no wrapping. `inBounds(state, p)`: `0 <= x < width`, `0 <= y < height`.
- `turn(state, dir)`: unchanged unless `Playing` and `dir != opposite(state.dir)` (checked against `dir`, not `nextDir`); then `nextDir = dir`.
- `advance(state)`: unchanged unless `Playing`. `head = move(snake[0], nextDir)`; `eating` = head is on the apple; `body` = the whole snake when eating, else the snake without its last cell. If head is off the board or in `body`: `{dir: nextDir, status: GameOver}`, snake unchanged. Otherwise snake = head + body, `dir = nextDir`, and when eating the apple is removed and score + 1. `advance` ignores `queued` and never touches `seed` or `frame`.
### M04 `random`, `spawn`, optional `state`
- `nextUint32` is mulberry32: `s = (seed + 0x6D2B79F5) mod 2^32`; `t = imul(s ^ (s >>> 15), s | 1)`; `t = t ^ (t + imul(t ^ (t >>> 7), t | 61))`; value `= (t ^ (t >>> 14)) >>> 0`; result `(value, s)`. From seed 0: `(1144304738, 1831565813)`. F# routes `*` and `+` through `uint64` helpers because Fable does not wrap `uint32`.
- `Random<A>` = `seed -> (A, seed)` with `pure`, `map`, `flatMap`, `run`. `intBelow(n)` = `nextUint32` mapped with `v % n` (biased on purpose; the fixtures depend on it).
- `freeCells(state)`: board cells not in the snake, row-major; the apple's cell counts as free.
- `spawnApple(state)`: unchanged if there is an apple or the status is not `Playing`; `Won` if no cell is free (seed unchanged); otherwise apple = `freeCells[i]` with `i` from `intBelow(count)` on `state.seed`, and the seed advances.
- `tick` = `spawnApple ∘ advance`. `newGame(seed)` = `spawnApple(initialState(seed))`.
- `state` (optional Exercise 4.7): `State<S, A>` = `s -> (A, s)` with `pure`, `map`, `flatMap`, `get`, `put`, `modify`, `run`. Nothing in the game uses it. Its tests are under M04; a student who skips it copies the solution file.
### M05 `game` (extended in M10)
- `movesEvery(score)` = `max(2, 8 - floor(score / 3))`.
- `update(state, action)`, final form:
- `Tick`: `step(state)` where `step = takeQueued ∘ tick`.
- `Turn(dir)`: `steer(state, dir)`.
- `TogglePause`: `Playing <-> Paused`; other statuses unchanged.
- `Restart`: `newGame(state.seed)` from any status (the **current** seed).
- `Frame`: unchanged unless `Playing`. `n = frame + 1`; if `n >= movesEvery(score)` then `step` of the state with `frame: 0`, else the state with `frame: n`. The threshold uses the score before the step.
- `steer(state, dir)`: not `Playing` → unchanged. No turn pending (`state.nextDir == state.dir`) → `turn(state, dir)`. A turn pending → if `dir` is neither `nextDir` nor its opposite, `queued = dir` (a later key replaces it); otherwise unchanged.
- `takeQueued`: after a step, if the result is `Playing` and something is queued: `nextDir = queued`, `queued` = none. A result that is not `Playing` keeps `queued`.
- `replay(state, actions)` is the left fold of `update`; `history` is the scan (length = actions + 1, starts with `state`).
- In Module 5 itself, `update` only handles `Tick` (→ `tick`) and `Turn` (→ `turn`); the Module 5 fixtures hold under both forms.
### M06 `view`
`CELL = 20`. Scene, in order: background `Rect(0, 0, w*20, h*20, '#111111')`; the apple, if any, as a cell `'#e53935'`; each snake cell (head `'#7cb342'`, body `'#43a047'`), where a cell is `Rect(x*20+1, y*20+1, 18, 18)`; `Text(6, 18, 'Score: <n>', '#ffffff', 16, 'left')`; then for `Paused` / `GameOver` / `Won` two centred texts at `(w*20/2, h*20/2)` size 32 and 30 px lower size 16: `Paused` + `Press Space to resume`, `Game Over` + `Press R to restart`, `You Win!` + `Press R to restart`.
### M07 `io`
- `IO<A>` = `Pure(value)` | `Effect(op, k)`. `pure`, `effect(op)` = `Effect(op, pure)`, `flatMap` (on `Pure` it calls `f` at once; on `Effect` it extends `k`), `map` via `flatMap`, `sequence` (**right-nested**; left nesting overflows the 100 000-effect runtime test), `draw`, `randomSeed`, `drawScene(scene)` = `sequence(scene.map(draw))`.
- Module 10 adds `loadBest` and `saveBest(score)`.
- `trace(io, seed, best = 0)` → `{result, ops}`: answers `RandomSeed` with `seed`, `LoadBest` with `best`, the others with unit. Scala: default argument. F#: `traceWith seed best io`, and `trace seed io = traceWith seed 0 io`. Scala/F# answers are `OpResult`: `Done | Seed(n) | Best(n)`.
### M07b `safeio` (optional)
A second, independent IO: `Pure | Effect(op, k) | FlatMap(io, f)`. `flatMap` only builds a node and never calls `f`. `step(io)` does one constant-work rewrite: `FlatMap(Pure(a), f)` → `f(a)`; `FlatMap(Effect(op, k), f)` → `Effect(op, r => FlatMap(k(r), f))`; `FlatMap(FlatMap(m, g), f)` → `FlatMap(m, x => FlatMap(g(x), f))`. `trace(io, seed)` must not grow the stack for 100 000 left- or right-nested binds, and must equal Module 7's `trace` on programs of `Draw` and `RandomSeed`. It answers only those two ops and **throws** for any other. JS gets a given trampoline `drive`; Scala and F# use a tail-recursive loop. F# erases the intermediate type (`FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>)`, given private `erase`).
### M08 `stream` (extended in M10 and M13)
- `Stream<A>` = `{step: input -> (outputs, nextStream)}`; no hidden state.
- Sources: `keyDowns`, `keyUps` (emit the key), `clicks` (emit `(x, y)`, given, used from M13), `interval(ms)`: on `Time t` emits every multiple `k*ms` with `last < k*ms <= t`, ascending; `last` starts at 0 and becomes `t`.
- Combinators: `map`, `filter`, `filterMap`/`choose` (drops none), `merge(a, b)` (both step on every input; `a`'s outputs first), `scan(s, f, z)` (emits each new accumulator, not `z`), `startWith(s, v)` (emits `v` before the outputs of the first step, whatever the first input is), `run(s, inputs)`.
- `clampTime(s, maxGap)` (M10): gives `s` a clock of its own starting at 0. On `Time t` the clock advances by `min(max(t - last, 0), maxGap)` and `s` is stepped with `Time(clock)`; `last` (initially 0) becomes `t`. Every other input is passed through unchanged.
### M09 `input`, `app`, `runtime`, `browser` (extended in M10 and M13)
- `keyToAction(key)`: `ArrowUp`/`w`/`W` → `Turn Up`, likewise Down (`s`), Left (`a`), Right (`d`); from M10 `' '` → `TogglePause`, `r`/`R` → `Restart`; anything else none (including `constructor`, `toString`).
- Constants: `TICK_MS = 150`, `FRAME_MS = 25`, `MAX_GAP_MS = 250`.
- `classicActions` = `merge(map(interval(TICK_MS), Tick), filterMap(keyDowns, keyToAction))`; `speedActions` the same with `Frame` every `FRAME_MS`.
- `states(seed, actions)` = `startWith(scan(actions, update, newGame(seed)), newGame(seed))`; `render(state)` = `drawScene(view(state))`.
- Session (M10): `sessionUpdate(s, a)`: `game' = update(game, a)`, `best' = max(best, game'.score)`, `improved = best' > best`. `sessions(seed, best, actions)` = `startWith(scan(actions, sessionUpdate, s0), s0)`, `s0 = {newGame(seed), best, false}`. `sessionView(s)` = `view(game)` + `Text(width*20 - 6, 18, 'Best: <best>', '#ffffff', 16, 'right')`. `renderSession(s)` = `drawScene(sessionView(s))`, then `saveBest(best)` exactly when `improved`.
- `main` : `IO<Stream<IO<unit>>>`. Final form: `randomSeed`, then `loadBest`, then `clampTime(map(sessions(seed, best, speedActions), renderSession), MAX_GAP_MS)`. Its ops when traced: `[RandomSeed, LoadBest]`. In Module 9 it is `map(randomSeed, seed => map(states(seed, classicActions), render))`; the tests keep that program as `classicMain`.
- `play(main, seed, events, best = 0)`: the list of ops of each drawing `main` emits when fed `events`. F#: `play seed events main` and `playWith seed best events main`.
- `Platform`: `fillRect(x, y, w, h, color)`, `fillText(x, y, text, color, size, align)`, `randomSeed()`, `startClock(onTime)`, `onKeyDown(cb)`, `onKeyUp(cb)`, `loadBest()`, `saveBest(n)` (M10), `onClick(cb)` (M13).
- `runIO(io, platform)`: a loop (or tail recursion); performs each op on the platform. `runApp(main, platform)`: runs `main`, keeps the current stream in the program's one mutable variable, and feeds `KeyDown`, `KeyUp`, `Time` (and from M13 `Click`) inputs: step, store the next stream, run each emitted IO in order. Nothing is drawn before the first input.
- Browser platform: the clock reports milliseconds since the first animation frame; the seed is one `Uint32` from `crypto.getRandomValues`; text is `<size>px monospace`. Storage key `pure-snake-best`: **only a string of 1–10 decimal digits with a value of at most 2147483647 counts; anything else, a missing entry, or storage that throws reads as 0; saving never throws.** Click position: `floor((clientX - rect.left - canvas.clientLeft) * canvas.width / canvas.clientWidth)`, same for `y`; the listener is registered only when `onClick` is called.
### M10 additions not listed above: `replay`
- Recording `{seed, actions}`; `frames(rec)` = `history(newGame(seed), actions)`.
- Viewer `{index, last, playing}`; `viewerStart(last)` = `{0, last, false}`. Actions `Next | Prev | First | Last | TogglePlay | Beat`.
- `viewerUpdate`: `Next`/`Prev` move one frame, clamped to `[0, last]`; `First`/`Last` jump; `TogglePlay` flips `playing`, and starting to play at the last frame goes back to frame 0; `Beat` advances one frame when playing; after every action a viewer on the last frame is not playing.
- `viewerKey`: `ArrowRight`/`d`/`D` Next, `ArrowLeft`/`a`/`A` Prev, `Home` First, `End` Last, `' '` TogglePlay.
- `viewerView(frames, viewer)` = `view(frames[index])` + `Text(w - 6, 18, 'step <index> / <last>', '#ffffff', 16, 'right')` + `Text(w/2, h*20 - 8, hint, '#aaaaaa', 12, 'center')`, hint `'Space: pause'` when playing, else `'Left/Right: step Home/End: jump Space: play'`.
- `REPLAY_MS = 100`; `replayActions` = beats merged with keys (beats first); `viewerStates(last, actions)`; `replayMain(rec)` = `pure` of the clamped stream of drawings. No seed and no stored best are asked for.
- `fixtures/replay-sample.json`: seed 1, 226 actions, 19 apples, ends in game over. All three tracks' given glue loads it with `fetch("../fixtures/replay-sample.json")`.
### M11 `pong`
- Constants: `W = 80`, `H = 50`, `PCELL = 8`, `PADDLE = 8`, `PLAYER_X = 2`, `CPU_X = 77`, `WIN = 5`, `FRAME_MS_PONG = 30`.
- State `{ball, vel {dx, dy}, player, cpu, held {up, down}, score {player, cpu}, status Playing | Over, seed, frame}`; `initialPong(seed)`: ball `(40, 25)`, vel `(1, 1)`, both paddles at 21, nothing held, 0–0.
- Actions `Press(key) | Release(key) | Frame | Restart` with key `Up | Down`. `Press`/`Release` set `held` even when the match is over. `Restart` = `initialPong(state.seed)` **with `held` kept**.
- `Frame` while `Playing`, in order: frame + 1; player moves by `held` (both cancel), clamped to `[0, H - PADDLE]`; on an even new frame the cpu moves one cell toward `ball.y - PADDLE/2`, clamped, looking at the ball before it moves; `ny = y + dy`, and if `ny < 0 || ny >= H` then `dy` flips and `ny = y + dy`; `nx = x + dx`, and the ball bounces (`dx` flips, `nx = x`) when `nx == PLAYER_X`, `dx < 0` and `player <= ny < player + PADDLE` (player's new position), or `nx == CPU_X`, `dx > 0` and `cpu <= ny < cpu + PADDLE`; `nx < 0` → cpu scores, `nx >= W` → player scores; a point serves from `(40, 25)` with `dx` toward the side that conceded and `dy = 1` if `nextUint32(seed)` is even else `-1`, and the seed advances; a score of `WIN` sets `Over`.
- `pongKey(isDown, key)`: arrows and `w`/`s` (either case) press/release; `r`/`R` restart on press only. `pongMain` is clamped like `main` and has no stored best.
- `pongView`: background `Rect(0, 0, 640, 400, '#111111')`; paddles `Rect(X*8, y*8, 8, 64, '#ffffff')` (player, then cpu); ball `Rect(x*8, y*8, 8, 8, '#ffeb3b')`; `Text(320, 30, '<player> <cpu>', 24, 'center')`; when over `Text(320, 200, 'You Win!' | 'CPU Wins', 32)` and `Text(320, 230, 'Press R to restart', 16)`.
### M12 `check` (bonus)
- A generator is a `Random<A>`. `elementOf(xs)` = element at `intBelow(length)`. `listOf(gen, n)`: `n` draws threaded left to right; `n <= 0` → `pure([])`.
- `genActions(n)` = `listOf(elementOf(pool), n)` with pool exactly `[Tick, Tick, Tick, Turn Up, Turn Down, Turn Left, Turn Right]`.
- `forAll(gen, prop, runs, seed)`: draws from `seed`, each draw continuing from the previous one's seed; stops at the first failure. Result: passed with `runs`, or failed with `run` (0-based), the `seed` that draw started from, and the `value`. Stack-safe in Scala and F#.
- `removals(xs)`: every list with one element removed, by index. `minimize(xs, fails)`: repeatedly continue with the **first** failing removal; the result is 1-minimal.
- `violations(state)`: the names of broken invariants in this order: `snake-not-empty`, `snake-in-bounds`, `snake-no-overlap`, `snake-connected` (consecutive cells exactly one step apart), `apple-in-bounds`, `apple-not-on-snake`, `turn-not-reversed` (`nextDir != opposite(dir)`), `queued-not-reversed` (nothing queued, or `queued != opposite(nextDir)`).
- `moveGaps(states)`: differences between consecutive indices `i >= 1` at which the head differs from the head at `i - 1`; total on empty snakes.
- `genSnake(width, height, maxLen)`: draw `x = intBelow(width)`, then `y = intBelow(height)`; then, until the snake has `maxLen` cells, one `elementOf` among the in-bounds neighbours of the **last** cell that are not in the snake, listed in the order Up, Down, Left, Right; stop early when there is none (stopping uses no draw). The first cell is the head.
- `genState(width, height)` (given), draws in order: `n = intBelow(width*height)`; the snake `genSnake(width, height, n + 1)`; `nextDir` = `elementOf` of `[Up, Down, Left, Right]` without `opposite(dir)`; a coin `intBelow(2)`: 0 = no apple, 1 = `elementOf(freeCells)` (none if no cell is free). `dir` is the direction from the second cell to the head (`Right` for one cell); seed, score, frame are 0; nothing queued; `Playing`.
- `advanceSpec(state)`: builds the next snake naively, asks `violations` for `snake-in-bounds` / `snake-no-overlap`, and otherwise behaves as `advance`. It must equal `advance` on every sane state and must not call it.
### M13 `life`
- Constants: `LW = 40`, `LH = 30`, `LCELL = 16`, `STRIP = 24`, `GEN_MS = 150`. Canvas 640×504.
- State `{cells, running, seed, generation}`; `cells` is always canonical: on the board, no duplicates, row-major. `initialLife(seed)`: no cells, paused, generation 0.
- `lifeStep(cells)`: B3/S23 judged by the old board only; edges are dead, nothing wraps; the result is canonical.
- `lifeUpdate`: `Frame` → next generation and generation + 1 when running; `Step` → the same when paused; `TogglePause` flips `running`; `Toggle(cell)` flips a cell **whether running or not** (off the board: unchanged; generation unchanged); `Clear` → no cells, generation 0, `running` and seed kept; `Randomize` → one `intBelow(4)` per board cell in row-major order from `state.seed`, alive when the draw is 0, seed after the last draw, generation 0, `running` kept.
- `lifeKey`: `' '` TogglePause, `n`/`N` Step, `c`/`C` Clear, `r`/`R` Randomize. `lifeClick(x, y)`: `Toggle` of `(floor(x/16), floor(y/16))`, or none when the pixel is not on the board (test `x >= 0 && y >= 0` before dividing).
- `lifeActions` = frames, then keys, then clicks (merge order). `lifeMain`: seed, then the clamped stream of drawings; no stored best.
- `lifeView`: `Rect(0, 0, 640, 480, '#111111')`; `Rect(x*16+1, y*16+1, 14, 14, '#7cb342')` per live cell in order; `Rect(0, 480, 640, 24, '#222222')`; `Text(6, 497, 'Generation <n>', '#ffffff', 14, 'left')`; `Text(634, 497, 'Running: Space pauses' | 'Paused: Space runs, N steps', '#ffffff', 14, 'right')`.
- Clicks reach a student only here: Module 13 has them add `onClick` to their runtime (and, in JS, to `browser.js`). Modules 9–12 must pass with a runtime that does not register clicks.
## Pinned values that all tracks must reproduce
| What | Value |
|---|---|
| `nextUint32(0)` | `(1144304738, 1831565813)` |
| `listOf(nextUint32, 3)` from 0 | `[1144304738, 1416247, 958946056]`, seed `1199730143` |
| `newGame(1)` | apple at `(12, 8)`, seed `1831565814` |
| `genSnake(5, 5, 6)` from seed 0 | `(3,2) (3,1) (3,0) (2,0) (1,0) (0,0)`, next seed `4231026099` |
| Double-turn mutant, `forAll(genActions(40), …, 300, 2024)` | shrinks to `[Turn Up or Down, Turn Left]` |
| No-walls mutant, same call | shrinks to ten `Tick`s |
| "Tick forgets the queue" mutant | shrinks to `[Turn Up, Turn Right, Tick, Turn Left]` |
| "Queue accepts a reversal" mutant | shrinks to `[Turn Up, Turn Down]` |
| Puzzle 1 mutant vs `advanceSpec`, 500 states of 5×5, seed 2024 | caught at run 21 |
## Fixtures
Format: `{function, cases: [{name, args, expected}]}`; names are kebab-case sentences. Generated from the JS solutions with throwaway scripts and then read by hand; formatting is `JSON.stringify(x, null, 2)` plus a newline.
| File | Function | Cases |
|---|---|---|
| `m00-sanity` | sanity | 2 |
| `m02-delta`, `m02-opposite`, `m02-point-equals`, `m02-initial-state` | as named | 4, 4, 4, 2 |
| `m03-move`, `m03-in-bounds`, `m03-turn`, `m03-advance` | as named | 5, 7, 8, 12 |
| `m04-random`, `m04-int-below` | `nextUint32`, `intBelow` | 30, 7 |
| `m04-free-cells`, `m04-spawn`, `m04-tick`, `m04-new-game` | as named | 5, 8, 5, 2 |
| `m05-update`, `m05-replay`, `m05-history` | as named | 7, 2, 2 |
| `m06-view` | `view` | 5 |
| `m09-input`, `m10-input` | `keyToAction` | 17, 7 |
| `m10-moves-every`, `m10-update` | `movesEvery`, `update` | 6, 40 |
| `m10-session-update`, `m10-session-view` | as named | 11, 3 |
| `m10-golden` | `play` on `main`; args `[seed, events, best]`, expected = the last drawing's shapes | 3 |
| `m10-viewer-update`, `m10-viewer-key`, `m10-viewer-view` | as named | 18, 14, 3 |
| `m11-pong-update`, `m11-pong-key`, `m11-pong-view` | as named | 49, 21, 4 |
| `m12-violations` | `violations` | 21 |
| `m13-life-step`, `m13-life-update`, `m13-life-key`, `m13-life-click`, `m13-life-view` | as named | 15, 21, 12, 10, 3 |
| `replay-sample` | data, not cases | — |
Changing an existing case is a contract change: say which and why in the build log.
## Checks a change must pass
Run from the repository root; see the README maintainer table for details.
| Command | Must show |
|---|---|
| `scripts/verify.sh <js\|scala\|fsharp>` | whole suite green against the solutions |
| `scripts/check-stubs.sh <track>` | every module M01–M13 and M07b fails against the stubs, no build errors |
| `scripts/check-progression.sh <track>` | every checkpoint passes with solutions up to module N |
| `scripts/typecheck-js.sh` | JS stubs and solutions type-check (needs network for `npx`) |
| `node scripts/mutate.mjs` | no surviving mutant outside `scripts/mutate-allow.txt` |
| `scripts/e2e.sh` | every page of every track draws exactly what the pure JS model says (Playwright; Chromium and WebKit) |
| `node scripts/smoke.mjs <url>` | quick dependency-free check that a page paints |
| `node scripts/check-links.mjs` | every link in the Markdown resolves |
Test counts on 2026-11-11: JS 728, Scala 689, F# 691, end-to-end 176. They differ between tracks because a few tests are track-specific (JS browser-glue and constructor tests, one F# stack test, one JS-only `play` default test).
@@ -49,3 +49,85 @@ Record of how the course was built, the decisions made on the owner's behalf, an
## Environment notes ## Environment notes
- scala-cli installed at `~/.local/bin/scala-cli` (downloads its own JVM 17); dotnet at `~/.dotnet/dotnet`; Fable 5.24.0 via `fsharp/.config/dotnet-tools.json`; headless Chrome at `/usr/bin/google-chrome`. - scala-cli installed at `~/.local/bin/scala-cli` (downloads its own JVM 17); dotnet at `~/.dotnet/dotnet`; Fable 5.24.0 via `fsharp/.config/dotnet-tools.json`; headless Chrome at `/usr/bin/google-chrome`.
- First `scala/build-web.sh` / `fsharp/build-web.sh` run needs network (Scala.js linker / Fable restore). `scripts/serve.mjs` binds 127.0.0.1:8000; run from the repo root. - First `scala/build-web.sh` / `fsharp/build-web.sh` run needs network (Scala.js linker / Fable restore). `scripts/serve.mjs` binds 127.0.0.1:8000; run from the repo root.
---
# Audit and extension pass (2026-11-11)
Owner request: audit the course, fact-check, add sources, knowledge and exercises, test edge cases, extend the project; no questions asked. Decisions below were made on the owner's behalf.
## Behaviour changes (all three tracks, fixtures regenerated)
1. **Frame rule (Module 10).** `frame` now counts frames since the last move and resets on each step (`n >= movesEvery(score)` → `tick` with `frame: 0`). The old rule (`totalFrames % movesEvery == 0`) made the snake take two steps as little as 25 ms apart at every speed-up. The old rule lives on as "Puzzle 3" in Module 10 and as a mutant in the M12 tests. Cost if wrong: `m10-update.json` frame cases were renamed/replaced.
2. **`clampTime(s, maxGap)` + `MAX_GAP_MS = 250` (Module 10, Exercise 10.5; also used by `pongMain`).** A hidden tab stops `requestAnimationFrame`; on return `interval` caught up on the whole gap at once and the snake died unseen. Fixed in pure stream code, not in the browser glue. Module 8's "the game should catch up" text now says catching up has a limit.
3. **Pong `Restart` keeps `held`.** The text claimed a key held across a restart "behaves sensibly"; the code cleared `held`.
## New content
- **Module 12 (bonus), property-based testing**: `check.js` / `Check.scala` / `Check.fs` (`elementOf`, `listOf`, `forAll`, `removals`, `minimize`, `violations`, `moveGaps`), fixture `m12-violations.json`, tests that run the library against three mutants (double-turn, no walls, old frame rule). Scripts, README, reference and setup tables know M12.
- New fixture cases from a mutation analysis of the JS solutions (212 killed, 14 survivors, 6 of them real gaps): Pong first paddle row, ball entering rows 0/49 and columns 0/79, `eating-the-last-free-cell-wins`, JS `deepFreeze` with `null`.
- Challenge questions in Modules 3, 4, 7, 8, 10; new pitfalls in method.md; primary sources added (Wadler, Moggi, Peyton Jones, Kiselyov & Ishii, Steele/Lea/Flood, Lemire, Czaplicki, Elliott, Claessen & Hughes, Zeller, Kleppmann).
## Fact corrections
- Module 4: mulberry32's output step is not a bijection; measured over the full period it emits 1 893 145 848 distinct values (44 %). The modulo-bias remark was reworded accordingly. Haskell `uniformR` → `uniform`.
- Module 7: Haskell's `IO` has no `Suspend` case (laziness does that job); only cats-effect/ZIO do.
- Module 10: "for a game this is invisible" (about uneven steps) was false; removed.
## Verification (2026-11-11)
- `verify.sh`: JS 446, Scala 423, F# 425 tests pass (F# has one extra stack-safety test for `forAll`).
- `check-stubs.sh` and `check-progression.sh`: M01–M12 ok in all tracks.
- `smoke.mjs`: all 6 pages paint with no console errors, built from solutions.
- `check-links.mjs`: all links OK after replacing one dead Moggi URL.
- Not done: a human playing the games; the tab-switch fix is covered by stream tests only.
## Open follow-ups (added)
- Changes are uncommitted in the working tree.
- `Check.fs` is not in `fsharp/web/Web.fsproj` (not needed in the browser).
- A turn queue (honouring Up-then-Left within one step) is still only a question in Module 3.
- Resolved from the earlier list: backwards `Time` is now harmless behind `clampTime`.
---
# Extension pass (2026-11-11, later the same day)
Owner approved eleven extensions; spec: `docs/superpowers/specs/2026-11-11-course-extensions.md` (corrected in place where the build differed). Specs and verification by the orchestrating session; code and text by Opus 5.5 builders: a JS reference builder per wave, then one Scala and one F# porter.
## What was added
1. **Turn queue** (Exercise 10.6): dormant `queued` field in `GameState`; `steer`; every game-state fixture gained `"queued": null`. From Module 10 on, Up-then-Down with Up pending is ignored (Module 3's `turn` alone would replace it).
2. **Persisted high score** (Exercise 10.7): `LoadBest`/`SaveBest` ops, `Platform.loadBest/saveBest` (`localStorage` key `pure-snake-best`), a `Session {game, best, improved}` around the game; `GameState`, `update`, `view` untouched.
3. **Replay viewer** (Exercise 10.8): `replay.*`, pages `replay.html`, sample `fixtures/replay-sample.json` (seed 1, 226 actions, 19 apples), loaded with `fetch` in all tracks.
4. **Stack-safe IO** (optional Module 7b): `safeio.*`, tag `M07b`; it answers only Module 7's two ops and throws for others.
5. **`play(main, seed, events)`** (Exercise 9.5) and golden fixture `m10-golden.json`.
6. **`State`** (optional Exercise 4.7): `state.*`, tests under M04. A student who skips it must copy the solution file to keep M04 green.
7. **Module 12**: `genSnake`, given `genState`, `advanceSpec` as an oracle; invariant `queued-not-reversed`; six mutants in the tests.
8. **`scripts/mutate.mjs`** with `scripts/mutate-allow.txt` (13 reasoned equivalent mutants; exit 0).
9. **JS type checking**: `js/jsconfig.json`, `types.d.ts`, JSDoc; `scripts/typecheck-js.sh` (needs network for `npx`; exits 2 without it).
10. **Module 13, Life with the mouse**: `life/`, `Click` event, `clicks` source, `Platform.onClick`; clicks toggle cells whether running or paused; canvas 640×504.
11. **`scripts/play.mjs`**: scripted browser session over all pages of all tracks.
## Decisions made on the owner's behalf
- Two earlier tests on `main` were loosened (they accept the program with or without `LoadBest`) so a student between Modules 9 and 10 stays green; the exact sequence is pinned by a 10.7 test.
- Click support reaches students in Module 13; M09–M12 pass with a runtime that does not register clicks.
- `./test.sh MNN` in Scala and F# now selects exactly that module (M07 no longer pulls in M07b).
- `smoke.mjs` picks the tab it opened (Chrome sometimes lists an extension page first).
- One builder ran `git checkout` on `fixtures/m00-sanity.json` after its script reformatted it; the file had no pending changes.
## Verification (2026-11-11, final)
- `verify.sh`: JS 728, Scala 689, F# 691 tests pass. Counts differ because a few tests are track-specific (JS browser-glue tests, JS-only constructors, one F# stack test).
- `check-stubs.sh`, `check-progression.sh`: M01–M13 and M07b ok in all tracks.
- `typecheck-js.sh`: stubs and solutions clean (TypeScript 7.0.2; also checked with 5.9.3).
- `mutate.mjs`: 404 mutants, 391 killed, 13 allow-listed, exit 0.
- `smoke.mjs`: 12 pages paint with no errors. `play.mjs`: 66 checks pass on all tracks.
- `check-links.mjs`: all resolve; two okmij.org PDFs timed out once and returned 200 on retry.
## Not verified / open
- No person has played any of it. `play.mjs` cannot see overlay text, the speed-up, the turn queue at real speed, Pong scoring, replay autoplay timing, a real hidden desktop tab, or blocked storage.
- VS Code's own display of the JS type errors was not checked, only `tsc`.
- TypeScript version is not pinned (`TS_PACKAGE` can pin it).
- Scala lacks the JS test "play without a best answers LoadBest with 0".
- Everything is uncommitted in the working tree.
## Playwright end-to-end suite (2026-11-11, owner's suggestion; spec Item 12)
- `e2e/` (own `package.json`, `@playwright/test` 1.65.0 pinned) and `scripts/e2e.sh`; `scripts/play.mjs` deleted, `smoke.mjs` kept. The course tracks stay dependency-free.
- Technique: fixed seed (stubbed `crypto.getRandomValues`), fake clock driving `requestAnimationFrame`, recorded `fillRect`/`fillText` calls; every frame a page draws is compared with the pure JS model for the same inputs, for all three tracks on Chromium and WebKit.
- Result: 176 tests pass in about 20 s. No frame of any track differs from the model.
- Found and fixed: the three tracks read an unusual stored best differently (`"1e2"`, `"7.0"`, `"2147483648"`). Ruling: only plain decimal digits with a value of at most 2147483647 count; anything else reads as 0. Applied to `js/solutions/browser.js`, `scala/web/BrowserPlatform.scala`, `fsharp/web/BrowserPlatform.fs`.
- Still not covered: real animation timing and real hidden-tab throttling (the clamp is tested with a fake 10 s frame), rendered pixels and fonts, zoom, touch, key auto-repeat, Firefox (no build installed), and a person playing.
@@ -0,0 +1,30 @@
# Audit pass — spec, written after the fact (2026-11-11)
**Status: built and verified.** This pass was done without a written spec (the owner asked for an audit with no questions). This document records what was decided, so that it can be read like the other specs. The current rules are in `docs/contract.md`; the later extension pass is in `2026-11-11-course-extensions.md`.
## Request
Audit the finished course: find improvements, fact-check, add sources, knowledge and exercises, test edge cases, extend the project.
## Findings and the decision for each
| # | Finding | Decision |
|---|---|---|
| 1 | **Uneven steps at every speed-up.** `Frame` stepped when the *total* frame count was a multiple of `movesEvery(score)`. When the interval changes, the next multiple can be one frame away: two moves 25 ms apart. The text called this invisible. | `frame` counts frames since the last move and resets on each step (`n >= movesEvery(score)`). The old rule became "Puzzle 3" in Module 10 and a mutant in the Module 12 tests. `m10-update.json` frame cases were replaced. |
| 2 | **A hidden tab killed the snake.** `requestAnimationFrame` stops in background tabs; on return `interval` emitted every missed beat at once. Module 8 said that catching up was wanted. | New pure combinator `clampTime(s, maxGap)` and `MAX_GAP_MS = 250` (Exercise 10.5), wrapped around `main` and `pongMain`. Fixed in stream code, not in the browser glue, so it is testable and identical across tracks. Module 8's text now says catching up has a limit. |
| 3 | **Pong restart cleared held keys**, although the text said a key held across a restart behaves sensibly. | `Restart` keeps `held`. Fixture updated; new case `restart-keeps-the-held-keys`. |
| 4 | **mulberry32's output is not one-to-one.** Measured over the full period: 1 893 145 848 distinct values (44 %). | Stated in Module 4; the modulo-bias remark was reworded. |
| 5 | Module 7 said Haskell's `IO` has a `Suspend` case. | Corrected: only cats-effect and ZIO do; Haskell is lazy. |
| 6 | Module 4 cited `uniformR :: g -> (a, g)`. | Corrected to `uniform`. |
| 7 | **Test gaps.** A mutation analysis of the JS solutions (212 killed, 14 survived) showed six real gaps. | New fixture cases: Pong first paddle row, ball entering rows 0/49 and columns 0/79, `eating-the-last-free-cell-wins`; JS test for `deepFreeze` with `null`. |
## New material
- **Module 12 (bonus), property-based testing:** `elementOf`, `listOf`, `forAll`, `removals`, `minimize`, `violations`, `moveGaps`; fixture `m12-violations.json`; tests that run the library against deliberately broken games.
- Challenge questions in Modules 3, 4, 7, 8, 10; three pitfalls in `method.md`; primary sources in several modules.
## Rules followed
- JS first, then Scala and F# ported from it, each porter confined to its track directory.
- Fixtures regenerated from the JS solution and read by hand.
- No Scala bridging in the course text (owner's rule of 2026-10-29).
## Verification at the end of this pass
JS 446, Scala 423, F# 425 tests; stubs and progression M01–M12 ok; six pages painted; 128 links resolved. (Superseded by the extension pass; current numbers are in the build log and the contract.)
@@ -0,0 +1,142 @@
# Course extensions — spec (2026-11-11)
**Status: all twelve items built and verified on 2026-11-11.** Where the build differed from the first draft, the text below was corrected in place. The rules as they now stand are in `docs/contract.md`; results and decisions are in `docs/superpowers/notes/2026-10-29-build-log.md`. Known leftovers in the wording: Item 9's command is `tsc -p js/jsconfig.json` (not `-p js`); Item 11's `scripts/play.mjs` was built and then replaced by Item 12; Item 9's event type is called `Input` in JS.
Eleven additions to the Pure Snake course, approved by the owner on 2026-11-11. This document is the contract for the builders. Where it says "builder's call", decide, and list the decision in your report.
Read first: `README.md` ("For maintainers"), `docs/superpowers/notes/2026-10-29-build-log.md` (both sections), one finished module end to end (`course/10-extensions.md`) and its code in all places (`js/src`, `js/solutions`, `js/test`, `fixtures`).
## Rules for every builder
- **Layout.** Student stubs in `<track>/src`, finished reference in `<track>/solutions`, same file names. A stub compiles, carries the doc comment that specifies behaviour, and fails per test (`todo('mNN')`, `???`, `failwith "TODO mNN"`; in F# use unit functions where a module-level value would fail at load). Tests in `js/test/mNN-*.test.js`, `scala/test/MNN*.test.scala`, `fsharp/tests/MNN*.fs` (+ fsproj entries; `src/Snake.fsproj` and `solutions/Snake.fsproj` stay identical).
- **Fixtures.** Pure-logic cases live in `fixtures/mNN-<function>.json` as `{function, cases:[{name, args, expected}]}`, generated from the JS solution with a throwaway script and then read by hand. All three tracks read the same file. Case names are kebab-case sentences.
- **Purity.** Outside the runtime/browser files: no `let`/`var`/mutation/loops with counters/`Math.random`/clock/DOM (the purity tests of each track enforce it). Recursion and folds instead.
- **JS is the reference track.** A JS builder writes JS stubs, solutions, tests, fixtures and the course text. Port builders port to Scala or F# and touch only their track's directory.
- **Course text** (`course/*.md`): explain why before how; assume general programming experience and **no** FP or Scala background; never explain by reference to Scala; connect ideas to everyday programming in blockquotes starting `> **From everyday programming:**`. Signatures in all three languages. Every coding exercise gets three folded hints (nudge, stronger hint, near-solution skeleton with `___` holes — never the full solution), a "Tests"/"Proved by" list naming the JS test titles and fixture cases, and "Check your understanding" questions. Plain, direct prose; British spelling as in the existing text. Update `course/reference.md`, the README module map/checkpoint table, `course/00-setup.md` tables and `scripts/check-progression.sh` (`files_for`) when files or modules are added. External links must resolve (`node scripts/check-links.mjs`).
- **Signature conventions.** JS: data first, plain objects with `tag`. Scala: methods/objects in `package snake`, `Option`, `List`, `Long` for seeds and time. F#: data last, `bind`/`choose`, PascalCase fields, `uint32` seeds, `int64` time.
- **Verification before you report** (from the repo root): `scripts/verify.sh <track>`, `scripts/check-stubs.sh <track>`, `scripts/check-progression.sh <track>` for every track you touched; `node scripts/check-links.mjs --offline` (and online if you added links) if you touched text. For browser-facing changes: copy the repo to a temp dir, replace `src` with `solutions`, run `build-web.sh` (Scala/F#), serve with `node scripts/serve.mjs` and run `node scripts/smoke.mjs <url>` for each page.
- **Do not** commit, stash, reset or checkout. Do not edit files outside your stated scope; other builders are working in the tree at the same time. Do not weaken or delete an existing test to make something pass; if an existing test contradicts this spec, change it deliberately and report it.
- **Report:** files created/changed, final script results with test counts, every decision you made that this spec left open, and anything in the spec you found wrong or ambiguous.
---
## Item 1 — Turn queue (Module 10, new Exercise 10.6)
**Why.** Module 3 admits that Up-then-Left within one step loses the second key. Fix it as an extension, without changing the Module 3 rules.
**Model.** `GameState` gets one more field, dormant until Module 10 like `frame`: `queued` — JS `Direction | null`, Scala `Option[Direction]`, F# `Direction option` (`Queued`). `initialState` sets it to none. Add `"queued": null` to every game state in every existing fixture with a script; existing expectations otherwise stay as they are unless they contradict the semantics below (report each such case).
**Semantics** (in the game module, used by `update`; `turn`, `advance`, `tick` of Modules 3–4 are unchanged and ignore `queued`):
- `steer(state, dir)` — what `update` does for `Turn(dir)` from Module 10 on:
- not `Playing` → unchanged;
- no turn pending (`nextDir == dir`) → `turn(state, dir)` (the Module 3 rule);
- a turn is pending (`nextDir != dir`) → if `dir` is neither `nextDir` nor its opposite, the state with `queued = dir` (a later key replaces an earlier queued one); otherwise unchanged.
- After every game step taken by `update` (the `Tick` case and the stepping branch of `Frame`): if the result is `Playing` and `queued` is set, then `nextDir = queued` and `queued` = none. If the result is not `Playing`, `queued` is left as it is. `Restart` gives a state with nothing queued.
- Result: Up then Left within one step makes the snake go Up on the next step and Left on the one after.
**Tests.** New `m10-update.json` cases covering each branch above, plus a non-fixture test that plays "Turn Up, Turn Left, step, step" and checks the head's path. Module 5's "every other action unchanged"/`Turn` cases must still pass with the finished `update`.
**Module 12.** Add invariant `queued-not-reversed` (queued is none, or differs from the opposite of `nextDir`) after `turn-not-reversed`; extend `m12-violations.json`; `genActions` unchanged.
**Text.** Module 2 (field list, "ten fields" → eleven, canonical model, fixtures note), Module 3 (the question about the queue now points to Exercise 10.6), Module 10 (new section + exercise + hints + questions), reference.
## Item 7 — Random states and a model-based test (Module 12, Exercises 12.6–12.7)
**Why.** Module 12 says invariants cannot see the wrong collision check of Puzzle 1. Close that.
- `genSnake(width, height, maxLen)` : `Random` of a snake (head first): a random start cell, then up to `maxLen - 1` further cells, each chosen with `elementOf` among the in-bounds neighbours of the last cell that are not yet in the snake; stop early when there is none. (Exact draw order is part of the contract so that tracks agree; write it in the stub doc.)
- `genState(width, height)` (given, built from `genSnake`): a sane `Playing` state: snake from `genSnake`, `dir` consistent with the first two cells (any fixed choice for a one-cell snake), `nextDir` = `dir` or a perpendicular direction, apple = none or a free cell, `queued` none.
- `advanceSpec(state)` : the step written straight from the rules, in a different style from Module 3's `advance`: build the snake the step would produce (new head in front; drop the last cell unless eating), then ask `violations` whether that snake is on the board and free of overlap; if not, game over as Module 3 specifies. It must agree with `advance` on every state.
- Tests: fixture or seed-pinned tests for `genSnake`; every generated state has no violations; `advance` and `advanceSpec` agree on 500 generated states of a 5×5 board; a mutant `advance` with Puzzle 1's wrong check ("new head must not be on the old snake") is caught by that comparison.
- Text: new sections in Module 12 ("generating states", "a second implementation as an oracle"), rewrite the bullet that says invariants cannot see Puzzle 1, question 6 now refers to the exercise.
## Item 6 — `Random` is `State` (Module 4, optional Exercise 4.7)
New file `state.js` / `State.scala` / `State.fs`: `State<S, A>` = `S -> (A, S)` with `pure`, `map`, `flatMap` (`bind`), `get`, `put`, `modify`, `run`. Tests (`m04-state`): the laws on small examples; a counter example; and "`Random` is `State` with the seed as `S`": the dice of Module 4 written with `State` give the same values and seeds as with `Random`. Nothing in the game changes to use it. Text: a "Going further" section in Module 4 after "A pattern with a name", exercise 4.7 marked optional, hints, two questions (one asks the reader to express a label counter or the Pong serve with `State`). Progression mapping: with Module 4's files.
## Item 4 — Stack-safe `IO` (optional Module 7b)
New text `course/07b-stack-safe-io.md` and new file `safeio.js` / `SafeIO.scala` / `SafeIO.fs`, independent of the game and of `io`: an `IO` with three constructors (`Pure`, `Effect(op, k)`, `FlatMap(io, f)`), where `flatMap` only builds a `FlatMap` node, and an interpreter written as a loop (in the purity-audited file a loop is not allowed: write it as a tail-recursive function in Scala/F#; in JS put the interpreter where a `while` is permitted or — builder's call — express it with a trampoline; say which). Provide `pure`, `effect`, `map`, `flatMap`, `sequence`, a pure `trace`-like interpreter that does not grow the stack, and the re-association step explained in the text. Tests (`m07b-*`, module tag `M07b`; make `test.sh M07b`, check-stubs and check-progression understand it — builder's call how, e.g. number it so the scripts' loops find it): the three monad laws observationally; 100 000 left-nested `flatMap`s and 100 000 right-nested ones both run; traces equal those of Module 7's `IO` on the same programs. Text: why left nesting is quadratic and deep for the simple `IO`, the trampoline idea, the re-association rule as a picture, what production libraries add. Link from Module 7 section 12 and the README map (marked optional).
## Item 8 — Mutation analysis as a maintainer script
`scripts/mutate.mjs [file...]`: single-token mutants of `js/solutions` files run against the JS suite in a temp copy; prints killed/survived and each survivor with file, line, original and mutated text; exits 1 when there are survivors not listed in an allow-list file of known equivalent mutants (`scripts/mutate-allow.txt`, with a reason per line). Start from the prototype at `/tmp/claude-1000/-home-user-functional-programming-course/9bf00381-a84f-4bd0-87b0-ffcbdbf94782/scratchpad/mutate.mjs`. Add a row to the README maintainer table and CLAUDE.md's list of checks is left alone.
## Item 2 — A persisted high score (Module 10, Exercise 10.7, replaces the thought exercise)
**Effects.** Two new `Op`s: `LoadBest` (answer: a number) and `SaveBest(score)` (answer: unit). Scala/F#: `OpResult` gets `Best(value: Int)`. `io`: `loadBest` (a value; F# `loadBest ()`), `saveBest(n)`. `trace` must answer `LoadBest`: JS `trace(io, seed, best = 0)`, Scala a default argument, F# `traceWith seed best io` with `trace seed = traceWith seed 0`. `Platform` gets `loadBest()` and `saveBest(n)`; the browser implementations use `localStorage` key `pure-snake-best`, treat anything unreadable as 0 and never throw; `runIO` performs them; the fake platforms in the tests record them.
**State.** `GameState`, `update` and `view` are **not** changed. A new value wraps the game: `Session = {game, best, improved}`. `sessionUpdate(session, action)`: `game' = update(game, action)`, `best' = max(best, game'.score)`, `improved = best' > best`. `sessions(seed, best, actions)` = `startWith(scan(actions, sessionUpdate, s0), s0)` with `s0 = {game: newGame(seed), best, improved: false}`.
**Output.** `sessionView(session)` = `view(game)` followed by one text shape `Best: <n>` (right-aligned at the top right; exact numbers builder's call, fixed by a fixture). `renderSession(session)` = `drawScene(sessionView(session))`, followed by `saveBest(best)` when `improved`. `main` = ask for the seed, then load the best, then the clamped stream of `renderSession` over `sessions(seed, best, speedActions)`.
**Tests.** Module 7/9/10 tests that pin `main`'s ops change from `[RandomSeed]` to `[RandomSeed, LoadBest]`: update them deliberately. `classicMain` in the test helpers stays the Module 9 program without the session. New tests: the interpreters handle the new ops; a traced `renderSession` contains `SaveBest` exactly when `improved`; a scripted run that beats the record saves once per improvement; the runtime with a fake platform loads once at start.
**Text.** Section 7 of Module 10 becomes a built feature: keep the six design questions as the lead-in, then the canonical design above, exercise, hints, questions. Module 7's "adding an effect is adding an `Op`" now points to it.
## Item 5 — The whole program in one equality (Module 9, Exercise 9.5)
A pure helper in the app module: `play(main, seed, events)` (after Item 2: also the stored best; JS default 0, Scala default argument, F# explicit) returns, for each drawing the program emits while fed `events`, the list of ops it performs (`trace` of `main`, `run` of the stream, `trace` of each emitted IO). Tests: `play` on `classicMain` equals the scenes of the expected states for a short scripted game; a golden fixture `m10-golden.json` (built under that name because the final `main` only exists after Module 10) holds a seed, a list of events and the expected **final** scene, for `main`. Text: a short section in Module 9 ("testing the program end to end without a browser"), exercise, hints.
## Item 3 — A replay viewer (Module 10, Exercise 10.8)
**Why.** Module 5 promises replays and nothing shows one.
- A recording is `{seed, actions}`. `fixtures/replay-sample.json` holds one worth watching (the snake eats several apples and then dies; produce it with a small bot script and keep it under ~400 actions; Tick/Turn only).
- Pure `viewer` module (`replay.js` / `Replay.scala` / `Replay.fs`): `frames(recording)` = `history(newGame(seed), actions)`; viewer state = index into the frames; keys: `ArrowRight`/`d` next, `ArrowLeft`/`a` previous, `Home` first, `End` last, Space toggles autoplay (advance one frame every 100 ms from `interval` while on; stops at the end); `viewerUpdate(viewerState, viewerAction)` is total and clamps at both ends; `viewerView` = the game's `view` of the current frame plus a `step i / n` text and a hint line; `replayMain(recording)` : `IO<Stream<IO<unit>>>` (no seed needed, so it starts with `pure`).
- Pages: `js/replay.html` (+ `src/replay-main.js`, given), `scala/replay.html`, `fsharp/replay.html` with given browser glue and build-script changes; the given glue of all three tracks loads the sample recording with `fetch("../fixtures/replay-sample.json")` (as built; an earlier draft had Scala/F# embed a generated value).
- Tests `m10-replay`: `viewerUpdate` fixture; autoplay via `run` on scripted events; `replayMain` traced; stepping back shows exactly the earlier scene.
- Text: Module 10 section + exercise + hints; Module 5's "Replays" bullet points forward to it. README quick start mentions the page.
## Item 10 — Second capstone: Conway's Life with the mouse (Module 13)
**Why.** Pong is close to Snake. Life forces a new kind of input.
- **New event:** `Click(x, y)` in canvas pixels. Stream source `clicks`. `Platform.onClick(cb)`; runtime feeds it; browser glue listens for `click` on the canvas and converts to canvas coordinates. Existing sources ignore it. This touches `stream`, `runtime`, `browser` in all tracks: keep every existing test green.
- **Game** in `life/` (`Life/` in F#): board 40×30 cells of 16 px; state `{cells (set of live points, kept as a sorted list, row-major, no duplicates), running, seed, generation}`; actions `Frame` (when running: next generation every 150 ms), `Toggle(cell)` from a click (as built: always, running or paused; a click that is silently ignored looks broken), `TogglePause` (Space), `Step` (`n`, one generation while paused), `Clear` (`c`), `Randomize` (`r`: each cell alive with probability about 1/4 drawn from the seed; seed advances). Classic rules B3/S23, edges are dead (no wrap). `lifeView`, `lifeKey`, `lifeActions`, `lifeStates`, `lifeMain` (clamped like the others).
- Fixtures `m13-life-step.json` (blinker, block, glider, edge cases, empty), `m13-life-update.json`, `m13-life-view.json`, `m13-life-key.json`; tests `M13`. Pages `life.html` per track with given glue.
- Text `course/13-capstone-life.md`: the stateful sketch to convert, the rules precisely, the method step by step with questions first and the canonical choice after (as Module 11 does), lighter hints (nudge + stronger hint only), a section on why the next generation is a pure function of the whole board and what goes wrong when the usual in-place loop updates cells one by one. README, setup, reference, progression script, method.md's "next projects" list.
## Item 9 — Type checking for the JavaScript track, with no install
`js/jsconfig.json` with `checkJs`, strict null checks, ES modules; shared types in `js/src/types.d.ts` (Point, Direction, Status, Action, GameState, Shape, Op, IO, Event, Stream, Random, Platform, Session, Pong and Life types); JSDoc on exported functions in **stubs and solutions** referring to them. Goal: an editor with TypeScript support (VS Code out of the box) shows errors, including a non-exhaustive `switch` over `Action` (use the `never` trick in the text, not in the given stubs). No dependency is added to the course: the optional command is `npx -y -p typescript tsc -p js --noEmit`. `scripts/typecheck-js.sh` runs it against a temp copy with solutions as `src` and must pass; if `npx` has no network, it exits 2 with a clear message. Text: a short optional section in Module 0 ("if your editor understands types") and a paragraph in Module 2 where exhaustiveness is discussed, replacing "in JS you are the type checker" with the more precise statement. The purity test must ignore `.d.ts`.
## Item 11 — An automated play session
`scripts/play.mjs <base-url>` (Chrome DevTools protocol, zero dependencies, same style as `smoke.mjs`): for each track's Snake page: load, check the first paint, press keys and check that the picture changes, press Space and check that it stops changing, press R; simulate a hidden tab if CDP allows (`Emulation.setCPUThrottlingRate` or `Page.setWebLifecycleState`; otherwise skip and say so) and check that the game is still alive afterwards; for Pong, Life and the replay viewer: load, interact once, check a change. Prints one line per check; exit 1 on any failure. README maintainer row. This does not replace a person playing; the report must say what it could not check.
---
## Order of work
Wave 1 (parallel, disjoint files): **A** = Items 1 + 7 (JS, fixtures, text) · **B** = Items 6 + 8 · **C** = Item 4.
Wave 2: **D** = Items 2 + 5 (JS, fixtures, text).
Ports 1: Scala and F# builders port Items 1, 7, 6, 4, 2, 5.
Wave 3: **E** = Items 3 + 10 (JS, fixtures, text), then ports 2 (Scala, F#).
Wave 4: **F** = Items 9 + 11.
---
## Item 12 — End-to-end tests with Playwright (replaces `scripts/play.mjs`)
Suggested by the owner after Item 11. Maintainer tooling only: the three course tracks stay free of dependencies.
**Layout.** `e2e/` at the repo root: `package.json` (private; one dev dependency, `@playwright/test`, pinned to the exact version installed here), `playwright.config.mjs`, `tests/*.spec.mjs`, `lib/` helpers. `e2e/node_modules`, reports and traces are git-ignored. `scripts/e2e.sh [--tracks js,scala,fsharp] [-- <playwright args>]` builds a temp copy with solutions as `src` (running `build-web.sh` for Scala/F# when those tracks are selected), serves it with `scripts/serve.mjs` on a free port, runs the tests against it and cleans up; exit code is Playwright's. Projects: Chromium and WebKit (Firefox too if a build is installed; never download browsers implicitly — if a browser is missing, skip that project with a clear message).
**Three techniques every test builds on** (put them in `e2e/lib`, each proven by its own small test):
1. **Fixed seed.** An init script replaces `crypto.getRandomValues` so the page's seed is a known number.
2. **Fake clock.** `page.clock` drives `requestAnimationFrame`, so time advances only when the test says so. Confirm on all three tracks' glue that the timestamp the page receives really is the fake one.
3. **Recorded drawing.** An init script wraps the canvas 2D context's `fillRect` and `fillText` and records each call with the fill style, font and alignment in force, marking where each frame starts. A test can then read the last complete frame as a list of shapes.
**The oracle.** The tests import the pure JS solutions (`js/solutions/*.js`) in Node and compute what the page must draw: the last recorded frame must equal the model's scene (`sessionView`, `pongView`, `viewerView`, `lifeView`) for the same seed and the same events. This is a differential test of each track's browser build against the pure model; it is what replaces pixel-guessing.
**Checks, for each track and each browser:**
- Snake: first frame equals the new session's scene; one step every 200 ms of fake time at score 0; a scripted game (keys at chosen fake times, enough to eat at least three apples with the fixed seed) matches the model after every key and at the end, including the faster step after the third apple; Up then Left inside one step gives Up then Left (turn queue); Space shows `Paused` and freezes the scene, Space resumes; a crash shows `Game Over`, R restarts; a 10 s jump of the fake clock in one frame advances the game by 250 ms only; the best score is saved on improvement, shown after reload, and a page whose `localStorage` throws or holds garbage still runs and shows `Best: 0`.
- Pong: holding and releasing keys moves the paddle as the model says; a whole match on the fake clock ends with the model's score and the right winner text; R restarts and keeps a held key.
- Replay: Right/Left/Home/End match the model's frames; autoplay advances one frame per 100 ms of fake time and stops on the last frame.
- Life: clicks on a CSS-scaled canvas with a border toggle the cells the model says (click at cell centres and near cell edges); N, Space (one generation per 150 ms), C and R (with the fixed seed: exactly the model's cells) match the model.
- No page errors or console errors anywhere.
**Retire `scripts/play.mjs`** (delete it; `scripts/smoke.mjs` stays as the dependency-free quick check) and update the README maintainer table, CLAUDE.md is left alone.
**Report** what the suite still cannot check and what a person still has to try.
+186
View File
@@ -0,0 +1,186 @@
// The test fixture every spec uses: one page of one track, with the fixed seed, the paused fake clock and the drawing
// recorder installed, a Model fed exactly the inputs the page received, and a check that compares the two.
import { test as base, expect } from '@playwright/test'
import { fixSeed, SEED } from './seed.mjs'
import { FakeClock } from './clock.mjs'
import { recordDrawing, framesFrom, canvasColors, pageShape, expectedShape } from './drawing.mjs'
import { Model, programs } from './oracle.mjs'
/** The tracks under test: E2E_TRACKS=js,scala,fsharp (scripts/e2e.sh sets it from --tracks). */
export const TRACKS = (process.env.E2E_TRACKS ?? 'js,scala,fsharp').split(',').filter(Boolean)
// Playwright's key names for the DOM `key` values the programs listen to.
const KEY_NAMES = { ' ': 'Space' }
const keyName = (key) => KEY_NAMES[key] ?? key
const describeShape = (s) => (s ? JSON.stringify(s) : 'nothing')
export class App {
constructor(page, track, name, { seed = SEED, best = 0 } = {}) {
this.page = page
this.track = track
this.name = name
this.seed = seed
this.best = best
this.clock = new FakeClock(page)
this.colors = new Map()
this.errors = []
page.on('pageerror', (e) => this.errors.push(`page error: ${e.message}`))
page.on('console', (m) => { if (m.type() === 'error') this.errors.push(`console error: ${m.text()}`) })
}
get url() {
return `/${this.track}/${programs[this.name].page}`
}
/** Install the helpers and open the page; nothing is drawn until start(). */
async open() {
await fixSeed(this.page, this.seed)
await recordDrawing(this.page)
await this.clock.install()
await this.page.goto(this.url)
}
/** Run the clock to the page's first frame and set up a fresh Model with `best` as the stored best. */
async start({ best = this.best } = {}) {
this.best = best
this.model = new Model(this.name, { seed: this.seed, best })
this.seen = 0 // page frames already compared with the model
this.lastPageFrame = null
const first = await this.clock.start(async () => {
const [f] = await framesFrom(this.page, 0)
return f ? f.at : null
})
this.model.feed(first)
return this
}
/** Reload the page (its storage stays), then start again with `best` as the best the page should load. */
async reload({ best } = {}) {
await this.page.reload()
return this.start({ best })
}
get state() {
return this.model.state
}
async advance(ms) {
this.model.feed(await this.clock.advance(ms))
}
/** Advance one animation frame at a time until `done(state)` holds in the model (checked by peeking ahead), and
* stop on the first frame where it does. Returns the fake ms that passed. */
async advanceUntil(done, limitMs = 60000) {
const from = this.clock.now
const ms = (() => {
for (let n = 1; n * 16 <= limitMs; n++) {
if (done(this.model.peek(this.clock.timesBetween(from, from + n * 16)))) return n * 16
}
throw new Error(`advanceUntil: the model never got there within ${limitMs} ms`)
})()
await this.advance(ms)
return ms
}
async jump(ms) {
this.model.feed(await this.clock.jump(ms))
}
async press(key) {
await this.page.keyboard.press(keyName(key))
this.model.feed([{ tag: 'KeyDown', key }, { tag: 'KeyUp', key }])
}
async down(key) {
await this.page.keyboard.down(keyName(key))
this.model.feed([{ tag: 'KeyDown', key }])
}
async up(key) {
await this.page.keyboard.up(keyName(key))
this.model.feed([{ tag: 'KeyUp', key }])
}
/**
* A real mouse click on canvas pixel (px, py), wherever and however large the page shows the canvas: the window
* position is the middle of that pixel, through the canvas's bounding box, border and CSS scale. The model is fed a
* Click at (px, py), so the page's own conversion from window to canvas coordinates is what is under test.
*/
async click(px, py) {
const box = await this.page.locator('canvas').evaluate((c) => {
const r = c.getBoundingClientRect()
return { left: r.left + c.clientLeft, top: r.top + c.clientTop, sx: c.clientWidth / c.width, sy: c.clientHeight / c.height }
})
await this.page.mouse.click(box.left + (px + 0.5) * box.sx, box.top + (py + 0.5) * box.sy)
this.model.feed([{ tag: 'Click', x: px, y: py }])
}
async color(c) {
if (!this.colors.has(c)) this.colors.set(c, (await canvasColors(this.page, [c]))[0])
return this.colors.get(c)
}
/**
* The check every test makes: every frame the page has drawn since the last check equals, shape by shape and in
* order, what the model drew for the corresponding input; and the page has drawn exactly as many frames as the
* model. Returns the page's last frame (shapes normalised for comparison).
*/
async expectInSync(label = '') {
const fresh = await framesFrom(this.page, this.seen)
const modelFrames = this.model.frames
const colors = [...new Set(modelFrames.slice(this.seen).flatMap((f) => f.shapes.map((s) => s.color)))]
await Promise.all(colors.map((c) => this.color(c)))
const color = (c) => this.colors.get(c)
const where = `${this.track} ${this.name}${label ? `, ${label}` : ''}`
for (const [i, frame] of fresh.entries()) {
const n = this.seen + i
const want = modelFrames[n]
const got = frame.shapes.map(pageShape)
const t = frame.at - this.clock.t0
if (!want) {
throw new Error(`${where}: the page drew frame #${n} (at ${t} ms) but the model drew only ${modelFrames.length} frames. ` +
`Page frame: ${got.length} shapes, first ${describeShape(got[0])}`)
}
const exp = want.shapes.map((s) => expectedShape(s, color))
const j = exp.findIndex((s, k) => JSON.stringify(s) !== JSON.stringify(got[k]))
if (j >= 0 || exp.length !== got.length) {
const k = j >= 0 ? j : Math.min(exp.length, got.length)
throw new Error(`${where}: frame #${n} (page at ${t} ms; model input ${JSON.stringify(want.input)}) differs at shape ${k}:\n` +
` page drew: ${describeShape(got[k])}\n model says: ${describeShape(exp[k])}\n` +
` (page ${got.length} shapes, model ${exp.length})`)
}
}
this.seen += fresh.length
if (fresh.length > 0) this.lastPageFrame = fresh[fresh.length - 1].shapes.map(pageShape)
expect(this.seen, `${where}: frames drawn by the page vs by the model`).toBe(modelFrames.length)
return this.lastPageFrame
}
/** The texts of the page's last checked frame. */
texts() {
return (this.lastPageFrame ?? []).filter((s) => s.tag === 'Text').map((s) => s.text)
}
/** Model shapes normalised the way the page's are, for direct comparisons with a scene built in the test. */
async expected(scene) {
await Promise.all(scene.map((s) => this.color(s.color)))
return scene.map((s) => expectedShape(s, (c) => this.colors.get(c)))
}
}
/** `app(track, name, options)` opens a page and returns its App; the test fails if the page logged any error. */
export const test = base.extend({
app: async ({ page }, use) => {
const apps = []
await use(async (track, name, options) => {
const app = new App(page, track, name, options)
apps.push(app)
await app.open()
return app
})
for (const app of apps) expect(app.errors, `${app.track} ${app.name}: page and console errors`).toEqual([])
},
})
export { expect }
+91
View File
@@ -0,0 +1,91 @@
// Technique 2: a fake clock.
// Playwright's page.clock replaces requestAnimationFrame, performance.now, Date and the timers in the page. Paused,
// it moves only when a test calls runFor or fastForward, so the game sees exactly the time the test chooses.
//
// How fake time becomes the model's `Time t` events (worked out from Playwright 1.65's clock and the three glues):
// - The fake clock counts integer milliseconds ("ticks"). A requestAnimationFrame callback is due at the next
// multiple of 16 ticks after the request (`16 - ticks % 16` later) and receives the tick count as its timestamp.
// - Each track's glue (js/solutions/browser.js, scala/web/BrowserPlatform.scala, fsharp/web/BrowserPlatform.fs)
// remembers the timestamp T0 of its first callback and feeds `Time(T - T0)` for every callback at tick T, and asks
// for the next frame inside the callback (so the next one is due 16 ticks later). Scala and F# truncate to a whole
// number; fake timestamps are whole numbers already, so all three see the same values.
// - Hence: the first frame is Time 0, and from a moment at tick N, `runFor(ms)` fires one Time(T - T0) for every
// multiple of 16 T in (N, N + ms], in order; `fastForward(ms)` fires the single pending frame once, at N + ms
// (Time(N + ms - T0)) — that is how a test makes one long frame, like a tab that was hidden.
// - Keys and clicks sent between two clock calls reach the page after every frame up to the current tick, so the
// model is fed them in that order.
// This class mirrors that arithmetic and returns, for each move of the clock, the exact list of Time events the page
// received; the oracle is fed those. The technique test (tests/techniques.spec.mjs) checks the mapping against the
// timestamps of the recorded frames on every track, so a change in Playwright's clock shows up there first.
/** Interval of Playwright's fake requestAnimationFrame, in ms. */
export const FRAME = 16
export class FakeClock {
constructor(page) {
this.page = page
this.now = null // current tick, known after start()
this.t0 = null // tick of the page's first animation frame
}
/** Install a paused clock for every document the page loads from now on. Call before page.goto. */
async install() {
await this.page.clock.install({ time: new Date('2026-01-01T00:00:00Z') })
await this.page.clock.pauseAt(new Date('2026-01-01T00:00:01Z'))
}
/** The tick the page's clock shows now. */
ticks() {
return this.page.evaluate(() => performance.now())
}
/**
* Step the clock one frame at a time until the page has drawn its first frame (`drawn()` returns that frame's tick,
* or null while nothing is drawn). Before that, the page may still be loading its script or the replay viewer its
* recording; frames that fire before the program asked for one do not exist for it. Returns [Time 0].
*/
async start(drawn, tries = 200) {
this.now = await this.ticks()
for (let i = 0; i < tries; i++) {
const at = await drawn()
if (at !== null) {
if (this.now !== at) throw new Error(`fake clock: expected to stop on the first frame (tick ${at}), but it shows ${this.now}`)
this.t0 = at
return [{ tag: 'Time', t: 0 }]
}
// Up to the next multiple of 16, where a frame fires if the program has asked for one.
const step = FRAME - (this.now % FRAME)
await this.page.clock.runFor(step)
this.now += step
}
throw new Error('the page drew nothing within 200 animation frames of fake time')
}
/** The Time events the page receives while the clock runs from tick `from` to tick `to` frame by frame. */
timesBetween(from, to) {
const first = Math.floor(from / FRAME) + 1
const last = Math.floor(to / FRAME)
return Array.from({ length: Math.max(0, last - first + 1) }, (_, i) => ({ tag: 'Time', t: (first + i) * FRAME - this.t0 }))
}
/** Let `ms` of fake time pass, one animation frame every 16 ms. Returns the Time events the page received. */
async advance(ms) {
const events = this.timesBetween(this.now, this.now + ms)
await this.page.clock.runFor(ms)
this.now += ms
return events
}
/** Advance to the next tick that is a multiple of 16 after `this.now + ms` (inclusive), so a frame fires there. */
async advanceToFrameAfter(ms) {
const target = Math.ceil((this.now + ms) / FRAME) * FRAME
return this.advance(target - this.now)
}
/** Jump `ms` ahead in one go: the pending animation frame fires once, at the end. Returns that one Time event. */
async jump(ms) {
await this.page.clock.fastForward(ms)
this.now += ms
return [{ tag: 'Time', t: this.now - this.t0 }]
}
}
+67
View File
@@ -0,0 +1,67 @@
// Technique 3: recorded drawing.
// The glue of every track draws a Rect shape as `fillStyle = color; fillRect(x, y, w, h)` and a Text shape as
// `fillStyle = color; font = "<size>px monospace"; textAlign = align; fillText(text, x, y)`. This init script wraps
// fillRect and fillText on CanvasRenderingContext2D.prototype before any page script runs and records each call with
// the fill style, font and alignment in force at the moment of the call, read back from the context (so colours and
// fonts are recorded the way the canvas reports them, e.g. "#7cb342" and "16px monospace").
//
// Frames. A frame is everything drawn in one go: in all three runtimes one input (an animation frame, a key, a click)
// is fed to the program and every drawing it produces is performed synchronously before the callback returns. So the
// first call after a quiet spell opens a frame (stamped with the fake `performance.now()` of that moment) and a
// queued microtask, which runs as soon as the callback has returned, closes it. One frame of the page therefore
// corresponds to the drawings the model produces for one input; if an input produces several drawings (several game
// frames due in one animation frame), they are all in that one page frame, in order.
/** Runs in the page. */
const recorderScript = () => {
const frames = []
let open = null
const current = () => {
if (open === null) {
open = { at: performance.now(), shapes: [] }
frames.push(open)
queueMicrotask(() => { open = null })
}
return open
}
const proto = CanvasRenderingContext2D.prototype
const fillRect = proto.fillRect
const fillText = proto.fillText
proto.fillRect = function (x, y, w, h) {
current().shapes.push({ tag: 'Rect', x, y, w, h, color: this.fillStyle })
return fillRect.call(this, x, y, w, h)
}
proto.fillText = function (text, x, y, ...rest) {
current().shapes.push({ tag: 'Text', x, y, text: String(text), color: this.fillStyle, font: this.font, align: this.textAlign })
return fillText.call(this, text, x, y, ...rest)
}
Object.defineProperty(window, '__e2eFrames', { value: frames })
}
/** Install the recorder for every document the page loads from now on. */
export const recordDrawing = (page) => page.addInitScript(recorderScript)
/** The frames recorded in the current document from index `from` on: [{at, shapes}]. */
export const framesFrom = (page, from = 0) => page.evaluate((i) => (window.__e2eFrames ?? []).slice(i), from)
/** How many frames the current document has recorded. */
export const frameCount = (page) => page.evaluate(() => (window.__e2eFrames ?? []).length)
/** Ask the page's canvas how it reports each colour (fillStyle normalises "#ABC", names and so on). */
export const canvasColors = (page, colors) => page.evaluate((cs) => {
const ctx = document.createElement('canvas').getContext('2d')
return cs.map((c) => { ctx.fillStyle = '#000000'; ctx.fillStyle = c; return ctx.fillStyle })
}, colors)
/** A recorded Text shape, with its font split into size and family, so it can be compared with a model shape. */
export const pageShape = (s) => {
if (s.tag !== 'Text') return s
const m = /^\s*(?:\S+\s+)*?(\d+(?:\.\d+)?)px\s+(.+?)\s*$/.exec(s.font)
return { tag: 'Text', x: s.x, y: s.y, text: s.text, color: s.color, size: m ? Number(m[1]) : s.font, family: m ? m[2] : s.font, align: s.align }
}
/** A model shape as the page should have recorded it; `color` maps a model colour to the canvas's spelling. */
export const expectedShape = (s, color) =>
s.tag === 'Rect'
? { tag: 'Rect', x: s.x, y: s.y, w: s.w, h: s.h, color: color(s.color) }
: { tag: 'Text', x: s.x, y: s.y, text: s.text, color: color(s.color), size: s.size, family: 'monospace', align: s.align }
+106
View File
@@ -0,0 +1,106 @@
// The oracle: the pure JS solutions, run in Node, say what a page must draw for a given seed, stored best and list of
// inputs. Nothing here touches a browser.
//
// A program (`main`, `pongMain`, `replayMain(recording)`, `lifeMain`) is an IO that yields a stream; `trace` answers
// its RandomSeed and LoadBest ops with the test's seed and best, exactly as the course's `play(main, seed, events,
// best)` does (js/solutions/app.js). The Model then steps that stream one input at a time and traces each IO it emits,
// so it knows, per input, which shapes are drawn and which scores are saved. Flattened, its output equals `play`'s
// (tests/techniques.spec.mjs checks that); grouped per input, it lines up with the page's recorded frames.
//
// Next to the program, the Model steps a stream of the program's *states*, built from the same pieces as the program
// (e.g. `clampTime(sessions(seed, best, speedActions), MAX_GAP_MS)` for Snake). It is used only to plan inputs and to
// state assertions in game terms ("the head moved one cell"); every judgement of the page is made on its drawing.
import { readFileSync } from 'node:fs'
import { trace } from '../../js/solutions/io.js'
import { clampTime } from '../../js/solutions/stream.js'
import { main, play, sessions, sessionView, speedActions, MAX_GAP_MS, FRAME_MS } from '../../js/solutions/app.js'
import { movesEvery } from '../../js/solutions/game.js'
import { newGame } from '../../js/solutions/spawn.js'
import { pongMain, pongStates, pongActions, FRAME_MS_PONG } from '../../js/solutions/pong/app.js'
import { pongView } from '../../js/solutions/pong/view.js'
import { replayMain, viewerStates, replayActions, viewerView, frames as replayFrames, REPLAY_MS } from '../../js/solutions/replay.js'
import { lifeMain, lifeStates, lifeActions, GEN_MS } from '../../js/solutions/life/app.js'
import { lifeView } from '../../js/solutions/life/view.js'
import { LCELL, LW, LH } from '../../js/solutions/life/model.js'
export const recording = JSON.parse(readFileSync(new URL('../../fixtures/replay-sample.json', import.meta.url), 'utf8'))
/** The four pages of a track, the program each runs, and the matching stream of states. */
export const programs = {
snake: {
page: 'index.html',
main: () => main,
states: (seed, best) => clampTime(sessions(seed, best, speedActions), MAX_GAP_MS),
},
pong: {
page: 'pong.html',
main: () => pongMain,
states: (seed) => clampTime(pongStates(seed, pongActions), MAX_GAP_MS),
},
replay: {
page: 'replay.html',
main: () => replayMain(recording),
states: () => clampTime(viewerStates(replayFrames(recording).length - 1, replayActions), MAX_GAP_MS),
},
life: {
page: 'life.html',
main: () => lifeMain,
states: (seed) => clampTime(lifeStates(seed, lifeActions), MAX_GAP_MS),
},
}
export class Model {
constructor(name, { seed, best = 0 }) {
const program = programs[name]
this.name = name
this.seed = seed
this.best = best
const traced = trace(program.main(), seed, best)
this.startOps = traced.ops // RandomSeed, LoadBest: what the program asks before it starts
this.stream = traced.result
this.stateStream = program.states(seed, best)
this.state = null // latest state the program emitted
this.inputs = [] // every input fed so far
this.frames = [] // per input that drew anything: {input, shapes}
this.saves = [] // scores passed to SaveBest, in order
}
/** Feed inputs, in order, as the page received them. */
feed(inputs) {
for (const input of inputs) {
const [ios, next] = this.stream.step(input)
this.stream = next
const ops = ios.flatMap((io) => trace(io, this.seed, this.best).ops)
const shapes = ops.filter((op) => op.tag === 'Draw').map((op) => op.shape)
this.saves.push(...ops.filter((op) => op.tag === 'SaveBest').map((op) => op.score))
if (shapes.length > 0) this.frames.push({ input, shapes })
const [states, nextStates] = this.stateStream.step(input)
this.stateStream = nextStates
if (states.length > 0) this.state = states[states.length - 1]
this.inputs.push(input)
}
return this
}
/** What the state would be after these inputs, without feeding them. Streams are values, so this is free. */
peek(inputs) {
const [states] = inputs.reduce(([acc, s], input) => {
const [out, next] = s.step(input)
return [out.length > 0 ? out[out.length - 1] : acc, next]
}, [this.state, this.stateStream])
return states
}
/** The course's own one-equality run of the program over every input fed so far. */
play() {
return play(programs[this.name].main(), this.seed, this.inputs, this.best)
}
lastFrame() {
return this.frames.length === 0 ? null : this.frames[this.frames.length - 1]
}
}
// Re-exported for tests that state expectations directly in model terms.
export { sessionView, newGame, movesEvery, pongView, viewerView, replayFrames, lifeView }
export { FRAME_MS, MAX_GAP_MS, FRAME_MS_PONG, REPLAY_MS, GEN_MS, LCELL, LW, LH }
+32
View File
@@ -0,0 +1,32 @@
// Technique 1: a fixed seed.
// Every track's glue asks the browser for its seed the same way: it makes a Uint32Array of length 1 and hands it to
// crypto.getRandomValues (JS: `new Uint32Array(1)`; Scala: `new Uint32Array(1)`; F#: `Array.zeroCreate<uint32> 1`,
// which Fable compiles to a Uint32Array). This init script replaces `getRandomValues` before any page script runs,
// writes the seed into such an array, and logs every call so a test can prove the page asked exactly once, in exactly
// that shape. Any other request (none of the pages makes one) falls through to the real generator and is logged too.
/** The seed every test uses unless it says otherwise. Above 2^31 on purpose: a track that read the seed as a signed
* 32-bit number would start a different game and every comparison with the model would fail. */
export const SEED = 0xdeadbeef
/** Runs in the page (Playwright serialises it), so it may use only its argument. */
const seedScript = (seed) => {
const real = Crypto.prototype.getRandomValues
const calls = []
Object.defineProperty(window, '__e2eSeedCalls', { value: calls })
Crypto.prototype.getRandomValues = function (array) {
const type = array?.constructor?.name
calls.push({ type, length: array?.length })
if (type === 'Uint32Array' && array.length === 1) {
array[0] = seed
return array
}
return real.call(this, array)
}
}
/** Install the fixed seed for every document the page loads from now on. */
export const fixSeed = (page, seed = SEED) => page.addInitScript(seedScript, seed)
/** The calls the current document made to crypto.getRandomValues: [{type, length}]. */
export const seedCalls = (page) => page.evaluate(() => window.__e2eSeedCalls ?? null)
+58
View File
@@ -0,0 +1,58 @@
{
"name": "pure-snake-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pure-snake-e2e",
"devDependencies": {
"@playwright/test": "1.65.0"
}
},
"node_modules/@playwright/test": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz",
"integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.65.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
"integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.65.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.65.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
"integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "pure-snake-e2e",
"private": true,
"description": "Maintainer tooling: end-to-end tests of the three tracks' browser pages against the pure JS model. Run scripts/e2e.sh from the repository root.",
"type": "module",
"devDependencies": {
"@playwright/test": "1.65.0"
}
}
+54
View File
@@ -0,0 +1,54 @@
// Playwright configuration for the end-to-end tests. Run through scripts/e2e.sh, which builds and serves a copy of the
// repository whose <track>/src holds the solutions and passes its address in E2E_BASE_URL.
//
// Browsers: Chromium and WebKit, and Firefox if a build is installed. Nothing is ever downloaded. If the exact build
// this Playwright version expects is missing but another revision of the same browser is in the Playwright cache
// (~/.cache/ms-playwright, or PLAYWRIGHT_BROWSERS_PATH), that one is used and named; if there is none, the project is
// left out with a message.
import { defineConfig, chromium, webkit, firefox } from '@playwright/test'
import { existsSync, readdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
const cache = process.env.PLAYWRIGHT_BROWSERS_PATH || join(homedir(), '.cache', 'ms-playwright')
const launchers = {
chromium: ['chrome-linux64/chrome', 'chrome-linux/chrome'],
webkit: ['pw_run.sh'],
firefox: ['firefox/firefox'],
}
// Print the notes once, from the main process, not again from every worker.
const say = (msg) => { if (!process.env.TEST_WORKER_INDEX) console.log(msg) }
const browser = (name, type) => {
if (existsSync(type.executablePath())) return { name, use: { browserName: name } }
const other = existsSync(cache)
? readdirSync(cache)
.filter((d) => new RegExp(`^${name}-\\d+$`).test(d))
.sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]))
.flatMap((d) => launchers[name].map((l) => join(cache, d, l)))
.find(existsSync)
: undefined
if (other) {
say(`e2e: ${name}: the expected build is not installed; using ${other}`)
return { name, use: { browserName: name, launchOptions: { executablePath: other } } }
}
say(`e2e: ${name}: no build installed, skipping this project (npx playwright install ${name} would download one)`)
return null
}
export default defineConfig({
testDir: 'tests',
fullyParallel: true,
workers: process.env.E2E_WORKERS ? Number(process.env.E2E_WORKERS) : '50%',
forbidOnly: true,
retries: 0,
timeout: 60_000,
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
outputDir: 'test-results',
use: {
baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:8000',
viewport: { width: 1280, height: 900 },
trace: 'retain-on-failure',
},
projects: [browser('chromium', chromium), browser('webkit', webkit), browser('firefox', firefox)].filter(Boolean),
})
+81
View File
@@ -0,0 +1,81 @@
// Conway's Life (life.html) on every track, judged against the pure model.
import { test, expect, TRACKS } from '../lib/app.mjs'
import { FRAME } from '../lib/clock.mjs'
import { GEN_MS, LCELL } from '../lib/oracle.mjs'
const GREEN = '#7cb342'
const lastScene = (shapes) => shapes.slice(Math.max(0, shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 640)))
// Live cells as the page drew them: the green squares, back in cell coordinates.
const cellsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Rect' && s.color === GREEN)
.map((s) => ({ x: (s.x - 1) / LCELL, y: (s.y - 1) / LCELL }))
const generationOf = (shapes) => Number(/^Generation (\d+)$/.exec(lastScene(shapes).find((s) => s.tag === 'Text' && s.text.startsWith('Generation')).text)[1])
const rowMajor = (cells) => [...cells].sort((p, q) => p.y - q.y || p.x - q.x)
const beats = (ms, a, b) => Math.floor(b / ms) - Math.floor(a / ms)
const lastFrameTime = (a) => Math.floor(a.clock.now / FRAME) * FRAME - a.clock.t0
for (const track of TRACKS) {
test.describe(`${track}: life`, () => {
test('clicks on a CSS-scaled canvas with a border toggle the cells the model says', async ({ app, page }) => {
const a = await (await app(track, 'life')).start()
// Show the 640 x 504 canvas 1.5 times as large; its 2 px border stays. Clicks must still land on the right cells.
await page.locator('canvas').evaluate((c) => { c.style.width = `${c.width * 1.5}px` })
expect(await page.locator('canvas').evaluate((c) => [c.clientWidth, c.clientHeight, c.clientLeft])).toEqual([960, 756, 2])
await a.expectInSync()
const c = (x, y) => [x * LCELL + 8, y * LCELL + 8] // the centre of cell (x, y)
const clicks = [
...[c(10, 10), c(11, 10), c(12, 10)], // cell centres
[0, 0], // the first pixel of the board: cell (0, 0)
[639, 479], // the last pixel of the board: cell (39, 29)
[5 * LCELL, 7 * LCELL + 15], // the left edge of cell (5, 7), bottom row of its pixels
[6 * LCELL - 1, 7 * LCELL], // the right edge of the same cell, top row: switches it off again
[6 * LCELL, 7 * LCELL], // one pixel further right: cell (6, 7)
[320, 490], // the status strip under the board: no cell
]
for (const [x, y] of clicks) {
await a.click(x, y)
await a.expectInSync(`click at canvas pixel (${x}, ${y})`)
}
await a.advance(200)
const drawn = await a.expectInSync()
const want = rowMajor([{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 11, y: 10 }, { x: 12, y: 10 }, { x: 39, y: 29 }, { x: 6, y: 7 }])
expect(cellsOf(drawn)).toEqual(want)
expect(a.state.cells).toEqual(want)
})
test('N steps, Space runs one generation per 150 ms, C clears, R fills the board as the model says', async ({ app }) => {
const a = await (await app(track, 'life')).start()
for (const x of [10, 11, 12]) await a.click(x * LCELL + 8, 10 * LCELL + 8)
await a.press('n')
const stepped = await a.expectInSync('after N')
expect(cellsOf(stepped)).toEqual([{ x: 11, y: 9 }, { x: 11, y: 10 }, { x: 11, y: 11 }])
expect(generationOf(stepped)).toBe(1)
await a.advance(40)
await a.press(' ')
const startedAt = lastFrameTime(a)
await a.expectInSync('running')
for (let i = 0; i < 8; i++) {
await a.advance(GEN_MS / 2)
const f = await a.expectInSync(`running, ${i}`)
expect(generationOf(f)).toBe(1 + beats(GEN_MS, startedAt, lastFrameTime(a)))
}
await a.press(' ')
const pausedGen = generationOf(await a.expectInSync('paused'))
await a.advance(600)
expect(generationOf(await a.expectInSync('paused for 600 ms'))).toBe(pausedGen)
await a.press('c')
const cleared = await a.expectInSync('after C')
expect(cellsOf(cleared)).toEqual([])
expect(generationOf(cleared)).toBe(0)
await a.press('r')
const filled = await a.expectInSync('after R')
expect(cellsOf(filled)).toEqual(a.state.cells)
expect(cellsOf(filled).length).toBeGreaterThan(100)
await a.press('n')
await a.advance(300)
await a.expectInSync('after R and N')
})
})
}
+75
View File
@@ -0,0 +1,75 @@
// Pong (pong.html) on every track, judged against the pure model.
import { test, expect, TRACKS } from '../lib/app.mjs'
// The player's paddle is the white 8 x 64 rect in column 2 (x = 16); its top row in cells is y / 8.
const lastScene = (shapes) => shapes.slice(Math.max(0, shapes.findLastIndex((s) => s.tag === 'Rect' && s.w === 640)))
const paddleOf = (shapes) => lastScene(shapes).find((s) => s.tag === 'Rect' && s.x === 16 && s.h === 64).y / 8
const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text)
for (const track of TRACKS) {
test.describe(`${track}: pong`, () => {
test('holding and releasing keys moves the paddle as the model says', async ({ app }) => {
const a = await (await app(track, 'pong')).start()
const p0 = paddleOf(await a.expectInSync())
await a.down('ArrowUp')
await a.advance(300)
const p1 = paddleOf(await a.expectInSync('holding ArrowUp'))
await a.up('ArrowUp')
await a.advance(300)
const p2 = paddleOf(await a.expectInSync('released'))
await a.down('ArrowDown')
await a.advance(600)
const p3 = paddleOf(await a.expectInSync('holding ArrowDown'))
await a.up('ArrowDown')
await a.advance(100)
await a.expectInSync('released again')
// One cell per 30 ms game frame while held, none after release.
expect(p0).toBe(21)
expect(p1).toBeLessThan(p0)
expect(p2).toBe(p1)
expect(p3).toBeGreaterThan(p2)
})
test('a whole match on the fake clock ends with the model\'s score and the right winner text', async ({ app }) => {
const a = await (await app(track, 'pong')).start()
// A simple player: every 90 ms, hold the key that brings the paddle's middle toward the ball.
let held = null
for (let i = 0; i < 1000 && a.state.status === 'Playing'; i++) {
const { ball, player } = a.state
const want = player + 4 < ball.y - 1 ? 'ArrowDown' : player + 4 > ball.y + 1 ? 'ArrowUp' : null
if (want !== held) {
if (held) await a.up(held)
if (want) await a.down(want)
held = want
}
await a.advance(90)
if (i % 10 === 0) await a.expectInSync(`match, ${i * 90} ms`)
}
if (held) await a.up(held)
await a.advance(500)
const end = await a.expectInSync('match over')
const { score } = a.state
expect(a.state.status).toBe('Over')
expect(Math.max(score.player, score.cpu)).toBe(5)
expect(textsOf(end)).toEqual(expect.arrayContaining([`${score.player} ${score.cpu}`,
score.player > score.cpu ? 'You Win!' : 'CPU Wins', 'Press R to restart']))
})
test('R restarts and keeps a held key', async ({ app }) => {
const a = await (await app(track, 'pong')).start()
await a.advance(300)
await a.down('ArrowDown')
await a.advance(150)
const before = paddleOf(await a.expectInSync('holding ArrowDown'))
await a.press('r')
const restarted = paddleOf(await a.expectInSync('after R'))
await a.advance(300)
const after = paddleOf(await a.expectInSync('after R, still holding'))
await a.up('ArrowDown')
await a.expectInSync('released')
expect(before).toBeGreaterThan(21)
expect(restarted).toBe(21)
expect(after).toBeGreaterThan(21)
})
})
}
+58
View File
@@ -0,0 +1,58 @@
// The replay viewer (replay.html) on every track, judged against the pure model. The recording is
// fixtures/replay-sample.json, which the pages fetch and the oracle reads from disk.
import { test, expect, TRACKS } from '../lib/app.mjs'
import { FRAME } from '../lib/clock.mjs'
import { recording, REPLAY_MS } from '../lib/oracle.mjs'
const LAST = recording.actions.length
const lastScene = (shapes) => shapes.slice(Math.max(0, shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 400)))
const stepOf = (shapes) => {
const t = lastScene(shapes).find((s) => s.tag === 'Text' && s.text.startsWith('step '))
return Number(/^step (\d+) \//.exec(t.text)[1])
}
const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text)
// Multiples of `ms` in (a, b]: how many beats of a `ms` interval fall between two frames at times a and b.
const beats = (ms, a, b) => Math.floor(b / ms) - Math.floor(a / ms)
// Time of the last animation frame so far, in the page's own clock (ms since its first frame).
const lastFrameTime = (a) => Math.floor(a.clock.now / FRAME) * FRAME - a.clock.t0
for (const track of TRACKS) {
test.describe(`${track}: replay`, () => {
test('Right, Left, Home and End show the model\'s frames', async ({ app }) => {
const a = await (await app(track, 'replay')).start()
expect(stepOf(await a.expectInSync())).toBe(0)
const seen = []
for (const key of ['ArrowRight', 'ArrowRight', 'ArrowRight', 'ArrowLeft', 'End', 'ArrowRight', 'ArrowLeft', 'Home', 'ArrowLeft']) {
await a.press(key)
seen.push(stepOf(await a.expectInSync(`after ${key}`)))
await a.advance(50)
}
await a.expectInSync()
expect(seen).toEqual([1, 2, 3, 2, LAST, LAST, LAST - 1, 0, 0])
expect(textsOf(a.lastPageFrame)).toContain(`step 0 / ${LAST}`)
})
test('autoplay advances one frame per 100 ms of fake time and stops on the last frame', async ({ app }) => {
const a = await (await app(track, 'replay')).start()
await a.advance(130)
await a.press(' ')
const pressedAt = lastFrameTime(a)
expect(textsOf(await a.expectInSync('play'))).toContain('Space: pause')
// For a while, check every 100 ms: the step shown is the number of 100 ms beats since the key.
for (let i = 0; i < 12; i++) {
await a.advance(REPLAY_MS)
expect(stepOf(await a.expectInSync(`playing, ${i}`))).toBe(beats(REPLAY_MS, pressedAt, lastFrameTime(a)))
}
// Then run to the end and beyond, a second at a time; it stops on the last frame.
while (a.state.playing) {
await a.advance(1000)
await a.expectInSync('playing on')
}
await a.advance(1000)
const end = await a.expectInSync('stopped')
expect(stepOf(end)).toBe(LAST)
expect(textsOf(end)).toContain('Left/Right: step Home/End: jump Space: play')
expect(beats(REPLAY_MS, pressedAt, lastFrameTime(a))).toBeGreaterThan(LAST)
})
})
}
+227
View File
@@ -0,0 +1,227 @@
// Snake (index.html) on every track, judged against the pure model: the page must draw, frame for frame, what the
// course's own program draws for the same seed, stored best and inputs.
import { test, expect, TRACKS } from '../lib/app.mjs'
import { framesFrom } from '../lib/drawing.mjs'
import { FRAME } from '../lib/clock.mjs'
import { SEED } from '../lib/seed.mjs'
import { sessionView, newGame, movesEvery, FRAME_MS } from '../lib/oracle.mjs'
import { browserPlatform } from '../../js/solutions/browser.js'
// What the reference glue (js/solutions/browser.js) reads from a stored string: its own loadBest, given a window
// whose localStorage holds `raw`.
// What the reference glue (js/solutions/browser.js) reads from a stored string: its own loadBest, given a window
// whose localStorage holds `raw`. The rule: only plain decimal digits with a value of at most 2147483647 count.
const referenceLoadBest = (raw) =>
browserPlatform({ getContext: () => ({}) }, { localStorage: { getItem: () => raw } }).loadBest()
// Stored strings that saveBest never writes. Number() would read most of them as a number; the glue must not.
const SPELLINGS = ['007', '2147483647', '+7', ' 7', '7 ', '0x10', '1e2', '7.0', '2147483648']
// No known differences between the tracks. If one appears, list it here as {track: {raw: reason}} and report it.
const KNOWN = {}
const BEST_KEY = 'pure-snake-best'
const HEAD = '#7cb342'
const DIRS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
const KEY_OF = { Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight' }
const OPPOSITE = { Up: 'Down', Down: 'Up', Left: 'Right', Right: 'Left' }
// One page frame can hold several scenes (several game frames due in one animation frame); the last one is the picture.
const lastScene = (shapes) => {
const i = shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 400)
return shapes.slice(Math.max(i, 0))
}
const headOf = (shapes) => {
const r = lastScene(shapes).find((s) => s.tag === 'Rect' && s.color === HEAD)
return r ? { x: (r.x - 1) / 20, y: (r.y - 1) / 20 } : null
}
const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text)
const headMoved = (before) => (s) => s.game.snake[0].x !== before.x || s.game.snake[0].y !== before.y || s.game.status !== 'Playing'
// A greedy player for the scripted game: head for the apple, never into a wall or the body, never reverse.
const choose = (g) => {
const h = g.snake[0]
const body = g.snake.slice(0, -1)
const safe = Object.entries(DIRS).filter(([key, [dx, dy]]) => {
const dir = key.slice(5)
const p = { x: h.x + dx, y: h.y + dy }
return dir !== OPPOSITE[g.dir] && p.x >= 0 && p.y >= 0 && p.x < g.width && p.y < g.height &&
!body.some((b) => b.x === p.x && b.y === p.y)
})
const dist = ([, [dx, dy]]) => Math.abs(h.x + dx - g.apple.x) + Math.abs(h.y + dy - g.apple.y)
const [best] = safe.sort((a, b) => dist(a) - dist(b))
return best ? best[0].slice(5) : g.dir
}
/**
* Play the scripted game until `done(session)`: after every step, press the key the greedy player wants (checking the
* page against the model after the key), then run the clock to the next step (checking again). Returns, per step,
* the score before it and how many game frames it took, counted from the page's frames (each game frame draws once,
* and so does each key that turns the snake).
*/
const playUntil = async (a, done, maxSteps = 200) => {
await a.expectInSync('before the scripted game')
const steps = []
for (let i = 0; i < maxSteps && !done(a.state); i++) {
const g = a.state.game
const want = choose(g)
const seenBefore = a.seen
const keys = want !== g.nextDir ? 1 : 0
if (keys) {
await a.press(KEY_OF[want])
await a.expectInSync(`after ${KEY_OF[want]} at step ${i}`)
}
await a.advanceUntil(headMoved(g.snake[0]))
await a.expectInSync(`step ${i}`)
steps.push({ score: g.score, frames: a.seen - seenBefore - keys })
}
expect(done(a.state), 'the scripted game reached its goal').toBe(true)
return steps
}
for (const track of TRACKS) {
test.describe(`${track}: snake`, () => {
test('first frame equals the new session\'s scene', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
expect(await a.expectInSync()).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 0, improved: false })))
})
test('one step every 200 ms of fake time at score 0', async ({ app, page }) => {
const a = await (await app(track, 'snake')).start()
await a.advance(1000)
await a.expectInSync()
const frames = await framesFrom(page, 0)
const moves = frames.filter((f, i) => i > 0 && JSON.stringify(headOf(f.shapes)) !== JSON.stringify(headOf(frames[i - 1].shapes)))
// A step every movesEvery(0) = 8 game frames of 25 ms, seen on the first animation frame at or after it.
expect(movesEvery(0) * FRAME_MS).toBe(200)
expect(moves.map((f) => f.at - a.clock.t0)).toEqual([200, 400, 600, 800].map((t) => Math.ceil(t / FRAME) * FRAME))
expect(moves.map((f) => headOf(f.shapes).x)).toEqual([11, 12, 13, 14])
})
test('a scripted game eats three apples, matching the model after every key, and speeds up after the third', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
const steps = await playUntil(a, (s) => s.game.score >= 3)
const after = await playUntil(a, (s) => s.game.score >= 4 || s.game.status !== 'Playing', 6)
const all = [...steps, ...after]
// Every step took movesEvery(score) game frames on the page: 8 at scores 0-2, 7 from the third apple on.
expect(all.map((s) => s.frames)).toEqual(all.map((s) => movesEvery(s.score)))
expect(all.filter((s) => s.score === 3).length, 'steps observed at score 3').toBeGreaterThan(0)
expect(textsOf(a.lastPageFrame)).toContain(`Score: ${a.state.game.score}`)
})
test('Up then Left inside one step turns up, then left (turn queue)', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
await a.advanceUntil(headMoved(a.state.game.snake[0]))
const h0 = headOf(await a.expectInSync())
await a.press('ArrowUp')
await a.expectInSync('after ArrowUp')
await a.press('ArrowLeft')
await a.expectInSync('after ArrowLeft')
await a.advanceUntil(headMoved(a.state.game.snake[0]))
const h1 = headOf(await a.expectInSync('first step'))
await a.advanceUntil(headMoved(a.state.game.snake[0]))
const h2 = headOf(await a.expectInSync('second step'))
expect([h1, h2]).toEqual([{ x: h0.x, y: h0.y - 1 }, { x: h0.x - 1, y: h0.y - 1 }])
})
test('Space shows Paused and freezes the scene, Space resumes', async ({ app, page }) => {
const a = await (await app(track, 'snake')).start()
await a.advance(300)
await a.press(' ')
const frozen = await a.expectInSync('paused')
expect(textsOf(frozen)).toEqual(expect.arrayContaining(['Paused', 'Press Space to resume']))
const from = a.seen
await a.advance(1000)
await a.expectInSync('paused for 1 s')
const during = await framesFrom(page, from)
expect(during.length, 'the page keeps drawing while paused').toBeGreaterThan(30)
for (const f of during) expect(f.shapes).toEqual(during[0].shapes)
expect(headOf(during.at(-1).shapes)).toEqual(headOf(frozen))
await a.press(' ')
await a.advance(400)
const resumed = await a.expectInSync('resumed')
expect(textsOf(resumed)).not.toContain('Paused')
expect(headOf(resumed)).not.toEqual(headOf(frozen))
})
test('a crash shows Game Over, R restarts', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
await a.advanceUntil((s) => s.game.status === 'GameOver')
const over = await a.expectInSync('crashed')
expect(textsOf(over)).toEqual(expect.arrayContaining(['Game Over', 'Press R to restart']))
await a.advance(500)
await a.expectInSync('after the crash')
// Restart starts a new game from the game's current seed (which every apple has moved on), not from SEED.
const seed = a.state.game.seed
await a.press('r')
const again = await a.expectInSync('after R')
expect(lastScene(again)).toEqual(await a.expected(sessionView({ game: newGame(seed), best: a.state.best, improved: false })))
await a.advance(500)
await a.expectInSync('playing again')
})
test('a 10 s jump of the fake clock in one frame advances the game by 250 ms only', async ({ app }) => {
const a = await (await app(track, 'snake')).start()
await a.advance(500)
const h1 = headOf(await a.expectInSync())
await a.jump(10_000)
const h2 = headOf(await a.expectInSync('after the jump'))
// 250 ms are 10 game frames: one or two steps. Unclamped, 10 s would be 50 steps: into the wall.
expect(Math.abs(h2.x - h1.x) + Math.abs(h2.y - h1.y)).toBeGreaterThanOrEqual(1)
expect(Math.abs(h2.x - h1.x) + Math.abs(h2.y - h1.y)).toBeLessThanOrEqual(2)
expect(a.state.game.status).toBe('Playing')
await a.advance(400)
await a.expectInSync('after the jump, running on')
})
test('the best score is saved on improvement and shown after a reload; a stored best of 37 too', async ({ app, page }) => {
const a = await (await app(track, 'snake')).start()
await playUntil(a, (s) => s.game.score >= 1)
expect(a.model.saves).toEqual([1])
expect(await page.evaluate((k) => localStorage.getItem(k), BEST_KEY)).toBe('1')
await a.reload({ best: 1 })
const shown = await a.expectInSync('after reload')
expect(shown).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 1, improved: false })))
expect(textsOf(shown)).toContain('Best: 1')
await page.evaluate((k) => localStorage.setItem(k, '37'), BEST_KEY)
await a.reload({ best: 37 })
expect(textsOf(await a.expectInSync('stored 37'))).toContain('Best: 37')
await a.advance(300)
await a.expectInSync('stored 37, running')
})
test('garbage in localStorage reads as Best: 0 and the game runs', async ({ app, page }) => {
const a = await (await app(track, 'snake')).start()
for (const raw of ['banana', '-5', '3.5', 'NaN', '1e999', '99999999999999999999', '']) {
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BEST_KEY, raw])
await a.reload({ best: 0 })
expect(textsOf(await a.expectInSync(`stored ${JSON.stringify(raw)}`))).toContain('Best: 0')
await a.advance(250)
await a.expectInSync(`stored ${JSON.stringify(raw)}, running`)
}
})
for (const raw of SPELLINGS) {
test(`a stored best spelled ${JSON.stringify(raw)} reads as the reference glue reads it`, async ({ app, page }) => {
test.fail(Boolean(KNOWN[track]?.[raw]), KNOWN[track]?.[raw])
const want = referenceLoadBest(raw)
await page.addInitScript(([k, v]) => localStorage.setItem(k, v), [BEST_KEY, raw])
const a = await (await app(track, 'snake')).start({ best: want })
expect(textsOf(await a.expectInSync(`stored ${JSON.stringify(raw)}`))).toContain(`Best: ${want}`)
})
}
test('a page whose localStorage throws still runs, shows Best: 0, and survives a new best', async ({ app, page }) => {
await page.addInitScript(() => {
Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw new DOMException('blocked by the test', 'SecurityError') } })
})
const a = await (await app(track, 'snake')).start()
expect(textsOf(await a.expectInSync())).toContain('Best: 0')
await playUntil(a, (s) => s.game.score >= 1)
expect(a.model.saves).toEqual([1])
expect(textsOf(a.lastPageFrame)).toContain('Best: 1')
await a.advance(300)
await a.expectInSync('running after the failed save')
})
})
}
+69
View File
@@ -0,0 +1,69 @@
// Each of the three techniques the other specs build on, proven on its own, on every track; and the oracle checked
// against the course's own `play`.
import { test, expect, TRACKS } from '../lib/app.mjs'
import { seedCalls, SEED } from '../lib/seed.mjs'
import { framesFrom, frameCount } from '../lib/drawing.mjs'
import { FRAME } from '../lib/clock.mjs'
import { Model, sessionView, newGame } from '../lib/oracle.mjs'
for (const track of TRACKS) {
test.describe(`${track}: techniques`, () => {
test('fixed seed: the page asks for one Uint32Array(1) and starts the game that seed makes', async ({ app }) => {
const a = await app(track, 'snake')
await a.start()
expect(await seedCalls(a.page)).toEqual([{ type: 'Uint32Array', length: 1 }])
// The apple of the first frame is where newGame(SEED) puts it; another seed would (almost surely) put it elsewhere.
const apple = (await a.expectInSync()).find((s) => s.tag === 'Rect' && s.color === '#e53935')
const want = newGame(SEED).apple
expect(apple).toMatchObject({ x: want.x * 20 + 1, y: want.y * 20 + 1 })
expect(newGame(SEED + 1).apple).not.toEqual(want)
})
test('fake clock: frames come only when the test moves the clock, with the timestamps the clock arithmetic predicts', async ({ app, page }) => {
const a = await app(track, 'snake')
await a.start()
// Paused: real time passing changes nothing.
const before = await frameCount(page)
await page.waitForTimeout(300)
expect(await frameCount(page)).toBe(before)
// 1000 ms of fake time: the page draws once per 25 ms game frame, and each drawing happens on the first
// animation frame (a multiple of 16 ms after the first) at or after that game frame's time.
const startTick = a.clock.now
await a.advance(1000)
expect(await a.clock.ticks()).toBe(startTick + 1000)
const frames = await framesFrom(page, 0)
const times = frames.map((f) => f.at - a.clock.t0)
const due = Array.from({ length: 40 }, (_, i) => (i + 1) * 25).map((g) => Math.ceil(g / FRAME) * FRAME).filter((t) => t <= 1000)
expect(times).toEqual([0, ...due])
// And the model, fed the Time events of the same arithmetic, drew exactly the same frames.
await a.expectInSync()
// One long frame: fastForward fires one animation frame, 10 s later.
await a.jump(10_000)
const [last] = (await framesFrom(page, frames.length)).slice(-1)
expect(last.at - a.clock.t0).toBe(1000 + 10_000)
await a.expectInSync()
})
test('recorded drawing: the first frame is the new session\'s scene, with colours, fonts and alignment as drawn', async ({ app, page }) => {
const a = await app(track, 'snake')
await a.start()
const [first] = await framesFrom(page, 0)
// Read straight from the recorder, before any normalisation: what the canvas reported at each call.
const text = first.shapes.find((s) => s.tag === 'Text' && s.text.startsWith('Best'))
expect(text).toMatchObject({ text: 'Best: 0', color: '#ffffff', align: 'right', x: 394, y: 18 })
expect(text.font).toMatch(/16px monospace/)
expect(first.shapes[0]).toEqual({ tag: 'Rect', x: 0, y: 0, w: 400, h: 400, color: '#111111' })
expect(await a.expectInSync()).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 0, improved: false })))
})
})
}
test('oracle: fed input by input, the model emits exactly what the course\'s play() emits for the same inputs', () => {
const m = new Model('snake', { seed: SEED, best: 4 })
const times = Array.from({ length: 80 }, (_, i) => ({ tag: 'Time', t: i * 16 }))
m.feed(times.slice(0, 30)).feed([{ tag: 'KeyDown', key: 'ArrowUp' }, { tag: 'KeyUp', key: 'ArrowUp' }]).feed(times.slice(30))
m.feed([{ tag: 'Time', t: 80 * 16 + 10_000 }])
const drawn = m.play().flat().filter((op) => op.tag === 'Draw').map((op) => op.shape)
expect(m.frames.flatMap((f) => f.shapes)).toEqual(drawn)
expect(m.startOps.map((op) => op.tag)).toEqual(['RandomSeed', 'LoadBest'])
})
+4 -2
View File
@@ -29,7 +29,8 @@
"seed": 7, "seed": 7,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -60,7 +61,8 @@
"seed": 4294967295, "seed": 4294967295,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
} }
] ]
+48 -24
View File
@@ -27,7 +27,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -53,7 +54,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -82,7 +84,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -108,7 +111,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -140,7 +144,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -169,7 +174,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -201,7 +207,8 @@
"seed": 1, "seed": 1,
"score": 4, "score": 4,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -231,7 +238,8 @@
"seed": 1, "seed": 1,
"score": 5, "score": 5,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -260,7 +268,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -286,7 +295,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -315,7 +325,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -341,7 +352,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -378,7 +390,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -412,7 +425,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -445,7 +459,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -475,7 +490,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -511,7 +527,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -544,7 +561,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -573,7 +591,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -599,7 +618,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -628,7 +648,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -654,7 +675,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -683,7 +705,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Won", "status": "Won",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -709,7 +732,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Won", "status": "Won",
"frame": 0 "frame": 0,
"queued": null
} }
} }
] ]
+14 -7
View File
@@ -27,7 +27,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"x": 0, "x": 0,
@@ -62,7 +63,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"x": 19, "x": 19,
@@ -97,7 +99,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"x": -1, "x": -1,
@@ -132,7 +135,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"x": 0, "x": 0,
@@ -167,7 +171,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"x": 20, "x": 20,
@@ -202,7 +207,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"x": 5, "x": 5,
@@ -237,7 +243,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"x": 10, "x": 10,
+32 -16
View File
@@ -27,7 +27,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
"Up" "Up"
], ],
@@ -54,7 +55,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -83,7 +85,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
"Right" "Right"
], ],
@@ -110,7 +113,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -139,7 +143,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
"Left" "Left"
], ],
@@ -166,7 +171,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -195,7 +201,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
"Down" "Down"
], ],
@@ -222,7 +229,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -251,7 +259,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
"Left" "Left"
], ],
@@ -278,7 +287,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -307,7 +317,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
}, },
"Up" "Up"
], ],
@@ -334,7 +345,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -363,7 +375,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
}, },
"Up" "Up"
], ],
@@ -390,7 +403,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -419,7 +433,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Won", "status": "Won",
"frame": 0 "frame": 0,
"queued": null
}, },
"Up" "Up"
], ],
@@ -446,7 +461,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Won", "status": "Won",
"frame": 0 "frame": 0,
"queued": null
} }
} }
] ]
+10 -5
View File
@@ -19,7 +19,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [ "expected": [
@@ -67,7 +68,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [ "expected": [
@@ -115,7 +117,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [ "expected": [
@@ -158,7 +161,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [ "expected": [
@@ -218,7 +222,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [] "expected": []
+4 -2
View File
@@ -32,7 +32,8 @@
"seed": 1831565813, "seed": 1831565813,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -66,7 +67,8 @@
"seed": 1831565855, "seed": 1831565855,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
} }
] ]
+32 -16
View File
@@ -23,7 +23,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -48,7 +49,8 @@
"seed": 1831565813, "seed": 1831565813,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -73,7 +75,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -98,7 +101,8 @@
"seed": 1831565814, "seed": 1831565814,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -135,7 +139,8 @@
"seed": 7, "seed": 7,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -172,7 +177,8 @@
"seed": 1831565820, "seed": 1831565820,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -213,7 +219,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -251,7 +258,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "Won", "status": "Won",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -279,7 +287,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -304,7 +313,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -329,7 +339,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -351,7 +362,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -376,7 +388,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -398,7 +411,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -423,7 +437,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "Won", "status": "Won",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -445,7 +460,8 @@
"seed": 5, "seed": 5,
"score": 0, "score": 0,
"status": "Won", "status": "Won",
"frame": 0 "frame": 0,
"queued": null
} }
} }
] ]
+80 -8
View File
@@ -22,7 +22,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -47,7 +48,8 @@
"seed": 1831565813, "seed": 1831565813,
"score": 1, "score": 1,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -71,7 +73,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -92,7 +95,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -116,7 +120,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -137,7 +142,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -161,7 +167,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": { "expected": {
@@ -182,7 +189,72 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
}
},
{
"name": "eating-the-last-free-cell-wins",
"args": [
{
"width": 2,
"height": 2,
"snake": [
{
"x": 0,
"y": 0
},
{
"x": 0,
"y": 1
},
{
"x": 1,
"y": 1
}
],
"dir": "Up",
"nextDir": "Right",
"apple": {
"x": 1,
"y": 0
},
"seed": 7,
"score": 2,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": {
"width": 2,
"height": 2,
"snake": [
{
"x": 1,
"y": 0
},
{
"x": 0,
"y": 0
},
{
"x": 0,
"y": 1
},
{
"x": 1,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": null,
"seed": 7,
"score": 3,
"status": "Won",
"frame": 0,
"queued": null
} }
} }
] ]
+14 -7
View File
@@ -22,7 +22,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
[] []
], ],
@@ -45,7 +46,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
] ]
}, },
@@ -70,7 +72,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
[ [
{ {
@@ -104,7 +107,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"width": 3, "width": 3,
@@ -124,7 +128,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"width": 3, "width": 3,
@@ -144,7 +149,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"width": 3, "width": 3,
@@ -164,7 +170,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
] ]
} }
+8 -4
View File
@@ -30,7 +30,8 @@
"seed": 1831565855, "seed": 1831565855,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
[ [
{ {
@@ -103,7 +104,8 @@
"seed": 3663131668, "seed": 3663131668,
"score": 1, "score": 1,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -135,7 +137,8 @@
"seed": 1831565855, "seed": 1831565855,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
[] []
], ],
@@ -165,7 +168,8 @@
"seed": 1831565855, "seed": 1831565855,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
} }
] ]
+28 -14
View File
@@ -22,7 +22,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"tag": "Tick" "tag": "Tick"
@@ -46,7 +47,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -70,7 +72,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"tag": "Tick" "tag": "Tick"
@@ -98,7 +101,8 @@
"seed": 1831565813, "seed": 1831565813,
"score": 1, "score": 1,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -122,7 +126,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"tag": "Tick" "tag": "Tick"
@@ -146,7 +151,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -170,7 +176,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"tag": "Tick" "tag": "Tick"
@@ -194,7 +201,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -218,7 +226,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"tag": "Turn", "tag": "Turn",
@@ -243,7 +252,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -267,7 +277,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"tag": "Turn", "tag": "Turn",
@@ -292,7 +303,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
}, },
{ {
@@ -316,7 +328,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
}, },
{ {
"tag": "Turn", "tag": "Turn",
@@ -341,7 +354,8 @@
"seed": 0, "seed": 0,
"score": 0, "score": 0,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
} }
} }
] ]
+10 -5
View File
@@ -26,7 +26,8 @@
"seed": 0, "seed": 0,
"score": 5, "score": 5,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [ "expected": [
@@ -95,7 +96,8 @@
"seed": 0, "seed": 0,
"score": 5, "score": 5,
"status": "Playing", "status": "Playing",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [ "expected": [
@@ -159,7 +161,8 @@
"seed": 0, "seed": 0,
"score": 5, "score": 5,
"status": "Paused", "status": "Paused",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [ "expected": [
@@ -250,7 +253,8 @@
"seed": 1, "seed": 1,
"score": 0, "score": 0,
"status": "GameOver", "status": "GameOver",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [ "expected": [
@@ -337,7 +341,8 @@
"seed": 0, "seed": 0,
"score": 5, "score": 5,
"status": "Won", "status": "Won",
"frame": 0 "frame": 0,
"queued": null
} }
], ],
"expected": [ "expected": [
+694
View File
@@ -0,0 +1,694 @@
{
"function": "play",
"cases": [
{
"name": "seed-42-eats-three-apples-with-no-stored-best",
"args": [
42,
[
{
"tag": "KeyDown",
"key": "ArrowUp"
},
{
"tag": "Time",
"t": 200
},
{
"tag": "KeyDown",
"key": "ArrowLeft"
},
{
"tag": "Time",
"t": 450
},
{
"tag": "Time",
"t": 700
},
{
"tag": "Time",
"t": 950
},
{
"tag": "Time",
"t": 1200
},
{
"tag": "Time",
"t": 1450
},
{
"tag": "Time",
"t": 1700
},
{
"tag": "Time",
"t": 1950
},
{
"tag": "Time",
"t": 2000
},
{
"tag": "KeyDown",
"key": "ArrowUp"
},
{
"tag": "Time",
"t": 2250
},
{
"tag": "Time",
"t": 2500
},
{
"tag": "Time",
"t": 2750
},
{
"tag": "Time",
"t": 2800
},
{
"tag": "KeyDown",
"key": "ArrowRight"
},
{
"tag": "Time",
"t": 3050
},
{
"tag": "Time",
"t": 3300
},
{
"tag": "Time",
"t": 3550
},
{
"tag": "Time",
"t": 3800
},
{
"tag": "Time",
"t": 4050
},
{
"tag": "Time",
"t": 4300
},
{
"tag": "Time",
"t": 4550
},
{
"tag": "Time",
"t": 4800
},
{
"tag": "Time",
"t": 5050
},
{
"tag": "Time",
"t": 5300
},
{
"tag": "Time",
"t": 5400
},
{
"tag": "KeyDown",
"key": "ArrowUp"
},
{
"tag": "Time",
"t": 5650
},
{
"tag": "Time",
"t": 5900
},
{
"tag": "Time",
"t": 6150
},
{
"tag": "Time",
"t": 6200
}
],
0
],
"expected": [
{
"tag": "Rect",
"x": 0,
"y": 0,
"w": 400,
"h": 400,
"color": "#111111"
},
{
"tag": "Rect",
"x": 21,
"y": 161,
"w": 18,
"h": 18,
"color": "#e53935"
},
{
"tag": "Rect",
"x": 281,
"y": 21,
"w": 18,
"h": 18,
"color": "#7cb342"
},
{
"tag": "Rect",
"x": 281,
"y": 41,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 281,
"y": 61,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 281,
"y": 81,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 281,
"y": 101,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 261,
"y": 101,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Text",
"x": 6,
"y": 18,
"text": "Score: 3",
"color": "#ffffff",
"size": 16,
"align": "left"
},
{
"tag": "Text",
"x": 394,
"y": 18,
"text": "Best: 3",
"color": "#ffffff",
"size": 16,
"align": "right"
}
]
},
{
"name": "seed-7-beats-a-stored-best-of-2-and-hits-the-wall",
"args": [
7,
[
{
"tag": "Time",
"t": 250
},
{
"tag": "Time",
"t": 500
},
{
"tag": "Time",
"t": 750
},
{
"tag": "Time",
"t": 1000
},
{
"tag": "Time",
"t": 1250
},
{
"tag": "Time",
"t": 1500
},
{
"tag": "Time",
"t": 1750
},
{
"tag": "Time",
"t": 1800
},
{
"tag": "KeyDown",
"key": "ArrowDown"
},
{
"tag": "Time",
"t": 2050
},
{
"tag": "Time",
"t": 2200
},
{
"tag": "KeyDown",
"key": "ArrowLeft"
},
{
"tag": "Time",
"t": 2450
},
{
"tag": "Time",
"t": 2700
},
{
"tag": "Time",
"t": 2950
},
{
"tag": "Time",
"t": 3200
},
{
"tag": "KeyDown",
"key": "ArrowDown"
},
{
"tag": "Time",
"t": 3450
},
{
"tag": "Time",
"t": 3700
},
{
"tag": "Time",
"t": 3950
},
{
"tag": "Time",
"t": 4200
},
{
"tag": "KeyDown",
"key": "ArrowLeft"
},
{
"tag": "Time",
"t": 4375
},
{
"tag": "KeyDown",
"key": "ArrowUp"
},
{
"tag": "Time",
"t": 4625
},
{
"tag": "Time",
"t": 4875
},
{
"tag": "Time",
"t": 5125
},
{
"tag": "Time",
"t": 5375
},
{
"tag": "Time",
"t": 5600
},
{
"tag": "KeyDown",
"key": "ArrowRight"
},
{
"tag": "Time",
"t": 5850
},
{
"tag": "Time",
"t": 6100
},
{
"tag": "Time",
"t": 6350
},
{
"tag": "Time",
"t": 6600
},
{
"tag": "Time",
"t": 6825
}
],
2
],
"expected": [
{
"tag": "Rect",
"x": 0,
"y": 0,
"w": 400,
"h": 400,
"color": "#111111"
},
{
"tag": "Rect",
"x": 321,
"y": 81,
"w": 18,
"h": 18,
"color": "#e53935"
},
{
"tag": "Rect",
"x": 381,
"y": 201,
"w": 18,
"h": 18,
"color": "#7cb342"
},
{
"tag": "Rect",
"x": 361,
"y": 201,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 341,
"y": 201,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 321,
"y": 201,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 301,
"y": 201,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 281,
"y": 201,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 261,
"y": 201,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Text",
"x": 6,
"y": 18,
"text": "Score: 4",
"color": "#ffffff",
"size": 16,
"align": "left"
},
{
"tag": "Text",
"x": 200,
"y": 200,
"text": "Game Over",
"color": "#ffffff",
"size": 32,
"align": "center"
},
{
"tag": "Text",
"x": 200,
"y": 230,
"text": "Press R to restart",
"color": "#ffffff",
"size": 16,
"align": "center"
},
{
"tag": "Text",
"x": 394,
"y": 18,
"text": "Best: 4",
"color": "#ffffff",
"size": 16,
"align": "right"
}
]
},
{
"name": "seed-42-restart-after-game-over-keeps-the-best",
"args": [
42,
[
{
"tag": "KeyDown",
"key": "ArrowUp"
},
{
"tag": "Time",
"t": 200
},
{
"tag": "KeyDown",
"key": "ArrowLeft"
},
{
"tag": "Time",
"t": 450
},
{
"tag": "Time",
"t": 700
},
{
"tag": "Time",
"t": 950
},
{
"tag": "Time",
"t": 1200
},
{
"tag": "Time",
"t": 1450
},
{
"tag": "Time",
"t": 1700
},
{
"tag": "Time",
"t": 1950
},
{
"tag": "Time",
"t": 2000
},
{
"tag": "KeyDown",
"key": "ArrowUp"
},
{
"tag": "Time",
"t": 2250
},
{
"tag": "Time",
"t": 2500
},
{
"tag": "Time",
"t": 2750
},
{
"tag": "Time",
"t": 2800
},
{
"tag": "KeyDown",
"key": "ArrowRight"
},
{
"tag": "Time",
"t": 3050
},
{
"tag": "Time",
"t": 3300
},
{
"tag": "Time",
"t": 3550
},
{
"tag": "Time",
"t": 3800
},
{
"tag": "Time",
"t": 4050
},
{
"tag": "Time",
"t": 4300
},
{
"tag": "Time",
"t": 4550
},
{
"tag": "Time",
"t": 4800
},
{
"tag": "Time",
"t": 5050
},
{
"tag": "Time",
"t": 5300
},
{
"tag": "Time",
"t": 5550
},
{
"tag": "Time",
"t": 5800
},
{
"tag": "Time",
"t": 6050
},
{
"tag": "Time",
"t": 6300
},
{
"tag": "Time",
"t": 6550
},
{
"tag": "Time",
"t": 6600
},
{
"tag": "KeyDown",
"key": "r"
}
],
0
],
"expected": [
{
"tag": "Rect",
"x": 0,
"y": 0,
"w": 400,
"h": 400,
"color": "#111111"
},
{
"tag": "Rect",
"x": 261,
"y": 61,
"w": 18,
"h": 18,
"color": "#e53935"
},
{
"tag": "Rect",
"x": 201,
"y": 201,
"w": 18,
"h": 18,
"color": "#7cb342"
},
{
"tag": "Rect",
"x": 181,
"y": 201,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Rect",
"x": 161,
"y": 201,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Text",
"x": 6,
"y": 18,
"text": "Score: 0",
"color": "#ffffff",
"size": 16,
"align": "left"
},
{
"tag": "Text",
"x": 394,
"y": 18,
"text": "Best: 2",
"color": "#ffffff",
"size": 16,
"align": "right"
}
]
}
]
}
+756
View File
@@ -0,0 +1,756 @@
{
"function": "sessionUpdate",
"cases": [
{
"name": "a-step-without-eating-keeps-the-best",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 2,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 5,
"improved": false
},
{
"tag": "Tick"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 2,
"y": 1
},
{
"x": 1,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 2,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 5,
"improved": false
}
},
{
"name": "the-first-apple-with-no-record-is-an-improvement",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 2,
"y": 1
},
"seed": 5,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 0,
"improved": false
},
{
"tag": "Tick"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 2,
"y": 1
},
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 1831565818,
"score": 1,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 1,
"improved": true
}
},
{
"name": "eating-past-the-record-raises-the-best",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 2,
"y": 1
},
"seed": 5,
"score": 5,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 5,
"improved": false
},
{
"tag": "Tick"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 2,
"y": 1
},
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 1831565818,
"score": 6,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 6,
"improved": true
}
},
{
"name": "eating-below-the-record-keeps-the-best",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 2,
"y": 1
},
"seed": 5,
"score": 1,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 5,
"improved": false
},
{
"tag": "Tick"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 2,
"y": 1
},
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 1831565818,
"score": 2,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 5,
"improved": false
}
},
{
"name": "equalling-the-record-is-not-an-improvement",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 2,
"y": 1
},
"seed": 5,
"score": 4,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 5,
"improved": false
},
{
"tag": "Tick"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 2,
"y": 1
},
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 1831565818,
"score": 5,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 5,
"improved": false
}
},
{
"name": "the-next-action-clears-improved",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 6,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 6,
"improved": true
},
{
"tag": "Turn",
"dir": "Up"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Up",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 6,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 6,
"improved": false
}
},
{
"name": "a-frame-that-steps-and-eats-raises-the-best",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 2,
"y": 1
},
"seed": 5,
"score": 3,
"status": "Playing",
"frame": 6,
"queued": null
},
"best": 3,
"improved": false
},
{
"tag": "Frame"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 2,
"y": 1
},
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 1831565818,
"score": 4,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 4,
"improved": true
}
},
{
"name": "a-frame-that-only-counts-keeps-the-best-and-clears-improved",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 3,
"status": "Playing",
"frame": 1,
"queued": null
},
"best": 3,
"improved": true
},
{
"tag": "Frame"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 3,
"status": "Playing",
"frame": 2,
"queued": null
},
"best": 3,
"improved": false
}
},
{
"name": "game-over-keeps-the-best",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 3,
"y": 1
},
{
"x": 2,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 4,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 4,
"improved": false
},
{
"tag": "Tick"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 3,
"y": 1
},
{
"x": 2,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 4,
"status": "GameOver",
"frame": 0,
"queued": null
},
"best": 4,
"improved": false
}
},
{
"name": "restart-keeps-the-best",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 6,
"status": "GameOver",
"frame": 0,
"queued": null
},
"best": 6,
"improved": false
},
{
"tag": "Restart"
}
],
"expected": {
"game": {
"width": 20,
"height": 20,
"snake": [
{
"x": 10,
"y": 10
},
{
"x": 9,
"y": 10
},
{
"x": 8,
"y": 10
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 11,
"y": 9
},
"seed": 1831565818,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 6,
"improved": false
}
},
{
"name": "pausing-keeps-the-best",
"args": [
{
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 2,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 9,
"improved": true
},
{
"tag": "TogglePause"
}
],
"expected": {
"game": {
"width": 4,
"height": 3,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 3,
"y": 0
},
"seed": 5,
"score": 2,
"status": "Paused",
"frame": 0,
"queued": null
},
"best": 9,
"improved": false
}
}
]
}
+281
View File
@@ -0,0 +1,281 @@
{
"function": "sessionView",
"cases": [
{
"name": "playing-adds-the-best-at-the-top-right",
"args": [
{
"game": {
"width": 3,
"height": 2,
"snake": [
{
"x": 1,
"y": 0
},
{
"x": 0,
"y": 0
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 2,
"y": 1
},
"seed": 0,
"score": 5,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 12,
"improved": false
}
],
"expected": [
{
"tag": "Rect",
"x": 0,
"y": 0,
"w": 60,
"h": 40,
"color": "#111111"
},
{
"tag": "Rect",
"x": 41,
"y": 21,
"w": 18,
"h": 18,
"color": "#e53935"
},
{
"tag": "Rect",
"x": 21,
"y": 1,
"w": 18,
"h": 18,
"color": "#7cb342"
},
{
"tag": "Rect",
"x": 1,
"y": 1,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Text",
"x": 6,
"y": 18,
"text": "Score: 5",
"color": "#ffffff",
"size": 16,
"align": "left"
},
{
"tag": "Text",
"x": 54,
"y": 18,
"text": "Best: 12",
"color": "#ffffff",
"size": 16,
"align": "right"
}
]
},
{
"name": "a-best-of-zero-is-shown-too",
"args": [
{
"game": {
"width": 3,
"height": 2,
"snake": [
{
"x": 1,
"y": 0
},
{
"x": 0,
"y": 0
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 2,
"y": 1
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
},
"best": 0,
"improved": false
}
],
"expected": [
{
"tag": "Rect",
"x": 0,
"y": 0,
"w": 60,
"h": 40,
"color": "#111111"
},
{
"tag": "Rect",
"x": 41,
"y": 21,
"w": 18,
"h": 18,
"color": "#e53935"
},
{
"tag": "Rect",
"x": 21,
"y": 1,
"w": 18,
"h": 18,
"color": "#7cb342"
},
{
"tag": "Rect",
"x": 1,
"y": 1,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Text",
"x": 6,
"y": 18,
"text": "Score: 0",
"color": "#ffffff",
"size": 16,
"align": "left"
},
{
"tag": "Text",
"x": 54,
"y": 18,
"text": "Best: 0",
"color": "#ffffff",
"size": 16,
"align": "right"
}
]
},
{
"name": "game-over-shows-the-banner-and-then-the-best",
"args": [
{
"game": {
"width": 3,
"height": 2,
"snake": [
{
"x": 1,
"y": 0
},
{
"x": 0,
"y": 0
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 2,
"y": 1
},
"seed": 0,
"score": 5,
"status": "GameOver",
"frame": 0,
"queued": null
},
"best": 7,
"improved": true
}
],
"expected": [
{
"tag": "Rect",
"x": 0,
"y": 0,
"w": 60,
"h": 40,
"color": "#111111"
},
{
"tag": "Rect",
"x": 41,
"y": 21,
"w": 18,
"h": 18,
"color": "#e53935"
},
{
"tag": "Rect",
"x": 21,
"y": 1,
"w": 18,
"h": 18,
"color": "#7cb342"
},
{
"tag": "Rect",
"x": 1,
"y": 1,
"w": 18,
"h": 18,
"color": "#43a047"
},
{
"tag": "Text",
"x": 6,
"y": 18,
"text": "Score: 5",
"color": "#ffffff",
"size": 16,
"align": "left"
},
{
"tag": "Text",
"x": 30,
"y": 20,
"text": "Game Over",
"color": "#ffffff",
"size": 32,
"align": "center"
},
{
"tag": "Text",
"x": 30,
"y": 50,
"text": "Press R to restart",
"color": "#ffffff",
"size": 16,
"align": "center"
},
{
"tag": "Text",
"x": 54,
"y": 18,
"text": "Best: 7",
"color": "#ffffff",
"size": 16,
"align": "right"
}
]
}
]
}
+1266 -54
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
{
"function": "viewerKey",
"cases": [
{"name":"ArrowRight-is-next","args":["ArrowRight"],"expected":{"tag":"Next"}},
{"name":"d-is-next","args":["d"],"expected":{"tag":"Next"}},
{"name":"D-is-next","args":["D"],"expected":{"tag":"Next"}},
{"name":"ArrowLeft-is-prev","args":["ArrowLeft"],"expected":{"tag":"Prev"}},
{"name":"a-is-prev","args":["a"],"expected":{"tag":"Prev"}},
{"name":"A-is-prev","args":["A"],"expected":{"tag":"Prev"}},
{"name":"Home-is-first","args":["Home"],"expected":{"tag":"First"}},
{"name":"End-is-last","args":["End"],"expected":{"tag":"Last"}},
{"name":"space-toggles-play","args":[" "],"expected":{"tag":"TogglePlay"}},
{"name":"ArrowUp-is-not-bound","args":["ArrowUp"],"expected":null},
{"name":"r-is-not-bound","args":["r"],"expected":null},
{"name":"constructor-is-not-bound","args":["constructor"],"expected":null},
{"name":"toString-is-not-bound","args":["toString"],"expected":null},
{"name":"empty-string-is-not-bound","args":[""],"expected":null}
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"function": "viewerUpdate",
"cases": [
{"name":"next-moves-one-frame-forward","args":[{"index":3,"last":10,"playing":false},{"tag":"Next"}],"expected":{"index":4,"last":10,"playing":false}},
{"name":"next-at-the-last-frame-stays","args":[{"index":10,"last":10,"playing":false},{"tag":"Next"}],"expected":{"index":10,"last":10,"playing":false}},
{"name":"prev-moves-one-frame-back","args":[{"index":3,"last":10,"playing":false},{"tag":"Prev"}],"expected":{"index":2,"last":10,"playing":false}},
{"name":"prev-at-the-first-frame-stays","args":[{"index":0,"last":10,"playing":false},{"tag":"Prev"}],"expected":{"index":0,"last":10,"playing":false}},
{"name":"first-goes-to-frame-zero","args":[{"index":7,"last":10,"playing":false},{"tag":"First"}],"expected":{"index":0,"last":10,"playing":false}},
{"name":"last-goes-to-the-last-frame","args":[{"index":2,"last":10,"playing":false},{"tag":"Last"}],"expected":{"index":10,"last":10,"playing":false}},
{"name":"toggle-play-starts-autoplay","args":[{"index":4,"last":10,"playing":false},{"tag":"TogglePlay"}],"expected":{"index":4,"last":10,"playing":true}},
{"name":"toggle-play-stops-autoplay","args":[{"index":4,"last":10,"playing":true},{"tag":"TogglePlay"}],"expected":{"index":4,"last":10,"playing":false}},
{"name":"toggle-play-at-the-end-starts-again-from-the-first-frame","args":[{"index":10,"last":10,"playing":false},{"tag":"TogglePlay"}],"expected":{"index":0,"last":10,"playing":true}},
{"name":"beat-while-playing-moves-one-frame-forward","args":[{"index":4,"last":10,"playing":true},{"tag":"Beat"}],"expected":{"index":5,"last":10,"playing":true}},
{"name":"beat-while-not-playing-is-ignored","args":[{"index":4,"last":10,"playing":false},{"tag":"Beat"}],"expected":{"index":4,"last":10,"playing":false}},
{"name":"beat-that-reaches-the-last-frame-stops-autoplay","args":[{"index":9,"last":10,"playing":true},{"tag":"Beat"}],"expected":{"index":10,"last":10,"playing":false}},
{"name":"next-while-playing-keeps-playing","args":[{"index":4,"last":10,"playing":true},{"tag":"Next"}],"expected":{"index":5,"last":10,"playing":true}},
{"name":"prev-while-playing-keeps-playing","args":[{"index":4,"last":10,"playing":true},{"tag":"Prev"}],"expected":{"index":3,"last":10,"playing":true}},
{"name":"last-while-playing-stops-autoplay","args":[{"index":4,"last":10,"playing":true},{"tag":"Last"}],"expected":{"index":10,"last":10,"playing":false}},
{"name":"first-while-playing-keeps-playing","args":[{"index":4,"last":10,"playing":true},{"tag":"First"}],"expected":{"index":0,"last":10,"playing":true}},
{"name":"a-recording-with-no-actions-cannot-play","args":[{"index":0,"last":0,"playing":false},{"tag":"TogglePlay"}],"expected":{"index":0,"last":0,"playing":false}},
{"name":"a-recording-with-no-actions-stays-at-frame-zero","args":[{"index":0,"last":0,"playing":false},{"tag":"Next"}],"expected":{"index":0,"last":0,"playing":false}}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"function": "viewerView",
"cases": [
{"name":"first-frame-not-playing","args":[[{"width":20,"height":20,"snake":[{"x":10,"y":10},{"x":9,"y":10},{"x":8,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Up","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null}],{"index":0,"last":2,"playing":false}],"expected":[{"tag":"Rect","x":0,"y":0,"w":400,"h":400,"color":"#111111"},{"tag":"Rect","x":21,"y":181,"w":18,"h":18,"color":"#e53935"},{"tag":"Rect","x":201,"y":201,"w":18,"h":18,"color":"#7cb342"},{"tag":"Rect","x":181,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Rect","x":161,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Text","x":6,"y":18,"text":"Score: 0","color":"#ffffff","size":16,"align":"left"},{"tag":"Text","x":394,"y":18,"text":"step 0 / 2","color":"#ffffff","size":16,"align":"right"},{"tag":"Text","x":200,"y":392,"text":"Left/Right: step Home/End: jump Space: play","color":"#aaaaaa","size":12,"align":"center"}]},
{"name":"middle-frame-while-playing","args":[[{"width":20,"height":20,"snake":[{"x":10,"y":10},{"x":9,"y":10},{"x":8,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Up","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null}],{"index":1,"last":2,"playing":true}],"expected":[{"tag":"Rect","x":0,"y":0,"w":400,"h":400,"color":"#111111"},{"tag":"Rect","x":21,"y":181,"w":18,"h":18,"color":"#e53935"},{"tag":"Rect","x":221,"y":201,"w":18,"h":18,"color":"#7cb342"},{"tag":"Rect","x":201,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Rect","x":181,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Text","x":6,"y":18,"text":"Score: 0","color":"#ffffff","size":16,"align":"left"},{"tag":"Text","x":394,"y":18,"text":"step 1 / 2","color":"#ffffff","size":16,"align":"right"},{"tag":"Text","x":200,"y":392,"text":"Space: pause","color":"#aaaaaa","size":12,"align":"center"}]},
{"name":"last-frame","args":[[{"width":20,"height":20,"snake":[{"x":10,"y":10},{"x":9,"y":10},{"x":8,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Up","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null}],{"index":2,"last":2,"playing":false}],"expected":[{"tag":"Rect","x":0,"y":0,"w":400,"h":400,"color":"#111111"},{"tag":"Rect","x":21,"y":181,"w":18,"h":18,"color":"#e53935"},{"tag":"Rect","x":221,"y":201,"w":18,"h":18,"color":"#7cb342"},{"tag":"Rect","x":201,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Rect","x":181,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Text","x":6,"y":18,"text":"Score: 0","color":"#ffffff","size":16,"align":"left"},{"tag":"Text","x":394,"y":18,"text":"step 2 / 2","color":"#ffffff","size":16,"align":"right"},{"tag":"Text","x":200,"y":392,"text":"Left/Right: step Home/End: jump Space: play","color":"#aaaaaa","size":12,"align":"center"}]}
]
}
+326 -2
View File
@@ -2156,8 +2156,8 @@
"player": 21, "player": 21,
"cpu": 21, "cpu": 21,
"held": { "held": {
"up": false, "up": true,
"down": false "down": true
}, },
"score": { "score": {
"player": 0, "player": 0,
@@ -2331,6 +2331,330 @@
"seed": 0, "seed": 0,
"frame": 0 "frame": 0
} }
},
{
"name": "player-paddle-catches-the-ball-at-its-first-row",
"args": [
{
"ball": {
"x": 3,
"y": 19
},
"vel": {
"dx": -1,
"dy": 1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 0
},
{
"tag": "Frame"
}
],
"expected": {
"ball": {
"x": 3,
"y": 20
},
"vel": {
"dx": 1,
"dy": 1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 1
}
},
{
"name": "ball-may-enter-the-top-row",
"args": [
{
"ball": {
"x": 40,
"y": 1
},
"vel": {
"dx": 1,
"dy": -1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 0
},
{
"tag": "Frame"
}
],
"expected": {
"ball": {
"x": 41,
"y": 0
},
"vel": {
"dx": 1,
"dy": -1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 1
}
},
{
"name": "ball-may-enter-the-bottom-row",
"args": [
{
"ball": {
"x": 40,
"y": 48
},
"vel": {
"dx": 1,
"dy": 1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 0
},
{
"tag": "Frame"
}
],
"expected": {
"ball": {
"x": 41,
"y": 49
},
"vel": {
"dx": 1,
"dy": 1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 1
}
},
{
"name": "ball-in-column-zero-is-still-in-play",
"args": [
{
"ball": {
"x": 1,
"y": 40
},
"vel": {
"dx": -1,
"dy": 1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 0
},
{
"tag": "Frame"
}
],
"expected": {
"ball": {
"x": 0,
"y": 41
},
"vel": {
"dx": -1,
"dy": 1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 1
}
},
{
"name": "ball-in-the-last-column-is-still-in-play",
"args": [
{
"ball": {
"x": 78,
"y": 40
},
"vel": {
"dx": 1,
"dy": 1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 0
},
{
"tag": "Frame"
}
],
"expected": {
"ball": {
"x": 79,
"y": 41
},
"vel": {
"dx": 1,
"dy": 1
},
"player": 20,
"cpu": 21,
"held": {
"up": false,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 1
}
},
{
"name": "restart-keeps-the-held-keys",
"args": [
{
"ball": {
"x": 3,
"y": 24
},
"vel": {
"dx": -1,
"dy": 1
},
"player": 3,
"cpu": 21,
"held": {
"up": true,
"down": false
},
"score": {
"player": 1,
"cpu": 4
},
"status": "Playing",
"seed": 0,
"frame": 250
},
{
"tag": "Restart"
}
],
"expected": {
"ball": {
"x": 40,
"y": 25
},
"vel": {
"dx": 1,
"dy": 1
},
"player": 21,
"cpu": 21,
"held": {
"up": true,
"down": false
},
"score": {
"player": 0,
"cpu": 0
},
"status": "Playing",
"seed": 0,
"frame": 0
}
} }
] ]
} }
+756
View File
@@ -0,0 +1,756 @@
{
"function": "violations",
"cases": [
{
"name": "a-sane-state-has-no-violations",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": []
},
{
"name": "no-apple-is-fine",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": null,
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": []
},
{
"name": "a-pending-perpendicular-turn-is-fine",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Up",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": []
},
{
"name": "a-one-cell-snake-is-fine",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 0,
"y": 0
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": []
},
{
"name": "a-game-over-state-can-be-sane",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 4,
"y": 2
},
{
"x": 3,
"y": 2
},
{
"x": 2,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "GameOver",
"frame": 0,
"queued": null
}
],
"expected": []
},
{
"name": "empty-snake",
"args": [
{
"width": 5,
"height": 5,
"snake": [],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"snake-not-empty"
]
},
{
"name": "head-off-the-right-edge",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 5,
"y": 2
},
{
"x": 4,
"y": 2
},
{
"x": 3,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"snake-in-bounds"
]
},
{
"name": "tail-above-the-board",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 1,
"y": 1
},
{
"x": 1,
"y": 0
},
{
"x": 1,
"y": -1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"snake-in-bounds"
]
},
{
"name": "head-on-its-own-body",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 2,
"y": 3
},
{
"x": 1,
"y": 3
},
{
"x": 1,
"y": 2
},
{
"x": 2,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"snake-no-overlap"
]
},
{
"name": "snake-with-a-gap",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 3,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"snake-connected"
]
},
{
"name": "snake-with-a-diagonal-step",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 1
},
{
"x": 0,
"y": 1
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"snake-connected"
]
},
{
"name": "apple-outside-the-board",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 5,
"y": 0
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"apple-in-bounds"
]
},
{
"name": "apple-on-the-head",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 2,
"y": 2
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"apple-not-on-snake"
]
},
{
"name": "apple-on-the-tail",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 0,
"y": 2
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"apple-not-on-snake"
]
},
{
"name": "pending-turn-straight-back",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Left",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"turn-not-reversed"
]
},
{
"name": "pending-turn-straight-back-vertical",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Up",
"nextDir": "Down",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"turn-not-reversed"
]
},
{
"name": "several-violations-are-listed-in-order",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 5,
"y": 2
},
{
"x": 3,
"y": 2
},
{
"x": 3,
"y": 2
}
],
"dir": "Right",
"nextDir": "Left",
"apple": {
"x": 3,
"y": 2
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"snake-in-bounds",
"snake-no-overlap",
"snake-connected",
"apple-not-on-snake",
"turn-not-reversed"
]
},
{
"name": "doubled-cell-counts-as-overlap-and-as-disconnected",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 2,
"y": 2
}
],
"dir": "Right",
"nextDir": "Right",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": null
}
],
"expected": [
"snake-no-overlap",
"snake-connected"
]
},
{
"name": "a-queued-turn-is-fine",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Up",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": "Left"
}
],
"expected": []
},
{
"name": "queued-turn-straight-back-from-the-pending-turn",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Up",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": "Down"
}
],
"expected": [
"queued-not-reversed"
]
},
{
"name": "reversed-pending-turn-and-reversed-queue-are-both-listed",
"args": [
{
"width": 5,
"height": 5,
"snake": [
{
"x": 2,
"y": 2
},
{
"x": 1,
"y": 2
},
{
"x": 0,
"y": 2
}
],
"dir": "Right",
"nextDir": "Left",
"apple": {
"x": 4,
"y": 4
},
"seed": 0,
"score": 0,
"status": "Playing",
"frame": 0,
"queued": "Right"
}
],
"expected": [
"turn-not-reversed",
"queued-not-reversed"
]
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"function": "lifeClick",
"cases": [
{"name":"the-top-left-pixel-is-cell-0-0","args":[0,0],"expected":{"tag":"Toggle","cell":{"x":0,"y":0}}},
{"name":"the-last-pixel-of-the-first-cell","args":[15,15],"expected":{"tag":"Toggle","cell":{"x":0,"y":0}}},
{"name":"the-next-pixel-is-the-next-cell","args":[16,0],"expected":{"tag":"Toggle","cell":{"x":1,"y":0}}},
{"name":"the-row-below","args":[3,16],"expected":{"tag":"Toggle","cell":{"x":0,"y":1}}},
{"name":"a-pixel-in-the-middle","args":[100,250],"expected":{"tag":"Toggle","cell":{"x":6,"y":15}}},
{"name":"the-bottom-right-pixel-of-the-board","args":[639,479],"expected":{"tag":"Toggle","cell":{"x":39,"y":29}}},
{"name":"right-of-the-board-is-nothing","args":[640,10],"expected":null},
{"name":"the-status-strip-under-the-board-is-nothing","args":[10,480],"expected":null},
{"name":"left-of-the-board-is-nothing","args":[-1,10],"expected":null},
{"name":"above-the-board-is-nothing","args":[10,-1],"expected":null}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"function": "lifeKey",
"cases": [
{"name":"space-toggles-pause","args":[" "],"expected":{"tag":"TogglePause"}},
{"name":"n-steps","args":["n"],"expected":{"tag":"Step"}},
{"name":"N-steps","args":["N"],"expected":{"tag":"Step"}},
{"name":"c-clears","args":["c"],"expected":{"tag":"Clear"}},
{"name":"C-clears","args":["C"],"expected":{"tag":"Clear"}},
{"name":"r-randomizes","args":["r"],"expected":{"tag":"Randomize"}},
{"name":"R-randomizes","args":["R"],"expected":{"tag":"Randomize"}},
{"name":"ArrowUp-is-not-bound","args":["ArrowUp"],"expected":null},
{"name":"Enter-is-not-bound","args":["Enter"],"expected":null},
{"name":"constructor-is-not-bound","args":["constructor"],"expected":null},
{"name":"toString-is-not-bound","args":["toString"],"expected":null},
{"name":"empty-string-is-not-bound","args":[""],"expected":null}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"function": "lifeStep",
"cases": [
{"name":"an-empty-board-stays-empty","args":[[]],"expected":[]},
{"name":"a-lonely-cell-dies","args":[[{"x":5,"y":5}]],"expected":[]},
{"name":"two-cells-die-of-loneliness","args":[[{"x":5,"y":5},{"x":6,"y":5}]],"expected":[]},
{"name":"a-block-is-stable","args":[[{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6},{"x":6,"y":6}]],"expected":[{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6},{"x":6,"y":6}]},
{"name":"a-horizontal-blinker-turns-vertical","args":[[{"x":4,"y":5},{"x":5,"y":5},{"x":6,"y":5}]],"expected":[{"x":5,"y":4},{"x":5,"y":5},{"x":5,"y":6}]},
{"name":"a-vertical-blinker-turns-horizontal","args":[[{"x":5,"y":4},{"x":5,"y":5},{"x":5,"y":6}]],"expected":[{"x":4,"y":5},{"x":5,"y":5},{"x":6,"y":5}]},
{"name":"three-cells-in-an-l-give-birth-to-a-block","args":[[{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6}]],"expected":[{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6},{"x":6,"y":6}]},
{"name":"a-cell-with-four-neighbours-dies-of-overcrowding","args":[[{"x":5,"y":4},{"x":4,"y":5},{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6}]],"expected":[{"x":4,"y":4},{"x":5,"y":4},{"x":6,"y":4},{"x":4,"y":5},{"x":6,"y":5},{"x":4,"y":6},{"x":5,"y":6},{"x":6,"y":6}]},
{"name":"a-glider-moves-and-the-result-is-sorted-row-major","args":[[{"x":1,"y":0},{"x":2,"y":1},{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]],"expected":[{"x":0,"y":1},{"x":2,"y":1},{"x":1,"y":2},{"x":2,"y":2},{"x":1,"y":3}]},
{"name":"a-block-in-the-corner-is-stable","args":[[{"x":0,"y":0},{"x":1,"y":0},{"x":0,"y":1},{"x":1,"y":1}]],"expected":[{"x":0,"y":0},{"x":1,"y":0},{"x":0,"y":1},{"x":1,"y":1}]},
{"name":"a-block-in-the-far-corner-is-stable","args":[[{"x":38,"y":28},{"x":39,"y":28},{"x":38,"y":29},{"x":39,"y":29}]],"expected":[{"x":38,"y":28},{"x":39,"y":28},{"x":38,"y":29},{"x":39,"y":29}]},
{"name":"a-blinker-on-the-top-edge-cannot-grow-beyond-it","args":[[{"x":4,"y":0},{"x":5,"y":0},{"x":6,"y":0}]],"expected":[{"x":5,"y":0},{"x":5,"y":1}]},
{"name":"a-blinker-on-the-right-edge-does-not-wrap-to-the-left-edge","args":[[{"x":39,"y":9},{"x":39,"y":10},{"x":39,"y":11}]],"expected":[{"x":38,"y":10},{"x":39,"y":10}]},
{"name":"cells-on-opposite-edges-are-not-neighbours","args":[[{"x":39,"y":9},{"x":0,"y":10},{"x":39,"y":10},{"x":39,"y":11}]],"expected":[{"x":38,"y":10},{"x":39,"y":10}]},
{"name":"a-blinker-on-the-bottom-edge-cannot-grow-beyond-it","args":[[{"x":10,"y":29},{"x":11,"y":29},{"x":12,"y":29}]],"expected":[{"x":11,"y":28},{"x":11,"y":29}]}
]
}
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
{
"function": "lifeView",
"cases": [
{"name":"a-new-world-is-an-empty-paused-board","args":[{"cells":[],"running":false,"seed":42,"generation":0}],"expected":[{"tag":"Rect","x":0,"y":0,"w":640,"h":480,"color":"#111111"},{"tag":"Rect","x":0,"y":480,"w":640,"h":24,"color":"#222222"},{"tag":"Text","x":6,"y":497,"text":"Generation 0","color":"#ffffff","size":14,"align":"left"},{"tag":"Text","x":634,"y":497,"text":"Paused: Space runs, N steps","color":"#ffffff","size":14,"align":"right"}]},
{"name":"live-cells-in-row-major-order-while-running","args":[{"cells":[{"x":0,"y":0},{"x":5,"y":5},{"x":39,"y":29}],"running":true,"seed":1,"generation":17}],"expected":[{"tag":"Rect","x":0,"y":0,"w":640,"h":480,"color":"#111111"},{"tag":"Rect","x":1,"y":1,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":81,"y":81,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":625,"y":465,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":0,"y":480,"w":640,"h":24,"color":"#222222"},{"tag":"Text","x":6,"y":497,"text":"Generation 17","color":"#ffffff","size":14,"align":"left"},{"tag":"Text","x":634,"y":497,"text":"Running: Space pauses","color":"#ffffff","size":14,"align":"right"}]},
{"name":"paused-after-some-generations","args":[{"cells":[{"x":4,"y":5},{"x":5,"y":5},{"x":6,"y":5}],"running":false,"seed":1,"generation":4}],"expected":[{"tag":"Rect","x":0,"y":0,"w":640,"h":480,"color":"#111111"},{"tag":"Rect","x":65,"y":81,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":81,"y":81,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":97,"y":81,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":0,"y":480,"w":640,"h":24,"color":"#222222"},{"tag":"Text","x":6,"y":497,"text":"Generation 4","color":"#ffffff","size":14,"align":"left"},{"tag":"Text","x":634,"y":497,"text":"Paused: Space runs, N steps","color":"#ffffff","size":14,"align":"right"}]}
]
}
+231
View File
@@ -0,0 +1,231 @@
{
"seed": 1,
"actions": [
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Up"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Right"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Down"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Left"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Up"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Right"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Down"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Right"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Up"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Right"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Down"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Left"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Up"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Right"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Down"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Left"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Up"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Right"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Up"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Left"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Down"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Left"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Up"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Right"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Down"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Left"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Up"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Right"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Down"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Left"},
{"tag":"Tick"},
{"tag":"Turn","dir":"Up"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"},
{"tag":"Tick"}
]
}
+2 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Usage: ./build-web.sh # Usage: ./build-web.sh
# Compiles src/ + web/ to JavaScript with Fable (a local dotnet tool, see .config/dotnet-tools.json) into web/dist/. # Compiles src/ + web/ to JavaScript with Fable (a local dotnet tool, see .config/dotnet-tools.json) into web/dist/.
# index.html loads the Snake entry module, pong.html the Pong one. # index.html loads the Snake entry module (web/dist/SnakeMain.js), pong.html the Pong one (PongMain.js), replay.html the
# replay viewer (ReplayMain.js, m10) and life.html Conway's Life (LifeMain.js, m13).
# Then serve the repo root (node scripts/serve.mjs) and open http://localhost:8000/fsharp/index.html. # Then serve the repo root (node scripts/serve.mjs) and open http://localhost:8000/fsharp/index.html.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pure Life</title>
<link rel="icon" href="data:,">
<style>
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 12px; background: #1b1b1b; color: #ddd; font: 14px system-ui, sans-serif; }
h1 { margin: 0; font-size: 20px; }
canvas { background: #111111; border: 2px solid #333; cursor: crosshair; }
p { margin: 0; color: #999; }
</style>
</head>
<body>
<h1>Pure Life</h1>
<canvas id="game" width="640" height="504"></canvas>
<p>Click a cell to switch it on or off. Space runs and pauses, N steps one generation, R fills the board at random, C clears it.</p>
<script type="module" src="web/dist/LifeMain.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pure Snake replay</title>
<link rel="icon" href="data:,">
<style>
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 12px; background: #1b1b1b; color: #ddd; font: 14px system-ui, sans-serif; }
h1 { margin: 0; font-size: 20px; }
canvas { background: #111111; border: 2px solid #333; }
p { margin: 0; color: #999; }
</style>
</head>
<body>
<h1>Pure Snake: replay</h1>
<canvas id="game" width="400" height="400"></canvas>
<p>Right / D next step, Left / A previous, Home / End first / last, Space plays (Module 10, Exercise 10.8).</p>
<script type="module" src="web/dist/ReplayMain.js"></script>
</body>
</html>
+45 -2
View File
@@ -2,11 +2,14 @@
module Snake.App module Snake.App
open Snake.Model open Snake.Model
open Snake.View
open Snake.IO open Snake.IO
open Snake.Stream open Snake.Stream
let TICK_MS = 150L let TICK_MS = 150L
let FRAME_MS = 25L let FRAME_MS = 25L
// m10: the longest step of time the game accepts at once; a longer silence (a background tab) counts as this much.
let MAX_GAP_MS = 250L
// Everything that can happen to the game: a Tick every TICK_MS, and an action for each bound key. // Everything that can happen to the game: a Tick every TICK_MS, and an action for each bound key.
let classicActions: Stream<Action> = let classicActions: Stream<Action> =
@@ -22,7 +25,47 @@ let states (seed: uint32) (actions: Stream<Action>) : Stream<GameState> =
let render (state: GameState) : IO<unit> = IO.drawScene (View.view state) let render (state: GameState) : IO<unit> = IO.drawScene (View.view state)
// The whole program as a value: get a seed, then turn the stream of states into a stream of drawings. // m10: a session wraps the game with the best score so far. GameState, update and view do not know about it.
// `Improved` says whether the action that produced this session raised the best, which is when it must be saved.
type Session = { Game: GameState; Best: int; Improved: bool }
let sessionUpdate (session: Session) (action: Action) : Session =
let game = Game.update session.Game action
let best = max session.Best game.Score
{ Game = game; Best = best; Improved = best > session.Best }
// The same running fold as `states`, one level up.
let sessions (seed: uint32) (best: int) (actions: Stream<Action>) : Stream<Session> =
let s0 = { Game = Spawn.newGame seed; Best = best; Improved = false }
actions |> Stream.scan sessionUpdate s0 |> Stream.startWith s0
// The game's picture with the best score at the top right, level with the score on the left.
let sessionView (session: Session) : Scene =
View.view session.Game
@ [ Text(session.Game.Width * CELL - 6, 18, $"Best: {session.Best}", "#ffffff", 16, "right") ]
// Draw, and save the best only on the action that raised it.
let renderSession (session: Session) : IO<unit> =
if session.Improved then
IO.sequence [ IO.drawScene (sessionView session); IO.saveBest session.Best ]
else
IO.drawScene (sessionView session)
// The whole program as a value: get a seed, then the stored best, then turn the stream of sessions into a stream
// of drawings. clampTime wraps the whole stream, so everything inside it sees the clamped clock.
// Nothing runs here; Runtime interprets it. // Nothing runs here; Runtime interprets it.
let main () : IO<Stream<IO<unit>>> = let main () : IO<Stream<IO<unit>>> =
IO.randomSeed () |> IO.map (fun seed -> states seed speedActions |> Stream.map render) IO.randomSeed ()
|> IO.bind (fun seed ->
IO.loadBest ()
|> IO.map (fun best -> sessions seed best speedActions |> Stream.map renderSession |> Stream.clampTime MAX_GAP_MS))
// m09: the whole program in one equality. For each drawing that `main` emits while fed `events`, the ops it
// performs, answered by the pure interpreter (`seed` for RandomSeed, and from m10 `best` for LoadBest).
let playWith (seed: uint32) (best: int) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list =
(IO.traceWith seed best main).Result
|> Stream.run events
|> List.map (fun io -> (IO.traceWith seed best io).Ops)
// The Module 9 helper is the "no stored best" case.
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = playWith seed 0 events main
+170
View File
@@ -0,0 +1,170 @@
/// m12 check: property-based testing built from the course's own parts.
/// A generator of test data is nothing new: it is a `Random<'a>` from Random.fs (`seed -> (value, nextSeed)`).
module Snake.Check
open Snake.Model
open Snake.Random
/// What `forAll` reports: every value passed, or the first one that did not.
type CheckResult<'a> =
| Passed of runs: int
| Failed of run: int * seed: uint32 * value: 'a
/// One of the elements of the non-empty list `xs`: the one at index `intBelow (List.length xs)`.
let elementOf (xs: 'a list) : Random<'a> =
Random.intBelow (List.length xs) |> Random.map (fun i -> List.item i xs)
/// `n` values drawn from `gen`, one after another; the seed is threaded from each draw to the next.
let rec listOf (n: int) (gen: Random<'a>) : Random<'a list> =
if n <= 0 then
Random.pure []
else
gen |> Random.bind (fun a -> listOf (n - 1) gen |> Random.map (fun rest -> a :: rest))
// Ticks appear three times so that the snake moves more often than it turns.
let private actionPool: Action list =
[ Tick; Tick; Tick; Turn Up; Turn Down; Turn Left; Turn Right ]
/// A generator of `n` random actions (given).
let genActions (n: int) : Random<Action list> = listOf n (elementOf actionPool)
/// Check `prop` on `runs` values drawn from `gen`, starting from `seed`; stop at the first value that fails.
/// Recursion carries the seed: no counter, no loop variable. `go` calls itself last, so it runs in constant stack.
let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a> =
let rec go (i: int) (s: uint32) : CheckResult<'a> =
if i >= runs then
Passed runs
else
let value, next = Random.run s gen
if prop value then go (i + 1) next else Failed(i, s, value)
go 0 seed
/// Every list that is `xs` with exactly one element removed, in the order of the removed index.
let removals (xs: 'a list) : 'a list list = xs |> List.mapi (fun i _ -> List.removeAt i xs)
/// Keep removing one element for as long as the list still fails; the result is 1-minimal.
let rec minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list =
match List.tryFind fails (removals xs) with
| Some smaller -> minimize fails smaller
| None -> xs
let private neighbours (a: Point, b: Point) : bool = abs (a.X - b.X) + abs (a.Y - b.Y) = 1
let rec private allDifferent (ps: Point list) : bool =
match ps with
| [] -> true
| p :: rest -> not (Util.contains rest p (=)) && allDifferent rest
// Each invariant: its name, and a predicate that is true when the state is fine.
// `Option.forall` is true for `None`: no apple breaks nothing.
let private invariants: (string * (GameState -> bool)) list =
[ "snake-not-empty", (fun s -> not (List.isEmpty s.Snake))
"snake-in-bounds", (fun s -> s.Snake |> List.forall (Rules.inBounds s))
"snake-no-overlap", (fun s -> allDifferent s.Snake)
"snake-connected", (fun s -> s.Snake |> List.pairwise |> List.forall neighbours)
"apple-in-bounds", (fun s -> s.Apple |> Option.forall (Rules.inBounds s))
"apple-not-on-snake", (fun s -> s.Apple |> Option.forall (fun a -> not (Util.contains s.Snake a (=))))
"turn-not-reversed", (fun s -> s.NextDir <> opposite s.Dir)
"queued-not-reversed", (fun s -> s.Queued |> Option.forall (fun q -> q <> opposite s.NextDir)) ]
/// The names of the invariants that `state` breaks, in the order of the table above.
let violations (state: GameState) : string list =
invariants |> List.filter (fun (_, holds) -> not (holds state)) |> List.map fst
/// The numbers of states between consecutive moves of the snake's head.
let moveGaps (states: GameState list) : int list =
let moved =
states
|> List.pairwise
|> List.indexed
|> List.filter (fun (_, (before, after)) -> List.tryHead after.Snake <> List.tryHead before.Snake)
|> List.map (fun (i, _) -> i + 1)
moved |> List.pairwise |> List.map (fun (a, b) -> b - a)
// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
let private directions: Direction list = [ Up; Down; Left; Right ]
// The direction the head moved in to get from the second cell to the first (`Right` for a one-cell snake).
let private headingOf (snake: Point list) : Direction =
match snake with
| head :: second :: _ -> directions |> List.find (fun d -> Rules.move second d = head)
| _ -> Right
/// A random snake, head first: a random head, then a random walk that never leaves the board or crosses itself.
let genSnake (width: int) (height: int) (maxLen: int) : Random<Point list> =
// Extend `snake` at its tail by up to `left` more cells, each a random free in-bounds neighbour of the last cell.
let rec grow (snake: Point list) (left: int) : Random<Point list> =
if left <= 0 then
Random.pure snake
else
let last = List.last snake
let options =
directions
|> List.map (Rules.move last)
|> List.filter (fun p ->
p.X >= 0 && p.X < width && p.Y >= 0 && p.Y < height && not (Util.contains snake p (=)))
if List.isEmpty options then
Random.pure snake
else
elementOf options |> Random.bind (fun p -> grow (snake @ [ p ]) (left - 1))
Random.intBelow width
|> Random.bind (fun x -> Random.intBelow height |> Random.bind (fun y -> grow [ { X = x; Y = y } ] (maxLen - 1)))
/// A random sane Playing state on a `width` x `height` board (given).
let genState (width: int) (height: int) : Random<GameState> =
Random.intBelow (width * height)
|> Random.bind (fun n ->
genSnake width height (n + 1)
|> Random.bind (fun snake ->
let dir = headingOf snake
let state =
{ Width = width
Height = height
Snake = snake
Dir = dir
NextDir = dir
Apple = None
Seed = 0u
Score = 0
Status = Playing
Frame = 0
Queued = None }
let free = Spawn.freeCells state
elementOf (directions |> List.filter (fun d -> d <> opposite dir))
|> Random.bind (fun nextDir ->
Random.intBelow 2
|> Random.bind (fun coin ->
(if coin = 0 || List.isEmpty free then Random.pure None else elementOf free |> Random.map Some)
|> Random.map (fun apple -> { state with NextDir = nextDir; Apple = apple })))))
/// The step of m03, written from the rules instead of from `advance`: build the snake the step would give,
/// then ask `violations` whether that snake is on the board and does not overlap itself.
let advanceSpec (state: GameState) : GameState =
if state.Status <> Playing then
state
else
let head = Rules.move (List.head state.Snake) state.NextDir
let eating = state.Apple = Some head
let snake = head :: (if eating then state.Snake else Util.dropLast state.Snake)
let broken =
violations { state with Snake = snake }
|> List.exists (fun v -> v = "snake-in-bounds" || v = "snake-no-overlap")
if broken then
{ state with Dir = state.NextDir; Status = GameOver }
else
{ state with
Snake = snake
Dir = state.NextDir
Apple = if eating then None else state.Apple
Score = state.Score + (if eating then 1 else 0) }
+27 -6
View File
@@ -1,19 +1,40 @@
/// m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. /// m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions.
/// m10 adds pause, restart and a frame clock that speeds up as the score grows. /// m10 adds pause, restart, a frame counter that makes the snake speed up as the score grows, and a turn queue.
module Snake.Game module Snake.Game
open Snake.Model open Snake.Model
/// What `update` does with `Turn dir` from m10 on. With no turn pending (`NextDir = Dir`) it is the m03 `Rules.turn`.
/// With a turn pending, a key that is neither `NextDir` nor its opposite is remembered in `Queued`
/// (replacing an earlier one) and taken on the step after; any other key changes nothing.
let steer (state: GameState) (dir: Direction) : GameState =
if state.Status <> Playing then state
elif state.NextDir = state.Dir then Rules.turn state dir
elif dir <> state.NextDir && dir <> opposite state.NextDir then { state with Queued = Some dir }
else state
// After a step: a game still in play takes the queued turn, if there is one. It is always a legal turn, because the
// step just made `Dir` equal to the old `NextDir`, and `Queued` is neither that nor its opposite.
let private takeQueued (state: GameState) : GameState =
match state.Queued with
| Some d when state.Status = Playing -> { state with NextDir = d; Queued = None }
| _ -> state
// One game step as `update` takes it: `Spawn.tick`, then the queued turn.
let private step (state: GameState) : GameState = takeQueued (Spawn.tick state)
/// How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2. /// How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2.
let movesEvery (score: int) : int = max 2 (8 - score / 3) let movesEvery (score: int) : int = max 2 (8 - score / 3)
// One animation frame: count it, and every `movesEvery score`-th frame run a game step. // One animation frame. `Frame` counts the frames since the snake last moved; when it reaches
// `movesEvery Score` the count starts again and the game takes a step. Counting from the last move
// (not from the start of the game) keeps every step exactly `movesEvery` frames long, also right after a speed-up.
let private frame (state: GameState) : GameState = let private frame (state: GameState) : GameState =
if state.Status <> Playing then if state.Status <> Playing then
state state
else else
let counted = { state with Frame = state.Frame + 1 } let n = state.Frame + 1
if counted.Frame % movesEvery counted.Score = 0 then Spawn.tick counted else counted if n >= movesEvery state.Score then step { state with Frame = 0 } else { state with Frame = n }
// Pause and resume only make sense in those two statuses; a finished game stays finished. // Pause and resume only make sense in those two statuses; a finished game stays finished.
let private togglePause (state: GameState) : GameState = let private togglePause (state: GameState) : GameState =
@@ -26,8 +47,8 @@ let private togglePause (state: GameState) : GameState =
// Every case is listed (no `| _ ->`): adding an Action later makes the compiler point here. // Every case is listed (no `| _ ->`): adding an Action later makes the compiler point here.
let update (state: GameState) (action: Action) : GameState = let update (state: GameState) (action: Action) : GameState =
match action with match action with
| Tick -> Spawn.tick state | Tick -> step state
| Turn dir -> Rules.turn state dir | Turn dir -> steer state dir
| TogglePause -> togglePause state | TogglePause -> togglePause state
| Restart -> Spawn.newGame state.Seed | Restart -> Spawn.newGame state.Seed
| Frame -> frame state | Frame -> frame state
+25 -4
View File
@@ -9,11 +9,16 @@ open Snake.View
type Op = type Op =
| Draw of Shape | Draw of Shape
| RandomSeed | RandomSeed
// m10: the stored best score, read and written.
| LoadBest
| SaveBest of int
/// What the outside world answers: `Done` for a `Draw`, `Seed value` for a `RandomSeed`. /// What the outside world answers: `Done` for a `Draw` or a `SaveBest`, `Seed value` for a `RandomSeed`,
/// `Best value` for a `LoadBest`.
type OpResult = type OpResult =
| Done | Done
| Seed of uint32 | Seed of uint32
| Best of int
type IO<'a> = type IO<'a> =
| Pure of 'a | Pure of 'a
@@ -47,13 +52,24 @@ let randomSeed () : IO<uint32> =
|> map (fun result -> |> map (fun result ->
match result with match result with
| Seed value -> value | Seed value -> value
| Done -> failwith "RandomSeed must be answered with a Seed") | _ -> failwith "RandomSeed must be answered with a Seed")
let drawScene (scene: Scene) : IO<unit> = sequence (List.map draw scene) let drawScene (scene: Scene) : IO<unit> = sequence (List.map draw scene)
// m10: the stored best score. Reading it answers a number (built like randomSeed); saving answers Done, like a draw.
let loadBest () : IO<int> =
effect LoadBest
|> map (fun result ->
match result with
| Best value -> value
| _ -> failwith "LoadBest must be answered with a Best")
let saveBest (score: int) : IO<unit> = effect (SaveBest score) |> map ignore
// Answer each op with a canned result, collecting the ops as we go. Tail recursion: the compiler turns it // Answer each op with a canned result, collecting the ops as we go. Tail recursion: the compiler turns it
// into a loop, so long programs cannot overflow the stack, and still nothing is mutated. // into a loop, so long programs cannot overflow the stack, and still nothing is mutated.
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = // `best` (m10) answers LoadBest, the way `seed` answers RandomSeed.
let traceWith (seed: uint32) (best: int) (io: IO<'a>) : Traced<'a> =
let rec go io performed = let rec go io performed =
match io with match io with
| Pure a -> { Result = a; Ops = List.rev performed } | Pure a -> { Result = a; Ops = List.rev performed }
@@ -61,8 +77,13 @@ let trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
let answer = let answer =
match op with match op with
| RandomSeed -> Seed seed | RandomSeed -> Seed seed
| Draw _ -> Done | LoadBest -> Best best
| Draw _
| SaveBest _ -> Done
go (k answer) (op :: performed) go (k answer) (op :: performed)
go io [] go io []
// The Module 7 interpreter is the "no stored best" case.
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = traceWith seed 0 io
+44
View File
@@ -0,0 +1,44 @@
/// m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse.
module Snake.Life.App
open Snake
open Snake.Model
open Snake.IO
open Snake.Stream
open Snake.Life.Model
open Snake.Life.Rules
open Snake.Life.View
let GEN_MS = 150L
let lifeKey (key: string) : LifeAction option =
match key with
| " " -> Some LifeAction.TogglePause
| "n" | "N" -> Some LifeAction.Step
| "c" | "C" -> Some LifeAction.Clear
| "r" | "R" -> Some LifeAction.Randomize
| _ -> None
// Integer division rounds towards zero (-1 / 16 = 0), so the pixel is checked before dividing.
let lifeClick (x: int) (y: int) : LifeAction option =
if x >= 0 && y >= 0 && x / LCELL < LW && y / LCELL < LH then
Some(LifeAction.Toggle { X = x / LCELL; Y = y / LCELL })
else
None
let lifeActions: Stream<LifeAction> =
Stream.merge
(Stream.merge
(Stream.interval GEN_MS |> Stream.map (fun _ -> LifeAction.Frame))
(Stream.keyDowns |> Stream.choose lifeKey))
(Stream.clicks |> Stream.choose (fun (x, y) -> lifeClick x y))
let lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState> =
actions |> Stream.scan lifeUpdate (initialLife seed) |> Stream.startWith (initialLife seed)
let lifeMain () : IO<Stream<IO<unit>>> =
IO.randomSeed ()
|> IO.map (fun seed ->
lifeStates seed lifeActions
|> Stream.map (fun state -> IO.drawScene (lifeView state))
|> Stream.clampTime App.MAX_GAP_MS)
+32
View File
@@ -0,0 +1,32 @@
/// m13 life model: the whole world is one value, like the snake and the match.
module Snake.Life.Model
open Snake.Model
/// `Cells` is canonical: sorted row-major (by `Y`, then `X`), no duplicates, all on the board.
type LifeState =
{ Cells: Point list
Running: bool
Seed: uint32
Generation: int }
[<RequireQualifiedAccess>]
type LifeAction =
| Frame
| Toggle of Point
| TogglePause
| Step
| Clear
| Randomize
// The board, in cells: LW columns, LH rows, LCELL pixels per cell. Below it the page shows a status strip.
let LW = 40
let LH = 30
let LCELL = 16
let STRIP = 24
let initialLife (seed: uint32) : LifeState =
{ Cells = []
Running = false
Seed = seed
Generation = 0 }
+71
View File
@@ -0,0 +1,71 @@
/// m13 life rules: the next generation is a function of the whole old board, and nothing else.
module Snake.Life.Rules
open Snake
open Snake.Model
open Snake.Life.Model
// Every cell of the board, row-major (y, then x). Filtering this list keeps any result in canonical order for free.
let private board: Point list =
[ for y in 0 .. LH - 1 do
for x in 0 .. LW - 1 -> { X = x; Y = y } ]
let private onBoard (p: Point) : bool = p.X >= 0 && p.X < LW && p.Y >= 0 && p.Y < LH
// A cell's position in row-major order: the order the canonical list is sorted by.
let private index (p: Point) : int = p.Y * LW + p.X
let private offsets = [ (-1, -1); (0, -1); (1, -1); (-1, 0); (1, 0); (-1, 1); (0, 1); (1, 1) ]
// The up to eight neighbours of `p` that are on the board (the edges are dead: nothing wraps around).
let private neighbours (p: Point) : Point list =
offsets |> List.map (fun (dx, dy) -> { X = p.X + dx; Y = p.Y + dy }) |> List.filter onBoard
// B3/S23, every cell judged on the *old* list only. A Set of the old cells makes "is this neighbour alive?" one
// lookup, so a generation is 1200 x 8 lookups however many cells live. The set is built once and only read.
let lifeStep (cells: Point list) : Point list =
let alive = Set.ofList cells
let count p = neighbours p |> List.filter alive.Contains |> List.length
board
|> List.filter (fun p ->
let n = count p
n = 3 || (n = 2 && alive.Contains p))
// Add a dead cell or remove a live one, keeping the list sorted row-major without duplicates. Off the board: unchanged.
let private toggle (cells: Point list) (p: Point) : Point list =
if not (onBoard p) then
cells
elif List.contains p cells then
List.filter (fun c -> c <> p) cells
else
List.filter (fun c -> index c < index p) cells
@ [ p ]
@ List.filter (fun c -> index c > index p) cells
// A random board: one `intBelow 4` per cell in row-major order, the cell alive when the draw is 0 (about one cell in
// four). mapFold threads the seed through the 1200 draws; the result is the cells and the seed after the last draw.
let private randomCells (seed: uint32) : Point list * uint32 =
let draws, next = List.mapFold (fun s _ -> Random.run s (Random.intBelow 4)) seed board
List.zip board draws |> List.filter (fun (_, d) -> d = 0) |> List.map fst, next
let private nextGeneration (state: LifeState) : LifeState =
{ state with
Cells = lifeStep state.Cells
Generation = state.Generation + 1 }
let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState =
match action with
| LifeAction.Frame -> if state.Running then nextGeneration state else state
| LifeAction.Step -> if state.Running then state else nextGeneration state
| LifeAction.TogglePause -> { state with Running = not state.Running }
// Clicking edits the world whether it runs or not: the click is one action between two generations.
| LifeAction.Toggle p -> { state with Cells = toggle state.Cells p }
| LifeAction.Clear -> { state with Cells = []; Generation = 0 }
| LifeAction.Randomize ->
let cells, seed = randomCells state.Seed
{ state with
Cells = cells
Seed = seed
Generation = 0 }
+17
View File
@@ -0,0 +1,17 @@
/// m13 life view: live cells as squares, and a status strip under the board.
module Snake.Life.View
open Snake.View
open Snake.Life.Model
let private text x y str align = Text(x, y, str, "#ffffff", 14, align)
let private W = LW * LCELL
let private H = LH * LCELL
let lifeView (state: LifeState) : Scene =
[ Rect(0, 0, W, H, "#111111") ]
@ (state.Cells |> List.map (fun p -> Rect(p.X * LCELL + 1, p.Y * LCELL + 1, LCELL - 2, LCELL - 2, "#7cb342")))
@ [ Rect(0, H, W, STRIP, "#222222")
text 6 (H + 17) $"Generation {state.Generation}" "left"
text (W - 6) (H + 17) (if state.Running then "Running: Space pauses" else "Paused: Space runs, N steps") "right" ]
+5 -2
View File
@@ -25,6 +25,7 @@ type Action =
| Frame | Frame
/// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed. /// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed.
/// `Frame` and `Queued` stay unused until m10 (`Queued` holds a turn waiting for the next step).
type GameState = type GameState =
{ Width: int { Width: int
Height: int Height: int
@@ -35,7 +36,8 @@ type GameState =
Seed: uint32 Seed: uint32
Score: int Score: int
Status: Status Status: Status
Frame: int } Frame: int
Queued: Direction option }
let delta (dir: Direction) : Point = let delta (dir: Direction) : Point =
match dir with match dir with
@@ -61,4 +63,5 @@ let initialState (seed: uint32) : GameState =
Seed = seed Seed = seed
Score = 0 Score = 0
Status = Playing Status = Playing
Frame = 0 } Frame = 0
Queued = None }
+4 -1
View File
@@ -33,4 +33,7 @@ let pongStates (seed: uint32) (actions: Stream<PongAction>) : Stream<PongState>
let pongMain () : IO<Stream<IO<unit>>> = let pongMain () : IO<Stream<IO<unit>>> =
IO.randomSeed () IO.randomSeed ()
|> IO.map (fun seed -> pongStates seed pongActions |> Stream.map (fun state -> IO.drawScene (pongView state))) |> IO.map (fun seed ->
pongStates seed pongActions
|> Stream.map (fun state -> IO.drawScene (pongView state))
|> Stream.clampTime App.MAX_GAP_MS)
+2 -1
View File
@@ -89,5 +89,6 @@ let pongUpdate (state: PongState) (action: PongAction) : PongState =
match action with match action with
| PongAction.Press key -> setHeld state key true | PongAction.Press key -> setHeld state key true
| PongAction.Release key -> setHeld state key false | PongAction.Release key -> setHeld state key false
| PongAction.Restart -> initialPong state.Seed // A new match, but the keys are still where the player's fingers are.
| PongAction.Restart -> { initialPong state.Seed with Held = state.Held }
| PongAction.Frame -> frame state | PongAction.Frame -> frame state
+96
View File
@@ -0,0 +1,96 @@
/// m10 replay viewer: a recorded game is a seed and a list of actions; watching it is a second, tiny program
/// whose state is only a position in the list of the game's states.
module Snake.Replay
open Snake.Model
open Snake.View
open Snake.IO
open Snake.Stream
type Recording = { Seed: uint32; Actions: Action list }
type Viewer = { Index: int; Last: int; Playing: bool }
[<RequireQualifiedAccess>]
type ViewerAction =
| Next
| Prev
| First
| Last
| TogglePlay
| Beat
let REPLAY_MS = 100L
// Every state of the recorded game, from the new game to the end: the recording replayed with `history`.
let frames (recording: Recording) : GameState list =
Game.history (Spawn.newGame recording.Seed) recording.Actions
let viewerStart (last: int) : Viewer = { Index = 0; Last = last; Playing = false }
// Where each action moves the viewer, and whether it plays afterwards (before the end-of-recording rule).
let private move (v: Viewer) (action: ViewerAction) : Viewer =
let clamp i = min v.Last (max 0 i)
match action with
| ViewerAction.Next -> { v with Index = clamp (v.Index + 1) }
| ViewerAction.Prev -> { v with Index = clamp (v.Index - 1) }
| ViewerAction.First -> { v with Index = 0 }
| ViewerAction.Last -> { v with Index = v.Last }
// Play from the end means: watch it again from the start.
| ViewerAction.TogglePlay ->
if v.Playing then
{ v with Playing = false }
else
{ v with
Index = (if v.Index = v.Last then 0 else v.Index)
Playing = true }
| ViewerAction.Beat -> if v.Playing then { v with Index = clamp (v.Index + 1) } else v
// The index never leaves [0, Last], and the viewer never plays on the last frame, so autoplay stops by itself.
let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer =
let next = move viewer action
{ next with Playing = next.Playing && next.Index < next.Last }
let viewerKey (key: string) : ViewerAction option =
match key with
| "ArrowRight" | "d" | "D" -> Some ViewerAction.Next
| "ArrowLeft" | "a" | "A" -> Some ViewerAction.Prev
| "Home" -> Some ViewerAction.First
| "End" -> Some ViewerAction.Last
| " " -> Some ViewerAction.TogglePlay
| _ -> None
// The game's own picture of the current frame, then the position at the top right and a hint at the bottom.
let viewerView (frames: GameState list) (viewer: Viewer) : Scene =
let game = List.item viewer.Index frames
let w = game.Width * CELL
let hint =
if viewer.Playing then
"Space: pause"
else
"Left/Right: step Home/End: jump Space: play"
View.view game
@ [ Text(w - 6, 18, $"step {viewer.Index} / {viewer.Last}", "#ffffff", 16, "right")
Text(w / 2, game.Height * CELL - 8, hint, "#aaaaaa", 12, "center") ]
let replayActions: Stream<ViewerAction> =
Stream.merge
(Stream.interval REPLAY_MS |> Stream.map (fun _ -> ViewerAction.Beat))
(Stream.keyDowns |> Stream.choose viewerKey)
let viewerStates (last: int) (actions: Stream<ViewerAction>) : Stream<Viewer> =
actions |> Stream.scan viewerUpdate (viewerStart last) |> Stream.startWith (viewerStart last)
// It needs no seed from the world (the recording has one), so it starts with `pure`.
// The frames are computed once, here, and every drawing picks one of them.
let replayMain (recording: Recording) : IO<Stream<IO<unit>>> =
let fs = frames recording
IO.pure (
viewerStates (List.length fs - 1) replayActions
|> Stream.map (fun v -> IO.drawScene (viewerView fs v))
|> Stream.clampTime App.MAX_GAP_MS
)
+12 -1
View File
@@ -11,14 +11,23 @@ type Platform =
{ FillRect: int -> int -> int -> int -> string -> unit { FillRect: int -> int -> int -> int -> string -> unit
FillText: int -> int -> string -> string -> int -> string -> unit FillText: int -> int -> string -> string -> int -> string -> unit
RandomSeed: unit -> uint32 RandomSeed: unit -> uint32
// m10: the stored best score; reading must never fail (anything unreadable is 0).
LoadBest: unit -> int
SaveBest: int -> unit
StartClock: (int64 -> unit) -> unit StartClock: (int64 -> unit) -> unit
OnKeyDown: (string -> unit) -> unit OnKeyDown: (string -> unit) -> unit
OnKeyUp: (string -> unit) -> unit } OnKeyUp: (string -> unit) -> unit
// m13: clicks on the canvas, in canvas pixels.
OnClick: (int -> int -> unit) -> unit }
// Perform one op on the platform and return its answer. // Perform one op on the platform and return its answer.
let private perform (p: Platform) (op: Op) : OpResult = let private perform (p: Platform) (op: Op) : OpResult =
match op with match op with
| RandomSeed -> Seed(p.RandomSeed()) | RandomSeed -> Seed(p.RandomSeed())
| LoadBest -> Best(p.LoadBest())
| SaveBest score ->
p.SaveBest score
Done
| Draw(Rect(x, y, w, h, color)) -> | Draw(Rect(x, y, w, h, color)) ->
p.FillRect x y w h color p.FillRect x y w h color
Done Done
@@ -43,4 +52,6 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
p.OnKeyDown(fun key -> feed (KeyDown key)) p.OnKeyDown(fun key -> feed (KeyDown key))
p.OnKeyUp(fun key -> feed (KeyUp key)) p.OnKeyUp(fun key -> feed (KeyUp key))
// m13: a fourth kind of event. Programs that do not listen for clicks simply ignore them.
p.OnClick(fun x y -> feed (Click(x, y)))
p.StartClock(fun t -> feed (Time t)) p.StartClock(fun t -> feed (Time t))
+65
View File
@@ -0,0 +1,65 @@
/// m07b safeio (optional): a stack-safe IO. Same idea as m07's IO, plus one more case, so that `bind` only builds
/// data and no program shape can overflow the stack, however deeply its `bind`s are nested.
/// - `Pure a` done, with a value
/// - `Effect (op, k)` perform `op`, then continue with `k result`
/// - `FlatMap (io, f)` run `io`, then continue with `f result`
/// The ops, their answers and the result of `trace` are m07's `IO.Op`, `IO.OpResult` and `IO.Traced`.
/// (Not `open Snake.IO`: its cases are also called Pure and Effect.)
module Snake.SafeIO
/// `FlatMap`'s inner program produces some type `X` that only its continuation knows. A union case cannot have a
/// type parameter of its own, so `X` is erased to `obj`; only `bind` builds a `FlatMap`, and it keeps the two in step.
type SafeIO<'a> =
| Pure of 'a
| Effect of IO.Op * (IO.OpResult -> SafeIO<'a>)
| FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>)
// Given: the same program, seen as a SafeIO<obj>. Constant work: continuations are wrapped, not called.
let rec private erase (io: SafeIO<'a>) : SafeIO<obj> =
match io with
| Pure a -> Pure(box a)
| Effect(op, k) -> Effect(op, fun r -> erase (k r))
| FlatMap(m, g) -> FlatMap(m, fun x -> erase (g x))
let pure (a: 'a) : SafeIO<'a> = Pure a
let effect (op: IO.Op) : SafeIO<IO.OpResult> = Effect(op, pure)
// Only builds a node: never calls f, not even when io is Pure. The unbox cannot fail: x is the result of `io`.
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = FlatMap(erase io, fun x -> f (unbox<'a> x))
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = io |> bind (fun a -> Pure(f a))
// A left fold on purpose: with SafeIO the nesting no longer matters.
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = List.fold (fun acc io -> acc |> bind (fun () -> io)) (pure ()) ios
// One rewrite towards a program whose top is a Pure or an Effect. Constant work, no recursion.
// FlatMap(Pure a, f) -> f a
// FlatMap(Effect(op, k), f) -> Effect(op, fun r -> FlatMap(k r, f))
// FlatMap(FlatMap(m, g), f) -> FlatMap(m, fun x -> FlatMap(g x, f)) (re-association)
// Pure and Effect are already in that shape and come back unchanged.
let step (io: SafeIO<'a>) : SafeIO<'a> =
match io with
| FlatMap(Pure x, f) -> f x
| FlatMap(Effect(op, k), f) -> Effect(op, fun r -> FlatMap(k r, f))
| FlatMap(FlatMap(m, g), f) -> FlatMap(m, fun x -> FlatMap(g x, f))
| other -> other
// Given: the canned answer of the pure interpreter, as in m07. SafeIO keeps Module 7's two operations; the stored
// best score of Module 10 is not part of it, so its operations are refused loudly rather than given a meaning here.
let private answer (seed: uint32) (op: IO.Op) : IO.OpResult =
match op with
| IO.RandomSeed -> IO.Seed seed
| IO.Draw _ -> IO.Done
| IO.LoadBest
| IO.SaveBest _ -> failwith "SafeIO.trace answers only Module 7's operations (Draw and RandomSeed)"
// `loop` calls itself only in tail position, so the compiler turns it into a loop: the stack stays flat.
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> =
let rec loop (current: SafeIO<'a>) (performed: IO.Op list) : IO.Traced<'a> =
match current with
| Pure a -> { IO.Result = a; IO.Ops = List.rev performed }
| Effect(op, k) -> loop (k (answer seed op)) (op :: performed)
| FlatMap _ -> loop (step current) performed
loop io []
+8
View File
@@ -10,17 +10,25 @@
<Compile Include="Model.fs" /> <Compile Include="Model.fs" />
<Compile Include="Rules.fs" /> <Compile Include="Rules.fs" />
<Compile Include="Random.fs" /> <Compile Include="Random.fs" />
<Compile Include="State.fs" />
<Compile Include="Spawn.fs" /> <Compile Include="Spawn.fs" />
<Compile Include="Game.fs" /> <Compile Include="Game.fs" />
<Compile Include="View.fs" /> <Compile Include="View.fs" />
<Compile Include="IO.fs" /> <Compile Include="IO.fs" />
<Compile Include="SafeIO.fs" />
<Compile Include="Stream.fs" /> <Compile Include="Stream.fs" />
<Compile Include="Input.fs" /> <Compile Include="Input.fs" />
<Compile Include="App.fs" /> <Compile Include="App.fs" />
<Compile Include="Runtime.fs" /> <Compile Include="Runtime.fs" />
<Compile Include="Replay.fs" />
<Compile Include="Pong/Model.fs" /> <Compile Include="Pong/Model.fs" />
<Compile Include="Pong/Rules.fs" /> <Compile Include="Pong/Rules.fs" />
<Compile Include="Pong/View.fs" /> <Compile Include="Pong/View.fs" />
<Compile Include="Pong/App.fs" /> <Compile Include="Pong/App.fs" />
<Compile Include="Life/Model.fs" />
<Compile Include="Life/Rules.fs" />
<Compile Include="Life/View.fs" />
<Compile Include="Life/App.fs" />
<Compile Include="Check.fs" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+27
View File
@@ -0,0 +1,27 @@
/// m04 state (optional, Exercise 4.7): the general pattern behind Random. A `State<'s, 'a>` wraps a function
/// `s -> (value, nextS)`: it reads a state, produces a value and hands on a new state. `Random<'a>` is
/// `State<uint32, 'a>`. Nothing in the game uses this file; it is here to show that Random was one example of a bigger idea.
module Snake.State
type State<'s, 'a> = State of ('s -> 'a * 's)
let run (s: 's) (State f: State<'s, 'a>) : 'a * 's = f s
let pure (a: 'a) : State<'s, 'a> = State(fun s -> (a, s))
let map (f: 'a -> 'b) (st: State<'s, 'a>) : State<'s, 'b> =
State(fun s ->
let a, next = run s st
(f a, next))
let bind (f: 'a -> State<'s, 'b>) (st: State<'s, 'a>) : State<'s, 'b> =
State(fun s ->
let a, next = run s st
run next (f a))
// A function of unit: a generic module-level value is not allowed in F#.
let get () : State<'s, 's> = State(fun s -> (s, s))
let put (s: 's) : State<'s, unit> = State(fun _ -> ((), s))
let modify (f: 's -> 's) : State<'s, unit> = State(fun s -> ((), f s))
+30 -1
View File
@@ -2,11 +2,13 @@
/// There is no hidden state: whatever a stream "remembers" lives in the next stream it returns. /// There is no hidden state: whatever a stream "remembers" lives in the next stream it returns.
module Snake.Stream module Snake.Stream
/// Something that happens to the program: time passing (ms since start) or a key going down or up. /// Something that happens to the program: time passing (ms since start) or a key going down or up, and from
/// Module 13 a mouse click at `(x, y)` in canvas pixels. Every stream ignores the kinds of event it does not use.
type Event = type Event =
| Time of int64 | Time of int64
| KeyDown of string | KeyDown of string
| KeyUp of string | KeyUp of string
| Click of int * int
type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>) type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>)
@@ -31,6 +33,16 @@ let keyUps: Stream<string> =
source () source ()
// (m13, given in src/) The position of every Click event, in canvas pixels. Like the key sources it remembers nothing.
let clicks: Stream<int * int> =
let rec source () =
Stream(fun e ->
match e with
| Click(x, y) -> [ (x, y) ], source ()
| _ -> [], source ())
source ()
// `last` is the time of the previous Time event. Multiples of ms in (last, t] fire, ascending. // `last` is the time of the previous Time event. Multiples of ms in (last, t] fire, ascending.
let rec private intervalFrom (ms: int64) (last: int64) : Stream<int64> = let rec private intervalFrom (ms: int64) (last: int64) : Stream<int64> =
Stream(fun e -> Stream(fun e ->
@@ -76,6 +88,23 @@ let startWith (v: 'a) (s: Stream<'a>) : Stream<'a> =
let out, next = step e s let out, next = step e s
v :: out, next) v :: out, next)
// (m10) `last` is the previous real time, `clock` the time the inner stream has been told. A long silence
// (a background tab) moves `clock` by at most `maxGap`, so the inner stream never has to catch up on minutes of beats.
let rec private clampFrom (maxGap: int64) (last: int64) (clock: int64) (s: Stream<'a>) : Stream<'a> =
Stream(fun e ->
match e with
| Time t ->
let now = clock + min (max (t - last) 0L) maxGap
let out, next = step (Time now) s
out, clampFrom maxGap t now next
| KeyDown _
| KeyUp _
| Click _ ->
let out, next = step e s
out, clampFrom maxGap last clock next)
let clampTime (maxGap: int64) (s: Stream<'a>) : Stream<'a> = clampFrom maxGap 0L 0L s
// mapFold threads the stream through the events like fold, and keeps each step's outputs. // mapFold threads the stream through the events like fold, and keeps each step's outputs.
let run (events: Event list) (s: Stream<'a>) : 'a list = let run (events: Event list) (s: Stream<'a>) : 'a list =
List.mapFold (fun stream e -> step e stream) s events |> fst |> List.concat List.mapFold (fun stream e -> step e stream) s events |> fst |> List.concat
+39 -2
View File
@@ -2,6 +2,7 @@
module Snake.App module Snake.App
open Snake.Model open Snake.Model
open Snake.View
open Snake.IO open Snake.IO
open Snake.Stream open Snake.Stream
@@ -11,6 +12,9 @@ let TICK_MS = 150L
/// Milliseconds between animation frames (m10). /// Milliseconds between animation frames (m10).
let FRAME_MS = 25L let FRAME_MS = 25L
/// (m10) The longest step of time the game accepts at once, in milliseconds (see `clampTime` in Stream.fs).
let MAX_GAP_MS = 250L
/// Stream of everything that can happen to the game: `Tick` every TICK_MS (from `Stream.interval`), /// Stream of everything that can happen to the game: `Tick` every TICK_MS (from `Stream.interval`),
/// merged with the action for each key press that `Input.keyToAction` recognises (from `Stream.keyDowns`). /// merged with the action for each key press that `Input.keyToAction` recognises (from `Stream.keyDowns`).
/// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.) /// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.)
@@ -27,7 +31,40 @@ let states (seed: uint32) (actions: Stream<Action>) : Stream<GameState> = failwi
/// The drawing of one state, as an IO: `IO.drawScene (View.view state)`. /// The drawing of one state, as an IO: `IO.drawScene (View.view state)`.
let render (state: GameState) : IO<unit> = failwith "TODO m09" let render (state: GameState) : IO<unit> = failwith "TODO m09"
/// (m10) A session: the game, the best score so far, and whether the action that produced this session raised the best.
type Session = { Game: GameState; Best: int; Improved: bool }
/// (m10) One action: `game' = Game.update session.Game action`, `best' = max session.Best game'.Score`,
/// `improved = best' > session.Best` (strictly greater: equalling the record is not a new record).
let sessionUpdate (session: Session) (action: Action) : Session = failwith "TODO m10"
/// (m10) Like `states`, one level up: scan with `sessionUpdate` from `s0`, and emit `s0` first (startWith),
/// with `s0 = { Game = Spawn.newGame seed; Best = best; Improved = false }`.
let sessions (seed: uint32) (best: int) (actions: Stream<Action>) : Stream<Session> = failwith "TODO m10"
/// (m10) `View.view session.Game` followed by one more shape: the text `Best: <best>` at the top right,
/// `Text(game.Width * CELL - 6, 18, "Best: 3", "#ffffff", 16, "right")`.
let sessionView (session: Session) : Scene = failwith "TODO m10"
/// (m10) `IO.drawScene (sessionView session)`, followed by `IO.saveBest session.Best` when `session.Improved`
/// (and only then).
let renderSession (session: Session) : IO<unit> = failwith "TODO m10"
/// The whole program as a value: ask for a random seed, then turn the stream of states (built from `classicActions` /// The whole program as a value: ask for a random seed, then turn the stream of states (built from `classicActions`
/// in m09, from `speedActions` after m10) into a stream of drawings (`render`). Nothing runs here. /// in m09, from `speedActions` after m10) into a stream of drawings (`render`).
/// A function of `unit`, like `IO.randomSeed`. /// (m10) The whole stream is wrapped in `Stream.clampTime MAX_GAP_MS`.
/// (m10, Exercise 10.7) After the seed, ask for the stored best (`IO.loadBest ()`), and draw the stream of
/// `sessions seed best speedActions` with `renderSession` instead of the states with `render`.
/// Nothing runs here. A function of `unit`, like `IO.randomSeed`.
let main () : IO<Stream<IO<unit>>> = failwith "TODO m09" let main () : IO<Stream<IO<unit>>> = failwith "TODO m09"
/// (m10) `play` that also passes `best` to both traces (`IO.traceWith seed best`), so that `LoadBest` is answered
/// with it.
let playWith (seed: uint32) (best: int) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list =
failwith "TODO m10"
/// (m09) The whole program in one equality: for each drawing that `main` emits while it is fed `events` (a list of
/// inputs), the list of ops that drawing performs. Interpret `main` with `IO.trace seed`, `Stream.run` the stream it
/// produces on `events`, and trace each emitted IO with the same seed, keeping its `Ops`.
/// (m10) Once `playWith` (above) is written, `play` becomes its "no stored best" case: `playWith seed 0 events main`.
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = failwith "TODO m09"
+126
View File
@@ -0,0 +1,126 @@
/// m12 check: property-based testing, built from the course's own parts.
/// A generator of test data is nothing new: it is a `Random<'a>` from Random.fs (`seed -> (value, nextSeed)`).
module Snake.Check
open Snake.Model
open Snake.Random
/// What `forAll` reports: every value passed (`Passed runs`), or the first one that did not (`Failed`, see `forAll`).
type CheckResult<'a> =
| Passed of runs: int
| Failed of run: int * seed: uint32 * value: 'a
/// A Random that gives one of the elements of the non-empty list `xs`: the element at index `i`,
/// where `i` is drawn with `Random.intBelow (List.length xs)`.
let elementOf (xs: 'a list) : Random<'a> = failwith "TODO m12"
/// A Random that gives a list of `n` values drawn from `gen`, one after another: the first draw starts from the
/// incoming seed, each later draw from the seed the previous one left behind, and the last draw's seed is the result's.
/// `listOf 0 gen` gives `[]` and leaves the seed alone. No loop: recursion on `n`, with `Random.bind` and `Random.map`.
let listOf (n: int) (gen: Random<'a>) : Random<'a list> = failwith "TODO m12"
// Ticks appear three times so that the snake moves more often than it turns.
let private actionPool: Action list =
[ Tick; Tick; Tick; Turn Up; Turn Down; Turn Left; Turn Right ]
/// A generator of `n` random actions (given; it works once `elementOf` and `listOf` do).
let genActions (n: int) : Random<Action list> = listOf n (elementOf actionPool)
/// Check the property `prop` (a function value -> bool) on `runs` values drawn from `gen`.
/// The first value is drawn from `seed`, each later one from the seed the previous draw left behind.
/// Stop at the first value for which `prop` is false.
/// Result: `Passed runs` when every value passed, otherwise `Failed(run, seed, value)`, where `run` is the 0-based
/// number of the failing draw, `value` is the counterexample and `seed` is the seed that draw started from
/// (so `Random.run seed gen |> fst` gives `value` again).
/// Write it with a tail-recursive helper that carries the run number and the seed.
let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a> = failwith "TODO m12"
/// Every list that is `xs` with exactly one element removed, in the order of the removed index:
/// `removals [ 1; 2; 3 ]` is `[ [ 2; 3 ]; [ 1; 3 ]; [ 1; 2 ] ]`; `removals []` is `[]`.
let removals (xs: 'a list) : 'a list list = failwith "TODO m12"
/// Shrink a failing list. `fails` is a function list -> bool and `fails xs` is true.
/// If some list in `removals xs` still fails, continue with the first one that does; when none fails, return `xs`.
/// The result is "1-minimal": it fails, and removing any single element makes it pass.
let minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list = failwith "TODO m12"
/// The names of the invariants that `state` breaks, in this order (an empty list means the state is sane):
/// "snake-not-empty" the snake has at least one cell
/// "snake-in-bounds" every cell of the snake is on the board
/// "snake-no-overlap" no two cells of the snake are equal
/// "snake-connected" each cell of the snake is exactly one step (up, down, left or right) from the one before it
/// "apple-in-bounds" the apple, if there is one (`Some`), is on the board
/// "apple-not-on-snake" the apple, if there is one (`Some`), is not on a cell of the snake
/// "turn-not-reversed" `NextDir` is not the opposite of `Dir`
/// "queued-not-reversed" `Queued` is `None`, or is not the opposite of `NextDir`
/// No apple (`None`) breaks neither apple invariant.
let violations (state: GameState) : string list = failwith "TODO m12"
/// Given the states of a game in order, the snake "moves at index i" (i >= 1) when the head of `states[i]`
/// differs from the head of `states[i - 1]`. `moveGaps states` is the list of differences between consecutive
/// such indices: moves at 2, 5 and 12 give `[ 3; 7 ]`. Fewer than two moves give `[]`.
let moveGaps (states: GameState list) : int list = failwith "TODO m12"
// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
/// (Exercise 12.6) A Random that gives a snake, head first, on a `width` x `height` board, with at most `maxLen` cells
/// (`maxLen >= 1`). The draws, in this exact order (so that every track gets the same snake from the same seed):
/// 1. the head: `x` with `Random.intBelow width`, then `y` with `Random.intBelow height`;
/// 2. then, while the snake has fewer than `maxLen` cells: look at the neighbours of its *last* cell, in the order
/// Up, Down, Left, Right (`Rules.move last dir`), and keep those that are on the board and not yet in the snake.
/// If none is left, stop early (with `Random.pure`, no draw). Otherwise append the one picked with
/// `elementOf neighbours` and go on.
/// So the result is always on the board, connected and free of overlap. No loop: recursion, with `Random.bind`.
let genSnake (width: int) (height: int) (maxLen: int) : Random<Point list> = failwith "TODO m12"
let private directions: Direction list = [ Up; Down; Left; Right ]
// The direction the head moved in to get from the second cell to the first (`Right` for a one-cell snake).
let private headingOf (snake: Point list) : Direction =
match snake with
| head :: second :: _ -> directions |> List.find (fun d -> Rules.move second d = head)
| _ -> Right
/// A random sane Playing state on a `width` x `height` board (given; it works once `genSnake` does). Draws in order:
/// the snake's length limit `n + 1` with `n` from `Random.intBelow (width * height)`; the snake with `genSnake`;
/// `NextDir` with `elementOf` among Up, Down, Left, Right without the opposite of `Dir` (so: `Dir` or a perpendicular
/// direction); a coin `Random.intBelow 2`: 0 means no apple, 1 means an apple at `elementOf (Spawn.freeCells ...)`
/// (no apple if no cell is free). `Dir` is the direction from the snake's second cell to its head (`Right` for a
/// one-cell snake); seed, score and frame are 0, `Queued` is `None`.
let genState (width: int) (height: int) : Random<GameState> =
Random.intBelow (width * height)
|> Random.bind (fun n ->
genSnake width height (n + 1)
|> Random.bind (fun snake ->
let dir = headingOf snake
let state =
{ Width = width
Height = height
Snake = snake
Dir = dir
NextDir = dir
Apple = None
Seed = 0u
Score = 0
Status = Playing
Frame = 0
Queued = None }
let free = Spawn.freeCells state
elementOf (directions |> List.filter (fun d -> d <> opposite dir))
|> Random.bind (fun nextDir ->
Random.intBelow 2
|> Random.bind (fun coin ->
(if coin = 0 || List.isEmpty free then Random.pure None else elementOf free |> Random.map Some)
|> Random.map (fun apple -> { state with NextDir = nextDir; Apple = apple })))))
/// (Exercise 12.7) The step of m03, written straight from the rules, as a second opinion on `Rules.advance`.
/// Not Playing: the state unchanged. Otherwise build the snake the step would produce: the new head
/// (`Rules.move head NextDir`) in front, followed by the old snake, without its last cell unless the new head is on
/// the apple. Then ask `violations` about the state with that snake: if the list contains "snake-in-bounds" or
/// "snake-no-overlap", the result is m03's game over (`Dir = NextDir`, `Status = GameOver`, nothing else changed);
/// otherwise the state with the new snake, `Dir = NextDir`, and, when eating, `Apple = None` and the score one higher.
/// Do not call `Rules.advance`. On every sane state the result must equal `Rules.advance state`.
let advanceSpec (state: GameState) : GameState = failwith "TODO m12"
+14 -2
View File
@@ -3,6 +3,13 @@ module Snake.Game
open Snake.Model open Snake.Model
/// (m10, Exercise 10.6) What `update` does with `Turn dir` from m10 on (the turn queue).
/// - not Playing: the state unchanged;
/// - no turn pending (`NextDir = Dir`): `Rules.turn state dir`, the m03 rule;
/// - a turn pending (`NextDir <> Dir`): if `dir` is neither `NextDir` nor `opposite NextDir`, the state with
/// `Queued = Some dir` (a later key replaces an earlier queued one); otherwise the state unchanged.
let steer (state: GameState) (dir: Direction) : GameState = failwith "TODO m10"
/// How many frames pass between game steps (m10): `max 2 (8 - score / 3)` (integer division). /// How many frames pass between game steps (m10): `max 2 (8 - score / 3)` (integer division).
/// Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2. /// Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2.
let movesEvery (score: int) : int = failwith "TODO m10" let movesEvery (score: int) : int = failwith "TODO m10"
@@ -14,8 +21,13 @@ let movesEvery (score: int) : int = failwith "TODO m10"
/// ///
/// (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status. /// (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status.
/// (m10) `Restart` gives `Spawn.newGame state.Seed`, whatever the status. /// (m10) `Restart` gives `Spawn.newGame state.Seed`, whatever the status.
/// (m10) `Frame` does nothing unless Playing; otherwise it adds 1 to `Frame`, and when the new `Frame` is a multiple of /// (m10) `Frame` does nothing unless Playing. Otherwise it counts the frame: `n = Frame + 1`. While `n` is below
/// `movesEvery Score` it also runs a game step (`Spawn.tick`) on that incremented state. /// `movesEvery Score` the result is the state with `Frame = n`. When `n` reaches `movesEvery Score` (`n >= ...`),
/// the count starts again and the game takes a step: the result is `Spawn.tick` of the state with `Frame = 0`.
/// So `Frame` is the number of frames since the snake last moved.
/// (m10, Exercise 10.6) The turn queue: `Turn` uses `steer` instead of `Rules.turn`, and after every game step `update`
/// takes (the `Tick` case and the stepping branch of `Frame`), if the result is Playing and `Queued` is `Some d`,
/// the result gets `NextDir = d` and `Queued = None`. A result that is not Playing keeps `Queued` as it is.
/// In m10, match every case of `Action` explicitly (no `| _ ->`), so the compiler tells you if one is forgotten. /// In m10, match every case of `Action` explicitly (no `| _ ->`), so the compiler tells you if one is forgotten.
let update (state: GameState) (action: Action) : GameState = failwith "TODO m05" let update (state: GameState) (action: Action) : GameState = failwith "TODO m05"
+20 -1
View File
@@ -1,6 +1,8 @@
/// m07 io: effects as values. An `IO<'a>` is a description of a program that, when interpreted, produces an `'a`: /// m07 io: effects as values. An `IO<'a>` is a description of a program that, when interpreted, produces an `'a`:
/// - `Pure a` finished, nothing left to do /// - `Pure a` finished, nothing left to do
/// - `Effect (op, k)` perform `op`, then continue with `k result` (which returns another IO) /// - `Effect (op, k)` perform `op`, then continue with `k result` (which returns another IO)
/// (m10) Two more operations, for a best score that survives a reload: `LoadBest` (answered with `Best value`, the
/// stored best) and `SaveBest score` (answered with `Done`).
/// Building an IO never performs anything; only an interpreter (`trace` here, `Runtime.runIO` in m09) does. /// Building an IO never performs anything; only an interpreter (`trace` here, `Runtime.runIO` in m09) does.
module Snake.IO module Snake.IO
@@ -10,11 +12,17 @@ open Snake.View
type Op = type Op =
| Draw of Shape | Draw of Shape
| RandomSeed | RandomSeed
/// (m10) Read the stored best score.
| LoadBest
/// (m10) Store `score` as the new best.
| SaveBest of int
/// What the outside world answers: `Done` for a `Draw`, `Seed value` (an unsigned 32-bit number) for a `RandomSeed`. /// What the outside world answers: `Done` for a `Draw`, `Seed value` (an unsigned 32-bit number) for a `RandomSeed`;
/// (m10) `Best value` for a `LoadBest`, `Done` for a `SaveBest`.
type OpResult = type OpResult =
| Done | Done
| Seed of uint32 | Seed of uint32
| Best of int
type IO<'a> = type IO<'a> =
| Pure of 'a | Pure of 'a
@@ -50,7 +58,18 @@ let randomSeed () : IO<uint32> = failwith "TODO m07"
/// Draw every shape of a scene in order: `sequence (List.map draw scene)`. /// Draw every shape of a scene in order: `sequence (List.map draw scene)`.
let drawScene (scene: Scene) : IO<unit> = failwith "TODO m07" let drawScene (scene: Scene) : IO<unit> = failwith "TODO m07"
/// (m10) An IO that reads the stored best score and produces it (the `value` of the `Best` answer).
/// A function of `unit`, like `randomSeed ()`; an answer other than `Best` is a broken interpreter: fail loudly.
let loadBest () : IO<int> = failwith "TODO m10"
/// (m10) An IO that stores `score` as the new best and produces `()`: built like `draw`, from `SaveBest score`.
let saveBest (score: int) : IO<unit> = failwith "TODO m10"
/// (m10) `trace` that also answers every `LoadBest` with `Best best` and every `SaveBest` with `Done`.
let traceWith (seed: uint32) (best: int) (io: IO<'a>) : Traced<'a> = failwith "TODO m10"
/// A pure interpreter for tests. Answers every `RandomSeed` with `Seed seed` and every `Draw` with `Done`. /// A pure interpreter for tests. Answers every `RandomSeed` with `Seed seed` and every `Draw` with `Done`.
/// Returns the final value and the operations in the order they were executed. /// Returns the final value and the operations in the order they were executed.
/// Implemented by recursion, with no mutation. /// Implemented by recursion, with no mutation.
/// (m10) Once `traceWith` (above) is written, `trace` becomes its "no stored best" case: `traceWith seed 0 io`.
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = failwith "TODO m07" let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = failwith "TODO m07"
+34
View File
@@ -0,0 +1,34 @@
/// m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse.
module Snake.Life.App
open Snake
open Snake.IO
open Snake.Stream
open Snake.Life.Model
open Snake.Life.Rules
open Snake.Life.View
/// Milliseconds between two generations while the world runs.
let GEN_MS = 150L
/// A key press to a `LifeAction`, if any: " " -> TogglePause, "n"/"N" -> Step, "c"/"C" -> Clear,
/// "r"/"R" -> Randomize. Any other key -> `None`.
let lifeKey (key: string) : LifeAction option = failwith "TODO m13"
/// A click at canvas pixel `(x, y)` to `Toggle` of the cell under it (`x / LCELL` and `y / LCELL`, rounded down),
/// or `None` when the pixel is not on the board (left of it, above it, right of it, or in the status strip below it).
/// Careful: F# integer division rounds towards zero, so `-1 / 16` is 0. Check the pixel before you divide.
let lifeClick (x: int) (y: int) : LifeAction option = failwith "TODO m13"
/// Everything that can happen to the world: `Frame` every GEN_MS (from `Stream.interval`), merged with `lifeKey` of
/// every key press, merged with `lifeClick` of every click (`Stream.clicks`), in that order.
/// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.)
let lifeActions: Stream<LifeAction> = Stream(fun _ -> failwith "TODO m13")
/// Fold the actions into a stream of states: start from `initialLife seed`, `lifeUpdate` for each action, emit the
/// initial state first.
let lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState> = failwith "TODO m13"
/// The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings
/// of `lifeView`, and wrap that stream in `Stream.clampTime App.MAX_GAP_MS`. A function of `unit`, like `App.main`.
let lifeMain () : IO<Stream<IO<unit>>> = failwith "TODO m13"
+38
View File
@@ -0,0 +1,38 @@
/// m13 life model: the whole world is one value, like the snake and the match.
module Snake.Life.Model
open Snake.Model
/// The whole world. `Cells` are the live cells, always canonical: sorted row-major (by `Y`, then by `X`), each cell
/// at most once, all on the board. `Running` says whether generations follow each other on their own; `Seed` is the
/// unsigned 32-bit random seed used by `Randomize`; `Generation` counts the generations since the last Clear or
/// Randomize.
type LifeState =
{ Cells: Point list
Running: bool
Seed: uint32
Generation: int }
/// Everything that can happen to the world, as data. `Toggle` carries the cell that was clicked.
/// (RequireQualifiedAccess: Snake's `Action` also has `Frame` and `TogglePause`.)
[<RequireQualifiedAccess>]
type LifeAction =
| Frame
| Toggle of Point
| TogglePause
| Step
| Clear
| Randomize
// The board, in cells: LW columns, LH rows. Below it the page shows a status strip.
let LW = 40
let LH = 30
/// Pixels per cell.
let LCELL = 16
/// Height of the status strip under the board, in pixels.
let STRIP = 24
/// A fresh world: no live cells (`Cells = []`), paused (`Running = false`), the given seed, generation 0.
let initialLife (seed: uint32) : LifeState = failwith "TODO m13"
+27
View File
@@ -0,0 +1,27 @@
/// m13 life rules: the next generation is a function of the whole old board, and nothing else.
module Snake.Life.Rules
open Snake
open Snake.Model
open Snake.Life.Model
/// One generation of Conway's Life on the LW x LH board (B3/S23), from a canonical list of live cells to a canonical
/// list (sorted row-major: by `Y`, then `X`; no duplicates).
/// - A live cell with 2 or 3 live neighbours stays alive; a dead cell with exactly 3 live neighbours is born;
/// every other cell is dead in the next generation.
/// - The neighbours of a cell are the up to 8 cells around it that are on the board: the edges are dead,
/// nothing wraps around.
/// - Every cell is judged by the *old* board only.
let lifeStep (cells: Point list) : Point list = failwith "TODO m13"
/// The one function that says how the world changes.
/// - `Frame`: when running, the next generation (`lifeStep`) and `Generation + 1`; when paused, unchanged.
/// - `Step`: when paused, the next generation and `Generation + 1`; when running, unchanged.
/// - `TogglePause`: `Running` flips.
/// - `Toggle cell`: a dead cell becomes alive, a live one dead, whether the world runs or not; `Cells` stays canonical
/// (insert the new cell at its row-major place). A cell off the board changes nothing. `Generation` is unchanged.
/// - `Clear`: no live cells, generation 0; `Running` and `Seed` are kept.
/// - `Randomize`: draw `Random.intBelow 4` (Module 4) once for every cell of the board, in row-major order
/// (y = 0..LH-1, and within a row x = 0..LW-1), threading the seed from `state.Seed`; a cell is alive when its draw
/// is 0. The state gets those cells, the seed after the last draw, and generation 0; `Running` is kept.
let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState = failwith "TODO m13"
+11
View File
@@ -0,0 +1,11 @@
/// m13 life view: live cells as squares, and a status strip under the board.
module Snake.Life.View
open Snake.View
open Snake.Life.Model
/// Describe the world as shapes, drawn in order (all text "#ffffff", size 14):
/// the board `Rect(0, 0, 640, 480, "#111111")`; one `Rect(x*16+1, y*16+1, 14, 14, "#7cb342")` per live cell, in the
/// order of `Cells`; the status strip `Rect(0, 480, 640, 24, "#222222")`; `Text(6, 497, "Generation <n>", align "left")`;
/// `Text(634, 497, "Running: Space pauses"` when running, else `"Paused: Space runs, N steps", align "right")`.
let lifeView (state: LifeState) : Scene = failwith "TODO m13"
+4 -2
View File
@@ -25,6 +25,7 @@ type Action =
| Frame | Frame
/// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed. /// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed.
/// `Frame` and `Queued` stay unused until m10 (`Queued` holds a turn waiting for the next step).
type GameState = type GameState =
{ Width: int { Width: int
Height: int Height: int
@@ -35,7 +36,8 @@ type GameState =
Seed: uint32 Seed: uint32
Score: int Score: int
Status: Status Status: Status
Frame: int } Frame: int
Queued: Direction option }
/// The unit step for a direction: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0). Y grows downward. /// The unit step for a direction: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0). Y grows downward.
let delta (dir: Direction) : Point = failwith "TODO m02" let delta (dir: Direction) : Point = failwith "TODO m02"
@@ -44,5 +46,5 @@ let delta (dir: Direction) : Point = failwith "TODO m02"
let opposite (dir: Direction) : Direction = failwith "TODO m02" let opposite (dir: Direction) : Direction = failwith "TODO m02"
/// A fresh game: 20x20, snake (10,10),(9,10),(8,10) heading Right (`Dir` and `NextDir`), /// A fresh game: 20x20, snake (10,10),(9,10),(8,10) heading Right (`Dir` and `NextDir`),
/// no apple, the given seed, score 0, status Playing, frame 0. /// no apple, the given seed, score 0, status Playing, frame 0, nothing queued (`Queued = None`).
let initialState (seed: uint32) : GameState = failwith "TODO m02" let initialState (seed: uint32) : GameState = failwith "TODO m02"
+1 -1
View File
@@ -25,5 +25,5 @@ let pongActions: Stream<PongAction> = Stream(fun _ -> failwith "TODO m11")
let pongStates (seed: uint32) (actions: Stream<PongAction>) : Stream<PongState> = failwith "TODO m11" let pongStates (seed: uint32) (actions: Stream<PongAction>) : Stream<PongState> = failwith "TODO m11"
/// The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings /// The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings
/// of `pongView`. A function of `unit`, like `App.main`. /// of `pongView`, and wrap that stream in `Stream.clampTime App.MAX_GAP_MS`. A function of `unit`, like `App.main`.
let pongMain () : IO<Stream<IO<unit>>> = failwith "TODO m11" let pongMain () : IO<Stream<IO<unit>>> = failwith "TODO m11"
+1 -1
View File
@@ -11,7 +11,7 @@ let clamp (lo: int) (hi: int) (x: int) : int = failwith "TODO m11"
/// The one function that says how a match changes: `state -> action -> state`. /// The one function that says how a match changes: `state -> action -> state`.
/// ///
/// - `Press key` / `Release key`: set `Held.Up` or `Held.Down` to true / false (even when the match is over). /// - `Press key` / `Release key`: set `Held.Up` or `Held.Down` to true / false (even when the match is over).
/// - `Restart`: `initialPong state.Seed`. /// - `Restart`: `initialPong state.Seed`, except that `Held` is kept (the player may still be holding a key).
/// - `Frame`: unchanged unless status is Playing. Otherwise, in this order: /// - `Frame`: unchanged unless status is Playing. Otherwise, in this order:
/// 1. `Frame + 1`. /// 1. `Frame + 1`.
/// 2. The player moves one cell down for `Held.Down` and one up for `Held.Up` (both cancel out), /// 2. The player moves one cell down for `Held.Down` and one up for `Held.Up` (both cancel out),
+66
View File
@@ -0,0 +1,66 @@
/// m10 replay viewer: a recorded game is a seed and a list of actions; watching it is a second, tiny program
/// whose state is only a position in the list of the game's states.
module Snake.Replay
open Snake.Model
open Snake.View
open Snake.IO
open Snake.Stream
/// A recorded game: the seed of the game and every action, in order (see `fixtures/replay-sample.json`).
type Recording = { Seed: uint32; Actions: Action list }
/// The viewer: which frame is shown (`Index`, 0 = the new game, k = after k actions), the index of the last frame
/// (`Last`, the number of actions), and whether autoplay is on (`Playing`).
type Viewer = { Index: int; Last: int; Playing: bool }
/// Everything the viewer can be asked to do, as data. `Beat` is one beat of the autoplay clock.
/// (RequireQualifiedAccess: `ViewerAction.Last` must never be confused with anything else in scope.)
[<RequireQualifiedAccess>]
type ViewerAction =
| Next
| Prev
| First
| Last
| TogglePlay
| Beat
/// Milliseconds between two frames of autoplay.
let REPLAY_MS = 100L
/// Every state of the recorded game: `Game.history (Spawn.newGame seed) actions`. Its length is the number of
/// actions + 1.
let frames (recording: Recording) : GameState list = failwith "TODO m10"
/// The viewer at the first frame, not playing: `{ Index = 0; Last = last; Playing = false }`.
let viewerStart (last: int) : Viewer = failwith "TODO m10"
/// One step of the viewer; total, for every action and every viewer.
/// - `Next` / `Prev`: one frame forward / back, never past `Last` or below 0.
/// - `First` / `Last`: frame 0 / frame `Last`.
/// - `TogglePlay`: playing -> not playing. Not playing -> playing; at the last frame it starts again from frame 0.
/// - `Beat`: when playing, one frame forward (never past `Last`); when not playing, unchanged.
/// - After every action: a viewer at the last frame is not playing (so autoplay stops at the end by itself).
let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer = failwith "TODO m10"
/// A key to a viewer action, or `None`: "ArrowRight"/"d"/"D" -> Next, "ArrowLeft"/"a"/"A" -> Prev, "Home" -> First,
/// "End" -> Last, " " -> TogglePlay. Any other key -> `None`.
let viewerKey (key: string) : ViewerAction option = failwith "TODO m10"
/// The picture: `View.view` of the frame at `Index`, followed by two texts. With `w = Width * CELL` of that frame:
/// `Text(w - 6, 18, "step <index> / <last>", "#ffffff", 16, "right")` and
/// `Text(w / 2, Height * CELL - 8, <hint>, "#aaaaaa", 12, "center")`, where the hint is "Space: pause" while playing
/// and "Left/Right: step Home/End: jump Space: play" (three spaces between the parts) otherwise.
let viewerView (frames: GameState list) (viewer: Viewer) : Scene = failwith "TODO m10"
/// `Beat` every REPLAY_MS (from `Stream.interval`), merged with `viewerKey` of every key press (the beats first).
/// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.)
let replayActions: Stream<ViewerAction> = Stream(fun _ -> failwith "TODO m10")
/// Fold the actions into viewers with `viewerUpdate`, starting from `viewerStart last`, which is emitted first.
let viewerStates (last: int) (actions: Stream<ViewerAction>) : Stream<Viewer> = failwith "TODO m10"
/// The whole viewer as a value: `IO.pure` of the stream of drawings of `viewerView (frames recording) viewer` over
/// `viewerStates <last index> replayActions`, wrapped in `Stream.clampTime App.MAX_GAP_MS`.
/// No seed is asked for: the recording carries its own. Compute the frames once, not for every drawing.
let replayMain (recording: Recording) : IO<Stream<IO<unit>>> = failwith "TODO m10"
+9 -1
View File
@@ -13,14 +13,21 @@ type Platform =
FillText: int -> int -> string -> string -> int -> string -> unit FillText: int -> int -> string -> string -> int -> string -> unit
/// A random unsigned 32-bit seed. /// A random unsigned 32-bit seed.
RandomSeed: unit -> uint32 RandomSeed: unit -> uint32
/// (m10) The stored best score; 0 when there is none or it cannot be read. Never throws.
LoadBest: unit -> int
/// (m10) Store a new best score.
SaveBest: int -> unit
/// Call `onTime t` again and again, `t` = milliseconds since the clock started. /// Call `onTime t` again and again, `t` = milliseconds since the clock started.
StartClock: (int64 -> unit) -> unit StartClock: (int64 -> unit) -> unit
OnKeyDown: (string -> unit) -> unit OnKeyDown: (string -> unit) -> unit
OnKeyUp: (string -> unit) -> unit } OnKeyUp: (string -> unit) -> unit
/// (m13, given) Call `cb x y` for every click on the canvas, `x` and `y` in canvas pixels.
OnClick: (int -> int -> unit) -> unit }
/// Interpret an IO against a platform and return its final value. /// Interpret an IO against a platform and return its final value.
/// `Draw` of a Rect -> `p.FillRect x y w h color` and of a Text -> `p.FillText x y text color size align`, /// `Draw` of a Rect -> `p.FillRect x y w h color` and of a Text -> `p.FillText x y text color size align`,
/// both answered with `Done`; `RandomSeed` -> `Seed (p.RandomSeed ())`. Each answer is passed to `k`. /// both answered with `Done`; `RandomSeed` -> `Seed (p.RandomSeed ())`. Each answer is passed to `k`.
/// (m10) `LoadBest` -> `Best (p.LoadBest ())`; `SaveBest score` -> `p.SaveBest score`, answered with `Done`.
/// Must not grow the stack with the number of ops: a loop, or a tail-recursive `let rec` (which the compiler /// Must not grow the stack with the number of ops: a loop, or a tail-recursive `let rec` (which the compiler
/// turns into a loop). /// turns into a loop).
let runIO (p: Platform) (io: IO<'a>) : 'a = failwith "TODO m09" let runIO (p: Platform) (io: IO<'a>) : 'a = failwith "TODO m09"
@@ -29,4 +36,5 @@ let runIO (p: Platform) (io: IO<'a>) : 'a = failwith "TODO m09"
/// (F# closures cannot capture a `let mutable`, so use a ref cell: `let stream = ref ...`, `stream.Value <- next`). /// (F# closures cannot capture a `let mutable`, so use a ref cell: `let stream = ref ...`, `stream.Value <- next`).
/// Register `p.OnKeyDown` / `p.OnKeyUp` / `p.StartClock` so that each event steps the stream with the matching /// Register `p.OnKeyDown` / `p.OnKeyUp` / `p.StartClock` so that each event steps the stream with the matching
/// `Event` (`KeyDown key`, `KeyUp key`, `Time t`), stores the next stream, and runs every IO the step emitted, in order. /// `Event` (`KeyDown key`, `KeyUp key`, `Time t`), stores the next stream, and runs every IO the step emitted, in order.
/// (m13) Also register `p.OnClick` so that each click steps the stream with `Click(x, y)`.
let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit = failwith "TODO m09" let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit = failwith "TODO m09"
+63
View File
@@ -0,0 +1,63 @@
/// m07b (optional): a stack-safe IO. The same idea as m07's IO, with one more case:
/// - `Pure a` finished, with a value
/// - `Effect (op, k)` perform `op`, then continue with `k result` (which returns another SafeIO)
/// - `FlatMap (io, f)` run `io`, then continue with `f result` (which returns another SafeIO)
/// The ops, their answers and the result of `trace` are m07's: write them `IO.Op`, `IO.OpResult`, `IO.Traced`
/// (do not `open Snake.IO`: its cases are also called Pure and Effect). This module does not use m07's `IO` type;
/// it is a second, independent IO. Building a SafeIO never performs anything and never calls a function given to
/// `map` or `bind`.
module Snake.SafeIO
/// `FlatMap`'s inner program produces some type `X` that only its continuation knows. A union case cannot have a
/// type parameter of its own, so `X` is erased to `obj`; only `bind` builds a `FlatMap`, and it keeps the two in step.
type SafeIO<'a> =
| Pure of 'a
| Effect of IO.Op * (IO.OpResult -> SafeIO<'a>)
| FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>)
// Given: the same program, seen as a SafeIO<obj>. Constant work: continuations are wrapped, not called.
let rec private erase (io: SafeIO<'a>) : SafeIO<obj> =
match io with
| Pure a -> Pure(box a)
| Effect(op, k) -> Effect(op, fun r -> erase (k r))
| FlatMap(m, g) -> FlatMap(m, fun x -> erase (g x))
/// A SafeIO that does nothing and produces `a`: `Pure a`.
let pure (a: 'a) : SafeIO<'a> = failwith "TODO m07b"
/// A SafeIO that performs the single operation `op` and produces its answer: `Effect (op, pure)`.
let effect (op: IO.Op) : SafeIO<IO.OpResult> = failwith "TODO m07b"
/// "Run `io`, then continue with `f result`": only builds a node, `FlatMap (erase io, fun x -> f (unbox<'a> x))`.
/// Never calls `f`, and never looks inside `io`.
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
/// Transform the result of `io` with `f`, built from `bind` and `pure`. Never calls `f` while building.
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
/// Run the SafeIOs one after another, left to right, producing `()`. Either fold is fine here.
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = failwith "TODO m07b"
/// One rewrite towards a program whose top is a `Pure` or an `Effect`, with a constant amount of work:
/// FlatMap(Pure a, f) becomes f a
/// FlatMap(Effect(op, k), f) becomes Effect(op, fun r -> FlatMap(k r, f))
/// FlatMap(FlatMap(m, g), f) becomes FlatMap(m, fun x -> FlatMap(g x, f)) (re-association)
/// A `Pure` or an `Effect` is returned unchanged. No recursion: `step` looks at most two levels deep and calls no
/// function except `f` in the first rule. (The inner parts already have the erased types, so no `erase` is needed.)
let step (io: SafeIO<'a>) : SafeIO<'a> = failwith "TODO m07b"
// Given: the canned answer of the pure interpreter, as in m07. SafeIO keeps Module 7's two operations; the stored
// best score of Module 10 is not part of it, so its operations are refused loudly rather than given a meaning here.
let private answer (seed: uint32) (op: IO.Op) : IO.OpResult =
match op with
| IO.RandomSeed -> IO.Seed seed
| IO.Draw _ -> IO.Done
| IO.LoadBest
| IO.SaveBest _ -> failwith "SafeIO.trace answers only Module 7's operations (Draw and RandomSeed)"
/// The pure interpreter for tests. Answers every `RandomSeed` with `IO.Seed seed` and every `Draw` with `IO.Done`
/// (`answer seed op`). Returns the final value and the operations in the order they were performed.
/// Write it with an inner `let rec` that calls itself only in tail position (so the stack stays flat), one case per
/// constructor: a `Pure` is finished; an `Effect` is answered and continues with `k answer`, recording its op; a
/// `FlatMap` is rewritten once with `step`. Collect the ops at the front of a list and reverse it once at the end.
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> = failwith "TODO m07b"
+8
View File
@@ -10,17 +10,25 @@
<Compile Include="Model.fs" /> <Compile Include="Model.fs" />
<Compile Include="Rules.fs" /> <Compile Include="Rules.fs" />
<Compile Include="Random.fs" /> <Compile Include="Random.fs" />
<Compile Include="State.fs" />
<Compile Include="Spawn.fs" /> <Compile Include="Spawn.fs" />
<Compile Include="Game.fs" /> <Compile Include="Game.fs" />
<Compile Include="View.fs" /> <Compile Include="View.fs" />
<Compile Include="IO.fs" /> <Compile Include="IO.fs" />
<Compile Include="SafeIO.fs" />
<Compile Include="Stream.fs" /> <Compile Include="Stream.fs" />
<Compile Include="Input.fs" /> <Compile Include="Input.fs" />
<Compile Include="App.fs" /> <Compile Include="App.fs" />
<Compile Include="Runtime.fs" /> <Compile Include="Runtime.fs" />
<Compile Include="Replay.fs" />
<Compile Include="Pong/Model.fs" /> <Compile Include="Pong/Model.fs" />
<Compile Include="Pong/Rules.fs" /> <Compile Include="Pong/Rules.fs" />
<Compile Include="Pong/View.fs" /> <Compile Include="Pong/View.fs" />
<Compile Include="Pong/App.fs" /> <Compile Include="Pong/App.fs" />
<Compile Include="Life/Model.fs" />
<Compile Include="Life/Rules.fs" />
<Compile Include="Life/View.fs" />
<Compile Include="Life/App.fs" />
<Compile Include="Check.fs" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+28
View File
@@ -0,0 +1,28 @@
/// m04 state (optional, Exercise 4.7). A `State<'s, 'a>` wraps a plain function from a state to a pair:
/// `s -> (value, nextS)`. It is `Random<'a>` with the seed replaced by any state type `'s`.
/// Nothing in the game uses this file.
module Snake.State
type State<'s, 'a> = State of ('s -> 'a * 's)
/// Run `st` from the initial state `s`: the value it produces and the final state.
let run (s: 's) (st: State<'s, 'a>) : 'a * 's = failwith "TODO m04"
/// A State that gives `a` and leaves the state untouched: `s -> (a, s)`.
let pure (a: 'a) : State<'s, 'a> = failwith "TODO m04"
/// Transform the value a State produces; the state threading is unchanged.
let map (f: 'a -> 'b) (st: State<'s, 'a>) : State<'s, 'b> = failwith "TODO m04"
/// Run `st`, feed its value to `f` to pick the next State, and run that with the state `st` left behind.
let bind (f: 'a -> State<'s, 'b>) (st: State<'s, 'a>) : State<'s, 'b> = failwith "TODO m04"
/// A State whose value is the current state, which it leaves untouched: `s -> (s, s)`.
/// A function of unit (use it as `get ()`), because F# does not allow a generic module-level value.
let get () : State<'s, 's> = failwith "TODO m04"
/// A State that replaces the state with `s` and gives `()`: whatever the old state, `((), s)`.
let put (s: 's) : State<'s, unit> = failwith "TODO m04"
/// A State that replaces the state `s` with `f s` and gives `()`.
let modify (f: 's -> 's) : State<'s, unit> = failwith "TODO m04"
+22 -1
View File
@@ -3,11 +3,13 @@
/// the next event. Nothing is mutated, so a stream value can be run any number of times with the same results. /// the next event. Nothing is mutated, so a stream value can be run any number of times with the same results.
module Snake.Stream module Snake.Stream
/// Something that happens to the program: time passing (ms since start) or a key going down or up. /// Something that happens to the program: time passing (ms since start) or a key going down or up, and from
/// Module 13 a mouse click at `(x, y)` in canvas pixels. Every stream ignores the kinds of event it does not use.
type Event = type Event =
| Time of int64 | Time of int64
| KeyDown of string | KeyDown of string
| KeyUp of string | KeyUp of string
| Click of int * int
type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>) type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>)
@@ -21,6 +23,17 @@ let keyDowns: Stream<string> = Stream(fun _ -> failwith "TODO m08")
/// Emits the `key` of every KeyUp event; ignores everything else. /// Emits the `key` of every KeyUp event; ignores everything else.
let keyUps: Stream<string> = Stream(fun _ -> failwith "TODO m08") let keyUps: Stream<string> = Stream(fun _ -> failwith "TODO m08")
/// (m13) Emits the position `(x, y)` of every Click event; ignores everything else.
/// Given: nothing to write here. It is the third source, built exactly like your `keyDowns`.
let clicks: Stream<int * int> =
let rec source () =
Stream(fun e ->
match e with
| Click(x, y) -> [ (x, y) ], source ()
| _ -> [], source ())
source ()
/// On `Time t`, emits every multiple `k*ms` with `last < k*ms <= t`, ascending, where `last` starts at 0 /// On `Time t`, emits every multiple `k*ms` with `last < k*ms <= t`, ascending, where `last` starts at 0
/// and becomes `t` afterwards. A big time jump can emit several values. Ignores key events. /// and becomes `t` afterwards. A big time jump can emit several values. Ignores key events.
/// Hint: write a private helper that carries `last`. /// Hint: write a private helper that carries `last`.
@@ -45,5 +58,13 @@ let scan (f: 'b -> 'a -> 'b) (z: 'b) (s: Stream<'a>) : Stream<'b> = failwith "TO
/// Note: streams only act when an event arrives, so if no event ever arrives, `v` is never emitted. /// Note: streams only act when an event arrives, so if no event ever arrives, `v` is never emitted.
let startWith (v: 'a) (s: Stream<'a>) : Stream<'a> = failwith "TODO m08" let startWith (v: 'a) (s: Stream<'a>) : Stream<'a> = failwith "TODO m08"
/// (m10) Limit how far one Time event can move the clock that `s` sees, so a long silence (a background tab)
/// does not turn into hundreds of beats at once.
/// `s` gets a clock of its own, starting at 0. On `Time t` that clock advances by `t - last`, but by at most `maxGap`
/// and never by less than 0 (time does not run backwards), where `last` is the `t` of the previous Time event
/// (0 at the start). `s` is then stepped with `Time clock`. Key and click events are passed to `s` unchanged.
/// Hint: a private helper that carries `last` and `clock`, like the one for `interval`.
let clampTime (maxGap: int64) (s: Stream<'a>) : Stream<'a> = failwith "TODO m10"
/// Feed all events through the stream and collect every output, in order. A pure fold. /// Feed all events through the stream and collect every output, in order. A pure fold.
let run (events: Event list) (s: Stream<'a>) : 'a list = failwith "TODO m08" let run (events: Event list) (s: Stream<'a>) : 'a list = failwith "TODO m08"
+4 -2
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Usage: ./test.sh [MNN] (no arg = all tests) # Usage: ./test.sh [MNN] (no arg = all tests; e.g. ./test.sh M04, ./test.sh M07b)
# Every test list is named "MNN Name" (tag, a space, a name). Expecto's --filter-test-list matches a substring, so the
# tag is passed with its trailing space: "M07 " selects "M07 IO" but not "M07b SafeIO".
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
export PATH="$HOME/.dotnet:$PATH" export PATH="$HOME/.dotnet:$PATH"
export DOTNET_NOLOGO=1 DOTNET_CLI_TELEMETRY_OPTOUT=1 export DOTNET_NOLOGO=1 DOTNET_CLI_TELEMETRY_OPTOUT=1
if [ $# -ge 1 ]; then if [ $# -ge 1 ]; then
exec dotnet run --project tests -- --filter-test-list "$1" exec dotnet run --project tests -- --filter-test-list "$1 "
else else
exec dotnet run --project tests exec dotnet run --project tests
fi fi
+2 -1
View File
@@ -72,7 +72,8 @@ let gameState (v: JsonElement) : GameState =
Seed = u32 (prop "seed" v) Seed = u32 (prop "seed" v)
Score = num (prop "score" v) Score = num (prop "score" v)
Status = status (prop "status" v) Status = status (prop "status" v)
Frame = num (prop "frame" v) } Frame = num (prop "frame" v)
Queued = opt direction (prop "queued" v) }
let shape (v: JsonElement) : Shape = let shape (v: JsonElement) : Shape =
let n key = num (prop key v) let n key = num (prop key v)
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more