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
+45
-2
@@ -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
|
||||
@@ -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) }
|
||||
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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" ]
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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 []
|
||||
@@ -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>
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Reference in new issue
Block a user