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

+51 -1
View File
@@ -1,5 +1,6 @@
import { todo } from './todo.js'
import { pure } from './io.js'
/** @import { Action, GameState, Session, Scene, IO, Stream, Input, Op, Program } from './types' */
/** Milliseconds between game steps. */
export const TICK_MS = 150
@@ -7,30 +8,79 @@ export const TICK_MS = 150
/** Milliseconds between animation frames (m10). */
export const FRAME_MS = 25
/** (m10) The longest step of time the game accepts at once, in milliseconds (see `clampTime` in stream.js). */
export const MAX_GAP_MS = 250
/**
* Stream of everything that can happen to the game: `Action.Tick` every TICK_MS (from `interval`),
* merged with the `Action` for each key press that `keyToAction` recognises (from `keyDowns`).
* @type {Stream<Action>}
*/
export const classicActions = { step: () => todo('m09') }
/**
* (m10) Like `classicActions`, but the clock emits `Action.Frame` every FRAME_MS instead of `Tick` every TICK_MS;
* key presses are translated by `keyToAction` as before (now including pause and restart).
* @type {Stream<Action>}
*/
export const speedActions = { step: () => todo('m10') }
/**
* Fold a stream of actions into a stream of game states: start from `newGame(seed)`, apply `update` to each action
* (scan), and emit the initial state first (startWith) so there is something to draw before anything happens.
* @type {(seed: number, actions: Stream<Action>) => Stream<GameState>}
*/
export const states = (seed, actions) => todo('m09')
/** The drawing of one state, as an IO: `drawScene(view(state))`. */
/** The drawing of one state, as an IO: `drawScene(view(state))`.
* @type {(state: GameState) => IO<undefined>} */
export const render = (state) => todo('m09')
/**
* (m10) Session = `{game, best, improved}`: the game, the best score so far, and whether the action that produced
* this session raised the best. One action: `game' = update(game, action)`, `best' = max(best, game'.score)`,
* `improved = best' > best` (strictly greater: equalling the record is not a new record).
* @type {(session: Session, action: Action) => Session}
*/
export const sessionUpdate = (session, action) => todo('m10')
/**
* (m10) Like `states`, one level up: `startWith(scan(actions, sessionUpdate, s0), s0)`
* with `s0 = {game: newGame(seed), best, improved: false}`.
* @type {(seed: number, best: number, actions: Stream<Action>) => Stream<Session>}
*/
export const sessions = (seed, best, actions) => todo('m10')
/**
* (m10) `view(session.game)` followed by one more shape: the text `Best: <best>` at the top right,
* `{tag:'Text', x: game.width * CELL - 6, y: 18, text: 'Best: 3', color: '#ffffff', size: 16, align: 'right'}`.
* @type {(session: Session) => Scene}
*/
export const sessionView = (session) => todo('m10')
/**
* (m10) `drawScene(sessionView(session))`, followed by `saveBest(session.best)` when `session.improved`
* (and only then).
* @type {(session: Session) => IO<undefined>}
*/
export const renderSession = (session) => todo('m10')
/**
* The whole program as a value, `IO<Stream<IO<unit>>>`: 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`).
* (m10) The whole stream is wrapped in `clampTime(..., MAX_GAP_MS)`.
* (m10, Exercise 10.7) After the seed, ask for the stored best (`loadBest`), and draw the stream of
* `sessions(seed, best, speedActions)` with `renderSession` instead of the states with `render`.
* Nothing runs here.
* @type {Program}
*/
export const main = pure({ step: () => todo('m09') })
/**
* (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 `trace(main, seed)`, `run` the
* stream it produces on `events`, and `trace` each emitted IO, keeping its `ops`.
* (m10) Pass `best` to both traces as well, so that `LoadBest` is answered with it.
* @type {(main: Program, seed: number, events: Input[], best?: number) => Op[][]}
*/
export const play = (main, seed, events, best = 0) => todo('m09')
+25
View File
@@ -1,4 +1,5 @@
import { todo } from './todo.js'
/** @import { Point, Platform } from './types' */
/**
* Build a Platform from a canvas and a window (see runtime.js).
@@ -6,5 +7,29 @@ import { todo } from './todo.js'
* - startClock calls `onTime(t)` on every animation frame (requestAnimationFrame), `t` = ms since the first frame.
* - onKeyDown / onKeyUp listen on `win`; call `preventDefault()` for arrow keys and space so the page does not scroll.
* - randomSeed uses `crypto.getRandomValues(new Uint32Array(1))[0]`.
* - (m10) loadBest reads `win.localStorage` key `'pure-snake-best'` and returns it as a number; anything missing or
* unreadable (no entry, anything but plain decimal digits with a value of at most 2147483647, storage blocked so that access throws) counts as 0.
* saveBest(score) writes `String(score)` under the same key. Neither may ever throw.
* - (m13) onClick(cb) listens for `click` on the canvas (not on `win`) and calls `cb(x, y)` with the position in canvas
* pixels: `canvasPoint(canvas, e.clientX, e.clientY)` below does the conversion. Add the listener inside `onClick`,
* not when the platform is built.
* @type {(canvas: HTMLCanvasElement, win: Window) => Platform}
*/
export const browserPlatform = (canvas, win) => todo('m09')
/**
* (m13, given) Where a mouse event hit the canvas, in canvas pixels, as whole numbers.
* The page may show the canvas at another size than its `width` x `height` (CSS scaling, zoom) and draws a border
* around it, so the event's window coordinates (`clientX`, `clientY`) are first made relative to the drawing area
* (`getBoundingClientRect` minus the border, `clientLeft`/`clientTop`) and then scaled by the canvas size over its
* shown size (`clientWidth`/`clientHeight`).
* @type {(canvas: HTMLCanvasElement, clientX: number, clientY: number) => Point}
*/
export const canvasPoint = (canvas, clientX, clientY) => {
const r = canvas.getBoundingClientRect()
return {
x: Math.floor(((clientX - r.left - canvas.clientLeft) * canvas.width) / canvas.clientWidth),
y: Math.floor(((clientY - r.top - canvas.clientTop) * canvas.height) / canvas.clientHeight),
}
}
+136
View File
@@ -0,0 +1,136 @@
import { todo } from './todo.js'
import { Action, pointEquals, opposite } from './model.js'
import { move } from './rules.js'
import { freeCells } from './spawn.js'
import { pure, map, flatMap, intBelow } from './random.js'
/** @import { Point, Direction, GameState, Random, CheckResult } from './types' */
/**
* 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.js (`seed -> [A, nextSeed]`).
*/
/**
* A Random that gives one of the elements of the non-empty array `xs`: the element at index `i`,
* where `i` is drawn with `intBelow(xs.length)`.
* @type {<A>(xs: A[]) => Random<A>}
*/
export const elementOf = (xs) => todo('m12')
/**
* A Random that gives an array of `n` values drawn from `gen`, one after another: the first draw starts from the
* incoming seed, each later draw from the seed the previous one left behind, and the last draw's seed is the result's.
* `listOf(gen, 0)` gives `[]` and leaves the seed alone. No loop: recursion on `n`, with `flatMap` and `map`.
* @type {<A>(gen: Random<A>, n: number) => Random<A[]>}
*/
export const listOf = (gen, n) => todo('m12')
// Ticks appear three times so that the snake moves more often than it turns.
const actionPool = [
Action.Tick, Action.Tick, Action.Tick,
Action.Turn('Up'), Action.Turn('Down'), Action.Turn('Left'), Action.Turn('Right'),
]
/** A generator of `n` random actions (given; it works once `elementOf` and `listOf` do).
* @type {(n: number) => Random<Action[]>} */
export const genActions = (n) => listOf(elementOf(actionPool), n)
/**
* Check the property `prop` (a function value -> boolean) on `runs` values drawn from `gen`.
* The first value is drawn from `seed`, each later one from the seed the previous draw left behind.
* Stop at the first value for which `prop` is false.
* Result: `{ ok: true, runs }` when every value passed, otherwise `{ ok: false, 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 `run(gen, seed)[0]` gives `value` again).
* @type {<A>(gen: Random<A>, prop: (a: A) => boolean, runs: number, seed: number) => CheckResult<A>}
*/
export const forAll = (gen, prop, runs, seed) => 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 `[]`.
* @type {<A>(xs: A[]) => A[][]}
*/
export const removals = (xs) => todo('m12')
/**
* Shrink a failing list. `fails` is a function list -> boolean and `fails(xs)` is true.
* If some list in `removals(xs)` still fails, continue with the first one that does; when none fails, return `xs`.
* The result is "1-minimal": it fails, and removing any single element makes it pass.
* @type {<A>(xs: A[], fails: (xs: A[]) => boolean) => A[]}
*/
export const minimize = (xs, fails) => todo('m12')
/**
* The names of the invariants that `state` breaks, in this order (an empty array means the state is sane):
* 'snake-not-empty' the snake has at least one cell
* 'snake-in-bounds' every cell of the snake is on the board
* 'snake-no-overlap' no two cells of the snake are equal
* 'snake-connected' each cell of the snake is exactly one step (up, down, left or right) from the one before it
* 'apple-in-bounds' the apple, if there is one, is on the board
* 'apple-not-on-snake' the apple, if there is one, is not on a cell of the snake
* 'turn-not-reversed' `nextDir` is not the opposite of `dir`
* 'queued-not-reversed' `queued` is null, or is not the opposite of `nextDir`
* @type {(state: GameState) => string[]}
*/
export const violations = (state) => 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 `[]`.
* @type {(states: GameState[]) => number[]}
*/
export const moveGaps = (states) => todo('m12')
/**
* (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 `intBelow(width)`, then `y` with `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 (`move(last, dir)`), and keep those that are on the board and not yet in the snake.
* If none is left, stop early. Otherwise append the one picked with `elementOf(neighbours)` and go on.
* So the result is always on the board, connected and free of overlap. No loop: recursion, with `flatMap`.
* @type {(width: number, height: number, maxLen: number) => Random<Point[]>}
*/
export const genSnake = (width, height, maxLen) => todo('m12')
/** @type {Direction[]} */
const directions = ['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).
const headingOf = (snake) =>
snake.length < 2 ? 'Right' : /** @type {Direction} */ (directions.find((d) => pointEquals(move(snake[1], d), snake[0])))
/**
* 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 `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 `intBelow(2)`: 0 means no apple, 1 means an apple at `elementOf(freeCells)` (no apple if no cell is free).
* `dir` is the direction from the snake's second cell to its head ('Right' for a one-cell snake); seed, score and
* frame are 0, `queued` is null.
* @type {(width: number, height: number) => Random<GameState>}
*/
export const genState = (width, height) =>
flatMap(intBelow(width * height), (n) =>
flatMap(genSnake(width, height, n + 1), (snake) => {
const dir = headingOf(snake)
const free = freeCells({ width, height, snake })
return flatMap(elementOf(directions.filter((d) => d !== opposite(dir))), (nextDir) =>
flatMap(intBelow(2), (coin) =>
map(coin === 0 || free.length === 0 ? pure(null) : elementOf(free), (apple) => ({
width, height, snake, dir, nextDir, apple, seed: 0, score: 0, status: 'Playing', frame: 0, queued: null,
}))))
}))
/**
* (Exercise 12.7) The step of Module 3, written straight from the rules, as a second opinion on `advance`.
* Not Playing: the state unchanged. Otherwise build the snake the step would produce: the new head
* (`move(head, nextDir)`) in front, followed by the old snake, without its last cell unless the new head is on the
* apple. Then ask `violations` about the state with that snake: if the list contains 'snake-in-bounds' or
* 'snake-no-overlap', the result is Module 3's game over (`dir: nextDir`, status 'GameOver', nothing else changed);
* otherwise the state with the new snake, `dir: nextDir`, and, when eating, the apple gone and the score one higher.
* Do not call `advance`. On every sane state the result must equal `advance(state)`.
* @type {(state: GameState) => GameState}
*/
export const advanceSpec = (state) => todo('m12')
+25 -4
View File
@@ -1,8 +1,21 @@
import { todo } from './todo.js'
/** @import { Direction, Action, GameState } from './types' */
/**
* (m10, Exercise 10.6) What `update` does with `Turn(dir)` once the turn queue is in, so that a quick Up-then-Left
* within one step is not lost:
* - not Playing: the state unchanged;
* - no turn pending (`nextDir == dir`): `turn(state, dir)`, the Module 3 rule;
* - a turn pending (`nextDir != dir`): if `dir` is neither `nextDir` nor `opposite(nextDir)`, the state with
* `queued: dir` (a later key replaces an earlier queued one); otherwise the state unchanged.
* @type {(state: GameState, dir: Direction) => GameState}
*/
export const steer = (state, dir) => todo('m10')
/**
* How many frames pass between game steps (m10): `max(2, 8 - floor(score / 3))`.
* Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2.
* @type {(score: number) => number}
*/
export const movesEvery = (score) => todo('m10')
@@ -14,13 +27,21 @@ export const movesEvery = (score) => todo('m10')
*
* (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status.
* (m10) `Restart` gives `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 (`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 `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 `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 not null,
* the result gets `nextDir: queued` and `queued: null`. A result that is not Playing keeps `queued` as it is.
* @type {(state: GameState, action: Action) => GameState}
*/
export const update = (state, action) => todo('m05')
/** Apply all actions in order, starting from `state`, and return the final state. */
/** Apply all actions in order, starting from `state`, and return the final state.
* @type {(state: GameState, actions: Action[]) => GameState} */
export const replay = (state, actions) => todo('m05')
/** Like `replay`, but keep every state: `[state, afterFirst, afterSecond, ...]`, so its length is `actions.length + 1`. */
/** Like `replay`, but keep every state: `[state, afterFirst, afterSecond, ...]`, so its length is `actions.length + 1`.
* @type {(state: GameState, actions: Action[]) => GameState[]} */
export const history = (state, actions) => todo('m05')
+2
View File
@@ -1,9 +1,11 @@
import { todo } from './todo.js'
/** @import { Action } from './types' */
/**
* Translate a keyboard key name (as in `KeyboardEvent.key`) into the game action it triggers, or `null`.
* m09: `ArrowUp`/`w`/`W` -> `Action.Turn('Up')`, `ArrowDown`/`s`/`S` -> Down, `ArrowLeft`/`a`/`A` -> Left,
* `ArrowRight`/`d`/`D` -> Right. Any other key -> `null`.
* (m10) `' '` (Space) -> `Action.TogglePause`, `r`/`R` -> `Action.Restart`.
* @type {(key: string) => Action | null}
*/
export const keyToAction = (key) => todo('m09')
+33 -9
View File
@@ -1,43 +1,67 @@
import { todo } from './todo.js'
/** @import { Op, Shape, Scene, IO, Trace } from './types' */
/**
* Effects as values. An `IO<A>` is a description of a program that, when interpreted, produces an `A`:
* `{tag:'Pure', value}` - finished, nothing left to do
* `{tag:'Effect', op, k}` - perform `op`, then continue with `k(result)` (which returns another IO)
* Operations: `{tag:'Draw', shape}` (result `undefined`) and `{tag:'RandomSeed'}` (result: a uint32 number).
* (m10) Two more: `{tag:'LoadBest'}` (result: the stored best score, a number) and `{tag:'SaveBest', score}`
* (result `undefined`).
* Building an IO never performs anything; only an interpreter (`trace` here, `runIO` in m09) does.
*/
/** An IO that does nothing and produces `a`. */
/** An IO that does nothing and produces `a`.
* @type {<A>(a: A) => IO<A>} */
export const pure = (a) => todo('m07')
/** An IO that performs the single operation `op` and produces its result: `{tag:'Effect', op, k: pure}`. */
/** An IO that performs the single operation `op` and produces its result: `{tag:'Effect', op, k: pure}`.
* @type {<A>(op: Op) => IO<A>} */
export const effect = (op) => todo('m07')
/** Transform the result of `io` with `f`. Must not call `f` until the IO is interpreted. */
/** Transform the result of `io` with `f`. Must not call `f` until the IO is interpreted.
* @type {<A, B>(io: IO<A>, f: (a: A) => B) => IO<B>} */
export const map = (io, f) => todo('m07')
/**
* Run `io`, then feed its result to `f` to get the next IO.
* Pure: `f(value)`. Effect(op, k): the same op, with the continuation extended by `f`.
* @type {<A, B>(io: IO<A>, f: (a: A) => IO<B>) => IO<B>}
*/
export const flatMap = (io, f) => todo('m07')
/** Run the IOs one after another, left to right, producing `undefined`. `sequence([])` performs nothing. */
/** Run the IOs one after another, left to right, producing `undefined`. `sequence([])` performs nothing.
* @type {(ios: IO<unknown>[]) => IO<undefined>} */
export const sequence = (ios) => todo('m07')
/** An IO that draws one shape (a `Rect` or `Text` from m06). */
/** An IO that draws one shape (a `Rect` or `Text` from m06).
* @type {(shape: Shape) => IO<undefined>} */
export const draw = (shape) => todo('m07')
/** An IO that asks the outside world for a random uint32 seed. A value, not a function. */
export const randomSeed = { get tag() { return todo('m07') } }
/** An IO that asks the outside world for a random uint32 seed. A value, not a function.
* (The getters only exist so the stub loads; replace the whole object with your IO.)
* @type {IO<number>} */
export const randomSeed = { get tag() { return todo('m07') }, get op() { return todo('m07') }, get k() { return todo('m07') } }
/** Draw every shape of a scene in order: `sequence(scene.map(draw))`. */
/** Draw every shape of a scene in order: `sequence(scene.map(draw))`.
* @type {(scene: Scene) => IO<undefined>} */
export const drawScene = (scene) => todo('m07')
/** (m10) An IO that reads the stored best score: `effect({tag:'LoadBest'})`. A value, not a function.
* (The getters only exist so the stub loads; replace the whole object with your IO.)
* @type {IO<number>} */
export const loadBest = { get tag() { return todo('m10') }, get op() { return todo('m10') }, get k() { return todo('m10') } }
/** (m10) An IO that stores `score` as the new best: `effect({tag:'SaveBest', score})`.
* @type {(score: number) => IO<undefined>} */
export const saveBest = (score) => todo('m10')
/**
* A pure interpreter for tests. Answers every `RandomSeed` with `seed` and every `Draw` with `undefined`.
* (m10) Also answers every `LoadBest` with `best` (default 0) and every `SaveBest` with `undefined`;
* in m07 you can leave `best` out.
* Returns `{result, ops}`: the final value and the operations in the order they were executed.
* Implemented by recursion, with no mutation.
* @type {<A>(io: IO<A>, seed: number, best?: number) => Trace<A>}
*/
export const trace = (io, seed) => todo('m07')
export const trace = (io, seed, best = 0) => todo('m07')
+38
View File
@@ -0,0 +1,38 @@
import { todo } from '../todo.js'
import { pure } from '../io.js'
/** @import { LifeAction, LifeState, Stream, Program } from '../types' */
/** Milliseconds between two generations while the world runs. */
export const GEN_MS = 150
/**
* A key press to a `LifeAction`, or `null`: `' '` -> TogglePause, `n`/`N` -> Step, `c`/`C` -> Clear,
* `r`/`R` -> Randomize. Any other key -> `null` (watch out for inherited names such as `constructor`).
* @type {(key: string) => LifeAction | null}
*/
export const lifeKey = (key) => todo('m13')
/**
* A click at canvas pixel `(x, y)` to `Toggle` of the cell under it (`x / LCELL` and `y / LCELL`, rounded down),
* or `null` when the pixel is not on the board (left of it, above it, right of it, or in the status strip below it).
* @type {(x: number, y: number) => LifeAction | null}
*/
export const lifeClick = (x, y) => todo('m13')
/**
* Everything that can happen to the world: `Frame` every GEN_MS (from `interval`), merged with `lifeKey` of every key
* press, merged with `lifeClick` of every click (`clicks` from `../stream.js`), in that order.
* @type {Stream<LifeAction>}
*/
export const lifeActions = { step: () => todo('m13') }
/** Fold the actions into a stream of states: start from `initialLife(seed)`, `lifeUpdate` for each action, emit the initial state first.
* @type {(seed: number, actions: Stream<LifeAction>) => Stream<LifeState>} */
export const lifeStates = (seed, actions) => todo('m13')
/**
* The whole program as a value: ask for a seed, turn the stream of states into a stream of drawings of `lifeView`,
* and wrap that stream in `clampTime(..., MAX_GAP_MS)` (`MAX_GAP_MS` is exported by `../app.js`).
* @type {Program}
*/
export const lifeMain = pure({ step: () => todo('m13') })
+5
View File
@@ -0,0 +1,5 @@
import { runApp } from '../runtime.js'
import { browserPlatform } from '../browser.js'
import { lifeMain } from './app.js'
runApp(lifeMain, browserPlatform(/** @type {HTMLCanvasElement} */ (document.getElementById('game')), window))
+35
View File
@@ -0,0 +1,35 @@
import { todo } from '../todo.js'
/** @typedef {{x: number, y: number}} Point */
/** @typedef {{cells: Point[], running: boolean, seed: number, generation: number}} LifeState */
/** @typedef {{tag: 'Frame'} | {tag: 'Toggle', cell: Point} | {tag: 'TogglePause'} | {tag: 'Step'} | {tag: 'Clear'} | {tag: 'Randomize'}} LifeAction */
// The board, in cells: LW columns, LH rows, LCELL pixels per cell. Below it the page shows a status strip.
export const LW = 40
export const LH = 30
/** Pixels per cell. */
export const LCELL = 16
/** Height of the status strip under the board, in pixels. */
export const STRIP = 24
/**
* A fresh world: no live cells (`cells: []`), paused (`running: false`), the given seed, generation 0.
* `cells` is always kept canonical: sorted row-major (by `y`, then by `x`), each cell at most once, all on the board.
* @type {(seed: number) => LifeState}
*/
export const initialLife = (seed) => todo('m13')
/**
* Everything that can happen to the world, as data: `Toggle(cell)` (`{tag:'Toggle', cell}`), and `Frame`,
* `TogglePause`, `Step`, `Clear`, `Randomize` (`{tag}`).
* The getters only exist so the stub compiles; replace them with plain values.
* @type {{Frame: LifeAction, Toggle: (cell: Point) => LifeAction, TogglePause: LifeAction, Step: LifeAction, Clear: LifeAction, Randomize: LifeAction}}
*/
export const LifeAction = {
Toggle: (cell) => todo('m13'),
get Frame() { return todo('m13') },
get TogglePause() { return todo('m13') },
get Step() { return todo('m13') },
get Clear() { return todo('m13') },
get Randomize() { return todo('m13') },
}
+29
View File
@@ -0,0 +1,29 @@
import { todo } from '../todo.js'
/** @import { Point, LifeState, LifeAction } from '../types' */
/**
* 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.
* @type {(cells: Point[]) => Point[]}
*/
export const lifeStep = (cells) => todo('m13')
/**
* The one function that says how the world changes: `(state, action) -> state`.
* - `Frame`: when running, the next generation (`lifeStep`) and `generation + 1`; when paused, unchanged.
* - `Step`: when paused, the next generation and `generation + 1`; when running, unchanged.
* - `TogglePause`: `running` flips.
* - `Toggle(cell)`: a dead cell becomes alive, a live one dead, whether the world runs or not; `cells` stays canonical
* (insert the new cell at its row-major place). A cell off the board changes nothing. `generation` is unchanged.
* - `Clear`: no live cells, generation 0; `running` and `seed` are kept.
* - `Randomize`: draw `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.
* @type {(state: LifeState, action: LifeAction) => LifeState}
*/
export const lifeUpdate = (state, action) => todo('m13')
+11
View File
@@ -0,0 +1,11 @@
import { todo } from '../todo.js'
/** @import { LifeState, Scene } from '../types' */
/**
* 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').
* @type {(state: LifeState) => Scene}
*/
export const lifeView = (state) => todo('m13')
+1 -1
View File
@@ -2,4 +2,4 @@ import { runApp } from './runtime.js'
import { browserPlatform } from './browser.js'
import { main } from './app.js'
runApp(main, browserPlatform(document.getElementById('game'), window))
runApp(main, browserPlatform(/** @type {HTMLCanvasElement} */ (document.getElementById('game')), window))
+15 -6
View File
@@ -4,18 +4,24 @@ import { todo } from './todo.js'
/** @typedef {'Up'|'Down'|'Left'|'Right'} Direction */
/** @typedef {'Playing'|'Paused'|'GameOver'|'Won'} Status */
/** @typedef {{width: number, height: number, snake: Point[], dir: Direction, nextDir: Direction,
* apple: Point|null, seed: number, score: number, status: Status, frame: number}} GameState */
* apple: Point|null, seed: number, score: number, status: Status, frame: number,
* queued: Direction|null}} GameState */
/** @typedef {{tag: 'Tick'} | {tag: 'Turn', dir: Direction} | {tag: 'TogglePause'} | {tag: 'Restart'} | {tag: 'Frame'}} Action */
/** A grid cell `{x, y}`. */
/** A grid cell `{x, y}`.
* @type {(x: number, y: number) => Point} */
export const point = (x, y) => todo('m02')
/** Structural equality: same x and same y. */
/** Structural equality: same x and same y.
* @type {(a: Point, b: Point) => boolean} */
export const pointEquals = (a, b) => todo('m02')
/** The unit step for a direction: Up {0,-1}, Down {0,1}, Left {-1,0}, Right {1,0} (y grows downward). */
/** The unit step for a direction: Up {0,-1}, Down {0,1}, Left {-1,0}, Right {1,0} (y grows downward).
* @type {(dir: Direction) => Point} */
export const delta = (dir) => todo('m02')
/** The direction pointing the other way. */
/** The direction pointing the other way.
* @type {(dir: Direction) => Direction} */
export const opposite = (dir) => todo('m02')
/**
@@ -23,6 +29,7 @@ export const opposite = (dir) => todo('m02')
* `Tick`, `Turn(dir)`, `TogglePause`, `Restart`, `Frame`, each `{ tag, ... }`.
* `Turn` is a function because it carries a direction; the rest are constants
* (the getters below only exist so the stub compiles; replace them with plain values).
* @type {{Tick: Action, Turn: (dir: Direction) => Action, TogglePause: Action, Restart: Action, Frame: Action}}
*/
export const Action = {
get Tick() { return todo('m02') },
@@ -34,6 +41,8 @@ export const Action = {
/**
* A fresh game: 20x20, snake [(10,10),(9,10),(8,10)] heading Right (dir and nextDir),
* apple null, the given seed, score 0, status 'Playing', frame 0.
* apple null, the given seed, score 0, status 'Playing', frame 0, queued null.
* (`frame` and `queued` stay unused until Module 10.)
* @type {(seed: number) => GameState}
*/
export const initialState = (seed) => todo('m02')
+10 -2
View File
@@ -1,5 +1,6 @@
import { todo } from '../todo.js'
import { pure } from '../io.js'
/** @import { PongAction, PongState, Stream, Program } from '../types' */
/** Milliseconds between frames of the match. */
export const FRAME_MS_PONG = 30
@@ -8,17 +9,24 @@ export const FRAME_MS_PONG = 30
* Translate a key event into a `PongAction`, or `null`. `isDown` is true for a key press, false for a release.
* `ArrowUp`/`w`/`W` -> `Press('Up')` / `Release('Up')`, `ArrowDown`/`s`/`S` -> `Press('Down')` / `Release('Down')`,
* `r`/`R` -> `Restart` (on press only; releasing `r` does nothing). Any other key -> `null`.
* @type {(isDown: boolean, key: string) => PongAction | null}
*/
export const pongKey = (isDown, key) => todo('m11')
/**
* Everything that can happen in a match: `Frame` every FRAME_MS_PONG (from `interval`), merged with the
* `pongKey(true, key)` of every key press and the `pongKey(false, key)` of every key release.
* @type {Stream<PongAction>}
*/
export const pongActions = { step: () => todo('m11') }
/** Fold the actions into a stream of states: start from `initialPong(seed)`, `pongUpdate` for each action, emit the initial state first. */
/** Fold the actions into a stream of states: start from `initialPong(seed)`, `pongUpdate` for each action, emit the initial state first.
* @type {(seed: number, actions: Stream<PongAction>) => Stream<PongState>} */
export const pongStates = (seed, actions) => todo('m11')
/** The whole program as a value, like `main`: ask for a seed, turn the stream of states into a stream of drawings of `pongView`. */
/**
* The whole program as a value, like `main`: ask for a seed, turn the stream of states into a stream of drawings of
* `pongView`, and wrap that stream in `clampTime(..., MAX_GAP_MS)` (`MAX_GAP_MS` is exported by `../app.js`).
* @type {Program}
*/
export const pongMain = pure({ step: () => todo('m11') })
+1 -1
View File
@@ -2,4 +2,4 @@ import { runApp } from '../runtime.js'
import { browserPlatform } from '../browser.js'
import { pongMain } from './app.js'
runApp(pongMain, browserPlatform(document.getElementById('game'), window))
runApp(pongMain, browserPlatform(/** @type {HTMLCanvasElement} */ (document.getElementById('game')), window))
+3
View File
@@ -3,6 +3,7 @@ import { todo } from '../todo.js'
/** @typedef {{ball: {x: number, y: number}, vel: {dx: number, dy: number}, player: number, cpu: number,
* held: {up: boolean, down: boolean}, score: {player: number, cpu: number},
* status: 'Playing'|'Over', seed: number, frame: number}} PongState */
/** @typedef {{tag: 'Press', key: 'Up'|'Down'} | {tag: 'Release', key: 'Up'|'Down'} | {tag: 'Frame'} | {tag: 'Restart'}} PongAction */
// The court, in cells. `player` and `cpu` are the y of the top cell of each paddle.
export const W = 80
@@ -20,6 +21,7 @@ export const WIN = 5
/**
* A fresh match: ball at (40,25) moving (dx 1, dy 1), both paddles at y 21, no keys held,
* score 0 to 0, status 'Playing', the given seed, frame 0.
* @type {(seed: number) => PongState}
*/
export const initialPong = (seed) => todo('m11')
@@ -27,6 +29,7 @@ export const initialPong = (seed) => todo('m11')
* Everything that can happen in a match, as data:
* `Press(key)` / `Release(key)` for key 'Up' or 'Down' (`{tag, key}`), and `Frame` / `Restart` (`{tag}`).
* The getters only exist so the stub compiles; replace them with plain values.
* @type {{Press: (key: 'Up'|'Down') => PongAction, Release: (key: 'Up'|'Down') => PongAction, Frame: PongAction, Restart: PongAction}}
*/
export const PongAction = {
Press: (key) => todo('m11'),
+5 -2
View File
@@ -1,13 +1,15 @@
import { todo } from '../todo.js'
/** @import { PongState, PongAction } from '../types' */
/** Keep `x` inside `[lo, hi]`. */
/** Keep `x` inside `[lo, hi]`.
* @type {(lo: number, hi: number, x: number) => number} */
export const clamp = (lo, hi, x) => todo('m11')
/**
* The one function that says how a match changes: `(state, action) -> state`.
*
* - `Press(key)` / `Release(key)`: set `held.up` or `held.down` (even when the match is over); any other key leaves the state unchanged.
* - `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),
@@ -22,5 +24,6 @@ export const clamp = (lo, hi, x) => todo('m11')
* `dx` toward the side that conceded (cpu scored -> -1, player scored -> +1), `dy = 1` when
* `nextUint32(seed)` is even and `-1` otherwise, and the seed advances.
* 7. A score of `WIN` ends the match: status 'Over'.
* @type {(state: PongState, action: PongAction) => PongState}
*/
export const pongUpdate = (state, action) => todo('m11')
+2
View File
@@ -1,4 +1,5 @@
import { todo } from '../todo.js'
/** @import { PongState, Scene } from '../types' */
/**
* Describe the match as shapes, drawn in order (all text is '#ffffff', align 'center'):
@@ -7,5 +8,6 @@ import { todo } from '../todo.js'
* score Text(320, 30, '<player> <cpu>' (three spaces), size 24).
* When Over: Text(320, 200, 'You Win!' if the player has more points, else 'CPU Wins', size 32) and
* Text(320, 230, 'Press R to restart', size 16).
* @type {(state: PongState) => Scene}
*/
export const pongView = (state) => todo('m11')
+12 -5
View File
@@ -1,4 +1,5 @@
import { todo } from './todo.js'
/** @import { Random } from './types' */
/**
* A `Random<A>` is a plain function from a seed to a pair: `seed => [value, nextSeed]`.
@@ -11,20 +12,26 @@ import { todo } from './todo.js'
* t = t ^ (t + imul(t ^ (t >>> 7), t | 61))
* value = (t ^ (t >>> 14)) >>> 0
* Returns `[value, s]`: the new seed is `s`.
* @type {Random<number>}
*/
export const nextUint32 = (seed) => todo('m04')
/** A Random that always gives `a` and leaves the seed untouched. */
/** A Random that always gives `a` and leaves the seed untouched.
* @type {<A>(a: A) => Random<A>} */
export const pure = (a) => todo('m04')
/** Transform the value a Random produces; the seed threading is unchanged. */
/** Transform the value a Random produces; the seed threading is unchanged.
* @type {<A, B>(r: Random<A>, f: (a: A) => B) => Random<B>} */
export const map = (r, f) => todo('m04')
/** Run `r`, feed its value to `f` to pick the next Random, and run that with the seed `r` left behind. */
/** Run `r`, feed its value to `f` to pick the next Random, and run that with the seed `r` left behind.
* @type {<A, B>(r: Random<A>, f: (a: A) => Random<B>) => Random<B>} */
export const flatMap = (r, f) => todo('m04')
/** A Random giving a number in `[0, n)`: `nextUint32` value modulo `n`. */
/** A Random giving a number in `[0, n)`: `nextUint32` value modulo `n`.
* @type {(n: number) => Random<number>} */
export const intBelow = (n) => todo('m04')
/** Run a Random from a seed: `[value, nextSeed]`. */
/** Run a Random from a seed: `[value, nextSeed]`.
* @type {<A>(r: Random<A>, seed: number) => [A, number]} */
export const run = (r, seed) => todo('m04')
+9
View File
@@ -0,0 +1,9 @@
import { runApp } from './runtime.js'
import { browserPlatform } from './browser.js'
import { replayMain } from './replay.js'
// Given glue for js/replay.html (m10). The recording is plain JSON next to the test fixtures; the page is served from the
// repository root by scripts/serve.mjs, so it can be fetched. Resolved against this file, so the page's URL does not matter.
const recording = await (await fetch(new URL('../../fixtures/replay-sample.json', import.meta.url))).json()
runApp(replayMain(recording), browserPlatform(/** @type {HTMLCanvasElement} */ (document.getElementById('game')), window))
+80
View File
@@ -0,0 +1,80 @@
import { todo } from './todo.js'
import { pure } from './io.js'
/** @import { GameState, Scene, Stream, Recording, Program } from './types' */
/** @typedef {{index: number, last: number, playing: boolean}} Viewer */
/** @typedef {{tag: 'Next'} | {tag: 'Prev'} | {tag: 'First'} | {tag: 'Last'} | {tag: 'TogglePlay'} | {tag: 'Beat'}} ViewerAction */
/**
* (m10) A replay viewer. A recording is `{seed, actions}`: the seed of the game and every action, in order
* (see `fixtures/replay-sample.json`). The viewer's state is `{index, last, playing}`: which frame is shown,
* the index of the last frame, and whether autoplay is on.
*/
/** Milliseconds between two frames of autoplay. */
export const REPLAY_MS = 100
/** Every state of the recorded game: `history(newGame(seed), actions)`. Its length is `actions.length + 1`.
* @type {(recording: Recording) => GameState[]} */
export const frames = (recording) => todo('m10')
/** The viewer at the first frame, not playing: `{index: 0, last, playing: false}`.
* @type {(last: number) => Viewer} */
export const viewerStart = (last) => todo('m10')
/**
* Everything the viewer can be asked to do, as data (`{tag}` objects): `Next`, `Prev`, `First`, `Last`, `TogglePlay`,
* and `Beat`, one beat of the autoplay clock. The getters only exist so the stub compiles; replace them with plain values.
* @type {{Next: ViewerAction, Prev: ViewerAction, First: ViewerAction, Last: ViewerAction, TogglePlay: ViewerAction, Beat: ViewerAction}}
*/
export const ViewerAction = {
get Next() { return todo('m10') },
get Prev() { return todo('m10') },
get First() { return todo('m10') },
get Last() { return todo('m10') },
get TogglePlay() { return todo('m10') },
get Beat() { return 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).
* @type {(viewer: Viewer, action: ViewerAction) => Viewer}
*/
export const viewerUpdate = (viewer, action) => todo('m10')
/**
* A key to a viewer action, or `null`: `ArrowRight`/`d`/`D` -> Next, `ArrowLeft`/`a`/`A` -> Prev, `Home` -> First,
* `End` -> Last, `' '` -> TogglePlay. Any other key -> `null` (watch out for inherited names such as `constructor`).
* @type {(key: string) => ViewerAction | null}
*/
export const viewerKey = (key) => todo('m10')
/**
* The picture: `view(frames[index])`, followed by two texts. With `w = width * CELL` of that frame:
* `{tag:'Text', x: w - 6, y: 18, text: 'step <index> / <last>', color: '#ffffff', size: 16, align: 'right'}` and
* `{tag:'Text', x: w / 2, y: height * CELL - 8, text: <hint>, color: '#aaaaaa', size: 12, align: 'center'}`, where the
* hint is `'Space: pause'` while playing and `'Left/Right: step Home/End: jump Space: play'` (three spaces between
* the parts) otherwise.
* @type {(frames: GameState[], viewer: Viewer) => Scene}
*/
export const viewerView = (frames, viewer) => todo('m10')
/** `Beat` every REPLAY_MS (from `interval`), merged with `viewerKey` of every key press (the beats first).
* @type {Stream<ViewerAction>} */
export const replayActions = { step: () => todo('m10') }
/** Fold the actions into viewers with `viewerUpdate`, starting from `viewerStart(last)`, which is emitted first.
* @type {(last: number, actions: Stream<ViewerAction>) => Stream<Viewer>} */
export const viewerStates = (last, actions) => todo('m10')
/**
* The whole viewer as a value: `pure` of the stream of drawings of `viewerView(frames(recording), viewer)` over
* `viewerStates(<last index>, replayActions)`, wrapped in `clampTime(..., MAX_GAP_MS)` (from `./app.js`).
* No seed is asked for: the recording carries its own.
* @type {(recording: Recording) => Program}
*/
export const replayMain = (recording) => pure({ step: () => todo('m10') })
+7 -2
View File
@@ -1,15 +1,19 @@
import { todo } from './todo.js'
/** @import { Point, Direction, GameState } from './types' */
/** The cell one step from `p` in direction `dir`. No wrapping: it may land outside the board. */
/** The cell one step from `p` in direction `dir`. No wrapping: it may land outside the board.
* @type {(p: Point, dir: Direction) => Point} */
export const move = (p, dir) => todo('m03')
/** True if `p` lies on the board: `0 <= x < width` and `0 <= y < height`. */
/** True if `p` lies on the board: `0 <= x < width` and `0 <= y < height`.
* @type {(state: {width: number, height: number}, p: Point) => boolean} */
export const inBounds = (state, p) => todo('m03')
/**
* Ask to turn the snake. Returns `state` unchanged unless the game is 'Playing' and
* `dir` is not the opposite of `state.dir` (the direction the snake last *moved*, not `nextDir`).
* Otherwise sets `nextDir = dir`. Several turns in one tick: the last allowed one wins.
* @type {(state: GameState, dir: Direction) => GameState}
*/
export const turn = (state, dir) => todo('m03')
@@ -19,5 +23,6 @@ export const turn = (state, dir) => todo('m03')
* - head lands on the apple: the snake grows (tail kept), score + 1, apple becomes null;
* - otherwise the tail cell is vacated, so the head may enter the cell the tail is leaving;
* - head off the board, or onto a remaining body cell: status 'GameOver', snake unchanged (but `dir` updated).
* @type {(state: GameState) => GameState}
*/
export const advance = (state) => todo('m03')
+8 -2
View File
@@ -1,17 +1,21 @@
import { todo } from './todo.js'
/** @import { IO, Platform, Program } from './types' */
/**
* The single impure place in the course (with browser.js and main.js): everything else only builds values.
* A Platform is `{ fillRect(x,y,w,h,color), fillText(x,y,text,color,size,align), randomSeed() -> number,
* startClock(onTime: t => void), onKeyDown(cb: key => void), onKeyUp(cb: key => void) }`.
* loadBest() -> number, saveBest(score), startClock(onTime: t => void), onKeyDown(cb: key => void),
* onKeyUp(cb: key => void), onClick(cb: (x, y) => void) }`. (`loadBest` and `saveBest` arrive in m10, `onClick` in m13.)
* Here `let`, loops and mutation are allowed. Keep it tiny.
*/
/**
* Interpret an IO against a platform and return its final value.
* `Draw` of a Rect -> `platform.fillRect(x, y, w, h, color)`; of a Text -> `platform.fillText(x, y, text, color, size, align)`;
* `RandomSeed` -> `platform.randomSeed()`. The result of each op is passed to `io.k`.
* `RandomSeed` -> `platform.randomSeed()`. (m10) `LoadBest` -> `platform.loadBest()`;
* `SaveBest` -> `platform.saveBest(score)`. The result of each op is passed to `io.k`.
* Must be a loop, not recursion: a long sequence of draws must not overflow the stack.
* @type {<A>(io: IO<A>, platform: Platform) => A}
*/
export const runIO = (io, platform) => todo('m09')
@@ -20,5 +24,7 @@ export const runIO = (io, platform) => todo('m09')
* Register `platform.onKeyDown` / `onKeyUp` / `startClock` so that each event steps the stream with the matching
* input (`{tag:'KeyDown', key}`, `{tag:'KeyUp', key}`, `{tag:'Time', t}`), stores the next stream,
* and runs every IO the step emitted, in order.
* (m13) Also register `platform.onClick` so that each click steps the stream with `{tag:'Click', x, y}`.
* @type {(main: Program, platform: Platform) => void}
*/
export const runApp = (main, platform) => todo('m09')
+98
View File
@@ -0,0 +1,98 @@
import { todo } from './todo.js'
/** @import { Op, SafeIO, Trace } from './types' */
/**
* m07b (optional): a stack-safe IO. The same idea as m07's IO, with one more constructor:
* `{tag:'Pure', value}` - finished, with a value
* `{tag:'Effect', op, k}` - perform `op`, then continue with `k(answer)` (which returns another SafeIO)
* `{tag:'FlatMap', io, f}` - run `io`, then continue with `f(result)` (which returns another SafeIO)
* Operations are the same plain data as in m07: `{tag:'Draw', shape}` (answer `undefined`) and
* `{tag:'RandomSeed'}` (answer: a number). This file does not import io.js; it is a second, independent IO.
* Building a SafeIO never performs anything and never calls a function given to `map` or `flatMap`.
*/
/** A SafeIO that does nothing and produces `value`: `{tag:'Pure', value}`.
* @type {<A>(value: A) => SafeIO<A>} */
export const pure = (value) => todo('m07b')
/** A SafeIO that performs the single operation `op` and produces its answer: `{tag:'Effect', op, k: pure}`.
* @type {<A>(op: Op) => SafeIO<A>} */
export const effect = (op) => todo('m07b')
/** "Run `io`, then continue with `f(result)`": only builds `{tag:'FlatMap', io, f}`. Never calls `f`.
* @type {<A, B>(io: SafeIO<A>, f: (a: A) => SafeIO<B>) => SafeIO<B>} */
export const flatMap = (io, f) => todo('m07b')
/** Transform the result of `io` with `f`, built from `flatMap` and `pure`. Never calls `f` while building.
* @type {<A, B>(io: SafeIO<A>, f: (a: A) => B) => SafeIO<B>} */
export const map = (io, f) => todo('m07b')
/** Run the SafeIOs one after another, left to right, producing `undefined`. Either fold is fine here.
* @type {(ios: SafeIO<unknown>[]) => SafeIO<undefined>} */
export const sequence = (ios) => 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, r => FlatMap(k(r), f))
* FlatMap(FlatMap(m, g), f) becomes FlatMap(m, x => FlatMap(g(x), f)) (re-association)
* A `Pure` or an `Effect` is returned unchanged (the same object). No recursion: `step` looks at most two
* levels deep and calls no function except `f` in the first rule.
* @type {<A>(io: SafeIO<A>) => SafeIO<A>}
*/
export const step = (io) => todo('m07b')
// Given (not an exercise): a trampoline driver without loops or mutation.
// `next(s)` returns `[s2, outs]` (outs: an array). `drive` applies `next` until `isDone(s)` and returns
// `[last, allOuts]`, the final state and every output in order. Running `n` moves is running `n/2` moves and then
// `n/2` more, so the recursion is only log2(n) deep; when the budget runs out before the end, the driver doubles
// it and goes on. The outputs are kept in a balanced tree and flattened once at the end, so no array is copied
// once per move. Module 7b, section 6 explains it.
const leaf = (items) => ({ tag: 'Leaf', items })
const node = (l, r) => ({ tag: 'Node', l, r })
const toArray = (t) => (t.tag === 'Leaf' ? t.items : toArray(t.l).concat(toArray(t.r)))
const runFor = (s, n, isDone, next) => {
if (isDone(s)) return { state: s, outs: leaf([]) }
if (n === 1) {
const [s2, outs] = next(s)
return { state: s2, outs: leaf(outs) }
}
const half = Math.floor(n / 2)
const first = runFor(s, half, isDone, next)
const second = runFor(first.state, n - half, isDone, next)
return { state: second.state, outs: node(first.outs, second.outs) }
}
/** @type {<S, O>(start: S, isDone: (s: S) => boolean, next: (s: S) => [S, O[]]) => [S, O[]]} */
export const drive = (start, isDone, next) => {
const go = (s, budget, acc) => {
const r = runFor(s, budget, isDone, next)
const outs = node(acc, r.outs)
return isDone(r.state) ? [r.state, toArray(outs)] : go(r.state, budget * 2, outs)
}
return go(start, 1, leaf([]))
}
// Given: the canned answer of the pure interpreter, as in m07. Any other op (such as Module 10's LoadBest) is an error.
const answer = (op, seed) => {
if (op.tag === 'RandomSeed') return seed
if (op.tag === 'Draw') return undefined
throw new Error("SafeIO.trace answers only Module 7's operations")
}
/**
* One move of the pure interpreter, returning `[nextProgram, ops]`:
* a FlatMap is rewritten once with `step` and records nothing (`ops = []`);
* an Effect is answered (`answer(op, seed)`), continues with `k(answer)` and records its op (`ops = [op]`);
* a Pure is finished and comes back unchanged with `ops = []`.
* @type {<A>(io: SafeIO<A>, seed: number) => [SafeIO<A>, Op[]]}
*/
export const traceStep = (io, seed) => todo('m07b')
/**
* The pure interpreter: same contract as m07's `trace`, `{result, ops}`, but it never grows the stack with the
* program. Drive `traceStep` with `drive` until the program is a `Pure`.
* @type {<A>(io: SafeIO<A>, seed: number) => Trace<A>}
*/
export const trace = (io, seed) => todo('m07b')
+8 -3
View File
@@ -1,17 +1,22 @@
import { todo } from './todo.js'
/** @import { Point, GameState } from './types' */
/** Every board cell not covered by the snake, row-major (y outer loop, x inner loop). The apple does not count as occupied. */
/** Every board cell not covered by the snake, row-major (y outer loop, x inner loop). The apple does not count as occupied.
* @type {(state: {width: number, height: number, snake: Point[]}) => Point[]} */
export const freeCells = (state) => todo('m04')
/**
* Put an apple on a random free cell, drawing from `state.seed` (use `intBelow` and store the new seed).
* Unchanged if there already is an apple or the status is not 'Playing'.
* If no cell is free the board is full: status becomes 'Won' (seed unchanged, no apple).
* @type {(state: GameState) => GameState}
*/
export const spawnApple = (state) => todo('m04')
/** One game step: `advance`, then make sure there is an apple. */
/** One game step: `advance`, then make sure there is an apple.
* @type {(state: GameState) => GameState} */
export const tick = (state) => todo('m04')
/** A fresh game (`initialState`) with its first apple already placed. */
/** A fresh game (`initialState`) with its first apple already placed.
* @type {(seed: number) => GameState} */
export const newGame = (seed) => todo('m04')
+35
View File
@@ -0,0 +1,35 @@
import { todo } from './todo.js'
/** @import { State } from './types' */
/**
* Optional (Exercise 4.7). A `State<S, A>` is a plain function from a state to a pair: `s => [value, nextS]`.
* It is `Random<A>` with the seed replaced by any state type `S`. Nothing in the game uses this file.
*/
/** A State that gives `a` and leaves the state untouched: `s => [a, s]`.
* @type {<S, A>(a: A) => State<S, A>} */
export const pure = (a) => todo('m04')
/** Transform the value a State produces; the state threading is unchanged.
* @type {<S, A, B>(st: State<S, A>, f: (a: A) => B) => State<S, B>} */
export const map = (st, f) => todo('m04')
/** Run `st`, feed its value to `f` to pick the next State, and run that with the state `st` left behind.
* @type {<S, A, B>(st: State<S, A>, f: (a: A) => State<S, B>) => State<S, B>} */
export const flatMap = (st, f) => todo('m04')
/** A State whose value is the current state, which it leaves untouched: `s => [s, s]`. (A value, not a function call.)
* @type {<S>(s: S) => [S, S]} */
export const get = (s) => todo('m04')
/** A State that replaces the state with `s` and gives `undefined`: whatever the old state, `[undefined, s]`.
* @type {<S>(s: S) => State<S, undefined>} */
export const put = (s) => todo('m04')
/** A State that replaces the state `s` with `f(s)` and gives `undefined`.
* @type {<S>(f: (s: S) => S) => State<S, undefined>} */
export const modify = (f) => todo('m04')
/** Run a State from an initial state: `[value, finalState]`.
* @type {<S, A>(st: State<S, A>, s: S) => [A, S]} */
export const run = (st, s) => todo('m04')
+39 -9
View File
@@ -1,46 +1,76 @@
import { todo } from './todo.js'
/** @import { Point, Input, Stream } from './types' */
/**
* An event stream as a pure automaton (a Mealy machine).
* `Stream<A> = { step: input => [outputs, nextStream] }`: feed one input, get the (possibly empty) array
* of outputs it produced and the stream to use for the next input. Nothing is mutated, so a stream value
* can be run any number of times with the same results.
* Inputs: `{tag:'Time', t}`, `{tag:'KeyDown', key}`, `{tag:'KeyUp', key}`.
* Inputs: `{tag:'Time', t}`, `{tag:'KeyDown', key}`, `{tag:'KeyUp', key}`, and from Module 13 `{tag:'Click', x, y}`
* (a mouse click, in canvas pixels). Every stream ignores the kinds of input it does not use.
*/
/** Emits the `key` of every KeyDown input; ignores everything else. */
/** Emits the `key` of every KeyDown input; ignores everything else.
* @type {Stream<string>} */
export const keyDowns = { step: () => todo('m08') }
/** Emits the `key` of every KeyUp input; ignores everything else. */
/** Emits the `key` of every KeyUp input; ignores everything else.
* @type {Stream<string>} */
export const keyUps = { step: () => todo('m08') }
/**
* (m13) Emits `{x, y}` for every Click input; ignores everything else.
* Given: nothing to write here. It is the third source, built exactly like your `keyDowns`.
* @type {Stream<Point>}
*/
export const clicks = { step: (i) => [i.tag === 'Click' ? [{ x: i.x, y: i.y }] : [], clicks] }
/**
* On `Time t`, emits every multiple `k*ms` with `last < k*ms <= t`, ascending, where `last` starts at 0
* and becomes `t` afterwards. A big time jump can emit several values. Ignores key inputs.
* Hint: write a private helper that carries `last`.
* @type {(ms: number) => Stream<number>}
*/
export const interval = (ms) => todo('m08')
/** Apply `f` to every output of `s`. */
/** Apply `f` to every output of `s`.
* @type {<A, B>(s: Stream<A>, f: (a: A) => B) => Stream<B>} */
export const map = (s, f) => todo('m08')
/** Keep only the outputs of `s` for which `p` is true. */
/** Keep only the outputs of `s` for which `p` is true.
* @type {<A>(s: Stream<A>, p: (a: A) => boolean) => Stream<A>} */
export const filter = (s, p) => todo('m08')
/** Apply `f` to every output of `s`; `f` returns a value or `null`, and nulls are dropped. */
/** Apply `f` to every output of `s`; `f` returns a value or `null`, and nulls are dropped.
* @type {<A, B>(s: Stream<A>, f: (a: A) => B | null) => Stream<B>} */
export const filterMap = (s, f) => todo('m08')
/** Both streams step on every input; the outputs of `a` come before the outputs of `b`. */
/** Both streams step on every input; the outputs of `a` come before the outputs of `b`.
* @type {<A>(a: Stream<A>, b: Stream<A>) => Stream<A>} */
export const merge = (a, b) => todo('m08')
/** Fold the outputs of `s` into an accumulator, starting from `z`; emits each new accumulator (not `z` itself). */
/** Fold the outputs of `s` into an accumulator, starting from `z`; emits each new accumulator (not `z` itself).
* @type {<A, B>(s: Stream<A>, f: (acc: B, a: A) => B, z: B) => Stream<B>} */
export const scan = (s, f, z) => todo('m08')
/**
* Emit `v` once, before the outputs of the first step; later steps are unaffected.
* Note: streams only act when an input arrives, so if no input ever arrives, `v` is never emitted.
* @type {<A>(s: Stream<A>, v: A) => Stream<A>}
*/
export const startWith = (s, v) => todo('m08')
/** Feed all inputs through the stream and collect every output, in order. A pure fold (reduce). */
/**
* (m10) Limit how far one Time input 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 input
* (0 at the start). `s` is then stepped with `{tag:'Time', t: clock}`. Key inputs are passed to `s` unchanged.
* Hint: a private helper that carries `last` and `clock`, like the one for `interval`.
* @type {<A>(s: Stream<A>, maxGap: number) => Stream<A>}
*/
export const clampTime = (s, maxGap) => todo('m10')
/** Feed all inputs through the stream and collect every output, in order. A pure fold (reduce).
* @type {<A>(s: Stream<A>, inputs: Input[]) => A[]} */
export const run = (s, inputs) => todo('m08')
+1
View File
@@ -1,4 +1,5 @@
// Placeholder body for unimplemented stubs: replace each `todo('mNN')` call with real code.
/** @type {(module: string) => never} */
export const todo = (module) => {
throw new Error(`TODO: implement in ${module}`)
}
+92
View File
@@ -0,0 +1,92 @@
// Given, not an exercise: the course's data types, written down for an editor that understands types (optional,
// see Module 0, "If your editor understands types"). Nothing imports this file at run time; the JS files refer to it
// from their doc comments (`@import`, `@type`). Every sum type is a union of plain objects with a `tag`, so a
// `switch (x.tag)` narrows `x` to one case inside each `case`.
// ---- m02 model, m10 replay viewer, m11 Pong, m13 Life
// The model types are written where the model is: as `@typedef` comments in model.js (Module 2 shows them there),
// pong/model.js, life/model.js and replay.js. Each action union sits next to the object of the same name that
// builds its values, so `import { Action } from './model.js'` gives both. They are re-exported here so that every
// type can be found in one place.
import type { Point, Direction, Status, Action, GameState } from './model.js'
import type { Viewer, ViewerAction } from './replay.js'
import type { PongState, PongAction } from './pong/model.js'
import type { LifeState, LifeAction } from './life/model.js'
export type { Point, Direction, Status, Action, GameState, Viewer, ViewerAction, PongState, PongAction, LifeState, LifeAction }
// ---- m04 random and state
/** A random value: a function from a seed to the value and the next seed. */
export type Random<A> = (seed: number) => [A, number]
/** A value that reads and replaces a state of type S. `Random<A>` is `State<number, A>`. */
export type State<S, A> = (s: S) => [A, S]
// ---- m06 view
export type Align = 'left' | 'center' | 'right'
export type Shape =
| { tag: 'Rect'; x: number; y: number; w: number; h: number; color: string }
| { tag: 'Text'; x: number; y: number; text: string; color: string; size: number; align: Align }
export type Scene = Shape[]
// ---- m07 IO
export type Op =
| { tag: 'Draw'; shape: Shape }
| { tag: 'RandomSeed' }
| { tag: 'LoadBest' }
| { tag: 'SaveBest'; score: number }
/** A program that produces an A. The answer given to `k` depends on the op (a number, or undefined), hence `any`. */
export type IO<A> =
| { tag: 'Pure'; value: A }
| { tag: 'Effect'; op: Op; k: (answer: any) => IO<A> }
/** What the pure interpreters return: the program's result and the ops it performed, in order. */
export type Trace<A> = { result: A; ops: Op[] }
// ---- m07b stack-safe IO
export type SafeIO<A> =
| { tag: 'Pure'; value: A }
| { tag: 'Effect'; op: Op; k: (answer: any) => SafeIO<A> }
| { tag: 'FlatMap'; io: SafeIO<any>; f: (result: any) => SafeIO<A> }
// ---- m08 streams
/** What the world feeds a stream. `Click` arrives in Module 13. */
export type Input =
| { tag: 'Time'; t: number }
| { tag: 'KeyDown'; key: string }
| { tag: 'KeyUp'; key: string }
| { tag: 'Click'; x: number; y: number }
export type Stream<A> = { step: (input: Input) => [A[], Stream<A>] }
/** The whole program: ask the world for what it needs, then a stream of drawings. */
export type Program = IO<Stream<IO<unknown>>>
// ---- m09 runtime
export type Platform = {
fillRect: (x: number, y: number, w: number, h: number, color: string) => void
fillText: (x: number, y: number, text: string, color: string, size: number, align: Align) => void
randomSeed: () => number
loadBest: () => number
saveBest: (score: number) => void
startClock: (onTime: (t: number) => void) => void
onKeyDown: (cb: (key: string) => void) => void
onKeyUp: (cb: (key: string) => void) => void
onClick: (cb: (x: number, y: number) => void) => void
}
// ---- m10 high score and replay viewer
export type Session = { game: GameState; best: number; improved: boolean }
/** A recorded game (`fixtures/replay-sample.json`). */
export type Recording = { seed: number; actions: Action[] }
// ---- m12 property-based testing
export type CheckResult<A> = { ok: true; runs: number } | { ok: false; run: number; seed: number; value: A }
+12 -6
View File
@@ -1,19 +1,25 @@
import { todo } from './todo.js'
/** `range(n)` is `[0, 1, ..., n-1]`; `range(0)` is `[]`. */
/** `range(n)` is `[0, 1, ..., n-1]`; `range(0)` is `[]`.
* @type {(n: number) => number[]} */
export const range = (n) => todo('m01')
/** `xs` without its last element; an empty list stays empty. Never mutates `xs`. */
/** `xs` without its last element; an empty list stays empty. Never mutates `xs`.
* @type {<A>(xs: A[]) => A[]} */
export const dropLast = (xs) => todo('m01')
/** True if some element `x` of `xs` satisfies `eq(x, p)`. `eq` lets callers compare structurally (e.g. `pointEquals`). */
/** True if some element `x` of `xs` satisfies `eq(x, p)`. `eq` lets callers compare structurally (e.g. `pointEquals`).
* @type {<A, B>(xs: A[], p: B, eq: (x: A, p: B) => boolean) => boolean} */
export const contains = (xs, p, eq) => todo('m01')
/** `pipe(x, f, g)` is `g(f(x))`: thread a value through functions left to right. */
/** `pipe(x, f, g)` is `g(f(x))`: thread a value through functions left to right.
* @type {(x: any, ...fns: ((a: any) => any)[]) => any} */
export const pipe = (x, ...fns) => todo('m01')
/** `compose(f, g)(x)` is `f(g(x))`: functions applied right to left. */
/** `compose(f, g)(x)` is `f(g(x))`: functions applied right to left.
* @type {(...fns: ((a: any) => any)[]) => (x: any) => any} */
export const compose = (...fns) => todo('m01')
/** Freeze `obj` and everything reachable from it (even below already-frozen parents; cycles are fine), then return it (same object). */
/** Freeze `obj` and everything reachable from it (even below already-frozen parents; cycles are fine), then return it (same object).
* @type {<T>(obj: T, ancestors?: unknown[]) => T} */
export const deepFreeze = (obj) => todo('m01')
+2
View File
@@ -1,4 +1,5 @@
import { todo } from './todo.js'
/** @import { GameState, Scene } from './types' */
/** Pixels per board cell. */
export const CELL = 20
@@ -13,5 +14,6 @@ export const CELL = 20
* Paused: 'Paused' (size 32) and, 30px lower, 'Press Space to resume' (size 16);
* GameOver: 'Game Over' + 'Press R to restart'; Won: 'You Win!' + 'Press R to restart'; Playing: none.
* Text in the overlay uses align 'center'.
* @type {(state: GameState) => Scene}
*/
export const view = (state) => todo('m06')