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
+51
-5
@@ -1,30 +1,76 @@
|
||||
import { Action } from './model.js'
|
||||
import { update } from './game.js'
|
||||
import { newGame } from './spawn.js'
|
||||
import { view } from './view.js'
|
||||
import { map as mapIO, randomSeed, drawScene } from './io.js'
|
||||
import { keyDowns, interval, map, filterMap, merge, scan, startWith } from './stream.js'
|
||||
import { view, CELL } from './view.js'
|
||||
import { map as mapIO, flatMap as flatMapIO, sequence, randomSeed, drawScene, loadBest, saveBest, trace } from './io.js'
|
||||
import { keyDowns, interval, map, filterMap, merge, scan, startWith, clampTime, run } from './stream.js'
|
||||
import { keyToAction } from './input.js'
|
||||
/** @import { GameState, Session, Scene, IO, Stream, Input, Op, Program } from './types' */
|
||||
|
||||
export const TICK_MS = 150
|
||||
export const FRAME_MS = 25
|
||||
// m10: the longest step of time the game accepts at once; a longer silence (a background tab) counts as this much.
|
||||
export const MAX_GAP_MS = 250
|
||||
|
||||
// Everything that can happen to the game: a Tick every TICK_MS, and a Turn for each steering key.
|
||||
/** @type {Stream<Action>} */
|
||||
export const classicActions = merge(
|
||||
map(interval(TICK_MS), () => Action.Tick),
|
||||
filterMap(keyDowns, keyToAction))
|
||||
|
||||
// m10: a Frame every FRAME_MS instead of a Tick; the game itself decides (movesEvery) which frames take a step.
|
||||
/** @type {Stream<Action>} */
|
||||
export const speedActions = merge(
|
||||
map(interval(FRAME_MS), () => Action.Frame),
|
||||
filterMap(keyDowns, keyToAction))
|
||||
|
||||
// Actions folded into game states. startWith puts the initial state first so it is drawn right away.
|
||||
/** @type {(seed: number, actions: Stream<Action>) => Stream<GameState>} */
|
||||
export const states = (seed, actions) =>
|
||||
startWith(scan(actions, update, newGame(seed)), newGame(seed))
|
||||
|
||||
/** @type {(state: GameState) => IO<undefined>} */
|
||||
export const render = (state) => drawScene(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: Session, action: Action) => Session} */
|
||||
export const sessionUpdate = (session, action) => {
|
||||
const game = update(session.game, action)
|
||||
const best = Math.max(session.best, game.score)
|
||||
return { game, best, improved: best > session.best }
|
||||
}
|
||||
|
||||
// The same running fold as `states`, one level up.
|
||||
/** @type {(seed: number, best: number, actions: Stream<Action>) => Stream<Session>} */
|
||||
export const sessions = (seed, best, actions) => {
|
||||
const s0 = { game: newGame(seed), best, improved: false }
|
||||
return startWith(scan(actions, sessionUpdate, s0), s0)
|
||||
}
|
||||
|
||||
// The game's picture with the best score at the top right, level with the score on the left.
|
||||
/** @type {(session: Session) => Scene} */
|
||||
export const sessionView = (session) => [
|
||||
...view(session.game),
|
||||
{ tag: 'Text', x: session.game.width * CELL - 6, y: 18, text: `Best: ${session.best}`, color: '#ffffff', size: 16, align: 'right' },
|
||||
]
|
||||
|
||||
// Draw, and save the best only on the action that raised it.
|
||||
/** @type {(session: Session) => IO<undefined>} */
|
||||
export const renderSession = (session) =>
|
||||
session.improved
|
||||
? sequence([drawScene(sessionView(session)), saveBest(session.best)])
|
||||
: 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.js interprets it.
|
||||
export const main = mapIO(randomSeed, (seed) => map(states(seed, speedActions), render))
|
||||
/** @type {Program} */
|
||||
export const main = flatMapIO(randomSeed, (seed) =>
|
||||
mapIO(loadBest, (best) => clampTime(map(sessions(seed, best, speedActions), renderSession), 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).
|
||||
/** @type {(main: Program, seed: number, events: Input[], best?: number) => Op[][]} */
|
||||
export const play = (main, seed, events, best = 0) =>
|
||||
run(trace(main, seed, best).result, events).map((io) => trace(io, seed, best).ops)
|
||||
+46
-1
@@ -1,9 +1,30 @@
|
||||
// Browser glue: turns a canvas and a window into a Platform. Impure by nature; not part of the exercises' logic.
|
||||
/** @import { Point, Platform } from './types' */
|
||||
|
||||
const PREVENT = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '])
|
||||
|
||||
// m10: where the best score survives a reload.
|
||||
const BEST_KEY = 'pure-snake-best'
|
||||
|
||||
/**
|
||||
* (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),
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {(canvas: HTMLCanvasElement, win: Window) => Platform} */
|
||||
export const browserPlatform = (canvas, win) => {
|
||||
const ctx = canvas.getContext('2d')
|
||||
const ctx = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d'))
|
||||
// Keys that scroll the page should only steer the game.
|
||||
const onKey = (cb) => (e) => {
|
||||
if (PREVENT.has(e.key)) e.preventDefault()
|
||||
@@ -21,6 +42,24 @@ export const browserPlatform = (canvas, win) => {
|
||||
ctx.fillText(text, x, y)
|
||||
},
|
||||
randomSeed: () => crypto.getRandomValues(new Uint32Array(1))[0],
|
||||
// Storage can be missing, blocked (privacy settings) or hold anything: all of that reads as 0, and nothing throws.
|
||||
loadBest: () => {
|
||||
try {
|
||||
// Only what saveBest writes counts: decimal digits, at most 2147483647 (so all three tracks read alike).
|
||||
const raw = win.localStorage.getItem(BEST_KEY)
|
||||
return typeof raw === 'string' && /^[0-9]{1,10}$/.test(raw) && Number(raw) <= 2147483647 ? Number(raw) : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
},
|
||||
// A record that cannot be stored is lost; the game goes on.
|
||||
saveBest: (score) => {
|
||||
try {
|
||||
win.localStorage.setItem(BEST_KEY, String(score))
|
||||
} catch {
|
||||
// ignored on purpose
|
||||
}
|
||||
},
|
||||
// t is the number of milliseconds since the first frame.
|
||||
startClock: (onTime) => {
|
||||
const frame = (start) => (now) => {
|
||||
@@ -32,5 +71,11 @@ export const browserPlatform = (canvas, win) => {
|
||||
},
|
||||
onKeyDown: (cb) => win.addEventListener('keydown', onKey(cb)),
|
||||
onKeyUp: (cb) => win.addEventListener('keyup', onKey(cb)),
|
||||
// m13: clicks on the canvas (not the window), converted to canvas pixels.
|
||||
onClick: (cb) =>
|
||||
canvas.addEventListener('click', (e) => {
|
||||
const p = canvasPoint(canvas, e.clientX, e.clientY)
|
||||
cb(p.x, p.y)
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// 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]).
|
||||
import { contains, dropLast } from './util.js'
|
||||
import { Action, point, pointEquals, opposite } from './model.js'
|
||||
import { inBounds, move } from './rules.js'
|
||||
import { freeCells } from './spawn.js'
|
||||
import { pure, map, flatMap, intBelow, run } from './random.js'
|
||||
/** @import { Point, Direction, GameState, Random, CheckResult } from './types' */
|
||||
|
||||
/** One of the elements of the non-empty array `xs`: the one at index `intBelow(xs.length)`.
|
||||
* @type {<A>(xs: A[]) => Random<A>} */
|
||||
export const elementOf = (xs) => map(intBelow(xs.length), (i) => xs[i])
|
||||
|
||||
/** `n` values drawn from `gen`, one after another; the seed is threaded from each draw to the next.
|
||||
* @type {<A>(gen: Random<A>, n: number) => Random<A[]>} */
|
||||
export const listOf = (gen, n) =>
|
||||
n <= 0 ? pure([]) : flatMap(gen, (a) => map(listOf(gen, n - 1), (rest) => [a, ...rest]))
|
||||
|
||||
// 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).
|
||||
* @type {(n: number) => Random<Action[]>} */
|
||||
export const genActions = (n) => listOf(elementOf(actionPool), n)
|
||||
|
||||
/**
|
||||
* Check `prop` on `runs` values drawn from `gen`, starting from `seed`; stop at the first value that fails.
|
||||
* Recursion carries the seed: no counter, no loop variable.
|
||||
* @type {<A>(gen: Random<A>, prop: (a: A) => boolean, runs: number, seed: number) => CheckResult<A>}
|
||||
*/
|
||||
export const forAll = (gen, prop, runs, seed) => {
|
||||
const go = (i, s) => {
|
||||
if (i >= runs) return { ok: true, runs }
|
||||
const [value, next] = run(gen, s)
|
||||
return prop(value) ? go(i + 1, next) : { ok: false, run: i, seed: s, value }
|
||||
}
|
||||
return go(0, seed)
|
||||
}
|
||||
|
||||
/** Every list that is `xs` with exactly one element removed, in the order of the removed index.
|
||||
* @type {<A>(xs: A[]) => A[][]} */
|
||||
export const removals = (xs) => xs.map((_, i) => [...xs.slice(0, i), ...xs.slice(i + 1)])
|
||||
|
||||
/** Keep removing one element for as long as the list still fails; the result is 1-minimal.
|
||||
* @type {<A>(xs: A[], fails: (xs: A[]) => boolean) => A[]} */
|
||||
export const minimize = (xs, fails) => {
|
||||
const smaller = removals(xs).find(fails)
|
||||
return smaller === undefined ? xs : minimize(smaller, fails)
|
||||
}
|
||||
|
||||
const neighbours = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1
|
||||
|
||||
// Each invariant: its name, and a predicate that is true when the state is fine.
|
||||
/** @type {[string, (s: GameState) => boolean][]} */
|
||||
const invariants = [
|
||||
['snake-not-empty', (s) => s.snake.length > 0],
|
||||
['snake-in-bounds', (s) => s.snake.every((p) => inBounds(s, p))],
|
||||
['snake-no-overlap', (s) => s.snake.every((p, i) => !contains(s.snake.slice(i + 1), p, pointEquals))],
|
||||
['snake-connected', (s) => s.snake.slice(1).every((p, i) => neighbours(s.snake[i], p))],
|
||||
['apple-in-bounds', (s) => s.apple === null || inBounds(s, s.apple)],
|
||||
['apple-not-on-snake', (s) => s.apple === null || !contains(s.snake, s.apple, pointEquals)],
|
||||
['turn-not-reversed', (s) => s.nextDir !== opposite(s.dir)],
|
||||
['queued-not-reversed', (s) => s.queued === null || s.queued !== opposite(s.nextDir)],
|
||||
]
|
||||
|
||||
/** The names of the invariants that `state` breaks, in the order of the table above.
|
||||
* @type {(state: GameState) => string[]} */
|
||||
export const violations = (state) => invariants.filter(([, holds]) => !holds(state)).map(([name]) => name)
|
||||
|
||||
// Total also for an empty snake (an illegal state, but `moveGaps` should not be the one to crash on it).
|
||||
const sameHead = (a, b) => (a.length === 0 || b.length === 0 ? a.length === b.length : pointEquals(a[0], b[0]))
|
||||
|
||||
/** The numbers of states between consecutive moves of the snake's head.
|
||||
* @type {(states: GameState[]) => number[]} */
|
||||
export const moveGaps = (states) => {
|
||||
const moved = states
|
||||
.map((s, i) => (i > 0 && !sameHead(s.snake, states[i - 1].snake) ? i : -1))
|
||||
.filter((i) => i >= 0)
|
||||
return moved.slice(1).map((i, k) => i - moved[k])
|
||||
}
|
||||
|
||||
// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
|
||||
|
||||
/** @type {Direction[]} */
|
||||
const directions = ['Up', 'Down', 'Left', 'Right']
|
||||
|
||||
// Extend `snake` at its tail by up to `left` more cells, each a random free in-bounds neighbour of the last cell.
|
||||
const grow = (width, height, snake, left) => {
|
||||
if (left <= 0) return pure(snake)
|
||||
const last = snake[snake.length - 1]
|
||||
const options = directions
|
||||
.map((d) => move(last, d))
|
||||
.filter((p) => inBounds({ width, height }, p) && !contains(snake, p, pointEquals))
|
||||
return options.length === 0 ? pure(snake) : flatMap(elementOf(options), (p) => grow(width, height, [...snake, p], left - 1))
|
||||
}
|
||||
|
||||
/** A random snake, head first: a random head, then a random walk that never leaves the board or crosses itself.
|
||||
* @type {(width: number, height: number, maxLen: number) => Random<Point[]>} */
|
||||
export const genSnake = (width, height, maxLen) =>
|
||||
flatMap(intBelow(width), (x) => flatMap(intBelow(height), (y) => grow(width, height, [point(x, y)], maxLen - 1)))
|
||||
|
||||
// The direction the head moved in to get from the second cell to the first ('Right' for a one-cell snake).
|
||||
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).
|
||||
* @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,
|
||||
}))))
|
||||
}))
|
||||
|
||||
/**
|
||||
* The step of Module 3, written from the rules instead of from `advance`: build the snake the step would give,
|
||||
* then ask `violations` whether that snake is on the board and does not overlap itself.
|
||||
* @type {(state: GameState) => GameState}
|
||||
*/
|
||||
export const advanceSpec = (state) => {
|
||||
if (state.status !== 'Playing') return state
|
||||
const head = move(state.snake[0], state.nextDir)
|
||||
const eating = state.apple !== null && pointEquals(head, state.apple)
|
||||
const snake = [head, ...(eating ? state.snake : dropLast(state.snake))]
|
||||
const broken = violations({ ...state, snake }).some((v) => v === 'snake-in-bounds' || v === 'snake-no-overlap')
|
||||
return broken
|
||||
? { ...state, dir: state.nextDir, status: 'GameOver' }
|
||||
: { ...state, snake, dir: state.nextDir, apple: eating ? null : state.apple, score: state.score + (eating ? 1 : 0) }
|
||||
}
|
||||
+37
-9
@@ -1,16 +1,41 @@
|
||||
// m05 game: one `update` function turns every action into a new state; replay is a fold over 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.
|
||||
import { turn } from './rules.js'
|
||||
import { opposite } from './model.js'
|
||||
import { tick, newGame } from './spawn.js'
|
||||
/** @import { Direction, Action, GameState } from './types' */
|
||||
|
||||
/** How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2. */
|
||||
/**
|
||||
* What `update` does with `Turn(dir)` from Module 10 on. With no turn pending (`nextDir == dir`) it is the Module 3
|
||||
* `turn`. With a turn pending, a key that is neither `nextDir` nor its opposite is remembered in `queued`
|
||||
* (replacing an earlier one) and taken on the step after; any other key changes nothing.
|
||||
* @type {(state: GameState, dir: Direction) => GameState}
|
||||
*/
|
||||
export const steer = (state, dir) => {
|
||||
if (state.status !== 'Playing') return state
|
||||
if (state.nextDir === state.dir) return turn(state, dir)
|
||||
return dir !== state.nextDir && dir !== opposite(state.nextDir) ? { ...state, queued: dir } : 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.
|
||||
const takeQueued = (state) =>
|
||||
state.status === 'Playing' && state.queued !== null ? { ...state, nextDir: state.queued, queued: null } : state
|
||||
|
||||
// One game step as `update` takes it: `tick`, then the queued turn.
|
||||
const step = (state) => takeQueued(tick(state))
|
||||
|
||||
/** How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2.
|
||||
* @type {(score: number) => number} */
|
||||
export const movesEvery = (score) => Math.max(2, 8 - Math.floor(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.
|
||||
const frame = (state) => {
|
||||
if (state.status !== 'Playing') return state
|
||||
const counted = { ...state, frame: state.frame + 1 }
|
||||
return counted.frame % movesEvery(counted.score) === 0 ? tick(counted) : counted
|
||||
const n = state.frame + 1
|
||||
return n >= movesEvery(state.score) ? step({ ...state, frame: 0 }) : { ...state, frame: n }
|
||||
}
|
||||
|
||||
// Pause and resume only make sense in those two statuses; a finished game stays finished.
|
||||
@@ -19,10 +44,11 @@ const togglePause = (state) =>
|
||||
: state.status === 'Paused' ? { ...state, status: 'Playing' }
|
||||
: state
|
||||
|
||||
/** @type {(state: GameState, action: Action) => GameState} */
|
||||
export const update = (state, action) => {
|
||||
switch (action.tag) {
|
||||
case 'Tick': return tick(state)
|
||||
case 'Turn': return turn(state, action.dir)
|
||||
case 'Tick': return step(state)
|
||||
case 'Turn': return steer(state, action.dir)
|
||||
case 'TogglePause': return togglePause(state)
|
||||
case 'Restart': return newGame(state.seed)
|
||||
case 'Frame': return frame(state)
|
||||
@@ -30,10 +56,12 @@ export const update = (state, action) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fold the actions over the state, left to right. */
|
||||
/** Fold the actions over the state, left to right.
|
||||
* @type {(state: GameState, actions: Action[]) => GameState} */
|
||||
export const replay = (state, actions) => actions.reduce(update, state)
|
||||
|
||||
/** Like replay, but keeps each intermediate state. Recursion instead of mutation: no array is ever pushed to. */
|
||||
/** Like replay, but keeps each intermediate state. Recursion instead of mutation: no array is ever pushed to.
|
||||
* @type {(state: GameState, actions: Action[]) => GameState[]} */
|
||||
export const history = (state, actions) =>
|
||||
actions.length === 0
|
||||
? [state]
|
||||
|
||||
@@ -12,6 +12,7 @@ const directions = {
|
||||
const commands = { ' ': Action.TogglePause, r: Action.Restart, R: Action.Restart }
|
||||
|
||||
// hasOwn, not `key in table`: "constructor" and "toString" are inherited properties, not keys we mapped.
|
||||
/** @type {(key: string) => Action | null} */
|
||||
export const keyToAction = (key) =>
|
||||
Object.hasOwn(directions, key) ? Action.Turn(directions[key])
|
||||
: Object.hasOwn(commands, key) ? commands[key]
|
||||
|
||||
+23
-4
@@ -1,34 +1,53 @@
|
||||
// m07 io: effects as values. An IO is a plain-data description of a program; nothing runs until interpreted.
|
||||
// {tag:'Pure', value} - done, with a value
|
||||
// {tag:'Effect', op, k} - perform `op`, then continue with `k(result)`
|
||||
/** @import { Op, Shape, Scene, IO, Trace } from './types' */
|
||||
|
||||
/** @type {<A>(a: A) => IO<A>} */
|
||||
export const pure = (value) => ({ tag: 'Pure', value })
|
||||
|
||||
/** @type {<A>(op: Op) => IO<A>} */
|
||||
export const effect = (op) => ({ tag: 'Effect', op, k: pure })
|
||||
|
||||
// Pure: apply f now. Effect: keep the op, and extend the continuation so f runs after k.
|
||||
// Nothing is performed here, we only build a bigger description.
|
||||
/** @type {<A, B>(io: IO<A>, f: (a: A) => IO<B>) => IO<B>} */
|
||||
export const flatMap = (io, f) =>
|
||||
io.tag === 'Pure'
|
||||
? f(io.value)
|
||||
: { tag: 'Effect', op: io.op, k: (result) => flatMap(io.k(result), f) }
|
||||
|
||||
/** @type {<A, B>(io: IO<A>, f: (a: A) => B) => IO<B>} */
|
||||
export const map = (io, f) => flatMap(io, (a) => pure(f(a)))
|
||||
|
||||
/** @type {(ios: IO<any>[]) => IO<undefined>} */
|
||||
export const sequence = (ios) =>
|
||||
ios.reduceRight((rest, io) => flatMap(io, () => rest), pure(undefined))
|
||||
|
||||
/** @type {(shape: Shape) => IO<undefined>} */
|
||||
export const draw = (shape) => effect({ tag: 'Draw', shape })
|
||||
|
||||
/** @type {IO<number>} */
|
||||
export const randomSeed = effect({ tag: 'RandomSeed' })
|
||||
|
||||
/** @type {(scene: Scene) => IO<undefined>} */
|
||||
export const drawScene = (scene) => sequence(scene.map(draw))
|
||||
|
||||
// Answer each op with a canned result, collecting the ops as we go (recursion, no mutation).
|
||||
const answer = (op, seed) => (op.tag === 'RandomSeed' ? seed : undefined)
|
||||
// m10: the stored best score. Reading it answers a number; saving answers nothing, like a draw.
|
||||
/** @type {IO<number>} */
|
||||
export const loadBest = effect({ tag: 'LoadBest' })
|
||||
|
||||
export const trace = (io, seed) => {
|
||||
/** @type {(score: number) => IO<undefined>} */
|
||||
export const saveBest = (score) => effect({ tag: 'SaveBest', score })
|
||||
|
||||
// Answer each op with a canned result, collecting the ops as we go (recursion, no mutation).
|
||||
// `best` (m10) answers LoadBest, the way `seed` answers RandomSeed; it defaults to 0 so m07 calls stay valid.
|
||||
const answer = (op, seed, best) =>
|
||||
op.tag === 'RandomSeed' ? seed : op.tag === 'LoadBest' ? best : undefined
|
||||
|
||||
/** @type {<A>(io: IO<A>, seed: number, best?: number) => Trace<A>} */
|
||||
export const trace = (io, seed, best = 0) => {
|
||||
if (io.tag === 'Pure') return { result: io.value, ops: [] }
|
||||
const rest = trace(io.k(answer(io.op, seed)), seed)
|
||||
const rest = trace(io.k(answer(io.op, seed, best)), seed, best)
|
||||
return { result: rest.result, ops: [io.op, ...rest.ops] }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse.
|
||||
import { LW, LH, LCELL, initialLife, LifeAction } from './model.js'
|
||||
import { lifeUpdate } from './rules.js'
|
||||
import { lifeView } from './view.js'
|
||||
import { map as mapIO, randomSeed, drawScene } from '../io.js'
|
||||
import { keyDowns, clicks, interval, map, filterMap, merge, scan, startWith, clampTime } from '../stream.js'
|
||||
import { MAX_GAP_MS } from '../app.js'
|
||||
/** @import { LifeState, Stream, Program } from '../types' */
|
||||
|
||||
/** Milliseconds between two generations while the world runs. */
|
||||
export const GEN_MS = 150
|
||||
|
||||
const keys = {
|
||||
' ': LifeAction.TogglePause,
|
||||
n: LifeAction.Step, N: LifeAction.Step,
|
||||
c: LifeAction.Clear, C: LifeAction.Clear,
|
||||
r: LifeAction.Randomize, R: LifeAction.Randomize,
|
||||
}
|
||||
|
||||
/** A key to an action, or null.
|
||||
* @type {(key: string) => LifeAction | null} */
|
||||
export const lifeKey = (key) => (Object.hasOwn(keys, key) ? keys[key] : null)
|
||||
|
||||
/** A click at canvas pixel (x, y) to `Toggle` of the cell under it, or null when it is not on the board.
|
||||
* @type {(x: number, y: number) => LifeAction | null} */
|
||||
export const lifeClick = (x, y) => {
|
||||
const cell = { x: Math.floor(x / LCELL), y: Math.floor(y / LCELL) }
|
||||
return x >= 0 && y >= 0 && cell.x < LW && cell.y < LH ? LifeAction.Toggle(cell) : null
|
||||
}
|
||||
|
||||
/** @type {Stream<LifeAction>} */
|
||||
export const lifeActions = merge(
|
||||
merge(
|
||||
map(interval(GEN_MS), () => LifeAction.Frame),
|
||||
filterMap(keyDowns, lifeKey)),
|
||||
filterMap(clicks, (p) => lifeClick(p.x, p.y)))
|
||||
|
||||
/** @type {(seed: number, actions: Stream<LifeAction>) => Stream<LifeState>} */
|
||||
export const lifeStates = (seed, actions) =>
|
||||
startWith(scan(actions, lifeUpdate, initialLife(seed)), initialLife(seed))
|
||||
|
||||
/** @type {Program} */
|
||||
export const lifeMain = mapIO(randomSeed, (seed) =>
|
||||
clampTime(map(lifeStates(seed, lifeActions), (state) => drawScene(lifeView(state))), MAX_GAP_MS))
|
||||
@@ -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))
|
||||
@@ -0,0 +1,28 @@
|
||||
// m13 life model: the whole world is one value, like the snake and the match.
|
||||
|
||||
/** @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, paused, the given seed, generation 0.
|
||||
* @type {(seed: number) => LifeState} */
|
||||
export const initialLife = (seed) => ({ cells: [], running: false, seed, generation: 0 })
|
||||
|
||||
/** Everything that can happen to the world, as data. `Toggle` carries the cell that was clicked.
|
||||
* @type {{Frame: LifeAction, Toggle: (cell: Point) => LifeAction, TogglePause: LifeAction, Step: LifeAction, Clear: LifeAction, Randomize: LifeAction}} */
|
||||
export const LifeAction = {
|
||||
Frame: { tag: 'Frame' },
|
||||
Toggle: (cell) => ({ tag: 'Toggle', cell }),
|
||||
TogglePause: { tag: 'TogglePause' },
|
||||
Step: { tag: 'Step' },
|
||||
Clear: { tag: 'Clear' },
|
||||
Randomize: { tag: 'Randomize' },
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// m13 life rules: the next generation is a function of the whole old board, and nothing else.
|
||||
import { LW, LH } from './model.js'
|
||||
import { intBelow } from '../random.js'
|
||||
/** @import { Point, LifeState, LifeAction } from '../types' */
|
||||
|
||||
// Every cell of the board, row-major (y, then x). Filtering this list keeps any result in canonical order for free.
|
||||
const board = Array.from({ length: LW * LH }, (_, i) => ({ x: i % LW, y: Math.floor(i / LW) }))
|
||||
|
||||
/** Is `p` on the board? */
|
||||
const onBoard = (p) => 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.
|
||||
const index = (p) => p.y * LW + p.x
|
||||
|
||||
const 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). */
|
||||
const neighbours = (p) => offsets.map(([dx, dy]) => ({ x: p.x + dx, y: p.y + dy })).filter(onBoard)
|
||||
|
||||
/**
|
||||
* One generation, B3/S23: a live cell with 2 or 3 live neighbours stays alive, a dead cell with exactly 3 is born,
|
||||
* every other cell is dead afterwards. Every cell is judged on the *old* list only. A Set of the old cells' indices
|
||||
* makes "is this neighbour alive?" one lookup, so a generation is 1200 x 8 lookups however many cells live.
|
||||
* @type {(cells: Point[]) => Point[]}
|
||||
*/
|
||||
export const lifeStep = (cells) => {
|
||||
const alive = new Set(cells.map(index))
|
||||
const isAlive = (p) => alive.has(index(p))
|
||||
const count = (p) => neighbours(p).filter(isAlive).length
|
||||
return board.filter((p) => {
|
||||
const n = count(p)
|
||||
return n === 3 || (n === 2 && isAlive(p))
|
||||
})
|
||||
}
|
||||
|
||||
/** Add a dead cell or remove a live one, keeping the list sorted row-major without duplicates. Off the board: unchanged. */
|
||||
const toggle = (cells, p) =>
|
||||
!onBoard(p) ? cells
|
||||
: cells.some((c) => index(c) === index(p)) ? cells.filter((c) => index(c) !== index(p))
|
||||
: [...cells.filter((c) => index(c) < index(p)), { x: p.x, y: p.y }, ...cells.filter((c) => index(c) > index(p))]
|
||||
|
||||
/**
|
||||
* A random board from `seed`: one `intBelow(4)` per cell in row-major order, the cell alive when the draw is 0
|
||||
* (about one cell in four). Returns `[cells, nextSeed]`.
|
||||
* @type {(seed: number) => [Point[], number]}
|
||||
*/
|
||||
const randomCells = (seed) => {
|
||||
const [draws, next] = board.reduce(
|
||||
([ds, s], _) => {
|
||||
const [d, s2] = intBelow(4)(s)
|
||||
return [[...ds, d], s2]
|
||||
},
|
||||
/** @type {[number[], number]} */ ([[], seed]))
|
||||
return [board.filter((_, i) => draws[i] === 0), next]
|
||||
}
|
||||
|
||||
const nextGeneration = (state) => ({ ...state, cells: lifeStep(state.cells), generation: state.generation + 1 })
|
||||
|
||||
/** @type {(state: LifeState, action: LifeAction) => LifeState} */
|
||||
export const lifeUpdate = (state, action) => {
|
||||
switch (action.tag) {
|
||||
case 'Frame': return state.running ? nextGeneration(state) : state
|
||||
case 'Step': return state.running ? state : nextGeneration(state)
|
||||
case 'TogglePause': return { ...state, running: !state.running }
|
||||
// Clicking edits the world whether it runs or not: the click is one action between two generations.
|
||||
case 'Toggle': return { ...state, cells: toggle(state.cells, action.cell) }
|
||||
case 'Clear': return { ...state, cells: [], generation: 0 }
|
||||
case 'Randomize': {
|
||||
const [cells, seed] = randomCells(state.seed)
|
||||
return { ...state, cells, seed, generation: 0 }
|
||||
}
|
||||
default: return state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// m13 life view: live cells as squares, and a status strip under the board.
|
||||
import { LW, LH, LCELL, STRIP } from './model.js'
|
||||
/** @import { LifeState, Scene, Shape, Align } from '../types' */
|
||||
|
||||
/** @type {(x: number, y: number, w: number, h: number, color: string) => Shape} */
|
||||
const rect = (x, y, w, h, color) => ({ tag: 'Rect', x, y, w, h, color })
|
||||
/** @type {(x: number, y: number, str: string, align: Align) => Shape} */
|
||||
const text = (x, y, str, align) => ({ tag: 'Text', x, y, text: str, color: '#ffffff', size: 14, align })
|
||||
|
||||
const W = LW * LCELL
|
||||
const H = LH * LCELL
|
||||
|
||||
/** @type {(state: LifeState) => Scene} */
|
||||
export const lifeView = (state) => [
|
||||
rect(0, 0, W, H, '#111111'),
|
||||
...state.cells.map((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, state.running ? 'Running: Space pauses' : 'Paused: Space runs, N steps', 'right'),
|
||||
]
|
||||
@@ -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))
|
||||
+17
-7
@@ -4,12 +4,16 @@
|
||||
/** @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. */
|
||||
/** A grid cell.
|
||||
* @type {(x: number, y: number) => Point} */
|
||||
export const point = (x, y) => ({ x, y })
|
||||
|
||||
/** 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) => a.x === b.x && a.y === b.y
|
||||
|
||||
const deltas = {
|
||||
@@ -19,15 +23,19 @@ const deltas = {
|
||||
Right: point(1, 0),
|
||||
}
|
||||
|
||||
/** The unit step for a direction (y grows downward). */
|
||||
/** The unit step for a direction (y grows downward).
|
||||
* @type {(dir: Direction) => Point} */
|
||||
export const delta = (dir) => deltas[dir]
|
||||
|
||||
/** @type {Record<Direction, Direction>} */
|
||||
const opposites = { Up: 'Down', Down: 'Up', Left: 'Right', Right: 'Left' }
|
||||
|
||||
/** The direction pointing the other way. */
|
||||
/** The direction pointing the other way.
|
||||
* @type {(dir: Direction) => Direction} */
|
||||
export const opposite = (dir) => opposites[dir]
|
||||
|
||||
/** Everything that can happen to the game, as data. `Turn` is a function because it carries a direction. */
|
||||
/** Everything that can happen to the game, as data. `Turn` is a function because it carries a direction.
|
||||
* @type {{Tick: Action, Turn: (dir: Direction) => Action, TogglePause: Action, Restart: Action, Frame: Action}} */
|
||||
export const Action = {
|
||||
Tick: { tag: 'Tick' },
|
||||
Turn: (dir) => ({ tag: 'Turn', dir }),
|
||||
@@ -36,7 +44,8 @@ export const Action = {
|
||||
Frame: { tag: 'Frame' },
|
||||
}
|
||||
|
||||
/** A fresh 20x20 game: 3-segment snake heading Right, no apple yet. */
|
||||
/** A fresh 20x20 game: 3-segment snake heading Right, no apple yet, nothing queued (`queued` is used from m10 on).
|
||||
* @type {(seed: number) => GameState} */
|
||||
export const initialState = (seed) => ({
|
||||
width: 20,
|
||||
height: 20,
|
||||
@@ -48,4 +57,5 @@ export const initialState = (seed) => ({
|
||||
score: 0,
|
||||
status: 'Playing',
|
||||
frame: 0,
|
||||
queued: null,
|
||||
})
|
||||
@@ -3,7 +3,9 @@ import { initialPong, PongAction } from './model.js'
|
||||
import { pongUpdate } from './rules.js'
|
||||
import { pongView } from './view.js'
|
||||
import { map as mapIO, randomSeed, drawScene } from '../io.js'
|
||||
import { keyDowns, keyUps, interval, map, filterMap, merge, scan, startWith } from '../stream.js'
|
||||
import { keyDowns, keyUps, interval, map, filterMap, merge, scan, startWith, clampTime } from '../stream.js'
|
||||
import { MAX_GAP_MS } from '../app.js'
|
||||
/** @import { PongState, Stream, Program } from '../types' */
|
||||
|
||||
export const FRAME_MS_PONG = 30
|
||||
|
||||
@@ -13,19 +15,23 @@ const keys = {
|
||||
}
|
||||
|
||||
// hasOwn, not `key in keys`: "constructor" and "toString" are inherited, not keys we mapped.
|
||||
/** @type {(isDown: boolean, key: string) => PongAction | null} */
|
||||
export const pongKey = (isDown, key) =>
|
||||
Object.hasOwn(keys, key) ? (isDown ? PongAction.Press : PongAction.Release)(keys[key])
|
||||
: (key === 'r' || key === 'R') && isDown ? PongAction.Restart
|
||||
: null
|
||||
|
||||
/** @type {Stream<PongAction>} */
|
||||
export const pongActions = merge(
|
||||
map(interval(FRAME_MS_PONG), () => PongAction.Frame),
|
||||
merge(
|
||||
filterMap(keyDowns, (key) => pongKey(true, key)),
|
||||
filterMap(keyUps, (key) => pongKey(false, key))))
|
||||
|
||||
/** @type {(seed: number, actions: Stream<PongAction>) => Stream<PongState>} */
|
||||
export const pongStates = (seed, actions) =>
|
||||
startWith(scan(actions, pongUpdate, initialPong(seed)), initialPong(seed))
|
||||
|
||||
/** @type {Program} */
|
||||
export const pongMain = mapIO(randomSeed, (seed) =>
|
||||
map(pongStates(seed, pongActions), (state) => drawScene(pongView(state))))
|
||||
clampTime(map(pongStates(seed, pongActions), (state) => drawScene(pongView(state))), MAX_GAP_MS))
|
||||
@@ -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,6 +3,7 @@
|
||||
/** @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
|
||||
@@ -17,6 +18,7 @@ export const CPU_X = 77
|
||||
/** Points needed to win the match. */
|
||||
export const WIN = 5
|
||||
|
||||
/** @type {(seed: number) => PongState} */
|
||||
export const initialPong = (seed) => ({
|
||||
ball: { x: 40, y: 25 },
|
||||
vel: { dx: 1, dy: 1 },
|
||||
@@ -29,7 +31,8 @@ export const initialPong = (seed) => ({
|
||||
frame: 0,
|
||||
})
|
||||
|
||||
/** Everything that can happen in a match, as data. */
|
||||
/** Everything that can happen in a match, as data.
|
||||
* @type {{Press: (key: 'Up'|'Down') => PongAction, Release: (key: 'Up'|'Down') => PongAction, Frame: PongAction, Restart: PongAction}} */
|
||||
export const PongAction = {
|
||||
Press: (key) => ({ tag: 'Press', key }),
|
||||
Release: (key) => ({ tag: 'Release', key }),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// m11 pong rules: one pure `pongUpdate`, built from small steps that each return a new piece of state.
|
||||
import { H, W, PADDLE, PLAYER_X, CPU_X, WIN, initialPong } from './model.js'
|
||||
import { nextUint32 } from '../random.js'
|
||||
/** @import { PongState, PongAction } from '../types' */
|
||||
|
||||
/** @type {(lo: number, hi: number, x: number) => number} */
|
||||
export const clamp = (lo, hi, x) => Math.min(hi, Math.max(lo, x))
|
||||
|
||||
const clampPaddle = (y) => clamp(0, H - PADDLE, y)
|
||||
@@ -62,11 +64,13 @@ const heldFlag = { Up: 'up', Down: 'down' }
|
||||
const setHeld = (state, key, value) =>
|
||||
Object.hasOwn(heldFlag, key) ? { ...state, held: { ...state.held, [heldFlag[key]]: value } } : state
|
||||
|
||||
/** @type {(state: PongState, action: PongAction) => PongState} */
|
||||
export const pongUpdate = (state, action) => {
|
||||
switch (action.tag) {
|
||||
case 'Press': return setHeld(state, action.key, true)
|
||||
case 'Release': return setHeld(state, action.key, false)
|
||||
case 'Restart': return initialPong(state.seed)
|
||||
// A new match, but the keys are still where the player's fingers are.
|
||||
case 'Restart': return { ...initialPong(state.seed), held: state.held }
|
||||
case 'Frame': return frame(state)
|
||||
default: return state
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// m11 pong view: the same Scene shapes as the snake view, so the same drawScene and runtime draw it.
|
||||
import { PCELL, PADDLE, PLAYER_X, CPU_X } from './model.js'
|
||||
/** @import { PongState, Scene, Shape } from '../types' */
|
||||
|
||||
/** @type {(x: number, y: number, w: number, h: number, color: string) => Shape} */
|
||||
const rect = (x, y, w, h, color) => ({ tag: 'Rect', x, y, w, h, color })
|
||||
/** @type {(x: number, y: number, str: string, size: number) => Shape} */
|
||||
const text = (x, y, str, size) => ({ tag: 'Text', x, y, text: str, color: '#ffffff', size, align: 'center' })
|
||||
|
||||
const paddle = (column, y) => rect(column * PCELL, y * PCELL, PCELL, PADDLE * PCELL, '#ffffff')
|
||||
@@ -14,6 +17,7 @@ const overlay = (state) =>
|
||||
]
|
||||
: []
|
||||
|
||||
/** @type {(state: PongState) => Scene} */
|
||||
export const pongView = (state) => [
|
||||
rect(0, 0, 640, 400, '#111111'),
|
||||
paddle(PLAYER_X, state.player),
|
||||
|
||||
+13
-6
@@ -1,7 +1,9 @@
|
||||
// m04 random: a tiny "state monad" for randomness. A Random<A> is a function seed -> [A, nextSeed],
|
||||
// so every draw is a pure function and the seed is passed along explicitly instead of hidden.
|
||||
/** @import { Random } from './types' */
|
||||
|
||||
/** mulberry32: a small, fast generator. `imul` is 32-bit integer multiplication (plain `*` would lose bits). */
|
||||
/** mulberry32: a small, fast generator. `imul` is 32-bit integer multiplication (plain `*` would lose bits).
|
||||
* @type {Random<number>} */
|
||||
export const nextUint32 = (seed) => {
|
||||
const s = (seed + 0x6D2B79F5) >>> 0 // `>>> 0` keeps numbers unsigned 32-bit
|
||||
const t1 = Math.imul(s ^ (s >>> 15), s | 1)
|
||||
@@ -9,23 +11,28 @@ export const nextUint32 = (seed) => {
|
||||
return [(t2 ^ (t2 >>> 14)) >>> 0, s]
|
||||
}
|
||||
|
||||
/** 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) => (seed) => [a, seed]
|
||||
|
||||
/** 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) => (seed) => {
|
||||
const [a, next] = r(seed)
|
||||
return [f(a), next]
|
||||
}
|
||||
|
||||
/** Run `r`, let `f` pick the next Random from its value, and run that with the seed `r` left behind. */
|
||||
/** Run `r`, let `f` pick the next Random from its value, 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) => (seed) => {
|
||||
const [a, next] = r(seed)
|
||||
return f(a)(next)
|
||||
}
|
||||
|
||||
/** A Random giving a number in `[0, n)`. */
|
||||
/** A Random giving a number in `[0, n)`.
|
||||
* @type {(n: number) => Random<number>} */
|
||||
export const intBelow = (n) => map(nextUint32, (v) => v % n)
|
||||
|
||||
/** 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) => r(seed)
|
||||
@@ -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))
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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.
|
||||
import { history } from './game.js'
|
||||
import { newGame } from './spawn.js'
|
||||
import { view, CELL } from './view.js'
|
||||
import { pure, drawScene } from './io.js'
|
||||
import { keyDowns, interval, map, filterMap, merge, scan, startWith, clampTime } from './stream.js'
|
||||
import { MAX_GAP_MS } from './app.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 */
|
||||
|
||||
/** Milliseconds between two frames of autoplay. */
|
||||
export const REPLAY_MS = 100
|
||||
|
||||
/** Every state of the recorded game, from the new game to the end: the recording replayed with `history`.
|
||||
* @type {(recording: Recording) => GameState[]} */
|
||||
export const frames = (recording) => history(newGame(recording.seed), recording.actions)
|
||||
|
||||
/** The viewer at the first frame, not playing. `last` is the index of the last frame (the number of actions).
|
||||
* @type {(last: number) => Viewer} */
|
||||
export const viewerStart = (last) => ({ index: 0, last, playing: false })
|
||||
|
||||
/** Everything the viewer can be asked to do, as data. `Beat` is one beat of the autoplay clock.
|
||||
* @type {{Next: ViewerAction, Prev: ViewerAction, First: ViewerAction, Last: ViewerAction, TogglePlay: ViewerAction, Beat: ViewerAction}} */
|
||||
export const ViewerAction = {
|
||||
Next: { tag: 'Next' },
|
||||
Prev: { tag: 'Prev' },
|
||||
First: { tag: 'First' },
|
||||
Last: { tag: 'Last' },
|
||||
TogglePlay: { tag: 'TogglePlay' },
|
||||
Beat: { tag: 'Beat' },
|
||||
}
|
||||
|
||||
const clampIndex = (viewer, index) => Math.min(viewer.last, Math.max(0, index))
|
||||
|
||||
// Where each action moves the viewer, and whether it plays afterwards (before the end-of-recording rule).
|
||||
const move = (viewer, action) => {
|
||||
switch (action.tag) {
|
||||
case 'Next': return { ...viewer, index: clampIndex(viewer, viewer.index + 1) }
|
||||
case 'Prev': return { ...viewer, index: clampIndex(viewer, viewer.index - 1) }
|
||||
case 'First': return { ...viewer, index: 0 }
|
||||
case 'Last': return { ...viewer, index: viewer.last }
|
||||
// Play from the end means: watch it again from the start.
|
||||
case 'TogglePlay':
|
||||
return viewer.playing ? { ...viewer, playing: false }
|
||||
: { ...viewer, index: viewer.index === viewer.last ? 0 : viewer.index, playing: true }
|
||||
case 'Beat': return viewer.playing ? { ...viewer, index: clampIndex(viewer, viewer.index + 1) } : viewer
|
||||
default: return viewer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One step of the viewer. Total: the index never leaves `[0, last]`, and the viewer never plays on the last frame,
|
||||
* so autoplay stops by itself at the end.
|
||||
* @type {(viewer: Viewer, action: ViewerAction) => Viewer}
|
||||
*/
|
||||
export const viewerUpdate = (viewer, action) => {
|
||||
const next = move(viewer, action)
|
||||
return { ...next, playing: next.playing && next.index < next.last }
|
||||
}
|
||||
|
||||
const keys = {
|
||||
ArrowRight: ViewerAction.Next, d: ViewerAction.Next, D: ViewerAction.Next,
|
||||
ArrowLeft: ViewerAction.Prev, a: ViewerAction.Prev, A: ViewerAction.Prev,
|
||||
Home: ViewerAction.First,
|
||||
End: ViewerAction.Last,
|
||||
' ': ViewerAction.TogglePlay,
|
||||
}
|
||||
|
||||
/** A key to a viewer action, or null.
|
||||
* @type {(key: string) => ViewerAction | null} */
|
||||
export const viewerKey = (key) => (Object.hasOwn(keys, key) ? keys[key] : null)
|
||||
|
||||
/** The game's own picture of the current frame, then the position at the top right and a hint at the bottom.
|
||||
* @type {(frames: GameState[], viewer: Viewer) => Scene} */
|
||||
export const viewerView = (frames, viewer) => {
|
||||
const game = frames[viewer.index]
|
||||
const w = game.width * CELL
|
||||
return [
|
||||
...view(game),
|
||||
{ tag: 'Text', x: w - 6, y: 18, text: `step ${viewer.index} / ${viewer.last}`, color: '#ffffff', size: 16, align: 'right' },
|
||||
{
|
||||
tag: 'Text', x: w / 2, y: game.height * CELL - 8,
|
||||
text: viewer.playing ? 'Space: pause' : 'Left/Right: step Home/End: jump Space: play',
|
||||
color: '#aaaaaa', size: 12, align: 'center',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** Beats every REPLAY_MS, merged with the viewer's keys.
|
||||
* @type {Stream<ViewerAction>} */
|
||||
export const replayActions = merge(
|
||||
map(interval(REPLAY_MS), () => ViewerAction.Beat),
|
||||
filterMap(keyDowns, viewerKey))
|
||||
|
||||
/** The viewer over time: the start first, then one viewer per action.
|
||||
* @type {(last: number, actions: Stream<ViewerAction>) => Stream<Viewer>} */
|
||||
export const viewerStates = (last, actions) =>
|
||||
startWith(scan(actions, viewerUpdate, viewerStart(last)), viewerStart(last))
|
||||
|
||||
/**
|
||||
* The whole viewer as a value. 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.
|
||||
* @type {(recording: Recording) => Program}
|
||||
*/
|
||||
export const replayMain = (recording) => {
|
||||
const fs = frames(recording)
|
||||
return pure(clampTime(map(viewerStates(fs.length - 1, replayActions), (v) => drawScene(viewerView(fs, v))), MAX_GAP_MS))
|
||||
}
|
||||
@@ -1,24 +1,29 @@
|
||||
// m03 rules: the pure game rules. Each function takes a state and returns a new one.
|
||||
import { contains, dropLast } from './util.js'
|
||||
import { pointEquals, point, delta, opposite } from './model.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) => {
|
||||
const d = delta(dir)
|
||||
return point(p.x + d.x, p.y + d.y)
|
||||
}
|
||||
|
||||
/** True if `p` lies on the board. */
|
||||
/** True if `p` lies on the board.
|
||||
* @type {(state: {width: number, height: number}, p: Point) => boolean} */
|
||||
export const inBounds = (state, p) => p.x >= 0 && p.x < state.width && p.y >= 0 && p.y < state.height
|
||||
|
||||
/**
|
||||
* Ask to turn. We compare with `dir` (where the snake last moved), not `nextDir`:
|
||||
* otherwise Up-then-Left within one tick would turn a Right-moving snake back onto itself.
|
||||
* @type {(state: GameState, dir: Direction) => GameState}
|
||||
*/
|
||||
export const turn = (state, dir) =>
|
||||
state.status === 'Playing' && dir !== opposite(state.dir) ? { ...state, nextDir: dir } : state
|
||||
|
||||
/** One step of the game. */
|
||||
/** One step of the game.
|
||||
* @type {(state: GameState) => GameState} */
|
||||
export const advance = (state) => {
|
||||
if (state.status !== 'Playing') return state
|
||||
const d = state.nextDir
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// The single impure place in the course (with browser.js and main.js): everything else only builds values.
|
||||
// Here we interpret IO descriptions against a Platform and hold the one mutable variable, the current stream.
|
||||
// Platform = { fillRect, fillText, randomSeed, startClock, onKeyDown, onKeyUp }
|
||||
// Platform = { fillRect, fillText, randomSeed, loadBest, saveBest, startClock, onKeyDown, onKeyUp, onClick }
|
||||
/** @import { IO, Platform, Program } from './types' */
|
||||
|
||||
// Perform one op on the platform and return its result.
|
||||
const perform = (op, platform) => {
|
||||
if (op.tag === 'RandomSeed') return platform.randomSeed()
|
||||
if (op.tag === 'LoadBest') return platform.loadBest()
|
||||
if (op.tag === 'SaveBest') return platform.saveBest(op.score)
|
||||
const s = op.shape
|
||||
return s.tag === 'Rect'
|
||||
? platform.fillRect(s.x, s.y, s.w, s.h, s.color)
|
||||
@@ -12,12 +15,14 @@ const perform = (op, platform) => {
|
||||
}
|
||||
|
||||
// A loop, not recursion: a scene is hundreds of ops and the stack must not grow with them.
|
||||
/** @type {<A>(io: IO<A>, platform: Platform) => A} */
|
||||
export const runIO = (io, platform) => {
|
||||
let current = io
|
||||
while (current.tag === 'Effect') current = current.k(perform(current.op, platform))
|
||||
return current.value
|
||||
}
|
||||
|
||||
/** @type {(main: Program, platform: Platform) => void} */
|
||||
export const runApp = (main, platform) => {
|
||||
let stream = runIO(main, platform)
|
||||
const feed = (input) => {
|
||||
@@ -27,5 +32,7 @@ export const runApp = (main, platform) => {
|
||||
}
|
||||
platform.onKeyDown((key) => feed({ tag: 'KeyDown', key }))
|
||||
platform.onKeyUp((key) => feed({ tag: 'KeyUp', key }))
|
||||
// m13: a fourth kind of input. Programs that do not listen for clicks simply ignore them.
|
||||
platform.onClick((x, y) => feed({ tag: 'Click', x, y }))
|
||||
platform.startClock((t) => feed({ tag: 'Time', t }))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// m07b safeio: a stack-safe IO. Same idea as m07's IO, plus one more constructor, so that `flatMap` only builds
|
||||
// data and no program shape can overflow the stack, however deeply its `flatMap`s are nested.
|
||||
// {tag:'Pure', value} - done, with a value
|
||||
// {tag:'Effect', op, k} - perform `op`, then continue with `k(answer)`
|
||||
// {tag:'FlatMap', io, f} - run `io`, then continue with `f(result)`
|
||||
// Ops are the same plain data as in m07: {tag:'Draw', shape} (answer undefined), {tag:'RandomSeed'} (answer a number);
|
||||
// the pure interpreter throws for any other op.
|
||||
/** @import { Op, SafeIO, Trace } from './types' */
|
||||
|
||||
/** @type {<A>(value: A) => SafeIO<A>} */
|
||||
export const pure = (value) => ({ tag: 'Pure', value })
|
||||
|
||||
/** @type {<A>(op: Op) => SafeIO<A>} */
|
||||
export const effect = (op) => ({ tag: 'Effect', op, k: pure })
|
||||
|
||||
// Only builds a node: never calls f, not even when io is Pure.
|
||||
/** @type {<A, B>(io: SafeIO<A>, f: (a: A) => SafeIO<B>) => SafeIO<B>} */
|
||||
export const flatMap = (io, f) => ({ tag: 'FlatMap', io, f })
|
||||
|
||||
/** @type {<A, B>(io: SafeIO<A>, f: (a: A) => B) => SafeIO<B>} */
|
||||
export const map = (io, f) => flatMap(io, (a) => pure(f(a)))
|
||||
|
||||
// A left fold on purpose: with SafeIO the nesting no longer matters.
|
||||
/** @type {(ios: SafeIO<any>[]) => SafeIO<undefined>} */
|
||||
export const sequence = (ios) => ios.reduce((acc, io) => flatMap(acc, () => io), pure(undefined))
|
||||
|
||||
// One rewrite towards a program whose top is a Pure or an Effect. Constant work, no recursion.
|
||||
// FlatMap(Pure(a), f) -> f(a)
|
||||
// FlatMap(Effect(op, k), f) -> Effect(op, r => FlatMap(k(r), f))
|
||||
// FlatMap(FlatMap(m, g), f) -> FlatMap(m, x => FlatMap(g(x), f)) (re-association)
|
||||
// Pure and Effect are already in that shape and come back unchanged.
|
||||
/** @type {<A>(io: SafeIO<A>) => SafeIO<A>} */
|
||||
export const step = (io) => {
|
||||
if (io.tag !== 'FlatMap') return io
|
||||
const { io: inner, f } = io
|
||||
if (inner.tag === 'Pure') return f(inner.value)
|
||||
if (inner.tag === 'Effect') return { tag: 'Effect', op: inner.op, k: (r) => flatMap(inner.k(r), f) }
|
||||
return flatMap(inner.io, (x) => flatMap(inner.f(x), f))
|
||||
}
|
||||
|
||||
// Given (not an exercise): a trampoline driver without loops or mutation.
|
||||
// `next(s)` returns `[s2, outs]`. Applies `next` until `isDone(s)`, returns `[last, allOuts]` (outs 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.
|
||||
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([]))
|
||||
}
|
||||
|
||||
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: rewrite a FlatMap, or answer an Effect and record its op.
|
||||
/** @type {<A>(io: SafeIO<A>, seed: number) => [SafeIO<A>, Op[]]} */
|
||||
export const traceStep = (io, seed) => {
|
||||
if (io.tag === 'FlatMap') return [step(io), []]
|
||||
if (io.tag === 'Effect') return [io.k(answer(io.op, seed)), [io.op]]
|
||||
return [io, []]
|
||||
}
|
||||
|
||||
/** @type {<A>(io: SafeIO<A>, seed: number) => Trace<A>} */
|
||||
export const trace = (io, seed) => {
|
||||
const [last, ops] = drive(io, (p) => p.tag === 'Pure', (p) => traceStep(p, seed))
|
||||
return { result: /** @type {any} */ (last).value, ops } // drive stops only at a Pure
|
||||
}
|
||||
@@ -3,14 +3,17 @@ import { range, contains } from './util.js'
|
||||
import { point, pointEquals, initialState } from './model.js'
|
||||
import { advance } from './rules.js'
|
||||
import { intBelow, run } from './random.js'
|
||||
/** @import { Point, GameState } from './types' */
|
||||
|
||||
/** Every board cell not covered by the snake, row-major (y outer, x inner). */
|
||||
/** Every board cell not covered by the snake, row-major (y outer, x inner).
|
||||
* @type {(state: {width: number, height: number, snake: Point[]}) => Point[]} */
|
||||
export const freeCells = (state) =>
|
||||
range(state.height)
|
||||
.flatMap((y) => range(state.width).map((x) => point(x, y)))
|
||||
.filter((p) => !contains(state.snake, p, pointEquals))
|
||||
|
||||
/** Put an apple on a random free cell; a full board means the player has won. */
|
||||
/** Put an apple on a random free cell; a full board means the player has won.
|
||||
* @type {(state: GameState) => GameState} */
|
||||
export const spawnApple = (state) => {
|
||||
if (state.apple !== null || state.status !== 'Playing') return state
|
||||
const cells = freeCells(state)
|
||||
@@ -19,8 +22,10 @@ export const spawnApple = (state) => {
|
||||
return { ...state, apple: cells[i], seed }
|
||||
}
|
||||
|
||||
/** One game step: move the snake, then make sure there is an apple. */
|
||||
/** One game step: move the snake, then make sure there is an apple.
|
||||
* @type {(state: GameState) => GameState} */
|
||||
export const tick = (state) => spawnApple(advance(state))
|
||||
|
||||
/** A fresh game with its first apple already placed. */
|
||||
/** A fresh game with its first apple already placed.
|
||||
* @type {(seed: number) => GameState} */
|
||||
export const newGame = (seed) => spawnApple(initialState(seed))
|
||||
@@ -0,0 +1,38 @@
|
||||
// m04 state (optional, Exercise 4.7): the general pattern behind Random. A State<S, A> is a function
|
||||
// s => [A, nextS]: it reads a state, produces a value and hands on a new state. Random<A> is State<seed, A>.
|
||||
// Nothing in the game uses this file; it is here to show that Random was one example of a bigger idea.
|
||||
/** @import { State } from './types' */
|
||||
|
||||
/** A State that gives `a` and leaves the state untouched.
|
||||
* @type {<S, A>(a: A) => State<S, A>} */
|
||||
export const pure = (a) => (s) => [a, s]
|
||||
|
||||
/** 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) => (s) => {
|
||||
const [a, next] = st(s)
|
||||
return [f(a), next]
|
||||
}
|
||||
|
||||
/** Run `st`, let `f` pick the next State from its value, 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) => (s) => {
|
||||
const [a, next] = st(s)
|
||||
return f(a)(next)
|
||||
}
|
||||
|
||||
/** A State whose value is the current state, which it leaves untouched.
|
||||
* @type {<S>(s: S) => [S, S]} */
|
||||
export const get = (s) => [s, s]
|
||||
|
||||
/** A State that replaces the state with `s` and gives `undefined` (there is no interesting value).
|
||||
* @type {<S>(s: S) => State<S, undefined>} */
|
||||
export const put = (s) => () => [undefined, s]
|
||||
|
||||
/** 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) => (s) => [undefined, f(s)]
|
||||
|
||||
/** 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) => st(s)
|
||||
+36
-1
@@ -1,13 +1,21 @@
|
||||
// m08 stream: an event stream is a pure automaton (a Mealy machine).
|
||||
// Stream<A> = { step: input => [outputs, nextStream] }
|
||||
// There is no hidden state: whatever a stream "remembers" lives in the next stream it returns.
|
||||
/** @import { Point, Input, Stream } from './types' */
|
||||
|
||||
// Sources may return themselves as the next stream, since they remember nothing.
|
||||
/** @type {Stream<string>} */
|
||||
export const keyDowns = { step: (i) => [i.tag === 'KeyDown' ? [i.key] : [], keyDowns] }
|
||||
|
||||
/** @type {Stream<string>} */
|
||||
export const keyUps = { step: (i) => [i.tag === 'KeyUp' ? [i.key] : [], keyUps] }
|
||||
|
||||
// (m13, given in src/) The position of every Click input, in canvas pixels. Like the key sources it remembers nothing.
|
||||
/** @type {Stream<Point>} */
|
||||
export const clicks = { step: (i) => [i.tag === 'Click' ? [{ x: i.x, y: i.y }] : [], clicks] }
|
||||
|
||||
// `last` is the time of the previous Time input. Multiples of ms in (last, t] fire, ascending.
|
||||
/** @type {(ms: number, last: number) => Stream<number>} */
|
||||
const intervalFrom = (ms, last) => ({
|
||||
step: (i) => {
|
||||
if (i.tag !== 'Time') return [[], intervalFrom(ms, last)]
|
||||
@@ -17,9 +25,11 @@ const intervalFrom = (ms, last) => ({
|
||||
},
|
||||
})
|
||||
|
||||
/** @type {(ms: number) => Stream<number>} */
|
||||
export const interval = (ms) => intervalFrom(ms, 0)
|
||||
|
||||
// Each combinator steps its inputs and wraps the *next* streams in the same combinator.
|
||||
/** @type {<A, B>(s: Stream<A>, f: (a: A) => B) => Stream<B>} */
|
||||
export const map = (s, f) => ({
|
||||
step: (i) => {
|
||||
const [out, next] = s.step(i)
|
||||
@@ -27,6 +37,7 @@ export const map = (s, f) => ({
|
||||
},
|
||||
})
|
||||
|
||||
/** @type {<A>(s: Stream<A>, p: (a: A) => boolean) => Stream<A>} */
|
||||
export const filter = (s, p) => ({
|
||||
step: (i) => {
|
||||
const [out, next] = s.step(i)
|
||||
@@ -34,8 +45,10 @@ export const filter = (s, p) => ({
|
||||
},
|
||||
})
|
||||
|
||||
export const filterMap = (s, f) => filter(map(s, f), (b) => b !== null)
|
||||
/** @type {<A, B>(s: Stream<A>, f: (a: A) => B | null) => Stream<B>} */
|
||||
export const filterMap = (s, f) => /** @type {any} */ (filter(map(s, f), (b) => b !== null))
|
||||
|
||||
/** @type {<A>(a: Stream<A>, b: Stream<A>) => Stream<A>} */
|
||||
export const merge = (a, b) => ({
|
||||
step: (i) => {
|
||||
const [outA, nextA] = a.step(i)
|
||||
@@ -45,6 +58,7 @@ export const merge = (a, b) => ({
|
||||
})
|
||||
|
||||
// The accumulator travels in the arguments of the next scan, not in a variable.
|
||||
/** @type {<A, B>(s: Stream<A>, f: (acc: B, a: A) => B, z: B) => Stream<B>} */
|
||||
export const scan = (s, f, z) => ({
|
||||
step: (i) => {
|
||||
const [out, next] = s.step(i)
|
||||
@@ -54,6 +68,7 @@ export const scan = (s, f, z) => ({
|
||||
})
|
||||
|
||||
// Only the first step is special; afterwards we are just `next`.
|
||||
/** @type {<A>(s: Stream<A>, v: A) => Stream<A>} */
|
||||
export const startWith = (s, v) => ({
|
||||
step: (i) => {
|
||||
const [out, next] = s.step(i)
|
||||
@@ -61,8 +76,28 @@ export const startWith = (s, v) => ({
|
||||
},
|
||||
})
|
||||
|
||||
// (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.
|
||||
/** @type {<A>(s: Stream<A>, maxGap: number, last: number, clock: number) => Stream<A>} */
|
||||
const clampFrom = (s, maxGap, last, clock) => ({
|
||||
step: (i) => {
|
||||
if (i.tag !== 'Time') {
|
||||
const [out, next] = s.step(i)
|
||||
return [out, clampFrom(next, maxGap, last, clock)]
|
||||
}
|
||||
const now = clock + Math.min(Math.max(i.t - last, 0), maxGap)
|
||||
const [out, next] = s.step({ tag: 'Time', t: now })
|
||||
return [out, clampFrom(next, maxGap, i.t, now)]
|
||||
},
|
||||
})
|
||||
|
||||
/** @type {<A>(s: Stream<A>, maxGap: number) => Stream<A>} */
|
||||
export const clampTime = (s, maxGap) => clampFrom(s, maxGap, 0, 0)
|
||||
|
||||
/** @type {<A>(s: Stream<A>, inputs: Input[]) => A[]} */
|
||||
export const run = (s, inputs) =>
|
||||
inputs.reduce(
|
||||
/** @type {(acc: [any[], typeof s], i: Input) => [any[], typeof s]} */
|
||||
([outs, stream], i) => {
|
||||
const [out, next] = stream.step(i)
|
||||
return [[...outs, ...out], next]
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
Vendored
+92
@@ -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 }
|
||||
+11
-5
@@ -1,18 +1,23 @@
|
||||
// m01 util: small helpers used by every later module.
|
||||
|
||||
/** `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) => Array.from({ length: n }, (_, i) => i)
|
||||
|
||||
/** `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) => xs.slice(0, -1)
|
||||
|
||||
/** 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) => xs.some((x) => eq(x, p))
|
||||
|
||||
/** `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) => fns.reduce((acc, f) => f(acc), x)
|
||||
|
||||
/** `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) => (x) => fns.reduceRight((acc, f) => f(acc), x)
|
||||
|
||||
/**
|
||||
@@ -21,6 +26,7 @@ export const compose = (...fns) => (x) => fns.reduceRight((acc, f) => f(acc), x)
|
||||
* We recurse even into children that are already frozen (a frozen parent can still hold
|
||||
* unfrozen children). `ancestors` is the path from the root to `obj`; passing it down
|
||||
* (instead of keeping a shared "seen" set) stops cyclic structures without any mutable state.
|
||||
* @type {<T>(obj: T, ancestors?: unknown[]) => T}
|
||||
*/
|
||||
export const deepFreeze = (obj, ancestors = []) => {
|
||||
if (obj === null || typeof obj !== 'object' || ancestors.includes(obj)) return obj
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// m06 view: the picture is plain data (a list of shapes); drawing it is somebody else's job (m07).
|
||||
/** @import { GameState, Scene, Shape, Align } from './types' */
|
||||
|
||||
/** Pixels per board cell. */
|
||||
export const CELL = 20
|
||||
|
||||
/** @type {(x: number, y: number, w: number, h: number, color: string) => Shape} */
|
||||
const rect = (x, y, w, h, color) => ({ tag: 'Rect', x, y, w, h, color })
|
||||
/** @type {(x: number, y: number, str: string, size: number, align: Align) => Shape} */
|
||||
const text = (x, y, str, size, align) => ({ tag: 'Text', x, y, text: str, color: '#ffffff', size, align })
|
||||
|
||||
// One cell, inset by 1px so neighbouring segments show a gap.
|
||||
@@ -21,6 +24,7 @@ const overlay = (state) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {(state: GameState) => Scene} */
|
||||
export const view = (state) => [
|
||||
rect(0, 0, state.width * CELL, state.height * CELL, '#111111'),
|
||||
...(state.apple === null ? [] : [cell(state.apple, '#e53935')]),
|
||||
|
||||
Reference in new issue
Block a user