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,36 @@
|
||||
package snake
|
||||
package life
|
||||
|
||||
import LifeModel.*
|
||||
|
||||
/** m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse. */
|
||||
object LifeApp:
|
||||
|
||||
val GEN_MS: Long = 150
|
||||
|
||||
def lifeKey(key: String): Option[LifeAction] = key match
|
||||
case " " => Some(LifeAction.TogglePause)
|
||||
case "n" | "N" => Some(LifeAction.Step)
|
||||
case "c" | "C" => Some(LifeAction.Clear)
|
||||
case "r" | "R" => Some(LifeAction.Randomize)
|
||||
case _ => None
|
||||
|
||||
// The pixel is checked before dividing: integer division rounds towards zero, so -1 / 16 would be cell 0.
|
||||
def lifeClick(x: Int, y: Int): Option[LifeAction] =
|
||||
if x >= 0 && y >= 0 && x / LCELL < LW && y / LCELL < LH then Some(LifeAction.Toggle(Point(x / LCELL, y / LCELL)))
|
||||
else None
|
||||
|
||||
lazy val lifeActions: Stream[LifeAction] =
|
||||
Stream
|
||||
.interval(GEN_MS)
|
||||
.map(_ => LifeAction.Frame)
|
||||
.merge(Stream.keyDowns.filterMap(lifeKey))
|
||||
.merge(Stream.clicks.filterMap(lifeClick(_, _)))
|
||||
|
||||
def lifeStates(seed: Long, actions: Stream[LifeAction]): Stream[LifeState] =
|
||||
actions.scan(initialLife(seed))(LifeRules.lifeUpdate).startWith(initialLife(seed))
|
||||
|
||||
lazy val lifeMain: IO[Stream[IO[Unit]]] =
|
||||
IO.randomSeed.map(seed =>
|
||||
lifeStates(seed, lifeActions).map(state => IO.drawScene(LifeView.lifeView(state))).clampTime(App.MAX_GAP_MS)
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package snake
|
||||
package life
|
||||
|
||||
// m13 life model: the whole world is one value, like the snake and the match.
|
||||
|
||||
/**
|
||||
* The whole world. `cells` are the live cells, always kept canonical: sorted row-major (by `y`, then by `x`), each cell
|
||||
* at most once, all on the board. `seed` is the unsigned 32-bit seed that `Randomize` draws from; `generation` counts
|
||||
* the generations since the board was last cleared or randomized.
|
||||
*/
|
||||
final case class LifeState(cells: List[Point], running: Boolean, seed: Long, generation: Int)
|
||||
|
||||
/** Everything that can happen to the world, as data. `Toggle` carries the cell that was clicked. */
|
||||
enum LifeAction:
|
||||
case Frame
|
||||
case Toggle(cell: Point)
|
||||
case TogglePause, Step, Clear, Randomize
|
||||
|
||||
object LifeModel:
|
||||
|
||||
// The board, in cells: LW columns, LH rows, LCELL pixels per cell. Below it the page shows a status strip.
|
||||
val LW = 40
|
||||
val LH = 30
|
||||
|
||||
/** Pixels per cell. */
|
||||
val LCELL = 16
|
||||
|
||||
/** Height of the status strip under the board, in pixels. */
|
||||
val STRIP = 24
|
||||
|
||||
/** A fresh world: no live cells, paused, the given seed, generation 0. */
|
||||
def initialLife(seed: Long): LifeState = LifeState(Nil, running = false, seed, generation = 0)
|
||||
@@ -0,0 +1,59 @@
|
||||
package snake
|
||||
package life
|
||||
|
||||
import LifeModel.*
|
||||
|
||||
/** m13 life rules: the next generation is a function of the whole old board, and nothing else. */
|
||||
object LifeRules:
|
||||
|
||||
// Every cell of the board, row-major (y, then x). Filtering this list keeps any result in canonical order for free.
|
||||
private val board: List[Point] = (for y <- 0 until LH; x <- 0 until LW yield Point(x, y)).toList
|
||||
|
||||
private def onBoard(p: Point): Boolean = 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.
|
||||
private def index(p: Point): Int = p.y * LW + p.x
|
||||
|
||||
private val offsets = List((-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).
|
||||
private def neighbours(p: Point): List[Point] =
|
||||
offsets.map((dx, dy) => Point(p.x + dx, p.y + dy)).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.
|
||||
def lifeStep(cells: List[Point]): List[Point] =
|
||||
val alive = cells.toSet
|
||||
board.filter { p =>
|
||||
val n = neighbours(p).count(alive)
|
||||
n == 3 || (n == 2 && alive(p))
|
||||
}
|
||||
|
||||
// Add a dead cell or remove a live one, keeping the list sorted row-major without duplicates. Off the board: unchanged.
|
||||
private def toggle(cells: List[Point], p: Point): List[Point] =
|
||||
if !onBoard(p) then cells
|
||||
else if cells.contains(p) then cells.filterNot(_ == p)
|
||||
else cells.filter(index(_) < index(p)) ++ (p :: cells.filter(index(_) > index(p)))
|
||||
|
||||
// One intBelow(4) per board cell in row-major order, the cell alive when the draw is 0 (about one cell in four).
|
||||
// A fold carries the seed; the live cells are collected in reverse and turned round once at the end.
|
||||
private def randomCells(seed: Long): (List[Point], Long) =
|
||||
val (aliveReversed, next) = board.foldLeft((List.empty[Point], seed)) { case ((acc, s), p) =>
|
||||
val (d, s2) = Random.intBelow(4).run(s)
|
||||
(if d == 0 then p :: acc else acc, s2)
|
||||
}
|
||||
(aliveReversed.reverse, next)
|
||||
|
||||
private def nextGeneration(state: LifeState): LifeState =
|
||||
state.copy(cells = lifeStep(state.cells), generation = state.generation + 1)
|
||||
|
||||
def lifeUpdate(state: LifeState, action: LifeAction): LifeState = action match
|
||||
case LifeAction.Frame => if state.running then nextGeneration(state) else state
|
||||
case LifeAction.Step => if state.running then state else nextGeneration(state)
|
||||
case LifeAction.TogglePause => state.copy(running = !state.running)
|
||||
// Clicking edits the world whether it runs or not: the click is one action between two generations.
|
||||
case LifeAction.Toggle(cell) => state.copy(cells = toggle(state.cells, cell))
|
||||
case LifeAction.Clear => state.copy(cells = Nil, generation = 0)
|
||||
case LifeAction.Randomize =>
|
||||
val (cells, seed) = randomCells(state.seed)
|
||||
state.copy(cells = cells, seed = seed, generation = 0)
|
||||
@@ -0,0 +1,21 @@
|
||||
package snake
|
||||
package life
|
||||
|
||||
import LifeModel.*
|
||||
|
||||
/** m13 life view: live cells as squares, and a status strip under the board. */
|
||||
object LifeView:
|
||||
|
||||
private val W = LW * LCELL
|
||||
private val H = LH * LCELL
|
||||
|
||||
private def text(x: Int, y: Int, str: String, align: String): Shape = Shape.Text(x, y, str, "#ffffff", 14, align)
|
||||
|
||||
def lifeView(state: LifeState): Scene =
|
||||
Shape.Rect(0, 0, W, H, "#111111") ::
|
||||
state.cells.map(p => Shape.Rect(p.x * LCELL + 1, p.y * LCELL + 1, LCELL - 2, LCELL - 2, "#7cb342")) ++
|
||||
List(
|
||||
Shape.Rect(0, H, W, STRIP, "#222222"),
|
||||
text(6, H + 17, s"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