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

+4 -1
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# Usage: ./build-web.sh
# Compiles src/ + web/ to JavaScript with Scala.js: web/dist/snake.js (index.html) and web/dist/pong.js (pong.html).
# Compiles src/ + web/ to JavaScript with Scala.js: web/dist/snake.js (index.html), web/dist/pong.js (pong.html),
# web/dist/replay.js (replay.html) and web/dist/life.js (life.html).
# Then serve the repo root (node scripts/serve.mjs) and open http://localhost:8000/scala/index.html.
set -euo pipefail
cd "$(dirname "$0")"
@@ -11,3 +12,5 @@ build() { # <main class> <output file>
}
build snake.web.SnakeMain web/dist/snake.js
build snake.web.PongMain web/dist/pong.js
build snake.web.ReplayMain web/dist/replay.js
build snake.web.LifeMain web/dist/life.js
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pure Life</title>
<link rel="icon" href="data:,">
<style>
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 12px; background: #1b1b1b; color: #ddd; font: 14px system-ui, sans-serif; }
h1 { margin: 0; font-size: 20px; }
canvas { background: #111111; border: 2px solid #333; cursor: crosshair; }
p { margin: 0; color: #999; }
</style>
</head>
<body>
<h1>Pure Life</h1>
<canvas id="game" width="640" height="504"></canvas>
<p>Click a cell to switch it on or off. Space runs and pauses, N steps one generation, R fills the board at random, C clears it.</p>
<script type="module" src="web/dist/life.js"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pure Snake replay</title>
<link rel="icon" href="data:,">
<style>
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 12px; background: #1b1b1b; color: #ddd; font: 14px system-ui, sans-serif; }
h1 { margin: 0; font-size: 20px; }
canvas { background: #111111; border: 2px solid #333; }
p { margin: 0; color: #999; }
</style>
</head>
<body>
<h1>Pure Snake: replay</h1>
<canvas id="game" width="400" height="400"></canvas>
<p>Right / D next step, Left / A previous, Home / End first / last, Space plays (Module 10, Exercise 10.8).</p>
<script type="module" src="web/dist/replay.js"></script>
</body>
</html>
+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)
+46 -1
View File
@@ -1,5 +1,11 @@
package snake
/**
* (m10) A session: the game, the best score so far, and whether the action that produced this session raised the
* best. Given: `GameState`, `Game.update` and `View.view` do not change; the session wraps the game.
*/
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:
@@ -9,6 +15,9 @@ object App:
/** Milliseconds between animation frames (m10). */
val FRAME_MS: Long = 25
/** (m10) The longest step of time the game accepts at once, in milliseconds (see `clampTime` in Stream.scala). */
val MAX_GAP_MS: Long = 250
/**
* Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `Stream.interval`),
* merged with the action for each key press that `Input.keyToAction` recognises (from `Stream.keyDowns`).
@@ -30,8 +39,44 @@ object App:
/** The drawing of one state, as an IO: `IO.drawScene(View.view(state))`. */
def render(state: GameState): IO[Unit] = ???
/**
* (m10) One action for the session: `game' = Game.update(game, action)`, `best' = max(best, game'.score)`,
* `improved = best' > best` (strictly greater: equalling the record is not a new record).
*/
def sessionUpdate(session: Session, action: Action): Session = ???
/**
* (m10) Like `states`, one level up: scan the actions with `sessionUpdate` from `s0`, and emit `s0` first
* (startWith), with `s0 = Session(Spawn.newGame(seed), best, improved = false)`.
*/
def sessions(seed: Long, best: Int, actions: Stream[Action]): Stream[Session] = ???
/**
* (m10) `View.view(session.game)` followed by one more shape: the text `Best: <best>` at the top right,
* `Shape.Text(game.width * View.CELL - 6, 18, "Best: 3", "#ffffff", 16, "right")`.
*/
def sessionView(session: Session): Scene = ???
/**
* (m10) `IO.drawScene(sessionView(session))`, followed by `IO.saveBest(session.best)` when `session.improved`
* (and only then).
*/
def renderSession(session: Session): IO[Unit] = ???
/**
* The whole program as a value: ask for a random seed, then turn the stream of states (built from `classicActions`
* in m09, from `speedActions` after m10) into a stream of drawings (`render`). Nothing runs here.
* in m09, from `speedActions` after m10) into a stream of drawings (`render`).
* (m10) The whole stream is wrapped in `.clampTime(MAX_GAP_MS)`.
* (m10, Exercise 10.7) After the seed, ask for the stored best (`IO.loadBest`), and draw the stream of
* `sessions(seed, best, speedActions)` with `renderSession` instead of the states with `render`.
* Nothing runs here.
*/
lazy val main: IO[Stream[IO[Unit]]] = ???
/**
* (m09) The whole program in one equality: for each drawing that `main` emits while it is fed `events`, the list of
* ops that drawing performs. Interpret `main` with `IO.trace(main, seed)`, `run` the stream it produces on `events`,
* and `IO.trace` each emitted IO, keeping its `ops`.
* (m10) Pass `best` to both traces as well, so that `LoadBest` is answered with it.
*/
def play(main: IO[Stream[IO[Unit]]], seed: Long, events: List[Event], best: Int = 0): List[List[Op]] = ???
+138
View File
@@ -0,0 +1,138 @@
package snake
/**
* What `Check.forAll` reports (given): `Passed(runs)` when every value passed, otherwise `Failed(run, seed, value)`,
* where `run` is the 0-based number of the failing draw, `value` is the counterexample and `seed` is the seed that
* draw started from (so `gen.run(seed)._1` gives `value` 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:
/**
* A Random that gives one of the elements of the non-empty list `xs`: the element at index `i`,
* where `i` is drawn with `Random.intBelow(xs.length)`.
*/
def elementOf[A](xs: List[A]): Random[A] = ???
/**
* A Random that gives a list of `n` values drawn from `gen`, one after another: the first draw starts from the
* incoming seed, each later draw from the seed the previous one left behind, and the last draw's seed is the result's.
* `listOf(gen, 0)` gives `Nil` and leaves the seed alone. No loop: recursion on `n`, with `flatMap` and `map`.
*/
def listOf[A](gen: Random[A], n: Int): Random[List[A]] = ???
// 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; it works once `elementOf` and `listOf` do). */
def genActions(n: Int): Random[List[Action]] = listOf(elementOf(actionPool), n)
/**
* Check the property `prop` (a function value => Boolean) on `runs` values drawn from `gen`.
* The first value is drawn from `seed`, each later one from the seed the previous draw left behind.
* Stop at the first value for which `prop` is false.
* Result: `CheckResult.Passed(runs)` when every value passed, otherwise `CheckResult.Failed(run, seed, value)`, where
* `run` is the 0-based number of the failing draw, `value` is the counterexample and `seed` is the seed that
* draw started from (so `gen.run(seed)._1` gives `value` again).
* No loop: a recursive helper that carries the run number and the seed. Mark it `@annotation.tailrec`, so the
* compiler checks that a million runs cannot overflow the stack.
*/
def forAll[A](gen: Random[A], runs: Int, seed: Long)(prop: A => Boolean): CheckResult[A] = ???
/**
* Every list that is `xs` with exactly one element removed, in the order of the removed index:
* `removals(List(1, 2, 3))` is `List(List(2, 3), List(1, 3), List(1, 2))`; `removals(Nil)` is `Nil`.
*/
def removals[A](xs: List[A]): List[List[A]] = ???
/**
* Shrink a failing list. `fails` is a function list => Boolean and `fails(xs)` is true.
* If some list in `removals(xs)` still fails, continue with the first one that does; when none fails, return `xs`.
* The result is "1-minimal": it fails, and removing any single element makes it pass.
*/
def minimize[A](xs: List[A])(fails: List[A] => Boolean): List[A] = ???
/**
* The names of the invariants that `state` breaks, in this order (`Nil` means the state is sane):
* "snake-not-empty" the snake has at least one cell
* "snake-in-bounds" every cell of the snake is on the board
* "snake-no-overlap" no two cells of the snake are equal
* "snake-connected" each cell of the snake is exactly one step (up, down, left or right) from the one before it
* "apple-in-bounds" the apple, if there is one, is on the board
* "apple-not-on-snake" the apple, if there is one, is not on a cell of the snake
* "turn-not-reversed" `nextDir` is not the opposite of `dir`
* "queued-not-reversed" `queued` is `None`, or is not the opposite of `nextDir`
* "If there is one": an `apple` of `None` breaks neither apple invariant.
*/
def violations(state: GameState): List[String] = ???
/**
* Given the states of a game in order, the snake "moves at index i" (i >= 1) when the head of `states(i)`
* differs from the head of `states(i - 1)`. `moveGaps(states)` is the list of differences between consecutive
* such indices: moves at 2, 5 and 12 give `List(3, 7)`. Fewer than two moves give `Nil`.
*/
def moveGaps(states: List[GameState]): List[Int] = ???
/**
* (Exercise 12.6) A Random that gives a snake, head first, on a `width` x `height` board, with at most `maxLen` cells
* (`maxLen >= 1`). The draws, in this exact order (so that every track gets the same snake from the same seed):
* 1. the head: `x` with `Random.intBelow(width)`, then `y` with `Random.intBelow(height)`;
* 2. then, while the snake has fewer than `maxLen` cells: look at the neighbours of its *last* cell, in the order
* Up, Down, Left, Right (`Rules.move(last, dir)`), and keep those that are on the board (`0 <= x < width`,
* `0 <= y < height`) and not yet in the snake. If none is left, stop early. Otherwise append the one picked
* with `elementOf(neighbours)` and go on.
* So the result is always on the board, connected and free of overlap. No loop: recursion, with `flatMap`.
*/
def genSnake(width: Int, height: Int, maxLen: Int): Random[List[Point]] = ???
private val directions = List(Direction.Up, Direction.Down, Direction.Left, Direction.Right)
// 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; it works once `genSnake` does). Draws in order:
* the snake's length limit `n + 1` with `n` from `Random.intBelow(width * height)`; the snake with `genSnake`;
* `nextDir` with `elementOf` among Up, Down, Left, Right without the opposite of `dir` (so: `dir` or a perpendicular
* direction); a coin `Random.intBelow(2)`: 0 means no apple, 1 means an apple at `elementOf(freeCells)` (no apple if
* no cell is free). `dir` is the direction from the snake's second cell to its head (Right for a one-cell snake);
* seed, score and frame are 0, `queued` is `None`.
*/
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))
}
}
}
}
/**
* (Exercise 12.7) The step of Module 3, written straight from the rules, as a second opinion on `Rules.advance`.
* Not Playing: the state unchanged. Otherwise build the snake the step would produce: the new head
* (`Rules.move(head, nextDir)`) in front, followed by the old snake, without its last cell unless the new head is on
* the apple. Then ask `violations` about the state with that snake: if the list contains "snake-in-bounds" or
* "snake-no-overlap", the result is Module 3's game over (`dir = nextDir`, status GameOver, nothing else changed);
* otherwise the state with the new snake, `dir = nextDir`, and, when eating, the apple gone and the score one higher.
* Do not call `Rules.advance`. On every sane state the result must equal `Rules.advance(state)`.
*/
def advanceSpec(state: GameState): GameState = ???
+18 -2
View File
@@ -3,6 +3,16 @@ package snake
/** m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions. */
object Game:
/**
* (m10, Exercise 10.6) What `update` does with `Turn(dir)` once the turn queue is in, so that a quick Up-then-Left
* within one step is not lost:
* - not Playing: the state unchanged;
* - no turn pending (`nextDir == dir`): `Rules.turn(state, dir)`, the Module 3 rule;
* - a turn pending (`nextDir != dir`): if `dir` is neither `nextDir` nor `Model.opposite(nextDir)`, the state with
* `queued = Some(dir)` (a later key replaces an earlier queued one); otherwise the state unchanged.
*/
def steer(state: GameState, dir: Direction): GameState = ???
/**
* How many frames pass between game steps (m10): `max(2, 8 - score / 3)` (integer division).
* Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2.
@@ -17,8 +27,14 @@ object Game:
*
* (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status.
* (m10) `Restart` gives `Spawn.newGame(state.seed)`, whatever the status.
* (m10) `Frame` does nothing unless Playing; otherwise it adds 1 to `frame`, and when the new `frame` is a multiple of
* `movesEvery(score)` it also runs a game step (`Spawn.tick`) on that incremented state.
* (m10) `Frame` does nothing unless Playing. Otherwise it counts the frame: `n = frame + 1`. While `n` is below
* `movesEvery(score)` the result is the state with `frame = n`. When `n` reaches `movesEvery(score)` (`n >= ...`),
* the count starts again and the game takes a step: the result is `Spawn.tick` of the state with `frame = 0`.
* So `frame` is the number of frames since the snake last moved.
* (m10, Exercise 10.6) The turn queue: `Turn` uses `steer` instead of `Rules.turn`, and after every game step
* `update` takes (the `Tick` case and the stepping branch of `Frame`), if the result is Playing and `queued` is
* `Some(dir)`, the result gets `nextDir = dir` and `queued = None`. A result that is not Playing keeps `queued`
* as it is.
* In m10, match every case of `Action` explicitly (no `case _`), so the compiler tells you if one is forgotten.
*/
def update(state: GameState, action: Action): GameState = ???
+19 -2
View File
@@ -4,11 +4,17 @@ package snake
enum Op:
case Draw(shape: Shape)
case RandomSeed
case LoadBest // m10: read the stored best score
case SaveBest(score: Int) // m10: store `score` as the new best
/** What the outside world answers: `Done` for a `Draw`, `Seed(value)` (an unsigned 32-bit number) for a `RandomSeed`. */
/**
* What the outside world answers: `Done` for a `Draw`, `Seed(value)` (an unsigned 32-bit number) for a `RandomSeed`;
* (m10) `Best(value)` (the stored best score) 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[A]` is a description of a program that, when interpreted, produces an `A`:
@@ -52,9 +58,20 @@ object IO:
/** Draw every shape of a scene in order: `sequence(scene.map(draw))`. */
def drawScene(scene: Scene): IO[Unit] = ???
/**
* (m10) An IO that reads the stored best score and produces it (the `value` of the `Best` answer). Built like
* `randomSeed`: `effect(Op.LoadBest)`.
*/
lazy val loadBest: IO[Int] = ???
/** (m10) An IO that stores `score` as the new best and produces `()`: `effect(Op.SaveBest(score))`. */
def saveBest(score: Int): IO[Unit] = ???
/**
* A pure interpreter for tests. Answers every `RandomSeed` with `Seed(seed)` and every `Draw` with `Done`.
* (m10) Also answers every `LoadBest` with `Best(best)` (default 0) and every `SaveBest` with `Done`;
* in m07 you can leave `best` out.
* Returns the final value and the operations in the order they were executed.
* Implemented by recursion, with no mutation.
*/
def trace[A](io: IO[A], seed: Long): Traced[A] = ???
def trace[A](io: IO[A], seed: Long, best: Int = 0): Traced[A] = ???
+7 -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 game is built from, plus two tiny functions over directions. */
@@ -42,6 +46,6 @@ object Model:
/**
* A fresh game: 20x20, snake (10,10),(9,10),(8,10) heading Right (`dir` and `nextDir`),
* no apple, the given seed, score 0, status Playing, frame 0.
* no apple, the given seed, score 0, status Playing, frame 0, nothing queued (`queued = None`).
*/
def initialState(seed: Long): GameState = ???
+62
View File
@@ -0,0 +1,62 @@
package snake
/**
* (m10) A replay viewer. A recording is the seed of the game and every action, in order
* (see `fixtures/replay-sample.json`).
*/
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:
/** Milliseconds between two frames of autoplay. */
val REPLAY_MS: Long = 100
/** Every state of the recorded game: `Game.history(Spawn.newGame(seed), actions)`. Its length is `actions.length + 1`. */
def frames(recording: Recording): List[GameState] = ???
/** The viewer at the first frame, not playing: `Viewer(0, last, false)`. */
def viewerStart(last: Int): Viewer = ???
/**
* One step of the viewer; total, for every action and every viewer.
* - `Next` / `Prev`: one frame forward / back, never past `last` or below 0.
* - `First` / `Last`: frame 0 / frame `last`.
* - `TogglePlay`: playing -> not playing. Not playing -> playing; at the last frame it starts again from frame 0.
* - `Beat`: when playing, one frame forward (never past `last`); when not playing, unchanged.
* - After every action: a viewer at the last frame is not playing (so autoplay stops at the end by itself).
*/
def viewerUpdate(viewer: Viewer, action: ViewerAction): Viewer = ???
/**
* A key to a viewer action, if any: "ArrowRight"/"d"/"D" -> Next, "ArrowLeft"/"a"/"A" -> Prev, "Home" -> First,
* "End" -> Last, " " -> TogglePlay. Any other key -> `None`.
*/
def viewerKey(key: String): Option[ViewerAction] = ???
/**
* The picture: `View.view(frames(index))`, followed by two texts. With `w = width * View.CELL` of that frame:
* `Text(w - 6, 18, "step <index> / <last>", "#ffffff", 16, "right")` and
* `Text(w / 2, height * View.CELL - 8, <hint>, "#aaaaaa", 12, "center")`, where the hint is `"Space: pause"` while
* playing and `"Left/Right: step Home/End: jump Space: play"` (three spaces between the parts) otherwise.
*/
def viewerView(frames: List[GameState], viewer: Viewer): Scene = ???
/** `Beat` every REPLAY_MS (from `Stream.interval`), merged with `viewerKey` of every key press (the beats first). */
lazy val replayActions: Stream[ViewerAction] = ???
/** Fold the actions into viewers with `viewerUpdate`, starting from `viewerStart(last)`, which is emitted first. */
def viewerStates(last: Int, actions: Stream[ViewerAction]): Stream[Viewer] = ???
/**
* The whole viewer as a value: `IO.pure` of the stream of drawings of `viewerView(frames(recording), viewer)` over
* `viewerStates(<last index>, replayActions)`, wrapped in `.clampTime(App.MAX_GAP_MS)`.
* No seed is asked for: the recording carries its own.
*/
def replayMain(recording: Recording): IO[Stream[IO[Unit]]] = ???
+10 -1
View File
@@ -9,10 +9,16 @@ trait Platform:
def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit
/** A random unsigned 32-bit seed. */
def randomSeed(): Long
/** (m10) The stored best score; anything missing or unreadable counts as 0. Never throws. */
def loadBest(): Int
/** (m10) Store `score` as the new best. Never throws: a record that cannot be stored is lost. */
def saveBest(score: Int): Unit
/** Call `onTime(t)` again and again, `t` = milliseconds since the clock started. */
def startClock(onTime: Long => Unit): Unit
def onKeyDown(cb: String => Unit): Unit
def onKeyUp(cb: String => Unit): Unit
/** (m13, given) Call `cb(x, y)` for every mouse click on the canvas, `x` and `y` in canvas pixels. */
def onClick(cb: (Int, Int) => Unit): Unit
/**
* m09 runtime: the single impure place in the course (with the web/ glue); everything else only builds values.
@@ -23,7 +29,9 @@ object Runtime:
/**
* Interpret an IO against a platform and return its final value.
* `Draw` of a Rect -> `p.fillRect(x, y, w, h, color)` and of a Text -> `p.fillText(x, y, text, color, size, align)`,
* both answered with `OpResult.Done`; `RandomSeed` -> `OpResult.Seed(p.randomSeed())`. Each answer is passed to `k`.
* both answered with `OpResult.Done`; `RandomSeed` -> `OpResult.Seed(p.randomSeed())`.
* (m10) `LoadBest` -> `OpResult.Best(p.loadBest())`; `SaveBest(score)` -> `p.saveBest(score)`, answered with `Done`.
* Each answer is passed to `k`.
* Must not grow the stack with the number of ops: a loop, or a `@annotation.tailrec` function (which the compiler
* turns into a loop).
*/
@@ -33,5 +41,6 @@ object Runtime:
* Run the program: `runIO(main, p)` gives the stream; keep the current stream in the one `var`.
* Register `p.onKeyDown` / `p.onKeyUp` / `p.startClock` so that each event steps the stream with the matching
* `Event` (`KeyDown(key)`, `KeyUp(key)`, `Time(t)`), stores the next stream, and runs every IO the step emitted, in order.
* (m13) Also register `p.onClick` so that each click steps the stream with `Event.Click(x, y)`.
*/
def runApp(main: IO[Stream[IO[Unit]]], p: Platform): Unit = ???
+58
View File
@@ -0,0 +1,58 @@
package snake
import scala.annotation.tailrec
/**
* Optional Module 7b. A stack-safe IO: the IO of Module 7 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)` finished, with the value `a`
* - `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. Nothing else in the course uses this file.
*/
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]
/** (Exercise 7b.1) Only builds the node `FlatMap(this, f)`. It never looks at `this` and never calls `f`. */
def flatMap[B](f: A => SafeIO[B]): SafeIO[B] = ???
/** (Exercise 7b.1) Transform the result with `f`, built from `flatMap` and `Pure`. Must not call `f` yet. */
def map[B](f: A => B): SafeIO[B] = ???
object SafeIO:
/** (Exercise 7b.1) A program that does nothing and produces `a`: `Pure(a)`. */
def pure[A](a: A): SafeIO[A] = ???
/** (Exercise 7b.1) A program that performs the single operation `op` and produces its answer: `Effect(op, pure)`. */
def effect(op: Op): SafeIO[OpResult] = ???
/**
* (Exercise 7b.1) Run the programs one after another, left to right, producing `()`. `sequence(Nil)` performs
* nothing. Either fold is fine now, also the left one that made Module 7's IO slow and deep.
*/
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = ???
/**
* (Exercise 7b.2) One rewrite towards a program whose top is a `Pure` or an `Effect`:
* `FlatMap(Pure(a), f)` becomes `f(a)`
* `FlatMap(Effect(op, k), f)` becomes `Effect(op, r => FlatMap(k(r), f))`
* `FlatMap(FlatMap(m, g), f)` becomes `FlatMap(m, x => FlatMap(g(x), f))` (re-association)
* A `Pure` or an `Effect` comes back unchanged. Constant work: `step` never calls itself, and it calls none of the
* functions it moves around (`f`, `g`, `k`); it only builds new nodes.
*/
def step[A](io: SafeIO[A]): SafeIO[A] = ???
/**
* (Exercise 7b.3) A pure interpreter for tests that does not grow the stack. Answers every `RandomSeed` with
* `Seed(seed)` and every `Draw` with `Done`, and returns the final value and the operations in the order they were
* performed. Write it as an inner function `loop(current, performed)` marked `@tailrec`, with one case per
* constructor, each ending in a call to `loop` (except `Pure`, which is the end): an `Effect` is answered, a
* `FlatMap` is rewritten with `step`. Put each op at the front of `performed` and reverse the list once at the end.
* `Op` also has Module 10's `LoadBest` and `SaveBest`; they are not this module's business: answer them by throwing
* an `IllegalArgumentException`, so that the match stays exhaustive without inventing a meaning for them.
*/
def trace[A](io: SafeIO[A], seed: Long): Traced[A] = ???
+31
View File
@@ -0,0 +1,31 @@
package snake
/**
* Optional (Exercise 4.7). A `State[S, A]` is a plain function from a state to a pair: `s => (value, nextS)`.
* It is `Random[A]` with the seed replaced by any state type `S`. Nothing in the game uses this file.
* `run` is the case class's field, so there is nothing to write for it.
*/
final case class State[S, A](run: S => (A, S)):
/** Transform the value this State produces; the state threading is unchanged. */
def map[B](f: A => B): State[S, B] = ???
/** Run this State, feed its value to `f` to pick the next State, and run that with the state this one left behind. */
def flatMap[B](f: A => State[S, B]): State[S, B] = ???
object State:
/** A State that gives `a` and leaves the state untouched: `s => (a, s)`. */
def pure[S, A](a: A): State[S, A] = ???
/**
* A State whose value is the current state, which it leaves untouched: `s => (s, s)`. When nothing else fixes the
* state type, give it at the call site: `State.get[Int]`.
*/
def get[S]: State[S, S] = ???
/** A State that replaces the state with `s` and gives `()`: whatever the old state, `((), s)`. */
def put[S](s: S): State[S, Unit] = ???
/** A State that replaces the state `s` with `f(s)` and gives `()`. */
def modify[S](f: S => S): State[S, Unit] = ???
+24 -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 as a pure automaton (a Mealy machine).
@@ -34,6 +38,16 @@ final case class Stream[A](step: Event => (List[A], Stream[A])):
*/
def startWith(v: A): Stream[A] = ???
/**
* (m10) Limit how far one Time event can move the clock that this stream sees, so a long silence (a background tab)
* does not turn into hundreds of beats at once.
* This stream gets a clock of its own, starting at 0. On `Time(t)` that clock advances by `t - last`, but by at most
* `maxGap` and never by less than 0 (time does not run backwards), where `last` is the `t` of the previous Time event
* (0 at the start). This stream is then stepped with `Event.Time(clock)`. Key events are passed on unchanged.
* Hint: a private helper that carries `last` and `clock`, like the one for `interval`.
*/
def clampTime(maxGap: Long): Stream[A] = ???
/** Feed all events through the stream and collect every output, in order. A pure fold. */
def run(events: List[Event]): List[A] = ???
@@ -45,6 +59,15 @@ object Stream:
/** Emits the `key` of every KeyUp event; ignores everything else. */
lazy val keyUps: Stream[String] = ???
/**
* (m13) Emits `(x, y)` for every Click event; ignores everything else.
* Given: nothing to write here. It is the third source, built exactly like your `keyDowns`.
*/
lazy val clicks: Stream[(Int, Int)] = Stream {
case Event.Click(x, y) => (List((x, y)), clicks)
case _ => (Nil, clicks)
}
/**
* On `Time(t)`, emits every multiple `k*ms` with `last < k*ms <= t`, ascending, where `last` starts at 0
* and becomes `t` afterwards. A big time jump can emit several values. Ignores key events.
+36
View File
@@ -0,0 +1,36 @@
package snake
package life
/** m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse. */
object LifeApp:
/** Milliseconds between two generations while the world runs. */
val GEN_MS: Long = 150
/**
* A key press to a `LifeAction`, if any: " " -> TogglePause, "n"/"N" -> Step, "c"/"C" -> Clear,
* "r"/"R" -> Randomize. Any other key -> `None`.
*/
def lifeKey(key: String): Option[LifeAction] = ???
/**
* A click at canvas pixel `(x, y)` to `Toggle` of the cell under it (`x / LCELL` and `y / LCELL`, rounded down),
* or `None` when the pixel is not on the board (left of it, above it, right of it, or in the status strip below it).
* Careful: Scala's integer division rounds towards zero, so `-1 / 16` is 0. Check the pixel before you divide.
*/
def lifeClick(x: Int, y: Int): Option[LifeAction] = ???
/**
* Everything that can happen to the world: `Frame` every GEN_MS (from `Stream.interval`), merged with `lifeKey` of
* every key press, merged with `lifeClick` of every click (`Stream.clicks`), in that order.
*/
lazy val lifeActions: Stream[LifeAction] = ???
/** Fold the actions into a stream of states: start from `initialLife(seed)`, `lifeUpdate` for each action, emit the initial state first. */
def lifeStates(seed: Long, actions: Stream[LifeAction]): Stream[LifeState] = ???
/**
* The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings
* of `lifeView`, and wrap that stream in `.clampTime(App.MAX_GAP_MS)`.
*/
lazy val lifeMain: IO[Stream[IO[Unit]]] = ???
+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 (`Nil`), paused (`running = false`), the given seed, generation 0. */
def initialLife(seed: Long): LifeState = ???
+33
View File
@@ -0,0 +1,33 @@
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:
/**
* One generation of Conway's Life on the LW x LH board (B3/S23), from a canonical list of live cells to a canonical
* list (sorted row-major: by `y`, then `x`; no duplicates).
* - A live cell with 2 or 3 live neighbours stays alive; a dead cell with exactly 3 live neighbours is born;
* every other cell is dead in the next generation.
* - The neighbours of a cell are the up to 8 cells around it that are on the board: the edges are dead,
* nothing wraps around.
* - Every cell is judged by the *old* board only.
*/
def lifeStep(cells: List[Point]): List[Point] = ???
/**
* The one function that says how the world changes: `(state, action) => state`.
* - `Frame`: when running, the next generation (`lifeStep`) and `generation + 1`; when paused, unchanged.
* - `Step`: when paused, the next generation and `generation + 1`; when running, unchanged.
* - `TogglePause`: `running` flips.
* - `Toggle(cell)`: a dead cell becomes alive, a live one dead, whether the world runs or not; `cells` stays canonical
* (insert the new cell at its row-major place). A cell off the board changes nothing. `generation` is unchanged.
* - `Clear`: no live cells, generation 0; `running` and `seed` are kept.
* - `Randomize`: draw `Random.intBelow(4)` (Module 4) once for every cell of the board, in row-major order
* (y = 0 until LH, and within a row x = 0 until LW), threading the seed from `state.seed`; a cell is alive when its
* draw is 0. The state gets those cells, the seed after the last draw, and generation 0; `running` is kept.
* 1200 draws: thread the seed with a fold, not with deep recursion.
*/
def lifeUpdate(state: LifeState, action: LifeAction): LifeState = ???
+15
View File
@@ -0,0 +1,15 @@
package snake
package life
import LifeModel.*
/** m13 life view: live cells as squares, and a status strip under the board. */
object LifeView:
/**
* Describe the world as shapes, drawn in order (all text "#ffffff", size 14):
* the board `Rect(0, 0, 640, 480, "#111111")`; one `Rect(x*16+1, y*16+1, 14, 14, "#7cb342")` per live cell, in the
* order of `cells`; the status strip `Rect(0, 480, 640, 24, "#222222")`; `Text(6, 497, "Generation <n>", align "left")`;
* `Text(634, 497, "Running: Space pauses"` when running, else `"Paused: Space runs, N steps", align "right")`.
*/
def lifeView(state: LifeState): Scene = ???
+4 -1
View File
@@ -23,5 +23,8 @@ object PongApp:
/** Fold the actions into a stream of states: start from `initialPong(seed)`, `pongUpdate` for each action, emit the initial state first. */
def pongStates(seed: Long, actions: Stream[PongAction]): Stream[PongState] = ???
/** The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings of `pongView`. */
/**
* The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings
* of `pongView`, and wrap that stream in `.clampTime(App.MAX_GAP_MS)`.
*/
lazy val pongMain: IO[Stream[IO[Unit]]] = ???
+1 -1
View File
@@ -13,7 +13,7 @@ object PongRules:
* The one function that says how a match changes: `(state, action) => state`.
*
* - `Press(key)` / `Release(key)`: set `held.up` or `held.down` to true / false (even when the match is over).
* - `Restart`: `initialPong(state.seed)`.
* - `Restart`: `initialPong(state.seed)`, except that `held` is kept (the player may still be holding a key).
* - `Frame`: unchanged unless status is Playing. Otherwise, in this order:
* 1. `frame + 1`.
* 2. The player moves one cell down for `held.down` and one up for `held.up` (both cancel out),
+10 -1
View File
@@ -1,10 +1,19 @@
#!/usr/bin/env bash
# Usage: ./test.sh [MNN] (no arg = all tests)
# A module tag selects exactly that module's test files: test/MNN<Upper>*.test.scala, so M07 runs M07IO but not
# M07bSafeIO, and M07b runs only M07bSafeIO. Each file gives one munit glob, `*<file name>*`, matching its suite.
set -euo pipefail
cd "$(dirname "$0")"
SCALA_CLI="${SCALA_CLI:-$(command -v scala-cli || echo "$HOME/.local/bin/scala-cli")}"
if [ $# -ge 1 ]; then
exec "$SCALA_CLI" test src test project.scala -- "*$1*"
globs=()
for f in test/"$1"[[:upper:]]*.test.scala; do
[ -e "$f" ] || continue
name="$(basename "$f" .test.scala)"
globs+=("*$name*")
done
[ ${#globs[@]} -gt 0 ] || globs=("*$1*") # no test file for this tag: keep the old behaviour
exec "$SCALA_CLI" test src test project.scala -- "${globs[@]}"
else
exec "$SCALA_CLI" test src test project.scala
fi
+12 -1
View File
@@ -40,9 +40,20 @@ object Fixtures:
seed = v("seed").num.toLong,
score = v("score").num.toInt,
status = status(v("status")),
frame = v("frame").num.toInt
frame = v("frame").num.toInt,
queued = opt(v("queued"))(direction)
)
def session(v: ujson.Value): Session =
Session(gameState(v("game")), v("best").num.toInt, v("improved").bool)
def event(v: ujson.Value): Event = v("tag").str match
case "Time" => Event.Time(v("t").num.toLong)
case "KeyDown" => Event.KeyDown(v("key").str)
case "KeyUp" => Event.KeyUp(v("key").str)
case "Click" => Event.Click(v("x").num.toInt, v("y").num.toInt)
case other => throw new IllegalArgumentException(s"unknown event tag: $other")
def shape(v: ujson.Value): Shape = v("tag").str match
case "Rect" => Shape.Rect(v("x").num.toInt, v("y").num.toInt, v("w").num.toInt, v("h").num.toInt, v("color").str)
case "Text" =>
+1 -1
View File
@@ -29,7 +29,7 @@ class M04SpawnSuite extends munit.FunSuite:
// 3x2 board, one-cell snake, about to eat the apple at (1,0)
private val eating = GameState(3, 2, List(Point(0, 0)), Direction.Right, Direction.Right,
Some(Point(1, 0)), 0L, 0, Status.Playing, 0)
Some(Point(1, 0)), 0L, 0, Status.Playing, 0, None)
test("tick: eating the apple spawns a new one in the same step") {
// snake becomes (1,0),(0,0); free cells are (2,0),(0,1),(1,1),(2,1); seed 0 draws 1144304738 % 4 = 2 -> (1,1)
+77
View File
@@ -0,0 +1,77 @@
package snake
class M04StateSuite extends munit.FunSuite:
// Small initial states for the "behaves like" checks: two States behave alike when they give the same pair from each.
private val starts = List(0, 1, 7, 42)
private def sameOnAll[A](st1: State[Int, A], st2: State[Int, A]): Unit =
starts.foreach(s => assertEquals(st1.run(s), st2.run(s), s"from state $s"))
// A counter step: gives the current number times 10 and adds 1 to the state. A def, so that a stub that throws
// fails each test on its own instead of the whole suite at construction time.
private def step: State[Int, Int] = State.get[Int].flatMap(n => State.put(n + 1).map(_ => n * 10))
private def f(a: Int): State[Int, Int] = State.modify[Int](_ * 2).map(_ => a + 1)
private def g(b: Int): State[Int, (Int, Int)] = State.get[Int].flatMap(s => State.pure((b, s)))
test("State pure gives its value and leaves the state alone") {
assertEquals(State.pure[Int, String]("x").run(99), ("x", 99))
}
test("State map applies f to the value and keeps the state from the underlying step") {
assertEquals(step.map(_ + 1).run(4), (41, 5))
}
test("State flatMap threads the state through both steps") {
assertEquals(step.flatMap(a => step.map(b => (a, b))).run(4), ((40, 50), 6))
}
test("State get gives the state as the value and leaves it alone") {
assertEquals(State.get[String].run("board"), ("board", "board"))
}
test("State put replaces the state and gives unit") {
assertEquals(State.put(5).run(99), ((), 5))
}
test("State modify applies f to the state and gives unit") {
assertEquals(State.modify[Int](_ * 2).run(21), ((), 42))
}
test("State law: left identity, flatMap(pure(a), f) behaves like f(a)") {
sameOnAll(State.pure[Int, Int](3).flatMap(f), f(3))
}
test("State law: right identity, flatMap(m, pure) behaves like m") {
sameOnAll(step.flatMap(State.pure), step)
}
test("State law: associativity, the grouping of flatMap does not matter") {
sameOnAll(step.flatMap(f).flatMap(g), step.flatMap(a => f(a).flatMap(g)))
}
test("State a counter hands out fresh labels in order") {
val fresh = State.get[Int].flatMap(n => State.put(n + 1).map(_ => s"L$n"))
val three = fresh.flatMap(a => fresh.flatMap(b => fresh.map(c => List(a, b, c))))
assertEquals(three.run(0), (List("L0", "L1", "L2"), 3))
assertEquals(three.run(7), (List("L7", "L8", "L9"), 10))
}
test("State Random is State with the seed as S: the dice give the same values and seeds") {
// One draw written only with State's get and put: read the seed, compute, store the next seed.
val draw = State.get[Long].flatMap { seed =>
val (v, next) = Random.nextUint32.run(seed)
State.put(next).map(_ => v)
}
val d6State = draw.map(v => 1 + (v % 6))
val twoDiceState = d6State.flatMap(a => d6State.map(b => a + b))
val d6Random = Random.nextUint32.map(v => 1 + (v % 6))
val twoDiceRandom = d6Random.flatMap(a => d6Random.map(b => a + b))
// A Random step turned into a State by hand: the same function, in the other wrapper.
val drawAsState = State[Long, Long](seed => Random.nextUint32.run(seed))
List(0L, 1L, 42L, 4294967295L).foreach { seed =>
assertEquals(draw.run(seed), Random.nextUint32.run(seed), s"draw from $seed")
assertEquals(draw.run(seed), drawAsState.run(seed), s"draw as a State from $seed")
assertEquals(d6State.run(seed), d6Random.run(seed), s"d6 from $seed")
assertEquals(twoDiceState.run(seed), twoDiceRandom.run(seed), s"twoDice from $seed")
}
}
+262
View File
@@ -0,0 +1,262 @@
package snake
import SafeIO.*
class M07bSafeIOSuite extends munit.FunSuite:
private def rectAt(x: Int): Shape = Shape.Rect(x, 0, 1, 1, "#000000")
private val r1 = rectAt(0)
private val t1 = Shape.Text(1, 2, "hi", "#ffffff", 16, "left")
private def drawOp(shape: Shape): Op = Op.Draw(shape)
private val seedOp: Op = Op.RandomSeed
private def boom[A, B](a: A): B = throw new RuntimeException("boom")
private val N = 100000
// The value of a RandomSeed answer (the tests' interpreters always answer it with a Seed).
private def seedOf(r: OpResult): Long = r match
case OpResult.Seed(value) => value
case _ => -1L // Done, or Module 10's Best: never the answer to a RandomSeed
// Which constructor a program is. Functions cannot be compared, so the tests check a node's shape by its tag and
// then observe the program through `trace`. (A tag, not the program itself: printing a 100 000-deep chain would
// overflow the stack.)
private def tag(p: SafeIO[Any]): String = p match
case Pure(_) => "Pure"
case Effect(_, _) => "Effect"
case FlatMap(_, _) => "FlatMap"
// --- building -------------------------------------------------------------------------------------------------
test("pure builds a Pure") { assertEquals(pure(3), Pure(3)) }
test("effect(op) is an Effect whose continuation is pure") {
effect(seedOp) match
case Effect(op, k) =>
assertEquals(op, seedOp)
assertEquals(k(OpResult.Seed(5)), Pure(OpResult.Seed(5)))
case other => fail(s"expected an Effect, got ${tag(other)}")
}
test("flatMap only builds a FlatMap node") {
val m = pure(1)
val p = m.flatMap(n => pure(n + 1))
p match
case FlatMap(io, _) => assertEquals[SafeIO[Any], SafeIO[Any]](io, m)
case other => fail(s"expected a FlatMap, got ${tag(other)}")
assertEquals(trace(p, 0), Traced(2, Nil))
}
test("building flatMap(pure(1), throwingF) does not throw") {
assertEquals(tag(pure(1).flatMap(boom[Int, SafeIO[Int]])), "FlatMap")
}
test("building map(io, throwingF) does not throw") {
assertEquals(tag(effect(seedOp).map(boom[OpResult, Int])), "FlatMap")
}
test("tracing map(io, throwingF) runs f and throws") {
interceptMessage[RuntimeException]("boom")(trace(effect(seedOp).map(boom[OpResult, Int]), 0))
}
// --- step: the three rewrite rules ----------------------------------------------------------------------------
// An Effect that reads the seed and produces it as a Long.
private val readSeed: SafeIO[Long] = Effect(seedOp, r => Pure(seedOf(r)))
test("step leaves a Pure unchanged") {
val p = pure(1)
assertEquals(step(p), p)
}
test("step leaves an Effect unchanged") {
val p = effect(seedOp)
assertEquals(step(p), p)
}
test("step of FlatMap(Pure(a), f) is f(a)") {
assertEquals(step(pure(3).flatMap(n => pure(n * 2))), Pure(6))
}
test("step of FlatMap(Effect(op, k), f) is an Effect with the same op that continues with FlatMap(k(r), f)") {
step(readSeed.flatMap(n => pure(n * 2))) match
case Effect(op, k) =>
assertEquals(op, seedOp)
val next = k(OpResult.Seed(5))
next match
case FlatMap(io, _) => assertEquals[SafeIO[Any], SafeIO[Any]](io, Pure(5L))
case other => fail(s"expected a FlatMap, got ${tag(other)}")
assertEquals(trace(next, 0), Traced(10L, Nil))
case other => fail(s"expected an Effect, got ${tag(other)}")
}
test("step re-associates FlatMap(FlatMap(m, g), f) into FlatMap(m, x => FlatMap(g(x), f))") {
val m = readSeed
val s = step(m.flatMap(n => pure(n + 1)).flatMap(n => pure(n * 2)))
s match
case FlatMap(io, _) => assertEquals[SafeIO[Any], SafeIO[Any]](io, m)
case other => fail(s"expected a FlatMap, got ${tag(other)}")
assertEquals(trace(s, 4), Traced(10L, List(seedOp)))
}
test("step does not call the functions it re-associates") {
assertEquals(tag(step(effect(seedOp).flatMap(boom[OpResult, SafeIO[Int]]).flatMap(boom[Int, SafeIO[Int]]))), "FlatMap")
}
test("step of a left-nested chain is one rewrite, not a whole walk") {
// After one step, the top is still a FlatMap: step did constant work, the rest is left for later steps.
val chain = (1 to N).foldLeft(pure(0))((acc, _) => acc.flatMap(n => pure(n + 1)))
assertEquals(tag(step(chain)), "FlatMap")
}
// --- the interpreter ------------------------------------------------------------------------------------------
test("trace of pure(3) has result 3 and no ops") {
assertEquals(trace(pure(3), 0), Traced(3, Nil))
}
test("trace answers RandomSeed with the seed and Draw with Done") {
assertEquals(
trace(effect(seedOp).flatMap(s => effect(drawOp(r1)).map(_ => s)), 9),
Traced(OpResult.Seed(9), List(seedOp, drawOp(r1)))
)
assertEquals(trace(effect(drawOp(r1)), 9), Traced(OpResult.Done, List(drawOp(r1))))
}
test("trace refuses operations of later modules") {
intercept[IllegalArgumentException](trace(effect(Op.LoadBest), 0))
}
private def drawing(shape: Shape): SafeIO[Unit] = effect(drawOp(shape)).map(_ => ())
test("sequence of nothing has no ops") {
assertEquals(trace(sequence(Nil), 0), Traced((), Nil))
}
test("sequence runs effects left to right") {
assertEquals(
trace(sequence(List(effect(seedOp).map(_ => ()), drawing(t1), drawing(r1))), 0).ops,
List(seedOp, drawOp(t1), drawOp(r1))
)
}
// --- stack safety ---------------------------------------------------------------------------------------------
private def leftDraws(n: Int): SafeIO[Any] =
(0 until n).foldLeft[SafeIO[Any]](pure(()))((acc, i) => acc.flatMap(_ => effect(drawOp(rectAt(i)))))
private def rightDraws(n: Int): SafeIO[Any] =
(0 until n).foldRight[SafeIO[Any]](pure(()))((i, rest) => effect(drawOp(rectAt(i))).flatMap(_ => rest))
test("100 000 left-nested flatMaps of draws run, in order") {
val Traced(result, ops) = trace(leftDraws(N), 0)
assertEquals(result, OpResult.Done)
assertEquals(ops.length, N)
assertEquals((ops.head, ops.last), (drawOp(rectAt(0)), drawOp(rectAt(N - 1))))
}
test("100 000 right-nested flatMaps of draws run, in order") {
val ops = trace(rightDraws(N), 0).ops
assertEquals(ops.length, N)
assertEquals((ops.head, ops.last), (drawOp(rectAt(0)), drawOp(rectAt(N - 1))))
}
test("100 000 left-nested maps run without any op") {
assertEquals(trace((1 to N).foldLeft(pure(0))((acc, _) => acc.map(_ + 1)), 0), Traced(N, Nil))
}
test("sequence of 100 000 draws runs") {
assertEquals(trace(sequence(List.tabulate(N)(i => drawing(rectAt(i)))), 0).ops.length, N)
}
test("a while loop over step is a complete interpreter for 100 000 left-nested flatMaps") {
// What an impure runtime would do (a loop is allowed there): rewrite until the top is Pure or Effect, perform.
var performed = List.empty[Op]
var current = leftDraws(N)
while tag(current) != "Pure" do
current = step(current)
current match
case Effect(op, k) =>
performed = op :: performed
current = k(OpResult.Done)
case _ => ()
assertEquals(performed.length, N)
}
// --- the monad laws, observed through trace -------------------------------------------------------------------
private val programs: List[(String, () => SafeIO[Long])] = List(
("a pure value", () => pure(4L)),
("one effect", () => readSeed),
("a draw followed by reading the seed", () => effect(drawOp(r1)).flatMap(_ => readSeed))
)
private def f(n: Long): SafeIO[Long] = effect(drawOp(rectAt(n.toInt))).flatMap(_ => pure(n * 2))
private def g(n: Long): SafeIO[Long] = effect(seedOp).map(s => seedOf(s) + n)
programs.foreach { (name, program) =>
test(s"left identity holds for $name") {
val a = trace(program(), 5).result
assertEquals(trace(pure(a).flatMap(f), 5), trace(f(a), 5))
}
test(s"right identity holds for $name") {
assertEquals(trace(program().flatMap(pure), 5), trace(program(), 5))
}
test(s"associativity holds for $name") {
assertEquals(trace(program().flatMap(f).flatMap(g), 5), trace(program().flatMap(x => f(x).flatMap(g)), 5))
}
}
// --- the same programs under Module 7's IO --------------------------------------------------------------------
// Each program is written once against this interface and built with both IOs.
private trait Builder[F[_]]:
def pure[A](a: A): F[A]
def effect(op: Op): F[OpResult]
def map[A, B](io: F[A])(f: A => B): F[B]
def flatMap[A, B](io: F[A])(f: A => F[B]): F[B]
def sequence(ios: List[F[Unit]]): F[Unit]
def trace[A](io: F[A], seed: Long): Traced[A]
def perform(op: Op): F[Unit] = map(effect(op))(_ => ())
private object Safe extends Builder[SafeIO]:
def pure[A](a: A): SafeIO[A] = SafeIO.pure(a)
def effect(op: Op): SafeIO[OpResult] = SafeIO.effect(op)
def map[A, B](io: SafeIO[A])(f: A => B): SafeIO[B] = io.map(f)
def flatMap[A, B](io: SafeIO[A])(f: A => SafeIO[B]): SafeIO[B] = io.flatMap(f)
def sequence(ios: List[SafeIO[Unit]]): SafeIO[Unit] = SafeIO.sequence(ios)
def trace[A](io: SafeIO[A], seed: Long): Traced[A] = SafeIO.trace(io, seed)
private object Simple extends Builder[IO]:
def pure[A](a: A): IO[A] = IO.pure(a)
def effect(op: Op): IO[OpResult] = IO.effect(op)
def map[A, B](io: IO[A])(f: A => B): IO[B] = io.map(f)
def flatMap[A, B](io: IO[A])(f: A => IO[B]): IO[B] = io.flatMap(f)
def sequence(ios: List[IO[Unit]]): IO[Unit] = IO.sequence(ios)
def trace[A](io: IO[A], seed: Long): Traced[A] = IO.trace(io, seed)
// A program for any Builder, traced with seed 7.
private trait Shared:
def traced[F[_]](M: Builder[F]): Traced[?]
private val shared: List[(String, Shared)] = List(
("seed, then a draw that depends on it", new Shared:
def traced[F[_]](M: Builder[F]): Traced[?] =
M.trace(M.flatMap(M.map(M.effect(seedOp))(seedOf))(s => M.map(M.effect(drawOp(rectAt((s % 20).toInt))))(_ => s)), 7)
),
("a sequence of three effects", new Shared:
def traced[F[_]](M: Builder[F]): Traced[?] =
M.trace(M.sequence(List(M.perform(drawOp(t1)), M.perform(seedOp), M.perform(drawOp(r1)))), 7)
),
("200 left-nested flatMaps", new Shared:
def traced[F[_]](M: Builder[F]): Traced[?] =
M.trace((0 until 200).foldLeft(M.pure(0))((acc, i) => M.flatMap(acc)(n => M.map(M.effect(drawOp(rectAt(i))))(_ => n + 1))), 7)
),
("nested on both sides", new Shared:
def traced[F[_]](M: Builder[F]): Traced[?] =
val left = M.flatMap(M.map(M.effect(seedOp))(seedOf))(s => M.flatMap(M.effect(drawOp(r1)))(_ => M.pure(s + 1)))
M.trace(M.flatMap(left)(n => M.map(M.sequence(List(M.perform(drawOp(t1)), M.perform(seedOp))))(_ => n * 3)), 7)
)
)
shared.foreach { (name, program) =>
test(s"trace equals Module 7's trace for $name") {
assertEquals(program.traced(Safe), program.traced(Simple))
}
}
+4
View File
@@ -27,6 +27,10 @@ class M08StreamSuite extends munit.FunSuite:
assertEquals(interval(10).run(List(time(35))), List(10L, 20L, 30L))
}
test("interval(1) emits every millisecond crossed") {
assertEquals(interval(1).run(List(time(1), time(3))), List(1L, 2L, 3L))
}
test("interval ignores key events") {
assertEquals(interval(10).run(List(down("a"), up("a"))), Nil)
}
+26 -2
View File
@@ -28,12 +28,18 @@ class M09AppSuite extends munit.FunSuite:
assertEquals(IO.trace(App.render(Spawn.newGame(42)), 0).ops, View.view(Spawn.newGame(42)).map(Op.Draw(_)))
}
// Module 10 (Exercise 10.7) makes `main` also load the stored best and draw one more shape after the game's view,
// so this test accepts both versions; m10 pins the final one exactly.
test("main asks for a random seed, then builds the stream from it") {
val traced = IO.trace(App.main, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
assert(
traced.ops == List(Op.RandomSeed) || traced.ops == List(Op.RandomSeed, Op.LoadBest),
s"main may only ask for the seed (and, from m10 on, the stored best); it asked for ${traced.ops}"
)
val (outs, _) = traced.result.step(time(0))
assertEquals(outs.length, 1)
assertEquals(drawn(outs.head), View.view(Spawn.newGame(42)))
val expected = View.view(Spawn.newGame(42))
assertEquals(drawn(outs.head).take(expected.length), expected)
}
// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
@@ -54,3 +60,21 @@ class M09AppSuite extends munit.FunSuite:
assertEquals(first.length, 1)
assert(later.nonEmpty)
}
// ---- play: the whole program in one equality (Exercise 9.5)
private def drawOps(state: GameState): List[Op] = View.view(state).map(Op.Draw(_))
test("play on the classic program equals the scenes of the expected states") {
val events = List(time(150), down("ArrowUp"), down("x"), time(300), time(310))
val expectedStates = Game.history(Spawn.newGame(42), List(Action.Tick, Action.Turn(Direction.Up), Action.Tick))
assertEquals(App.play(classicMain, 42, events), expectedStates.map(drawOps))
}
test("play with no events gives no drawings") {
assertEquals(App.play(classicMain, 42, Nil), Nil)
}
test("play uses the seed for the program's RandomSeed") {
assertEquals(App.play(classicMain, 7, List(time(0))), List(drawOps(Spawn.newGame(7))))
}
+24 -5
View File
@@ -2,12 +2,15 @@ package snake
import scala.collection.mutable.ListBuffer
/** A fake platform: records every call and lets the test fire events by hand. */
class FakePlatform(seed: Long = 42) extends Platform:
/** A fake platform: records every call and lets the test fire events by hand. `best` answers loadBest (m10). */
class FakePlatform(seed: Long = 42, best: Int = 0) extends Platform:
val calls: ListBuffer[List[Any]] = ListBuffer.empty
private var onTime: Long => Unit = _ => ()
private var onDown: String => Unit = _ => ()
private var onUp: String => Unit = _ => ()
private var onClickCb: (Int, Int) => Unit = (_, _) => ()
// Which callbacks the runtime registered, by name: "click", "down", "time", "up" (m13).
private var handlers: Set[String] = Set.empty
def fillRect(x: Int, y: Int, w: Int, h: Int, color: String): Unit = calls += List("fillRect", x, y, w, h, color)
def fillText(x: Int, y: Int, text: String, color: String, size: Int, align: String): Unit =
@@ -15,13 +18,29 @@ class FakePlatform(seed: Long = 42) extends Platform:
def randomSeed(): Long =
calls += List("randomSeed")
seed
def startClock(cb: Long => Unit): Unit = onTime = cb
def onKeyDown(cb: String => Unit): Unit = onDown = cb
def onKeyUp(cb: String => Unit): Unit = onUp = cb
def loadBest(): Int =
calls += List("loadBest")
best
def saveBest(score: Int): Unit = calls += List("saveBest", score)
def startClock(cb: Long => Unit): Unit =
onTime = cb
handlers += "time"
def onKeyDown(cb: String => Unit): Unit =
onDown = cb
handlers += "down"
def onKeyUp(cb: String => Unit): Unit =
onUp = cb
handlers += "up"
// m13: a runtime from Module 9 does not register this yet; nothing in m09-m12 fires a click.
def onClick(cb: (Int, Int) => Unit): Unit =
onClickCb = cb
handlers += "click"
def fireTime(t: Long): Unit = onTime(t)
def fireKey(key: String): Unit = onDown(key)
def fireKeyUp(key: String): Unit = onUp(key)
def fireClick(x: Int, y: Int): Unit = onClickCb(x, y)
def registered: List[String] = handlers.toList.sorted
class M09RuntimeSuite extends munit.FunSuite:
// The m09 program (classicMain) ticks every 150 ms; the shipped `main` uses speedActions after m10.
+179 -4
View File
@@ -17,12 +17,42 @@ class M10ExtensionsSuite extends munit.FunSuite:
}
}
cases("m10-session-update").foreach { c =>
test(s"sessionUpdate: ${c("name").str}") {
val a = c("args")
assertEquals(App.sessionUpdate(session(a(0)), action(a(1))), session(c("expected")))
}
}
cases("m10-session-view").foreach { c =>
test(s"sessionView: ${c("name").str}") {
assertEquals(App.sessionView(session(c("args")(0))), list(c("expected"))(shape))
}
}
cases("m10-input").foreach { c =>
test(s"keyToAction: ${c("name").str}") {
assertEquals(Input.keyToAction(c("args")(0).str), opt(c("expected"))(action))
}
}
// ---- the turn queue (Exercise 10.6)
test("steer is what update does with a Turn") {
cases("m10-update").filter(_("args")(1)("tag").str == "Turn").foreach { c =>
val a = c("args")
assertEquals(Game.steer(gameState(a(0)), direction(a(1)("dir"))), gameState(c("expected")), c("name").str)
}
}
test("Turn Up then Turn Left within one step: the snake goes Up on the next step and Left on the one after") {
val start = Spawn.newGame(42) // heading Right from (10, 10); the apple is far away
val states = List(Action.Turn(Direction.Up), Action.Turn(Direction.Left), Action.Tick, Action.Tick)
.scanLeft(start)(Game.update)
assertEquals(states.drop(2).map(_.snake.head), List(Point(10, 10), Point(10, 9), Point(9, 9)))
assertEquals(states.map(_.queued), List(None, None, Some(Direction.Left), None, None))
}
private def time(t: Long): Event = Event.Time(t)
private val frameTimes = List(25L, 50L, 75L, 100L, 125L, 150L, 175L, 200L)
@@ -39,14 +69,159 @@ class M10ExtensionsSuite extends munit.FunSuite:
)
}
// The drawings are compared by their first shapes, the game's view: Exercise 10.7 adds the best score after it.
private def assertStartsWithView(io: IO[Unit], state: GameState): Unit =
val expected = View.view(state)
assertEquals(drawn(io).take(expected.length), expected)
test("main (seed 42) draws a moved snake after eight 25 ms frames") {
val traced = IO.trace(App.main, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
assertEquals(traced.ops.head, Op.RandomSeed)
val (first, afterFirst) = traced.result.step(time(0))
assertEquals(drawn(first.head), View.view(Spawn.newGame(42)))
assertStartsWithView(first.head, Spawn.newGame(42))
val drawings = afterFirst.run(frameTimes.map(time))
assertEquals(drawings.length, 8)
val start = Spawn.newGame(42)
val moved = start.copy(snake = start.snake.map(p => p.copy(x = p.x + 1)), frame = 8)
assertEquals(drawn(drawings(7)), View.view(moved))
val moved = start.copy(snake = start.snake.map(p => p.copy(x = p.x + 1)), frame = 0)
assertStartsWithView(drawings(7), moved)
}
// ---- clampTime: a long silence from the clock (a background tab) must not turn into hundreds of frames
private def down(key: String): Event = Event.KeyDown(key)
test("clampTime passes small steps of time through unchanged") {
assertEquals(Stream.interval(10).clampTime(100).run(List(5L, 10L, 35L).map(time)), List(10L, 20L, 30L))
}
test("clampTime lets a long gap count as maxGap, and carries on from there") {
assertEquals(Stream.interval(10).clampTime(25).run(List(10L, 5000L, 5010L).map(time)), List(10L, 20L, 30L, 40L))
}
test("clampTime clamps the very first step too (the clock starts at 0)") {
assertEquals(Stream.interval(10).clampTime(25).run(List(time(1000))), List(10L, 20L))
}
test("clampTime never moves the clock backwards") {
assertEquals(
Stream.interval(10).clampTime(100).run(List(30L, 10L, 40L).map(time)),
List(10L, 20L, 30L, 40L, 50L, 60L)
)
}
test("clampTime passes key inputs through, and they do not disturb the clock") {
assertEquals(Stream.keyDowns.clampTime(5).run(List(down("a"), time(50), down("b"))), List("a", "b"))
assertEquals(Stream.interval(10).clampTime(25).run(List(time(10), down("a"), time(20))), List(10L, 20L))
}
test("clampTime tells the inner stream exactly the clamped time, and a repeated time adds nothing") {
assertEquals(Stream.interval(1).clampTime(100).run(List(5L, 5L, 6L).map(time)), List(1L, 2L, 3L, 4L, 5L, 6L))
}
test("running the same clamped stream twice gives the same outputs") {
val s = Stream.interval(10).clampTime(25)
val xs = List(10L, 5000L, 5010L).map(time)
assertEquals(s.run(xs), s.run(xs))
}
test("the game accepts at most 250 ms of time at once") { assertEquals(App.MAX_GAP_MS, 250L) }
test("main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds") {
val (_, afterFirst) = IO.trace(App.main, 42).result.step(time(0))
val (drawings, next) = afterFirst.step(time(10000))
assertEquals(drawings.length.toLong, App.MAX_GAP_MS / App.FRAME_MS)
assertEquals(next.step(time(10025))._1.length, 1)
}
// ---- a persisted high score (Exercise 10.7)
private val loadOp: Op = Op.LoadBest
private def drawOps(scene: Scene): List[Op] = scene.map(Op.Draw(_))
test("loadBest records a LoadBest op and is answered with the best given to trace") {
assertEquals(IO.trace(IO.loadBest, 0, 17), Traced(17, List(loadOp)))
}
test("trace answers LoadBest with 0 when no best is given") {
assertEquals(IO.trace(IO.loadBest, 9).result, 0)
}
test("saveBest records a SaveBest op with the score and produces ()") {
assertEquals(IO.trace(IO.saveBest(7), 0), Traced((), List(Op.SaveBest(7))))
}
test("a program can load the best, then save a higher one") {
assertEquals(IO.trace(IO.loadBest.flatMap(b => IO.saveBest(b + 1)), 0, 4).ops, List(loadOp, Op.SaveBest(5)))
}
test("sessions starts with the new game and the stored best, then yields one session per action") {
val out = App.sessions(42, 3, App.speedActions).run(List(time(25), Event.KeyDown(" ")))
val s0 = Session(Spawn.newGame(42), 3, improved = false)
val s1 = App.sessionUpdate(s0, Action.Frame)
assertEquals(out, List(s0, s1, App.sessionUpdate(s1, Action.TogglePause)))
}
test("renderSession draws the session view and saves nothing when the best did not improve") {
val s = Session(Spawn.newGame(42), 5, improved = false)
assertEquals(IO.trace(App.renderSession(s), 0).ops, drawOps(App.sessionView(s)))
}
test("renderSession draws the session view, then saves the best, when the best improved") {
val s = Session(Spawn.newGame(42).copy(score = 6), 6, improved = true)
assertEquals(IO.trace(App.renderSession(s), 0).ops, drawOps(App.sessionView(s)) :+ Op.SaveBest(6))
}
test("main asks for the seed, then the stored best, and draws the first session") {
val traced = IO.trace(App.main, 42, 9)
assertEquals(traced.ops, List(Op.RandomSeed, loadOp))
val (first, _) = traced.result.step(time(0))
assertEquals(drawn(first.head), App.sessionView(Session(Spawn.newGame(42), 9, improved = false)))
}
test("play passes the best to the program: the first scene shows it") {
assertEquals(
App.play(App.main, 42, List(time(0)), 9),
List(drawOps(App.sessionView(Session(Spawn.newGame(42), 9, improved = false))))
)
}
// The golden fixture: a seed, a scripted list of inputs, a stored best, and the scene of the last drawing.
cases("m10-golden").foreach { c =>
val a = c("args")
def played = App.play(App.main, a(0).num.toLong, list(a(1))(event), a(2).num.toInt)
val expected = list(c("expected"))(shape)
test(s"play(main): ${c("name").str}") {
assertEquals(played.last.collect { case Op.Draw(shape) => shape }, expected)
}
test(s"play(main) saves once per improvement: ${c("name").str}") {
val best = a(2).num.toInt
val saves = played.flatten.collect { case Op.SaveBest(score) => score }
val finalBest = expected.collectFirst { case Shape.Text(_, _, t, _, _, _) if t.startsWith("Best: ") => t.drop(6).toInt }.get
assertEquals(saves, ((best + 1) to finalBest).toList)
}
}
// The runtime with a fake platform (M09Runtime.test.scala) that records the two new calls.
test("runIO answers LoadBest with platform.loadBest() and continues with it") {
val p = FakePlatform(42, 11)
assertEquals(Runtime.runIO(IO.loadBest.map(_ + 1), p), 12)
assertEquals(p.calls.toList, List(List("loadBest")))
}
test("runIO maps SaveBest to platform.saveBest(score)") {
val p = FakePlatform()
Runtime.runIO(IO.saveBest(8).flatMap(_ => IO.pure("done")), p)
assertEquals(p.calls.toList, List(List("saveBest", 8)))
}
test("runApp asks for the seed and loads the best once, at start, and draws nothing before the first input") {
val p = FakePlatform(42, 3)
Runtime.runApp(App.main, p)
assertEquals(p.calls.toList, List(List("randomSeed"), List("loadBest")))
p.fireTime(0)
p.fireTime(25)
assertEquals(p.calls.count(_.head == "loadBest"), 1)
assert(p.calls.exists(c => c.head == "fillText" && c(3) == "Best: 3"))
}
+125
View File
@@ -0,0 +1,125 @@
package snake
import Fixtures.*
import Replay.*
import M09Helpers.drawn
class M10ReplaySuite extends munit.FunSuite:
private def viewer(v: ujson.Value): Viewer = Viewer(v("index").num.toInt, v("last").num.toInt, v("playing").bool)
private def viewerAction(v: ujson.Value): ViewerAction = ViewerAction.valueOf(v("tag").str)
cases("m10-viewer-update").foreach { c =>
test(s"viewerUpdate: ${c("name").str}") {
val a = c("args")
assertEquals(viewerUpdate(viewer(a(0)), viewerAction(a(1))), viewer(c("expected")))
}
}
cases("m10-viewer-key").foreach { c =>
test(s"viewerKey: ${c("name").str}") {
assertEquals(viewerKey(c("args")(0).str), opt(c("expected"))(viewerAction))
}
}
cases("m10-viewer-view").foreach { c =>
test(s"viewerView: ${c("name").str}") {
val a = c("args")
assertEquals(viewerView(list(a(0))(gameState), viewer(a(1))), list(c("expected"))(shape))
}
}
// The sample recording, as the page loads it. Not a fixture of cases: a recording `{seed, actions}`.
private lazy val sample: Recording = {
val v = load("replay-sample")
Recording(v("seed").num.toLong, list(v("actions"))(action))
}
private def time(t: Long): Event = Event.Time(t)
private def key(k: String): Event = Event.KeyDown(k)
test("the sample recording is worth watching: Tick and Turn only, under 400 actions, at least five apples, then game over") {
assert(sample.actions.forall {
case Action.Tick | Action.Turn(_) => true
case _ => false
})
assert(sample.actions.length >= 150 && sample.actions.length <= 400, s"${sample.actions.length} actions")
val fs = Game.history(Spawn.newGame(sample.seed), sample.actions)
assert(fs.last.score >= 5, s"score ${fs.last.score}")
assertEquals(fs.last.status, Status.GameOver)
assertEquals(fs.init.last.status, Status.Playing)
}
test("frames replays the recording with history, from the new game of its seed") {
val recording = Recording(42, List(Action.Tick, Action.Turn(Direction.Up), Action.Tick))
assertEquals(frames(recording), Game.history(Spawn.newGame(42), recording.actions))
assertEquals(frames(sample).length, sample.actions.length + 1)
}
test("viewerStart is the first frame, not playing") {
assertEquals(viewerStart(12), Viewer(0, 12, playing = false))
}
test("viewerUpdate keeps the index in [0, last] whatever the actions") {
import ViewerAction.*
val actions = List(Prev, Next, Last, Next, TogglePlay, Beat, Beat, First, Prev, Beat)
val viewers = actions.scanLeft(viewerStart(3))(viewerUpdate)
assert(viewers.forall(v => v.index >= 0 && v.index <= 3 && !(v.playing && v.index == 3)))
}
test("the autoplay clock beats every 100 ms") { assertEquals(REPLAY_MS, 100L) }
test("replayActions turns the clock into Beats and keys into viewer actions") {
assertEquals(
replayActions.run(List(time(100), key("ArrowRight"), key("x"), key(" "), time(300))),
List(ViewerAction.Beat, ViewerAction.Next, ViewerAction.TogglePlay, ViewerAction.Beat, ViewerAction.Beat)
)
}
test("autoplay: Space, then the clock steps one frame per 100 ms and stops at the end") {
val out = viewerStates(3, replayActions).run(List(time(0), key(" "), time(100), time(200), time(300), time(400), time(500)))
assertEquals(out.map(_.index), List(0, 0, 1, 2, 3, 3, 3))
assertEquals(out.map(_.playing), List(false, true, true, true, false, false, false))
}
test("without Space nothing plays: the clock alone never moves the viewer") {
assert(viewerStates(3, replayActions).run(List(time(0), time(1000), time(2000))).forall(_.index == 0))
}
test("replayMain asks for nothing and draws the first frame of the recording first") {
val traced = IO.trace(replayMain(sample), 0)
assertEquals(traced.ops, Nil)
val (drawings, _) = traced.result.step(time(0))
assertEquals(drawings.length, 1)
assertEquals(drawn(drawings.head), viewerView(frames(sample), viewerStart(sample.actions.length)))
val newGameView = View.view(Spawn.newGame(sample.seed))
assertEquals(drawn(drawings.head).take(newGameView.length), newGameView)
}
test("replayMain: End shows the last frame of the game, the game over") {
val out = IO.trace(replayMain(sample), 0).result.run(List(time(0), key("End")))
val fs = frames(sample)
assertEquals(drawn(out.last), viewerView(fs, Viewer(fs.length - 1, fs.length - 1, playing = false)))
assert(drawn(out.last).exists {
case Shape.Text(_, _, "Game Over", _, _, _) => true
case _ => false
})
}
test("stepping back shows exactly the earlier scene") {
val out = IO
.trace(replayMain(sample), 0)
.result
.run(List(time(0), key("ArrowRight"), key("ArrowRight"), key("ArrowRight"), key("ArrowLeft"), key("d"), key("a"), key("a")))
val scenes = out.map(drawn)
assertEquals(scenes(4), scenes(2))
assertEquals(scenes(6), scenes(4))
assertEquals(scenes(7), scenes(1))
assertNotEquals(scenes(3), scenes(2))
}
test("replayMain: after ten seconds in a background tab autoplay moves 2 frames (250 ms), not 100") {
val (_, afterPlay) = IO.trace(replayMain(sample), 0).result.step(key(" "))
val (_, afterFirst) = afterPlay.step(time(0))
assertEquals(afterFirst.step(time(10000))._1.length, 2)
}
+5
View File
@@ -120,6 +120,11 @@ class M11PongSuite extends munit.FunSuite:
assertEquals(out(3).player, 20)
}
test("pongMain: after ten seconds in a background tab the match advances by 250 ms, not by ten seconds") {
val (_, afterFirst) = IO.trace(pongMain, 42).result.step(time(0))
assertEquals(afterFirst.step(time(10000))._1.length, 8)
}
test("pongMain asks for a seed, draws the new match first, and then the updated one") {
val traced = IO.trace(pongMain, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
+392
View File
@@ -0,0 +1,392 @@
package snake
import Fixtures.*
import Check.*
class M12CheckSuite extends munit.FunSuite:
// ---- generators
test("elementOf picks the element at the index drawn by intBelow") {
val xs = List("a", "b", "c", "d", "e")
val (i, seed) = Random.intBelow(5).run(0)
assertEquals(elementOf(xs).run(0), (xs(i), seed))
}
test("elementOf of a one-element array always gives that element, and still uses up a draw") {
assertEquals(elementOf(List("only")).run(0), ("only", 1831565813L))
}
test("listOf(gen, 0) is the empty list and leaves the seed alone") {
assertEquals(listOf(Random.nextUint32, 0).run(99), (List.empty[Long], 99L))
}
test("listOf threads the seed from each draw to the next") {
assertEquals(listOf(Random.nextUint32, 3).run(0), (List(1144304738L, 1416247L, 958946056L), 1199730143L))
}
test("listOf of a constant generator repeats the value and leaves the seed alone") {
assertEquals(listOf(Random.pure("x"), 4).run(7), (List("x", "x", "x", "x"), 7L))
}
test("genActions(n) gives n actions, the same ones for the same seed") {
val (actions, _) = genActions(30).run(5)
assertEquals(actions.length, 30)
assert(actions.forall {
case Action.Tick | Action.Turn(_) => true
case _ => false
})
assertEquals(genActions(30).run(5), genActions(30).run(5))
}
// ---- forAll
test("forAll reports success with the number of runs") {
assertEquals(forAll(Random.intBelow(10), 200, 1)(x => x >= 0 && x < 10), CheckResult.Passed(200))
}
test("forAll with zero runs succeeds without drawing anything") {
assertEquals(forAll(Random.nextUint32, 0, 1)(_ => false), CheckResult.Passed(0))
}
test("forAll reports the first failing draw, its value and the seed it started from") {
val gen = Random.intBelow(10)
val prop = (x: Int) => x != 7
val (draws, _) = listOf(gen, 200).run(3)
val first = draws.indexWhere(x => !prop(x))
assert(first > 0, "the test needs a failure that is not the very first draw")
forAll(gen, 200, 3)(prop) match
case CheckResult.Failed(run, seed, value) =>
assertEquals((run, value), (first, 7))
assertEquals(gen.run(seed)._1, 7)
case other => fail(s"expected a failure, got $other")
}
test("forAll stops at the first failure: the property is not asked about later values") {
val gen = Random.intBelow(10)
val (draws, _) = listOf(gen, 200).run(3)
val first = draws.indexWhere(_ == 7)
var calls = 0 // test code may count; the code under test may not
val result = forAll(gen, 200, 3) { x => calls = calls + 1; x != 7 }
result match
case CheckResult.Failed(run, _, _) => assertEquals(run, first)
case other => fail(s"expected a failure, got $other")
assertEquals(calls, first + 1)
}
test("forAll is reproducible: same generator, property, runs and seed give the same report") {
assertEquals(forAll(Random.intBelow(1000), 500, 11)(_ % 97 != 0), forAll(Random.intBelow(1000), 500, 11)(_ % 97 != 0))
}
// ---- shrinking
test("removals lists every way to drop one element, in order") {
assertEquals(removals(List(1, 2, 3)), List(List(2, 3), List(1, 3), List(1, 2)))
}
test("removals of an empty list is empty, and of a single element is one empty list") {
assertEquals(removals(List.empty[Int]), List.empty[List[Int]])
assertEquals(removals(List("a")), List(List.empty[String]))
}
test("minimize keeps exactly the elements the failure needs") {
assertEquals(minimize(List(1, 2, 3, 4, 5, 6, 7, 8, 9, 10))(xs => xs.contains(3) && xs.contains(7)), List(3, 7))
}
test("minimize keeps the order and the duplicates the failure needs") {
assertEquals(
minimize(List("b", "a", "c", "a", "d", "a"))(xs => xs.count(_ == "a") >= 2 && xs.contains("c")),
List("c", "a", "a")
)
}
test("minimize of a list that fails whatever you remove is the empty list") {
assertEquals(minimize(List(1, 2, 3))(_ => true), List.empty[Int])
}
test("minimize returns a list that still fails and has no failing removal (1-minimal)") {
val fails = (xs: List[Int]) => xs.sum >= 10
val small = minimize(List(5, 1, 4, 2, 6, 3))(fails)
assert(fails(small))
assert(removals(small).forall(ys => !fails(ys)))
}
// ---- invariants
cases("m12-violations").foreach { c =>
test(s"violations: ${c("name").str}") {
assertEquals(violations(gameState(c("args")(0))), list(c("expected"))(_.str))
}
}
// ---- moveGaps
// A whole state whose snake has its head at (x, y); moveGaps only looks at the head.
private def at(x: Int, y: Int): GameState =
GameState(20, 20, List(Point(x, y), Point(x - 1, y)), Direction.Right, Direction.Right, None, 0L, 0, Status.Playing,
0, None)
test("moveGaps measures the distance between consecutive moves of the head") {
assertEquals(moveGaps(List(at(1, 0), at(1, 0), at(2, 0), at(2, 0), at(2, 0), at(3, 0), at(3, 1))), List(3, 1))
}
test("moveGaps of fewer than two moves is empty") {
assertEquals(moveGaps(Nil), List.empty[Int])
assertEquals(moveGaps(List(at(1, 0))), List.empty[Int])
assertEquals(moveGaps(List(at(1, 0), at(1, 0), at(2, 0), at(2, 0))), List.empty[Int])
}
test("moveGaps counts a move at index 1") {
assertEquals(moveGaps(List(at(1, 0), at(2, 0), at(2, 0), at(3, 0))), List(2))
}
test("moveGaps treats an empty snake as a head of its own") {
def withSnake(snake: List[Point]): GameState = at(0, 0).copy(snake = snake)
val states = List(Nil, Nil, List(Point(1, 0)), List(Point(1, 0)), List(Point(2, 0))).map(withSnake)
assertEquals(moveGaps(states), List(2))
}
// ---- the payoff: properties of the game, and deliberately broken games ("mutants") that they must catch
private type Update = (GameState, Action) => GameState
// Every state of the game played with `upd` from `start`. (For the real `update` this is `history`.)
private def statesWith(upd: Update, start: GameState, actions: List[Action]): List[GameState] =
actions.scanLeft(start)(upd)
private def sane(upd: Update)(actions: List[Action]): Boolean =
statesWith(upd, Spawn.newGame(1), actions).forall(s => violations(s).isEmpty)
test("statesWith(update, ...) in these tests is the history of Module 5") {
val (actions, _) = genActions(25).run(9)
assertEquals(statesWith(Game.update, Spawn.newGame(1), actions), Game.history(Spawn.newGame(1), actions))
}
test("the real game keeps every invariant on 300 random games of 40 actions") {
assertEquals(forAll(genActions(40), 300, 2024)(sane(Game.update)), CheckResult.Passed(300))
}
// Mutant 1: `turn` checks against nextDir instead of dir (version A of Puzzle 2 in Module 3).
private def turnAgainstNextDir(state: GameState, action: Action): GameState = action match
case Action.Turn(dir) =>
if state.status == Status.Playing && dir != Model.opposite(state.nextDir) then state.copy(nextDir = dir)
else state
case other => Game.update(state, other)
test("the quick double turn bug is found and shrunk to its two key presses") {
val prop = sane(turnAgainstNextDir)
forAll(genActions(40), 300, 2024)(prop) match
case CheckResult.Failed(_, _, value) =>
minimize(value)(actions => !prop(actions)) match
case List(Action.Turn(first), Action.Turn(second)) =>
assert(List(Direction.Up, Direction.Down).contains(first))
assertEquals(second, Direction.Left)
case other => fail(s"expected two turns, got $other")
case other => fail(s"expected a failure, got $other")
}
// Mutant 2: `advance` forgets to check the walls.
private def advanceWithoutWalls(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 body = if eating then state.snake else Util.dropLast(state.snake)
if Util.contains(body, head, _ == _) then state.copy(dir = state.nextDir, status = Status.GameOver)
else
state.copy(
snake = head :: body,
dir = state.nextDir,
apple = if eating then None else state.apple,
score = state.score + (if eating then 1 else 0)
)
// What `update` does after a step since Exercise 10.6: take the queued turn. The mutants below that replace `Tick`
// do the same, so that each one differs from the real game in exactly one thing.
private def takeQueued(s: GameState): GameState = s.queued match
case Some(dir) if s.status == Status.Playing => s.copy(nextDir = dir, queued = None)
case _ => s
private def noWalls(state: GameState, action: Action): GameState = action match
case Action.Tick => takeQueued(Spawn.spawnApple(advanceWithoutWalls(state)))
case other => Game.update(state, other)
test("a game without walls is found and shrunk to the shortest way off the board: ten ticks") {
val prop = sane(noWalls)
forAll(genActions(40), 300, 2024)(prop) match
case CheckResult.Failed(_, _, value) =>
assertEquals(minimize(value)(actions => !prop(actions)), List.fill(10)(Action.Tick))
case other => fail(s"expected a failure, got $other")
}
// Mutant 4: a `Tick` that forgets the turn queue of Exercise 10.6 (the `Frame` step still takes it).
private def tickForgetsQueue(state: GameState, action: Action): GameState = action match
case Action.Tick => Spawn.tick(state)
case other => Game.update(state, other)
test("a Tick that forgets the queued turn is caught by queued-not-reversed and shrunk to four actions") {
val prop = sane(tickForgetsQueue)
forAll(genActions(40), 300, 2024)(prop) match
case CheckResult.Failed(_, _, value) =>
assertEquals(
minimize(value)(actions => !prop(actions)),
List(Action.Turn(Direction.Up), Action.Turn(Direction.Right), Action.Tick, Action.Turn(Direction.Left))
)
case other => fail(s"expected a failure, got $other")
}
// Mutant 5: the queue accepts any key that differs from the pending turn, even the one straight back from it.
private def queueAnyKey(state: GameState, action: Action): GameState = action match
case Action.Turn(dir) if state.status == Status.Playing && state.nextDir != state.dir && dir != state.nextDir =>
state.copy(queued = Some(dir))
case other => Game.update(state, other)
test("a queue that accepts a reversal is found and shrunk to its two key presses") {
val prop = sane(queueAnyKey)
forAll(genActions(40), 300, 2024)(prop) match
case CheckResult.Failed(_, _, value) =>
assertEquals(
minimize(value)(actions => !prop(actions)),
List(Action.Turn(Direction.Up), Action.Turn(Direction.Down))
)
case other => fail(s"expected a failure, got $other")
}
// Mutant 3: the frame rule this course used at first: step whenever the *total* frame count is a multiple of movesEvery.
private def totalFrameCount(state: GameState, action: Action): GameState = action match
case Action.Frame =>
if state.status != Status.Playing then state
else
val counted = state.copy(frame = state.frame + 1)
if counted.frame % Game.movesEvery(counted.score) == 0 then Spawn.tick(counted) else counted
case other => Game.update(state, other)
// A long corridor: the snake is one step from its third apple (score 2 -> 3, so the speed changes), `frame` frames into the game.
private def corridor(frame: Int): GameState =
GameState(60, 1, List(Point(1, 0), Point(0, 0)), Direction.Right, Direction.Right, Some(Point(2, 0)), 0L, 2,
Status.Playing, frame, None)
private val frames = List.fill(40)(Action.Frame)
private def steady(upd: Update)(frame: Int): Boolean =
val states = statesWith(upd, corridor(frame), frames)
val scoreAtMove = states.zip(states.drop(1)).collect {
case (before, after) if after.snake.head != before.snake.head => after.score
}
moveGaps(states).zip(scoreAtMove).forall((gap, score) => gap == Game.movesEvery(score))
test("the real game moves exactly every movesEvery(score) frames, also across a speed-up") {
assertEquals(forAll(Random.intBelow(8), 100, 7)(steady(Game.update)), CheckResult.Passed(100))
}
test("counting frames from the start of the game makes some steps too short, and the property finds one") {
forAll(Random.intBelow(56), 100, 7)(steady(totalFrameCount)) match
case CheckResult.Failed(_, _, value) => assertEquals(steady(totalFrameCount)(value), false)
case other => fail(s"expected a failure, got $other")
}
// ---- random states (Exercise 12.6)
private def connected(snake: List[Point]): Boolean =
snake.zip(snake.drop(1)).forall((a, b) => math.abs(b.x - a.x) + math.abs(b.y - a.y) == 1)
private def board(width: Int, height: Int): GameState = Model.initialState(0).copy(width = width, height = height)
test("genSnake draws the head first, then walks from the tail (seed 0, 5x5, at most 6 cells)") {
assertEquals(
genSnake(5, 5, 6).run(0),
(List(Point(3, 2), Point(3, 1), Point(3, 0), Point(2, 0), Point(1, 0), Point(0, 0)), 4231026099L)
)
}
test("genSnake is pinned for a few more seeds") {
assertEquals(
genSnake(5, 5, 6).run(1)._1,
List(Point(2, 3), Point(3, 3), Point(4, 3), Point(4, 4), Point(3, 4), Point(2, 4))
)
assertEquals(
genSnake(5, 5, 6).run(42)._1,
List(Point(1, 0), Point(1, 1), Point(2, 1), Point(2, 0), Point(3, 0), Point(3, 1))
)
}
test("genSnake with maxLen 1 is just the head, drawn with two draws") {
val (x, s1) = Random.intBelow(5).run(0)
val (y, s2) = Random.intBelow(5).run(s1)
assertEquals(genSnake(5, 5, 1).run(0), (List(Point(x, y)), s2))
}
test("genSnake stops early when the walk is stuck: a 1x1 board holds one cell") {
assertEquals(genSnake(1, 1, 5).run(3)._1, List(Point(0, 0)))
}
test("genSnake always gives a snake on the board, connected, without overlap, at most maxLen long") {
assertEquals(
forAll(genSnake(4, 3, 8), 300, 5) { snake =>
snake.length >= 1 && snake.length <= 8 && connected(snake)
&& snake.forall(p => Rules.inBounds(board(4, 3), p))
&& snake.distinct.length == snake.length
},
CheckResult.Passed(300)
)
}
test("every generated state is sane: 500 states of a 5x5 board have no violations") {
assertEquals(forAll(genState(5, 5), 500, 2024)(s => violations(s).isEmpty), CheckResult.Passed(500))
}
test("generated states are Playing, have nothing queued, and vary in length and apple") {
val (states, _) = listOf(genState(5, 5), 500).run(2024)
assert(states.forall(s => s.status == Status.Playing && s.queued.isEmpty && s.width == 5 && s.height == 5))
assert(states.forall(s =>
if s.snake.length == 1 then s.dir == Direction.Right else Rules.move(s.snake(1), s.dir) == s.snake.head
))
assert(states.exists(_.snake.length == 1) && states.exists(_.snake.length >= 10))
assert(states.exists(_.apple.isEmpty) && states.exists(_.apple.isDefined))
assert(states.exists(s => s.nextDir != s.dir))
}
test("generated states start with seed 0, score 0 and frame 0") {
val (states, _) = listOf(genState(5, 5), 50).run(2024)
assert(states.forall(s => s.seed == 0L && s.score == 0 && s.frame == 0))
}
// ---- a second implementation as an oracle (Exercise 12.7)
cases("m03-advance").foreach { c =>
test(s"advanceSpec agrees with Module 3's advance fixture: ${c("name").str}") {
assertEquals(advanceSpec(gameState(c("args")(0))), gameState(c("expected")))
}
}
test("advance and advanceSpec agree on 500 generated states of a 5x5 board") {
assertEquals(forAll(genState(5, 5), 500, 2024)(s => Rules.advance(s) == advanceSpec(s)), CheckResult.Passed(500))
}
// Mutant 6: Puzzle 1 of Module 3, "the new head must not be on the old snake" (it forgets that the tail moves away).
private def advanceOldSnakeCheck(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 body = if eating then state.snake else Util.dropLast(state.snake)
if !Rules.inBounds(state, head) || Util.contains(state.snake, head, _ == _) then
state.copy(dir = state.nextDir, status = Status.GameOver)
else
state.copy(
snake = head :: body,
dir = state.nextDir,
apple = if eating then None else state.apple,
score = state.score + (if eating then 1 else 0)
)
test("no invariant sees the wrong collision check of Puzzle 1: every state it produces is sane") {
assertEquals(
forAll(genState(5, 5), 500, 2024)(s => violations(advanceOldSnakeCheck(s)).isEmpty),
CheckResult.Passed(500)
)
}
test("comparing with advanceSpec catches the wrong collision check: the head was moving onto the tail") {
forAll(genState(5, 5), 500, 2024)(s => advanceOldSnakeCheck(s) == advanceSpec(s)) match
case CheckResult.Failed(run, _, s) =>
assertEquals(run, 21)
assertEquals(Rules.move(s.snake.head, s.nextDir), s.snake.last)
assertEquals(advanceOldSnakeCheck(s).status, Status.GameOver)
assertEquals(advanceSpec(s).status, Status.Playing)
case other => fail(s"expected a failure, got $other")
}
+222
View File
@@ -0,0 +1,222 @@
package snake
package life
import Fixtures.*
import LifeModel.*
import LifeRules.{lifeStep, lifeUpdate}
import LifeView.lifeView
import LifeApp.*
import M09Helpers.drawn
class M13LifeSuite extends munit.FunSuite:
private def lifeState(v: ujson.Value): LifeState = LifeState(
cells = list(v("cells"))(point),
running = v("running").bool,
seed = v("seed").num.toLong,
generation = v("generation").num.toInt
)
private def lifeAction(v: ujson.Value): LifeAction = v("tag").str match
case "Frame" => LifeAction.Frame
case "Toggle" => LifeAction.Toggle(point(v("cell")))
case "TogglePause" => LifeAction.TogglePause
case "Step" => LifeAction.Step
case "Clear" => LifeAction.Clear
case "Randomize" => LifeAction.Randomize
case other => throw new IllegalArgumentException(s"unknown life action tag: $other")
cases("m13-life-step").foreach { c =>
test(s"lifeStep: ${c("name").str}") {
assertEquals(lifeStep(list(c("args")(0))(point)), list(c("expected"))(point))
}
}
cases("m13-life-update").foreach { c =>
test(s"lifeUpdate: ${c("name").str}") {
val a = c("args")
assertEquals(lifeUpdate(lifeState(a(0)), lifeAction(a(1))), lifeState(c("expected")))
}
}
cases("m13-life-view").foreach { c =>
test(s"lifeView: ${c("name").str}") {
assertEquals(lifeView(lifeState(c("args")(0))), list(c("expected"))(shape))
}
}
cases("m13-life-key").foreach { c =>
test(s"lifeKey: ${c("name").str}") {
assertEquals(lifeKey(c("args")(0).str), opt(c("expected"))(lifeAction))
}
}
cases("m13-life-click").foreach { c =>
test(s"lifeClick: ${c("name").str}") {
val a = c("args")
assertEquals(lifeClick(a(0).num.toInt, a(1).num.toInt), opt(c("expected"))(lifeAction))
}
}
private def time(t: Long): Event = Event.Time(t)
private def down(key: String): Event = Event.KeyDown(key)
private def click(x: Int, y: Int): Event = Event.Click(x, y)
private def cell(x: Int, y: Int): Point = Point(x, y)
private def rowMajor(cells: List[Point]): Boolean =
cells.zip(cells.drop(1)).forall((a, b) => a.y < b.y || (a.y == b.y && a.x < b.x))
// ---- the model and the rules
test("the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below") {
assertEquals(List(LW, LH, LCELL, STRIP), List(40, 30, 16, 24))
}
test("initialLife is an empty, paused world at generation 0 with the given seed") {
assertEquals(initialLife(9), LifeState(Nil, running = false, seed = 9, generation = 0))
}
test("lifeUpdate does not modify the state it is given") {
val s = LifeState(List(cell(4, 5), cell(5, 5), cell(6, 5)), running = true, seed = 3, generation = 0)
val before = s.copy()
List(LifeAction.Frame, LifeAction.Toggle(cell(0, 0)), LifeAction.Toggle(cell(5, 5)), LifeAction.Randomize, LifeAction.Clear)
.foreach(lifeUpdate(s, _))
assertEquals(s, before)
}
test("a glider moves one cell diagonally every four generations") {
val glider = List(cell(1, 0), cell(2, 1), cell(0, 2), cell(1, 2), cell(2, 2))
val after4 = (1 to 4).foldLeft(glider)((cells, _) => lifeStep(cells))
assertEquals(after4, List(cell(2, 1), cell(3, 2), cell(1, 3), cell(2, 3), cell(3, 3)))
}
test("the next generation is computed from the whole old board: a blinker blinks forever") {
val horizontal = List(cell(9, 10), cell(10, 10), cell(11, 10))
val gens = (1 to 6).scanLeft(horizontal)((cells, _) => lifeStep(cells))
assertEquals(gens(2), horizontal)
assertEquals(gens(6), horizontal)
assertEquals(gens(1), List(cell(10, 9), cell(10, 10), cell(10, 11)))
}
test("Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0") {
val seed = 2024L
// The same draws, written the long way round with the Module 4 generator.
val (draws, next) = (0 until LW * LH).foldLeft((Vector.empty[Int], seed)) { case ((ds, s), _) =>
val (d, s2) = Random.intBelow(4).run(s)
(ds :+ d, s2)
}
val expected = draws.zipWithIndex.collect { case (0, i) => cell(i % LW, i / LW) }.toList
val result = lifeUpdate(initialLife(seed), LifeAction.Randomize)
assertEquals(result.cells, expected)
assertEquals(result.seed, next)
assert(result.cells.length > 200 && result.cells.length < 400, s"${result.cells.length} live cells")
}
test("every generation of a random board stays sorted row-major, on the board and without duplicates") {
val start = lifeUpdate(initialLife(5), LifeAction.Randomize).cells
val gens = (1 to 20).scanLeft(start)((cells, _) => lifeStep(cells))
gens.foreach { cells =>
assert(rowMajor(cells))
assert(cells.forall(c => c.x >= 0 && c.x < LW && c.y >= 0 && c.y < LH))
}
}
// Generous on purpose, and more so than in JS because the JVM may still be cold here: a straightforward lifeStep
// takes a few milliseconds per generation. This only catches a generation that is far slower than the 150 ms
// between two of them.
test("lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms") {
val start = lifeUpdate(initialLife(11), LifeAction.Randomize).cells
val t0 = java.lang.System.nanoTime()
(1 to 50).foldLeft(start)((cells, _) => lifeStep(cells))
val ms = (java.lang.System.nanoTime() - t0) / 1000000
assert(ms < 5000, s"$ms ms")
}
// ---- the fourth kind of event
test("clicks emits the position of every Click event and ignores everything else") {
assertEquals(
Stream.clicks.run(List(time(10), click(3, 4), down("a"), click(0, 479), Event.KeyUp("a"))),
List((3, 4), (0, 479))
)
}
test("the other sources ignore clicks") {
val events = List(click(5, 5), time(100), click(6, 6))
assertEquals(Stream.keyDowns.run(events), Nil)
assertEquals(Stream.keyUps.run(events), Nil)
assertEquals(Stream.interval(50).run(events), List(50L, 100L))
assertEquals(Stream.clicks.clampTime(250).run(events), List((5, 5), (6, 6)))
}
test("a generation every 150 ms") { assertEquals(GEN_MS, 150L) }
test("lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles") {
assertEquals(
lifeActions.run(List(time(150), down(" "), click(20, 40), down("x"), click(700, 10), down("n"), time(450))),
List(
LifeAction.Frame,
LifeAction.TogglePause,
LifeAction.Toggle(cell(1, 2)),
LifeAction.Step,
LifeAction.Frame,
LifeAction.Frame
)
)
}
test("lifeStates starts with the new world, then yields one state per action") {
val out = lifeStates(7, lifeActions).run(List(time(0), click(16, 16), click(32, 16), click(48, 16), down(" "), time(150)))
assertEquals(out.head, initialLife(7))
assertEquals(out.map(_.cells.length), List(0, 1, 2, 3, 3, 3))
assertEquals(out.last, LifeState(List(cell(2, 0), cell(2, 1), cell(2, 2)), running = true, seed = 7, generation = 1))
}
test("lifeMain asks for a seed, draws the empty world first, and then a click as a live cell") {
val traced = IO.trace(lifeMain, 42)
assertEquals(traced.ops, List(Op.RandomSeed))
val (first, next) = traced.result.step(time(0))
val (second, _) = next.step(click(17, 33))
assertEquals(drawn(first.head), lifeView(initialLife(42)))
assertEquals(drawn(second.head), lifeView(initialLife(42).copy(cells = List(cell(1, 2)))))
}
test("lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds") {
val (_, afterFirst) = IO.trace(lifeMain, 42).result.step(time(0))
assertEquals(afterFirst.step(time(10000))._1.length, 1)
}
// ---- the runtime (step 7 of the module), with the fake platform of M09Runtime.test.scala
test("runApp registers a click handler with the platform") {
val p = FakePlatform()
Runtime.runApp(IO.pure(Stream.clicks.map(_ => IO.pure(()))), p)
assertEquals(p.registered, List("click", "down", "time", "up"))
}
test("runApp feeds clicks to the stream") {
val p = FakePlatform()
Runtime.runApp(IO.pure(Stream.clicks.map((x, y) => IO.draw(Shape.Rect(x, y, 1, 1, "#ffffff")))), p)
p.fireClick(33, 47)
p.fireClick(0, 2)
assertEquals(p.calls.toList, List(List("fillRect", 33, 47, 1, 1, "#ffffff"), List("fillRect", 0, 2, 1, 1, "#ffffff")))
}
test("runApp runs Life: a click on the canvas draws the world with that cell alive") {
val p = FakePlatform()
Runtime.runApp(lifeMain, p)
p.fireTime(0)
p.calls.clear()
p.fireClick(100, 250)
assert(p.calls.contains(List("fillRect", 6 * 16 + 1, 15 * 16 + 1, 14, 14, "#7cb342")))
}
test("Snake and Pong ignore clicks: their actions and drawings are the same with and without them") {
val withClicks = List(time(0), click(5, 5), time(25), down("ArrowUp"), click(300, 300), time(200))
val without = withClicks.filter {
case Event.Click(_, _) => false
case _ => true
}
assertEquals(App.speedActions.run(withClicks), App.speedActions.run(without))
assertEquals(pong.PongApp.pongActions.run(withClicks), pong.PongApp.pongActions.run(without))
assertEquals(App.play(App.main, 42, withClicks), App.play(App.main, 42, without))
}
+37
View File
@@ -4,9 +4,12 @@ package web
import scala.scalajs.js
import scala.scalajs.js.Dynamic.global
import scala.scalajs.js.typedarray.Uint32Array
import scala.util.control.NonFatal
/**
* Browser glue: turns a canvas into a Platform. Impure by nature; not part of the exercises' logic.
* Snake, Pong, the replay viewer and Life share it; only Snake performs `LoadBest` and `SaveBest`, so only Snake
* touches storage, and only Life does anything with clicks.
* `js.Dynamic` lets us call browser APIs by name without type facades (and without extra libraries).
*/
final class BrowserPlatform(canvas: js.Dynamic) extends Platform:
@@ -38,6 +41,26 @@ final class BrowserPlatform(canvas: js.Dynamic) extends Platform:
global.crypto.getRandomValues(buf)
buf(0).toLong
// m10: where the best score survives a reload.
private val bestKey = "pure-snake-best"
// Storage can be missing, blocked (privacy settings) or hold anything: all of that reads as 0, and nothing throws.
// `window.localStorage` is read inside the `try`, because some browsers throw as soon as it is touched.
def loadBest(): Int =
try
val raw = window.localStorage.getItem(bestKey)
if raw == null || js.isUndefined(raw) then 0
else
// Only what saveBest writes counts: decimal digits, at most Int.MaxValue (so all three tracks read alike).
val s = raw.asInstanceOf[String]
if s.matches("[0-9]{1,10}") && s.toDouble <= Int.MaxValue then s.toDouble.toInt else 0
catch case NonFatal(_) => 0
// A record that cannot be stored is lost; the game goes on.
def saveBest(score: Int): Unit =
try window.localStorage.setItem(bestKey, score.toString)
catch case NonFatal(_) => () // ignored on purpose
// t is the number of milliseconds since the first animation frame.
def startClock(onTime: Long => Unit): Unit =
def frame(start: Option[Double]): js.Function1[Double, Unit] = now =>
@@ -48,3 +71,17 @@ final class BrowserPlatform(canvas: js.Dynamic) extends Platform:
def onKeyDown(cb: String => Unit): Unit = listen("keydown", cb)
def onKeyUp(cb: String => Unit): Unit = listen("keyup", cb)
// m13: clicks on the canvas (not the window), converted to canvas pixels. The listener is added only when a runtime
// asks for clicks. The page may show the canvas at another size than its width x height (CSS scaling, zoom) and draws
// a border around it, so the window coordinates are made relative to the drawing area (bounding rectangle minus the
// border, clientLeft/clientTop) and then scaled by the canvas size over its shown size (clientWidth/clientHeight).
def onClick(cb: (Int, Int) => Unit): Unit =
val handler: js.Function1[js.Dynamic, Unit] = e =>
val r = canvas.getBoundingClientRect()
def axis(client: String, start: String, border: String, size: String, shown: String): Int =
val offset = e.selectDynamic(client).asInstanceOf[Double] - r.selectDynamic(start).asInstanceOf[Double] -
canvas.selectDynamic(border).asInstanceOf[Double]
math.floor(offset * canvas.selectDynamic(size).asInstanceOf[Double] / canvas.selectDynamic(shown).asInstanceOf[Double]).toInt
cb(axis("clientX", "left", "clientLeft", "width", "clientWidth"), axis("clientY", "top", "clientTop", "height", "clientHeight"))
canvas.addEventListener("click", handler)
+28 -1
View File
@@ -1,9 +1,10 @@
package snake
package web
import scala.scalajs.js
import scala.scalajs.js.Dynamic.global
// Two programs, one runtime: build-web.sh picks the entry point with --main-class.
// Four programs, one runtime: build-web.sh picks the entry point with --main-class.
private def canvas() = global.document.getElementById("game")
@@ -14,3 +15,29 @@ object SnakeMain:
/** scala/pong.html: Pong (m11). */
object PongMain:
def main(args: Array[String]): Unit = Runtime.runApp(pong.PongApp.pongMain, BrowserPlatform(canvas()))
/**
* scala/replay.html: the replay viewer (m10). The recording is plain JSON next to the test fixtures; the page is served
* from the repository root by scripts/serve.mjs, so it can be fetched (relative to the page, scala/replay.html).
* The JSON is decoded by hand with js.Dynamic: `{seed, actions}`, each action `{"tag":"Tick"}` or
* `{"tag":"Turn","dir":"Up"}`.
*/
object ReplayMain:
private def action(a: js.Dynamic): Action = a.tag.asInstanceOf[String] match
case "Tick" => Action.Tick
case "Turn" => Action.Turn(Direction.valueOf(a.dir.asInstanceOf[String]))
case other => throw new IllegalArgumentException(s"a recording holds only Tick and Turn, not $other")
private def recording(json: js.Dynamic): Recording =
Recording(
json.seed.asInstanceOf[Double].toLong,
json.actions.asInstanceOf[js.Array[js.Dynamic]].toList.map(action)
)
def main(args: Array[String]): Unit =
val loaded = global.fetch("../fixtures/replay-sample.json").`then`((r: js.Dynamic) => r.json())
loaded.`then`((json: js.Dynamic) => Runtime.runApp(Replay.replayMain(recording(json)), BrowserPlatform(canvas())))
/** scala/life.html: Conway's Life with the mouse (m13). */
object LifeMain:
def main(args: Array[String]): Unit = Runtime.runApp(life.LifeApp.lifeMain, BrowserPlatform(canvas()))