Files
functional-programming-snake/docs/contract.md
T
Lucas WintherandClaude Fable 5.2 edd57f0148 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>
2026-11-11 12:05:29 +01:00

199 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).