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

+45 -2
View File
@@ -2,11 +2,14 @@
module Snake.App
open Snake.Model
open Snake.View
open Snake.IO
open Snake.Stream
let TICK_MS = 150L
let FRAME_MS = 25L
// m10: the longest step of time the game accepts at once; a longer silence (a background tab) counts as this much.
let MAX_GAP_MS = 250L
// Everything that can happen to the game: a Tick every TICK_MS, and an action for each bound key.
let classicActions: Stream<Action> =
@@ -22,7 +25,47 @@ let states (seed: uint32) (actions: Stream<Action>) : Stream<GameState> =
let 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: 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.
type Session = { Game: GameState; Best: int; Improved: bool }
let sessionUpdate (session: Session) (action: Action) : Session =
let game = Game.update session.Game action
let best = max session.Best game.Score
{ Game = game; Best = best; Improved = best > session.Best }
// The same running fold as `states`, one level up.
let sessions (seed: uint32) (best: int) (actions: Stream<Action>) : Stream<Session> =
let s0 = { Game = Spawn.newGame seed; Best = best; Improved = false }
actions |> Stream.scan sessionUpdate s0 |> Stream.startWith s0
// The game's picture with the best score at the top right, level with the score on the left.
let sessionView (session: Session) : Scene =
View.view session.Game
@ [ Text(session.Game.Width * CELL - 6, 18, $"Best: {session.Best}", "#ffffff", 16, "right") ]
// Draw, and save the best only on the action that raised it.
let renderSession (session: Session) : IO<unit> =
if session.Improved then
IO.sequence [ 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.
let main () : IO<Stream<IO<unit>>> =
IO.randomSeed () |> IO.map (fun seed -> states seed speedActions |> Stream.map render)
IO.randomSeed ()
|> IO.bind (fun seed ->
IO.loadBest ()
|> IO.map (fun best -> sessions seed best speedActions |> Stream.map renderSession |> Stream.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).
let playWith (seed: uint32) (best: int) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list =
(IO.traceWith seed best main).Result
|> Stream.run events
|> List.map (fun io -> (IO.traceWith seed best io).Ops)
// The Module 9 helper is the "no stored best" case.
let play (seed: uint32) (events: Event list) (main: IO<Stream<IO<unit>>>) : Op list list = playWith seed 0 events main
+170
View File
@@ -0,0 +1,170 @@
/// 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, or the first one that did not.
type CheckResult<'a> =
| Passed of runs: int
| Failed of run: int * seed: uint32 * value: 'a
/// One of the elements of the non-empty list `xs`: the one at index `intBelow (List.length xs)`.
let elementOf (xs: 'a list) : Random<'a> =
Random.intBelow (List.length xs) |> Random.map (fun i -> List.item i xs)
/// `n` values drawn from `gen`, one after another; the seed is threaded from each draw to the next.
let rec listOf (n: int) (gen: Random<'a>) : Random<'a list> =
if n <= 0 then
Random.pure []
else
gen |> Random.bind (fun a -> listOf (n - 1) gen |> Random.map (fun rest -> a :: rest))
// 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).
let genActions (n: int) : Random<Action list> = listOf n (elementOf actionPool)
/// 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 variable. `go` calls itself last, so it runs in constant stack.
let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a> =
let rec go (i: int) (s: uint32) : CheckResult<'a> =
if i >= runs then
Passed runs
else
let value, next = Random.run s gen
if prop value then go (i + 1) next else Failed(i, s, value)
go 0 seed
/// Every list that is `xs` with exactly one element removed, in the order of the removed index.
let removals (xs: 'a list) : 'a list list = xs |> List.mapi (fun i _ -> List.removeAt i xs)
/// Keep removing one element for as long as the list still fails; the result is 1-minimal.
let rec minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list =
match List.tryFind fails (removals xs) with
| Some smaller -> minimize fails smaller
| None -> xs
let private neighbours (a: Point, b: Point) : bool = abs (a.X - b.X) + abs (a.Y - b.Y) = 1
let rec private allDifferent (ps: Point list) : bool =
match ps with
| [] -> true
| p :: rest -> not (Util.contains rest p (=)) && allDifferent rest
// Each invariant: its name, and a predicate that is true when the state is fine.
// `Option.forall` is true for `None`: no apple breaks nothing.
let private invariants: (string * (GameState -> bool)) list =
[ "snake-not-empty", (fun s -> not (List.isEmpty s.Snake))
"snake-in-bounds", (fun s -> s.Snake |> List.forall (Rules.inBounds s))
"snake-no-overlap", (fun s -> allDifferent s.Snake)
"snake-connected", (fun s -> s.Snake |> List.pairwise |> List.forall neighbours)
"apple-in-bounds", (fun s -> s.Apple |> Option.forall (Rules.inBounds s))
"apple-not-on-snake", (fun s -> s.Apple |> Option.forall (fun a -> not (Util.contains s.Snake a (=))))
"turn-not-reversed", (fun s -> s.NextDir <> opposite s.Dir)
"queued-not-reversed", (fun s -> s.Queued |> Option.forall (fun q -> q <> opposite s.NextDir)) ]
/// The names of the invariants that `state` breaks, in the order of the table above.
let violations (state: GameState) : string list =
invariants |> List.filter (fun (_, holds) -> not (holds state)) |> List.map fst
/// The numbers of states between consecutive moves of the snake's head.
let moveGaps (states: GameState list) : int list =
let moved =
states
|> List.pairwise
|> List.indexed
|> List.filter (fun (_, (before, after)) -> List.tryHead after.Snake <> List.tryHead before.Snake)
|> List.map (fun (i, _) -> i + 1)
moved |> List.pairwise |> List.map (fun (a, b) -> b - a)
// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
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 snake, head first: a random head, then a random walk that never leaves the board or crosses itself.
let genSnake (width: int) (height: int) (maxLen: int) : Random<Point list> =
// Extend `snake` at its tail by up to `left` more cells, each a random free in-bounds neighbour of the last cell.
let rec grow (snake: Point list) (left: int) : Random<Point list> =
if left <= 0 then
Random.pure snake
else
let last = List.last snake
let options =
directions
|> List.map (Rules.move last)
|> List.filter (fun p ->
p.X >= 0 && p.X < width && p.Y >= 0 && p.Y < height && not (Util.contains snake p (=)))
if List.isEmpty options then
Random.pure snake
else
elementOf options |> Random.bind (fun p -> grow (snake @ [ p ]) (left - 1))
Random.intBelow width
|> Random.bind (fun x -> Random.intBelow height |> Random.bind (fun y -> grow [ { X = x; Y = y } ] (maxLen - 1)))
/// A random sane Playing state on a `width` x `height` board (given).
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 })))))
/// The step of m03, 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.
let advanceSpec (state: GameState) : GameState =
if state.Status <> Playing then
state
else
let head = Rules.move (List.head state.Snake) state.NextDir
let eating = state.Apple = Some head
let snake = head :: (if eating then state.Snake else Util.dropLast state.Snake)
let broken =
violations { state with Snake = snake }
|> List.exists (fun v -> v = "snake-in-bounds" || v = "snake-no-overlap")
if broken then
{ state with Dir = state.NextDir; Status = GameOver }
else
{ state with
Snake = snake
Dir = state.NextDir
Apple = if eating then None else state.Apple
Score = state.Score + (if eating then 1 else 0) }
+27 -6
View File
@@ -1,19 +1,40 @@
/// 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.
module Snake.Game
open Snake.Model
/// What `update` does with `Turn dir` from m10 on. With no turn pending (`NextDir = Dir`) it is the m03 `Rules.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.
let steer (state: GameState) (dir: Direction) : GameState =
if state.Status <> Playing then state
elif state.NextDir = state.Dir then Rules.turn state dir
elif dir <> state.NextDir && dir <> opposite state.NextDir then { state with 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.
let private takeQueued (state: GameState) : GameState =
match state.Queued with
| Some d when state.Status = Playing -> { state with NextDir = d; Queued = None }
| _ -> state
// One game step as `update` takes it: `Spawn.tick`, then the queued turn.
let private 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.
let movesEvery (score: int) : int = 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.
let private frame (state: GameState) : GameState =
if state.Status <> Playing then
state
else
let counted = { state with Frame = state.Frame + 1 }
if counted.Frame % movesEvery counted.Score = 0 then Spawn.tick counted else counted
let n = state.Frame + 1
if n >= movesEvery state.Score then step { state with Frame = 0 } else { state with Frame = n }
// Pause and resume only make sense in those two statuses; a finished game stays finished.
let private togglePause (state: GameState) : GameState =
@@ -26,8 +47,8 @@ let private togglePause (state: GameState) : GameState =
// Every case is listed (no `| _ ->`): adding an Action later makes the compiler point here.
let update (state: GameState) (action: Action) : GameState =
match action with
| Tick -> Spawn.tick state
| Turn dir -> Rules.turn state dir
| Tick -> step state
| Turn dir -> steer state dir
| TogglePause -> togglePause state
| Restart -> Spawn.newGame state.Seed
| Frame -> frame state
+25 -4
View File
@@ -9,11 +9,16 @@ open Snake.View
type Op =
| Draw of Shape
| RandomSeed
// m10: the stored best score, read and written.
| LoadBest
| SaveBest of int
/// What the outside world answers: `Done` for a `Draw`, `Seed value` for a `RandomSeed`.
/// What the outside world answers: `Done` for a `Draw` or a `SaveBest`, `Seed value` for a `RandomSeed`,
/// `Best value` for a `LoadBest`.
type OpResult =
| Done
| Seed of uint32
| Best of int
type IO<'a> =
| Pure of 'a
@@ -47,13 +52,24 @@ let randomSeed () : IO<uint32> =
|> map (fun result ->
match result with
| Seed value -> value
| Done -> failwith "RandomSeed must be answered with a Seed")
| _ -> failwith "RandomSeed must be answered with a Seed")
let drawScene (scene: Scene) : IO<unit> = sequence (List.map draw scene)
// m10: the stored best score. Reading it answers a number (built like randomSeed); saving answers Done, like a draw.
let loadBest () : IO<int> =
effect LoadBest
|> map (fun result ->
match result with
| Best value -> value
| _ -> failwith "LoadBest must be answered with a Best")
let saveBest (score: int) : IO<unit> = effect (SaveBest score) |> map ignore
// 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.
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
// `best` (m10) answers LoadBest, the way `seed` answers RandomSeed.
let traceWith (seed: uint32) (best: int) (io: IO<'a>) : Traced<'a> =
let rec go io performed =
match io with
| Pure a -> { Result = a; Ops = List.rev performed }
@@ -61,8 +77,13 @@ let trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
let answer =
match op with
| RandomSeed -> Seed seed
| Draw _ -> Done
| LoadBest -> Best best
| Draw _
| SaveBest _ -> Done
go (k answer) (op :: performed)
go io []
// The Module 7 interpreter is the "no stored best" case.
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = traceWith seed 0 io
+44
View File
@@ -0,0 +1,44 @@
/// 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.Model
open Snake.IO
open Snake.Stream
open Snake.Life.Model
open Snake.Life.Rules
open Snake.Life.View
let GEN_MS = 150L
let lifeKey (key: string) : LifeAction option =
match key with
| " " -> Some LifeAction.TogglePause
| "n" | "N" -> Some LifeAction.Step
| "c" | "C" -> Some LifeAction.Clear
| "r" | "R" -> Some LifeAction.Randomize
| _ -> None
// Integer division rounds towards zero (-1 / 16 = 0), so the pixel is checked before dividing.
let lifeClick (x: int) (y: int) : LifeAction option =
if x >= 0 && y >= 0 && x / LCELL < LW && y / LCELL < LH then
Some(LifeAction.Toggle { X = x / LCELL; Y = y / LCELL })
else
None
let lifeActions: Stream<LifeAction> =
Stream.merge
(Stream.merge
(Stream.interval GEN_MS |> Stream.map (fun _ -> LifeAction.Frame))
(Stream.keyDowns |> Stream.choose lifeKey))
(Stream.clicks |> Stream.choose (fun (x, y) -> lifeClick x y))
let lifeStates (seed: uint32) (actions: Stream<LifeAction>) : Stream<LifeState> =
actions |> Stream.scan lifeUpdate (initialLife seed) |> Stream.startWith (initialLife seed)
let lifeMain () : IO<Stream<IO<unit>>> =
IO.randomSeed ()
|> IO.map (fun seed ->
lifeStates seed lifeActions
|> Stream.map (fun state -> IO.drawScene (lifeView state))
|> Stream.clampTime App.MAX_GAP_MS)
+32
View File
@@ -0,0 +1,32 @@
/// m13 life model: the whole world is one value, like the snake and the match.
module Snake.Life.Model
open Snake.Model
/// `Cells` is canonical: sorted row-major (by `Y`, then `X`), no duplicates, all on the board.
type LifeState =
{ Cells: Point list
Running: bool
Seed: uint32
Generation: int }
[<RequireQualifiedAccess>]
type LifeAction =
| Frame
| Toggle of Point
| TogglePause
| Step
| Clear
| Randomize
// The board, in cells: LW columns, LH rows, LCELL pixels per cell. Below it the page shows a status strip.
let LW = 40
let LH = 30
let LCELL = 16
let STRIP = 24
let initialLife (seed: uint32) : LifeState =
{ Cells = []
Running = false
Seed = seed
Generation = 0 }
+71
View File
@@ -0,0 +1,71 @@
/// 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
// Every cell of the board, row-major (y, then x). Filtering this list keeps any result in canonical order for free.
let private board: Point list =
[ for y in 0 .. LH - 1 do
for x in 0 .. LW - 1 -> { X = x; Y = y } ]
let private onBoard (p: Point) : bool = 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.
let private index (p: Point) : int = p.Y * LW + p.X
let private offsets = [ (-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).
let private neighbours (p: Point) : Point list =
offsets |> List.map (fun (dx, dy) -> { X = p.X + dx; Y = p.Y + dy }) |> List.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. The set is built once and only read.
let lifeStep (cells: Point list) : Point list =
let alive = Set.ofList cells
let count p = neighbours p |> List.filter alive.Contains |> List.length
board
|> List.filter (fun p ->
let n = count p
n = 3 || (n = 2 && alive.Contains p))
// Add a dead cell or remove a live one, keeping the list sorted row-major without duplicates. Off the board: unchanged.
let private toggle (cells: Point list) (p: Point) : Point list =
if not (onBoard p) then
cells
elif List.contains p cells then
List.filter (fun c -> c <> p) cells
else
List.filter (fun c -> index c < index p) cells
@ [ p ]
@ List.filter (fun c -> index c > index p) cells
// A random board: one `intBelow 4` per cell in row-major order, the cell alive when the draw is 0 (about one cell in
// four). mapFold threads the seed through the 1200 draws; the result is the cells and the seed after the last draw.
let private randomCells (seed: uint32) : Point list * uint32 =
let draws, next = List.mapFold (fun s _ -> Random.run s (Random.intBelow 4)) seed board
List.zip board draws |> List.filter (fun (_, d) -> d = 0) |> List.map fst, next
let private nextGeneration (state: LifeState) : LifeState =
{ state with
Cells = lifeStep state.Cells
Generation = state.Generation + 1 }
let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState =
match action with
| LifeAction.Frame -> if state.Running then nextGeneration state else state
| LifeAction.Step -> if state.Running then state else nextGeneration state
| LifeAction.TogglePause -> { state with Running = not state.Running }
// Clicking edits the world whether it runs or not: the click is one action between two generations.
| LifeAction.Toggle p -> { state with Cells = toggle state.Cells p }
| LifeAction.Clear -> { state with Cells = []; Generation = 0 }
| LifeAction.Randomize ->
let cells, seed = randomCells state.Seed
{ state with
Cells = cells
Seed = seed
Generation = 0 }
+17
View File
@@ -0,0 +1,17 @@
/// 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
let private text x y str align = Text(x, y, str, "#ffffff", 14, align)
let private W = LW * LCELL
let private H = LH * LCELL
let lifeView (state: LifeState) : Scene =
[ Rect(0, 0, W, H, "#111111") ]
@ (state.Cells |> List.map (fun p -> Rect(p.X * LCELL + 1, p.Y * LCELL + 1, LCELL - 2, LCELL - 2, "#7cb342")))
@ [ Rect(0, H, W, STRIP, "#222222")
text 6 (H + 17) $"Generation {state.Generation}" "left"
text (W - 6) (H + 17) (if state.Running then "Running: Space pauses" else "Paused: Space runs, N steps") "right" ]
+5 -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 }
let delta (dir: Direction) : Point =
match dir with
@@ -61,4 +63,5 @@ let initialState (seed: uint32) : GameState =
Seed = seed
Score = 0
Status = Playing
Frame = 0 }
Frame = 0
Queued = None }
+4 -1
View File
@@ -33,4 +33,7 @@ let pongStates (seed: uint32) (actions: Stream<PongAction>) : Stream<PongState>
let pongMain () : IO<Stream<IO<unit>>> =
IO.randomSeed ()
|> IO.map (fun seed -> pongStates seed pongActions |> Stream.map (fun state -> IO.drawScene (pongView state)))
|> IO.map (fun seed ->
pongStates seed pongActions
|> Stream.map (fun state -> IO.drawScene (pongView state))
|> Stream.clampTime App.MAX_GAP_MS)
+2 -1
View File
@@ -89,5 +89,6 @@ let pongUpdate (state: PongState) (action: PongAction) : PongState =
match action with
| PongAction.Press key -> setHeld state key true
| PongAction.Release key -> setHeld state key false
| PongAction.Restart -> initialPong state.Seed
// A new match, but the keys are still where the player's fingers are.
| PongAction.Restart -> { initialPong state.Seed with Held = state.Held }
| PongAction.Frame -> frame state
+96
View File
@@ -0,0 +1,96 @@
/// 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
type Recording = { Seed: uint32; Actions: Action list }
type Viewer = { Index: int; Last: int; Playing: bool }
[<RequireQualifiedAccess>]
type ViewerAction =
| Next
| Prev
| First
| Last
| TogglePlay
| Beat
let REPLAY_MS = 100L
// Every state of the recorded game, from the new game to the end: the recording replayed with `history`.
let frames (recording: Recording) : GameState list =
Game.history (Spawn.newGame recording.Seed) recording.Actions
let viewerStart (last: int) : Viewer = { Index = 0; Last = last; Playing = false }
// Where each action moves the viewer, and whether it plays afterwards (before the end-of-recording rule).
let private move (v: Viewer) (action: ViewerAction) : Viewer =
let clamp i = min v.Last (max 0 i)
match action with
| ViewerAction.Next -> { v with Index = clamp (v.Index + 1) }
| ViewerAction.Prev -> { v with Index = clamp (v.Index - 1) }
| ViewerAction.First -> { v with Index = 0 }
| ViewerAction.Last -> { v with Index = v.Last }
// Play from the end means: watch it again from the start.
| ViewerAction.TogglePlay ->
if v.Playing then
{ v with Playing = false }
else
{ v with
Index = (if v.Index = v.Last then 0 else v.Index)
Playing = true }
| ViewerAction.Beat -> if v.Playing then { v with Index = clamp (v.Index + 1) } else v
// The index never leaves [0, Last], and the viewer never plays on the last frame, so autoplay stops by itself.
let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer =
let next = move viewer action
{ next with Playing = next.Playing && next.Index < next.Last }
let viewerKey (key: string) : ViewerAction option =
match key with
| "ArrowRight" | "d" | "D" -> Some ViewerAction.Next
| "ArrowLeft" | "a" | "A" -> Some ViewerAction.Prev
| "Home" -> Some ViewerAction.First
| "End" -> Some ViewerAction.Last
| " " -> Some ViewerAction.TogglePlay
| _ -> None
// The game's own picture of the current frame, then the position at the top right and a hint at the bottom.
let viewerView (frames: GameState list) (viewer: Viewer) : Scene =
let game = List.item viewer.Index frames
let w = game.Width * CELL
let hint =
if viewer.Playing then
"Space: pause"
else
"Left/Right: step Home/End: jump Space: play"
View.view game
@ [ Text(w - 6, 18, $"step {viewer.Index} / {viewer.Last}", "#ffffff", 16, "right")
Text(w / 2, game.Height * CELL - 8, hint, "#aaaaaa", 12, "center") ]
let replayActions: Stream<ViewerAction> =
Stream.merge
(Stream.interval REPLAY_MS |> Stream.map (fun _ -> ViewerAction.Beat))
(Stream.keyDowns |> Stream.choose viewerKey)
let viewerStates (last: int) (actions: Stream<ViewerAction>) : Stream<Viewer> =
actions |> Stream.scan viewerUpdate (viewerStart last) |> Stream.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.
let replayMain (recording: Recording) : IO<Stream<IO<unit>>> =
let fs = frames recording
IO.pure (
viewerStates (List.length fs - 1) replayActions
|> Stream.map (fun v -> IO.drawScene (viewerView fs v))
|> Stream.clampTime App.MAX_GAP_MS
)
+12 -1
View File
@@ -11,14 +11,23 @@ type Platform =
{ FillRect: int -> int -> int -> int -> string -> unit
FillText: int -> int -> string -> string -> int -> string -> unit
RandomSeed: unit -> uint32
// m10: the stored best score; reading must never fail (anything unreadable is 0).
LoadBest: unit -> int
SaveBest: int -> unit
StartClock: (int64 -> unit) -> unit
OnKeyDown: (string -> unit) -> unit
OnKeyUp: (string -> unit) -> unit }
OnKeyUp: (string -> unit) -> unit
// m13: clicks on the canvas, in canvas pixels.
OnClick: (int -> int -> unit) -> unit }
// Perform one op on the platform and return its answer.
let private perform (p: Platform) (op: Op) : OpResult =
match op with
| RandomSeed -> Seed(p.RandomSeed())
| LoadBest -> Best(p.LoadBest())
| SaveBest score ->
p.SaveBest score
Done
| Draw(Rect(x, y, w, h, color)) ->
p.FillRect x y w h color
Done
@@ -43,4 +52,6 @@ let runApp (p: Platform) (main: IO<Stream<IO<unit>>>) : unit =
p.OnKeyDown(fun key -> feed (KeyDown key))
p.OnKeyUp(fun key -> feed (KeyUp key))
// m13: a fourth kind of event. Programs that do not listen for clicks simply ignore them.
p.OnClick(fun x y -> feed (Click(x, y)))
p.StartClock(fun t -> feed (Time t))
+65
View File
@@ -0,0 +1,65 @@
/// m07b safeio (optional): a stack-safe IO. Same idea as m07's IO, plus one more case, so that `bind` only builds
/// data and no program shape can overflow the stack, however deeply its `bind`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 result`
/// The ops, their answers and the result of `trace` are m07's `IO.Op`, `IO.OpResult` and `IO.Traced`.
/// (Not `open Snake.IO`: its cases are also called Pure and Effect.)
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))
let pure (a: 'a) : SafeIO<'a> = Pure a
let effect (op: IO.Op) : SafeIO<IO.OpResult> = Effect(op, pure)
// Only builds a node: never calls f, not even when io is Pure. The unbox cannot fail: x is the result of `io`.
let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = FlatMap(erase io, fun x -> f (unbox<'a> x))
let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = io |> bind (fun a -> Pure(f a))
// A left fold on purpose: with SafeIO the nesting no longer matters.
let sequence (ios: SafeIO<unit> list) : SafeIO<unit> = List.fold (fun acc io -> acc |> bind (fun () -> io)) (pure ()) ios
// 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, fun r -> FlatMap(k r, f))
// FlatMap(FlatMap(m, g), f) -> FlatMap(m, fun x -> FlatMap(g x, f)) (re-association)
// Pure and Effect are already in that shape and come back unchanged.
let step (io: SafeIO<'a>) : SafeIO<'a> =
match io with
| FlatMap(Pure x, f) -> f x
| FlatMap(Effect(op, k), f) -> Effect(op, fun r -> FlatMap(k r, f))
| FlatMap(FlatMap(m, g), f) -> FlatMap(m, fun x -> FlatMap(g x, f))
| other -> other
// 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)"
// `loop` calls itself only in tail position, so the compiler turns it into a loop: the stack stays flat.
let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> =
let rec loop (current: SafeIO<'a>) (performed: IO.Op list) : IO.Traced<'a> =
match current with
| Pure a -> { IO.Result = a; IO.Ops = List.rev performed }
| Effect(op, k) -> loop (k (answer seed op)) (op :: performed)
| FlatMap _ -> loop (step current) performed
loop io []
+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>
+27
View File
@@ -0,0 +1,27 @@
/// m04 state (optional, Exercise 4.7): the general pattern behind Random. A `State<'s, 'a>` wraps a function
/// `s -> (value, nextS)`: it reads a state, produces a value and hands on a new state. `Random<'a>` is
/// `State<uint32, 'a>`. Nothing in the game uses this file; it is here to show that Random was one example of a bigger idea.
module Snake.State
type State<'s, 'a> = State of ('s -> 'a * 's)
let run (s: 's) (State f: State<'s, 'a>) : 'a * 's = f s
let pure (a: 'a) : State<'s, 'a> = State(fun s -> (a, s))
let map (f: 'a -> 'b) (st: State<'s, 'a>) : State<'s, 'b> =
State(fun s ->
let a, next = run s st
(f a, next))
let bind (f: 'a -> State<'s, 'b>) (st: State<'s, 'a>) : State<'s, 'b> =
State(fun s ->
let a, next = run s st
run next (f a))
// A function of unit: a generic module-level value is not allowed in F#.
let get () : State<'s, 's> = State(fun s -> (s, s))
let put (s: 's) : State<'s, unit> = State(fun _ -> ((), s))
let modify (f: 's -> 's) : State<'s, unit> = State(fun s -> ((), f s))
+30 -1
View File
@@ -2,11 +2,13 @@
/// There is no hidden state: whatever a stream "remembers" lives in the next stream it returns.
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>)
@@ -31,6 +33,16 @@ let keyUps: Stream<string> =
source ()
// (m13, given in src/) The position of every Click event, in canvas pixels. Like the key sources it remembers nothing.
let clicks: Stream<int * int> =
let rec source () =
Stream(fun e ->
match e with
| Click(x, y) -> [ (x, y) ], source ()
| _ -> [], source ())
source ()
// `last` is the time of the previous Time event. Multiples of ms in (last, t] fire, ascending.
let rec private intervalFrom (ms: int64) (last: int64) : Stream<int64> =
Stream(fun e ->
@@ -76,6 +88,23 @@ let startWith (v: 'a) (s: Stream<'a>) : Stream<'a> =
let out, next = step e s
v :: out, next)
// (m10) `last` is the previous real time, `clock` the time the inner stream has been told. A long silence
// (a background tab) moves `clock` by at most `maxGap`, so the inner stream never has to catch up on minutes of beats.
let rec private clampFrom (maxGap: int64) (last: int64) (clock: int64) (s: Stream<'a>) : Stream<'a> =
Stream(fun e ->
match e with
| Time t ->
let now = clock + min (max (t - last) 0L) maxGap
let out, next = step (Time now) s
out, clampFrom maxGap t now next
| KeyDown _
| KeyUp _
| Click _ ->
let out, next = step e s
out, clampFrom maxGap last clock next)
let clampTime (maxGap: int64) (s: Stream<'a>) : Stream<'a> = clampFrom maxGap 0L 0L s
// mapFold threads the stream through the events like fold, and keeps each step's outputs.
let run (events: Event list) (s: Stream<'a>) : 'a list =
List.mapFold (fun stream e -> step e stream) s events |> fst |> List.concat