feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)

Audit pass:
- Frame rule counts frames since the last move, so every step lasts
  exactly movesEvery(score) frames, also right after a speed-up
- clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap
- Pong Restart keeps held keys
- Fact corrections (mulberry32 output coverage, Haskell IO, uniform)
- New fixture cases from a mutation analysis

Extensions (all three tracks, shared fixtures, course text):
- Turn queue (Exercise 10.6), persisted high score (10.7),
  replay viewer (10.8), play() end-to-end helper (9.5)
- Optional State (4.7) and optional Module 7b, stack-safe IO
- Module 12, property-based testing with generators, shrinking,
  invariants and a model-based oracle
- Module 13, Conway's Life with a Click event
- JS type checking via jsconfig, JSDoc and types.d.ts
- Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh,
  Playwright end-to-end suite (e2e/, scripts/e2e.sh)

Docs: docs/contract.md is the canonical contract; specs and build log
under docs/superpowers.

Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression
M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Fable 5.2 committed 2026-11-11 12:05:29 +01:00
1 parent 876f5aabb1
commit edd57f0148
253 files changed
+21753 -503

No files matched your search

+8 -6
View File
@@ -79,7 +79,7 @@ The tests are shared fixtures, so the rules must be exact. Here they are in pros
6. **Scoring:** if `nx < 0`, the computer scores; if `nx >= W`, the player scores. A point **serves** a new ball: position `(40, 25)`, `dx` toward the side that conceded (computer scored → `-1`, player scored → `+1`), and `dy = 1` if `nextUint32(seed)` is even, `-1` otherwise — and the seed advances to the one `nextUint32` returned.
7. **End:** when a score reaches `WIN`, the status becomes `Over`.
When the match is `Over`, frames change nothing. Key presses and releases are still recorded (so that a key held across a restart behaves sensibly). **Restart** gives `initialPong(state.seed)` at any time.
When the match is `Over`, frames change nothing. Key presses and releases are still recorded. **Restart** gives `initialPong(state.seed)` at any time, **except that `held` is kept**: the keys are a fact about the player's fingers, not about the match, and a player who restarts while holding a key is still holding it. (A restart that cleared `held` would leave the paddle standing still until the key is released and pressed again; the fixture `restart-keeps-the-held-keys` checks this.)
**The picture** (`pongView`), drawn in order, all text `'#ffffff'` and `'center'`-aligned:
@@ -217,7 +217,7 @@ Question: why are `Press` and `Release` separate actions rather than one `SetHel
### Step 3 — Write `update` as a fold
`pongUpdate(state, action)` is your Pong `update`. `Press`/`Release` set a field of `held`; `Restart` is one call; `Frame` is the seven-step pipeline from [the rules](#the-rules-precisely).
`pongUpdate(state, action)` is your Pong `update`. `Press`/`Release` set a field of `held`; `Restart` is one call plus the kept `held`; `Frame` is the seven-step pipeline from [the rules](#the-rules-precisely).
The `Frame` case is the biggest function you'll write in the course. The way to keep it readable is the way you kept Snake readable: **split it into small, pure, separately-understandable steps**, each taking a state and returning a state, applied in the order of the rules. You'll want a `clamp(lo, hi, x)` helper (it's tested on its own).
@@ -241,11 +241,11 @@ Like Snake's `app`:
- `pongActions`: `Frame` every `FRAME_MS_PONG = 30` ms (from `interval`), merged with `pongKey(true, key)` of every key press (`keyDowns`) and `pongKey(false, key)` of every key release (`keyUps`). That's three streams merged — in which order? (The test `pongActions turns the clock into Frames and keys into Press, Release and Restart` tells you what it expects; read it.)
- `pongStates(seed, actions)`: start from `initialPong(seed)`, fold with `pongUpdate`, emit the initial state first.
- `pongMain`: ask for a seed; turn the stream of states into a stream of drawings of `pongView`.
- `pongMain`: ask for a seed; turn the stream of states into a stream of drawings of `pongView`; and wrap that stream in `clampTime(…, MAX_GAP_MS)` as you did for Snake in Module 10 (a ball that has played on alone for a minute while the tab was hidden is no more fun than a dead snake). `MAX_GAP_MS` is the constant from the Snake app module.
### Step 7 — One runtime at the edge
Nothing to write. `js/src/pong/main.js` is one line: `runApp(pongMain, browserPlatform(document.getElementById('game'), window))`. In Scala, `scala/web/Main.scala` has a `PongMain` object; `build-web.sh` already compiles it to `web/dist/pong.js`. F# has the equivalent in `fsharp/web/PongMain.fs`: `Runtime.runApp (BrowserPlatform.create (BrowserPlatform.gameCanvas ())) (Pong.App.pongMain ())`. Your m09 runtime runs a game it has never heard of. That's the reward for keeping it ignorant.
Nothing to write. `js/src/pong/main.js` is one line: `runApp(pongMain, browserPlatform(document.getElementById('game'), window))`. In Scala, `scala/web/Main.scala` has a `PongMain` object; `build-web.sh` already compiles it to `web/dist/pong.js`. F# has the equivalent in `fsharp/web/PongMain.fs`: `Runtime.runApp (BrowserPlatform.create (BrowserPlatform.gameCanvas ())) (Pong.App.pongMain ())`. Your m09 runtime runs a game it has never heard of. That's the reward for keeping it ignorant. (It will not stay untouched for ever: Module 13 needs a fourth kind of input, the mouse, and adds one registration to it.)
## Exercises
@@ -299,7 +299,7 @@ let clamp (lo: int) (hi: int) (x: int) : int = failwith "TODO m11"
let pongUpdate (state: PongState) (action: PongAction) : PongState = failwith "TODO m11"
```
**Proved by:** `clamp keeps a number inside [lo, hi]`, `pongUpdate does not modify the state it is given`, `an idle match stays on the court and ends once someone reaches five`, and `fixtures/m11-pong-update.json` — 43 named cases. Read the case names: they're the specification broken into single facts (`player-stops-at-the-top-edge`, `cpu-does-not-move-on-an-odd-frame`, `paddle-bounce-uses-the-player-position-after-it-moved`, `serve-direction-comes-from-the-seed-odd-value-serves-upward`, `cpu-reaching-five-ends-the-match`, …). When one fails, open the fixture, find the case, and work it out by hand.
**Proved by:** `clamp keeps a number inside [lo, hi]`, `pongUpdate does not modify the state it is given`, `an idle match stays on the court and ends once someone reaches five`, and `fixtures/m11-pong-update.json` — 49 named cases. Read the case names: they're the specification broken into single facts (`player-stops-at-the-top-edge`, `cpu-does-not-move-on-an-odd-frame`, `paddle-bounce-uses-the-player-position-after-it-moved`, `serve-direction-comes-from-the-seed-odd-value-serves-upward`, `cpu-reaching-five-ends-the-match`, …). When one fails, open the fixture, find the case, and work it out by hand.
**Strategy:** don't write all seven steps and then run the tests. Write `Press`/`Release`/`Restart` and run; then step 1 and 2 of `Frame` and run (watch the paddle cases go green); then the cpu; then the ball. Let the case names guide you.
@@ -361,7 +361,7 @@ let pongMain () : IO<Stream<IO<unit>>> = failwith "TODO m11"
In F#, `main` and `pongMain` are **functions of unit** (`pongMain ()`), not values. F# evaluates a module's top-level values when the module is first used, so a value whose definition fails (like a stub) would break every test that touches the module; a function of unit is only evaluated when called. The value it returns is still the same pure description.
**Proved by:** `fixtures/m11-pong-key.json` (21 cases, including `r-up-does-nothing` and the `constructor`/`toString` traps from Module 9), `the clock runs every 30 ms`, `pongActions turns the clock into Frames and keys into Press, Release and Restart`, `pongStates starts with the new match, then yields one state per action`, `pongMain asks for a seed, draws the new match first, and then the updated one`.
**Proved by:** `fixtures/m11-pong-key.json` (21 cases, including `r-up-does-nothing` and the `constructor`/`toString` traps from Module 9), `the clock runs every 30 ms`, `pongActions turns the clock into Frames and keys into Press, Release and Restart`, `pongStates starts with the new match, then yields one state per action`, `pongMain asks for a seed, draws the new match first, and then the updated one`, `pongMain: after ten seconds in a background tab the match advances by 250 ms, not by ten seconds`.
**Run and play:**
- JS: `cd js && node --test "test/m11-*.test.js"`; then, **from the repository root**, `node scripts/serve.mjs` and open <http://localhost:8000/js/pong.html>
@@ -419,3 +419,5 @@ Three sources, one `merge` chain: `merge(merge(frames, presses), releases)`. `pr
- [The Elm Architecture](https://guide.elm-lang.org/architecture/) — read it again now; every part should look familiar.
- [Glenn Fiedler, "Deterministic Lockstep"](https://gafferongames.com/post/deterministic_lockstep/) — what pure, deterministic game updates make possible in networked games.
- [John Hughes, "Why Functional Programming Matters"](https://www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf) — reread the conclusion with the whole course behind you.
**Next:** [Module 12](12-property-testing.md) (bonus) turns your random generator into a property-based testing tool. [Module 13](13-capstone-life.md), the second capstone, converts Conway's Game of Life, which needs a kind of input Pong did not: the mouse. It is the first time since Module 9 that the edge itself changes.