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

+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 }