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>
60 lines
3.0 KiB
Scala
60 lines
3.0 KiB
Scala
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)
|