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:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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" ]
|
||||
Reference in new issue
Block a user