Files
functional-programming-snake/docs/superpowers/specs/2026-11-11-course-extensions.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

143 lines
22 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.
# 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.