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

+40 -2
View File
@@ -1,10 +1,18 @@
package snake
/**
* m10: a session wraps the game with the best score so far. GameState, update and view do not know about it.
* `improved` says whether the action that produced this session raised the best, which is when it must be saved.
*/
final case class Session(game: GameState, best: Int, improved: Boolean)
/** m09 app: the whole game wired together as values: streams of actions in, a stream of drawings out. */
object App:
val TICK_MS: Long = 150
val FRAME_MS: Long = 25
// m10: the longest step of time the game accepts at once; a longer silence (a background tab) counts as this much.
val MAX_GAP_MS: Long = 250
// Everything that can happen to the game: a Tick every TICK_MS, and an action for each bound key.
lazy val classicActions: Stream[Action] =
@@ -20,6 +28,36 @@ object App:
def render(state: GameState): IO[Unit] = IO.drawScene(View.view(state))
// The whole program as a value: get a seed, then turn the stream of states into a stream of drawings.
// m10: one action for the session: step the game, then compare the best before with the best after.
def sessionUpdate(session: Session, action: Action): Session =
val game = Game.update(session.game, action)
val best = math.max(session.best, game.score)
Session(game, best, improved = best > session.best)
// The same running fold as `states`, one level up.
def sessions(seed: Long, best: Int, actions: Stream[Action]): Stream[Session] =
val s0 = Session(Spawn.newGame(seed), best, improved = false)
actions.scan(s0)(sessionUpdate).startWith(s0)
// The game's picture with the best score at the top right, level with the score on the left.
def sessionView(session: Session): Scene =
View.view(session.game) :+
Shape.Text(session.game.width * View.CELL - 6, 18, s"Best: ${session.best}", "#ffffff", 16, "right")
// Draw, and save the best only on the action that raised it.
def renderSession(session: Session): IO[Unit] =
if session.improved then IO.sequence(List(IO.drawScene(sessionView(session)), IO.saveBest(session.best)))
else IO.drawScene(sessionView(session))
// The whole program as a value: get a seed, then the stored best, then turn the stream of sessions into a stream
// of drawings. clampTime wraps the whole stream, so everything inside it sees the clamped clock.
// Nothing runs here; Runtime interprets it.
lazy val main: IO[Stream[IO[Unit]]] = IO.randomSeed.map(seed => states(seed, speedActions).map(render))
lazy val main: IO[Stream[IO[Unit]]] =
IO.randomSeed.flatMap(seed =>
IO.loadBest.map(best => sessions(seed, best, speedActions).map(renderSession).clampTime(MAX_GAP_MS))
)
// m09: the whole program in one equality. For each drawing that `main` emits while fed `events`, the ops it
// performs, answered by the pure interpreter (`seed` for RandomSeed, and from m10 `best` for LoadBest).
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event], best: Int = 0): List[List[Op]] =
IO.trace(main, seed, best).result.run(events).map(io => IO.trace(io, seed, best).ops)
+141
View File
@@ -0,0 +1,141 @@
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)
)
+28 -6
View File
@@ -2,19 +2,41 @@ package snake
/**
* m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions.
* m10 adds pause, restart and a frame clock that speeds up as the score grows.
* m10 adds pause, restart, a frame counter that makes the snake speed up as the score grows, and a turn queue.
*/
object Game:
/**
* What `update` does with `Turn(dir)` from Module 10 on. With no turn pending (`nextDir == dir`) it is the Module 3
* `turn`. With a turn pending, a key that is neither `nextDir` nor its opposite is remembered in `queued`
* (replacing an earlier one) and taken on the step after; any other key changes nothing.
*/
def steer(state: GameState, dir: Direction): GameState =
if state.status != Status.Playing then state
else if state.nextDir == state.dir then Rules.turn(state, dir)
else if dir != state.nextDir && dir != Model.opposite(state.nextDir) then state.copy(queued = Some(dir))
else state
// After a step: a game still in play takes the queued turn, if there is one. It is always a legal turn, because the
// step just made `dir` equal to the old `nextDir`, and `queued` is neither that nor its opposite.
private def takeQueued(state: GameState): GameState = state.queued match
case Some(dir) if state.status == Status.Playing => state.copy(nextDir = dir, queued = None)
case _ => state
// One game step as `update` takes it: `tick`, then the queued turn.
private def step(state: GameState): GameState = takeQueued(Spawn.tick(state))
/** How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2. */
def movesEvery(score: Int): Int = math.max(2, 8 - score / 3)
// One animation frame: count it, and every `movesEvery(score)`-th frame run a game step.
// One animation frame. `frame` counts the frames since the snake last moved; when it reaches
// `movesEvery(score)` the count starts again and the game takes a step. Counting from the last move
// (not from the start of the game) keeps every step exactly `movesEvery` frames long, also right after a speed-up.
private def frame(state: GameState): GameState =
if state.status != Status.Playing then state
else
val counted = state.copy(frame = state.frame + 1)
if counted.frame % movesEvery(counted.score) == 0 then Spawn.tick(counted) else counted
val n = state.frame + 1
if n >= movesEvery(state.score) then step(state.copy(frame = 0)) else state.copy(frame = n)
// Pause and resume only make sense in those two statuses; a finished game stays finished.
private def togglePause(state: GameState): GameState = state.status match
@@ -24,8 +46,8 @@ object Game:
// Every case is listed (no `case _`): adding an Action later makes the compiler point here.
def update(state: GameState, action: Action): GameState = action match
case Action.Tick => Spawn.tick(state)
case Action.Turn(dir) => Rules.turn(state, dir)
case Action.Tick => step(state)
case Action.Turn(dir) => steer(state, dir)
case Action.TogglePause => togglePause(state)
case Action.Restart => Spawn.newGame(state.seed)
case Action.Frame => frame(state)
+22 -5
View File
@@ -4,11 +4,17 @@ package snake
enum Op:
case Draw(shape: Shape)
case RandomSeed
case LoadBest // m10
case SaveBest(score: Int) // m10
/** What the outside world answers: `Done` for a `Draw`, `Seed(value)` for a `RandomSeed`. */
/**
* What the outside world answers: `Done` for a `Draw`, `Seed(value)` for a `RandomSeed`;
* (m10) `Best(value)` for a `LoadBest` and `Done` for a `SaveBest`.
*/
enum OpResult:
case Done
case Seed(value: Long)
case Best(value: Int)
/**
* m07 io: effects as values. An IO is a plain-data description of a program; nothing runs until interpreted.
@@ -43,20 +49,31 @@ object IO:
// An interpreter that answers a RandomSeed with anything but a Seed is broken; say so loudly.
lazy val randomSeed: IO[Long] = effect(Op.RandomSeed).map {
case OpResult.Seed(value) => value
case OpResult.Done => throw new IllegalStateException("RandomSeed must be answered with a Seed")
case other => throw new IllegalStateException(s"RandomSeed must be answered with a Seed, not $other")
}
def drawScene(scene: Scene): IO[Unit] = sequence(scene.map(draw))
// m10: the stored best score. Reading it answers a number; saving answers nothing, like a draw.
lazy val loadBest: IO[Int] = effect(Op.LoadBest).map {
case OpResult.Best(value) => value
case other => throw new IllegalStateException(s"LoadBest must be answered with a Best, not $other")
}
def saveBest(score: Int): IO[Unit] = effect(Op.SaveBest(score)).map(_ => ())
// Answer each op with a canned result, collecting the ops as we go. Tail recursion: the compiler turns it
// into a loop, so long programs cannot overflow the stack, and still nothing is mutated.
def trace[A](io: IO[A], seed: Long): Traced[A] =
// `best` (m10) answers LoadBest, the way `seed` answers RandomSeed; it defaults to 0 so m07 calls stay valid.
def trace[A](io: IO[A], seed: Long, best: Int = 0): Traced[A] =
@annotation.tailrec
def go(io: IO[A], done: List[Op]): Traced[A] = io match
case Pure(a) => Traced(a, done.reverse)
case Effect(op, k) =>
val answer = op match
case Op.RandomSeed => OpResult.Seed(seed)
case Op.Draw(_) => OpResult.Done
case Op.RandomSeed => OpResult.Seed(seed)
case Op.Draw(_) => OpResult.Done
case Op.LoadBest => OpResult.Best(best)
case Op.SaveBest(_) => OpResult.Done
go(k(answer), op :: done)
go(io, Nil)
+8 -3
View File
@@ -17,7 +17,10 @@ enum Action:
case Restart
case Frame
/** The whole game. `snake` is head first; `seed` is the unsigned 32-bit random seed (0 until 2^32). */
/**
* The whole game. `snake` is head first; `seed` is the unsigned 32-bit random seed (0 until 2^32).
* `frame` and `queued` stay unused until Module 10 (`queued` is the turn waiting for the next step, Exercise 10.6).
*/
final case class GameState(
width: Int,
height: Int,
@@ -28,7 +31,8 @@ final case class GameState(
seed: Long,
score: Int,
status: Status,
frame: Int
frame: Int,
queued: Option[Direction]
)
/** m02 model: the data the whole game is built from. Case classes and enums are immutable. */
@@ -56,5 +60,6 @@ object Model:
seed = seed,
score = 0,
status = Status.Playing,
frame = 0
frame = 0,
queued = None
)
+77
View File
@@ -0,0 +1,77 @@
package snake
/**
* (m10) A replay viewer: a recorded game is a seed and a list of actions; watching it is a second, tiny program
* whose state is only a position in the list of the game's states.
*/
final case class Recording(seed: Long, actions: List[Action])
/** The viewer's whole state: which frame is shown, the index of the last frame, and whether autoplay is on. */
final case class Viewer(index: Int, last: Int, playing: Boolean)
/** Everything the viewer can be asked to do, as data. `Beat` is one beat of the autoplay clock. */
enum ViewerAction:
case Next, Prev, First, Last, TogglePlay, Beat
object Replay:
val REPLAY_MS: Long = 100
// Every state of the recorded game, from the new game to the end: the recording replayed with `history`.
def frames(recording: Recording): List[GameState] =
Game.history(Spawn.newGame(recording.seed), recording.actions)
def viewerStart(last: Int): Viewer = Viewer(0, last, playing = false)
private def clampIndex(viewer: Viewer, index: Int): Int = math.min(viewer.last, math.max(0, index))
// Where each action moves the viewer, and whether it plays afterwards (before the end-of-recording rule).
private def move(viewer: Viewer, action: ViewerAction): Viewer = action match
case ViewerAction.Next => viewer.copy(index = clampIndex(viewer, viewer.index + 1))
case ViewerAction.Prev => viewer.copy(index = clampIndex(viewer, viewer.index - 1))
case ViewerAction.First => viewer.copy(index = 0)
case ViewerAction.Last => viewer.copy(index = viewer.last)
// Play from the end means: watch it again from the start.
case ViewerAction.TogglePlay =>
if viewer.playing then viewer.copy(playing = false)
else viewer.copy(index = if viewer.index == viewer.last then 0 else viewer.index, playing = true)
case ViewerAction.Beat =>
if viewer.playing then viewer.copy(index = clampIndex(viewer, viewer.index + 1)) else viewer
// Total: the index never leaves [0, last], and the viewer never plays on the last frame,
// so autoplay stops by itself at the end.
def viewerUpdate(viewer: Viewer, action: ViewerAction): Viewer =
val next = move(viewer, action)
next.copy(playing = next.playing && next.index < next.last)
def viewerKey(key: String): Option[ViewerAction] = key match
case "ArrowRight" | "d" | "D" => Some(ViewerAction.Next)
case "ArrowLeft" | "a" | "A" => Some(ViewerAction.Prev)
case "Home" => Some(ViewerAction.First)
case "End" => Some(ViewerAction.Last)
case " " => Some(ViewerAction.TogglePlay)
case _ => None
// The game's own picture of the current frame, then the position at the top right and a hint at the bottom.
def viewerView(frames: List[GameState], viewer: Viewer): Scene =
val game = frames(viewer.index)
val w = game.width * View.CELL
val hint = if viewer.playing then "Space: pause" else "Left/Right: step Home/End: jump Space: play"
View.view(game) ++ List(
Shape.Text(w - 6, 18, s"step ${viewer.index} / ${viewer.last}", "#ffffff", 16, "right"),
Shape.Text(w / 2, game.height * View.CELL - 8, hint, "#aaaaaa", 12, "center")
)
lazy val replayActions: Stream[ViewerAction] =
Stream.interval(REPLAY_MS).map(_ => ViewerAction.Beat).merge(Stream.keyDowns.filterMap(viewerKey))
def viewerStates(last: Int, actions: Stream[ViewerAction]): Stream[Viewer] =
actions.scan(viewerStart(last))(viewerUpdate).startWith(viewerStart(last))
// It needs no seed from the world (the recording has one), so it starts with `pure`.
// The frames are computed once, here, and every drawing picks one of them.
def replayMain(recording: Recording): IO[Stream[IO[Unit]]] =
val fs = frames(recording)
IO.pure(
viewerStates(fs.length - 1, replayActions).map(v => IO.drawScene(viewerView(fs, v))).clampTime(App.MAX_GAP_MS)
)
+9
View File
@@ -5,9 +5,12 @@ trait Platform:
def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit
def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit
def randomSeed(): Long
def loadBest(): Int // m10
def saveBest(score: Int): Unit // m10
def startClock(onTime: Long => Unit): Unit
def onKeyDown(cb: String => Unit): Unit
def onKeyUp(cb: String => Unit): Unit
def onClick(cb: (Int, Int) => Unit): Unit // m13
/**
* m09 runtime: the single impure place in the course (with the web/ glue); everything else only builds values.
@@ -18,6 +21,10 @@ object Runtime:
// Perform one op on the platform and return its answer.
private def perform(op: Op, p: Platform): OpResult = op match
case Op.RandomSeed => OpResult.Seed(p.randomSeed())
case Op.LoadBest => OpResult.Best(p.loadBest())
case Op.SaveBest(score) =>
p.saveBest(score)
OpResult.Done
case Op.Draw(Shape.Rect(x, y, w, h, color)) =>
p.fillRect(x, y, w, h, color)
OpResult.Done
@@ -39,4 +46,6 @@ object Runtime:
ios.foreach(runIO(_, p))
p.onKeyDown(key => feed(Event.KeyDown(key)))
p.onKeyUp(key => feed(Event.KeyUp(key)))
// m13: a fourth kind of input. Programs that do not listen for clicks simply ignore them.
p.onClick((x, y) => feed(Event.Click(x, y)))
p.startClock(t => feed(Event.Time(t)))
+58
View File
@@ -0,0 +1,58 @@
package snake
import scala.annotation.tailrec
/**
* m07b safeio (optional): a stack-safe IO. Same idea as m07's IO, plus one more constructor, so that `flatMap` only
* builds data and no program shape can overflow the stack, however deeply its `flatMap`s are nested.
* - `Pure(a)` done, with a value
* - `Effect(op, k)` perform `op`, then continue with `k(result)`
* - `FlatMap(io, f)` run `io`, then continue with `f` of its result. `io` produces some type `X` that only `f`
* knows about: the case has its own type parameters, so `X` stays hidden inside the node.
* `Op`, `OpResult` and `Traced` are the ones from IO.scala. Independent of the game and of `IO`.
*/
enum SafeIO[+A]:
case Pure(a: A)
case Effect(op: Op, k: OpResult => SafeIO[A])
case FlatMap[X, B](io: SafeIO[X], f: X => SafeIO[B]) extends SafeIO[B]
// Only builds a node: never calls f, not even when this is a Pure.
def flatMap[B](f: A => SafeIO[B]): SafeIO[B] = FlatMap(this, f)
def map[B](f: A => B): SafeIO[B] = flatMap(a => Pure(f(a)))
object SafeIO:
def pure[A](a: A): SafeIO[A] = Pure(a)
def effect(op: Op): SafeIO[OpResult] = Effect(op, pure)
// A left fold on purpose: with SafeIO the nesting no longer matters.
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = ios.foldLeft(pure(()))((acc, io) => acc.flatMap(_ => io))
// One rewrite towards a program whose top is a Pure or an Effect. Constant work, no recursion.
// FlatMap(Pure(a), f) -> f(a)
// FlatMap(Effect(op, k), f) -> Effect(op, r => FlatMap(k(r), f))
// FlatMap(FlatMap(m, g), f) -> FlatMap(m, x => FlatMap(g(x), f)) (re-association)
// Pure and Effect are already in that shape and come back unchanged.
def step[A](io: SafeIO[A]): SafeIO[A] = io match
case FlatMap(Pure(x), f) => f(x)
case FlatMap(Effect(op, k), f) => Effect(op, r => FlatMap(k(r), f))
case FlatMap(FlatMap(m, g), f) => FlatMap(m, x => FlatMap(g(x), f))
case other => other
// Module 7's two operations only. Module 10's LoadBest and SaveBest share the `Op` type but are not part of this
// module: a SafeIO program that uses them is a mistake, and saying so beats inventing an answer.
private def answer(op: Op, seed: Long): OpResult = op match
case Op.RandomSeed => OpResult.Seed(seed)
case Op.Draw(_) => OpResult.Done
case Op.LoadBest | Op.SaveBest(_) => throw new IllegalArgumentException(s"SafeIO.trace does not interpret $op")
// The interpreter as a loop: `loop` calls itself only as its very last action, which `@tailrec` turns into a jump,
// so the stack stays flat. Every FlatMap is rewritten by `step`, every Effect answered; ops collect at the front.
def trace[A](io: SafeIO[A], seed: Long): Traced[A] =
@tailrec def loop(current: SafeIO[A], performed: List[Op]): Traced[A] = current match
case Pure(a) => Traced(a, performed.reverse)
case Effect(op, k) => loop(k(answer(op, seed)), op :: performed)
case other => loop(step(other), performed)
loop(io, Nil)
+32
View File
@@ -0,0 +1,32 @@
package snake
/**
* m04 state (optional, Exercise 4.7): the general pattern behind Random. A State[S, A] is a function
* s => (A, nextS): it reads a state, produces a value and hands on a new state. Random[A] is State[Long, A].
* Nothing in the game uses this file; it is here to show that Random was one example of a bigger idea.
*/
final case class State[S, A](run: S => (A, S)):
def map[B](f: A => B): State[S, B] =
State { s =>
val (a, next) = run(s)
(f(a), next)
}
def flatMap[B](f: A => State[S, B]): State[S, B] =
State { s =>
val (a, next) = run(s)
f(a).run(next)
}
object State:
def pure[S, A](a: A): State[S, A] = State(s => (a, s))
/** The current state as the value; the state is left untouched. */
def get[S]: State[S, S] = State(s => (s, s))
/** Replace the state with `s`; there is no interesting value, so it is `()`. */
def put[S](s: S): State[S, Unit] = State(_ => ((), s))
def modify[S](f: S => S): State[S, Unit] = State(s => ((), f(s)))
+26 -1
View File
@@ -1,10 +1,14 @@
package snake
/** Something that happens to the program: time passing (ms since start) or a key going down or up. */
/**
* Something that happens to the program: time passing (ms since start), a key going down or up, or (from Module 13)
* a mouse click at canvas pixel (x, y). Every stream ignores the kinds of event it does not use.
*/
enum Event:
case Time(t: Long)
case KeyDown(key: String)
case KeyUp(key: String)
case Click(x: Int, y: Int)
/**
* m08 stream: an event stream is a pure automaton (a Mealy machine).
@@ -54,6 +58,21 @@ final case class Stream[A](step: Event => (List[A], Stream[A])):
(v :: out, next)
}
// (m10) `last` is the previous real time, `clock` the time this stream has been told. A long silence
// (a background tab) moves `clock` by at most `maxGap`, so this stream never has to catch up on minutes of beats.
private def clampFrom(maxGap: Long, last: Long, clock: Long): Stream[A] =
Stream {
case Event.Time(t) =>
val now = clock + math.min(math.max(t - last, 0L), maxGap)
val (out, next) = step(Event.Time(now))
(out, next.clampFrom(maxGap, t, now))
case e =>
val (out, next) = step(e)
(out, next.clampFrom(maxGap, last, clock))
}
def clampTime(maxGap: Long): Stream[A] = clampFrom(maxGap, 0, 0)
def run(events: List[Event]): List[A] =
events
.foldLeft((List.empty[A], this)) { case ((outs, stream), e) =>
@@ -75,6 +94,12 @@ object Stream:
case _ => (Nil, keyUps)
}
// (m13, given in src/) The position of every Click event, in canvas pixels. Like the key sources it remembers nothing.
lazy val clicks: Stream[(Int, Int)] = Stream {
case Event.Click(x, y) => (List((x, y)), clicks)
case _ => (Nil, clicks)
}
// `last` is the time of the previous Time event. Multiples of ms in (last, t] fire, ascending.
private def intervalFrom(ms: Long, last: Long): Stream[Long] = Stream {
case Event.Time(t) => ((last / ms + 1 to t / ms).map(_ * ms).toList, intervalFrom(ms, t))
+36
View File
@@ -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)
)
+32
View File
@@ -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)
+59
View File
@@ -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)
+21
View File
@@ -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")
)
+3 -1
View File
@@ -29,4 +29,6 @@ object PongApp:
actions.scan(initialPong(seed))(PongRules.pongUpdate).startWith(initialPong(seed))
lazy val pongMain: IO[Stream[IO[Unit]]] =
IO.randomSeed.map(seed => pongStates(seed, pongActions).map(state => IO.drawScene(PongView.pongView(state))))
IO.randomSeed.map(seed =>
pongStates(seed, pongActions).map(state => IO.drawScene(PongView.pongView(state))).clampTime(App.MAX_GAP_MS)
)
+2 -1
View File
@@ -67,5 +67,6 @@ object PongRules:
def pongUpdate(state: PongState, action: PongAction): PongState = action match
case PongAction.Press(key) => setHeld(state, key, true)
case PongAction.Release(key) => setHeld(state, key, false)
case PongAction.Restart => initialPong(state.seed)
// A new match, but the keys are still where the player's fingers are.
case PongAction.Restart => initialPong(state.seed).copy(held = state.held)
case PongAction.Frame => frame(state)