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

+106
View File
@@ -0,0 +1,106 @@
// The oracle: the pure JS solutions, run in Node, say what a page must draw for a given seed, stored best and list of
// inputs. Nothing here touches a browser.
//
// A program (`main`, `pongMain`, `replayMain(recording)`, `lifeMain`) is an IO that yields a stream; `trace` answers
// its RandomSeed and LoadBest ops with the test's seed and best, exactly as the course's `play(main, seed, events,
// best)` does (js/solutions/app.js). The Model then steps that stream one input at a time and traces each IO it emits,
// so it knows, per input, which shapes are drawn and which scores are saved. Flattened, its output equals `play`'s
// (tests/techniques.spec.mjs checks that); grouped per input, it lines up with the page's recorded frames.
//
// Next to the program, the Model steps a stream of the program's *states*, built from the same pieces as the program
// (e.g. `clampTime(sessions(seed, best, speedActions), MAX_GAP_MS)` for Snake). It is used only to plan inputs and to
// state assertions in game terms ("the head moved one cell"); every judgement of the page is made on its drawing.
import { readFileSync } from 'node:fs'
import { trace } from '../../js/solutions/io.js'
import { clampTime } from '../../js/solutions/stream.js'
import { main, play, sessions, sessionView, speedActions, MAX_GAP_MS, FRAME_MS } from '../../js/solutions/app.js'
import { movesEvery } from '../../js/solutions/game.js'
import { newGame } from '../../js/solutions/spawn.js'
import { pongMain, pongStates, pongActions, FRAME_MS_PONG } from '../../js/solutions/pong/app.js'
import { pongView } from '../../js/solutions/pong/view.js'
import { replayMain, viewerStates, replayActions, viewerView, frames as replayFrames, REPLAY_MS } from '../../js/solutions/replay.js'
import { lifeMain, lifeStates, lifeActions, GEN_MS } from '../../js/solutions/life/app.js'
import { lifeView } from '../../js/solutions/life/view.js'
import { LCELL, LW, LH } from '../../js/solutions/life/model.js'
export const recording = JSON.parse(readFileSync(new URL('../../fixtures/replay-sample.json', import.meta.url), 'utf8'))
/** The four pages of a track, the program each runs, and the matching stream of states. */
export const programs = {
snake: {
page: 'index.html',
main: () => main,
states: (seed, best) => clampTime(sessions(seed, best, speedActions), MAX_GAP_MS),
},
pong: {
page: 'pong.html',
main: () => pongMain,
states: (seed) => clampTime(pongStates(seed, pongActions), MAX_GAP_MS),
},
replay: {
page: 'replay.html',
main: () => replayMain(recording),
states: () => clampTime(viewerStates(replayFrames(recording).length - 1, replayActions), MAX_GAP_MS),
},
life: {
page: 'life.html',
main: () => lifeMain,
states: (seed) => clampTime(lifeStates(seed, lifeActions), MAX_GAP_MS),
},
}
export class Model {
constructor(name, { seed, best = 0 }) {
const program = programs[name]
this.name = name
this.seed = seed
this.best = best
const traced = trace(program.main(), seed, best)
this.startOps = traced.ops // RandomSeed, LoadBest: what the program asks before it starts
this.stream = traced.result
this.stateStream = program.states(seed, best)
this.state = null // latest state the program emitted
this.inputs = [] // every input fed so far
this.frames = [] // per input that drew anything: {input, shapes}
this.saves = [] // scores passed to SaveBest, in order
}
/** Feed inputs, in order, as the page received them. */
feed(inputs) {
for (const input of inputs) {
const [ios, next] = this.stream.step(input)
this.stream = next
const ops = ios.flatMap((io) => trace(io, this.seed, this.best).ops)
const shapes = ops.filter((op) => op.tag === 'Draw').map((op) => op.shape)
this.saves.push(...ops.filter((op) => op.tag === 'SaveBest').map((op) => op.score))
if (shapes.length > 0) this.frames.push({ input, shapes })
const [states, nextStates] = this.stateStream.step(input)
this.stateStream = nextStates
if (states.length > 0) this.state = states[states.length - 1]
this.inputs.push(input)
}
return this
}
/** What the state would be after these inputs, without feeding them. Streams are values, so this is free. */
peek(inputs) {
const [states] = inputs.reduce(([acc, s], input) => {
const [out, next] = s.step(input)
return [out.length > 0 ? out[out.length - 1] : acc, next]
}, [this.state, this.stateStream])
return states
}
/** The course's own one-equality run of the program over every input fed so far. */
play() {
return play(programs[this.name].main(), this.seed, this.inputs, this.best)
}
lastFrame() {
return this.frames.length === 0 ? null : this.frames[this.frames.length - 1]
}
}
// Re-exported for tests that state expectations directly in model terms.
export { sessionView, newGame, movesEvery, pongView, viewerView, replayFrames, lifeView }
export { FRAME_MS, MAX_GAP_MS, FRAME_MS_PONG, REPLAY_MS, GEN_MS, LCELL, LW, LH }