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
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"checkJs": true,
|
||||
"strict": false,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"target": "es2022",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["es2022", "dom"],
|
||||
"types": [],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pure Life</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 12px; background: #1b1b1b; color: #ddd; font: 14px system-ui, sans-serif; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
canvas { background: #111111; border: 2px solid #333; cursor: crosshair; }
|
||||
p { margin: 0; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Pure Life</h1>
|
||||
<canvas id="game" width="640" height="504"></canvas>
|
||||
<p>Click a cell to switch it on or off. Space runs and pauses, N steps one generation, R fills the board at random, C clears it.</p>
|
||||
<script type="module" src="src/life/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pure Snake replay</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 12px; background: #1b1b1b; color: #ddd; font: 14px system-ui, sans-serif; }
|
||||
h1 { margin: 0; font-size: 20px; }
|
||||
canvas { background: #111111; border: 2px solid #333; }
|
||||
p { margin: 0; color: #999; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Pure Snake: replay</h1>
|
||||
<canvas id="game" width="400" height="400"></canvas>
|
||||
<p>Right / D next step, Left / A previous, Home / End first / last, Space plays (Module 10, Exercise 10.8).</p>
|
||||
<script type="module" src="src/replay-main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+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')]),
|
||||
|
||||
+51
-1
@@ -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')
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
@@ -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
@@ -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')
|
||||
@@ -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') })
|
||||
@@ -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,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') },
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 @@ 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'),
|
||||
|
||||
@@ -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')
|
||||
@@ -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
@@ -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')
|
||||
@@ -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,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
@@ -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
@@ -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')
|
||||
@@ -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
@@ -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')
|
||||
@@ -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
@@ -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,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 }
|
||||
+12
-6
@@ -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')
|
||||
@@ -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')
|
||||
@@ -49,6 +49,13 @@ test('m01 deepFreeze reaches unfrozen children of an already-frozen parent', ()
|
||||
const o = deepFreeze({ a: Object.freeze({ b: { c: 1 } }) })
|
||||
assert.ok(Object.isFrozen(o.a.b))
|
||||
})
|
||||
test('m01 deepFreeze leaves null and other non-objects alone', () => {
|
||||
const o = deepFreeze({ apple: null, score: 3, name: 'x', missing: undefined })
|
||||
assert.ok(Object.isFrozen(o))
|
||||
assert.equal(deepFreeze(null), null)
|
||||
assert.equal(deepFreeze(7), 7)
|
||||
})
|
||||
|
||||
test('m01 deepFreeze handles cyclic objects', () => {
|
||||
const o = { child: {} }
|
||||
o.child.parent = o
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { pure, map, flatMap, get, put, modify, run } from '../src/state.js'
|
||||
import * as Random from '../src/random.js'
|
||||
|
||||
// Small initial states for the "behaves like" checks: two States behave alike when they give the same pair from each.
|
||||
const starts = [0, 1, 7, 42]
|
||||
const sameOnAll = (st1, st2) => starts.forEach((s) => assert.deepStrictEqual(run(st1, s), run(st2, s), `from state ${s}`))
|
||||
|
||||
// A counter step: gives the current number times 10 and adds 1 to the state. Built inside the tests, so that a stub
|
||||
// that throws fails each test on its own instead of the whole file at load time.
|
||||
const step = () => flatMap(get, (n) => map(put(n + 1), () => n * 10))
|
||||
|
||||
test('m04 State pure gives its value and leaves the state alone', () =>
|
||||
assert.deepStrictEqual(run(pure('x'), 99), ['x', 99]))
|
||||
|
||||
test('m04 State map applies f to the value and keeps the state from the underlying step', () =>
|
||||
assert.deepStrictEqual(run(map(step(), (v) => v + 1), 4), [41, 5]))
|
||||
|
||||
test('m04 State flatMap threads the state through both steps', () =>
|
||||
assert.deepStrictEqual(run(flatMap(step(), (a) => map(step(), (b) => [a, b])), 4), [[40, 50], 6]))
|
||||
|
||||
test('m04 State get gives the state as the value and leaves it alone', () =>
|
||||
assert.deepStrictEqual(run(get, 'board'), ['board', 'board']))
|
||||
|
||||
test('m04 State put replaces the state and gives undefined', () =>
|
||||
assert.deepStrictEqual(run(put(5), 99), [undefined, 5]))
|
||||
|
||||
test('m04 State modify applies f to the state and gives undefined', () =>
|
||||
assert.deepStrictEqual(run(modify((s) => s * 2), 21), [undefined, 42]))
|
||||
|
||||
test('m04 State law: left identity, flatMap(pure(a), f) behaves like f(a)', () => {
|
||||
const f = (a) => map(modify((s) => s * 2), () => a + 1)
|
||||
sameOnAll(flatMap(pure(3), f), f(3))
|
||||
})
|
||||
|
||||
test('m04 State law: right identity, flatMap(m, pure) behaves like m', () =>
|
||||
sameOnAll(flatMap(step(), pure), step()))
|
||||
|
||||
test('m04 State law: associativity, the grouping of flatMap does not matter', () => {
|
||||
const f = (a) => map(modify((s) => s * 2), () => a + 1)
|
||||
const g = (b) => flatMap(get, (s) => pure([b, s]))
|
||||
sameOnAll(flatMap(flatMap(step(), f), g), flatMap(step(), (a) => flatMap(f(a), g)))
|
||||
})
|
||||
|
||||
test('m04 State a counter hands out fresh labels in order', () => {
|
||||
const fresh = flatMap(get, (n) => map(put(n + 1), () => `L${n}`))
|
||||
const three = flatMap(fresh, (a) => flatMap(fresh, (b) => map(fresh, (c) => [a, b, c])))
|
||||
assert.deepStrictEqual(run(three, 0), [['L0', 'L1', 'L2'], 3])
|
||||
assert.deepStrictEqual(run(three, 7), [['L7', 'L8', 'L9'], 10])
|
||||
})
|
||||
|
||||
test('m04 State Random is State with the seed as S: the dice give the same values and seeds', () => {
|
||||
// One draw written only with State's get and put: read the seed, compute, store the next seed.
|
||||
const draw = flatMap(get, (seed) => {
|
||||
const [v, next] = Random.run(Random.nextUint32, seed)
|
||||
return map(put(next), () => v)
|
||||
})
|
||||
const d6State = map(draw, (v) => 1 + (v % 6))
|
||||
const twoDiceState = flatMap(d6State, (a) => map(d6State, (b) => a + b))
|
||||
const d6Random = Random.map(Random.nextUint32, (v) => 1 + (v % 6))
|
||||
const twoDiceRandom = Random.flatMap(d6Random, (a) => Random.map(d6Random, (b) => a + b))
|
||||
;[0, 1, 42, 4294967295].forEach((seed) => {
|
||||
assert.deepStrictEqual(run(draw, seed), Random.run(Random.nextUint32, seed), `draw from ${seed}`)
|
||||
assert.deepStrictEqual(run(d6State, seed), Random.run(d6Random, seed), `d6 from ${seed}`)
|
||||
assert.deepStrictEqual(run(twoDiceState, seed), Random.run(twoDiceRandom, seed), `twoDice from ${seed}`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import * as safe from '../src/safeio.js'
|
||||
import * as io from '../src/io.js'
|
||||
import { freeze as deepFreeze } from './helpers.js'
|
||||
|
||||
const { pure, effect, map, flatMap, sequence, step, drive, traceStep, trace } = safe
|
||||
|
||||
const r1 = deepFreeze({ tag: 'Rect', x: 0, y: 0, w: 1, h: 1, color: '#000000' })
|
||||
const t1 = deepFreeze({ tag: 'Text', x: 1, y: 2, text: 'hi', color: '#ffffff', size: 16, align: 'left' })
|
||||
const drawOp = (shape) => ({ tag: 'Draw', shape })
|
||||
const seedOp = { tag: 'RandomSeed' }
|
||||
const boom = () => { throw new Error('boom') }
|
||||
const N = 100000
|
||||
|
||||
// --- building -------------------------------------------------------------------------------------------------
|
||||
|
||||
test('m07b pure builds a Pure', () => assert.deepStrictEqual(pure(3), { tag: 'Pure', value: 3 }))
|
||||
|
||||
test('m07b effect(op) is an Effect whose continuation is pure', () => {
|
||||
const p = effect(seedOp)
|
||||
assert.equal(p.tag, 'Effect')
|
||||
assert.deepStrictEqual(p.op, seedOp)
|
||||
assert.deepStrictEqual(p.k(5), pure(5))
|
||||
})
|
||||
|
||||
test('m07b flatMap only builds a FlatMap node', () => {
|
||||
const m = pure(1)
|
||||
const f = (n) => pure(n + 1)
|
||||
const p = flatMap(m, f)
|
||||
assert.equal(p.tag, 'FlatMap')
|
||||
assert.equal(p.io, m)
|
||||
assert.equal(p.f, f)
|
||||
})
|
||||
|
||||
test('m07b building flatMap(pure(1), throwingF) does not throw', () =>
|
||||
assert.doesNotThrow(() => flatMap(pure(1), boom)))
|
||||
|
||||
test('m07b building map(io, throwingF) does not throw', () =>
|
||||
assert.doesNotThrow(() => map(effect(seedOp), boom)))
|
||||
|
||||
test('m07b tracing map(io, throwingF) runs f and throws', () =>
|
||||
assert.throws(() => trace(map(effect(seedOp), boom), 0), /boom/))
|
||||
|
||||
// --- step: the three rewrite rules ----------------------------------------------------------------------------
|
||||
|
||||
test('m07b step leaves a Pure unchanged', () => {
|
||||
const p = pure(1)
|
||||
assert.equal(step(p), p)
|
||||
})
|
||||
|
||||
test('m07b step leaves an Effect unchanged', () => {
|
||||
const p = effect(seedOp)
|
||||
assert.equal(step(p), p)
|
||||
})
|
||||
|
||||
test('m07b step of FlatMap(Pure(a), f) is f(a)', () =>
|
||||
assert.deepStrictEqual(step(flatMap(pure(3), (n) => pure(n * 2))), pure(6)))
|
||||
|
||||
test('m07b step of FlatMap(Effect(op, k), f) is an Effect with the same op that continues with FlatMap(k(r), f)', () => {
|
||||
const f = (n) => pure(n * 2)
|
||||
const s = step(flatMap(effect(seedOp), f))
|
||||
assert.equal(s.tag, 'Effect')
|
||||
assert.deepStrictEqual(s.op, seedOp)
|
||||
const next = s.k(5)
|
||||
assert.equal(next.tag, 'FlatMap')
|
||||
assert.deepStrictEqual(next.io, pure(5))
|
||||
assert.equal(next.f, f)
|
||||
})
|
||||
|
||||
test('m07b step re-associates FlatMap(FlatMap(m, g), f) into FlatMap(m, x => FlatMap(g(x), f))', () => {
|
||||
const m = effect(seedOp)
|
||||
const g = (n) => pure(n + 1)
|
||||
const f = (n) => pure(n * 2)
|
||||
const s = step(flatMap(flatMap(m, g), f))
|
||||
assert.equal(s.tag, 'FlatMap')
|
||||
assert.equal(s.io, m)
|
||||
const inner = s.f(4)
|
||||
assert.equal(inner.tag, 'FlatMap')
|
||||
assert.deepStrictEqual(inner.io, pure(5))
|
||||
assert.equal(inner.f, f)
|
||||
})
|
||||
|
||||
test('m07b step does not call the functions it re-associates', () =>
|
||||
assert.doesNotThrow(() => step(flatMap(flatMap(effect(seedOp), boom), boom))))
|
||||
|
||||
test('m07b step of a left-nested chain is one rewrite, not a whole walk', () => {
|
||||
// After one step, the top is still a FlatMap: step did constant work, the rest is left for later steps.
|
||||
const chain = Array.from({ length: N }).reduce((acc) => flatMap(acc, (n) => pure(n + 1)), pure(0))
|
||||
assert.equal(step(chain).tag, 'FlatMap')
|
||||
})
|
||||
|
||||
// --- the interpreter ------------------------------------------------------------------------------------------
|
||||
|
||||
test('m07b drive collects every output in order over 200 000 moves', () => {
|
||||
const [last, outs] = drive(0, (n) => n === 200000, (n) => [n + 1, n % 50000 === 0 ? [n] : []])
|
||||
assert.equal(last, 200000)
|
||||
assert.deepStrictEqual(outs, [0, 50000, 100000, 150000])
|
||||
})
|
||||
|
||||
test('m07b drive of a state that is already done makes no move', () =>
|
||||
assert.deepStrictEqual(drive(7, () => true, boom), [7, []]))
|
||||
|
||||
test('m07b traceStep answers an Effect and records its op', () => {
|
||||
const [next, ops] = traceStep(effect(seedOp), 9)
|
||||
assert.deepStrictEqual(next, pure(9))
|
||||
assert.deepStrictEqual(ops, [seedOp])
|
||||
})
|
||||
|
||||
test('m07b traceStep rewrites a FlatMap and records nothing', () => {
|
||||
const [next, ops] = traceStep(flatMap(pure(3), (n) => pure(n * 2)), 0)
|
||||
assert.deepStrictEqual(next, pure(6))
|
||||
assert.deepStrictEqual(ops, [])
|
||||
})
|
||||
|
||||
test('m07b traceStep leaves a Pure unchanged', () => {
|
||||
const p = pure(1)
|
||||
assert.deepStrictEqual(traceStep(p, 0), [p, []])
|
||||
})
|
||||
|
||||
test('m07b trace of pure(3) has result 3 and no ops', () =>
|
||||
assert.deepStrictEqual(trace(pure(3), 0), { result: 3, ops: [] }))
|
||||
|
||||
test('m07b trace answers RandomSeed with the seed and Draw with undefined', () =>
|
||||
assert.deepStrictEqual(
|
||||
trace(flatMap(effect(seedOp), (s) => map(effect(drawOp(r1)), () => s)), 9),
|
||||
{ result: 9, ops: [seedOp, drawOp(r1)] }))
|
||||
|
||||
test('m07b trace refuses operations of later modules', () =>
|
||||
assert.throws(() => trace(effect({ tag: 'LoadBest' }), 0), { message: "SafeIO.trace answers only Module 7's operations" }))
|
||||
|
||||
test('m07b sequence of nothing has no ops', () =>
|
||||
assert.deepStrictEqual(trace(sequence([]), 0), { result: undefined, ops: [] }))
|
||||
|
||||
test('m07b sequence runs effects left to right', () =>
|
||||
assert.deepStrictEqual(
|
||||
trace(sequence([effect(seedOp), effect(drawOp(t1)), effect(drawOp(r1))]), 0).ops,
|
||||
[seedOp, drawOp(t1), drawOp(r1)]))
|
||||
|
||||
// --- stack safety ---------------------------------------------------------------------------------------------
|
||||
|
||||
const leftDraws = (n) =>
|
||||
Array.from({ length: n }).reduce((acc, _, i) => flatMap(acc, () => effect(drawOp({ ...r1, x: i }))), pure(undefined))
|
||||
const rightDraws = (n) =>
|
||||
Array.from({ length: n }).reduceRight((rest, _, i) => flatMap(effect(drawOp({ ...r1, x: i })), () => rest), pure(undefined))
|
||||
|
||||
test('m07b 100 000 left-nested flatMaps of draws run, in order', () => {
|
||||
const { result, ops } = trace(leftDraws(N), 0)
|
||||
assert.equal(result, undefined)
|
||||
assert.equal(ops.length, N)
|
||||
assert.deepStrictEqual([ops[0], ops[N - 1]], [drawOp({ ...r1, x: 0 }), drawOp({ ...r1, x: N - 1 })])
|
||||
})
|
||||
|
||||
test('m07b 100 000 right-nested flatMaps of draws run, in order', () => {
|
||||
const { ops } = trace(rightDraws(N), 0)
|
||||
assert.equal(ops.length, N)
|
||||
assert.deepStrictEqual([ops[0], ops[N - 1]], [drawOp({ ...r1, x: 0 }), drawOp({ ...r1, x: N - 1 })])
|
||||
})
|
||||
|
||||
test('m07b 100 000 left-nested maps run without any op', () =>
|
||||
assert.deepStrictEqual(
|
||||
trace(Array.from({ length: N }).reduce((acc) => map(acc, (n) => n + 1), pure(0)), 0),
|
||||
{ result: N, ops: [] }))
|
||||
|
||||
test('m07b sequence of 100 000 draws runs', () =>
|
||||
assert.equal(trace(sequence(Array.from({ length: N }, (_, i) => effect(drawOp({ ...r1, x: i })))), 0).ops.length, N))
|
||||
|
||||
test('m07b a while loop over step is a complete interpreter for 100 000 left-nested flatMaps', () => {
|
||||
// What an impure runtime would do (a loop is allowed there): rewrite until the top is Pure or Effect, perform.
|
||||
const performed = []
|
||||
let current = leftDraws(N)
|
||||
while (current.tag !== 'Pure') {
|
||||
current = step(current)
|
||||
if (current.tag === 'Effect') {
|
||||
performed.push(current.op)
|
||||
current = current.k(undefined)
|
||||
}
|
||||
}
|
||||
assert.equal(performed.length, N)
|
||||
})
|
||||
|
||||
// --- the monad laws, observed through trace -------------------------------------------------------------------
|
||||
|
||||
const programs = {
|
||||
'a pure value': () => pure(4),
|
||||
'one effect': () => effect(seedOp),
|
||||
'a draw followed by reading the seed': () => flatMap(effect(drawOp(r1)), () => effect(seedOp)),
|
||||
}
|
||||
const f = (n) => flatMap(effect(drawOp({ ...r1, x: n })), () => pure(n * 2))
|
||||
const g = (n) => map(effect(seedOp), (s) => s + n)
|
||||
|
||||
Object.entries(programs).forEach(([name, program]) => {
|
||||
test(`m07b left identity holds for ${name}`, () => {
|
||||
const a = trace(program(), 5).result
|
||||
assert.deepStrictEqual(trace(flatMap(pure(a), f), 5), trace(f(a), 5))
|
||||
})
|
||||
test(`m07b right identity holds for ${name}`, () =>
|
||||
assert.deepStrictEqual(trace(flatMap(program(), pure), 5), trace(program(), 5)))
|
||||
test(`m07b associativity holds for ${name}`, () =>
|
||||
assert.deepStrictEqual(
|
||||
trace(flatMap(flatMap(program(), f), g), 5),
|
||||
trace(flatMap(program(), (x) => flatMap(f(x), g)), 5)))
|
||||
})
|
||||
|
||||
// --- the same programs under Module 7's IO --------------------------------------------------------------------
|
||||
|
||||
// Each program is written once against an interface {pure, effect, map, flatMap, sequence} and built with both.
|
||||
const shared = {
|
||||
'seed, then a draw that depends on it': (M) =>
|
||||
M.flatMap(M.effect(seedOp), (s) => M.map(M.effect(drawOp({ ...r1, x: s % 20 })), () => s)),
|
||||
'a sequence of three effects': (M) => M.sequence([M.effect(drawOp(t1)), M.effect(seedOp), M.effect(drawOp(r1))]),
|
||||
'200 left-nested flatMaps': (M) =>
|
||||
Array.from({ length: 200 }).reduce((acc, _, i) => M.flatMap(acc, (n) => M.map(M.effect(drawOp({ ...r1, x: i })), () => n + 1)), M.pure(0)),
|
||||
'nested on both sides': (M) =>
|
||||
M.flatMap(M.flatMap(M.effect(seedOp), (s) => M.flatMap(M.effect(drawOp(r1)), () => M.pure(s + 1))),
|
||||
(n) => M.map(M.sequence([M.effect(drawOp(t1)), M.effect(seedOp)]), () => n * 3)),
|
||||
}
|
||||
|
||||
Object.entries(shared).forEach(([name, program]) =>
|
||||
test(`m07b trace equals Module 7's trace for ${name}`, () =>
|
||||
assert.deepStrictEqual(trace(program(safe), 7), io.trace(program(io), 7))))
|
||||
@@ -23,6 +23,9 @@ test('m08 interval emits each multiple once as time passes', () =>
|
||||
test('m08 interval emits several multiples after one big time jump', () =>
|
||||
assert.deepStrictEqual(run(interval(10), inputs(time(35))), [10, 20, 30]))
|
||||
|
||||
test('m08 interval(1) emits every millisecond crossed', () =>
|
||||
assert.deepStrictEqual(run(interval(1), inputs(time(1), time(3))), [1, 2, 3]))
|
||||
|
||||
test('m08 interval ignores key inputs', () =>
|
||||
assert.deepStrictEqual(run(interval(10), inputs(down('a'), up('a'))), []))
|
||||
|
||||
|
||||
+25
-3
@@ -1,11 +1,12 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { states, classicActions, render, main, TICK_MS } from '../src/app.js'
|
||||
import { states, classicActions, render, main, play, TICK_MS } from '../src/app.js'
|
||||
import { run } from '../src/stream.js'
|
||||
import { trace } from '../src/io.js'
|
||||
import { view } from '../src/view.js'
|
||||
import { newGame } from '../src/spawn.js'
|
||||
import { Action } from '../src/model.js'
|
||||
import { history } from '../src/game.js'
|
||||
import { freeze } from './helpers.js'
|
||||
import { classicMain } from './m09-helpers.js'
|
||||
|
||||
@@ -33,12 +34,17 @@ test('m09 states starts with the new game, then yields one state per action', ()
|
||||
test('m09 render draws the whole view of the state', () =>
|
||||
assert.deepStrictEqual(trace(render(newGame(42)), 0).ops, view(newGame(42)).map((shape) => ({ tag: 'Draw', shape }))))
|
||||
|
||||
// Module 10 (Exercise 10.7) makes `main` also load the stored best and draw one more shape after the game's view,
|
||||
// so this test accepts both versions; m10 pins the final one exactly.
|
||||
test('m09 main asks for a random seed, then builds the stream from it', () => {
|
||||
const { ops, result: stream } = trace(main, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
|
||||
assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' })
|
||||
assert.ok(ops.length === 1 || (ops.length === 2 && ops[1].tag === 'LoadBest'),
|
||||
`main may only ask for the seed (and, from m10 on, the stored best); it asked for ${JSON.stringify(ops)}`)
|
||||
const [outs] = stream.step(time(0))
|
||||
assert.equal(outs.length, 1)
|
||||
assert.deepStrictEqual(trace(outs[0], 0).ops, trace(render(newGame(42)), 0).ops)
|
||||
const expected = trace(render(newGame(42)), 0).ops
|
||||
assert.deepStrictEqual(trace(outs[0], 0).ops.slice(0, expected.length), expected)
|
||||
})
|
||||
|
||||
// `main` itself switches to speedActions in m10, so the 150 ms behaviour is checked on the same program built from classicActions.
|
||||
@@ -59,3 +65,19 @@ test('m09 main draws again once time has passed', () => {
|
||||
assert.equal(first.length, 1)
|
||||
assert.ok(later.length >= 1)
|
||||
})
|
||||
|
||||
// ---- play: the whole program in one equality (Exercise 9.5)
|
||||
|
||||
const drawOps = (state) => view(state).map((shape) => ({ tag: 'Draw', shape }))
|
||||
|
||||
test('m09 play on the classic program equals the scenes of the expected states', () => {
|
||||
const events = freeze([time(150), down('ArrowUp'), down('x'), time(300), time(310)])
|
||||
const expectedStates = history(newGame(42), [Action.Tick, Action.Turn('Up'), Action.Tick])
|
||||
assert.deepStrictEqual(play(classicMain, 42, events), expectedStates.map(drawOps))
|
||||
})
|
||||
|
||||
test('m09 play with no events gives no drawings', () =>
|
||||
assert.deepStrictEqual(play(classicMain, 42, []), []))
|
||||
|
||||
test('m09 play uses the seed for the program\'s RandomSeed', () =>
|
||||
assert.deepStrictEqual(play(classicMain, 7, freeze([time(0)])), [drawOps(newGame(7))]))
|
||||
@@ -18,9 +18,12 @@ const fakePlatform = (seed = 42) => {
|
||||
startClock: (cb) => { handlers.time = cb },
|
||||
onKeyDown: (cb) => { handlers.down = cb },
|
||||
onKeyUp: (cb) => { handlers.up = cb },
|
||||
// m13: a runtime from Module 9 does not register this yet; nothing in m09 fires a click.
|
||||
onClick: (cb) => { handlers.click = cb },
|
||||
fireTime: (t) => handlers.time(t),
|
||||
fireKey: (k) => handlers.down(k),
|
||||
fireKeyUp: (k) => handlers.up(k),
|
||||
fireClick: (x, y) => handlers.click(x, y),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { update, movesEvery } from '../src/game.js'
|
||||
import { update, movesEvery, steer } from '../src/game.js'
|
||||
import { keyToAction } from '../src/input.js'
|
||||
import { speedActions, main, FRAME_MS } from '../src/app.js'
|
||||
import { run } from '../src/stream.js'
|
||||
import { trace } from '../src/io.js'
|
||||
import {
|
||||
speedActions, main, FRAME_MS, MAX_GAP_MS, sessionUpdate, sessions, sessionView, renderSession, play,
|
||||
} from '../src/app.js'
|
||||
import { run, clampTime, interval, keyDowns } from '../src/stream.js'
|
||||
import { trace, loadBest, saveBest, map as mapIO, flatMap as flatMapIO, pure } from '../src/io.js'
|
||||
import { runIO, runApp } from '../src/runtime.js'
|
||||
import { browserPlatform } from '../src/browser.js'
|
||||
import { view } from '../src/view.js'
|
||||
import { newGame } from '../src/spawn.js'
|
||||
import { Action } from '../src/model.js'
|
||||
@@ -19,6 +23,23 @@ const fixtureTests = (name, f) => {
|
||||
fixtureTests('m10-update', update)
|
||||
fixtureTests('m10-moves-every', movesEvery)
|
||||
fixtureTests('m10-input', keyToAction)
|
||||
fixtureTests('m10-session-update', sessionUpdate)
|
||||
fixtureTests('m10-session-view', sessionView)
|
||||
|
||||
// ---- the turn queue (Exercise 10.6)
|
||||
|
||||
test('m10 steer is what update does with a Turn', () =>
|
||||
loadFixture('m10-update').cases
|
||||
.filter((c) => c.args[1].tag === 'Turn')
|
||||
.forEach((c) => assert.deepStrictEqual(steer(...freeze([c.args[0], c.args[1].dir])), c.expected, c.name)))
|
||||
|
||||
test('m10 Turn Up then Turn Left within one step: the snake goes Up on the next step and Left on the one after', () => {
|
||||
const start = newGame(42) // heading Right from (10, 10); the apple is far away
|
||||
const states = [Action.Turn('Up'), Action.Turn('Left'), Action.Tick, Action.Tick]
|
||||
.reduce((acc, a) => [...acc, update(acc[acc.length - 1], a)], [freeze(start)])
|
||||
assert.deepStrictEqual(states.slice(2).map((s) => s.snake[0]), [{ x: 10, y: 10 }, { x: 10, y: 9 }, { x: 9, y: 9 }])
|
||||
assert.deepStrictEqual(states.map((s) => s.queued), [null, null, 'Left', null, null])
|
||||
})
|
||||
|
||||
const time = (t) => ({ tag: 'Time', t })
|
||||
|
||||
@@ -34,11 +55,17 @@ test('m10 speedActions still turns keys into actions, including pause and restar
|
||||
run(speedActions, freeze([{ tag: 'KeyDown', key: ' ' }, { tag: 'KeyDown', key: 'r' }, { tag: 'KeyDown', key: 'ArrowUp' }])),
|
||||
[Action.TogglePause, Action.Restart, Action.Turn('Up')]))
|
||||
|
||||
// The drawings are compared by their first shapes, the game's view: Exercise 10.7 adds the best score after it.
|
||||
const startsWithView = (io, state) => {
|
||||
const expected = view(state)
|
||||
assert.deepStrictEqual(trace(io, 0).ops.slice(0, expected.length).map((o) => o.shape), expected)
|
||||
}
|
||||
|
||||
test('m10 main (seed 42) draws a moved snake after eight 25 ms frames', () => {
|
||||
const { ops, result: stream } = trace(main, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
|
||||
assert.deepStrictEqual(ops[0], { tag: 'RandomSeed' })
|
||||
const [first, afterFirst] = stream.step(time(0))
|
||||
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), view(newGame(42)))
|
||||
startsWithView(first[0], newGame(42))
|
||||
const [drawings] = [25, 50, 75, 100, 125, 150, 175, 200].reduce(
|
||||
([outs, s], t) => {
|
||||
const [out, next] = s.step(time(t))
|
||||
@@ -46,6 +73,190 @@ test('m10 main (seed 42) draws a moved snake after eight 25 ms frames', () => {
|
||||
},
|
||||
[[], afterFirst])
|
||||
assert.equal(drawings.length, 8)
|
||||
const moved = { ...newGame(42), snake: newGame(42).snake.map((p) => ({ x: p.x + 1, y: p.y })), frame: 8 }
|
||||
assert.deepStrictEqual(trace(drawings[7], 0).ops.map((o) => o.shape), view(moved))
|
||||
const moved = { ...newGame(42), snake: newGame(42).snake.map((p) => ({ x: p.x + 1, y: p.y })), frame: 0 }
|
||||
startsWithView(drawings[7], moved)
|
||||
})
|
||||
|
||||
// ---- clampTime: a long silence from the clock (a background tab) must not turn into hundreds of frames
|
||||
|
||||
const down = (key) => ({ tag: 'KeyDown', key })
|
||||
|
||||
test('m10 clampTime passes small steps of time through unchanged', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 100), freeze([5, 10, 35].map(time))), [10, 20, 30]))
|
||||
|
||||
test('m10 clampTime lets a long gap count as maxGap, and carries on from there', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 25), freeze([10, 5000, 5010].map(time))), [10, 20, 30, 40]))
|
||||
|
||||
test('m10 clampTime clamps the very first step too (the clock starts at 0)', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 25), freeze([time(1000)])), [10, 20]))
|
||||
|
||||
test('m10 clampTime never moves the clock backwards', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 100), freeze([30, 10, 40].map(time))), [10, 20, 30, 40, 50, 60]))
|
||||
|
||||
test('m10 clampTime tells the inner stream exactly the clamped time, and a repeated time adds nothing', () =>
|
||||
assert.deepStrictEqual(run(clampTime(interval(1), 100), freeze([5, 5, 6].map(time))), [1, 2, 3, 4, 5, 6]))
|
||||
|
||||
test('m10 clampTime passes key inputs through, and they do not disturb the clock', () => {
|
||||
assert.deepStrictEqual(run(clampTime(keyDowns, 5), freeze([down('a'), time(50), down('b')])), ['a', 'b'])
|
||||
assert.deepStrictEqual(run(clampTime(interval(10), 25), freeze([time(10), down('a'), time(20)])), [10, 20])
|
||||
})
|
||||
|
||||
test('m10 running the same clamped stream twice gives the same outputs', () => {
|
||||
const s = clampTime(interval(10), 25)
|
||||
const xs = freeze([10, 5000, 5010].map(time))
|
||||
assert.deepStrictEqual(run(s, xs), run(s, xs))
|
||||
})
|
||||
|
||||
test('m10 the game accepts at most 250 ms of time at once', () => assert.equal(MAX_GAP_MS, 250))
|
||||
|
||||
test('m10 main: after ten seconds in a background tab the game advances by 250 ms, not by ten seconds', () => {
|
||||
const [, afterFirst] = trace(main, 42).result.step(time(0))
|
||||
const [drawings, next] = afterFirst.step(time(10000))
|
||||
assert.equal(drawings.length, MAX_GAP_MS / FRAME_MS)
|
||||
assert.equal(next.step(time(10025))[0].length, 1)
|
||||
})
|
||||
|
||||
// ---- a persisted high score (Exercise 10.7)
|
||||
|
||||
const loadOp = { tag: 'LoadBest' }
|
||||
const session = (game, best, improved = false) => ({ game, best, improved })
|
||||
|
||||
test('m10 loadBest records a LoadBest op and is answered with the best given to trace', () =>
|
||||
assert.deepStrictEqual(trace(loadBest, 0, 17), { result: 17, ops: [loadOp] }))
|
||||
|
||||
test('m10 trace answers LoadBest with 0 when no best is given', () =>
|
||||
assert.equal(trace(loadBest, 9).result, 0))
|
||||
|
||||
test('m10 saveBest records a SaveBest op with the score and produces undefined', () =>
|
||||
assert.deepStrictEqual(trace(saveBest(7), 0), { result: undefined, ops: [{ tag: 'SaveBest', score: 7 }] }))
|
||||
|
||||
test('m10 a program can load the best, then save a higher one', () =>
|
||||
assert.deepStrictEqual(trace(flatMapIO(loadBest, (b) => saveBest(b + 1)), 0, 4).ops, [loadOp, { tag: 'SaveBest', score: 5 }]))
|
||||
|
||||
test('m10 sessions starts with the new game and the stored best, then yields one session per action', () => {
|
||||
const out = run(sessions(42, 3, speedActions), freeze([time(25), { tag: 'KeyDown', key: ' ' }]))
|
||||
const s0 = session(newGame(42), 3)
|
||||
const s1 = sessionUpdate(s0, Action.Frame)
|
||||
assert.deepStrictEqual(out, [s0, s1, sessionUpdate(s1, Action.TogglePause)])
|
||||
})
|
||||
|
||||
test('m10 renderSession draws the session view and saves nothing when the best did not improve', () => {
|
||||
const s = freeze(session(newGame(42), 5))
|
||||
assert.deepStrictEqual(trace(renderSession(s), 0).ops, sessionView(s).map((shape) => ({ tag: 'Draw', shape })))
|
||||
})
|
||||
|
||||
test('m10 renderSession draws the session view, then saves the best, when the best improved', () => {
|
||||
const s = freeze(session({ ...newGame(42), score: 6 }, 6, true))
|
||||
assert.deepStrictEqual(trace(renderSession(s), 0).ops, [
|
||||
...sessionView(s).map((shape) => ({ tag: 'Draw', shape })),
|
||||
{ tag: 'SaveBest', score: 6 },
|
||||
])
|
||||
})
|
||||
|
||||
test('m10 main asks for the seed, then the stored best, and draws the first session', () => {
|
||||
const { ops, result: stream } = trace(main, 42, 9)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }, loadOp])
|
||||
const [first] = stream.step(time(0))
|
||||
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), sessionView(session(newGame(42), 9)))
|
||||
})
|
||||
|
||||
test('m10 play passes the best to the program: the first scene shows it', () =>
|
||||
assert.deepStrictEqual(
|
||||
play(main, 42, freeze([time(0)]), 9),
|
||||
[sessionView(session(newGame(42), 9)).map((shape) => ({ tag: 'Draw', shape }))]))
|
||||
|
||||
test('m10 play without a best answers LoadBest with 0', () =>
|
||||
assert.deepStrictEqual(
|
||||
play(main, 42, freeze([time(0)])),
|
||||
[sessionView(session(newGame(42), 0)).map((shape) => ({ tag: 'Draw', shape }))]))
|
||||
|
||||
// The golden fixture: a seed, a stored best, a scripted list of inputs, and the scene of the last drawing.
|
||||
loadFixture('m10-golden').cases.forEach((c) => {
|
||||
const [seed, events, best] = freeze(c.args)
|
||||
|
||||
test(`m10 play(main): ${c.name}`, () =>
|
||||
assert.deepStrictEqual(play(main, seed, events, best).at(-1).filter((o) => o.tag === 'Draw').map((o) => o.shape), c.expected))
|
||||
|
||||
test(`m10 play(main) saves once per improvement: ${c.name}`, () => {
|
||||
const saves = play(main, seed, events, best).flat().filter((o) => o.tag === 'SaveBest').map((o) => o.score)
|
||||
const finalBest = Number(c.expected.find((s) => s.tag === 'Text' && s.text.startsWith('Best: ')).text.slice(6))
|
||||
const improvements = Array.from({ length: Math.max(0, finalBest - best) }, (_, i) => best + 1 + i)
|
||||
assert.deepStrictEqual(saves, improvements)
|
||||
})
|
||||
})
|
||||
|
||||
// The runtime with a fake platform that records the two new calls.
|
||||
const fakePlatform = (seed = 42, best = 0) => {
|
||||
const calls = []
|
||||
const handlers = {}
|
||||
return {
|
||||
calls,
|
||||
fillRect: (...args) => calls.push(['fillRect', ...args]),
|
||||
fillText: (...args) => calls.push(['fillText', ...args]),
|
||||
randomSeed: () => { calls.push(['randomSeed']); return seed },
|
||||
loadBest: () => { calls.push(['loadBest']); return best },
|
||||
saveBest: (n) => { calls.push(['saveBest', n]) },
|
||||
startClock: (cb) => { handlers.time = cb },
|
||||
onKeyDown: (cb) => { handlers.down = cb },
|
||||
onKeyUp: (cb) => { handlers.up = cb },
|
||||
onClick: (cb) => { handlers.click = cb }, // m13; nothing in m10 fires a click
|
||||
fireTime: (t) => handlers.time(t),
|
||||
fireClick: (x, y) => handlers.click(x, y),
|
||||
}
|
||||
}
|
||||
|
||||
test('m10 runIO answers LoadBest with platform.loadBest() and continues with it', () => {
|
||||
const p = fakePlatform(42, 11)
|
||||
assert.equal(runIO(mapIO(loadBest, (b) => b + 1), p), 12)
|
||||
assert.deepStrictEqual(p.calls, [['loadBest']])
|
||||
})
|
||||
|
||||
test('m10 runIO maps SaveBest to platform.saveBest(score)', () => {
|
||||
const p = fakePlatform()
|
||||
runIO(flatMapIO(saveBest(8), () => pure('done')), p)
|
||||
assert.deepStrictEqual(p.calls, [['saveBest', 8]])
|
||||
})
|
||||
|
||||
test('m10 runApp asks for the seed and loads the best once, at start, and draws nothing before the first input', () => {
|
||||
const p = fakePlatform(42, 3)
|
||||
runApp(main, p)
|
||||
assert.deepStrictEqual(p.calls, [['randomSeed'], ['loadBest']])
|
||||
p.fireTime(0)
|
||||
p.fireTime(25)
|
||||
assert.equal(p.calls.filter((c) => c[0] === 'loadBest').length, 1)
|
||||
assert.ok(p.calls.some((c) => c[0] === 'fillText' && c[3] === 'Best: 3'))
|
||||
})
|
||||
|
||||
// browser.js: only the storage part can be checked without a browser. A fake window with a fake localStorage.
|
||||
const fakeCanvas = { getContext: () => ({}) }
|
||||
const fakeWindow = (storage) => ({ localStorage: storage, addEventListener: () => {}, requestAnimationFrame: () => {} })
|
||||
const memoryStorage = (entries = {}) => {
|
||||
const data = { ...entries }
|
||||
return { data, getItem: (k) => (k in data ? data[k] : null), setItem: (k, v) => { data[k] = String(v) } }
|
||||
}
|
||||
const throwing = { getItem: () => { throw new Error('blocked') }, setItem: () => { throw new Error('blocked') } }
|
||||
|
||||
test('m10 browserPlatform loads the best from localStorage key pure-snake-best', () =>
|
||||
assert.equal(browserPlatform(fakeCanvas, fakeWindow(memoryStorage({ 'pure-snake-best': '23' }))).loadBest(), 23))
|
||||
|
||||
const unreadable = [{}, ...['lots', '-4', '2.5', '1e2', '7.0', '+7', ' 7', '0x10', '2147483648', ''].map((v) => ({ 'pure-snake-best': v }))]
|
||||
|
||||
test('m10 browserPlatform treats a missing or unreadable best as 0', () =>
|
||||
unreadable.forEach((entries) =>
|
||||
assert.equal(browserPlatform(fakeCanvas, fakeWindow(memoryStorage(entries))).loadBest(), 0, JSON.stringify(entries))))
|
||||
|
||||
test('m10 browserPlatform saves the best under pure-snake-best', () => {
|
||||
const storage = memoryStorage()
|
||||
browserPlatform(fakeCanvas, fakeWindow(storage)).saveBest(31)
|
||||
assert.deepStrictEqual(storage.data, { 'pure-snake-best': '31' })
|
||||
})
|
||||
|
||||
// Some browsers throw on every storage call when storage is blocked, others already when `localStorage` is read.
|
||||
const blockedWindow = { addEventListener: () => {}, requestAnimationFrame: () => {}, get localStorage() { throw new Error('blocked') } }
|
||||
|
||||
test('m10 browserPlatform never throws when storage is blocked', () =>
|
||||
[fakeWindow(throwing), blockedWindow].forEach((win) => {
|
||||
const p = browserPlatform(fakeCanvas, win)
|
||||
assert.equal(p.loadBest(), 0)
|
||||
assert.doesNotThrow(() => p.saveBest(5))
|
||||
}))
|
||||
@@ -0,0 +1,106 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
REPLAY_MS, frames, viewerStart, ViewerAction, viewerUpdate, viewerKey, viewerView, replayActions, viewerStates, replayMain,
|
||||
} from '../src/replay.js'
|
||||
import { history } from '../src/game.js'
|
||||
import { newGame } from '../src/spawn.js'
|
||||
import { view } from '../src/view.js'
|
||||
import { run } from '../src/stream.js'
|
||||
import { trace } from '../src/io.js'
|
||||
import { loadFixture, freeze } from './helpers.js'
|
||||
|
||||
const fixtureTests = (name, f) => {
|
||||
const fx = loadFixture(name)
|
||||
fx.cases.forEach((c) =>
|
||||
test(`m10 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected)))
|
||||
}
|
||||
|
||||
fixtureTests('m10-viewer-update', viewerUpdate)
|
||||
fixtureTests('m10-viewer-key', viewerKey)
|
||||
fixtureTests('m10-viewer-view', viewerView)
|
||||
|
||||
// The sample recording, as the page loads it. Not a fixture of cases: a recording `{seed, actions}`.
|
||||
const sample = freeze(loadFixture('replay-sample'))
|
||||
|
||||
const time = (t) => ({ tag: 'Time', t })
|
||||
const key = (k) => ({ tag: 'KeyDown', key: k })
|
||||
const shapes = (io) => trace(io, 0).ops.map((o) => o.shape)
|
||||
|
||||
test('m10 the sample recording is worth watching: Tick and Turn only, under 400 actions, at least five apples, then game over', () => {
|
||||
assert.ok(sample.actions.every((a) => a.tag === 'Tick' || a.tag === 'Turn'))
|
||||
assert.ok(sample.actions.length >= 150 && sample.actions.length <= 400, `${sample.actions.length} actions`)
|
||||
const fs = history(newGame(sample.seed), sample.actions)
|
||||
assert.ok(fs.at(-1).score >= 5, `score ${fs.at(-1).score}`)
|
||||
assert.equal(fs.at(-1).status, 'GameOver')
|
||||
assert.equal(fs.at(-2).status, 'Playing')
|
||||
})
|
||||
|
||||
test('m10 frames replays the recording with history, from the new game of its seed', () => {
|
||||
const recording = freeze({ seed: 42, actions: [{ tag: 'Tick' }, { tag: 'Turn', dir: 'Up' }, { tag: 'Tick' }] })
|
||||
assert.deepStrictEqual(frames(recording), history(newGame(42), recording.actions))
|
||||
assert.equal(frames(sample).length, sample.actions.length + 1)
|
||||
})
|
||||
|
||||
test('m10 viewerStart is the first frame, not playing', () =>
|
||||
assert.deepStrictEqual(viewerStart(12), { index: 0, last: 12, playing: false }))
|
||||
|
||||
test('m10 ViewerAction has the six actions as plain values', () =>
|
||||
assert.deepStrictEqual(
|
||||
[ViewerAction.Next, ViewerAction.Prev, ViewerAction.First, ViewerAction.Last, ViewerAction.TogglePlay, ViewerAction.Beat],
|
||||
[{ tag: 'Next' }, { tag: 'Prev' }, { tag: 'First' }, { tag: 'Last' }, { tag: 'TogglePlay' }, { tag: 'Beat' }]))
|
||||
|
||||
test('m10 viewerUpdate keeps the index in [0, last] whatever the actions', () => {
|
||||
const actions = [ViewerAction.Prev, ViewerAction.Next, ViewerAction.Last, ViewerAction.Next, ViewerAction.TogglePlay,
|
||||
ViewerAction.Beat, ViewerAction.Beat, ViewerAction.First, ViewerAction.Prev, ViewerAction.Beat]
|
||||
const viewers = actions.reduce((acc, a) => [...acc, viewerUpdate(acc.at(-1), a)], [freeze(viewerStart(3))])
|
||||
assert.ok(viewers.every((v) => v.index >= 0 && v.index <= 3 && !(v.playing && v.index === 3)))
|
||||
})
|
||||
|
||||
test('m10 the autoplay clock beats every 100 ms', () => assert.equal(REPLAY_MS, 100))
|
||||
|
||||
test('m10 replayActions turns the clock into Beats and keys into viewer actions', () =>
|
||||
assert.deepStrictEqual(
|
||||
run(replayActions, freeze([time(100), key('ArrowRight'), key('x'), key(' '), time(300)])),
|
||||
[ViewerAction.Beat, ViewerAction.Next, ViewerAction.TogglePlay, ViewerAction.Beat, ViewerAction.Beat]))
|
||||
|
||||
test('m10 autoplay: Space, then the clock steps one frame per 100 ms and stops at the end', () => {
|
||||
const out = run(viewerStates(3, replayActions), freeze([time(0), key(' '), time(100), time(200), time(300), time(400), time(500)]))
|
||||
assert.deepStrictEqual(out.map((v) => v.index), [0, 0, 1, 2, 3, 3, 3])
|
||||
assert.deepStrictEqual(out.map((v) => v.playing), [false, true, true, true, false, false, false])
|
||||
})
|
||||
|
||||
test('m10 without Space nothing plays: the clock alone never moves the viewer', () =>
|
||||
assert.ok(run(viewerStates(3, replayActions), freeze([time(0), time(1000), time(2000)])).every((v) => v.index === 0)))
|
||||
|
||||
test('m10 replayMain asks for nothing and draws the first frame of the recording first', () => {
|
||||
const { ops, result: stream } = trace(replayMain(sample), 0)
|
||||
assert.deepStrictEqual(ops, [])
|
||||
const [drawings] = stream.step(time(0))
|
||||
assert.equal(drawings.length, 1)
|
||||
assert.deepStrictEqual(shapes(drawings[0]), viewerView(frames(sample), viewerStart(sample.actions.length)))
|
||||
assert.deepStrictEqual(shapes(drawings[0]).slice(0, view(newGame(sample.seed)).length), view(newGame(sample.seed)))
|
||||
})
|
||||
|
||||
test('m10 replayMain: End shows the last frame of the game, the game over', () => {
|
||||
const out = run(trace(replayMain(sample), 0).result, freeze([time(0), key('End')]))
|
||||
const fs = frames(sample)
|
||||
assert.deepStrictEqual(shapes(out.at(-1)), viewerView(fs, { index: fs.length - 1, last: fs.length - 1, playing: false }))
|
||||
assert.ok(shapes(out.at(-1)).some((s) => s.tag === 'Text' && s.text === 'Game Over'))
|
||||
})
|
||||
|
||||
test('m10 stepping back shows exactly the earlier scene', () => {
|
||||
const out = run(trace(replayMain(sample), 0).result,
|
||||
freeze([time(0), key('ArrowRight'), key('ArrowRight'), key('ArrowRight'), key('ArrowLeft'), key('d'), key('a'), key('a')]))
|
||||
const scenes = out.map(shapes)
|
||||
assert.deepStrictEqual(scenes[4], scenes[2])
|
||||
assert.deepStrictEqual(scenes[6], scenes[4])
|
||||
assert.deepStrictEqual(scenes[7], scenes[1])
|
||||
assert.notDeepStrictEqual(scenes[3], scenes[2])
|
||||
})
|
||||
|
||||
test('m10 replayMain: after ten seconds in a background tab autoplay moves 2 frames (250 ms), not 100', () => {
|
||||
const [, afterPlay] = trace(replayMain(sample), 0).result.step(key(' '))
|
||||
const [, afterFirst] = afterPlay.step(time(0))
|
||||
assert.equal(afterFirst.step(time(10000))[0].length, 2)
|
||||
})
|
||||
@@ -74,6 +74,11 @@ test('m11 pongStates starts with the new match, then yields one state per action
|
||||
assert.deepStrictEqual(out[3].player, 20)
|
||||
})
|
||||
|
||||
test('m11 pongMain: after ten seconds in a background tab the match advances by 250 ms, not by ten seconds', () => {
|
||||
const [, afterFirst] = trace(pongMain, 42).result.step(time(0))
|
||||
assert.equal(afterFirst.step(time(10000))[0].length, 8)
|
||||
})
|
||||
|
||||
test('m11 pongMain asks for a seed, draws the new match first, and then the updated one', () => {
|
||||
const { ops, result: stream } = trace(pongMain, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import {
|
||||
elementOf, listOf, genActions, forAll, removals, minimize, violations, moveGaps, genSnake, genState, advanceSpec,
|
||||
} from '../src/check.js'
|
||||
import { nextUint32, intBelow, pure, run } from '../src/random.js'
|
||||
import { update, history, movesEvery } from '../src/game.js'
|
||||
import { newGame, spawnApple, tick } from '../src/spawn.js'
|
||||
import { move, advance, inBounds } from '../src/rules.js'
|
||||
import { Action, opposite, pointEquals } from '../src/model.js'
|
||||
import { contains, dropLast } from '../src/util.js'
|
||||
import { loadFixture, freeze } from './helpers.js'
|
||||
|
||||
// ---- generators
|
||||
|
||||
test('m12 elementOf picks the element at the index drawn by intBelow', () => {
|
||||
const xs = freeze(['a', 'b', 'c', 'd', 'e'])
|
||||
const [i, seed] = run(intBelow(5), 0)
|
||||
assert.deepStrictEqual(run(elementOf(xs), 0), [xs[i], seed])
|
||||
})
|
||||
|
||||
test('m12 elementOf of a one-element array always gives that element, and still uses up a draw', () =>
|
||||
assert.deepStrictEqual(run(elementOf(freeze(['only'])), 0), ['only', 1831565813]))
|
||||
|
||||
test('m12 listOf(gen, 0) is the empty list and leaves the seed alone', () =>
|
||||
assert.deepStrictEqual(run(listOf(nextUint32, 0), 99), [[], 99]))
|
||||
|
||||
test('m12 listOf threads the seed from each draw to the next', () =>
|
||||
assert.deepStrictEqual(run(listOf(nextUint32, 3), 0), [[1144304738, 1416247, 958946056], 1199730143]))
|
||||
|
||||
test('m12 listOf of a constant generator repeats the value and leaves the seed alone', () =>
|
||||
assert.deepStrictEqual(run(listOf(pure('x'), 4), 7), [['x', 'x', 'x', 'x'], 7]))
|
||||
|
||||
test('m12 genActions(n) gives n actions, the same ones for the same seed', () => {
|
||||
const [actions] = run(genActions(30), 5)
|
||||
assert.equal(actions.length, 30)
|
||||
assert.ok(actions.every((a) => a.tag === 'Tick' || a.tag === 'Turn'))
|
||||
assert.deepStrictEqual(run(genActions(30), 5), run(genActions(30), 5))
|
||||
})
|
||||
|
||||
// ---- forAll
|
||||
|
||||
test('m12 forAll reports success with the number of runs', () =>
|
||||
assert.deepStrictEqual(forAll(intBelow(10), (x) => x >= 0 && x < 10, 200, 1), { ok: true, runs: 200 }))
|
||||
|
||||
test('m12 forAll with zero runs succeeds without drawing anything', () =>
|
||||
assert.deepStrictEqual(forAll(nextUint32, () => false, 0, 1), { ok: true, runs: 0 }))
|
||||
|
||||
test('m12 forAll reports the first failing draw, its value and the seed it started from', () => {
|
||||
const gen = intBelow(10)
|
||||
const prop = (x) => x !== 7
|
||||
const [draws] = run(listOf(gen, 200), 3)
|
||||
const first = draws.findIndex((x) => !prop(x))
|
||||
assert.ok(first > 0, 'the test needs a failure that is not the very first draw')
|
||||
const result = forAll(gen, prop, 200, 3)
|
||||
assert.deepStrictEqual({ ok: result.ok, run: result.run, value: result.value }, { ok: false, run: first, value: 7 })
|
||||
assert.equal(run(gen, result.seed)[0], 7)
|
||||
})
|
||||
|
||||
test('m12 forAll stops at the first failure: the property is not asked about later values', () => {
|
||||
const gen = intBelow(10)
|
||||
const [draws] = run(listOf(gen, 200), 3)
|
||||
const first = draws.findIndex((x) => x === 7)
|
||||
let calls = 0 // test code may count; the code under test may not
|
||||
const result = forAll(gen, (x) => { calls = calls + 1; return x !== 7 }, 200, 3)
|
||||
assert.equal(result.run, first)
|
||||
assert.equal(calls, first + 1)
|
||||
})
|
||||
|
||||
test('m12 forAll is reproducible: same generator, property, runs and seed give the same report', () =>
|
||||
assert.deepStrictEqual(forAll(intBelow(1000), (x) => x % 97 !== 0, 500, 11), forAll(intBelow(1000), (x) => x % 97 !== 0, 500, 11)))
|
||||
|
||||
// ---- shrinking
|
||||
|
||||
test('m12 removals lists every way to drop one element, in order', () =>
|
||||
assert.deepStrictEqual(removals(freeze([1, 2, 3])), [[2, 3], [1, 3], [1, 2]]))
|
||||
|
||||
test('m12 removals of an empty list is empty, and of a single element is one empty list', () => {
|
||||
assert.deepStrictEqual(removals([]), [])
|
||||
assert.deepStrictEqual(removals(['a']), [[]])
|
||||
})
|
||||
|
||||
test('m12 minimize keeps exactly the elements the failure needs', () =>
|
||||
assert.deepStrictEqual(
|
||||
minimize(freeze([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), (xs) => xs.includes(3) && xs.includes(7)),
|
||||
[3, 7]))
|
||||
|
||||
test('m12 minimize keeps the order and the duplicates the failure needs', () =>
|
||||
assert.deepStrictEqual(
|
||||
minimize(freeze(['b', 'a', 'c', 'a', 'd', 'a']), (xs) => xs.filter((x) => x === 'a').length >= 2 && xs.includes('c')),
|
||||
['c', 'a', 'a']))
|
||||
|
||||
test('m12 minimize of a list that fails whatever you remove is the empty list', () =>
|
||||
assert.deepStrictEqual(minimize(freeze([1, 2, 3]), () => true), []))
|
||||
|
||||
test('m12 minimize returns a list that still fails and has no failing removal (1-minimal)', () => {
|
||||
const fails = (xs) => xs.reduce((a, b) => a + b, 0) >= 10
|
||||
const small = minimize(freeze([5, 1, 4, 2, 6, 3]), fails)
|
||||
assert.ok(fails(small))
|
||||
assert.ok(removals(small).every((ys) => !fails(ys)))
|
||||
})
|
||||
|
||||
// ---- invariants
|
||||
|
||||
const fx = loadFixture('m12-violations')
|
||||
fx.cases.forEach((c) =>
|
||||
test(`m12 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(violations(...freeze(c.args)), c.expected)))
|
||||
|
||||
// ---- moveGaps
|
||||
|
||||
const at = (x, y) => ({ snake: [{ x, y }, { x: x - 1, y }] })
|
||||
|
||||
test('m12 moveGaps measures the distance between consecutive moves of the head', () =>
|
||||
assert.deepStrictEqual(
|
||||
moveGaps(freeze([at(1, 0), at(1, 0), at(2, 0), at(2, 0), at(2, 0), at(3, 0), at(3, 1)])),
|
||||
[3, 1]))
|
||||
|
||||
test('m12 moveGaps of fewer than two moves is empty', () => {
|
||||
assert.deepStrictEqual(moveGaps([]), [])
|
||||
assert.deepStrictEqual(moveGaps(freeze([at(1, 0)])), [])
|
||||
assert.deepStrictEqual(moveGaps(freeze([at(1, 0), at(1, 0), at(2, 0), at(2, 0)])), [])
|
||||
})
|
||||
|
||||
test('m12 moveGaps counts a move at index 1', () =>
|
||||
assert.deepStrictEqual(moveGaps(freeze([at(1, 0), at(2, 0), at(2, 0), at(3, 0)])), [2]))
|
||||
|
||||
test('m12 moveGaps treats an empty snake as a head of its own', () => {
|
||||
const snake = (...cells) => ({ snake: cells })
|
||||
assert.deepStrictEqual(
|
||||
moveGaps(freeze([snake(), snake(), snake({ x: 1, y: 0 }), snake({ x: 1, y: 0 }), snake({ x: 2, y: 0 })])), [2])
|
||||
})
|
||||
|
||||
// ---- the payoff: properties of the game, and deliberately broken games ("mutants") that they must catch
|
||||
|
||||
// Every state of the game played with `upd` from newGame(1). (For the real `update` this is `history`.)
|
||||
const statesWith = (upd, start, actions) => actions.reduce((acc, a) => [...acc, upd(acc[acc.length - 1], a)], [start])
|
||||
const sane = (upd) => (actions) => statesWith(upd, newGame(1), actions).every((s) => violations(s).length === 0)
|
||||
|
||||
test('m12 statesWith(update, ...) in these tests is the history of Module 5', () => {
|
||||
const [actions] = run(genActions(25), 9)
|
||||
assert.deepStrictEqual(statesWith(update, newGame(1), actions), history(newGame(1), actions))
|
||||
})
|
||||
|
||||
test('m12 the real game keeps every invariant on 300 random games of 40 actions', () =>
|
||||
assert.deepStrictEqual(forAll(genActions(40), sane(update), 300, 2024), { ok: true, runs: 300 }))
|
||||
|
||||
// Mutant 1: `turn` checks against nextDir instead of dir (version A of Puzzle 2 in Module 3).
|
||||
const turnAgainstNextDir = (state, action) =>
|
||||
action.tag !== 'Turn' ? update(state, action)
|
||||
: state.status === 'Playing' && action.dir !== opposite(state.nextDir) ? { ...state, nextDir: action.dir }
|
||||
: state
|
||||
|
||||
test('m12 the quick double turn bug is found and shrunk to its two key presses', () => {
|
||||
const prop = sane(turnAgainstNextDir)
|
||||
const result = forAll(genActions(40), prop, 300, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
const small = minimize(result.value, (actions) => !prop(actions))
|
||||
assert.equal(small.length, 2)
|
||||
assert.ok(small.every((a) => a.tag === 'Turn'))
|
||||
assert.ok(['Up', 'Down'].includes(small[0].dir))
|
||||
assert.equal(small[1].dir, 'Left')
|
||||
})
|
||||
|
||||
// Mutant 2: `advance` forgets to check the walls.
|
||||
const advanceWithoutWalls = (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 body = eating ? state.snake : dropLast(state.snake)
|
||||
return contains(body, head, pointEquals)
|
||||
? { ...state, dir: state.nextDir, status: 'GameOver' }
|
||||
: { ...state, snake: [head, ...body], dir: state.nextDir, apple: eating ? null : state.apple, score: state.score + (eating ? 1 : 0) }
|
||||
}
|
||||
// What `update` does after a step since Exercise 10.6: take the queued turn. The mutants below that replace `Tick`
|
||||
// do the same, so that each one differs from the real game in exactly one thing.
|
||||
const takeQueued = (s) => (s.status === 'Playing' && s.queued !== null ? { ...s, nextDir: s.queued, queued: null } : s)
|
||||
const noWalls = (state, action) =>
|
||||
action.tag === 'Tick' ? takeQueued(spawnApple(advanceWithoutWalls(state))) : update(state, action)
|
||||
|
||||
test('m12 a game without walls is found and shrunk to the shortest way off the board: ten ticks', () => {
|
||||
const prop = sane(noWalls)
|
||||
const result = forAll(genActions(40), prop, 300, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
assert.deepStrictEqual(minimize(result.value, (actions) => !prop(actions)), Array.from({ length: 10 }, () => Action.Tick))
|
||||
})
|
||||
|
||||
// Mutant 4: a `Tick` that forgets the turn queue of Exercise 10.6 (the `Frame` step still takes it).
|
||||
const tickForgetsQueue = (state, action) => (action.tag === 'Tick' ? tick(state) : update(state, action))
|
||||
|
||||
test('m12 a Tick that forgets the queued turn is caught by queued-not-reversed and shrunk to four actions', () => {
|
||||
const prop = sane(tickForgetsQueue)
|
||||
const result = forAll(genActions(40), prop, 300, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
assert.deepStrictEqual(
|
||||
minimize(result.value, (actions) => !prop(actions)),
|
||||
[Action.Turn('Up'), Action.Turn('Right'), Action.Tick, Action.Turn('Left')])
|
||||
})
|
||||
|
||||
// Mutant 5: the queue accepts any key that differs from the pending turn, even the one straight back from it.
|
||||
const queueAnyKey = (state, action) =>
|
||||
action.tag === 'Turn' && state.status === 'Playing' && state.nextDir !== state.dir && action.dir !== state.nextDir
|
||||
? { ...state, queued: action.dir }
|
||||
: update(state, action)
|
||||
|
||||
test('m12 a queue that accepts a reversal is found and shrunk to its two key presses', () => {
|
||||
const prop = sane(queueAnyKey)
|
||||
const result = forAll(genActions(40), prop, 300, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
assert.deepStrictEqual(minimize(result.value, (actions) => !prop(actions)), [Action.Turn('Up'), Action.Turn('Down')])
|
||||
})
|
||||
|
||||
// Mutant 3: the frame rule this course used at first: step whenever the *total* frame count is a multiple of movesEvery.
|
||||
const totalFrameCount = (state, action) => {
|
||||
if (action.tag !== 'Frame') return update(state, action)
|
||||
if (state.status !== 'Playing') return state
|
||||
const counted = { ...state, frame: state.frame + 1 }
|
||||
return counted.frame % movesEvery(counted.score) === 0 ? tick(counted) : counted
|
||||
}
|
||||
|
||||
// A long corridor: the snake is one step from its third apple (score 2 -> 3, so the speed changes), `frame` frames into the game.
|
||||
const corridor = (frame) => ({
|
||||
width: 60, height: 1, snake: [{ x: 1, y: 0 }, { x: 0, y: 0 }], dir: 'Right', nextDir: 'Right',
|
||||
apple: { x: 2, y: 0 }, seed: 0, score: 2, status: 'Playing', frame, queued: null,
|
||||
})
|
||||
const frames = Array.from({ length: 40 }, () => Action.Frame)
|
||||
const steady = (upd) => (frame) => {
|
||||
const states = statesWith(upd, corridor(frame), frames)
|
||||
const scoreAtMove = states.filter((s, i) => i > 0 && !pointEquals(s.snake[0], states[i - 1].snake[0])).map((s) => s.score)
|
||||
return moveGaps(states).every((gap, k) => gap === movesEvery(scoreAtMove[k]))
|
||||
}
|
||||
|
||||
test('m12 the real game moves exactly every movesEvery(score) frames, also across a speed-up', () =>
|
||||
assert.deepStrictEqual(forAll(intBelow(8), steady(update), 100, 7), { ok: true, runs: 100 }))
|
||||
|
||||
test('m12 counting frames from the start of the game makes some steps too short, and the property finds one', () => {
|
||||
const result = forAll(intBelow(56), steady(totalFrameCount), 100, 7)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(steady(totalFrameCount)(result.value), false)
|
||||
})
|
||||
|
||||
// ---- random states (Exercise 12.6)
|
||||
|
||||
const connected = (snake) => snake.slice(1).every((p, i) => Math.abs(p.x - snake[i].x) + Math.abs(p.y - snake[i].y) === 1)
|
||||
|
||||
test('m12 genSnake draws the head first, then walks from the tail (seed 0, 5x5, at most 6 cells)', () =>
|
||||
assert.deepStrictEqual(run(genSnake(5, 5, 6), 0), [
|
||||
[{ x: 3, y: 2 }, { x: 3, y: 1 }, { x: 3, y: 0 }, { x: 2, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 0 }], 4231026099]))
|
||||
|
||||
test('m12 genSnake is pinned for a few more seeds', () => {
|
||||
assert.deepStrictEqual(run(genSnake(5, 5, 6), 1)[0],
|
||||
[{ x: 2, y: 3 }, { x: 3, y: 3 }, { x: 4, y: 3 }, { x: 4, y: 4 }, { x: 3, y: 4 }, { x: 2, y: 4 }])
|
||||
assert.deepStrictEqual(run(genSnake(5, 5, 6), 42)[0],
|
||||
[{ x: 1, y: 0 }, { x: 1, y: 1 }, { x: 2, y: 1 }, { x: 2, y: 0 }, { x: 3, y: 0 }, { x: 3, y: 1 }])
|
||||
})
|
||||
|
||||
test('m12 genSnake with maxLen 1 is just the head, drawn with two draws', () => {
|
||||
const [x, s1] = run(intBelow(5), 0)
|
||||
const [y, s2] = run(intBelow(5), s1)
|
||||
assert.deepStrictEqual(run(genSnake(5, 5, 1), 0), [[{ x, y }], s2])
|
||||
})
|
||||
|
||||
test('m12 genSnake stops early when the walk is stuck: a 1x1 board holds one cell', () =>
|
||||
assert.deepStrictEqual(run(genSnake(1, 1, 5), 3)[0], [{ x: 0, y: 0 }]))
|
||||
|
||||
test('m12 genSnake always gives a snake on the board, connected, without overlap, at most maxLen long', () =>
|
||||
assert.deepStrictEqual(
|
||||
forAll(genSnake(4, 3, 8), (snake) =>
|
||||
snake.length >= 1 && snake.length <= 8 && connected(snake)
|
||||
&& snake.every((p) => inBounds({ width: 4, height: 3 }, p))
|
||||
&& snake.every((p, i) => !contains(snake.slice(i + 1), p, pointEquals)), 300, 5),
|
||||
{ ok: true, runs: 300 }))
|
||||
|
||||
test('m12 every generated state is sane: 500 states of a 5x5 board have no violations', () =>
|
||||
assert.deepStrictEqual(forAll(genState(5, 5), (s) => violations(s).length === 0, 500, 2024), { ok: true, runs: 500 }))
|
||||
|
||||
test('m12 generated states start with seed 0, score 0 and frame 0', () => {
|
||||
const [states] = run(listOf(genState(5, 5), 50), 2024)
|
||||
assert.ok(states.every((s) => s.seed === 0 && s.score === 0 && s.frame === 0))
|
||||
})
|
||||
|
||||
test('m12 generated states are Playing, have nothing queued, and vary in length and apple', () => {
|
||||
const [states] = run(listOf(genState(5, 5), 500), 2024)
|
||||
assert.ok(states.every((s) => s.status === 'Playing' && s.queued === null && s.width === 5 && s.height === 5))
|
||||
assert.ok(states.every((s) => s.snake.length === 1 ? s.dir === 'Right' : pointEquals(move(s.snake[1], s.dir), s.snake[0])))
|
||||
assert.ok(states.some((s) => s.snake.length === 1) && states.some((s) => s.snake.length >= 10))
|
||||
assert.ok(states.some((s) => s.apple === null) && states.some((s) => s.apple !== null))
|
||||
assert.ok(states.some((s) => s.nextDir !== s.dir))
|
||||
})
|
||||
|
||||
// ---- a second implementation as an oracle (Exercise 12.7)
|
||||
|
||||
const m03 = loadFixture('m03-advance')
|
||||
m03.cases.forEach((c) =>
|
||||
test(`m12 advanceSpec agrees with Module 3's advance fixture: ${c.name}`, () =>
|
||||
assert.deepStrictEqual(advanceSpec(...freeze(c.args)), c.expected)))
|
||||
|
||||
test('m12 advance and advanceSpec agree on 500 generated states of a 5x5 board', () =>
|
||||
assert.deepStrictEqual(
|
||||
forAll(genState(5, 5), (s) => isDeepStrictEqual(advance(freeze(s)), advanceSpec(s)), 500, 2024),
|
||||
{ ok: true, runs: 500 }))
|
||||
|
||||
// Mutant 6: Puzzle 1 of Module 3, "the new head must not be on the old snake" (it forgets that the tail moves away).
|
||||
const advanceOldSnakeCheck = (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 body = eating ? state.snake : dropLast(state.snake)
|
||||
return !inBounds(state, head) || contains(state.snake, head, pointEquals)
|
||||
? { ...state, dir: state.nextDir, status: 'GameOver' }
|
||||
: { ...state, snake: [head, ...body], dir: state.nextDir, apple: eating ? null : state.apple, score: state.score + (eating ? 1 : 0) }
|
||||
}
|
||||
|
||||
test('m12 no invariant sees the wrong collision check of Puzzle 1: every state it produces is sane', () =>
|
||||
assert.deepStrictEqual(
|
||||
forAll(genState(5, 5), (s) => violations(advanceOldSnakeCheck(s)).length === 0, 500, 2024),
|
||||
{ ok: true, runs: 500 }))
|
||||
|
||||
test('m12 comparing with advanceSpec catches the wrong collision check: the head was moving onto the tail', () => {
|
||||
const result = forAll(genState(5, 5), (s) => isDeepStrictEqual(advanceOldSnakeCheck(s), advanceSpec(s)), 500, 2024)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.run, 21)
|
||||
const s = result.value
|
||||
assert.deepStrictEqual(move(s.snake[0], s.nextDir), s.snake[s.snake.length - 1])
|
||||
assert.equal(advanceOldSnakeCheck(s).status, 'GameOver')
|
||||
assert.equal(advanceSpec(s).status, 'Playing')
|
||||
})
|
||||
@@ -0,0 +1,237 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { LW, LH, LCELL, STRIP, initialLife, LifeAction } from '../src/life/model.js'
|
||||
import { lifeStep, lifeUpdate } from '../src/life/rules.js'
|
||||
import { lifeView } from '../src/life/view.js'
|
||||
import { GEN_MS, lifeKey, lifeClick, lifeActions, lifeStates, lifeMain } from '../src/life/app.js'
|
||||
import { clicks, keyDowns, keyUps, interval, clampTime, run } from '../src/stream.js'
|
||||
import { trace, pure, draw } from '../src/io.js'
|
||||
import { intBelow } from '../src/random.js'
|
||||
import { runApp } from '../src/runtime.js'
|
||||
import { browserPlatform } from '../src/browser.js'
|
||||
import { map as mapStream } from '../src/stream.js'
|
||||
import { speedActions, main, play } from '../src/app.js'
|
||||
import { pongActions } from '../src/pong/app.js'
|
||||
import { loadFixture, freeze } from './helpers.js'
|
||||
|
||||
const fixtureTests = (name, f) => {
|
||||
const fx = loadFixture(name)
|
||||
fx.cases.forEach((c) =>
|
||||
test(`m13 ${fx.function}: ${c.name}`, () => assert.deepStrictEqual(f(...freeze(c.args)), c.expected)))
|
||||
}
|
||||
|
||||
fixtureTests('m13-life-step', lifeStep)
|
||||
fixtureTests('m13-life-update', lifeUpdate)
|
||||
fixtureTests('m13-life-view', lifeView)
|
||||
fixtureTests('m13-life-key', lifeKey)
|
||||
fixtureTests('m13-life-click', lifeClick)
|
||||
|
||||
const time = (t) => ({ tag: 'Time', t })
|
||||
const down = (key) => ({ tag: 'KeyDown', key })
|
||||
const click = (x, y) => ({ tag: 'Click', x, y })
|
||||
const cell = (x, y) => ({ x, y })
|
||||
const rowMajor = (cells) => cells.every((c, i) => i === 0 || cells[i - 1].y < c.y || (cells[i - 1].y === c.y && cells[i - 1].x < c.x))
|
||||
|
||||
// ---- the model and the rules
|
||||
|
||||
test('m13 the board is 40 by 30 cells of 16 pixels, with a 24-pixel status strip below', () =>
|
||||
assert.deepStrictEqual([LW, LH, LCELL, STRIP], [40, 30, 16, 24]))
|
||||
|
||||
test('m13 initialLife is an empty, paused world at generation 0 with the given seed', () =>
|
||||
assert.deepStrictEqual(initialLife(9), { cells: [], running: false, seed: 9, generation: 0 }))
|
||||
|
||||
test('m13 LifeAction builds Toggle with a cell, and has the other actions as plain values', () =>
|
||||
assert.deepStrictEqual(
|
||||
[LifeAction.Frame, LifeAction.Toggle(cell(1, 2)), LifeAction.TogglePause, LifeAction.Step, LifeAction.Clear, LifeAction.Randomize],
|
||||
[{ tag: 'Frame' }, { tag: 'Toggle', cell: { x: 1, y: 2 } }, { tag: 'TogglePause' }, { tag: 'Step' }, { tag: 'Clear' },
|
||||
{ tag: 'Randomize' }]))
|
||||
|
||||
test('m13 lifeUpdate does not modify the state it is given', () => {
|
||||
const s = freeze({ cells: [cell(4, 5), cell(5, 5), cell(6, 5)], running: true, seed: 3, generation: 0 })
|
||||
const before = structuredClone(s)
|
||||
;[LifeAction.Frame, LifeAction.Toggle(cell(0, 0)), LifeAction.Toggle(cell(5, 5)), LifeAction.Randomize, LifeAction.Clear]
|
||||
.forEach((a) => assert.doesNotThrow(() => lifeUpdate(s, a)))
|
||||
assert.deepStrictEqual(s, before)
|
||||
})
|
||||
|
||||
test('m13 Toggle of a cell in the row just below the board changes nothing', () => {
|
||||
const s = freeze({ cells: [cell(4, 5)], running: false, seed: 3, generation: 2 })
|
||||
assert.deepStrictEqual(lifeUpdate(s, LifeAction.Toggle(cell(3, LH))), s)
|
||||
})
|
||||
|
||||
test('m13 a glider moves one cell diagonally every four generations', () => {
|
||||
const glider = [cell(1, 0), cell(2, 1), cell(0, 2), cell(1, 2), cell(2, 2)]
|
||||
const after4 = [1, 2, 3, 4].reduce((cells) => lifeStep(cells), freeze(glider))
|
||||
assert.deepStrictEqual(after4, [cell(2, 1), cell(3, 2), cell(1, 3), cell(2, 3), cell(3, 3)])
|
||||
})
|
||||
|
||||
test('m13 the next generation is computed from the whole old board: a blinker blinks forever', () => {
|
||||
const horizontal = [cell(9, 10), cell(10, 10), cell(11, 10)]
|
||||
const gens = Array.from({ length: 6 }).reduce((acc) => [...acc, lifeStep(acc.at(-1))], [freeze(horizontal)])
|
||||
assert.deepStrictEqual(gens[2], horizontal)
|
||||
assert.deepStrictEqual(gens[6], horizontal)
|
||||
assert.deepStrictEqual(gens[1], [cell(10, 9), cell(10, 10), cell(10, 11)])
|
||||
})
|
||||
|
||||
test('m13 Randomize draws one intBelow(4) per cell in row-major order, alive when the draw is 0', () => {
|
||||
const seed = 2024
|
||||
// The same draws, written the long way round with the Module 4 generator.
|
||||
const [draws, next] = Array.from({ length: LW * LH }).reduce(([ds, s]) => {
|
||||
const [d, s2] = intBelow(4)(s)
|
||||
return [[...ds, d], s2]
|
||||
}, [[], seed])
|
||||
const expected = draws.flatMap((d, i) => (d === 0 ? [cell(i % LW, Math.floor(i / LW))] : []))
|
||||
const result = lifeUpdate(freeze(initialLife(seed)), LifeAction.Randomize)
|
||||
assert.deepStrictEqual(result.cells, expected)
|
||||
assert.equal(result.seed, next)
|
||||
assert.ok(result.cells.length > 200 && result.cells.length < 400, `${result.cells.length} live cells`)
|
||||
})
|
||||
|
||||
test('m13 every generation of a random board stays sorted row-major, on the board and without duplicates', () => {
|
||||
const start = lifeUpdate(initialLife(5), LifeAction.Randomize).cells
|
||||
const gens = Array.from({ length: 20 }).reduce((acc) => [...acc, lifeStep(acc.at(-1))], [freeze(start)])
|
||||
gens.forEach((cells) => {
|
||||
assert.ok(rowMajor(cells))
|
||||
assert.ok(cells.every((c) => c.x >= 0 && c.x < LW && c.y >= 0 && c.y < LH))
|
||||
})
|
||||
})
|
||||
|
||||
// Generous on purpose: a straightforward lifeStep takes a few milliseconds per generation. This only catches a
|
||||
// generation that is far slower than the 150 ms between two of them.
|
||||
test('m13 lifeStep keeps up: 50 generations of a quarter-full board take well under 50 x 150 ms', () => {
|
||||
const start = lifeUpdate(initialLife(11), LifeAction.Randomize).cells
|
||||
const t0 = Date.now()
|
||||
Array.from({ length: 50 }).reduce((cells) => lifeStep(cells), start)
|
||||
assert.ok(Date.now() - t0 < 2500, `${Date.now() - t0} ms`)
|
||||
})
|
||||
|
||||
// ---- the fourth kind of input
|
||||
|
||||
test('m13 clicks emits the position of every Click input and ignores everything else', () =>
|
||||
assert.deepStrictEqual(
|
||||
run(clicks, freeze([time(10), click(3, 4), down('a'), click(0, 479), { tag: 'KeyUp', key: 'a' }])),
|
||||
[{ x: 3, y: 4 }, { x: 0, y: 479 }]))
|
||||
|
||||
test('m13 the other sources ignore clicks', () => {
|
||||
const inputs = freeze([click(5, 5), time(100), click(6, 6)])
|
||||
assert.deepStrictEqual(run(keyDowns, inputs), [])
|
||||
assert.deepStrictEqual(run(keyUps, inputs), [])
|
||||
assert.deepStrictEqual(run(interval(50), inputs), [50, 100])
|
||||
assert.deepStrictEqual(run(clampTime(clicks, 250), inputs), [{ x: 5, y: 5 }, { x: 6, y: 6 }])
|
||||
})
|
||||
|
||||
test('m13 a generation every 150 ms', () => assert.equal(GEN_MS, 150))
|
||||
|
||||
test('m13 lifeActions turns the clock into Frames, keys into actions and clicks on the board into Toggles', () =>
|
||||
assert.deepStrictEqual(
|
||||
run(lifeActions, freeze([time(150), down(' '), click(20, 40), down('x'), click(700, 10), down('n'), time(450)])),
|
||||
[LifeAction.Frame, LifeAction.TogglePause, LifeAction.Toggle(cell(1, 2)), LifeAction.Step, LifeAction.Frame,
|
||||
LifeAction.Frame]))
|
||||
|
||||
test('m13 lifeStates starts with the new world, then yields one state per action', () => {
|
||||
const out = run(lifeStates(7, lifeActions), freeze([time(0), click(16, 16), click(32, 16), click(48, 16), down(' '), time(150)]))
|
||||
assert.deepStrictEqual(out[0], initialLife(7))
|
||||
assert.deepStrictEqual(out.map((s) => s.cells.length), [0, 1, 2, 3, 3, 3])
|
||||
assert.deepStrictEqual(out.at(-1), { cells: [cell(2, 0), cell(2, 1), cell(2, 2)], running: true, seed: 7, generation: 1 })
|
||||
})
|
||||
|
||||
test('m13 lifeMain asks for a seed, draws the empty world first, and then a click as a live cell', () => {
|
||||
const { ops, result: stream } = trace(lifeMain, 42)
|
||||
assert.deepStrictEqual(ops, [{ tag: 'RandomSeed' }])
|
||||
const [first, next] = stream.step(time(0))
|
||||
const [second] = next.step(click(17, 33))
|
||||
assert.deepStrictEqual(trace(first[0], 0).ops.map((o) => o.shape), lifeView(initialLife(42)))
|
||||
assert.deepStrictEqual(
|
||||
trace(second[0], 0).ops.map((o) => o.shape),
|
||||
lifeView({ ...initialLife(42), cells: [cell(1, 2)] }))
|
||||
})
|
||||
|
||||
test('m13 lifeMain: after ten seconds in a background tab the world advances by 250 ms, not by ten seconds', () => {
|
||||
const [, afterFirst] = trace(lifeMain, 42).result.step(time(0))
|
||||
assert.equal(afterFirst.step(time(10000))[0].length, 1)
|
||||
})
|
||||
|
||||
// ---- the runtime and the browser platform (step 6 and 7 of the module)
|
||||
|
||||
const fakePlatform = () => {
|
||||
const calls = []
|
||||
const handlers = {}
|
||||
return {
|
||||
calls,
|
||||
fillRect: (...args) => calls.push(['fillRect', ...args]),
|
||||
fillText: (...args) => calls.push(['fillText', ...args]),
|
||||
randomSeed: () => 42,
|
||||
loadBest: () => 0,
|
||||
saveBest: () => {},
|
||||
startClock: (cb) => { handlers.time = cb },
|
||||
onKeyDown: (cb) => { handlers.down = cb },
|
||||
onKeyUp: (cb) => { handlers.up = cb },
|
||||
onClick: (cb) => { handlers.click = cb },
|
||||
fireTime: (t) => handlers.time(t),
|
||||
fireClick: (x, y) => handlers.click(x, y),
|
||||
registered: () => Object.keys(handlers).sort(),
|
||||
}
|
||||
}
|
||||
|
||||
test('m13 runApp registers a click handler with the platform', () => {
|
||||
const p = fakePlatform()
|
||||
runApp(pure(mapStream(clicks, () => pure(undefined))), p)
|
||||
assert.deepStrictEqual(p.registered(), ['click', 'down', 'time', 'up'])
|
||||
})
|
||||
|
||||
test('m13 runApp feeds clicks to the stream', () => {
|
||||
const p = fakePlatform()
|
||||
const program = pure(mapStream(clicks, ({ x, y }) => draw({ tag: 'Rect', x, y, w: 1, h: 1, color: '#ffffff' })))
|
||||
runApp(program, p)
|
||||
p.fireClick(33, 47)
|
||||
p.fireClick(0, 2)
|
||||
assert.deepStrictEqual(p.calls, [['fillRect', 33, 47, 1, 1, '#ffffff'], ['fillRect', 0, 2, 1, 1, '#ffffff']])
|
||||
})
|
||||
|
||||
test('m13 runApp runs Life: a click on the canvas draws the world with that cell alive', () => {
|
||||
const p = fakePlatform()
|
||||
runApp(lifeMain, p)
|
||||
p.fireTime(0)
|
||||
p.calls.length = 0
|
||||
p.fireClick(100, 250)
|
||||
assert.ok(p.calls.some((c) => c[0] === 'fillRect' && c[1] === 6 * 16 + 1 && c[2] === 15 * 16 + 1 && c[5] === '#7cb342'))
|
||||
})
|
||||
|
||||
test('m13 Snake and Pong ignore clicks: their actions and drawings are the same with and without them', () => {
|
||||
const withClicks = freeze([time(0), click(5, 5), time(25), down('ArrowUp'), click(300, 300), time(200)])
|
||||
const without = withClicks.filter((i) => i.tag !== 'Click')
|
||||
assert.deepStrictEqual(run(speedActions, withClicks), run(speedActions, without))
|
||||
assert.deepStrictEqual(run(pongActions, withClicks), run(pongActions, without))
|
||||
assert.deepStrictEqual(play(main, 42, withClicks), play(main, 42, without))
|
||||
})
|
||||
|
||||
// A fake canvas shown at half its size (CSS), with a 2-pixel border, 100 px from the left and 50 px from the top.
|
||||
const fakeCanvas = () => {
|
||||
const listeners = []
|
||||
return {
|
||||
listeners,
|
||||
width: 640, height: 504, clientWidth: 320, clientHeight: 252, clientLeft: 2, clientTop: 2,
|
||||
getContext: () => ({}),
|
||||
getBoundingClientRect: () => ({ left: 100, top: 50, width: 324, height: 256 }),
|
||||
addEventListener: (type, f) => listeners.push([type, f]),
|
||||
}
|
||||
}
|
||||
const fakeWindow = { addEventListener: () => {}, requestAnimationFrame: () => {}, localStorage: null }
|
||||
|
||||
test('m13 browserPlatform listens for clicks on the canvas, not before onClick is called', () => {
|
||||
const canvas = fakeCanvas()
|
||||
const platform = browserPlatform(canvas, fakeWindow)
|
||||
assert.deepStrictEqual(canvas.listeners, [])
|
||||
platform.onClick(() => {})
|
||||
assert.deepStrictEqual(canvas.listeners.map(([type]) => type), ['click'])
|
||||
})
|
||||
|
||||
test('m13 browserPlatform converts a click to canvas pixels, through the border and the CSS scaling', () => {
|
||||
const canvas = fakeCanvas()
|
||||
const got = []
|
||||
browserPlatform(canvas, fakeWindow).onClick((x, y) => got.push([x, y]))
|
||||
const [, listener] = canvas.listeners[0]
|
||||
listener({ clientX: 100 + 2 + 10, clientY: 50 + 2 + 20 })
|
||||
listener({ clientX: 100 + 2 + 319.75, clientY: 50 + 2 + 0.4 })
|
||||
assert.deepStrictEqual(got, [[20, 40], [639, 0]])
|
||||
})
|
||||
@@ -7,7 +7,8 @@ import { dirname, join, basename } from 'node:path'
|
||||
const srcDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src')
|
||||
|
||||
// Files that are allowed to touch the outside world.
|
||||
const excluded = (file) => ['runtime.js', 'browser.js', 'main.js'].includes(basename(file))
|
||||
// The entry files (main.js, life/main.js, pong/main.js and the replay viewer's replay-main.js) are given browser glue.
|
||||
const excluded = (file) => ['runtime.js', 'browser.js', 'main.js', 'replay-main.js'].includes(basename(file))
|
||||
|
||||
const banned = [
|
||||
['let', /\blet\b/],
|
||||
@@ -32,6 +33,7 @@ const banned = [
|
||||
const stripComments = (code) => code.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
||||
const violations = (code) => banned.filter(([, re]) => re.test(stripComments(code))).map(([name]) => name)
|
||||
|
||||
// Only JavaScript is scanned. src/types.d.ts (the optional type declarations) holds no code that runs, so it is skipped.
|
||||
const jsFiles = (dir) =>
|
||||
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory() ? jsFiles(join(dir, e.name)) : e.name.endsWith('.js') ? [join(dir, e.name)] : [])
|
||||
@@ -74,6 +76,9 @@ jsFiles(srcDir)
|
||||
test(`purity: ${f.slice(srcDir.length + 1)} has no banned constructs`, () =>
|
||||
assert.deepEqual(violations(readFileSync(f, 'utf8')), [])))
|
||||
|
||||
test('the purity audit skips .d.ts files', () =>
|
||||
assert.deepEqual(jsFiles(srcDir).filter((f) => f.endsWith('.d.ts')), []))
|
||||
|
||||
// helpers.js is loaded by every test file; importing src/ there would make all tests fail at load time against the stubs.
|
||||
test('test/helpers.js imports nothing from src/', () =>
|
||||
assert.doesNotMatch(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'helpers.js'), 'utf8'), /from\s+['"][^'"]*src\//))
|
||||
Reference in new issue
Block a user