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

+44
View File
@@ -0,0 +1,44 @@
/// m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse.
module Snake.Life.App
open Snake
open Snake.Model
open Snake.IO
open Snake.Stream
open Snake.Life.Model
open Snake.Life.Rules
open Snake.Life.View
let GEN_MS = 150L
let lifeKey (key: string) : LifeAction option =
match key with
| " " -> Some LifeAction.TogglePause
| "n" | "N" -> Some LifeAction.Step
| "c" | "C" -> Some LifeAction.Clear
| "r" | "R" -> Some LifeAction.Randomize
| _ -> None
// Integer division rounds towards zero (-1 / 16 = 0), so the pixel is checked before dividing.
let lifeClick (x: int) (y: int) : LifeAction option =
if x >= 0 && y >= 0 && x / LCELL < LW && y / LCELL < LH then
Some(LifeAction.Toggle { X = x / LCELL; Y = y / LCELL })
else
None
let lifeActions: Stream<LifeAction> =
Stream.merge
(Stream.merge
(Stream.interval GEN_MS |> Stream.map (fun _ -> LifeAction.Frame))
(Stream.keyDowns |> Stream.choose lifeKey))
(Stream.clicks |> Stream.choose (fun (x, y) -> lifeClick x y))
let lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState> =
actions |> Stream.scan lifeUpdate (initialLife seed) |> Stream.startWith (initialLife seed)
let lifeMain () : IO<Stream<IO<unit>>> =
IO.randomSeed ()
|> IO.map (fun seed ->
lifeStates seed lifeActions
|> Stream.map (fun state -> IO.drawScene (lifeView state))
|> Stream.clampTime App.MAX_GAP_MS)
+32
View File
@@ -0,0 +1,32 @@
/// m13 life model: the whole world is one value, like the snake and the match.
module Snake.Life.Model
open Snake.Model
/// `Cells` is canonical: sorted row-major (by `Y`, then `X`), no duplicates, all on the board.
type LifeState =
{ Cells: Point list
Running: bool
Seed: uint32
Generation: int }
[<RequireQualifiedAccess>]
type LifeAction =
| Frame
| Toggle of Point
| TogglePause
| Step
| Clear
| Randomize
// The board, in cells: LW columns, LH rows, LCELL pixels per cell. Below it the page shows a status strip.
let LW = 40
let LH = 30
let LCELL = 16
let STRIP = 24
let initialLife (seed: uint32) : LifeState =
{ Cells = []
Running = false
Seed = seed
Generation = 0 }
+71
View File
@@ -0,0 +1,71 @@
/// m13 life rules: the next generation is a function of the whole old board, and nothing else.
module Snake.Life.Rules
open Snake
open Snake.Model
open Snake.Life.Model
// Every cell of the board, row-major (y, then x). Filtering this list keeps any result in canonical order for free.
let private board: Point list =
[ for y in 0 .. LH - 1 do
for x in 0 .. LW - 1 -> { X = x; Y = y } ]
let private onBoard (p: Point) : bool = p.X >= 0 && p.X < LW && p.Y >= 0 && p.Y < LH
// A cell's position in row-major order: the order the canonical list is sorted by.
let private index (p: Point) : int = p.Y * LW + p.X
let private offsets = [ (-1, -1); (0, -1); (1, -1); (-1, 0); (1, 0); (-1, 1); (0, 1); (1, 1) ]
// The up to eight neighbours of `p` that are on the board (the edges are dead: nothing wraps around).
let private neighbours (p: Point) : Point list =
offsets |> List.map (fun (dx, dy) -> { X = p.X + dx; Y = p.Y + dy }) |> List.filter onBoard
// B3/S23, every cell judged on the *old* list only. A Set of the old cells makes "is this neighbour alive?" one
// lookup, so a generation is 1200 x 8 lookups however many cells live. The set is built once and only read.
let lifeStep (cells: Point list) : Point list =
let alive = Set.ofList cells
let count p = neighbours p |> List.filter alive.Contains |> List.length
board
|> List.filter (fun p ->
let n = count p
n = 3 || (n = 2 && alive.Contains p))
// Add a dead cell or remove a live one, keeping the list sorted row-major without duplicates. Off the board: unchanged.
let private toggle (cells: Point list) (p: Point) : Point list =
if not (onBoard p) then
cells
elif List.contains p cells then
List.filter (fun c -> c <> p) cells
else
List.filter (fun c -> index c < index p) cells
@ [ p ]
@ List.filter (fun c -> index c > index p) cells
// A random board: one `intBelow 4` per cell in row-major order, the cell alive when the draw is 0 (about one cell in
// four). mapFold threads the seed through the 1200 draws; the result is the cells and the seed after the last draw.
let private randomCells (seed: uint32) : Point list * uint32 =
let draws, next = List.mapFold (fun s _ -> Random.run s (Random.intBelow 4)) seed board
List.zip board draws |> List.filter (fun (_, d) -> d = 0) |> List.map fst, next
let private nextGeneration (state: LifeState) : LifeState =
{ state with
Cells = lifeStep state.Cells
Generation = state.Generation + 1 }
let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState =
match action with
| LifeAction.Frame -> if state.Running then nextGeneration state else state
| LifeAction.Step -> if state.Running then state else nextGeneration state
| LifeAction.TogglePause -> { state with Running = not state.Running }
// Clicking edits the world whether it runs or not: the click is one action between two generations.
| LifeAction.Toggle p -> { state with Cells = toggle state.Cells p }
| LifeAction.Clear -> { state with Cells = []; Generation = 0 }
| LifeAction.Randomize ->
let cells, seed = randomCells state.Seed
{ state with
Cells = cells
Seed = seed
Generation = 0 }
+17
View File
@@ -0,0 +1,17 @@
/// m13 life view: live cells as squares, and a status strip under the board.
module Snake.Life.View
open Snake.View
open Snake.Life.Model
let private text x y str align = Text(x, y, str, "#ffffff", 14, align)
let private W = LW * LCELL
let private H = LH * LCELL
let lifeView (state: LifeState) : Scene =
[ Rect(0, 0, W, H, "#111111") ]
@ (state.Cells |> List.map (fun p -> Rect(p.X * LCELL + 1, p.Y * LCELL + 1, LCELL - 2, LCELL - 2, "#7cb342")))
@ [ Rect(0, H, W, STRIP, "#222222")
text 6 (H + 17) $"Generation {state.Generation}" "left"
text (W - 6) (H + 17) (if state.Running then "Running: Space pauses" else "Paused: Space runs, N steps") "right" ]