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

+51 -1
View File
@@ -1,5 +1,6 @@
import { todo } from './todo.js'
import { pure } from './io.js'
/** @import { Action, GameState, Session, Scene, IO, Stream, Input, Op, Program } from './types' */
/** Milliseconds between game steps. */
export const TICK_MS = 150
@@ -7,30 +8,79 @@ export const TICK_MS = 150
/** Milliseconds between animation frames (m10). */
export const FRAME_MS = 25
/** (m10) The longest step of time the game accepts at once, in milliseconds (see `clampTime` in stream.js). */
export const MAX_GAP_MS = 250
/**
* Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `interval`),
* merged with the `Action` for each key press that `keyToAction` recognises (from `keyDowns`).
* @type {Stream<Action>}
*/
export const classicActions = { step: () => todo('m09') }
/**
* (m10) Like `classicActions`, but the clock emits `Action.Frame` every FRAME_MS instead of `Tick` every TICK_MS;
* key presses are translated by `keyToAction` as before (now including pause and restart).
* @type {Stream<Action>}
*/
export const speedActions = { step: () => todo('m10') }
/**
* Fold a stream of actions into a stream of game states: start from `newGame(seed)`, apply `update` to each action
* (scan), and emit the initial state first (startWith) so there is something to draw before anything happens.
* @type {(seed: number, actions: Stream<Action>) => Stream<GameState>}
*/
export const states = (seed, actions) => todo('m09')
/** The drawing of one state, as an IO: `drawScene(view(state))`. */
/** The drawing of one state, as an IO: `drawScene(view(state))`.
* @type {(state: GameState) => IO<undefined>} */
export const render = (state) => todo('m09')
/**
* (m10) Session = `{game, best, improved}`: the game, the best score so far, and whether the action that produced
* this session raised the best. One action: `game' = update(game, action)`, `best' = max(best, game'.score)`,
* `improved = best' > best` (strictly greater: equalling the record is not a new record).
* @type {(session: Session, action: Action) => Session}
*/
export const sessionUpdate = (session, action) => todo('m10')
/**
* (m10) Like `states`, one level up: `startWith(scan(actions, sessionUpdate, s0), s0)`
* with `s0 = {game: newGame(seed), best, improved: false}`.
* @type {(seed: number, best: number, actions: Stream<Action>) => Stream<Session>}
*/
export const sessions = (seed, best, actions) => todo('m10')
/**
* (m10) `view(session.game)` followed by one more shape: the text `Best: <best>` at the top right,
* `{tag:'Text', x: game.width * CELL - 6, y: 18, text: 'Best: 3', color: '#ffffff', size: 16, align: 'right'}`.
* @type {(session: Session) => Scene}
*/
export const sessionView = (session) => todo('m10')
/**
* (m10) `drawScene(sessionView(session))`, followed by `saveBest(session.best)` when `session.improved`
* (and only then).
* @type {(session: Session) => IO<undefined>}
*/
export const renderSession = (session) => todo('m10')
/**
* The whole program as a value, `IO<Stream<IO<unit>>>`: ask for a random seed, then turn the stream of
* states (built from `classicActions` in m09, from `speedActions` after m10) into a stream of drawings (`render`).
* (m10) The whole stream is wrapped in `clampTime(..., MAX_GAP_MS)`.
* (m10, Exercise 10.7) After the seed, ask for the stored best (`loadBest`), and draw the stream of
* `sessions(seed, best, speedActions)` with `renderSession` instead of the states with `render`.
* Nothing runs here.
* @type {Program}
*/
export const main = pure({ step: () => todo('m09') })
/**
* (m09) The whole program in one equality: for each drawing that `main` emits while it is fed `events`
* (a list of inputs), the list of ops that drawing performs. Interpret `main` with `trace(main, seed)`, `run` the
* stream it produces on `events`, and `trace` each emitted IO, keeping its `ops`.
* (m10) Pass `best` to both traces as well, so that `LoadBest` is answered with it.
* @type {(main: Program, seed: number, events: Input[], best?: number) => Op[][]}
*/
export const play = (main, seed, events, best = 0) => todo('m09')