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>
142 lines
7.0 KiB
Scala
142 lines
7.0 KiB
Scala
package snake
|
|
|
|
/** What `Check.forAll` reports: every value passed, or the first one that did not (with the seed that draws it again). */
|
|
enum CheckResult[+A]:
|
|
case Passed(runs: Int)
|
|
case Failed(run: Int, seed: Long, value: A)
|
|
|
|
/**
|
|
* m12 check: property-based testing built from the course's own parts.
|
|
* A generator of test data is nothing new: it is a Random[A] from Random.scala (seed => (A, nextSeed)).
|
|
*/
|
|
object Check:
|
|
|
|
/** One of the elements of the non-empty list `xs`: the one at index `intBelow(xs.length)`. */
|
|
def elementOf[A](xs: List[A]): Random[A] = Random.intBelow(xs.length).map(xs(_))
|
|
|
|
/** `n` values drawn from `gen`, one after another; the seed is threaded from each draw to the next. */
|
|
def listOf[A](gen: Random[A], n: Int): Random[List[A]] =
|
|
if n <= 0 then Random.pure(Nil)
|
|
else gen.flatMap(a => listOf(gen, n - 1).map(rest => a :: rest))
|
|
|
|
// Ticks appear three times so that the snake moves more often than it turns.
|
|
private val actionPool: List[Action] = List(
|
|
Action.Tick, Action.Tick, Action.Tick,
|
|
Action.Turn(Direction.Up), Action.Turn(Direction.Down), Action.Turn(Direction.Left), Action.Turn(Direction.Right)
|
|
)
|
|
|
|
/** A generator of `n` random actions (given). */
|
|
def genActions(n: Int): Random[List[Action]] = listOf(elementOf(actionPool), n)
|
|
|
|
/**
|
|
* Check `prop` on `runs` values drawn from `gen`, starting from `seed`; stop at the first value that fails.
|
|
* Recursion carries the seed: no counter, no loop. `tailrec` makes the compiler check that it cannot overflow the stack.
|
|
*/
|
|
def forAll[A](gen: Random[A], runs: Int, seed: Long)(prop: A => Boolean): CheckResult[A] =
|
|
@annotation.tailrec
|
|
def go(i: Int, s: Long): CheckResult[A] =
|
|
if i >= runs then CheckResult.Passed(runs)
|
|
else
|
|
val (value, next) = gen.run(s)
|
|
if prop(value) then go(i + 1, next) else CheckResult.Failed(i, s, value)
|
|
go(0, seed)
|
|
|
|
/** Every list that is `xs` with exactly one element removed, in the order of the removed index. */
|
|
def removals[A](xs: List[A]): List[List[A]] = xs.indices.toList.map(i => xs.take(i) ++ xs.drop(i + 1))
|
|
|
|
/** Keep removing one element for as long as the list still fails; the result is 1-minimal. */
|
|
def minimize[A](xs: List[A])(fails: List[A] => Boolean): List[A] =
|
|
removals(xs).find(fails) match
|
|
case Some(smaller) => minimize(smaller)(fails)
|
|
case None => xs
|
|
|
|
private def neighbours(a: Point, b: Point): Boolean = math.abs(a.x - b.x) + math.abs(a.y - b.y) == 1
|
|
|
|
// Each invariant: its name, and a predicate that is true when the state is fine.
|
|
private val invariants: List[(String, GameState => Boolean)] = List(
|
|
("snake-not-empty", s => s.snake.nonEmpty),
|
|
("snake-in-bounds", s => s.snake.forall(p => Rules.inBounds(s, p))),
|
|
("snake-no-overlap", s => s.snake.distinct.length == s.snake.length),
|
|
("snake-connected", s => s.snake.zip(s.snake.drop(1)).forall((a, b) => neighbours(a, b))),
|
|
("apple-in-bounds", s => s.apple.forall(a => Rules.inBounds(s, a))),
|
|
("apple-not-on-snake", s => s.apple.forall(a => !Util.contains(s.snake, a, _ == _))),
|
|
("turn-not-reversed", s => s.nextDir != Model.opposite(s.dir)),
|
|
("queued-not-reversed", s => !s.queued.contains(Model.opposite(s.nextDir)))
|
|
)
|
|
|
|
/** The names of the invariants that `state` breaks, in the order of the table above. */
|
|
def violations(state: GameState): List[String] =
|
|
invariants.collect { case (name, holds) if !holds(state) => name }
|
|
|
|
/** The numbers of states between consecutive moves of the snake's head. */
|
|
def moveGaps(states: List[GameState]): List[Int] =
|
|
val moved = states.zip(states.drop(1)).zipWithIndex.collect {
|
|
case ((before, after), i) if after.snake.headOption != before.snake.headOption => i + 1
|
|
}
|
|
moved.zip(moved.drop(1)).map((a, b) => b - a)
|
|
|
|
// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
|
|
|
|
private val directions = List(Direction.Up, Direction.Down, Direction.Left, Direction.Right)
|
|
|
|
// `Rules.inBounds` wants a whole state; here there is only a board size.
|
|
private def onBoard(width: Int, height: Int, p: Point): Boolean = p.x >= 0 && p.x < width && p.y >= 0 && p.y < height
|
|
|
|
// Extend `snake` at its tail by up to `left` more cells, each a random free in-bounds neighbour of the last cell.
|
|
private def grow(width: Int, height: Int, snake: List[Point], left: Int): Random[List[Point]] =
|
|
if left <= 0 then Random.pure(snake)
|
|
else
|
|
val options = directions
|
|
.map(d => Rules.move(snake.last, d))
|
|
.filter(p => onBoard(width, height, p) && !Util.contains(snake, p, _ == _))
|
|
if options.isEmpty then Random.pure(snake)
|
|
else elementOf(options).flatMap(p => grow(width, height, snake :+ p, left - 1))
|
|
|
|
/** A random snake, head first: a random head, then a random walk that never leaves the board or crosses itself. */
|
|
def genSnake(width: Int, height: Int, maxLen: Int): Random[List[Point]] =
|
|
Random.intBelow(width).flatMap(x =>
|
|
Random.intBelow(height).flatMap(y => grow(width, height, List(Point(x, y)), maxLen - 1))
|
|
)
|
|
|
|
// The direction the head moved in to get from the second cell to the first (Right for a one-cell snake).
|
|
private def headingOf(snake: List[Point]): Direction = snake match
|
|
case head :: second :: _ => directions.find(d => Rules.move(second, d) == head).getOrElse(Direction.Right)
|
|
case _ => Direction.Right
|
|
|
|
/** A random sane Playing state on a `width` x `height` board (given). */
|
|
def genState(width: Int, height: Int): Random[GameState] =
|
|
Random.intBelow(width * height).flatMap { n =>
|
|
genSnake(width, height, n + 1).flatMap { snake =>
|
|
val dir = headingOf(snake)
|
|
val state = GameState(width, height, snake, dir, dir, None, 0L, 0, Status.Playing, 0, None)
|
|
val free = Spawn.freeCells(state)
|
|
elementOf(directions.filter(_ != Model.opposite(dir))).flatMap { nextDir =>
|
|
Random.intBelow(2).flatMap { coin =>
|
|
val apple: Random[Option[Point]] =
|
|
if coin == 0 || free.isEmpty then Random.pure(None) else elementOf(free).map(Some(_))
|
|
apple.map(a => state.copy(nextDir = nextDir, apple = a))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The step of Module 3, written from the rules instead of from `advance`: build the snake the step would give,
|
|
* then ask `violations` whether that snake is on the board and does not overlap itself.
|
|
*/
|
|
def advanceSpec(state: GameState): GameState =
|
|
if state.status != Status.Playing then state
|
|
else
|
|
val head = Rules.move(state.snake.head, state.nextDir)
|
|
val eating = state.apple.contains(head)
|
|
val snake = head :: (if eating then state.snake else Util.dropLast(state.snake))
|
|
val broken = violations(state.copy(snake = snake)).exists(v => v == "snake-in-bounds" || v == "snake-no-overlap")
|
|
if broken then state.copy(dir = state.nextDir, status = Status.GameOver)
|
|
else
|
|
state.copy(
|
|
snake = snake,
|
|
dir = state.nextDir,
|
|
apple = if eating then None else state.apple,
|
|
score = state.score + (if eating then 1 else 0)
|
|
)
|