feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
Audit pass: - Frame rule counts frames since the last move, so every step lasts exactly movesEvery(score) frames, also right after a speed-up - clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap - Pong Restart keeps held keys - Fact corrections (mulberry32 output coverage, Haskell IO, uniform) - New fixture cases from a mutation analysis Extensions (all three tracks, shared fixtures, course text): - Turn queue (Exercise 10.6), persisted high score (10.7), replay viewer (10.8), play() end-to-end helper (9.5) - Optional State (4.7) and optional Module 7b, stack-safe IO - Module 12, property-based testing with generators, shrinking, invariants and a model-based oracle - Module 13, Conway's Life with a Click event - JS type checking via jsconfig, JSDoc and types.d.ts - Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh, Playwright end-to-end suite (e2e/, scripts/e2e.sh) Docs: docs/contract.md is the canonical contract; specs and build log under docs/superpowers. Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -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
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
# 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.
|
||||
Reference in new issue
Block a user