feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
Audit pass: - Frame rule counts frames since the last move, so every step lasts exactly movesEvery(score) frames, also right after a speed-up - clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap - Pong Restart keeps held keys - Fact corrections (mulberry32 output coverage, Haskell IO, uniform) - New fixture cases from a mutation analysis Extensions (all three tracks, shared fixtures, course text): - Turn queue (Exercise 10.6), persisted high score (10.7), replay viewer (10.8), play() end-to-end helper (9.5) - Optional State (4.7) and optional Module 7b, stack-safe IO - Module 12, property-based testing with generators, shrinking, invariants and a model-based oracle - Module 13, Conway's Life with a Click event - JS type checking via jsconfig, JSDoc and types.d.ts - Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh, Playwright end-to-end suite (e2e/, scripts/e2e.sh) Docs: docs/contract.md is the canonical contract; specs and build log under docs/superpowers. Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)))
|
||||
@@ -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)
|
||||
@@ -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)))
|
||||
@@ -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))
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package snake
|
||||
package life
|
||||
|
||||
import LifeModel.*
|
||||
|
||||
/** m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse. */
|
||||
object LifeApp:
|
||||
|
||||
val GEN_MS: Long = 150
|
||||
|
||||
def lifeKey(key: String): Option[LifeAction] = key match
|
||||
case " " => Some(LifeAction.TogglePause)
|
||||
case "n" | "N" => Some(LifeAction.Step)
|
||||
case "c" | "C" => Some(LifeAction.Clear)
|
||||
case "r" | "R" => Some(LifeAction.Randomize)
|
||||
case _ => None
|
||||
|
||||
// The pixel is checked before dividing: integer division rounds towards zero, so -1 / 16 would be cell 0.
|
||||
def lifeClick(x: Int, y: Int): Option[LifeAction] =
|
||||
if x >= 0 && y >= 0 && x / LCELL < LW && y / LCELL < LH then Some(LifeAction.Toggle(Point(x / LCELL, y / LCELL)))
|
||||
else None
|
||||
|
||||
lazy val lifeActions: Stream[LifeAction] =
|
||||
Stream
|
||||
.interval(GEN_MS)
|
||||
.map(_ => LifeAction.Frame)
|
||||
.merge(Stream.keyDowns.filterMap(lifeKey))
|
||||
.merge(Stream.clicks.filterMap(lifeClick(_, _)))
|
||||
|
||||
def lifeStates(seed: Long, actions: Stream[LifeAction]): Stream[LifeState] =
|
||||
actions.scan(initialLife(seed))(LifeRules.lifeUpdate).startWith(initialLife(seed))
|
||||
|
||||
lazy val lifeMain: IO[Stream[IO[Unit]]] =
|
||||
IO.randomSeed.map(seed =>
|
||||
lifeStates(seed, lifeActions).map(state => IO.drawScene(LifeView.lifeView(state))).clampTime(App.MAX_GAP_MS)
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package snake
|
||||
package life
|
||||
|
||||
// m13 life model: the whole world is one value, like the snake and the match.
|
||||
|
||||
/**
|
||||
* The whole world. `cells` are the live cells, always kept canonical: sorted row-major (by `y`, then by `x`), each cell
|
||||
* at most once, all on the board. `seed` is the unsigned 32-bit seed that `Randomize` draws from; `generation` counts
|
||||
* the generations since the board was last cleared or randomized.
|
||||
*/
|
||||
final case class LifeState(cells: List[Point], running: Boolean, seed: Long, generation: Int)
|
||||
|
||||
/** Everything that can happen to the world, as data. `Toggle` carries the cell that was clicked. */
|
||||
enum LifeAction:
|
||||
case Frame
|
||||
case Toggle(cell: Point)
|
||||
case TogglePause, Step, Clear, Randomize
|
||||
|
||||
object LifeModel:
|
||||
|
||||
// The board, in cells: LW columns, LH rows, LCELL pixels per cell. Below it the page shows a status strip.
|
||||
val LW = 40
|
||||
val LH = 30
|
||||
|
||||
/** Pixels per cell. */
|
||||
val LCELL = 16
|
||||
|
||||
/** Height of the status strip under the board, in pixels. */
|
||||
val STRIP = 24
|
||||
|
||||
/** A fresh world: no live cells, paused, the given seed, generation 0. */
|
||||
def initialLife(seed: Long): LifeState = LifeState(Nil, running = false, seed, generation = 0)
|
||||
@@ -0,0 +1,59 @@
|
||||
package snake
|
||||
package life
|
||||
|
||||
import LifeModel.*
|
||||
|
||||
/** m13 life rules: the next generation is a function of the whole old board, and nothing else. */
|
||||
object LifeRules:
|
||||
|
||||
// Every cell of the board, row-major (y, then x). Filtering this list keeps any result in canonical order for free.
|
||||
private val board: List[Point] = (for y <- 0 until LH; x <- 0 until LW yield Point(x, y)).toList
|
||||
|
||||
private def onBoard(p: Point): Boolean = p.x >= 0 && p.x < LW && p.y >= 0 && p.y < LH
|
||||
|
||||
// A cell's position in row-major order: the order the canonical list is sorted by.
|
||||
private def index(p: Point): Int = p.y * LW + p.x
|
||||
|
||||
private val offsets = List((-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1))
|
||||
|
||||
// The up to eight neighbours of `p` that are on the board (the edges are dead: nothing wraps around).
|
||||
private def neighbours(p: Point): List[Point] =
|
||||
offsets.map((dx, dy) => Point(p.x + dx, p.y + dy)).filter(onBoard)
|
||||
|
||||
// B3/S23, every cell judged on the *old* list only. A Set of the old cells makes "is this neighbour alive?" one
|
||||
// lookup, so a generation is 1200 x 8 lookups however many cells live.
|
||||
def lifeStep(cells: List[Point]): List[Point] =
|
||||
val alive = cells.toSet
|
||||
board.filter { p =>
|
||||
val n = neighbours(p).count(alive)
|
||||
n == 3 || (n == 2 && alive(p))
|
||||
}
|
||||
|
||||
// Add a dead cell or remove a live one, keeping the list sorted row-major without duplicates. Off the board: unchanged.
|
||||
private def toggle(cells: List[Point], p: Point): List[Point] =
|
||||
if !onBoard(p) then cells
|
||||
else if cells.contains(p) then cells.filterNot(_ == p)
|
||||
else cells.filter(index(_) < index(p)) ++ (p :: cells.filter(index(_) > index(p)))
|
||||
|
||||
// One intBelow(4) per board cell in row-major order, the cell alive when the draw is 0 (about one cell in four).
|
||||
// A fold carries the seed; the live cells are collected in reverse and turned round once at the end.
|
||||
private def randomCells(seed: Long): (List[Point], Long) =
|
||||
val (aliveReversed, next) = board.foldLeft((List.empty[Point], seed)) { case ((acc, s), p) =>
|
||||
val (d, s2) = Random.intBelow(4).run(s)
|
||||
(if d == 0 then p :: acc else acc, s2)
|
||||
}
|
||||
(aliveReversed.reverse, next)
|
||||
|
||||
private def nextGeneration(state: LifeState): LifeState =
|
||||
state.copy(cells = lifeStep(state.cells), generation = state.generation + 1)
|
||||
|
||||
def lifeUpdate(state: LifeState, action: LifeAction): LifeState = action match
|
||||
case LifeAction.Frame => if state.running then nextGeneration(state) else state
|
||||
case LifeAction.Step => if state.running then state else nextGeneration(state)
|
||||
case LifeAction.TogglePause => state.copy(running = !state.running)
|
||||
// Clicking edits the world whether it runs or not: the click is one action between two generations.
|
||||
case LifeAction.Toggle(cell) => state.copy(cells = toggle(state.cells, cell))
|
||||
case LifeAction.Clear => state.copy(cells = Nil, generation = 0)
|
||||
case LifeAction.Randomize =>
|
||||
val (cells, seed) = randomCells(state.seed)
|
||||
state.copy(cells = cells, seed = seed, generation = 0)
|
||||
@@ -0,0 +1,21 @@
|
||||
package snake
|
||||
package life
|
||||
|
||||
import LifeModel.*
|
||||
|
||||
/** m13 life view: live cells as squares, and a status strip under the board. */
|
||||
object LifeView:
|
||||
|
||||
private val W = LW * LCELL
|
||||
private val H = LH * LCELL
|
||||
|
||||
private def text(x: Int, y: Int, str: String, align: String): Shape = Shape.Text(x, y, str, "#ffffff", 14, align)
|
||||
|
||||
def lifeView(state: LifeState): Scene =
|
||||
Shape.Rect(0, 0, W, H, "#111111") ::
|
||||
state.cells.map(p => Shape.Rect(p.x * LCELL + 1, p.y * LCELL + 1, LCELL - 2, LCELL - 2, "#7cb342")) ++
|
||||
List(
|
||||
Shape.Rect(0, H, W, STRIP, "#222222"),
|
||||
text(6, H + 17, s"Generation ${state.generation}", "left"),
|
||||
text(W - 6, H + 17, if state.running then "Running: Space pauses" else "Paused: Space runs, N steps", "right")
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user