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

+39 -2
View File
@@ -2,6 +2,7 @@
module Snake.App
open Snake.Model
open Snake.View
open Snake.IO
open Snake.Stream
@@ -11,6 +12,9 @@ let TICK_MS = 150L
/// Milliseconds between animation frames (m10).
let FRAME_MS = 25L
/// (m10) The longest step of time the game accepts at once, in milliseconds (see `clampTime` in Stream.fs).
let MAX_GAP_MS = 250L
/// Stream of everything that can happen to the game: `Tick` every TICK_MS (from `Stream.interval`),
/// merged with the action for each key press that `Input.keyToAction` recognises (from `Stream.keyDowns`).
/// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.)
@@ -27,7 +31,40 @@ let states (seed: uint32) (actions: Stream<Action>) : Stream<GameState> = failwi
/// The drawing of one state, as an IO: `IO.drawScene (View.view state)`.
let render (state: GameState) : IO<unit> = failwith "TODO m09"
/// (m10) A session: the game, the best score so far, and whether the action that produced this session raised the best.
type Session = { Game: GameState; Best: int; Improved: bool }
/// (m10) One action: `game' = Game.update session.Game action`, `best' = max session.Best game'.Score`,
/// `improved = best' > session.Best` (strictly greater: equalling the record is not a new record).
let sessionUpdate (session: Session) (action: Action) : Session = failwith "TODO m10"
/// (m10) Like `states`, one level up: scan with `sessionUpdate` from `s0`, and emit `s0` first (startWith),
/// with `s0 = { Game = Spawn.newGame seed; Best = best; Improved = false }`.
let sessions (seed: uint32) (best: int) (actions: Stream<Action>) : Stream<Session> = failwith "TODO m10"
/// (m10) `View.view session.Game` followed by one more shape: the text `Best: <best>` at the top right,
/// `Text(game.Width * CELL - 6, 18, "Best: 3", "#ffffff", 16, "right")`.
let sessionView (session: Session) : Scene = failwith "TODO m10"
/// (m10) `IO.drawScene (sessionView session)`, followed by `IO.saveBest session.Best` when `session.Improved`
/// (and only then).
let renderSession (session: Session) : IO<unit> = failwith "TODO m10"
/// 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.
/// A function of `unit`, like `IO.randomSeed`.
/// in m09, from `speedActions` after m10) into a stream of drawings (`render`).
/// (m10) The whole stream is wrapped in `Stream.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. A function of `unit`, like `IO.randomSeed`.
let main () : IO<Stream<IO<unit>>> = failwith "TODO m09"
/// (m10) `play` that also passes `best` to both traces (`IO.traceWith seed best`), so that `LoadBest` is answered
/// with it.
let playWith (seed: uint32) (best: int) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list =
failwith "TODO m10"
/// (m09) The whole program in one equality: for each drawing that `main` emits while it is fed `events` (a list of
/// inputs), the list of ops that drawing performs. Interpret `main` with `IO.trace seed`, `Stream.run` the stream it
/// produces on `events`, and trace each emitted IO with the same seed, keeping its `Ops`.
/// (m10) Once `playWith` (above) is written, `play` becomes its "no stored best" case: `playWith seed 0 events main`.
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = failwith "TODO m09"
+126
View File
@@ -0,0 +1,126 @@
/// 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.fs (`seed -> (value, nextSeed)`).
module Snake.Check
open Snake.Model
open Snake.Random
/// What `forAll` reports: every value passed (`Passed runs`), or the first one that did not (`Failed`, see `forAll`).
type CheckResult<'a> =
| Passed of runs: int
| Failed of run: int * seed: uint32 * value: 'a
/// 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 (List.length xs)`.
let elementOf (xs: 'a list) : Random<'a> = failwith "TODO m12"
/// 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 0 gen` gives `[]` and leaves the seed alone. No loop: recursion on `n`, with `Random.bind` and `Random.map`.
let listOf (n: int) (gen: Random<'a>) : Random<'a list> = failwith "TODO m12"
// Ticks appear three times so that the snake moves more often than it turns.
let private actionPool: Action list =
[ Tick; Tick; Tick; Turn Up; Turn Down; Turn Left; Turn Right ]
/// A generator of `n` random actions (given; it works once `elementOf` and `listOf` do).
let genActions (n: int) : Random<Action list> = listOf n (elementOf actionPool)
/// Check the property `prop` (a function value -> bool) 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: `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 `Random.run seed gen |> fst` gives `value` again).
/// Write it with a tail-recursive helper that carries the run number and the seed.
let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a> = failwith "TODO m12"
/// Every list that is `xs` with exactly one element removed, in the order of the removed index:
/// `removals [ 1; 2; 3 ]` is `[ [ 2; 3 ]; [ 1; 3 ]; [ 1; 2 ] ]`; `removals []` is `[]`.
let removals (xs: 'a list) : 'a list list = failwith "TODO m12"
/// Shrink a failing list. `fails` is a function list -> bool 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.
let minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list = failwith "TODO m12"
/// The names of the invariants that `state` breaks, in this order (an empty list 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 (`Some`), is on the board
/// "apple-not-on-snake" the apple, if there is one (`Some`), 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`
/// No apple (`None`) breaks neither apple invariant.
let violations (state: GameState) : string list = failwith "TODO m12"
/// 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 `[ 3; 7 ]`. Fewer than two moves give `[]`.
let moveGaps (states: GameState list) : int list = failwith "TODO m12"
// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
/// (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 and not yet in the snake.
/// If none is left, stop early (with `Random.pure`, no draw). 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 `Random.bind`.
let genSnake (width: int) (height: int) (maxLen: int) : Random<Point list> = failwith "TODO m12"
let private directions: Direction list = [ Up; Down; Left; Right ]
// The direction the head moved in to get from the second cell to the first (`Right` for a one-cell snake).
let private headingOf (snake: Point list) : Direction =
match snake with
| head :: second :: _ -> directions |> List.find (fun d -> Rules.move second d = head)
| _ -> 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 (Spawn.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`.
let genState (width: int) (height: int) : Random<GameState> =
Random.intBelow (width * height)
|> Random.bind (fun n ->
genSnake width height (n + 1)
|> Random.bind (fun snake ->
let dir = headingOf snake
let state =
{ Width = width
Height = height
Snake = snake
Dir = dir
NextDir = dir
Apple = None
Seed = 0u
Score = 0
Status = Playing
Frame = 0
Queued = None }
let free = Spawn.freeCells state
elementOf (directions |> List.filter (fun d -> d <> opposite dir))
|> Random.bind (fun nextDir ->
Random.intBelow 2
|> Random.bind (fun coin ->
(if coin = 0 || List.isEmpty free then Random.pure None else elementOf free |> Random.map Some)
|> Random.map (fun apple -> { state with NextDir = nextDir; Apple = apple })))))
/// (Exercise 12.7) The step of m03, 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 m03's game over (`Dir = NextDir`, `Status = GameOver`, nothing else changed);
/// otherwise the state with the new snake, `Dir = NextDir`, and, when eating, `Apple = None` and the score one higher.
/// Do not call `Rules.advance`. On every sane state the result must equal `Rules.advance state`.
let advanceSpec (state: GameState) : GameState = failwith "TODO m12"
+14 -2
View File
@@ -3,6 +3,13 @@ module Snake.Game
open Snake.Model
/// (m10, Exercise 10.6) What `update` does with `Turn dir` from m10 on (the turn queue).
/// - not Playing: the state unchanged;
/// - no turn pending (`NextDir = Dir`): `Rules.turn state dir`, the m03 rule;
/// - a turn pending (`NextDir <> Dir`): if `dir` is neither `NextDir` nor `opposite NextDir`, the state with
/// `Queued = Some dir` (a later key replaces an earlier queued one); otherwise the state unchanged.
let steer (state: GameState) (dir: Direction) : GameState = failwith "TODO m10"
/// 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.
let movesEvery (score: int) : int = failwith "TODO m10"
@@ -14,8 +21,13 @@ let movesEvery (score: int) : int = failwith "TODO m10"
///
/// (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 d`,
/// the result gets `NextDir = d` and `Queued = None`. A result that is not Playing keeps `Queued` as it is.
/// In m10, match every case of `Action` explicitly (no `| _ ->`), so the compiler tells you if one is forgotten.
let update (state: GameState) (action: Action) : GameState = failwith "TODO m05"
+20 -1
View File
@@ -1,6 +1,8 @@
/// m07 io: effects as values. An `IO<'a>` is a description of a program that, when interpreted, produces an `'a`:
/// - `Pure a` finished, nothing left to do
/// - `Effect (op, k)` perform `op`, then continue with `k result` (which returns another IO)
/// (m10) Two more operations, for a best score that survives a reload: `LoadBest` (answered with `Best value`, the
/// stored best) and `SaveBest score` (answered with `Done`).
/// Building an IO never performs anything; only an interpreter (`trace` here, `Runtime.runIO` in m09) does.
module Snake.IO
@@ -10,11 +12,17 @@ open Snake.View
type Op =
| Draw of Shape
| RandomSeed
/// (m10) Read the stored best score.
| LoadBest
/// (m10) Store `score` as the new best.
| SaveBest of int
/// 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` for a `LoadBest`, `Done` for a `SaveBest`.
type OpResult =
| Done
| Seed of uint32
| Best of int
type IO<'a> =
| Pure of 'a
@@ -50,7 +58,18 @@ let randomSeed () : IO<uint32> = failwith "TODO m07"
/// Draw every shape of a scene in order: `sequence (List.map draw scene)`.
let drawScene (scene: Scene) : IO<unit> = failwith "TODO m07"
/// (m10) An IO that reads the stored best score and produces it (the `value` of the `Best` answer).
/// A function of `unit`, like `randomSeed ()`; an answer other than `Best` is a broken interpreter: fail loudly.
let loadBest () : IO<int> = failwith "TODO m10"
/// (m10) An IO that stores `score` as the new best and produces `()`: built like `draw`, from `SaveBest score`.
let saveBest (score: int) : IO<unit> = failwith "TODO m10"
/// (m10) `trace` that also answers every `LoadBest` with `Best best` and every `SaveBest` with `Done`.
let traceWith (seed: uint32) (best: int) (io: IO<'a>) : Traced<'a> = failwith "TODO m10"
/// A pure interpreter for tests. Answers every `RandomSeed` with `Seed seed` and every `Draw` with `Done`.
/// Returns the final value and the operations in the order they were executed.
/// Implemented by recursion, with no mutation.
/// (m10) Once `traceWith` (above) is written, `trace` becomes its "no stored best" case: `traceWith seed 0 io`.
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = failwith "TODO m07"
+34
View File
@@ -0,0 +1,34 @@
/// m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse.
module Snake.Life.App
open Snake
open Snake.IO
open Snake.Stream
open Snake.Life.Model
open Snake.Life.Rules
open Snake.Life.View
/// Milliseconds between two generations while the world runs.
let GEN_MS = 150L
/// A key press to a `LifeAction`, if any: " " -> TogglePause, "n"/"N" -> Step, "c"/"C" -> Clear,
/// "r"/"R" -> Randomize. Any other key -> `None`.
let lifeKey (key: string) : LifeAction option = failwith "TODO m13"
/// 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: F# integer division rounds towards zero, so `-1 / 16` is 0. Check the pixel before you divide.
let lifeClick (x: int) (y: int) : LifeAction option = failwith "TODO m13"
/// 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.
/// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.)
let lifeActions: Stream<LifeAction> = Stream(fun _ -> failwith "TODO m13")
/// Fold the actions into a stream of states: start from `initialLife seed`, `lifeUpdate` for each action, emit the
/// initial state first.
let lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState> = failwith "TODO m13"
/// 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 `Stream.clampTime App.MAX_GAP_MS`. A function of `unit`, like `App.main`.
let lifeMain () : IO<Stream<IO<unit>>> = failwith "TODO m13"
+38
View File
@@ -0,0 +1,38 @@
/// m13 life model: the whole world is one value, like the snake and the match.
module Snake.Life.Model
open Snake.Model
/// The whole world. `Cells` are the live cells, always canonical: sorted row-major (by `Y`, then by `X`), each cell
/// at most once, all on the board. `Running` says whether generations follow each other on their own; `Seed` is the
/// unsigned 32-bit random seed used by `Randomize`; `Generation` counts the generations since the last Clear or
/// Randomize.
type LifeState =
{ Cells: Point list
Running: bool
Seed: uint32
Generation: int }
/// Everything that can happen to the world, as data. `Toggle` carries the cell that was clicked.
/// (RequireQualifiedAccess: Snake's `Action` also has `Frame` and `TogglePause`.)
[<RequireQualifiedAccess>]
type LifeAction =
| Frame
| Toggle of Point
| TogglePause
| Step
| Clear
| Randomize
// The board, in cells: LW columns, LH rows. Below it the page shows a status strip.
let LW = 40
let LH = 30
/// Pixels per cell.
let LCELL = 16
/// Height of the status strip under the board, in pixels.
let STRIP = 24
/// A fresh world: no live cells (`Cells = []`), paused (`Running = false`), the given seed, generation 0.
let initialLife (seed: uint32) : LifeState = failwith "TODO m13"
+27
View File
@@ -0,0 +1,27 @@
/// m13 life rules: the next generation is a function of the whole old board, and nothing else.
module Snake.Life.Rules
open Snake
open Snake.Model
open Snake.Life.Model
/// 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.
let lifeStep (cells: Point list) : Point list = failwith "TODO m13"
/// The one function that says how the world changes.
/// - `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..LH-1, and within a row x = 0..LW-1), 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.
let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState = failwith "TODO m13"
+11
View File
@@ -0,0 +1,11 @@
/// m13 life view: live cells as squares, and a status strip under the board.
module Snake.Life.View
open Snake.View
open Snake.Life.Model
/// 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")`.
let lifeView (state: LifeState) : Scene = failwith "TODO m13"
+4 -2
View File
@@ -25,6 +25,7 @@ type Action =
| Frame
/// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed.
/// `Frame` and `Queued` stay unused until m10 (`Queued` holds a turn waiting for the next step).
type GameState =
{ Width: int
Height: int
@@ -35,7 +36,8 @@ type GameState =
Seed: uint32
Score: int
Status: Status
Frame: int }
Frame: int
Queued: Direction option }
/// The unit step for a direction: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0). Y grows downward.
let delta (dir: Direction) : Point = failwith "TODO m02"
@@ -44,5 +46,5 @@ let delta (dir: Direction) : Point = failwith "TODO m02"
let opposite (dir: Direction) : Direction = failwith "TODO m02"
/// 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`).
let initialState (seed: uint32) : GameState = failwith "TODO m02"
+1 -1
View File
@@ -25,5 +25,5 @@ let pongActions: Stream<PongAction> = Stream(fun _ -> failwith "TODO m11")
let pongStates (seed: uint32) (actions: Stream<PongAction>) : Stream<PongState> = failwith "TODO m11"
/// 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`. A function of `unit`, like `App.main`.
/// of `pongView`, and wrap that stream in `Stream.clampTime App.MAX_GAP_MS`. A function of `unit`, like `App.main`.
let pongMain () : IO<Stream<IO<unit>>> = failwith "TODO m11"
+1 -1
View File
@@ -11,7 +11,7 @@ let clamp (lo: int) (hi: int) (x: int) : int = failwith "TODO m11"
/// 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),
+66
View File
@@ -0,0 +1,66 @@
/// m10 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.
module Snake.Replay
open Snake.Model
open Snake.View
open Snake.IO
open Snake.Stream
/// A recorded game: the seed of the game and every action, in order (see `fixtures/replay-sample.json`).
type Recording = { Seed: uint32; Actions: Action list }
/// The viewer: which frame is shown (`Index`, 0 = the new game, k = after k actions), the index of the last frame
/// (`Last`, the number of actions), and whether autoplay is on (`Playing`).
type Viewer = { Index: int; Last: int; Playing: bool }
/// Everything the viewer can be asked to do, as data. `Beat` is one beat of the autoplay clock.
/// (RequireQualifiedAccess: `ViewerAction.Last` must never be confused with anything else in scope.)
[<RequireQualifiedAccess>]
type ViewerAction =
| Next
| Prev
| First
| Last
| TogglePlay
| Beat
/// Milliseconds between two frames of autoplay.
let REPLAY_MS = 100L
/// Every state of the recorded game: `Game.history (Spawn.newGame seed) actions`. Its length is the number of
/// actions + 1.
let frames (recording: Recording) : GameState list = failwith "TODO m10"
/// The viewer at the first frame, not playing: `{ Index = 0; Last = last; Playing = false }`.
let viewerStart (last: int) : Viewer = failwith "TODO m10"
/// 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).
let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer = failwith "TODO m10"
/// A key to a viewer action, or `None`: "ArrowRight"/"d"/"D" -> Next, "ArrowLeft"/"a"/"A" -> Prev, "Home" -> First,
/// "End" -> Last, " " -> TogglePlay. Any other key -> `None`.
let viewerKey (key: string) : ViewerAction option = failwith "TODO m10"
/// The picture: `View.view` of the frame at `Index`, followed by two texts. With `w = Width * CELL` of that frame:
/// `Text(w - 6, 18, "step <index> / <last>", "#ffffff", 16, "right")` and
/// `Text(w / 2, Height * 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.
let viewerView (frames: GameState list) (viewer: Viewer) : Scene = failwith "TODO m10"
/// `Beat` every REPLAY_MS (from `Stream.interval`), merged with `viewerKey` of every key press (the beats first).
/// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.)
let replayActions: Stream<ViewerAction> = Stream(fun _ -> failwith "TODO m10")
/// Fold the actions into viewers with `viewerUpdate`, starting from `viewerStart last`, which is emitted first.
let viewerStates (last: int) (actions: Stream<ViewerAction>) : Stream<Viewer> = failwith "TODO m10"
/// 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 `Stream.clampTime App.MAX_GAP_MS`.
/// No seed is asked for: the recording carries its own. Compute the frames once, not for every drawing.
let replayMain (recording: Recording) : IO<Stream<IO<unit>>> = failwith "TODO m10"
+9 -1
View File
@@ -13,14 +13,21 @@ type Platform =
FillText: int -> int -> string -> string -> int -> string -> unit
/// A random unsigned 32-bit seed.
RandomSeed: unit -> uint32
/// (m10) The stored best score; 0 when there is none or it cannot be read. Never throws.
LoadBest: unit -> int
/// (m10) Store a new best score.
SaveBest: int -> unit
/// Call `onTime t` again and again, `t` = milliseconds since the clock started.
StartClock: (int64 -> unit) -> unit
OnKeyDown: (string -> unit) -> unit
OnKeyUp: (string -> unit) -> unit }
OnKeyUp: (string -> unit) -> unit
/// (m13, given) Call `cb x y` for every click on the canvas, `x` and `y` in canvas pixels.
OnClick: (int -> int -> unit) -> unit }
/// 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 `Done`; `RandomSeed` -> `Seed (p.RandomSeed ())`. Each answer is passed to `k`.
/// (m10) `LoadBest` -> `Best (p.LoadBest ())`; `SaveBest score` -> `p.SaveBest score`, answered with `Done`.
/// Must not grow the stack with the number of ops: a loop, or a tail-recursive `let rec` (which the compiler
/// turns into a loop).
let runIO (p: Platform) (io: IO<'a>) : 'a = failwith "TODO m09"
@@ -29,4 +36,5 @@ let runIO (p: Platform) (io: IO<'a>) : 'a = failwith "TODO m09"
/// (F# closures cannot capture a `let mutable`, so use a ref cell: `let stream = ref ...`, `stream.Value <- next`).
/// 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 `Click(x, y)`.
let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit = failwith "TODO m09"
+63
View File
@@ -0,0 +1,63 @@
/// m07b (optional): a stack-safe IO. The same idea as m07's IO, with one more case:
/// - `Pure a` finished, with a value
/// - `Effect (op, k)` perform `op`, then continue with `k result` (which returns another SafeIO)
/// - `FlatMap (io, f)` run `io`, then continue with `f result` (which returns another SafeIO)
/// The ops, their answers and the result of `trace` are m07's: write them `IO.Op`, `IO.OpResult`, `IO.Traced`
/// (do not `open Snake.IO`: its cases are also called Pure and Effect). This module does not use m07's `IO` type;
/// it is a second, independent IO. Building a SafeIO never performs anything and never calls a function given to
/// `map` or `bind`.
module Snake.SafeIO
/// `FlatMap`'s inner program produces some type `X` that only its continuation knows. A union case cannot have a
/// type parameter of its own, so `X` is erased to `obj`; only `bind` builds a `FlatMap`, and it keeps the two in step.
type SafeIO<'a> =
| Pure of 'a
| Effect of IO.Op * (IO.OpResult -> SafeIO<'a>)
| FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>)
// Given: the same program, seen as a SafeIO<obj>. Constant work: continuations are wrapped, not called.
let rec private erase (io: SafeIO<'a>) : SafeIO<obj> =
match io with
| Pure a -> Pure(box a)
| Effect(op, k) -> Effect(op, fun r -> erase (k r))
| FlatMap(m, g) -> FlatMap(m, fun x -> erase (g x))
/// A SafeIO that does nothing and produces `a`: `Pure a`.
let pure (a: 'a) : SafeIO<'a> = failwith "TODO m07b"
/// A SafeIO that performs the single operation `op` and produces its answer: `Effect (op, pure)`.
let effect (op: IO.Op) : SafeIO<IO.OpResult> = failwith "TODO m07b"
/// "Run `io`, then continue with `f result`": only builds a node, `FlatMap (erase io, fun x -> f (unbox<'a> x))`.
/// Never calls `f`, and never looks inside `io`.
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
/// Transform the result of `io` with `f`, built from `bind` and `pure`. Never calls `f` while building.
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = failwith "TODO m07b"
/// Run the SafeIOs one after another, left to right, producing `()`. Either fold is fine here.
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = failwith "TODO m07b"
/// One rewrite towards a program whose top is a `Pure` or an `Effect`, with a constant amount of work:
/// FlatMap(Pure a, f) becomes f a
/// FlatMap(Effect(op, k), f) becomes Effect(op, fun r -> FlatMap(k r, f))
/// FlatMap(FlatMap(m, g), f) becomes FlatMap(m, fun x -> FlatMap(g x, f)) (re-association)
/// A `Pure` or an `Effect` is returned unchanged. No recursion: `step` looks at most two levels deep and calls no
/// function except `f` in the first rule. (The inner parts already have the erased types, so no `erase` is needed.)
let step (io: SafeIO<'a>) : SafeIO<'a> = failwith "TODO m07b"
// Given: the canned answer of the pure interpreter, as in m07. SafeIO keeps Module 7's two operations; the stored
// best score of Module 10 is not part of it, so its operations are refused loudly rather than given a meaning here.
let private answer (seed: uint32) (op: IO.Op) : IO.OpResult =
match op with
| IO.RandomSeed -> IO.Seed seed
| IO.Draw _ -> IO.Done
| IO.LoadBest
| IO.SaveBest _ -> failwith "SafeIO.trace answers only Module 7's operations (Draw and RandomSeed)"
/// The pure interpreter for tests. Answers every `RandomSeed` with `IO.Seed seed` and every `Draw` with `IO.Done`
/// (`answer seed op`). Returns the final value and the operations in the order they were performed.
/// Write it with an inner `let rec` that calls itself only in tail position (so the stack stays flat), one case per
/// constructor: a `Pure` is finished; an `Effect` is answered and continues with `k answer`, recording its op; a
/// `FlatMap` is rewritten once with `step`. Collect the ops at the front of a list and reverse it once at the end.
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> = failwith "TODO m07b"
+8
View File
@@ -10,17 +10,25 @@
<Compile Include="Model.fs" />
<Compile Include="Rules.fs" />
<Compile Include="Random.fs" />
<Compile Include="State.fs" />
<Compile Include="Spawn.fs" />
<Compile Include="Game.fs" />
<Compile Include="View.fs" />
<Compile Include="IO.fs" />
<Compile Include="SafeIO.fs" />
<Compile Include="Stream.fs" />
<Compile Include="Input.fs" />
<Compile Include="App.fs" />
<Compile Include="Runtime.fs" />
<Compile Include="Replay.fs" />
<Compile Include="Pong/Model.fs" />
<Compile Include="Pong/Rules.fs" />
<Compile Include="Pong/View.fs" />
<Compile Include="Pong/App.fs" />
<Compile Include="Life/Model.fs" />
<Compile Include="Life/Rules.fs" />
<Compile Include="Life/View.fs" />
<Compile Include="Life/App.fs" />
<Compile Include="Check.fs" />
</ItemGroup>
</Project>
+28
View File
@@ -0,0 +1,28 @@
/// m04 state (optional, Exercise 4.7). A `State<'s, 'a>` wraps 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.
module Snake.State
type State<'s, 'a> = State of ('s -> 'a * 's)
/// Run `st` from the initial state `s`: the value it produces and the final state.
let run (s: 's) (st: State<'s, 'a>) : 'a * 's = failwith "TODO m04"
/// A State that gives `a` and leaves the state untouched: `s -> (a, s)`.
let pure (a: 'a) : State<'s, 'a> = failwith "TODO m04"
/// Transform the value a State produces; the state threading is unchanged.
let map (f: 'a -> 'b) (st: State<'s, 'a>) : State<'s, 'b> = failwith "TODO m04"
/// Run `st`, feed its value to `f` to pick the next State, and run that with the state `st` left behind.
let bind (f: 'a -> State<'s, 'b>) (st: State<'s, 'a>) : State<'s, 'b> = failwith "TODO m04"
/// A State whose value is the current state, which it leaves untouched: `s -> (s, s)`.
/// A function of unit (use it as `get ()`), because F# does not allow a generic module-level value.
let get () : State<'s, 's> = failwith "TODO m04"
/// A State that replaces the state with `s` and gives `()`: whatever the old state, `((), s)`.
let put (s: 's) : State<'s, unit> = failwith "TODO m04"
/// A State that replaces the state `s` with `f s` and gives `()`.
let modify (f: 's -> 's) : State<'s, unit> = failwith "TODO m04"
+22 -1
View File
@@ -3,11 +3,13 @@
/// the next event. Nothing is mutated, so a stream value can be run any number of times with the same results.
module Snake.Stream
/// 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) or a key going down or up, and from
/// Module 13 a mouse click at `(x, y)` in canvas pixels. Every stream ignores the kinds of event it does not use.
type Event =
| Time of int64
| KeyDown of string
| KeyUp of string
| Click of int * int
type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>)
@@ -21,6 +23,17 @@ let keyDowns: Stream<string> = Stream(fun _ -> failwith "TODO m08")
/// Emits the `key` of every KeyUp event; ignores everything else.
let keyUps: Stream<string> = Stream(fun _ -> failwith "TODO m08")
/// (m13) Emits the position `(x, y)` of every Click event; ignores everything else.
/// Given: nothing to write here. It is the third source, built exactly like your `keyDowns`.
let clicks: Stream<int * int> =
let rec source () =
Stream(fun e ->
match e with
| Click(x, y) -> [ (x, y) ], source ()
| _ -> [], source ())
source ()
/// 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.
/// Hint: write a private helper that carries `last`.
@@ -45,5 +58,13 @@ let scan (f: 'b -> 'a -> 'b) (z: 'b) (s: Stream<'a>) : Stream<'b> = failwith "TO
/// Note: streams only act when an event arrives, so if no event ever arrives, `v` is never emitted.
let startWith (v: 'a) (s: Stream<'a>) : Stream<'a> = failwith "TODO m08"
/// (m10) Limit how far one Time event can move the clock that `s` sees, so a long silence (a background tab)
/// does not turn into hundreds of beats at once.
/// `s` 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). `s` is then stepped with `Time clock`. Key and click events are passed to `s` unchanged.
/// Hint: a private helper that carries `last` and `clock`, like the one for `interval`.
let clampTime (maxGap: int64) (s: Stream<'a>) : Stream<'a> = failwith "TODO m10"
/// Feed all events through the stream and collect every output, in order. A pure fold.
let run (events: Event list) (s: Stream<'a>) : 'a list = failwith "TODO m08"