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>
199 lines
23 KiB
Markdown
199 lines
23 KiB
Markdown
# 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).
|