/src holds the solutions and passes its address in E2E_BASE_URL.
+//
+// Browsers: Chromium and WebKit, and Firefox if a build is installed. Nothing is ever downloaded. If the exact build
+// this Playwright version expects is missing but another revision of the same browser is in the Playwright cache
+// (~/.cache/ms-playwright, or PLAYWRIGHT_BROWSERS_PATH), that one is used and named; if there is none, the project is
+// left out with a message.
+import { defineConfig, chromium, webkit, firefox } from '@playwright/test'
+import { existsSync, readdirSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+
+const cache = process.env.PLAYWRIGHT_BROWSERS_PATH || join(homedir(), '.cache', 'ms-playwright')
+const launchers = {
+ chromium: ['chrome-linux64/chrome', 'chrome-linux/chrome'],
+ webkit: ['pw_run.sh'],
+ firefox: ['firefox/firefox'],
+}
+// Print the notes once, from the main process, not again from every worker.
+const say = (msg) => { if (!process.env.TEST_WORKER_INDEX) console.log(msg) }
+
+const browser = (name, type) => {
+ if (existsSync(type.executablePath())) return { name, use: { browserName: name } }
+ const other = existsSync(cache)
+ ? readdirSync(cache)
+ .filter((d) => new RegExp(`^${name}-\\d+$`).test(d))
+ .sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]))
+ .flatMap((d) => launchers[name].map((l) => join(cache, d, l)))
+ .find(existsSync)
+ : undefined
+ if (other) {
+ say(`e2e: ${name}: the expected build is not installed; using ${other}`)
+ return { name, use: { browserName: name, launchOptions: { executablePath: other } } }
+ }
+ say(`e2e: ${name}: no build installed, skipping this project (npx playwright install ${name} would download one)`)
+ return null
+}
+
+export default defineConfig({
+ testDir: 'tests',
+ fullyParallel: true,
+ workers: process.env.E2E_WORKERS ? Number(process.env.E2E_WORKERS) : '50%',
+ forbidOnly: true,
+ retries: 0,
+ timeout: 60_000,
+ reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
+ outputDir: 'test-results',
+ use: {
+ baseURL: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:8000',
+ viewport: { width: 1280, height: 900 },
+ trace: 'retain-on-failure',
+ },
+ projects: [browser('chromium', chromium), browser('webkit', webkit), browser('firefox', firefox)].filter(Boolean),
+})
diff --git a/e2e/tests/life.spec.mjs b/e2e/tests/life.spec.mjs
new file mode 100644
index 0000000..99cc8cc
--- /dev/null
+++ b/e2e/tests/life.spec.mjs
@@ -0,0 +1,81 @@
+// Conway's Life (life.html) on every track, judged against the pure model.
+import { test, expect, TRACKS } from '../lib/app.mjs'
+import { FRAME } from '../lib/clock.mjs'
+import { GEN_MS, LCELL } from '../lib/oracle.mjs'
+
+const GREEN = '#7cb342'
+const lastScene = (shapes) => shapes.slice(Math.max(0, shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 640)))
+// Live cells as the page drew them: the green squares, back in cell coordinates.
+const cellsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Rect' && s.color === GREEN)
+ .map((s) => ({ x: (s.x - 1) / LCELL, y: (s.y - 1) / LCELL }))
+const generationOf = (shapes) => Number(/^Generation (\d+)$/.exec(lastScene(shapes).find((s) => s.tag === 'Text' && s.text.startsWith('Generation')).text)[1])
+const rowMajor = (cells) => [...cells].sort((p, q) => p.y - q.y || p.x - q.x)
+const beats = (ms, a, b) => Math.floor(b / ms) - Math.floor(a / ms)
+const lastFrameTime = (a) => Math.floor(a.clock.now / FRAME) * FRAME - a.clock.t0
+
+for (const track of TRACKS) {
+ test.describe(`${track}: life`, () => {
+ test('clicks on a CSS-scaled canvas with a border toggle the cells the model says', async ({ app, page }) => {
+ const a = await (await app(track, 'life')).start()
+ // Show the 640 x 504 canvas 1.5 times as large; its 2 px border stays. Clicks must still land on the right cells.
+ await page.locator('canvas').evaluate((c) => { c.style.width = `${c.width * 1.5}px` })
+ expect(await page.locator('canvas').evaluate((c) => [c.clientWidth, c.clientHeight, c.clientLeft])).toEqual([960, 756, 2])
+ await a.expectInSync()
+ const c = (x, y) => [x * LCELL + 8, y * LCELL + 8] // the centre of cell (x, y)
+ const clicks = [
+ ...[c(10, 10), c(11, 10), c(12, 10)], // cell centres
+ [0, 0], // the first pixel of the board: cell (0, 0)
+ [639, 479], // the last pixel of the board: cell (39, 29)
+ [5 * LCELL, 7 * LCELL + 15], // the left edge of cell (5, 7), bottom row of its pixels
+ [6 * LCELL - 1, 7 * LCELL], // the right edge of the same cell, top row: switches it off again
+ [6 * LCELL, 7 * LCELL], // one pixel further right: cell (6, 7)
+ [320, 490], // the status strip under the board: no cell
+ ]
+ for (const [x, y] of clicks) {
+ await a.click(x, y)
+ await a.expectInSync(`click at canvas pixel (${x}, ${y})`)
+ }
+ await a.advance(200)
+ const drawn = await a.expectInSync()
+ const want = rowMajor([{ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 11, y: 10 }, { x: 12, y: 10 }, { x: 39, y: 29 }, { x: 6, y: 7 }])
+ expect(cellsOf(drawn)).toEqual(want)
+ expect(a.state.cells).toEqual(want)
+ })
+
+ test('N steps, Space runs one generation per 150 ms, C clears, R fills the board as the model says', async ({ app }) => {
+ const a = await (await app(track, 'life')).start()
+ for (const x of [10, 11, 12]) await a.click(x * LCELL + 8, 10 * LCELL + 8)
+ await a.press('n')
+ const stepped = await a.expectInSync('after N')
+ expect(cellsOf(stepped)).toEqual([{ x: 11, y: 9 }, { x: 11, y: 10 }, { x: 11, y: 11 }])
+ expect(generationOf(stepped)).toBe(1)
+
+ await a.advance(40)
+ await a.press(' ')
+ const startedAt = lastFrameTime(a)
+ await a.expectInSync('running')
+ for (let i = 0; i < 8; i++) {
+ await a.advance(GEN_MS / 2)
+ const f = await a.expectInSync(`running, ${i}`)
+ expect(generationOf(f)).toBe(1 + beats(GEN_MS, startedAt, lastFrameTime(a)))
+ }
+ await a.press(' ')
+ const pausedGen = generationOf(await a.expectInSync('paused'))
+ await a.advance(600)
+ expect(generationOf(await a.expectInSync('paused for 600 ms'))).toBe(pausedGen)
+
+ await a.press('c')
+ const cleared = await a.expectInSync('after C')
+ expect(cellsOf(cleared)).toEqual([])
+ expect(generationOf(cleared)).toBe(0)
+
+ await a.press('r')
+ const filled = await a.expectInSync('after R')
+ expect(cellsOf(filled)).toEqual(a.state.cells)
+ expect(cellsOf(filled).length).toBeGreaterThan(100)
+ await a.press('n')
+ await a.advance(300)
+ await a.expectInSync('after R and N')
+ })
+ })
+}
diff --git a/e2e/tests/pong.spec.mjs b/e2e/tests/pong.spec.mjs
new file mode 100644
index 0000000..8722a1b
--- /dev/null
+++ b/e2e/tests/pong.spec.mjs
@@ -0,0 +1,75 @@
+// Pong (pong.html) on every track, judged against the pure model.
+import { test, expect, TRACKS } from '../lib/app.mjs'
+
+// The player's paddle is the white 8 x 64 rect in column 2 (x = 16); its top row in cells is y / 8.
+const lastScene = (shapes) => shapes.slice(Math.max(0, shapes.findLastIndex((s) => s.tag === 'Rect' && s.w === 640)))
+const paddleOf = (shapes) => lastScene(shapes).find((s) => s.tag === 'Rect' && s.x === 16 && s.h === 64).y / 8
+const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text)
+
+for (const track of TRACKS) {
+ test.describe(`${track}: pong`, () => {
+ test('holding and releasing keys moves the paddle as the model says', async ({ app }) => {
+ const a = await (await app(track, 'pong')).start()
+ const p0 = paddleOf(await a.expectInSync())
+ await a.down('ArrowUp')
+ await a.advance(300)
+ const p1 = paddleOf(await a.expectInSync('holding ArrowUp'))
+ await a.up('ArrowUp')
+ await a.advance(300)
+ const p2 = paddleOf(await a.expectInSync('released'))
+ await a.down('ArrowDown')
+ await a.advance(600)
+ const p3 = paddleOf(await a.expectInSync('holding ArrowDown'))
+ await a.up('ArrowDown')
+ await a.advance(100)
+ await a.expectInSync('released again')
+ // One cell per 30 ms game frame while held, none after release.
+ expect(p0).toBe(21)
+ expect(p1).toBeLessThan(p0)
+ expect(p2).toBe(p1)
+ expect(p3).toBeGreaterThan(p2)
+ })
+
+ test('a whole match on the fake clock ends with the model\'s score and the right winner text', async ({ app }) => {
+ const a = await (await app(track, 'pong')).start()
+ // A simple player: every 90 ms, hold the key that brings the paddle's middle toward the ball.
+ let held = null
+ for (let i = 0; i < 1000 && a.state.status === 'Playing'; i++) {
+ const { ball, player } = a.state
+ const want = player + 4 < ball.y - 1 ? 'ArrowDown' : player + 4 > ball.y + 1 ? 'ArrowUp' : null
+ if (want !== held) {
+ if (held) await a.up(held)
+ if (want) await a.down(want)
+ held = want
+ }
+ await a.advance(90)
+ if (i % 10 === 0) await a.expectInSync(`match, ${i * 90} ms`)
+ }
+ if (held) await a.up(held)
+ await a.advance(500)
+ const end = await a.expectInSync('match over')
+ const { score } = a.state
+ expect(a.state.status).toBe('Over')
+ expect(Math.max(score.player, score.cpu)).toBe(5)
+ expect(textsOf(end)).toEqual(expect.arrayContaining([`${score.player} ${score.cpu}`,
+ score.player > score.cpu ? 'You Win!' : 'CPU Wins', 'Press R to restart']))
+ })
+
+ test('R restarts and keeps a held key', async ({ app }) => {
+ const a = await (await app(track, 'pong')).start()
+ await a.advance(300)
+ await a.down('ArrowDown')
+ await a.advance(150)
+ const before = paddleOf(await a.expectInSync('holding ArrowDown'))
+ await a.press('r')
+ const restarted = paddleOf(await a.expectInSync('after R'))
+ await a.advance(300)
+ const after = paddleOf(await a.expectInSync('after R, still holding'))
+ await a.up('ArrowDown')
+ await a.expectInSync('released')
+ expect(before).toBeGreaterThan(21)
+ expect(restarted).toBe(21)
+ expect(after).toBeGreaterThan(21)
+ })
+ })
+}
diff --git a/e2e/tests/replay.spec.mjs b/e2e/tests/replay.spec.mjs
new file mode 100644
index 0000000..8a57c23
--- /dev/null
+++ b/e2e/tests/replay.spec.mjs
@@ -0,0 +1,58 @@
+// The replay viewer (replay.html) on every track, judged against the pure model. The recording is
+// fixtures/replay-sample.json, which the pages fetch and the oracle reads from disk.
+import { test, expect, TRACKS } from '../lib/app.mjs'
+import { FRAME } from '../lib/clock.mjs'
+import { recording, REPLAY_MS } from '../lib/oracle.mjs'
+
+const LAST = recording.actions.length
+const lastScene = (shapes) => shapes.slice(Math.max(0, shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 400)))
+const stepOf = (shapes) => {
+ const t = lastScene(shapes).find((s) => s.tag === 'Text' && s.text.startsWith('step '))
+ return Number(/^step (\d+) \//.exec(t.text)[1])
+}
+const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text)
+// Multiples of `ms` in (a, b]: how many beats of a `ms` interval fall between two frames at times a and b.
+const beats = (ms, a, b) => Math.floor(b / ms) - Math.floor(a / ms)
+// Time of the last animation frame so far, in the page's own clock (ms since its first frame).
+const lastFrameTime = (a) => Math.floor(a.clock.now / FRAME) * FRAME - a.clock.t0
+
+for (const track of TRACKS) {
+ test.describe(`${track}: replay`, () => {
+ test('Right, Left, Home and End show the model\'s frames', async ({ app }) => {
+ const a = await (await app(track, 'replay')).start()
+ expect(stepOf(await a.expectInSync())).toBe(0)
+ const seen = []
+ for (const key of ['ArrowRight', 'ArrowRight', 'ArrowRight', 'ArrowLeft', 'End', 'ArrowRight', 'ArrowLeft', 'Home', 'ArrowLeft']) {
+ await a.press(key)
+ seen.push(stepOf(await a.expectInSync(`after ${key}`)))
+ await a.advance(50)
+ }
+ await a.expectInSync()
+ expect(seen).toEqual([1, 2, 3, 2, LAST, LAST, LAST - 1, 0, 0])
+ expect(textsOf(a.lastPageFrame)).toContain(`step 0 / ${LAST}`)
+ })
+
+ test('autoplay advances one frame per 100 ms of fake time and stops on the last frame', async ({ app }) => {
+ const a = await (await app(track, 'replay')).start()
+ await a.advance(130)
+ await a.press(' ')
+ const pressedAt = lastFrameTime(a)
+ expect(textsOf(await a.expectInSync('play'))).toContain('Space: pause')
+ // For a while, check every 100 ms: the step shown is the number of 100 ms beats since the key.
+ for (let i = 0; i < 12; i++) {
+ await a.advance(REPLAY_MS)
+ expect(stepOf(await a.expectInSync(`playing, ${i}`))).toBe(beats(REPLAY_MS, pressedAt, lastFrameTime(a)))
+ }
+ // Then run to the end and beyond, a second at a time; it stops on the last frame.
+ while (a.state.playing) {
+ await a.advance(1000)
+ await a.expectInSync('playing on')
+ }
+ await a.advance(1000)
+ const end = await a.expectInSync('stopped')
+ expect(stepOf(end)).toBe(LAST)
+ expect(textsOf(end)).toContain('Left/Right: step Home/End: jump Space: play')
+ expect(beats(REPLAY_MS, pressedAt, lastFrameTime(a))).toBeGreaterThan(LAST)
+ })
+ })
+}
diff --git a/e2e/tests/snake.spec.mjs b/e2e/tests/snake.spec.mjs
new file mode 100644
index 0000000..1997de9
--- /dev/null
+++ b/e2e/tests/snake.spec.mjs
@@ -0,0 +1,227 @@
+// Snake (index.html) on every track, judged against the pure model: the page must draw, frame for frame, what the
+// course's own program draws for the same seed, stored best and inputs.
+import { test, expect, TRACKS } from '../lib/app.mjs'
+import { framesFrom } from '../lib/drawing.mjs'
+import { FRAME } from '../lib/clock.mjs'
+import { SEED } from '../lib/seed.mjs'
+import { sessionView, newGame, movesEvery, FRAME_MS } from '../lib/oracle.mjs'
+import { browserPlatform } from '../../js/solutions/browser.js'
+
+// What the reference glue (js/solutions/browser.js) reads from a stored string: its own loadBest, given a window
+// whose localStorage holds `raw`.
+// What the reference glue (js/solutions/browser.js) reads from a stored string: its own loadBest, given a window
+// whose localStorage holds `raw`. The rule: only plain decimal digits with a value of at most 2147483647 count.
+const referenceLoadBest = (raw) =>
+ browserPlatform({ getContext: () => ({}) }, { localStorage: { getItem: () => raw } }).loadBest()
+
+// Stored strings that saveBest never writes. Number() would read most of them as a number; the glue must not.
+const SPELLINGS = ['007', '2147483647', '+7', ' 7', '7 ', '0x10', '1e2', '7.0', '2147483648']
+// No known differences between the tracks. If one appears, list it here as {track: {raw: reason}} and report it.
+const KNOWN = {}
+
+const BEST_KEY = 'pure-snake-best'
+const HEAD = '#7cb342'
+const DIRS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
+const KEY_OF = { Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight' }
+const OPPOSITE = { Up: 'Down', Down: 'Up', Left: 'Right', Right: 'Left' }
+
+// One page frame can hold several scenes (several game frames due in one animation frame); the last one is the picture.
+const lastScene = (shapes) => {
+ const i = shapes.findLastIndex((s) => s.tag === 'Rect' && s.x === 0 && s.y === 0 && s.w === 400)
+ return shapes.slice(Math.max(i, 0))
+}
+const headOf = (shapes) => {
+ const r = lastScene(shapes).find((s) => s.tag === 'Rect' && s.color === HEAD)
+ return r ? { x: (r.x - 1) / 20, y: (r.y - 1) / 20 } : null
+}
+const textsOf = (shapes) => lastScene(shapes).filter((s) => s.tag === 'Text').map((s) => s.text)
+
+const headMoved = (before) => (s) => s.game.snake[0].x !== before.x || s.game.snake[0].y !== before.y || s.game.status !== 'Playing'
+
+// A greedy player for the scripted game: head for the apple, never into a wall or the body, never reverse.
+const choose = (g) => {
+ const h = g.snake[0]
+ const body = g.snake.slice(0, -1)
+ const safe = Object.entries(DIRS).filter(([key, [dx, dy]]) => {
+ const dir = key.slice(5)
+ const p = { x: h.x + dx, y: h.y + dy }
+ return dir !== OPPOSITE[g.dir] && p.x >= 0 && p.y >= 0 && p.x < g.width && p.y < g.height &&
+ !body.some((b) => b.x === p.x && b.y === p.y)
+ })
+ const dist = ([, [dx, dy]]) => Math.abs(h.x + dx - g.apple.x) + Math.abs(h.y + dy - g.apple.y)
+ const [best] = safe.sort((a, b) => dist(a) - dist(b))
+ return best ? best[0].slice(5) : g.dir
+}
+
+/**
+ * Play the scripted game until `done(session)`: after every step, press the key the greedy player wants (checking the
+ * page against the model after the key), then run the clock to the next step (checking again). Returns, per step,
+ * the score before it and how many game frames it took, counted from the page's frames (each game frame draws once,
+ * and so does each key that turns the snake).
+ */
+const playUntil = async (a, done, maxSteps = 200) => {
+ await a.expectInSync('before the scripted game')
+ const steps = []
+ for (let i = 0; i < maxSteps && !done(a.state); i++) {
+ const g = a.state.game
+ const want = choose(g)
+ const seenBefore = a.seen
+ const keys = want !== g.nextDir ? 1 : 0
+ if (keys) {
+ await a.press(KEY_OF[want])
+ await a.expectInSync(`after ${KEY_OF[want]} at step ${i}`)
+ }
+ await a.advanceUntil(headMoved(g.snake[0]))
+ await a.expectInSync(`step ${i}`)
+ steps.push({ score: g.score, frames: a.seen - seenBefore - keys })
+ }
+ expect(done(a.state), 'the scripted game reached its goal').toBe(true)
+ return steps
+}
+
+for (const track of TRACKS) {
+ test.describe(`${track}: snake`, () => {
+ test('first frame equals the new session\'s scene', async ({ app }) => {
+ const a = await (await app(track, 'snake')).start()
+ expect(await a.expectInSync()).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 0, improved: false })))
+ })
+
+ test('one step every 200 ms of fake time at score 0', async ({ app, page }) => {
+ const a = await (await app(track, 'snake')).start()
+ await a.advance(1000)
+ await a.expectInSync()
+ const frames = await framesFrom(page, 0)
+ const moves = frames.filter((f, i) => i > 0 && JSON.stringify(headOf(f.shapes)) !== JSON.stringify(headOf(frames[i - 1].shapes)))
+ // A step every movesEvery(0) = 8 game frames of 25 ms, seen on the first animation frame at or after it.
+ expect(movesEvery(0) * FRAME_MS).toBe(200)
+ expect(moves.map((f) => f.at - a.clock.t0)).toEqual([200, 400, 600, 800].map((t) => Math.ceil(t / FRAME) * FRAME))
+ expect(moves.map((f) => headOf(f.shapes).x)).toEqual([11, 12, 13, 14])
+ })
+
+ test('a scripted game eats three apples, matching the model after every key, and speeds up after the third', async ({ app }) => {
+ const a = await (await app(track, 'snake')).start()
+ const steps = await playUntil(a, (s) => s.game.score >= 3)
+ const after = await playUntil(a, (s) => s.game.score >= 4 || s.game.status !== 'Playing', 6)
+ const all = [...steps, ...after]
+ // Every step took movesEvery(score) game frames on the page: 8 at scores 0-2, 7 from the third apple on.
+ expect(all.map((s) => s.frames)).toEqual(all.map((s) => movesEvery(s.score)))
+ expect(all.filter((s) => s.score === 3).length, 'steps observed at score 3').toBeGreaterThan(0)
+ expect(textsOf(a.lastPageFrame)).toContain(`Score: ${a.state.game.score}`)
+ })
+
+ test('Up then Left inside one step turns up, then left (turn queue)', async ({ app }) => {
+ const a = await (await app(track, 'snake')).start()
+ await a.advanceUntil(headMoved(a.state.game.snake[0]))
+ const h0 = headOf(await a.expectInSync())
+ await a.press('ArrowUp')
+ await a.expectInSync('after ArrowUp')
+ await a.press('ArrowLeft')
+ await a.expectInSync('after ArrowLeft')
+ await a.advanceUntil(headMoved(a.state.game.snake[0]))
+ const h1 = headOf(await a.expectInSync('first step'))
+ await a.advanceUntil(headMoved(a.state.game.snake[0]))
+ const h2 = headOf(await a.expectInSync('second step'))
+ expect([h1, h2]).toEqual([{ x: h0.x, y: h0.y - 1 }, { x: h0.x - 1, y: h0.y - 1 }])
+ })
+
+ test('Space shows Paused and freezes the scene, Space resumes', async ({ app, page }) => {
+ const a = await (await app(track, 'snake')).start()
+ await a.advance(300)
+ await a.press(' ')
+ const frozen = await a.expectInSync('paused')
+ expect(textsOf(frozen)).toEqual(expect.arrayContaining(['Paused', 'Press Space to resume']))
+ const from = a.seen
+ await a.advance(1000)
+ await a.expectInSync('paused for 1 s')
+ const during = await framesFrom(page, from)
+ expect(during.length, 'the page keeps drawing while paused').toBeGreaterThan(30)
+ for (const f of during) expect(f.shapes).toEqual(during[0].shapes)
+ expect(headOf(during.at(-1).shapes)).toEqual(headOf(frozen))
+ await a.press(' ')
+ await a.advance(400)
+ const resumed = await a.expectInSync('resumed')
+ expect(textsOf(resumed)).not.toContain('Paused')
+ expect(headOf(resumed)).not.toEqual(headOf(frozen))
+ })
+
+ test('a crash shows Game Over, R restarts', async ({ app }) => {
+ const a = await (await app(track, 'snake')).start()
+ await a.advanceUntil((s) => s.game.status === 'GameOver')
+ const over = await a.expectInSync('crashed')
+ expect(textsOf(over)).toEqual(expect.arrayContaining(['Game Over', 'Press R to restart']))
+ await a.advance(500)
+ await a.expectInSync('after the crash')
+ // Restart starts a new game from the game's current seed (which every apple has moved on), not from SEED.
+ const seed = a.state.game.seed
+ await a.press('r')
+ const again = await a.expectInSync('after R')
+ expect(lastScene(again)).toEqual(await a.expected(sessionView({ game: newGame(seed), best: a.state.best, improved: false })))
+ await a.advance(500)
+ await a.expectInSync('playing again')
+ })
+
+ test('a 10 s jump of the fake clock in one frame advances the game by 250 ms only', async ({ app }) => {
+ const a = await (await app(track, 'snake')).start()
+ await a.advance(500)
+ const h1 = headOf(await a.expectInSync())
+ await a.jump(10_000)
+ const h2 = headOf(await a.expectInSync('after the jump'))
+ // 250 ms are 10 game frames: one or two steps. Unclamped, 10 s would be 50 steps: into the wall.
+ expect(Math.abs(h2.x - h1.x) + Math.abs(h2.y - h1.y)).toBeGreaterThanOrEqual(1)
+ expect(Math.abs(h2.x - h1.x) + Math.abs(h2.y - h1.y)).toBeLessThanOrEqual(2)
+ expect(a.state.game.status).toBe('Playing')
+ await a.advance(400)
+ await a.expectInSync('after the jump, running on')
+ })
+
+ test('the best score is saved on improvement and shown after a reload; a stored best of 37 too', async ({ app, page }) => {
+ const a = await (await app(track, 'snake')).start()
+ await playUntil(a, (s) => s.game.score >= 1)
+ expect(a.model.saves).toEqual([1])
+ expect(await page.evaluate((k) => localStorage.getItem(k), BEST_KEY)).toBe('1')
+ await a.reload({ best: 1 })
+ const shown = await a.expectInSync('after reload')
+ expect(shown).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 1, improved: false })))
+ expect(textsOf(shown)).toContain('Best: 1')
+ await page.evaluate((k) => localStorage.setItem(k, '37'), BEST_KEY)
+ await a.reload({ best: 37 })
+ expect(textsOf(await a.expectInSync('stored 37'))).toContain('Best: 37')
+ await a.advance(300)
+ await a.expectInSync('stored 37, running')
+ })
+
+ test('garbage in localStorage reads as Best: 0 and the game runs', async ({ app, page }) => {
+ const a = await (await app(track, 'snake')).start()
+ for (const raw of ['banana', '-5', '3.5', 'NaN', '1e999', '99999999999999999999', '']) {
+ await page.evaluate(([k, v]) => localStorage.setItem(k, v), [BEST_KEY, raw])
+ await a.reload({ best: 0 })
+ expect(textsOf(await a.expectInSync(`stored ${JSON.stringify(raw)}`))).toContain('Best: 0')
+ await a.advance(250)
+ await a.expectInSync(`stored ${JSON.stringify(raw)}, running`)
+ }
+ })
+
+ for (const raw of SPELLINGS) {
+ test(`a stored best spelled ${JSON.stringify(raw)} reads as the reference glue reads it`, async ({ app, page }) => {
+ test.fail(Boolean(KNOWN[track]?.[raw]), KNOWN[track]?.[raw])
+ const want = referenceLoadBest(raw)
+ await page.addInitScript(([k, v]) => localStorage.setItem(k, v), [BEST_KEY, raw])
+ const a = await (await app(track, 'snake')).start({ best: want })
+ expect(textsOf(await a.expectInSync(`stored ${JSON.stringify(raw)}`))).toContain(`Best: ${want}`)
+ })
+ }
+
+ test('a page whose localStorage throws still runs, shows Best: 0, and survives a new best', async ({ app, page }) => {
+ await page.addInitScript(() => {
+ Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw new DOMException('blocked by the test', 'SecurityError') } })
+ })
+ const a = await (await app(track, 'snake')).start()
+ expect(textsOf(await a.expectInSync())).toContain('Best: 0')
+ await playUntil(a, (s) => s.game.score >= 1)
+ expect(a.model.saves).toEqual([1])
+ expect(textsOf(a.lastPageFrame)).toContain('Best: 1')
+ await a.advance(300)
+ await a.expectInSync('running after the failed save')
+ })
+ })
+}
diff --git a/e2e/tests/techniques.spec.mjs b/e2e/tests/techniques.spec.mjs
new file mode 100644
index 0000000..aae4b7c
--- /dev/null
+++ b/e2e/tests/techniques.spec.mjs
@@ -0,0 +1,69 @@
+// Each of the three techniques the other specs build on, proven on its own, on every track; and the oracle checked
+// against the course's own `play`.
+import { test, expect, TRACKS } from '../lib/app.mjs'
+import { seedCalls, SEED } from '../lib/seed.mjs'
+import { framesFrom, frameCount } from '../lib/drawing.mjs'
+import { FRAME } from '../lib/clock.mjs'
+import { Model, sessionView, newGame } from '../lib/oracle.mjs'
+
+for (const track of TRACKS) {
+ test.describe(`${track}: techniques`, () => {
+ test('fixed seed: the page asks for one Uint32Array(1) and starts the game that seed makes', async ({ app }) => {
+ const a = await app(track, 'snake')
+ await a.start()
+ expect(await seedCalls(a.page)).toEqual([{ type: 'Uint32Array', length: 1 }])
+ // The apple of the first frame is where newGame(SEED) puts it; another seed would (almost surely) put it elsewhere.
+ const apple = (await a.expectInSync()).find((s) => s.tag === 'Rect' && s.color === '#e53935')
+ const want = newGame(SEED).apple
+ expect(apple).toMatchObject({ x: want.x * 20 + 1, y: want.y * 20 + 1 })
+ expect(newGame(SEED + 1).apple).not.toEqual(want)
+ })
+
+ test('fake clock: frames come only when the test moves the clock, with the timestamps the clock arithmetic predicts', async ({ app, page }) => {
+ const a = await app(track, 'snake')
+ await a.start()
+ // Paused: real time passing changes nothing.
+ const before = await frameCount(page)
+ await page.waitForTimeout(300)
+ expect(await frameCount(page)).toBe(before)
+ // 1000 ms of fake time: the page draws once per 25 ms game frame, and each drawing happens on the first
+ // animation frame (a multiple of 16 ms after the first) at or after that game frame's time.
+ const startTick = a.clock.now
+ await a.advance(1000)
+ expect(await a.clock.ticks()).toBe(startTick + 1000)
+ const frames = await framesFrom(page, 0)
+ const times = frames.map((f) => f.at - a.clock.t0)
+ const due = Array.from({ length: 40 }, (_, i) => (i + 1) * 25).map((g) => Math.ceil(g / FRAME) * FRAME).filter((t) => t <= 1000)
+ expect(times).toEqual([0, ...due])
+ // And the model, fed the Time events of the same arithmetic, drew exactly the same frames.
+ await a.expectInSync()
+ // One long frame: fastForward fires one animation frame, 10 s later.
+ await a.jump(10_000)
+ const [last] = (await framesFrom(page, frames.length)).slice(-1)
+ expect(last.at - a.clock.t0).toBe(1000 + 10_000)
+ await a.expectInSync()
+ })
+
+ test('recorded drawing: the first frame is the new session\'s scene, with colours, fonts and alignment as drawn', async ({ app, page }) => {
+ const a = await app(track, 'snake')
+ await a.start()
+ const [first] = await framesFrom(page, 0)
+ // Read straight from the recorder, before any normalisation: what the canvas reported at each call.
+ const text = first.shapes.find((s) => s.tag === 'Text' && s.text.startsWith('Best'))
+ expect(text).toMatchObject({ text: 'Best: 0', color: '#ffffff', align: 'right', x: 394, y: 18 })
+ expect(text.font).toMatch(/16px monospace/)
+ expect(first.shapes[0]).toEqual({ tag: 'Rect', x: 0, y: 0, w: 400, h: 400, color: '#111111' })
+ expect(await a.expectInSync()).toEqual(await a.expected(sessionView({ game: newGame(SEED), best: 0, improved: false })))
+ })
+ })
+}
+
+test('oracle: fed input by input, the model emits exactly what the course\'s play() emits for the same inputs', () => {
+ const m = new Model('snake', { seed: SEED, best: 4 })
+ const times = Array.from({ length: 80 }, (_, i) => ({ tag: 'Time', t: i * 16 }))
+ m.feed(times.slice(0, 30)).feed([{ tag: 'KeyDown', key: 'ArrowUp' }, { tag: 'KeyUp', key: 'ArrowUp' }]).feed(times.slice(30))
+ m.feed([{ tag: 'Time', t: 80 * 16 + 10_000 }])
+ const drawn = m.play().flat().filter((op) => op.tag === 'Draw').map((op) => op.shape)
+ expect(m.frames.flatMap((f) => f.shapes)).toEqual(drawn)
+ expect(m.startOps.map((op) => op.tag)).toEqual(['RandomSeed', 'LoadBest'])
+})
diff --git a/fixtures/m02-initial-state.json b/fixtures/m02-initial-state.json
index b171272..8e6738f 100644
--- a/fixtures/m02-initial-state.json
+++ b/fixtures/m02-initial-state.json
@@ -29,7 +29,8 @@
"seed": 7,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -60,7 +61,8 @@
"seed": 4294967295,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
}
]
diff --git a/fixtures/m03-advance.json b/fixtures/m03-advance.json
index ff8962b..0d27b85 100644
--- a/fixtures/m03-advance.json
+++ b/fixtures/m03-advance.json
@@ -27,7 +27,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -53,7 +54,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -82,7 +84,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -108,7 +111,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -140,7 +144,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -169,7 +174,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -201,7 +207,8 @@
"seed": 1,
"score": 4,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -231,7 +238,8 @@
"seed": 1,
"score": 5,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -260,7 +268,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -286,7 +295,8 @@
"seed": 1,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -315,7 +325,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -341,7 +352,8 @@
"seed": 1,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -378,7 +390,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -412,7 +425,8 @@
"seed": 1,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -445,7 +459,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -475,7 +490,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -511,7 +527,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -544,7 +561,8 @@
"seed": 1,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -573,7 +591,8 @@
"seed": 1,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -599,7 +618,8 @@
"seed": 1,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -628,7 +648,8 @@
"seed": 1,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -654,7 +675,8 @@
"seed": 1,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -683,7 +705,8 @@
"seed": 1,
"score": 0,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -709,7 +732,8 @@
"seed": 1,
"score": 0,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
}
]
diff --git a/fixtures/m03-in-bounds.json b/fixtures/m03-in-bounds.json
index a5acad2..754d1d5 100644
--- a/fixtures/m03-in-bounds.json
+++ b/fixtures/m03-in-bounds.json
@@ -27,7 +27,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"x": 0,
@@ -62,7 +63,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"x": 19,
@@ -97,7 +99,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"x": -1,
@@ -132,7 +135,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"x": 0,
@@ -167,7 +171,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"x": 20,
@@ -202,7 +207,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"x": 5,
@@ -237,7 +243,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"x": 10,
diff --git a/fixtures/m03-turn.json b/fixtures/m03-turn.json
index b6fa922..4415923 100644
--- a/fixtures/m03-turn.json
+++ b/fixtures/m03-turn.json
@@ -27,7 +27,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
"Up"
],
@@ -54,7 +55,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -83,7 +85,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
"Right"
],
@@ -110,7 +113,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -139,7 +143,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
"Left"
],
@@ -166,7 +171,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -195,7 +201,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
"Down"
],
@@ -222,7 +229,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -251,7 +259,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
"Left"
],
@@ -278,7 +287,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -307,7 +317,8 @@
"seed": 1,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
"Up"
],
@@ -334,7 +345,8 @@
"seed": 1,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -363,7 +375,8 @@
"seed": 1,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
"Up"
],
@@ -390,7 +403,8 @@
"seed": 1,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -419,7 +433,8 @@
"seed": 1,
"score": 0,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
"Up"
],
@@ -446,7 +461,8 @@
"seed": 1,
"score": 0,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
}
]
diff --git a/fixtures/m04-free-cells.json b/fixtures/m04-free-cells.json
index 5695ea1..1b1ac33 100644
--- a/fixtures/m04-free-cells.json
+++ b/fixtures/m04-free-cells.json
@@ -19,7 +19,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": [
@@ -67,7 +68,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": [
@@ -115,7 +117,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": [
@@ -158,7 +161,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": [
@@ -218,7 +222,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": []
diff --git a/fixtures/m04-new-game.json b/fixtures/m04-new-game.json
index ede134b..c4251fd 100644
--- a/fixtures/m04-new-game.json
+++ b/fixtures/m04-new-game.json
@@ -32,7 +32,8 @@
"seed": 1831565813,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -66,7 +67,8 @@
"seed": 1831565855,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
}
]
diff --git a/fixtures/m04-spawn.json b/fixtures/m04-spawn.json
index 68668d4..7b0d5aa 100644
--- a/fixtures/m04-spawn.json
+++ b/fixtures/m04-spawn.json
@@ -23,7 +23,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -48,7 +49,8 @@
"seed": 1831565813,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -73,7 +75,8 @@
"seed": 1,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -98,7 +101,8 @@
"seed": 1831565814,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -135,7 +139,8 @@
"seed": 7,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -172,7 +177,8 @@
"seed": 1831565820,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -213,7 +219,8 @@
"seed": 5,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -251,7 +258,8 @@
"seed": 5,
"score": 0,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -279,7 +287,8 @@
"seed": 5,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -304,7 +313,8 @@
"seed": 5,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -329,7 +339,8 @@
"seed": 5,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -351,7 +362,8 @@
"seed": 5,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -376,7 +388,8 @@
"seed": 5,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -398,7 +411,8 @@
"seed": 5,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -423,7 +437,8 @@
"seed": 5,
"score": 0,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -445,7 +460,8 @@
"seed": 5,
"score": 0,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
}
]
diff --git a/fixtures/m04-tick.json b/fixtures/m04-tick.json
index 76d8ce6..fbf4699 100644
--- a/fixtures/m04-tick.json
+++ b/fixtures/m04-tick.json
@@ -22,7 +22,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -47,7 +48,8 @@
"seed": 1831565813,
"score": 1,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -71,7 +73,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -92,7 +95,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -116,7 +120,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -137,7 +142,8 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -161,7 +167,8 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": {
@@ -182,7 +189,72 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
+ }
+ },
+ {
+ "name": "eating-the-last-free-cell-wins",
+ "args": [
+ {
+ "width": 2,
+ "height": 2,
+ "snake": [
+ {
+ "x": 0,
+ "y": 0
+ },
+ {
+ "x": 0,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 1
+ }
+ ],
+ "dir": "Up",
+ "nextDir": "Right",
+ "apple": {
+ "x": 1,
+ "y": 0
+ },
+ "seed": 7,
+ "score": 2,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": {
+ "width": 2,
+ "height": 2,
+ "snake": [
+ {
+ "x": 1,
+ "y": 0
+ },
+ {
+ "x": 0,
+ "y": 0
+ },
+ {
+ "x": 0,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": null,
+ "seed": 7,
+ "score": 3,
+ "status": "Won",
+ "frame": 0,
+ "queued": null
}
}
]
diff --git a/fixtures/m05-history.json b/fixtures/m05-history.json
index d7d3a06..02a1ce1 100644
--- a/fixtures/m05-history.json
+++ b/fixtures/m05-history.json
@@ -22,7 +22,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
[]
],
@@ -45,7 +46,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
]
},
@@ -70,7 +72,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
[
{
@@ -104,7 +107,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"width": 3,
@@ -124,7 +128,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"width": 3,
@@ -144,7 +149,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"width": 3,
@@ -164,7 +170,8 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
]
}
diff --git a/fixtures/m05-replay.json b/fixtures/m05-replay.json
index 8e80b39..55ba74a 100644
--- a/fixtures/m05-replay.json
+++ b/fixtures/m05-replay.json
@@ -30,7 +30,8 @@
"seed": 1831565855,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
[
{
@@ -103,7 +104,8 @@
"seed": 3663131668,
"score": 1,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -135,7 +137,8 @@
"seed": 1831565855,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
[]
],
@@ -165,7 +168,8 @@
"seed": 1831565855,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
}
]
diff --git a/fixtures/m05-update.json b/fixtures/m05-update.json
index 36499b8..94b1f80 100644
--- a/fixtures/m05-update.json
+++ b/fixtures/m05-update.json
@@ -22,7 +22,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Tick"
@@ -46,7 +47,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -70,7 +72,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Tick"
@@ -98,7 +101,8 @@
"seed": 1831565813,
"score": 1,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -122,7 +126,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Tick"
@@ -146,7 +151,8 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -170,7 +176,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Tick"
@@ -194,7 +201,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -218,7 +226,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Turn",
@@ -243,7 +252,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -267,7 +277,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Turn",
@@ -292,7 +303,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -316,7 +328,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Turn",
@@ -341,7 +354,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
}
]
diff --git a/fixtures/m06-view.json b/fixtures/m06-view.json
index 2277eb8..9793166 100644
--- a/fixtures/m06-view.json
+++ b/fixtures/m06-view.json
@@ -26,7 +26,8 @@
"seed": 0,
"score": 5,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": [
@@ -95,7 +96,8 @@
"seed": 0,
"score": 5,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": [
@@ -159,7 +161,8 @@
"seed": 0,
"score": 5,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": [
@@ -250,7 +253,8 @@
"seed": 1,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": [
@@ -337,7 +341,8 @@
"seed": 0,
"score": 5,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
],
"expected": [
diff --git a/fixtures/m10-golden.json b/fixtures/m10-golden.json
new file mode 100644
index 0000000..3a5e6b1
--- /dev/null
+++ b/fixtures/m10-golden.json
@@ -0,0 +1,694 @@
+{
+ "function": "play",
+ "cases": [
+ {
+ "name": "seed-42-eats-three-apples-with-no-stored-best",
+ "args": [
+ 42,
+ [
+ {
+ "tag": "KeyDown",
+ "key": "ArrowUp"
+ },
+ {
+ "tag": "Time",
+ "t": 200
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowLeft"
+ },
+ {
+ "tag": "Time",
+ "t": 450
+ },
+ {
+ "tag": "Time",
+ "t": 700
+ },
+ {
+ "tag": "Time",
+ "t": 950
+ },
+ {
+ "tag": "Time",
+ "t": 1200
+ },
+ {
+ "tag": "Time",
+ "t": 1450
+ },
+ {
+ "tag": "Time",
+ "t": 1700
+ },
+ {
+ "tag": "Time",
+ "t": 1950
+ },
+ {
+ "tag": "Time",
+ "t": 2000
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowUp"
+ },
+ {
+ "tag": "Time",
+ "t": 2250
+ },
+ {
+ "tag": "Time",
+ "t": 2500
+ },
+ {
+ "tag": "Time",
+ "t": 2750
+ },
+ {
+ "tag": "Time",
+ "t": 2800
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowRight"
+ },
+ {
+ "tag": "Time",
+ "t": 3050
+ },
+ {
+ "tag": "Time",
+ "t": 3300
+ },
+ {
+ "tag": "Time",
+ "t": 3550
+ },
+ {
+ "tag": "Time",
+ "t": 3800
+ },
+ {
+ "tag": "Time",
+ "t": 4050
+ },
+ {
+ "tag": "Time",
+ "t": 4300
+ },
+ {
+ "tag": "Time",
+ "t": 4550
+ },
+ {
+ "tag": "Time",
+ "t": 4800
+ },
+ {
+ "tag": "Time",
+ "t": 5050
+ },
+ {
+ "tag": "Time",
+ "t": 5300
+ },
+ {
+ "tag": "Time",
+ "t": 5400
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowUp"
+ },
+ {
+ "tag": "Time",
+ "t": 5650
+ },
+ {
+ "tag": "Time",
+ "t": 5900
+ },
+ {
+ "tag": "Time",
+ "t": 6150
+ },
+ {
+ "tag": "Time",
+ "t": 6200
+ }
+ ],
+ 0
+ ],
+ "expected": [
+ {
+ "tag": "Rect",
+ "x": 0,
+ "y": 0,
+ "w": 400,
+ "h": 400,
+ "color": "#111111"
+ },
+ {
+ "tag": "Rect",
+ "x": 21,
+ "y": 161,
+ "w": 18,
+ "h": 18,
+ "color": "#e53935"
+ },
+ {
+ "tag": "Rect",
+ "x": 281,
+ "y": 21,
+ "w": 18,
+ "h": 18,
+ "color": "#7cb342"
+ },
+ {
+ "tag": "Rect",
+ "x": 281,
+ "y": 41,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 281,
+ "y": 61,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 281,
+ "y": 81,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 281,
+ "y": 101,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 261,
+ "y": 101,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Text",
+ "x": 6,
+ "y": 18,
+ "text": "Score: 3",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "left"
+ },
+ {
+ "tag": "Text",
+ "x": 394,
+ "y": 18,
+ "text": "Best: 3",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "right"
+ }
+ ]
+ },
+ {
+ "name": "seed-7-beats-a-stored-best-of-2-and-hits-the-wall",
+ "args": [
+ 7,
+ [
+ {
+ "tag": "Time",
+ "t": 250
+ },
+ {
+ "tag": "Time",
+ "t": 500
+ },
+ {
+ "tag": "Time",
+ "t": 750
+ },
+ {
+ "tag": "Time",
+ "t": 1000
+ },
+ {
+ "tag": "Time",
+ "t": 1250
+ },
+ {
+ "tag": "Time",
+ "t": 1500
+ },
+ {
+ "tag": "Time",
+ "t": 1750
+ },
+ {
+ "tag": "Time",
+ "t": 1800
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowDown"
+ },
+ {
+ "tag": "Time",
+ "t": 2050
+ },
+ {
+ "tag": "Time",
+ "t": 2200
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowLeft"
+ },
+ {
+ "tag": "Time",
+ "t": 2450
+ },
+ {
+ "tag": "Time",
+ "t": 2700
+ },
+ {
+ "tag": "Time",
+ "t": 2950
+ },
+ {
+ "tag": "Time",
+ "t": 3200
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowDown"
+ },
+ {
+ "tag": "Time",
+ "t": 3450
+ },
+ {
+ "tag": "Time",
+ "t": 3700
+ },
+ {
+ "tag": "Time",
+ "t": 3950
+ },
+ {
+ "tag": "Time",
+ "t": 4200
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowLeft"
+ },
+ {
+ "tag": "Time",
+ "t": 4375
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowUp"
+ },
+ {
+ "tag": "Time",
+ "t": 4625
+ },
+ {
+ "tag": "Time",
+ "t": 4875
+ },
+ {
+ "tag": "Time",
+ "t": 5125
+ },
+ {
+ "tag": "Time",
+ "t": 5375
+ },
+ {
+ "tag": "Time",
+ "t": 5600
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowRight"
+ },
+ {
+ "tag": "Time",
+ "t": 5850
+ },
+ {
+ "tag": "Time",
+ "t": 6100
+ },
+ {
+ "tag": "Time",
+ "t": 6350
+ },
+ {
+ "tag": "Time",
+ "t": 6600
+ },
+ {
+ "tag": "Time",
+ "t": 6825
+ }
+ ],
+ 2
+ ],
+ "expected": [
+ {
+ "tag": "Rect",
+ "x": 0,
+ "y": 0,
+ "w": 400,
+ "h": 400,
+ "color": "#111111"
+ },
+ {
+ "tag": "Rect",
+ "x": 321,
+ "y": 81,
+ "w": 18,
+ "h": 18,
+ "color": "#e53935"
+ },
+ {
+ "tag": "Rect",
+ "x": 381,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#7cb342"
+ },
+ {
+ "tag": "Rect",
+ "x": 361,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 341,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 321,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 301,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 281,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 261,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Text",
+ "x": 6,
+ "y": 18,
+ "text": "Score: 4",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "left"
+ },
+ {
+ "tag": "Text",
+ "x": 200,
+ "y": 200,
+ "text": "Game Over",
+ "color": "#ffffff",
+ "size": 32,
+ "align": "center"
+ },
+ {
+ "tag": "Text",
+ "x": 200,
+ "y": 230,
+ "text": "Press R to restart",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "center"
+ },
+ {
+ "tag": "Text",
+ "x": 394,
+ "y": 18,
+ "text": "Best: 4",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "right"
+ }
+ ]
+ },
+ {
+ "name": "seed-42-restart-after-game-over-keeps-the-best",
+ "args": [
+ 42,
+ [
+ {
+ "tag": "KeyDown",
+ "key": "ArrowUp"
+ },
+ {
+ "tag": "Time",
+ "t": 200
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowLeft"
+ },
+ {
+ "tag": "Time",
+ "t": 450
+ },
+ {
+ "tag": "Time",
+ "t": 700
+ },
+ {
+ "tag": "Time",
+ "t": 950
+ },
+ {
+ "tag": "Time",
+ "t": 1200
+ },
+ {
+ "tag": "Time",
+ "t": 1450
+ },
+ {
+ "tag": "Time",
+ "t": 1700
+ },
+ {
+ "tag": "Time",
+ "t": 1950
+ },
+ {
+ "tag": "Time",
+ "t": 2000
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowUp"
+ },
+ {
+ "tag": "Time",
+ "t": 2250
+ },
+ {
+ "tag": "Time",
+ "t": 2500
+ },
+ {
+ "tag": "Time",
+ "t": 2750
+ },
+ {
+ "tag": "Time",
+ "t": 2800
+ },
+ {
+ "tag": "KeyDown",
+ "key": "ArrowRight"
+ },
+ {
+ "tag": "Time",
+ "t": 3050
+ },
+ {
+ "tag": "Time",
+ "t": 3300
+ },
+ {
+ "tag": "Time",
+ "t": 3550
+ },
+ {
+ "tag": "Time",
+ "t": 3800
+ },
+ {
+ "tag": "Time",
+ "t": 4050
+ },
+ {
+ "tag": "Time",
+ "t": 4300
+ },
+ {
+ "tag": "Time",
+ "t": 4550
+ },
+ {
+ "tag": "Time",
+ "t": 4800
+ },
+ {
+ "tag": "Time",
+ "t": 5050
+ },
+ {
+ "tag": "Time",
+ "t": 5300
+ },
+ {
+ "tag": "Time",
+ "t": 5550
+ },
+ {
+ "tag": "Time",
+ "t": 5800
+ },
+ {
+ "tag": "Time",
+ "t": 6050
+ },
+ {
+ "tag": "Time",
+ "t": 6300
+ },
+ {
+ "tag": "Time",
+ "t": 6550
+ },
+ {
+ "tag": "Time",
+ "t": 6600
+ },
+ {
+ "tag": "KeyDown",
+ "key": "r"
+ }
+ ],
+ 0
+ ],
+ "expected": [
+ {
+ "tag": "Rect",
+ "x": 0,
+ "y": 0,
+ "w": 400,
+ "h": 400,
+ "color": "#111111"
+ },
+ {
+ "tag": "Rect",
+ "x": 261,
+ "y": 61,
+ "w": 18,
+ "h": 18,
+ "color": "#e53935"
+ },
+ {
+ "tag": "Rect",
+ "x": 201,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#7cb342"
+ },
+ {
+ "tag": "Rect",
+ "x": 181,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Rect",
+ "x": 161,
+ "y": 201,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Text",
+ "x": 6,
+ "y": 18,
+ "text": "Score: 0",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "left"
+ },
+ {
+ "tag": "Text",
+ "x": 394,
+ "y": 18,
+ "text": "Best: 2",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "right"
+ }
+ ]
+ }
+ ]
+}
diff --git a/fixtures/m10-session-update.json b/fixtures/m10-session-update.json
new file mode 100644
index 0000000..61954fb
--- /dev/null
+++ b/fixtures/m10-session-update.json
@@ -0,0 +1,756 @@
+{
+ "function": "sessionUpdate",
+ "cases": [
+ {
+ "name": "a-step-without-eating-keeps-the-best",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 2,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 5,
+ "improved": false
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 2,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 2,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 5,
+ "improved": false
+ }
+ },
+ {
+ "name": "the-first-apple-with-no-record-is-an-improvement",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 5,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 0,
+ "improved": false
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 2,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 1831565818,
+ "score": 1,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 1,
+ "improved": true
+ }
+ },
+ {
+ "name": "eating-past-the-record-raises-the-best",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 5,
+ "score": 5,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 5,
+ "improved": false
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 2,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 1831565818,
+ "score": 6,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 6,
+ "improved": true
+ }
+ },
+ {
+ "name": "eating-below-the-record-keeps-the-best",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 5,
+ "score": 1,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 5,
+ "improved": false
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 2,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 1831565818,
+ "score": 2,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 5,
+ "improved": false
+ }
+ },
+ {
+ "name": "equalling-the-record-is-not-an-improvement",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 5,
+ "score": 4,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 5,
+ "improved": false
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 2,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 1831565818,
+ "score": 5,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 5,
+ "improved": false
+ }
+ },
+ {
+ "name": "the-next-action-clears-improved",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 6,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 6,
+ "improved": true
+ },
+ {
+ "tag": "Turn",
+ "dir": "Up"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 6,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 6,
+ "improved": false
+ }
+ },
+ {
+ "name": "a-frame-that-steps-and-eats-raises-the-best",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 5,
+ "score": 3,
+ "status": "Playing",
+ "frame": 6,
+ "queued": null
+ },
+ "best": 3,
+ "improved": false
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 2,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 1831565818,
+ "score": 4,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 4,
+ "improved": true
+ }
+ },
+ {
+ "name": "a-frame-that-only-counts-keeps-the-best-and-clears-improved",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 3,
+ "status": "Playing",
+ "frame": 1,
+ "queued": null
+ },
+ "best": 3,
+ "improved": true
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 3,
+ "status": "Playing",
+ "frame": 2,
+ "queued": null
+ },
+ "best": 3,
+ "improved": false
+ }
+ },
+ {
+ "name": "game-over-keeps-the-best",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 3,
+ "y": 1
+ },
+ {
+ "x": 2,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 4,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 4,
+ "improved": false
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 3,
+ "y": 1
+ },
+ {
+ "x": 2,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 4,
+ "status": "GameOver",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 4,
+ "improved": false
+ }
+ },
+ {
+ "name": "restart-keeps-the-best",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 6,
+ "status": "GameOver",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 6,
+ "improved": false
+ },
+ {
+ "tag": "Restart"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 20,
+ "height": 20,
+ "snake": [
+ {
+ "x": 10,
+ "y": 10
+ },
+ {
+ "x": 9,
+ "y": 10
+ },
+ {
+ "x": 8,
+ "y": 10
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 11,
+ "y": 9
+ },
+ "seed": 1831565818,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 6,
+ "improved": false
+ }
+ },
+ {
+ "name": "pausing-keeps-the-best",
+ "args": [
+ {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 2,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 9,
+ "improved": true
+ },
+ {
+ "tag": "TogglePause"
+ }
+ ],
+ "expected": {
+ "game": {
+ "width": 4,
+ "height": 3,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 3,
+ "y": 0
+ },
+ "seed": 5,
+ "score": 2,
+ "status": "Paused",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 9,
+ "improved": false
+ }
+ }
+ ]
+}
diff --git a/fixtures/m10-session-view.json b/fixtures/m10-session-view.json
new file mode 100644
index 0000000..6f44bde
--- /dev/null
+++ b/fixtures/m10-session-view.json
@@ -0,0 +1,281 @@
+{
+ "function": "sessionView",
+ "cases": [
+ {
+ "name": "playing-adds-the-best-at-the-top-right",
+ "args": [
+ {
+ "game": {
+ "width": 3,
+ "height": 2,
+ "snake": [
+ {
+ "x": 1,
+ "y": 0
+ },
+ {
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 0,
+ "score": 5,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 12,
+ "improved": false
+ }
+ ],
+ "expected": [
+ {
+ "tag": "Rect",
+ "x": 0,
+ "y": 0,
+ "w": 60,
+ "h": 40,
+ "color": "#111111"
+ },
+ {
+ "tag": "Rect",
+ "x": 41,
+ "y": 21,
+ "w": 18,
+ "h": 18,
+ "color": "#e53935"
+ },
+ {
+ "tag": "Rect",
+ "x": 21,
+ "y": 1,
+ "w": 18,
+ "h": 18,
+ "color": "#7cb342"
+ },
+ {
+ "tag": "Rect",
+ "x": 1,
+ "y": 1,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Text",
+ "x": 6,
+ "y": 18,
+ "text": "Score: 5",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "left"
+ },
+ {
+ "tag": "Text",
+ "x": 54,
+ "y": 18,
+ "text": "Best: 12",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "right"
+ }
+ ]
+ },
+ {
+ "name": "a-best-of-zero-is-shown-too",
+ "args": [
+ {
+ "game": {
+ "width": 3,
+ "height": 2,
+ "snake": [
+ {
+ "x": 1,
+ "y": 0
+ },
+ {
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 0,
+ "improved": false
+ }
+ ],
+ "expected": [
+ {
+ "tag": "Rect",
+ "x": 0,
+ "y": 0,
+ "w": 60,
+ "h": 40,
+ "color": "#111111"
+ },
+ {
+ "tag": "Rect",
+ "x": 41,
+ "y": 21,
+ "w": 18,
+ "h": 18,
+ "color": "#e53935"
+ },
+ {
+ "tag": "Rect",
+ "x": 21,
+ "y": 1,
+ "w": 18,
+ "h": 18,
+ "color": "#7cb342"
+ },
+ {
+ "tag": "Rect",
+ "x": 1,
+ "y": 1,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Text",
+ "x": 6,
+ "y": 18,
+ "text": "Score: 0",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "left"
+ },
+ {
+ "tag": "Text",
+ "x": 54,
+ "y": 18,
+ "text": "Best: 0",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "right"
+ }
+ ]
+ },
+ {
+ "name": "game-over-shows-the-banner-and-then-the-best",
+ "args": [
+ {
+ "game": {
+ "width": 3,
+ "height": 2,
+ "snake": [
+ {
+ "x": 1,
+ "y": 0
+ },
+ {
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 0,
+ "score": 5,
+ "status": "GameOver",
+ "frame": 0,
+ "queued": null
+ },
+ "best": 7,
+ "improved": true
+ }
+ ],
+ "expected": [
+ {
+ "tag": "Rect",
+ "x": 0,
+ "y": 0,
+ "w": 60,
+ "h": 40,
+ "color": "#111111"
+ },
+ {
+ "tag": "Rect",
+ "x": 41,
+ "y": 21,
+ "w": 18,
+ "h": 18,
+ "color": "#e53935"
+ },
+ {
+ "tag": "Rect",
+ "x": 21,
+ "y": 1,
+ "w": 18,
+ "h": 18,
+ "color": "#7cb342"
+ },
+ {
+ "tag": "Rect",
+ "x": 1,
+ "y": 1,
+ "w": 18,
+ "h": 18,
+ "color": "#43a047"
+ },
+ {
+ "tag": "Text",
+ "x": 6,
+ "y": 18,
+ "text": "Score: 5",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "left"
+ },
+ {
+ "tag": "Text",
+ "x": 30,
+ "y": 20,
+ "text": "Game Over",
+ "color": "#ffffff",
+ "size": 32,
+ "align": "center"
+ },
+ {
+ "tag": "Text",
+ "x": 30,
+ "y": 50,
+ "text": "Press R to restart",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "center"
+ },
+ {
+ "tag": "Text",
+ "x": 54,
+ "y": 18,
+ "text": "Best: 7",
+ "color": "#ffffff",
+ "size": 16,
+ "align": "right"
+ }
+ ]
+ }
+ ]
+}
diff --git a/fixtures/m10-update.json b/fixtures/m10-update.json
index a21c9c4..96d5cc2 100644
--- a/fixtures/m10-update.json
+++ b/fixtures/m10-update.json
@@ -22,7 +22,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "TogglePause"
@@ -46,7 +47,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -70,7 +72,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "TogglePause"
@@ -94,7 +97,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -118,7 +122,8 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "TogglePause"
@@ -142,7 +147,8 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -166,7 +172,8 @@
"seed": 0,
"score": 0,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "TogglePause"
@@ -190,7 +197,8 @@
"seed": 0,
"score": 0,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -214,7 +222,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Turn",
@@ -239,7 +248,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -263,7 +273,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Tick"
@@ -287,7 +298,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -308,7 +320,8 @@
"seed": 0,
"score": 5,
"status": "Playing",
- "frame": 9
+ "frame": 9,
+ "queued": null
},
{
"tag": "Restart"
@@ -340,7 +353,8 @@
"seed": 1831565813,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -369,7 +383,8 @@
"seed": 0,
"score": 2,
"status": "GameOver",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Restart"
@@ -401,7 +416,8 @@
"seed": 1831565813,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -430,7 +446,8 @@
"seed": 0,
"score": 7,
"status": "Won",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Restart"
@@ -462,7 +479,8 @@
"seed": 1831565813,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -491,7 +509,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Restart"
@@ -523,7 +542,8 @@
"seed": 1831565813,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
}
},
{
@@ -547,7 +567,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 0
+ "frame": 0,
+ "queued": null
},
{
"tag": "Frame"
@@ -571,7 +592,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 1
+ "frame": 1,
+ "queued": null
}
},
{
@@ -595,7 +617,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 6
+ "frame": 6,
+ "queued": null
},
{
"tag": "Frame"
@@ -619,11 +642,12 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 7
+ "frame": 7,
+ "queued": null
}
},
{
- "name": "frame-at-threshold-ticks-the-game",
+ "name": "frame-at-threshold-ticks-the-game-and-restarts-the-count",
"args": [
{
"width": 3,
@@ -643,7 +667,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 7
+ "frame": 7,
+ "queued": null
},
{
"tag": "Frame"
@@ -667,18 +692,19 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 8
+ "frame": 0,
+ "queued": null
}
},
{
- "name": "frame-at-second-multiple-of-threshold-ticks-again",
+ "name": "frame-after-a-move-counts-from-zero-again",
"args": [
{
"width": 3,
"height": 2,
"snake": [
{
- "x": 0,
+ "x": 1,
"y": 0
}
],
@@ -691,7 +717,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 15
+ "frame": 0,
+ "queued": null
},
{
"tag": "Frame"
@@ -715,7 +742,112 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 16
+ "frame": 1,
+ "queued": null
+ }
+ },
+ {
+ "name": "frame-tick-that-eats-restarts-the-count",
+ "args": [
+ {
+ "width": 3,
+ "height": 2,
+ "snake": [
+ {
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 1,
+ "y": 0
+ },
+ "seed": 0,
+ "score": 2,
+ "status": "Playing",
+ "frame": 7,
+ "queued": null
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "width": 3,
+ "height": 2,
+ "snake": [
+ {
+ "x": 1,
+ "y": 0
+ },
+ {
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 1,
+ "y": 1
+ },
+ "seed": 1831565813,
+ "score": 3,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ },
+ {
+ "name": "frame-just-before-the-shrunk-threshold-only-counts-the-frame",
+ "args": [
+ {
+ "width": 3,
+ "height": 2,
+ "snake": [
+ {
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 0,
+ "score": 3,
+ "status": "Playing",
+ "frame": 5,
+ "queued": null
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "width": 3,
+ "height": 2,
+ "snake": [
+ {
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 1
+ },
+ "seed": 0,
+ "score": 3,
+ "status": "Playing",
+ "frame": 6,
+ "queued": null
}
},
{
@@ -739,7 +871,8 @@
"seed": 0,
"score": 3,
"status": "Playing",
- "frame": 6
+ "frame": 6,
+ "queued": null
},
{
"tag": "Frame"
@@ -763,11 +896,12 @@
"seed": 0,
"score": 3,
"status": "Playing",
- "frame": 7
+ "frame": 0,
+ "queued": null
}
},
{
- "name": "frame-after-threshold-shrinks-does-not-tick",
+ "name": "frame-at-top-speed-first-frame-does-not-tick",
"args": [
{
"width": 3,
@@ -785,9 +919,10 @@
"y": 1
},
"seed": 0,
- "score": 3,
+ "score": 18,
"status": "Playing",
- "frame": 7
+ "frame": 0,
+ "queued": null
},
{
"tag": "Frame"
@@ -809,9 +944,10 @@
"y": 1
},
"seed": 0,
- "score": 3,
+ "score": 18,
"status": "Playing",
- "frame": 8
+ "frame": 1,
+ "queued": null
}
},
{
@@ -835,7 +971,8 @@
"seed": 0,
"score": 18,
"status": "Playing",
- "frame": 1
+ "frame": 1,
+ "queued": null
},
{
"tag": "Frame"
@@ -859,11 +996,12 @@
"seed": 0,
"score": 18,
"status": "Playing",
- "frame": 2
+ "frame": 0,
+ "queued": null
}
},
{
- "name": "frame-at-top-speed-odd-frame-does-not-tick",
+ "name": "frame-count-past-the-threshold-still-ticks",
"args": [
{
"width": 3,
@@ -881,9 +1019,10 @@
"y": 1
},
"seed": 0,
- "score": 18,
+ "score": 0,
"status": "Playing",
- "frame": 2
+ "frame": 9,
+ "queued": null
},
{
"tag": "Frame"
@@ -894,7 +1033,7 @@
"height": 2,
"snake": [
{
- "x": 0,
+ "x": 1,
"y": 0
}
],
@@ -905,9 +1044,10 @@
"y": 1
},
"seed": 0,
- "score": 18,
+ "score": 0,
"status": "Playing",
- "frame": 3
+ "frame": 0,
+ "queued": null
}
},
{
@@ -931,7 +1071,8 @@
"seed": 0,
"score": 0,
"status": "Playing",
- "frame": 7
+ "frame": 7,
+ "queued": null
},
{
"tag": "Frame"
@@ -955,7 +1096,8 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 8
+ "frame": 0,
+ "queued": null
}
},
{
@@ -979,7 +1121,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 7
+ "frame": 7,
+ "queued": null
},
{
"tag": "Frame"
@@ -1003,7 +1146,8 @@
"seed": 0,
"score": 0,
"status": "Paused",
- "frame": 7
+ "frame": 7,
+ "queued": null
}
},
{
@@ -1027,7 +1171,8 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 7
+ "frame": 7,
+ "queued": null
},
{
"tag": "Frame"
@@ -1051,7 +1196,8 @@
"seed": 0,
"score": 0,
"status": "GameOver",
- "frame": 7
+ "frame": 7,
+ "queued": null
}
},
{
@@ -1075,7 +1221,8 @@
"seed": 0,
"score": 0,
"status": "Won",
- "frame": 7
+ "frame": 7,
+ "queued": null
},
{
"tag": "Frame"
@@ -1099,7 +1246,1072 @@
"seed": 0,
"score": 0,
"status": "Won",
- "frame": 7
+ "frame": 7,
+ "queued": null
+ }
+ },
+ {
+ "name": "turn-with-no-turn-pending-sets-next-dir",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ {
+ "tag": "Turn",
+ "dir": "Up"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ },
+ {
+ "name": "turn-straight-back-with-no-turn-pending-is-ignored",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ {
+ "tag": "Turn",
+ "dir": "Left"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ },
+ {
+ "name": "perpendicular-key-while-a-turn-is-pending-is-queued",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ {
+ "tag": "Turn",
+ "dir": "Left"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ }
+ },
+ {
+ "name": "key-in-the-current-direction-while-a-turn-is-pending-is-queued",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ {
+ "tag": "Turn",
+ "dir": "Right"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Right"
+ }
+ },
+ {
+ "name": "a-later-key-replaces-the-queued-one",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ },
+ {
+ "tag": "Turn",
+ "dir": "Right"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Right"
+ }
+ },
+ {
+ "name": "repeating-the-pending-turn-changes-nothing",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ },
+ {
+ "tag": "Turn",
+ "dir": "Up"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ }
+ },
+ {
+ "name": "reversing-the-pending-turn-changes-nothing",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ },
+ {
+ "tag": "Turn",
+ "dir": "Down"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ }
+ },
+ {
+ "name": "turn-while-paused-with-a-turn-pending-is-not-queued",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Paused",
+ "frame": 0,
+ "queued": null
+ },
+ {
+ "tag": "Turn",
+ "dir": "Left"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Paused",
+ "frame": 0,
+ "queued": null
+ }
+ },
+ {
+ "name": "tick-moves-then-takes-the-queued-turn",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 4
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ }
+ ],
+ "dir": "Up",
+ "nextDir": "Left",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ },
+ {
+ "name": "tick-with-nothing-queued-leaves-next-dir",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 4
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ }
+ ],
+ "dir": "Up",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ },
+ {
+ "name": "frame-step-takes-the-queued-turn",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 7,
+ "queued": "Left"
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 4
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ }
+ ],
+ "dir": "Up",
+ "nextDir": "Left",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ },
+ {
+ "name": "frame-without-a-step-keeps-the-queue",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 3,
+ "queued": "Left"
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 4,
+ "queued": "Left"
+ }
+ },
+ {
+ "name": "tick-that-ends-the-game-keeps-the-queue",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 0
+ },
+ {
+ "x": 4,
+ "y": 0
+ },
+ {
+ "x": 3,
+ "y": 0
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 0
+ },
+ {
+ "x": 4,
+ "y": 0
+ },
+ {
+ "x": 3,
+ "y": 0
+ }
+ ],
+ "dir": "Up",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "GameOver",
+ "frame": 0,
+ "queued": "Left"
+ }
+ },
+ {
+ "name": "tick-while-paused-keeps-the-queue",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Paused",
+ "frame": 0,
+ "queued": "Left"
+ },
+ {
+ "tag": "Tick"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Paused",
+ "frame": 0,
+ "queued": "Left"
+ }
+ },
+ {
+ "name": "toggle-pause-keeps-the-queue",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ },
+ {
+ "tag": "TogglePause"
+ }
+ ],
+ "expected": {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Paused",
+ "frame": 0,
+ "queued": "Left"
+ }
+ },
+ {
+ "name": "restart-clears-the-queue",
+ "args": [
+ {
+ "width": 10,
+ "height": 10,
+ "snake": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 5
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 0,
+ "y": 0
+ },
+ "seed": 1,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ },
+ {
+ "tag": "Restart"
+ }
+ ],
+ "expected": {
+ "width": 20,
+ "height": 20,
+ "snake": [
+ {
+ "x": 10,
+ "y": 10
+ },
+ {
+ "x": 9,
+ "y": 10
+ },
+ {
+ "x": 8,
+ "y": 10
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 12,
+ "y": 8
+ },
+ "seed": 1831565814,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
}
}
]
diff --git a/fixtures/m10-viewer-key.json b/fixtures/m10-viewer-key.json
new file mode 100644
index 0000000..eb8d0e4
--- /dev/null
+++ b/fixtures/m10-viewer-key.json
@@ -0,0 +1,19 @@
+{
+ "function": "viewerKey",
+ "cases": [
+ {"name":"ArrowRight-is-next","args":["ArrowRight"],"expected":{"tag":"Next"}},
+ {"name":"d-is-next","args":["d"],"expected":{"tag":"Next"}},
+ {"name":"D-is-next","args":["D"],"expected":{"tag":"Next"}},
+ {"name":"ArrowLeft-is-prev","args":["ArrowLeft"],"expected":{"tag":"Prev"}},
+ {"name":"a-is-prev","args":["a"],"expected":{"tag":"Prev"}},
+ {"name":"A-is-prev","args":["A"],"expected":{"tag":"Prev"}},
+ {"name":"Home-is-first","args":["Home"],"expected":{"tag":"First"}},
+ {"name":"End-is-last","args":["End"],"expected":{"tag":"Last"}},
+ {"name":"space-toggles-play","args":[" "],"expected":{"tag":"TogglePlay"}},
+ {"name":"ArrowUp-is-not-bound","args":["ArrowUp"],"expected":null},
+ {"name":"r-is-not-bound","args":["r"],"expected":null},
+ {"name":"constructor-is-not-bound","args":["constructor"],"expected":null},
+ {"name":"toString-is-not-bound","args":["toString"],"expected":null},
+ {"name":"empty-string-is-not-bound","args":[""],"expected":null}
+ ]
+}
diff --git a/fixtures/m10-viewer-update.json b/fixtures/m10-viewer-update.json
new file mode 100644
index 0000000..3d4b871
--- /dev/null
+++ b/fixtures/m10-viewer-update.json
@@ -0,0 +1,23 @@
+{
+ "function": "viewerUpdate",
+ "cases": [
+ {"name":"next-moves-one-frame-forward","args":[{"index":3,"last":10,"playing":false},{"tag":"Next"}],"expected":{"index":4,"last":10,"playing":false}},
+ {"name":"next-at-the-last-frame-stays","args":[{"index":10,"last":10,"playing":false},{"tag":"Next"}],"expected":{"index":10,"last":10,"playing":false}},
+ {"name":"prev-moves-one-frame-back","args":[{"index":3,"last":10,"playing":false},{"tag":"Prev"}],"expected":{"index":2,"last":10,"playing":false}},
+ {"name":"prev-at-the-first-frame-stays","args":[{"index":0,"last":10,"playing":false},{"tag":"Prev"}],"expected":{"index":0,"last":10,"playing":false}},
+ {"name":"first-goes-to-frame-zero","args":[{"index":7,"last":10,"playing":false},{"tag":"First"}],"expected":{"index":0,"last":10,"playing":false}},
+ {"name":"last-goes-to-the-last-frame","args":[{"index":2,"last":10,"playing":false},{"tag":"Last"}],"expected":{"index":10,"last":10,"playing":false}},
+ {"name":"toggle-play-starts-autoplay","args":[{"index":4,"last":10,"playing":false},{"tag":"TogglePlay"}],"expected":{"index":4,"last":10,"playing":true}},
+ {"name":"toggle-play-stops-autoplay","args":[{"index":4,"last":10,"playing":true},{"tag":"TogglePlay"}],"expected":{"index":4,"last":10,"playing":false}},
+ {"name":"toggle-play-at-the-end-starts-again-from-the-first-frame","args":[{"index":10,"last":10,"playing":false},{"tag":"TogglePlay"}],"expected":{"index":0,"last":10,"playing":true}},
+ {"name":"beat-while-playing-moves-one-frame-forward","args":[{"index":4,"last":10,"playing":true},{"tag":"Beat"}],"expected":{"index":5,"last":10,"playing":true}},
+ {"name":"beat-while-not-playing-is-ignored","args":[{"index":4,"last":10,"playing":false},{"tag":"Beat"}],"expected":{"index":4,"last":10,"playing":false}},
+ {"name":"beat-that-reaches-the-last-frame-stops-autoplay","args":[{"index":9,"last":10,"playing":true},{"tag":"Beat"}],"expected":{"index":10,"last":10,"playing":false}},
+ {"name":"next-while-playing-keeps-playing","args":[{"index":4,"last":10,"playing":true},{"tag":"Next"}],"expected":{"index":5,"last":10,"playing":true}},
+ {"name":"prev-while-playing-keeps-playing","args":[{"index":4,"last":10,"playing":true},{"tag":"Prev"}],"expected":{"index":3,"last":10,"playing":true}},
+ {"name":"last-while-playing-stops-autoplay","args":[{"index":4,"last":10,"playing":true},{"tag":"Last"}],"expected":{"index":10,"last":10,"playing":false}},
+ {"name":"first-while-playing-keeps-playing","args":[{"index":4,"last":10,"playing":true},{"tag":"First"}],"expected":{"index":0,"last":10,"playing":true}},
+ {"name":"a-recording-with-no-actions-cannot-play","args":[{"index":0,"last":0,"playing":false},{"tag":"TogglePlay"}],"expected":{"index":0,"last":0,"playing":false}},
+ {"name":"a-recording-with-no-actions-stays-at-frame-zero","args":[{"index":0,"last":0,"playing":false},{"tag":"Next"}],"expected":{"index":0,"last":0,"playing":false}}
+ ]
+}
diff --git a/fixtures/m10-viewer-view.json b/fixtures/m10-viewer-view.json
new file mode 100644
index 0000000..20374f9
--- /dev/null
+++ b/fixtures/m10-viewer-view.json
@@ -0,0 +1,8 @@
+{
+ "function": "viewerView",
+ "cases": [
+ {"name":"first-frame-not-playing","args":[[{"width":20,"height":20,"snake":[{"x":10,"y":10},{"x":9,"y":10},{"x":8,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Up","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null}],{"index":0,"last":2,"playing":false}],"expected":[{"tag":"Rect","x":0,"y":0,"w":400,"h":400,"color":"#111111"},{"tag":"Rect","x":21,"y":181,"w":18,"h":18,"color":"#e53935"},{"tag":"Rect","x":201,"y":201,"w":18,"h":18,"color":"#7cb342"},{"tag":"Rect","x":181,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Rect","x":161,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Text","x":6,"y":18,"text":"Score: 0","color":"#ffffff","size":16,"align":"left"},{"tag":"Text","x":394,"y":18,"text":"step 0 / 2","color":"#ffffff","size":16,"align":"right"},{"tag":"Text","x":200,"y":392,"text":"Left/Right: step Home/End: jump Space: play","color":"#aaaaaa","size":12,"align":"center"}]},
+ {"name":"middle-frame-while-playing","args":[[{"width":20,"height":20,"snake":[{"x":10,"y":10},{"x":9,"y":10},{"x":8,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Up","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null}],{"index":1,"last":2,"playing":true}],"expected":[{"tag":"Rect","x":0,"y":0,"w":400,"h":400,"color":"#111111"},{"tag":"Rect","x":21,"y":181,"w":18,"h":18,"color":"#e53935"},{"tag":"Rect","x":221,"y":201,"w":18,"h":18,"color":"#7cb342"},{"tag":"Rect","x":201,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Rect","x":181,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Text","x":6,"y":18,"text":"Score: 0","color":"#ffffff","size":16,"align":"left"},{"tag":"Text","x":394,"y":18,"text":"step 1 / 2","color":"#ffffff","size":16,"align":"right"},{"tag":"Text","x":200,"y":392,"text":"Space: pause","color":"#aaaaaa","size":12,"align":"center"}]},
+ {"name":"last-frame","args":[[{"width":20,"height":20,"snake":[{"x":10,"y":10},{"x":9,"y":10},{"x":8,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Right","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null},{"width":20,"height":20,"snake":[{"x":11,"y":10},{"x":10,"y":10},{"x":9,"y":10}],"dir":"Right","nextDir":"Up","apple":{"x":1,"y":9},"seed":1831565855,"score":0,"status":"Playing","frame":0,"queued":null}],{"index":2,"last":2,"playing":false}],"expected":[{"tag":"Rect","x":0,"y":0,"w":400,"h":400,"color":"#111111"},{"tag":"Rect","x":21,"y":181,"w":18,"h":18,"color":"#e53935"},{"tag":"Rect","x":221,"y":201,"w":18,"h":18,"color":"#7cb342"},{"tag":"Rect","x":201,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Rect","x":181,"y":201,"w":18,"h":18,"color":"#43a047"},{"tag":"Text","x":6,"y":18,"text":"Score: 0","color":"#ffffff","size":16,"align":"left"},{"tag":"Text","x":394,"y":18,"text":"step 2 / 2","color":"#ffffff","size":16,"align":"right"},{"tag":"Text","x":200,"y":392,"text":"Left/Right: step Home/End: jump Space: play","color":"#aaaaaa","size":12,"align":"center"}]}
+ ]
+}
diff --git a/fixtures/m11-pong-update.json b/fixtures/m11-pong-update.json
index b057fb7..571968c 100644
--- a/fixtures/m11-pong-update.json
+++ b/fixtures/m11-pong-update.json
@@ -2156,8 +2156,8 @@
"player": 21,
"cpu": 21,
"held": {
- "up": false,
- "down": false
+ "up": true,
+ "down": true
},
"score": {
"player": 0,
@@ -2331,6 +2331,330 @@
"seed": 0,
"frame": 0
}
+ },
+ {
+ "name": "player-paddle-catches-the-ball-at-its-first-row",
+ "args": [
+ {
+ "ball": {
+ "x": 3,
+ "y": 19
+ },
+ "vel": {
+ "dx": -1,
+ "dy": 1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 0
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "ball": {
+ "x": 3,
+ "y": 20
+ },
+ "vel": {
+ "dx": 1,
+ "dy": 1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 1
+ }
+ },
+ {
+ "name": "ball-may-enter-the-top-row",
+ "args": [
+ {
+ "ball": {
+ "x": 40,
+ "y": 1
+ },
+ "vel": {
+ "dx": 1,
+ "dy": -1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 0
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "ball": {
+ "x": 41,
+ "y": 0
+ },
+ "vel": {
+ "dx": 1,
+ "dy": -1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 1
+ }
+ },
+ {
+ "name": "ball-may-enter-the-bottom-row",
+ "args": [
+ {
+ "ball": {
+ "x": 40,
+ "y": 48
+ },
+ "vel": {
+ "dx": 1,
+ "dy": 1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 0
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "ball": {
+ "x": 41,
+ "y": 49
+ },
+ "vel": {
+ "dx": 1,
+ "dy": 1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 1
+ }
+ },
+ {
+ "name": "ball-in-column-zero-is-still-in-play",
+ "args": [
+ {
+ "ball": {
+ "x": 1,
+ "y": 40
+ },
+ "vel": {
+ "dx": -1,
+ "dy": 1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 0
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "ball": {
+ "x": 0,
+ "y": 41
+ },
+ "vel": {
+ "dx": -1,
+ "dy": 1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 1
+ }
+ },
+ {
+ "name": "ball-in-the-last-column-is-still-in-play",
+ "args": [
+ {
+ "ball": {
+ "x": 78,
+ "y": 40
+ },
+ "vel": {
+ "dx": 1,
+ "dy": 1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 0
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "ball": {
+ "x": 79,
+ "y": 41
+ },
+ "vel": {
+ "dx": 1,
+ "dy": 1
+ },
+ "player": 20,
+ "cpu": 21,
+ "held": {
+ "up": false,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 1
+ }
+ },
+ {
+ "name": "restart-keeps-the-held-keys",
+ "args": [
+ {
+ "ball": {
+ "x": 3,
+ "y": 24
+ },
+ "vel": {
+ "dx": -1,
+ "dy": 1
+ },
+ "player": 3,
+ "cpu": 21,
+ "held": {
+ "up": true,
+ "down": false
+ },
+ "score": {
+ "player": 1,
+ "cpu": 4
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 250
+ },
+ {
+ "tag": "Restart"
+ }
+ ],
+ "expected": {
+ "ball": {
+ "x": 40,
+ "y": 25
+ },
+ "vel": {
+ "dx": 1,
+ "dy": 1
+ },
+ "player": 21,
+ "cpu": 21,
+ "held": {
+ "up": true,
+ "down": false
+ },
+ "score": {
+ "player": 0,
+ "cpu": 0
+ },
+ "status": "Playing",
+ "seed": 0,
+ "frame": 0
+ }
}
]
}
diff --git a/fixtures/m12-violations.json b/fixtures/m12-violations.json
new file mode 100644
index 0000000..b8d385c
--- /dev/null
+++ b/fixtures/m12-violations.json
@@ -0,0 +1,756 @@
+{
+ "function": "violations",
+ "cases": [
+ {
+ "name": "a-sane-state-has-no-violations",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": []
+ },
+ {
+ "name": "no-apple-is-fine",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": null,
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": []
+ },
+ {
+ "name": "a-pending-perpendicular-turn-is-fine",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": []
+ },
+ {
+ "name": "a-one-cell-snake-is-fine",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 0,
+ "y": 0
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": []
+ },
+ {
+ "name": "a-game-over-state-can-be-sane",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 4,
+ "y": 2
+ },
+ {
+ "x": 3,
+ "y": 2
+ },
+ {
+ "x": 2,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "GameOver",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": []
+ },
+ {
+ "name": "empty-snake",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "snake-not-empty"
+ ]
+ },
+ {
+ "name": "head-off-the-right-edge",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 5,
+ "y": 2
+ },
+ {
+ "x": 4,
+ "y": 2
+ },
+ {
+ "x": 3,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "snake-in-bounds"
+ ]
+ },
+ {
+ "name": "tail-above-the-board",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 0
+ },
+ {
+ "x": 1,
+ "y": -1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "snake-in-bounds"
+ ]
+ },
+ {
+ "name": "head-on-its-own-body",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 2,
+ "y": 3
+ },
+ {
+ "x": 1,
+ "y": 3
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 2,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "snake-no-overlap"
+ ]
+ },
+ {
+ "name": "snake-with-a-gap",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 3,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "snake-connected"
+ ]
+ },
+ {
+ "name": "snake-with-a-diagonal-step",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 1
+ },
+ {
+ "x": 0,
+ "y": 1
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "snake-connected"
+ ]
+ },
+ {
+ "name": "apple-outside-the-board",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 5,
+ "y": 0
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "apple-in-bounds"
+ ]
+ },
+ {
+ "name": "apple-on-the-head",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 2,
+ "y": 2
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "apple-not-on-snake"
+ ]
+ },
+ {
+ "name": "apple-on-the-tail",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 0,
+ "y": 2
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "apple-not-on-snake"
+ ]
+ },
+ {
+ "name": "pending-turn-straight-back",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Left",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "turn-not-reversed"
+ ]
+ },
+ {
+ "name": "pending-turn-straight-back-vertical",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Up",
+ "nextDir": "Down",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "turn-not-reversed"
+ ]
+ },
+ {
+ "name": "several-violations-are-listed-in-order",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 5,
+ "y": 2
+ },
+ {
+ "x": 3,
+ "y": 2
+ },
+ {
+ "x": 3,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Left",
+ "apple": {
+ "x": 3,
+ "y": 2
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "snake-in-bounds",
+ "snake-no-overlap",
+ "snake-connected",
+ "apple-not-on-snake",
+ "turn-not-reversed"
+ ]
+ },
+ {
+ "name": "doubled-cell-counts-as-overlap-and-as-disconnected",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 2,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Right",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": null
+ }
+ ],
+ "expected": [
+ "snake-no-overlap",
+ "snake-connected"
+ ]
+ },
+ {
+ "name": "a-queued-turn-is-fine",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Left"
+ }
+ ],
+ "expected": []
+ },
+ {
+ "name": "queued-turn-straight-back-from-the-pending-turn",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Up",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Down"
+ }
+ ],
+ "expected": [
+ "queued-not-reversed"
+ ]
+ },
+ {
+ "name": "reversed-pending-turn-and-reversed-queue-are-both-listed",
+ "args": [
+ {
+ "width": 5,
+ "height": 5,
+ "snake": [
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 2
+ }
+ ],
+ "dir": "Right",
+ "nextDir": "Left",
+ "apple": {
+ "x": 4,
+ "y": 4
+ },
+ "seed": 0,
+ "score": 0,
+ "status": "Playing",
+ "frame": 0,
+ "queued": "Right"
+ }
+ ],
+ "expected": [
+ "turn-not-reversed",
+ "queued-not-reversed"
+ ]
+ }
+ ]
+}
diff --git a/fixtures/m13-life-click.json b/fixtures/m13-life-click.json
new file mode 100644
index 0000000..3135f97
--- /dev/null
+++ b/fixtures/m13-life-click.json
@@ -0,0 +1,15 @@
+{
+ "function": "lifeClick",
+ "cases": [
+ {"name":"the-top-left-pixel-is-cell-0-0","args":[0,0],"expected":{"tag":"Toggle","cell":{"x":0,"y":0}}},
+ {"name":"the-last-pixel-of-the-first-cell","args":[15,15],"expected":{"tag":"Toggle","cell":{"x":0,"y":0}}},
+ {"name":"the-next-pixel-is-the-next-cell","args":[16,0],"expected":{"tag":"Toggle","cell":{"x":1,"y":0}}},
+ {"name":"the-row-below","args":[3,16],"expected":{"tag":"Toggle","cell":{"x":0,"y":1}}},
+ {"name":"a-pixel-in-the-middle","args":[100,250],"expected":{"tag":"Toggle","cell":{"x":6,"y":15}}},
+ {"name":"the-bottom-right-pixel-of-the-board","args":[639,479],"expected":{"tag":"Toggle","cell":{"x":39,"y":29}}},
+ {"name":"right-of-the-board-is-nothing","args":[640,10],"expected":null},
+ {"name":"the-status-strip-under-the-board-is-nothing","args":[10,480],"expected":null},
+ {"name":"left-of-the-board-is-nothing","args":[-1,10],"expected":null},
+ {"name":"above-the-board-is-nothing","args":[10,-1],"expected":null}
+ ]
+}
diff --git a/fixtures/m13-life-key.json b/fixtures/m13-life-key.json
new file mode 100644
index 0000000..432b755
--- /dev/null
+++ b/fixtures/m13-life-key.json
@@ -0,0 +1,17 @@
+{
+ "function": "lifeKey",
+ "cases": [
+ {"name":"space-toggles-pause","args":[" "],"expected":{"tag":"TogglePause"}},
+ {"name":"n-steps","args":["n"],"expected":{"tag":"Step"}},
+ {"name":"N-steps","args":["N"],"expected":{"tag":"Step"}},
+ {"name":"c-clears","args":["c"],"expected":{"tag":"Clear"}},
+ {"name":"C-clears","args":["C"],"expected":{"tag":"Clear"}},
+ {"name":"r-randomizes","args":["r"],"expected":{"tag":"Randomize"}},
+ {"name":"R-randomizes","args":["R"],"expected":{"tag":"Randomize"}},
+ {"name":"ArrowUp-is-not-bound","args":["ArrowUp"],"expected":null},
+ {"name":"Enter-is-not-bound","args":["Enter"],"expected":null},
+ {"name":"constructor-is-not-bound","args":["constructor"],"expected":null},
+ {"name":"toString-is-not-bound","args":["toString"],"expected":null},
+ {"name":"empty-string-is-not-bound","args":[""],"expected":null}
+ ]
+}
diff --git a/fixtures/m13-life-step.json b/fixtures/m13-life-step.json
new file mode 100644
index 0000000..79a8d55
--- /dev/null
+++ b/fixtures/m13-life-step.json
@@ -0,0 +1,20 @@
+{
+ "function": "lifeStep",
+ "cases": [
+ {"name":"an-empty-board-stays-empty","args":[[]],"expected":[]},
+ {"name":"a-lonely-cell-dies","args":[[{"x":5,"y":5}]],"expected":[]},
+ {"name":"two-cells-die-of-loneliness","args":[[{"x":5,"y":5},{"x":6,"y":5}]],"expected":[]},
+ {"name":"a-block-is-stable","args":[[{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6},{"x":6,"y":6}]],"expected":[{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6},{"x":6,"y":6}]},
+ {"name":"a-horizontal-blinker-turns-vertical","args":[[{"x":4,"y":5},{"x":5,"y":5},{"x":6,"y":5}]],"expected":[{"x":5,"y":4},{"x":5,"y":5},{"x":5,"y":6}]},
+ {"name":"a-vertical-blinker-turns-horizontal","args":[[{"x":5,"y":4},{"x":5,"y":5},{"x":5,"y":6}]],"expected":[{"x":4,"y":5},{"x":5,"y":5},{"x":6,"y":5}]},
+ {"name":"three-cells-in-an-l-give-birth-to-a-block","args":[[{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6}]],"expected":[{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6},{"x":6,"y":6}]},
+ {"name":"a-cell-with-four-neighbours-dies-of-overcrowding","args":[[{"x":5,"y":4},{"x":4,"y":5},{"x":5,"y":5},{"x":6,"y":5},{"x":5,"y":6}]],"expected":[{"x":4,"y":4},{"x":5,"y":4},{"x":6,"y":4},{"x":4,"y":5},{"x":6,"y":5},{"x":4,"y":6},{"x":5,"y":6},{"x":6,"y":6}]},
+ {"name":"a-glider-moves-and-the-result-is-sorted-row-major","args":[[{"x":1,"y":0},{"x":2,"y":1},{"x":0,"y":2},{"x":1,"y":2},{"x":2,"y":2}]],"expected":[{"x":0,"y":1},{"x":2,"y":1},{"x":1,"y":2},{"x":2,"y":2},{"x":1,"y":3}]},
+ {"name":"a-block-in-the-corner-is-stable","args":[[{"x":0,"y":0},{"x":1,"y":0},{"x":0,"y":1},{"x":1,"y":1}]],"expected":[{"x":0,"y":0},{"x":1,"y":0},{"x":0,"y":1},{"x":1,"y":1}]},
+ {"name":"a-block-in-the-far-corner-is-stable","args":[[{"x":38,"y":28},{"x":39,"y":28},{"x":38,"y":29},{"x":39,"y":29}]],"expected":[{"x":38,"y":28},{"x":39,"y":28},{"x":38,"y":29},{"x":39,"y":29}]},
+ {"name":"a-blinker-on-the-top-edge-cannot-grow-beyond-it","args":[[{"x":4,"y":0},{"x":5,"y":0},{"x":6,"y":0}]],"expected":[{"x":5,"y":0},{"x":5,"y":1}]},
+ {"name":"a-blinker-on-the-right-edge-does-not-wrap-to-the-left-edge","args":[[{"x":39,"y":9},{"x":39,"y":10},{"x":39,"y":11}]],"expected":[{"x":38,"y":10},{"x":39,"y":10}]},
+ {"name":"cells-on-opposite-edges-are-not-neighbours","args":[[{"x":39,"y":9},{"x":0,"y":10},{"x":39,"y":10},{"x":39,"y":11}]],"expected":[{"x":38,"y":10},{"x":39,"y":10}]},
+ {"name":"a-blinker-on-the-bottom-edge-cannot-grow-beyond-it","args":[[{"x":10,"y":29},{"x":11,"y":29},{"x":12,"y":29}]],"expected":[{"x":11,"y":28},{"x":11,"y":29}]}
+ ]
+}
diff --git a/fixtures/m13-life-update.json b/fixtures/m13-life-update.json
new file mode 100644
index 0000000..e5120b7
--- /dev/null
+++ b/fixtures/m13-life-update.json
@@ -0,0 +1,3482 @@
+{
+ "function": "lifeUpdate",
+ "cases": [
+ {
+ "name": "frame-while-running-computes-the-next-generation",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 5,
+ "y": 4
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 6
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 4
+ }
+ },
+ {
+ "name": "frame-while-paused-is-unchanged",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "frame-on-an-empty-running-board-still-counts-the-generation",
+ "args": [
+ {
+ "cells": [],
+ "running": true,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Frame"
+ }
+ ],
+ "expected": {
+ "cells": [],
+ "running": true,
+ "seed": 7,
+ "generation": 4
+ }
+ },
+ {
+ "name": "step-while-paused-computes-one-generation",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Step"
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 5,
+ "y": 4
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 6
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 4
+ }
+ },
+ {
+ "name": "step-while-running-is-ignored",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Step"
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-pause-starts-a-paused-world",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "TogglePause"
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-pause-pauses-a-running-world",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "TogglePause"
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-on-an-empty-board-adds-the-cell",
+ "args": [
+ {
+ "cells": [],
+ "running": false,
+ "seed": 7,
+ "generation": 0
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 3,
+ "y": 2
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 3,
+ "y": 2
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 0
+ }
+ },
+ {
+ "name": "toggle-adds-a-dead-cell-in-row-major-order",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 9,
+ "y": 4
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 9,
+ "y": 4
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-adds-a-cell-between-two-on-the-same-row",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 5,
+ "y": 5
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-adds-a-cell-before-all-others",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 0,
+ "y": 0
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 0,
+ "y": 0
+ },
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-adds-a-cell-after-all-others",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 39,
+ "y": 29
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ },
+ {
+ "x": 39,
+ "y": 29
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-removes-a-live-cell",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 5,
+ "y": 5
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-adds-a-cell-at-the-start-of-a-row",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 4,
+ "y": 5
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-works-while-running-too",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 5,
+ "y": 6
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 6
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-off-the-board-is-ignored",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 40,
+ "y": 3
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-above-the-board-is-ignored",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 3,
+ "y": -1
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "toggle-just-below-the-board-is-ignored",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Toggle",
+ "cell": {
+ "x": 3,
+ "y": 30
+ }
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": false,
+ "seed": 7,
+ "generation": 3
+ }
+ },
+ {
+ "name": "clear-empties-the-board-and-restarts-the-count",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 3
+ },
+ {
+ "tag": "Clear"
+ }
+ ],
+ "expected": {
+ "cells": [],
+ "running": true,
+ "seed": 7,
+ "generation": 0
+ }
+ },
+ {
+ "name": "randomize-fills-the-board-from-the-seed-and-advances-it",
+ "args": [
+ {
+ "cells": [],
+ "running": false,
+ "seed": 42,
+ "generation": 0
+ },
+ {
+ "tag": "Randomize"
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 0,
+ "y": 0
+ },
+ {
+ "x": 2,
+ "y": 0
+ },
+ {
+ "x": 6,
+ "y": 0
+ },
+ {
+ "x": 13,
+ "y": 0
+ },
+ {
+ "x": 20,
+ "y": 0
+ },
+ {
+ "x": 21,
+ "y": 0
+ },
+ {
+ "x": 22,
+ "y": 0
+ },
+ {
+ "x": 23,
+ "y": 0
+ },
+ {
+ "x": 25,
+ "y": 0
+ },
+ {
+ "x": 30,
+ "y": 0
+ },
+ {
+ "x": 4,
+ "y": 1
+ },
+ {
+ "x": 8,
+ "y": 1
+ },
+ {
+ "x": 10,
+ "y": 1
+ },
+ {
+ "x": 12,
+ "y": 1
+ },
+ {
+ "x": 14,
+ "y": 1
+ },
+ {
+ "x": 20,
+ "y": 1
+ },
+ {
+ "x": 21,
+ "y": 1
+ },
+ {
+ "x": 24,
+ "y": 1
+ },
+ {
+ "x": 25,
+ "y": 1
+ },
+ {
+ "x": 31,
+ "y": 1
+ },
+ {
+ "x": 32,
+ "y": 1
+ },
+ {
+ "x": 35,
+ "y": 1
+ },
+ {
+ "x": 36,
+ "y": 1
+ },
+ {
+ "x": 2,
+ "y": 2
+ },
+ {
+ "x": 3,
+ "y": 2
+ },
+ {
+ "x": 5,
+ "y": 2
+ },
+ {
+ "x": 8,
+ "y": 2
+ },
+ {
+ "x": 13,
+ "y": 2
+ },
+ {
+ "x": 16,
+ "y": 2
+ },
+ {
+ "x": 19,
+ "y": 2
+ },
+ {
+ "x": 23,
+ "y": 2
+ },
+ {
+ "x": 29,
+ "y": 2
+ },
+ {
+ "x": 35,
+ "y": 2
+ },
+ {
+ "x": 0,
+ "y": 3
+ },
+ {
+ "x": 5,
+ "y": 3
+ },
+ {
+ "x": 6,
+ "y": 3
+ },
+ {
+ "x": 10,
+ "y": 3
+ },
+ {
+ "x": 16,
+ "y": 3
+ },
+ {
+ "x": 18,
+ "y": 3
+ },
+ {
+ "x": 19,
+ "y": 3
+ },
+ {
+ "x": 20,
+ "y": 3
+ },
+ {
+ "x": 25,
+ "y": 3
+ },
+ {
+ "x": 32,
+ "y": 3
+ },
+ {
+ "x": 37,
+ "y": 3
+ },
+ {
+ "x": 1,
+ "y": 4
+ },
+ {
+ "x": 3,
+ "y": 4
+ },
+ {
+ "x": 4,
+ "y": 4
+ },
+ {
+ "x": 5,
+ "y": 4
+ },
+ {
+ "x": 6,
+ "y": 4
+ },
+ {
+ "x": 10,
+ "y": 4
+ },
+ {
+ "x": 17,
+ "y": 4
+ },
+ {
+ "x": 18,
+ "y": 4
+ },
+ {
+ "x": 21,
+ "y": 4
+ },
+ {
+ "x": 22,
+ "y": 4
+ },
+ {
+ "x": 32,
+ "y": 4
+ },
+ {
+ "x": 34,
+ "y": 4
+ },
+ {
+ "x": 35,
+ "y": 4
+ },
+ {
+ "x": 36,
+ "y": 4
+ },
+ {
+ "x": 38,
+ "y": 4
+ },
+ {
+ "x": 8,
+ "y": 5
+ },
+ {
+ "x": 9,
+ "y": 5
+ },
+ {
+ "x": 11,
+ "y": 5
+ },
+ {
+ "x": 12,
+ "y": 5
+ },
+ {
+ "x": 16,
+ "y": 5
+ },
+ {
+ "x": 22,
+ "y": 5
+ },
+ {
+ "x": 29,
+ "y": 5
+ },
+ {
+ "x": 32,
+ "y": 5
+ },
+ {
+ "x": 34,
+ "y": 5
+ },
+ {
+ "x": 1,
+ "y": 6
+ },
+ {
+ "x": 3,
+ "y": 6
+ },
+ {
+ "x": 6,
+ "y": 6
+ },
+ {
+ "x": 11,
+ "y": 6
+ },
+ {
+ "x": 17,
+ "y": 6
+ },
+ {
+ "x": 23,
+ "y": 6
+ },
+ {
+ "x": 28,
+ "y": 6
+ },
+ {
+ "x": 33,
+ "y": 6
+ },
+ {
+ "x": 36,
+ "y": 6
+ },
+ {
+ "x": 38,
+ "y": 6
+ },
+ {
+ "x": 39,
+ "y": 6
+ },
+ {
+ "x": 6,
+ "y": 7
+ },
+ {
+ "x": 8,
+ "y": 7
+ },
+ {
+ "x": 12,
+ "y": 7
+ },
+ {
+ "x": 16,
+ "y": 7
+ },
+ {
+ "x": 24,
+ "y": 7
+ },
+ {
+ "x": 27,
+ "y": 7
+ },
+ {
+ "x": 29,
+ "y": 7
+ },
+ {
+ "x": 31,
+ "y": 7
+ },
+ {
+ "x": 33,
+ "y": 7
+ },
+ {
+ "x": 35,
+ "y": 7
+ },
+ {
+ "x": 36,
+ "y": 7
+ },
+ {
+ "x": 1,
+ "y": 8
+ },
+ {
+ "x": 4,
+ "y": 8
+ },
+ {
+ "x": 6,
+ "y": 8
+ },
+ {
+ "x": 10,
+ "y": 8
+ },
+ {
+ "x": 11,
+ "y": 8
+ },
+ {
+ "x": 14,
+ "y": 8
+ },
+ {
+ "x": 16,
+ "y": 8
+ },
+ {
+ "x": 20,
+ "y": 8
+ },
+ {
+ "x": 24,
+ "y": 8
+ },
+ {
+ "x": 39,
+ "y": 8
+ },
+ {
+ "x": 2,
+ "y": 9
+ },
+ {
+ "x": 4,
+ "y": 9
+ },
+ {
+ "x": 6,
+ "y": 9
+ },
+ {
+ "x": 8,
+ "y": 9
+ },
+ {
+ "x": 9,
+ "y": 9
+ },
+ {
+ "x": 10,
+ "y": 9
+ },
+ {
+ "x": 16,
+ "y": 9
+ },
+ {
+ "x": 18,
+ "y": 9
+ },
+ {
+ "x": 22,
+ "y": 9
+ },
+ {
+ "x": 24,
+ "y": 9
+ },
+ {
+ "x": 33,
+ "y": 9
+ },
+ {
+ "x": 37,
+ "y": 9
+ },
+ {
+ "x": 38,
+ "y": 9
+ },
+ {
+ "x": 0,
+ "y": 10
+ },
+ {
+ "x": 1,
+ "y": 10
+ },
+ {
+ "x": 6,
+ "y": 10
+ },
+ {
+ "x": 9,
+ "y": 10
+ },
+ {
+ "x": 14,
+ "y": 10
+ },
+ {
+ "x": 15,
+ "y": 10
+ },
+ {
+ "x": 20,
+ "y": 10
+ },
+ {
+ "x": 22,
+ "y": 10
+ },
+ {
+ "x": 23,
+ "y": 10
+ },
+ {
+ "x": 26,
+ "y": 10
+ },
+ {
+ "x": 39,
+ "y": 10
+ },
+ {
+ "x": 11,
+ "y": 11
+ },
+ {
+ "x": 14,
+ "y": 11
+ },
+ {
+ "x": 24,
+ "y": 11
+ },
+ {
+ "x": 27,
+ "y": 11
+ },
+ {
+ "x": 35,
+ "y": 11
+ },
+ {
+ "x": 36,
+ "y": 11
+ },
+ {
+ "x": 0,
+ "y": 12
+ },
+ {
+ "x": 2,
+ "y": 12
+ },
+ {
+ "x": 9,
+ "y": 12
+ },
+ {
+ "x": 17,
+ "y": 12
+ },
+ {
+ "x": 21,
+ "y": 12
+ },
+ {
+ "x": 26,
+ "y": 12
+ },
+ {
+ "x": 30,
+ "y": 12
+ },
+ {
+ "x": 31,
+ "y": 12
+ },
+ {
+ "x": 37,
+ "y": 12
+ },
+ {
+ "x": 1,
+ "y": 13
+ },
+ {
+ "x": 2,
+ "y": 13
+ },
+ {
+ "x": 7,
+ "y": 13
+ },
+ {
+ "x": 14,
+ "y": 13
+ },
+ {
+ "x": 16,
+ "y": 13
+ },
+ {
+ "x": 19,
+ "y": 13
+ },
+ {
+ "x": 23,
+ "y": 13
+ },
+ {
+ "x": 24,
+ "y": 13
+ },
+ {
+ "x": 26,
+ "y": 13
+ },
+ {
+ "x": 28,
+ "y": 13
+ },
+ {
+ "x": 29,
+ "y": 13
+ },
+ {
+ "x": 3,
+ "y": 14
+ },
+ {
+ "x": 7,
+ "y": 14
+ },
+ {
+ "x": 8,
+ "y": 14
+ },
+ {
+ "x": 10,
+ "y": 14
+ },
+ {
+ "x": 14,
+ "y": 14
+ },
+ {
+ "x": 17,
+ "y": 14
+ },
+ {
+ "x": 18,
+ "y": 14
+ },
+ {
+ "x": 22,
+ "y": 14
+ },
+ {
+ "x": 26,
+ "y": 14
+ },
+ {
+ "x": 27,
+ "y": 14
+ },
+ {
+ "x": 30,
+ "y": 14
+ },
+ {
+ "x": 7,
+ "y": 15
+ },
+ {
+ "x": 11,
+ "y": 15
+ },
+ {
+ "x": 12,
+ "y": 15
+ },
+ {
+ "x": 14,
+ "y": 15
+ },
+ {
+ "x": 16,
+ "y": 15
+ },
+ {
+ "x": 18,
+ "y": 15
+ },
+ {
+ "x": 19,
+ "y": 15
+ },
+ {
+ "x": 21,
+ "y": 15
+ },
+ {
+ "x": 24,
+ "y": 15
+ },
+ {
+ "x": 29,
+ "y": 15
+ },
+ {
+ "x": 30,
+ "y": 15
+ },
+ {
+ "x": 35,
+ "y": 15
+ },
+ {
+ "x": 38,
+ "y": 15
+ },
+ {
+ "x": 39,
+ "y": 15
+ },
+ {
+ "x": 0,
+ "y": 16
+ },
+ {
+ "x": 2,
+ "y": 16
+ },
+ {
+ "x": 6,
+ "y": 16
+ },
+ {
+ "x": 17,
+ "y": 16
+ },
+ {
+ "x": 20,
+ "y": 16
+ },
+ {
+ "x": 21,
+ "y": 16
+ },
+ {
+ "x": 22,
+ "y": 16
+ },
+ {
+ "x": 24,
+ "y": 16
+ },
+ {
+ "x": 29,
+ "y": 16
+ },
+ {
+ "x": 30,
+ "y": 16
+ },
+ {
+ "x": 31,
+ "y": 16
+ },
+ {
+ "x": 32,
+ "y": 16
+ },
+ {
+ "x": 11,
+ "y": 17
+ },
+ {
+ "x": 13,
+ "y": 17
+ },
+ {
+ "x": 14,
+ "y": 17
+ },
+ {
+ "x": 16,
+ "y": 17
+ },
+ {
+ "x": 17,
+ "y": 17
+ },
+ {
+ "x": 23,
+ "y": 17
+ },
+ {
+ "x": 29,
+ "y": 17
+ },
+ {
+ "x": 1,
+ "y": 18
+ },
+ {
+ "x": 5,
+ "y": 18
+ },
+ {
+ "x": 10,
+ "y": 18
+ },
+ {
+ "x": 11,
+ "y": 18
+ },
+ {
+ "x": 20,
+ "y": 18
+ },
+ {
+ "x": 22,
+ "y": 18
+ },
+ {
+ "x": 35,
+ "y": 18
+ },
+ {
+ "x": 37,
+ "y": 18
+ },
+ {
+ "x": 38,
+ "y": 18
+ },
+ {
+ "x": 0,
+ "y": 19
+ },
+ {
+ "x": 3,
+ "y": 19
+ },
+ {
+ "x": 4,
+ "y": 19
+ },
+ {
+ "x": 5,
+ "y": 19
+ },
+ {
+ "x": 6,
+ "y": 19
+ },
+ {
+ "x": 9,
+ "y": 19
+ },
+ {
+ "x": 12,
+ "y": 19
+ },
+ {
+ "x": 18,
+ "y": 19
+ },
+ {
+ "x": 24,
+ "y": 19
+ },
+ {
+ "x": 28,
+ "y": 19
+ },
+ {
+ "x": 29,
+ "y": 19
+ },
+ {
+ "x": 30,
+ "y": 19
+ },
+ {
+ "x": 34,
+ "y": 19
+ },
+ {
+ "x": 0,
+ "y": 20
+ },
+ {
+ "x": 3,
+ "y": 20
+ },
+ {
+ "x": 5,
+ "y": 20
+ },
+ {
+ "x": 6,
+ "y": 20
+ },
+ {
+ "x": 7,
+ "y": 20
+ },
+ {
+ "x": 13,
+ "y": 20
+ },
+ {
+ "x": 15,
+ "y": 20
+ },
+ {
+ "x": 17,
+ "y": 20
+ },
+ {
+ "x": 18,
+ "y": 20
+ },
+ {
+ "x": 34,
+ "y": 20
+ },
+ {
+ "x": 38,
+ "y": 20
+ },
+ {
+ "x": 4,
+ "y": 21
+ },
+ {
+ "x": 6,
+ "y": 21
+ },
+ {
+ "x": 10,
+ "y": 21
+ },
+ {
+ "x": 11,
+ "y": 21
+ },
+ {
+ "x": 13,
+ "y": 21
+ },
+ {
+ "x": 16,
+ "y": 21
+ },
+ {
+ "x": 21,
+ "y": 21
+ },
+ {
+ "x": 23,
+ "y": 21
+ },
+ {
+ "x": 27,
+ "y": 21
+ },
+ {
+ "x": 28,
+ "y": 21
+ },
+ {
+ "x": 35,
+ "y": 21
+ },
+ {
+ "x": 2,
+ "y": 22
+ },
+ {
+ "x": 10,
+ "y": 22
+ },
+ {
+ "x": 13,
+ "y": 22
+ },
+ {
+ "x": 16,
+ "y": 22
+ },
+ {
+ "x": 18,
+ "y": 22
+ },
+ {
+ "x": 20,
+ "y": 22
+ },
+ {
+ "x": 33,
+ "y": 22
+ },
+ {
+ "x": 35,
+ "y": 22
+ },
+ {
+ "x": 36,
+ "y": 22
+ },
+ {
+ "x": 0,
+ "y": 23
+ },
+ {
+ "x": 7,
+ "y": 23
+ },
+ {
+ "x": 12,
+ "y": 23
+ },
+ {
+ "x": 19,
+ "y": 23
+ },
+ {
+ "x": 21,
+ "y": 23
+ },
+ {
+ "x": 24,
+ "y": 23
+ },
+ {
+ "x": 29,
+ "y": 23
+ },
+ {
+ "x": 35,
+ "y": 23
+ },
+ {
+ "x": 7,
+ "y": 24
+ },
+ {
+ "x": 8,
+ "y": 24
+ },
+ {
+ "x": 9,
+ "y": 24
+ },
+ {
+ "x": 14,
+ "y": 24
+ },
+ {
+ "x": 15,
+ "y": 24
+ },
+ {
+ "x": 19,
+ "y": 24
+ },
+ {
+ "x": 22,
+ "y": 24
+ },
+ {
+ "x": 30,
+ "y": 24
+ },
+ {
+ "x": 36,
+ "y": 24
+ },
+ {
+ "x": 7,
+ "y": 25
+ },
+ {
+ "x": 9,
+ "y": 25
+ },
+ {
+ "x": 10,
+ "y": 25
+ },
+ {
+ "x": 11,
+ "y": 25
+ },
+ {
+ "x": 12,
+ "y": 25
+ },
+ {
+ "x": 17,
+ "y": 25
+ },
+ {
+ "x": 21,
+ "y": 25
+ },
+ {
+ "x": 22,
+ "y": 25
+ },
+ {
+ "x": 25,
+ "y": 25
+ },
+ {
+ "x": 36,
+ "y": 25
+ },
+ {
+ "x": 38,
+ "y": 25
+ },
+ {
+ "x": 2,
+ "y": 26
+ },
+ {
+ "x": 3,
+ "y": 26
+ },
+ {
+ "x": 11,
+ "y": 26
+ },
+ {
+ "x": 12,
+ "y": 26
+ },
+ {
+ "x": 21,
+ "y": 26
+ },
+ {
+ "x": 24,
+ "y": 26
+ },
+ {
+ "x": 25,
+ "y": 26
+ },
+ {
+ "x": 27,
+ "y": 26
+ },
+ {
+ "x": 29,
+ "y": 26
+ },
+ {
+ "x": 35,
+ "y": 26
+ },
+ {
+ "x": 36,
+ "y": 26
+ },
+ {
+ "x": 6,
+ "y": 27
+ },
+ {
+ "x": 7,
+ "y": 27
+ },
+ {
+ "x": 19,
+ "y": 27
+ },
+ {
+ "x": 20,
+ "y": 27
+ },
+ {
+ "x": 32,
+ "y": 27
+ },
+ {
+ "x": 33,
+ "y": 27
+ },
+ {
+ "x": 34,
+ "y": 27
+ },
+ {
+ "x": 38,
+ "y": 27
+ },
+ {
+ "x": 39,
+ "y": 27
+ },
+ {
+ "x": 0,
+ "y": 28
+ },
+ {
+ "x": 2,
+ "y": 28
+ },
+ {
+ "x": 3,
+ "y": 28
+ },
+ {
+ "x": 6,
+ "y": 28
+ },
+ {
+ "x": 7,
+ "y": 28
+ },
+ {
+ "x": 9,
+ "y": 28
+ },
+ {
+ "x": 12,
+ "y": 28
+ },
+ {
+ "x": 13,
+ "y": 28
+ },
+ {
+ "x": 14,
+ "y": 28
+ },
+ {
+ "x": 25,
+ "y": 28
+ },
+ {
+ "x": 26,
+ "y": 28
+ },
+ {
+ "x": 28,
+ "y": 28
+ },
+ {
+ "x": 32,
+ "y": 28
+ },
+ {
+ "x": 33,
+ "y": 28
+ },
+ {
+ "x": 5,
+ "y": 29
+ },
+ {
+ "x": 9,
+ "y": 29
+ },
+ {
+ "x": 14,
+ "y": 29
+ },
+ {
+ "x": 16,
+ "y": 29
+ },
+ {
+ "x": 19,
+ "y": 29
+ },
+ {
+ "x": 24,
+ "y": 29
+ },
+ {
+ "x": 26,
+ "y": 29
+ },
+ {
+ "x": 28,
+ "y": 29
+ },
+ {
+ "x": 32,
+ "y": 29
+ },
+ {
+ "x": 33,
+ "y": 29
+ }
+ ],
+ "running": false,
+ "seed": 3150687386,
+ "generation": 0
+ }
+ },
+ {
+ "name": "randomize-replaces-the-cells-restarts-the-count-and-keeps-running",
+ "args": [
+ {
+ "cells": [
+ {
+ "x": 4,
+ "y": 5
+ },
+ {
+ "x": 5,
+ "y": 5
+ },
+ {
+ "x": 6,
+ "y": 5
+ }
+ ],
+ "running": true,
+ "seed": 7,
+ "generation": 12
+ },
+ {
+ "tag": "Randomize"
+ }
+ ],
+ "expected": {
+ "cells": [
+ {
+ "x": 0,
+ "y": 0
+ },
+ {
+ "x": 5,
+ "y": 0
+ },
+ {
+ "x": 9,
+ "y": 0
+ },
+ {
+ "x": 13,
+ "y": 0
+ },
+ {
+ "x": 19,
+ "y": 0
+ },
+ {
+ "x": 20,
+ "y": 0
+ },
+ {
+ "x": 21,
+ "y": 0
+ },
+ {
+ "x": 22,
+ "y": 0
+ },
+ {
+ "x": 24,
+ "y": 0
+ },
+ {
+ "x": 27,
+ "y": 0
+ },
+ {
+ "x": 28,
+ "y": 0
+ },
+ {
+ "x": 29,
+ "y": 0
+ },
+ {
+ "x": 31,
+ "y": 0
+ },
+ {
+ "x": 5,
+ "y": 1
+ },
+ {
+ "x": 8,
+ "y": 1
+ },
+ {
+ "x": 11,
+ "y": 1
+ },
+ {
+ "x": 15,
+ "y": 1
+ },
+ {
+ "x": 22,
+ "y": 1
+ },
+ {
+ "x": 27,
+ "y": 1
+ },
+ {
+ "x": 30,
+ "y": 1
+ },
+ {
+ "x": 31,
+ "y": 1
+ },
+ {
+ "x": 37,
+ "y": 1
+ },
+ {
+ "x": 1,
+ "y": 2
+ },
+ {
+ "x": 4,
+ "y": 2
+ },
+ {
+ "x": 7,
+ "y": 2
+ },
+ {
+ "x": 12,
+ "y": 2
+ },
+ {
+ "x": 23,
+ "y": 2
+ },
+ {
+ "x": 33,
+ "y": 2
+ },
+ {
+ "x": 35,
+ "y": 2
+ },
+ {
+ "x": 39,
+ "y": 2
+ },
+ {
+ "x": 4,
+ "y": 3
+ },
+ {
+ "x": 8,
+ "y": 3
+ },
+ {
+ "x": 11,
+ "y": 3
+ },
+ {
+ "x": 15,
+ "y": 3
+ },
+ {
+ "x": 18,
+ "y": 3
+ },
+ {
+ "x": 21,
+ "y": 3
+ },
+ {
+ "x": 22,
+ "y": 3
+ },
+ {
+ "x": 23,
+ "y": 3
+ },
+ {
+ "x": 35,
+ "y": 3
+ },
+ {
+ "x": 36,
+ "y": 3
+ },
+ {
+ "x": 4,
+ "y": 4
+ },
+ {
+ "x": 11,
+ "y": 4
+ },
+ {
+ "x": 18,
+ "y": 4
+ },
+ {
+ "x": 20,
+ "y": 4
+ },
+ {
+ "x": 23,
+ "y": 4
+ },
+ {
+ "x": 26,
+ "y": 4
+ },
+ {
+ "x": 32,
+ "y": 4
+ },
+ {
+ "x": 35,
+ "y": 4
+ },
+ {
+ "x": 36,
+ "y": 4
+ },
+ {
+ "x": 2,
+ "y": 5
+ },
+ {
+ "x": 15,
+ "y": 5
+ },
+ {
+ "x": 20,
+ "y": 5
+ },
+ {
+ "x": 25,
+ "y": 5
+ },
+ {
+ "x": 28,
+ "y": 5
+ },
+ {
+ "x": 31,
+ "y": 5
+ },
+ {
+ "x": 35,
+ "y": 5
+ },
+ {
+ "x": 3,
+ "y": 6
+ },
+ {
+ "x": 5,
+ "y": 6
+ },
+ {
+ "x": 9,
+ "y": 6
+ },
+ {
+ "x": 10,
+ "y": 6
+ },
+ {
+ "x": 11,
+ "y": 6
+ },
+ {
+ "x": 12,
+ "y": 6
+ },
+ {
+ "x": 13,
+ "y": 6
+ },
+ {
+ "x": 14,
+ "y": 6
+ },
+ {
+ "x": 19,
+ "y": 6
+ },
+ {
+ "x": 22,
+ "y": 6
+ },
+ {
+ "x": 27,
+ "y": 6
+ },
+ {
+ "x": 32,
+ "y": 6
+ },
+ {
+ "x": 33,
+ "y": 6
+ },
+ {
+ "x": 0,
+ "y": 7
+ },
+ {
+ "x": 3,
+ "y": 7
+ },
+ {
+ "x": 10,
+ "y": 7
+ },
+ {
+ "x": 13,
+ "y": 7
+ },
+ {
+ "x": 16,
+ "y": 7
+ },
+ {
+ "x": 23,
+ "y": 7
+ },
+ {
+ "x": 32,
+ "y": 7
+ },
+ {
+ "x": 35,
+ "y": 7
+ },
+ {
+ "x": 0,
+ "y": 8
+ },
+ {
+ "x": 3,
+ "y": 8
+ },
+ {
+ "x": 4,
+ "y": 8
+ },
+ {
+ "x": 5,
+ "y": 8
+ },
+ {
+ "x": 14,
+ "y": 8
+ },
+ {
+ "x": 16,
+ "y": 8
+ },
+ {
+ "x": 19,
+ "y": 8
+ },
+ {
+ "x": 20,
+ "y": 8
+ },
+ {
+ "x": 21,
+ "y": 8
+ },
+ {
+ "x": 22,
+ "y": 8
+ },
+ {
+ "x": 24,
+ "y": 8
+ },
+ {
+ "x": 27,
+ "y": 8
+ },
+ {
+ "x": 37,
+ "y": 8
+ },
+ {
+ "x": 12,
+ "y": 9
+ },
+ {
+ "x": 14,
+ "y": 9
+ },
+ {
+ "x": 18,
+ "y": 9
+ },
+ {
+ "x": 23,
+ "y": 9
+ },
+ {
+ "x": 26,
+ "y": 9
+ },
+ {
+ "x": 37,
+ "y": 9
+ },
+ {
+ "x": 39,
+ "y": 9
+ },
+ {
+ "x": 18,
+ "y": 10
+ },
+ {
+ "x": 20,
+ "y": 10
+ },
+ {
+ "x": 26,
+ "y": 10
+ },
+ {
+ "x": 30,
+ "y": 10
+ },
+ {
+ "x": 1,
+ "y": 11
+ },
+ {
+ "x": 4,
+ "y": 11
+ },
+ {
+ "x": 15,
+ "y": 11
+ },
+ {
+ "x": 19,
+ "y": 11
+ },
+ {
+ "x": 24,
+ "y": 11
+ },
+ {
+ "x": 30,
+ "y": 11
+ },
+ {
+ "x": 33,
+ "y": 11
+ },
+ {
+ "x": 34,
+ "y": 11
+ },
+ {
+ "x": 37,
+ "y": 11
+ },
+ {
+ "x": 7,
+ "y": 12
+ },
+ {
+ "x": 14,
+ "y": 12
+ },
+ {
+ "x": 16,
+ "y": 12
+ },
+ {
+ "x": 18,
+ "y": 12
+ },
+ {
+ "x": 24,
+ "y": 12
+ },
+ {
+ "x": 26,
+ "y": 12
+ },
+ {
+ "x": 32,
+ "y": 12
+ },
+ {
+ "x": 34,
+ "y": 12
+ },
+ {
+ "x": 36,
+ "y": 12
+ },
+ {
+ "x": 37,
+ "y": 12
+ },
+ {
+ "x": 39,
+ "y": 12
+ },
+ {
+ "x": 0,
+ "y": 13
+ },
+ {
+ "x": 8,
+ "y": 13
+ },
+ {
+ "x": 10,
+ "y": 13
+ },
+ {
+ "x": 11,
+ "y": 13
+ },
+ {
+ "x": 12,
+ "y": 13
+ },
+ {
+ "x": 14,
+ "y": 13
+ },
+ {
+ "x": 15,
+ "y": 13
+ },
+ {
+ "x": 19,
+ "y": 13
+ },
+ {
+ "x": 20,
+ "y": 13
+ },
+ {
+ "x": 25,
+ "y": 13
+ },
+ {
+ "x": 32,
+ "y": 13
+ },
+ {
+ "x": 33,
+ "y": 13
+ },
+ {
+ "x": 37,
+ "y": 13
+ },
+ {
+ "x": 38,
+ "y": 13
+ },
+ {
+ "x": 2,
+ "y": 14
+ },
+ {
+ "x": 10,
+ "y": 14
+ },
+ {
+ "x": 11,
+ "y": 14
+ },
+ {
+ "x": 12,
+ "y": 14
+ },
+ {
+ "x": 14,
+ "y": 14
+ },
+ {
+ "x": 15,
+ "y": 14
+ },
+ {
+ "x": 16,
+ "y": 14
+ },
+ {
+ "x": 29,
+ "y": 14
+ },
+ {
+ "x": 37,
+ "y": 14
+ },
+ {
+ "x": 38,
+ "y": 14
+ },
+ {
+ "x": 0,
+ "y": 15
+ },
+ {
+ "x": 1,
+ "y": 15
+ },
+ {
+ "x": 2,
+ "y": 15
+ },
+ {
+ "x": 4,
+ "y": 15
+ },
+ {
+ "x": 5,
+ "y": 15
+ },
+ {
+ "x": 6,
+ "y": 15
+ },
+ {
+ "x": 8,
+ "y": 15
+ },
+ {
+ "x": 9,
+ "y": 15
+ },
+ {
+ "x": 10,
+ "y": 15
+ },
+ {
+ "x": 11,
+ "y": 15
+ },
+ {
+ "x": 14,
+ "y": 15
+ },
+ {
+ "x": 15,
+ "y": 15
+ },
+ {
+ "x": 17,
+ "y": 15
+ },
+ {
+ "x": 18,
+ "y": 15
+ },
+ {
+ "x": 19,
+ "y": 15
+ },
+ {
+ "x": 20,
+ "y": 15
+ },
+ {
+ "x": 24,
+ "y": 15
+ },
+ {
+ "x": 28,
+ "y": 15
+ },
+ {
+ "x": 0,
+ "y": 16
+ },
+ {
+ "x": 8,
+ "y": 16
+ },
+ {
+ "x": 9,
+ "y": 16
+ },
+ {
+ "x": 11,
+ "y": 16
+ },
+ {
+ "x": 18,
+ "y": 16
+ },
+ {
+ "x": 20,
+ "y": 16
+ },
+ {
+ "x": 21,
+ "y": 16
+ },
+ {
+ "x": 22,
+ "y": 16
+ },
+ {
+ "x": 31,
+ "y": 16
+ },
+ {
+ "x": 32,
+ "y": 16
+ },
+ {
+ "x": 33,
+ "y": 16
+ },
+ {
+ "x": 34,
+ "y": 16
+ },
+ {
+ "x": 38,
+ "y": 16
+ },
+ {
+ "x": 8,
+ "y": 17
+ },
+ {
+ "x": 13,
+ "y": 17
+ },
+ {
+ "x": 20,
+ "y": 17
+ },
+ {
+ "x": 21,
+ "y": 17
+ },
+ {
+ "x": 5,
+ "y": 18
+ },
+ {
+ "x": 9,
+ "y": 18
+ },
+ {
+ "x": 15,
+ "y": 18
+ },
+ {
+ "x": 20,
+ "y": 18
+ },
+ {
+ "x": 21,
+ "y": 18
+ },
+ {
+ "x": 24,
+ "y": 18
+ },
+ {
+ "x": 29,
+ "y": 18
+ },
+ {
+ "x": 31,
+ "y": 18
+ },
+ {
+ "x": 34,
+ "y": 18
+ },
+ {
+ "x": 35,
+ "y": 18
+ },
+ {
+ "x": 36,
+ "y": 18
+ },
+ {
+ "x": 37,
+ "y": 18
+ },
+ {
+ "x": 1,
+ "y": 19
+ },
+ {
+ "x": 2,
+ "y": 19
+ },
+ {
+ "x": 4,
+ "y": 19
+ },
+ {
+ "x": 5,
+ "y": 19
+ },
+ {
+ "x": 9,
+ "y": 19
+ },
+ {
+ "x": 15,
+ "y": 19
+ },
+ {
+ "x": 20,
+ "y": 19
+ },
+ {
+ "x": 23,
+ "y": 19
+ },
+ {
+ "x": 26,
+ "y": 19
+ },
+ {
+ "x": 27,
+ "y": 19
+ },
+ {
+ "x": 30,
+ "y": 19
+ },
+ {
+ "x": 34,
+ "y": 19
+ },
+ {
+ "x": 35,
+ "y": 19
+ },
+ {
+ "x": 37,
+ "y": 19
+ },
+ {
+ "x": 1,
+ "y": 20
+ },
+ {
+ "x": 2,
+ "y": 20
+ },
+ {
+ "x": 5,
+ "y": 20
+ },
+ {
+ "x": 6,
+ "y": 20
+ },
+ {
+ "x": 10,
+ "y": 20
+ },
+ {
+ "x": 11,
+ "y": 20
+ },
+ {
+ "x": 13,
+ "y": 20
+ },
+ {
+ "x": 18,
+ "y": 20
+ },
+ {
+ "x": 23,
+ "y": 20
+ },
+ {
+ "x": 27,
+ "y": 20
+ },
+ {
+ "x": 28,
+ "y": 20
+ },
+ {
+ "x": 29,
+ "y": 20
+ },
+ {
+ "x": 31,
+ "y": 20
+ },
+ {
+ "x": 32,
+ "y": 20
+ },
+ {
+ "x": 33,
+ "y": 20
+ },
+ {
+ "x": 34,
+ "y": 20
+ },
+ {
+ "x": 1,
+ "y": 21
+ },
+ {
+ "x": 2,
+ "y": 21
+ },
+ {
+ "x": 4,
+ "y": 21
+ },
+ {
+ "x": 11,
+ "y": 21
+ },
+ {
+ "x": 15,
+ "y": 21
+ },
+ {
+ "x": 16,
+ "y": 21
+ },
+ {
+ "x": 17,
+ "y": 21
+ },
+ {
+ "x": 26,
+ "y": 21
+ },
+ {
+ "x": 27,
+ "y": 21
+ },
+ {
+ "x": 29,
+ "y": 21
+ },
+ {
+ "x": 30,
+ "y": 21
+ },
+ {
+ "x": 32,
+ "y": 21
+ },
+ {
+ "x": 34,
+ "y": 21
+ },
+ {
+ "x": 38,
+ "y": 21
+ },
+ {
+ "x": 0,
+ "y": 22
+ },
+ {
+ "x": 3,
+ "y": 22
+ },
+ {
+ "x": 5,
+ "y": 22
+ },
+ {
+ "x": 6,
+ "y": 22
+ },
+ {
+ "x": 7,
+ "y": 22
+ },
+ {
+ "x": 14,
+ "y": 22
+ },
+ {
+ "x": 19,
+ "y": 22
+ },
+ {
+ "x": 22,
+ "y": 22
+ },
+ {
+ "x": 24,
+ "y": 22
+ },
+ {
+ "x": 27,
+ "y": 22
+ },
+ {
+ "x": 35,
+ "y": 22
+ },
+ {
+ "x": 36,
+ "y": 22
+ },
+ {
+ "x": 3,
+ "y": 23
+ },
+ {
+ "x": 7,
+ "y": 23
+ },
+ {
+ "x": 10,
+ "y": 23
+ },
+ {
+ "x": 13,
+ "y": 23
+ },
+ {
+ "x": 18,
+ "y": 23
+ },
+ {
+ "x": 19,
+ "y": 23
+ },
+ {
+ "x": 20,
+ "y": 23
+ },
+ {
+ "x": 25,
+ "y": 23
+ },
+ {
+ "x": 28,
+ "y": 23
+ },
+ {
+ "x": 31,
+ "y": 23
+ },
+ {
+ "x": 4,
+ "y": 24
+ },
+ {
+ "x": 6,
+ "y": 24
+ },
+ {
+ "x": 9,
+ "y": 24
+ },
+ {
+ "x": 19,
+ "y": 24
+ },
+ {
+ "x": 20,
+ "y": 24
+ },
+ {
+ "x": 21,
+ "y": 24
+ },
+ {
+ "x": 26,
+ "y": 24
+ },
+ {
+ "x": 35,
+ "y": 24
+ },
+ {
+ "x": 0,
+ "y": 25
+ },
+ {
+ "x": 3,
+ "y": 25
+ },
+ {
+ "x": 4,
+ "y": 25
+ },
+ {
+ "x": 6,
+ "y": 25
+ },
+ {
+ "x": 13,
+ "y": 25
+ },
+ {
+ "x": 17,
+ "y": 25
+ },
+ {
+ "x": 20,
+ "y": 25
+ },
+ {
+ "x": 21,
+ "y": 25
+ },
+ {
+ "x": 25,
+ "y": 25
+ },
+ {
+ "x": 27,
+ "y": 25
+ },
+ {
+ "x": 31,
+ "y": 25
+ },
+ {
+ "x": 39,
+ "y": 25
+ },
+ {
+ "x": 2,
+ "y": 26
+ },
+ {
+ "x": 3,
+ "y": 26
+ },
+ {
+ "x": 6,
+ "y": 26
+ },
+ {
+ "x": 11,
+ "y": 26
+ },
+ {
+ "x": 13,
+ "y": 26
+ },
+ {
+ "x": 19,
+ "y": 26
+ },
+ {
+ "x": 23,
+ "y": 26
+ },
+ {
+ "x": 31,
+ "y": 26
+ },
+ {
+ "x": 34,
+ "y": 26
+ },
+ {
+ "x": 35,
+ "y": 26
+ },
+ {
+ "x": 2,
+ "y": 27
+ },
+ {
+ "x": 17,
+ "y": 27
+ },
+ {
+ "x": 20,
+ "y": 27
+ },
+ {
+ "x": 32,
+ "y": 27
+ },
+ {
+ "x": 35,
+ "y": 27
+ },
+ {
+ "x": 3,
+ "y": 28
+ },
+ {
+ "x": 5,
+ "y": 28
+ },
+ {
+ "x": 9,
+ "y": 28
+ },
+ {
+ "x": 11,
+ "y": 28
+ },
+ {
+ "x": 13,
+ "y": 28
+ },
+ {
+ "x": 14,
+ "y": 28
+ },
+ {
+ "x": 15,
+ "y": 28
+ },
+ {
+ "x": 17,
+ "y": 28
+ },
+ {
+ "x": 20,
+ "y": 28
+ },
+ {
+ "x": 26,
+ "y": 28
+ },
+ {
+ "x": 28,
+ "y": 28
+ },
+ {
+ "x": 29,
+ "y": 28
+ },
+ {
+ "x": 34,
+ "y": 28
+ },
+ {
+ "x": 39,
+ "y": 28
+ },
+ {
+ "x": 2,
+ "y": 29
+ },
+ {
+ "x": 4,
+ "y": 29
+ },
+ {
+ "x": 5,
+ "y": 29
+ },
+ {
+ "x": 6,
+ "y": 29
+ },
+ {
+ "x": 7,
+ "y": 29
+ },
+ {
+ "x": 15,
+ "y": 29
+ },
+ {
+ "x": 17,
+ "y": 29
+ },
+ {
+ "x": 20,
+ "y": 29
+ },
+ {
+ "x": 23,
+ "y": 29
+ },
+ {
+ "x": 26,
+ "y": 29
+ },
+ {
+ "x": 29,
+ "y": 29
+ },
+ {
+ "x": 36,
+ "y": 29
+ },
+ {
+ "x": 37,
+ "y": 29
+ },
+ {
+ "x": 39,
+ "y": 29
+ }
+ ],
+ "running": true,
+ "seed": 3150687351,
+ "generation": 0
+ }
+ }
+ ]
+}
diff --git a/fixtures/m13-life-view.json b/fixtures/m13-life-view.json
new file mode 100644
index 0000000..e2969fd
--- /dev/null
+++ b/fixtures/m13-life-view.json
@@ -0,0 +1,8 @@
+{
+ "function": "lifeView",
+ "cases": [
+ {"name":"a-new-world-is-an-empty-paused-board","args":[{"cells":[],"running":false,"seed":42,"generation":0}],"expected":[{"tag":"Rect","x":0,"y":0,"w":640,"h":480,"color":"#111111"},{"tag":"Rect","x":0,"y":480,"w":640,"h":24,"color":"#222222"},{"tag":"Text","x":6,"y":497,"text":"Generation 0","color":"#ffffff","size":14,"align":"left"},{"tag":"Text","x":634,"y":497,"text":"Paused: Space runs, N steps","color":"#ffffff","size":14,"align":"right"}]},
+ {"name":"live-cells-in-row-major-order-while-running","args":[{"cells":[{"x":0,"y":0},{"x":5,"y":5},{"x":39,"y":29}],"running":true,"seed":1,"generation":17}],"expected":[{"tag":"Rect","x":0,"y":0,"w":640,"h":480,"color":"#111111"},{"tag":"Rect","x":1,"y":1,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":81,"y":81,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":625,"y":465,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":0,"y":480,"w":640,"h":24,"color":"#222222"},{"tag":"Text","x":6,"y":497,"text":"Generation 17","color":"#ffffff","size":14,"align":"left"},{"tag":"Text","x":634,"y":497,"text":"Running: Space pauses","color":"#ffffff","size":14,"align":"right"}]},
+ {"name":"paused-after-some-generations","args":[{"cells":[{"x":4,"y":5},{"x":5,"y":5},{"x":6,"y":5}],"running":false,"seed":1,"generation":4}],"expected":[{"tag":"Rect","x":0,"y":0,"w":640,"h":480,"color":"#111111"},{"tag":"Rect","x":65,"y":81,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":81,"y":81,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":97,"y":81,"w":14,"h":14,"color":"#7cb342"},{"tag":"Rect","x":0,"y":480,"w":640,"h":24,"color":"#222222"},{"tag":"Text","x":6,"y":497,"text":"Generation 4","color":"#ffffff","size":14,"align":"left"},{"tag":"Text","x":634,"y":497,"text":"Paused: Space runs, N steps","color":"#ffffff","size":14,"align":"right"}]}
+ ]
+}
diff --git a/fixtures/replay-sample.json b/fixtures/replay-sample.json
new file mode 100644
index 0000000..20505f2
--- /dev/null
+++ b/fixtures/replay-sample.json
@@ -0,0 +1,231 @@
+{
+ "seed": 1,
+ "actions": [
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Up"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Right"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Down"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Left"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Up"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Right"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Down"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Right"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Up"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Right"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Down"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Left"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Up"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Right"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Down"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Left"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Up"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Right"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Up"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Left"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Down"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Left"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Up"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Right"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Down"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Left"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Up"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Right"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Down"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Left"},
+ {"tag":"Tick"},
+ {"tag":"Turn","dir":"Up"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"},
+ {"tag":"Tick"}
+ ]
+}
diff --git a/fsharp/build-web.sh b/fsharp/build-web.sh
index 9088e1f..da13179 100755
--- a/fsharp/build-web.sh
+++ b/fsharp/build-web.sh
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Usage: ./build-web.sh
# Compiles src/ + web/ to JavaScript with Fable (a local dotnet tool, see .config/dotnet-tools.json) into web/dist/.
-# index.html loads the Snake entry module, pong.html the Pong one.
+# index.html loads the Snake entry module (web/dist/SnakeMain.js), pong.html the Pong one (PongMain.js), replay.html the
+# replay viewer (ReplayMain.js, m10) and life.html Conway's Life (LifeMain.js, m13).
# Then serve the repo root (node scripts/serve.mjs) and open http://localhost:8000/fsharp/index.html.
set -euo pipefail
cd "$(dirname "$0")"
diff --git a/fsharp/life.html b/fsharp/life.html
new file mode 100644
index 0000000..d40af51
--- /dev/null
+++ b/fsharp/life.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Pure Life
+
+
+
+
+ Pure Life
+
+ 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.
+
+
+
diff --git a/fsharp/replay.html b/fsharp/replay.html
new file mode 100644
index 0000000..b721a84
--- /dev/null
+++ b/fsharp/replay.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Pure Snake replay
+
+
+
+
+ Pure Snake: replay
+
+ Right / D next step, Left / A previous, Home / End first / last, Space plays (Module 10, Exercise 10.8).
+
+
+
diff --git a/fsharp/solutions/App.fs b/fsharp/solutions/App.fs
index 00d9f62..5a565e5 100644
--- a/fsharp/solutions/App.fs
+++ b/fsharp/solutions/App.fs
@@ -2,11 +2,14 @@
module Snake.App
open Snake.Model
+open Snake.View
open Snake.IO
open Snake.Stream
let TICK_MS = 150L
let FRAME_MS = 25L
+// m10: the longest step of time the game accepts at once; a longer silence (a background tab) counts as this much.
+let MAX_GAP_MS = 250L
// Everything that can happen to the game: a Tick every TICK_MS, and an action for each bound key.
let classicActions: Stream =
@@ -22,7 +25,47 @@ let states (seed: uint32) (actions: Stream) : Stream =
let render (state: GameState) : IO = IO.drawScene (View.view state)
-// The whole program as a value: get a seed, then turn the stream of states into a stream of drawings.
+// m10: a session wraps the game with the best score so far. GameState, update and view do not know about it.
+// `Improved` says whether the action that produced this session raised the best, which is when it must be saved.
+type Session = { Game: GameState; Best: int; Improved: bool }
+
+let sessionUpdate (session: Session) (action: Action) : Session =
+ let game = Game.update session.Game action
+ let best = max session.Best game.Score
+ { Game = game; Best = best; Improved = best > session.Best }
+
+// The same running fold as `states`, one level up.
+let sessions (seed: uint32) (best: int) (actions: Stream) : Stream =
+ let s0 = { Game = Spawn.newGame seed; Best = best; Improved = false }
+ actions |> Stream.scan sessionUpdate s0 |> Stream.startWith s0
+
+// The game's picture with the best score at the top right, level with the score on the left.
+let sessionView (session: Session) : Scene =
+ View.view session.Game
+ @ [ Text(session.Game.Width * CELL - 6, 18, $"Best: {session.Best}", "#ffffff", 16, "right") ]
+
+// Draw, and save the best only on the action that raised it.
+let renderSession (session: Session) : IO =
+ if session.Improved then
+ IO.sequence [ IO.drawScene (sessionView session); IO.saveBest session.Best ]
+ else
+ IO.drawScene (sessionView session)
+
+// The whole program as a value: get a seed, then the stored best, then turn the stream of sessions into a stream
+// of drawings. clampTime wraps the whole stream, so everything inside it sees the clamped clock.
// Nothing runs here; Runtime interprets it.
let main () : IO>> =
- IO.randomSeed () |> IO.map (fun seed -> states seed speedActions |> Stream.map render)
+ IO.randomSeed ()
+ |> IO.bind (fun seed ->
+ IO.loadBest ()
+ |> IO.map (fun best -> sessions seed best speedActions |> Stream.map renderSession |> Stream.clampTime MAX_GAP_MS))
+
+// m09: the whole program in one equality. For each drawing that `main` emits while fed `events`, the ops it
+// performs, answered by the pure interpreter (`seed` for RandomSeed, and from m10 `best` for LoadBest).
+let playWith (seed: uint32) (best: int) (events: Event list) (main: IO>>) : Op list list =
+ (IO.traceWith seed best main).Result
+ |> Stream.run events
+ |> List.map (fun io -> (IO.traceWith seed best io).Ops)
+
+// The Module 9 helper is the "no stored best" case.
+let play (seed: uint32) (events: Event list) (main: IO>>) : Op list list = playWith seed 0 events main
diff --git a/fsharp/solutions/Check.fs b/fsharp/solutions/Check.fs
new file mode 100644
index 0000000..9885ef6
--- /dev/null
+++ b/fsharp/solutions/Check.fs
@@ -0,0 +1,170 @@
+/// m12 check: property-based testing built from the course's own parts.
+/// A generator of test data is nothing new: it is a `Random<'a>` from Random.fs (`seed -> (value, nextSeed)`).
+module Snake.Check
+
+open Snake.Model
+open Snake.Random
+
+/// What `forAll` reports: every value passed, or the first one that did not.
+type CheckResult<'a> =
+ | Passed of runs: int
+ | Failed of run: int * seed: uint32 * value: 'a
+
+/// One of the elements of the non-empty list `xs`: the one at index `intBelow (List.length xs)`.
+let elementOf (xs: 'a list) : Random<'a> =
+ Random.intBelow (List.length xs) |> Random.map (fun i -> List.item i xs)
+
+/// `n` values drawn from `gen`, one after another; the seed is threaded from each draw to the next.
+let rec listOf (n: int) (gen: Random<'a>) : Random<'a list> =
+ if n <= 0 then
+ Random.pure []
+ else
+ gen |> Random.bind (fun a -> listOf (n - 1) gen |> Random.map (fun rest -> a :: rest))
+
+// Ticks appear three times so that the snake moves more often than it turns.
+let private actionPool: Action list =
+ [ Tick; Tick; Tick; Turn Up; Turn Down; Turn Left; Turn Right ]
+
+/// A generator of `n` random actions (given).
+let genActions (n: int) : Random = listOf n (elementOf actionPool)
+
+/// Check `prop` on `runs` values drawn from `gen`, starting from `seed`; stop at the first value that fails.
+/// Recursion carries the seed: no counter, no loop variable. `go` calls itself last, so it runs in constant stack.
+let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a> =
+ let rec go (i: int) (s: uint32) : CheckResult<'a> =
+ if i >= runs then
+ Passed runs
+ else
+ let value, next = Random.run s gen
+ if prop value then go (i + 1) next else Failed(i, s, value)
+
+ go 0 seed
+
+/// Every list that is `xs` with exactly one element removed, in the order of the removed index.
+let removals (xs: 'a list) : 'a list list = xs |> List.mapi (fun i _ -> List.removeAt i xs)
+
+/// Keep removing one element for as long as the list still fails; the result is 1-minimal.
+let rec minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list =
+ match List.tryFind fails (removals xs) with
+ | Some smaller -> minimize fails smaller
+ | None -> xs
+
+let private neighbours (a: Point, b: Point) : bool = abs (a.X - b.X) + abs (a.Y - b.Y) = 1
+
+let rec private allDifferent (ps: Point list) : bool =
+ match ps with
+ | [] -> true
+ | p :: rest -> not (Util.contains rest p (=)) && allDifferent rest
+
+// Each invariant: its name, and a predicate that is true when the state is fine.
+// `Option.forall` is true for `None`: no apple breaks nothing.
+let private invariants: (string * (GameState -> bool)) list =
+ [ "snake-not-empty", (fun s -> not (List.isEmpty s.Snake))
+ "snake-in-bounds", (fun s -> s.Snake |> List.forall (Rules.inBounds s))
+ "snake-no-overlap", (fun s -> allDifferent s.Snake)
+ "snake-connected", (fun s -> s.Snake |> List.pairwise |> List.forall neighbours)
+ "apple-in-bounds", (fun s -> s.Apple |> Option.forall (Rules.inBounds s))
+ "apple-not-on-snake", (fun s -> s.Apple |> Option.forall (fun a -> not (Util.contains s.Snake a (=))))
+ "turn-not-reversed", (fun s -> s.NextDir <> opposite s.Dir)
+ "queued-not-reversed", (fun s -> s.Queued |> Option.forall (fun q -> q <> opposite s.NextDir)) ]
+
+/// The names of the invariants that `state` breaks, in the order of the table above.
+let violations (state: GameState) : string list =
+ invariants |> List.filter (fun (_, holds) -> not (holds state)) |> List.map fst
+
+/// The numbers of states between consecutive moves of the snake's head.
+let moveGaps (states: GameState list) : int list =
+ let moved =
+ states
+ |> List.pairwise
+ |> List.indexed
+ |> List.filter (fun (_, (before, after)) -> List.tryHead after.Snake <> List.tryHead before.Snake)
+ |> List.map (fun (i, _) -> i + 1)
+
+ moved |> List.pairwise |> List.map (fun (a, b) -> b - a)
+
+// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
+
+let private directions: Direction list = [ Up; Down; Left; Right ]
+
+// The direction the head moved in to get from the second cell to the first (`Right` for a one-cell snake).
+let private headingOf (snake: Point list) : Direction =
+ match snake with
+ | head :: second :: _ -> directions |> List.find (fun d -> Rules.move second d = head)
+ | _ -> Right
+
+/// A random snake, head first: a random head, then a random walk that never leaves the board or crosses itself.
+let genSnake (width: int) (height: int) (maxLen: int) : Random =
+ // Extend `snake` at its tail by up to `left` more cells, each a random free in-bounds neighbour of the last cell.
+ let rec grow (snake: Point list) (left: int) : Random =
+ if left <= 0 then
+ Random.pure snake
+ else
+ let last = List.last snake
+
+ let options =
+ directions
+ |> List.map (Rules.move last)
+ |> List.filter (fun p ->
+ p.X >= 0 && p.X < width && p.Y >= 0 && p.Y < height && not (Util.contains snake p (=)))
+
+ if List.isEmpty options then
+ Random.pure snake
+ else
+ elementOf options |> Random.bind (fun p -> grow (snake @ [ p ]) (left - 1))
+
+ Random.intBelow width
+ |> Random.bind (fun x -> Random.intBelow height |> Random.bind (fun y -> grow [ { X = x; Y = y } ] (maxLen - 1)))
+
+/// A random sane Playing state on a `width` x `height` board (given).
+let genState (width: int) (height: int) : Random =
+ Random.intBelow (width * height)
+ |> Random.bind (fun n ->
+ genSnake width height (n + 1)
+ |> Random.bind (fun snake ->
+ let dir = headingOf snake
+
+ let state =
+ { Width = width
+ Height = height
+ Snake = snake
+ Dir = dir
+ NextDir = dir
+ Apple = None
+ Seed = 0u
+ Score = 0
+ Status = Playing
+ Frame = 0
+ Queued = None }
+
+ let free = Spawn.freeCells state
+
+ elementOf (directions |> List.filter (fun d -> d <> opposite dir))
+ |> Random.bind (fun nextDir ->
+ Random.intBelow 2
+ |> Random.bind (fun coin ->
+ (if coin = 0 || List.isEmpty free then Random.pure None else elementOf free |> Random.map Some)
+ |> Random.map (fun apple -> { state with NextDir = nextDir; Apple = apple })))))
+
+/// The step of m03, written from the rules instead of from `advance`: build the snake the step would give,
+/// then ask `violations` whether that snake is on the board and does not overlap itself.
+let advanceSpec (state: GameState) : GameState =
+ if state.Status <> Playing then
+ state
+ else
+ let head = Rules.move (List.head state.Snake) state.NextDir
+ let eating = state.Apple = Some head
+ let snake = head :: (if eating then state.Snake else Util.dropLast state.Snake)
+
+ let broken =
+ violations { state with Snake = snake }
+ |> List.exists (fun v -> v = "snake-in-bounds" || v = "snake-no-overlap")
+
+ if broken then
+ { state with Dir = state.NextDir; Status = GameOver }
+ else
+ { state with
+ Snake = snake
+ Dir = state.NextDir
+ Apple = if eating then None else state.Apple
+ Score = state.Score + (if eating then 1 else 0) }
diff --git a/fsharp/solutions/Game.fs b/fsharp/solutions/Game.fs
index 627c439..7fa1ce4 100644
--- a/fsharp/solutions/Game.fs
+++ b/fsharp/solutions/Game.fs
@@ -1,19 +1,40 @@
/// m05 game: one `update` turns every action into a new state; replaying a game is a fold over its actions.
-/// m10 adds pause, restart and a frame clock that speeds up as the score grows.
+/// m10 adds pause, restart, a frame counter that makes the snake speed up as the score grows, and a turn queue.
module Snake.Game
open Snake.Model
+/// What `update` does with `Turn dir` from m10 on. With no turn pending (`NextDir = Dir`) it is the m03 `Rules.turn`.
+/// With a turn pending, a key that is neither `NextDir` nor its opposite is remembered in `Queued`
+/// (replacing an earlier one) and taken on the step after; any other key changes nothing.
+let steer (state: GameState) (dir: Direction) : GameState =
+ if state.Status <> Playing then state
+ elif state.NextDir = state.Dir then Rules.turn state dir
+ elif dir <> state.NextDir && dir <> opposite state.NextDir then { state with Queued = Some dir }
+ else state
+
+// After a step: a game still in play takes the queued turn, if there is one. It is always a legal turn, because the
+// step just made `Dir` equal to the old `NextDir`, and `Queued` is neither that nor its opposite.
+let private takeQueued (state: GameState) : GameState =
+ match state.Queued with
+ | Some d when state.Status = Playing -> { state with NextDir = d; Queued = None }
+ | _ -> state
+
+// One game step as `update` takes it: `Spawn.tick`, then the queued turn.
+let private step (state: GameState) : GameState = takeQueued (Spawn.tick state)
+
/// How many frames pass between game steps: 8 at first, one fewer every 3 points, never fewer than 2.
let movesEvery (score: int) : int = max 2 (8 - score / 3)
-// One animation frame: count it, and every `movesEvery score`-th frame run a game step.
+// One animation frame. `Frame` counts the frames since the snake last moved; when it reaches
+// `movesEvery Score` the count starts again and the game takes a step. Counting from the last move
+// (not from the start of the game) keeps every step exactly `movesEvery` frames long, also right after a speed-up.
let private frame (state: GameState) : GameState =
if state.Status <> Playing then
state
else
- let counted = { state with Frame = state.Frame + 1 }
- if counted.Frame % movesEvery counted.Score = 0 then Spawn.tick counted else counted
+ let n = state.Frame + 1
+ if n >= movesEvery state.Score then step { state with Frame = 0 } else { state with Frame = n }
// Pause and resume only make sense in those two statuses; a finished game stays finished.
let private togglePause (state: GameState) : GameState =
@@ -26,8 +47,8 @@ let private togglePause (state: GameState) : GameState =
// Every case is listed (no `| _ ->`): adding an Action later makes the compiler point here.
let update (state: GameState) (action: Action) : GameState =
match action with
- | Tick -> Spawn.tick state
- | Turn dir -> Rules.turn state dir
+ | Tick -> step state
+ | Turn dir -> steer state dir
| TogglePause -> togglePause state
| Restart -> Spawn.newGame state.Seed
| Frame -> frame state
diff --git a/fsharp/solutions/IO.fs b/fsharp/solutions/IO.fs
index 2d92bd9..58b8cd8 100644
--- a/fsharp/solutions/IO.fs
+++ b/fsharp/solutions/IO.fs
@@ -9,11 +9,16 @@ open Snake.View
type Op =
| Draw of Shape
| RandomSeed
+ // m10: the stored best score, read and written.
+ | LoadBest
+ | SaveBest of int
-/// What the outside world answers: `Done` for a `Draw`, `Seed value` for a `RandomSeed`.
+/// What the outside world answers: `Done` for a `Draw` or a `SaveBest`, `Seed value` for a `RandomSeed`,
+/// `Best value` for a `LoadBest`.
type OpResult =
| Done
| Seed of uint32
+ | Best of int
type IO<'a> =
| Pure of 'a
@@ -47,13 +52,24 @@ let randomSeed () : IO =
|> map (fun result ->
match result with
| Seed value -> value
- | Done -> failwith "RandomSeed must be answered with a Seed")
+ | _ -> failwith "RandomSeed must be answered with a Seed")
let drawScene (scene: Scene) : IO = sequence (List.map draw scene)
+// m10: the stored best score. Reading it answers a number (built like randomSeed); saving answers Done, like a draw.
+let loadBest () : IO =
+ effect LoadBest
+ |> map (fun result ->
+ match result with
+ | Best value -> value
+ | _ -> failwith "LoadBest must be answered with a Best")
+
+let saveBest (score: int) : IO = effect (SaveBest score) |> map ignore
+
// Answer each op with a canned result, collecting the ops as we go. Tail recursion: the compiler turns it
// into a loop, so long programs cannot overflow the stack, and still nothing is mutated.
-let trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
+// `best` (m10) answers LoadBest, the way `seed` answers RandomSeed.
+let traceWith (seed: uint32) (best: int) (io: IO<'a>) : Traced<'a> =
let rec go io performed =
match io with
| Pure a -> { Result = a; Ops = List.rev performed }
@@ -61,8 +77,13 @@ let trace (seed: uint32) (io: IO<'a>) : Traced<'a> =
let answer =
match op with
| RandomSeed -> Seed seed
- | Draw _ -> Done
+ | LoadBest -> Best best
+ | Draw _
+ | SaveBest _ -> Done
go (k answer) (op :: performed)
go io []
+
+// The Module 7 interpreter is the "no stored best" case.
+let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = traceWith seed 0 io
diff --git a/fsharp/solutions/Life/App.fs b/fsharp/solutions/Life/App.fs
new file mode 100644
index 0000000..f8cf313
--- /dev/null
+++ b/fsharp/solutions/Life/App.fs
@@ -0,0 +1,44 @@
+/// m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse.
+module Snake.Life.App
+
+open Snake
+open Snake.Model
+open Snake.IO
+open Snake.Stream
+open Snake.Life.Model
+open Snake.Life.Rules
+open Snake.Life.View
+
+let GEN_MS = 150L
+
+let lifeKey (key: string) : LifeAction option =
+ match key with
+ | " " -> Some LifeAction.TogglePause
+ | "n" | "N" -> Some LifeAction.Step
+ | "c" | "C" -> Some LifeAction.Clear
+ | "r" | "R" -> Some LifeAction.Randomize
+ | _ -> None
+
+// Integer division rounds towards zero (-1 / 16 = 0), so the pixel is checked before dividing.
+let lifeClick (x: int) (y: int) : LifeAction option =
+ if x >= 0 && y >= 0 && x / LCELL < LW && y / LCELL < LH then
+ Some(LifeAction.Toggle { X = x / LCELL; Y = y / LCELL })
+ else
+ None
+
+let lifeActions: Stream =
+ Stream.merge
+ (Stream.merge
+ (Stream.interval GEN_MS |> Stream.map (fun _ -> LifeAction.Frame))
+ (Stream.keyDowns |> Stream.choose lifeKey))
+ (Stream.clicks |> Stream.choose (fun (x, y) -> lifeClick x y))
+
+let lifeStates (seed: uint32) (actions: Stream) : Stream =
+ actions |> Stream.scan lifeUpdate (initialLife seed) |> Stream.startWith (initialLife seed)
+
+let lifeMain () : IO>> =
+ IO.randomSeed ()
+ |> IO.map (fun seed ->
+ lifeStates seed lifeActions
+ |> Stream.map (fun state -> IO.drawScene (lifeView state))
+ |> Stream.clampTime App.MAX_GAP_MS)
diff --git a/fsharp/solutions/Life/Model.fs b/fsharp/solutions/Life/Model.fs
new file mode 100644
index 0000000..4ca8bca
--- /dev/null
+++ b/fsharp/solutions/Life/Model.fs
@@ -0,0 +1,32 @@
+/// m13 life model: the whole world is one value, like the snake and the match.
+module Snake.Life.Model
+
+open Snake.Model
+
+/// `Cells` is canonical: sorted row-major (by `Y`, then `X`), no duplicates, all on the board.
+type LifeState =
+ { Cells: Point list
+ Running: bool
+ Seed: uint32
+ Generation: int }
+
+[]
+type LifeAction =
+ | Frame
+ | Toggle of Point
+ | TogglePause
+ | Step
+ | Clear
+ | Randomize
+
+// The board, in cells: LW columns, LH rows, LCELL pixels per cell. Below it the page shows a status strip.
+let LW = 40
+let LH = 30
+let LCELL = 16
+let STRIP = 24
+
+let initialLife (seed: uint32) : LifeState =
+ { Cells = []
+ Running = false
+ Seed = seed
+ Generation = 0 }
diff --git a/fsharp/solutions/Life/Rules.fs b/fsharp/solutions/Life/Rules.fs
new file mode 100644
index 0000000..a6362c1
--- /dev/null
+++ b/fsharp/solutions/Life/Rules.fs
@@ -0,0 +1,71 @@
+/// m13 life rules: the next generation is a function of the whole old board, and nothing else.
+module Snake.Life.Rules
+
+open Snake
+open Snake.Model
+open Snake.Life.Model
+
+// Every cell of the board, row-major (y, then x). Filtering this list keeps any result in canonical order for free.
+let private board: Point list =
+ [ for y in 0 .. LH - 1 do
+ for x in 0 .. LW - 1 -> { X = x; Y = y } ]
+
+let private onBoard (p: Point) : bool = p.X >= 0 && p.X < LW && p.Y >= 0 && p.Y < LH
+
+// A cell's position in row-major order: the order the canonical list is sorted by.
+let private index (p: Point) : int = p.Y * LW + p.X
+
+let private offsets = [ (-1, -1); (0, -1); (1, -1); (-1, 0); (1, 0); (-1, 1); (0, 1); (1, 1) ]
+
+// The up to eight neighbours of `p` that are on the board (the edges are dead: nothing wraps around).
+let private neighbours (p: Point) : Point list =
+ offsets |> List.map (fun (dx, dy) -> { X = p.X + dx; Y = p.Y + dy }) |> List.filter onBoard
+
+// B3/S23, every cell judged on the *old* list only. A Set of the old cells makes "is this neighbour alive?" one
+// lookup, so a generation is 1200 x 8 lookups however many cells live. The set is built once and only read.
+let lifeStep (cells: Point list) : Point list =
+ let alive = Set.ofList cells
+ let count p = neighbours p |> List.filter alive.Contains |> List.length
+
+ board
+ |> List.filter (fun p ->
+ let n = count p
+ n = 3 || (n = 2 && alive.Contains p))
+
+// Add a dead cell or remove a live one, keeping the list sorted row-major without duplicates. Off the board: unchanged.
+let private toggle (cells: Point list) (p: Point) : Point list =
+ if not (onBoard p) then
+ cells
+ elif List.contains p cells then
+ List.filter (fun c -> c <> p) cells
+ else
+ List.filter (fun c -> index c < index p) cells
+ @ [ p ]
+ @ List.filter (fun c -> index c > index p) cells
+
+// A random board: one `intBelow 4` per cell in row-major order, the cell alive when the draw is 0 (about one cell in
+// four). mapFold threads the seed through the 1200 draws; the result is the cells and the seed after the last draw.
+let private randomCells (seed: uint32) : Point list * uint32 =
+ let draws, next = List.mapFold (fun s _ -> Random.run s (Random.intBelow 4)) seed board
+ List.zip board draws |> List.filter (fun (_, d) -> d = 0) |> List.map fst, next
+
+let private nextGeneration (state: LifeState) : LifeState =
+ { state with
+ Cells = lifeStep state.Cells
+ Generation = state.Generation + 1 }
+
+let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState =
+ match action with
+ | LifeAction.Frame -> if state.Running then nextGeneration state else state
+ | LifeAction.Step -> if state.Running then state else nextGeneration state
+ | LifeAction.TogglePause -> { state with Running = not state.Running }
+ // Clicking edits the world whether it runs or not: the click is one action between two generations.
+ | LifeAction.Toggle p -> { state with Cells = toggle state.Cells p }
+ | LifeAction.Clear -> { state with Cells = []; Generation = 0 }
+ | LifeAction.Randomize ->
+ let cells, seed = randomCells state.Seed
+
+ { state with
+ Cells = cells
+ Seed = seed
+ Generation = 0 }
diff --git a/fsharp/solutions/Life/View.fs b/fsharp/solutions/Life/View.fs
new file mode 100644
index 0000000..cb2756b
--- /dev/null
+++ b/fsharp/solutions/Life/View.fs
@@ -0,0 +1,17 @@
+/// m13 life view: live cells as squares, and a status strip under the board.
+module Snake.Life.View
+
+open Snake.View
+open Snake.Life.Model
+
+let private text x y str align = Text(x, y, str, "#ffffff", 14, align)
+
+let private W = LW * LCELL
+let private H = LH * LCELL
+
+let lifeView (state: LifeState) : Scene =
+ [ Rect(0, 0, W, H, "#111111") ]
+ @ (state.Cells |> List.map (fun p -> Rect(p.X * LCELL + 1, p.Y * LCELL + 1, LCELL - 2, LCELL - 2, "#7cb342")))
+ @ [ Rect(0, H, W, STRIP, "#222222")
+ text 6 (H + 17) $"Generation {state.Generation}" "left"
+ text (W - 6) (H + 17) (if state.Running then "Running: Space pauses" else "Paused: Space runs, N steps") "right" ]
diff --git a/fsharp/solutions/Model.fs b/fsharp/solutions/Model.fs
index 4f018e5..e5c04e8 100644
--- a/fsharp/solutions/Model.fs
+++ b/fsharp/solutions/Model.fs
@@ -25,6 +25,7 @@ type Action =
| Frame
/// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed.
+/// `Frame` and `Queued` stay unused until m10 (`Queued` holds a turn waiting for the next step).
type GameState =
{ Width: int
Height: int
@@ -35,7 +36,8 @@ type GameState =
Seed: uint32
Score: int
Status: Status
- Frame: int }
+ Frame: int
+ Queued: Direction option }
let delta (dir: Direction) : Point =
match dir with
@@ -61,4 +63,5 @@ let initialState (seed: uint32) : GameState =
Seed = seed
Score = 0
Status = Playing
- Frame = 0 }
+ Frame = 0
+ Queued = None }
diff --git a/fsharp/solutions/Pong/App.fs b/fsharp/solutions/Pong/App.fs
index 51657f7..b9026d1 100644
--- a/fsharp/solutions/Pong/App.fs
+++ b/fsharp/solutions/Pong/App.fs
@@ -33,4 +33,7 @@ let pongStates (seed: uint32) (actions: Stream) : Stream
let pongMain () : IO>> =
IO.randomSeed ()
- |> IO.map (fun seed -> pongStates seed pongActions |> Stream.map (fun state -> IO.drawScene (pongView state)))
+ |> IO.map (fun seed ->
+ pongStates seed pongActions
+ |> Stream.map (fun state -> IO.drawScene (pongView state))
+ |> Stream.clampTime App.MAX_GAP_MS)
diff --git a/fsharp/solutions/Pong/Rules.fs b/fsharp/solutions/Pong/Rules.fs
index b6e6b3a..2d42071 100644
--- a/fsharp/solutions/Pong/Rules.fs
+++ b/fsharp/solutions/Pong/Rules.fs
@@ -89,5 +89,6 @@ let pongUpdate (state: PongState) (action: PongAction) : PongState =
match action with
| PongAction.Press key -> setHeld state key true
| PongAction.Release key -> setHeld state key false
- | PongAction.Restart -> initialPong state.Seed
+ // A new match, but the keys are still where the player's fingers are.
+ | PongAction.Restart -> { initialPong state.Seed with Held = state.Held }
| PongAction.Frame -> frame state
diff --git a/fsharp/solutions/Replay.fs b/fsharp/solutions/Replay.fs
new file mode 100644
index 0000000..3d608b9
--- /dev/null
+++ b/fsharp/solutions/Replay.fs
@@ -0,0 +1,96 @@
+/// m10 replay viewer: a recorded game is a seed and a list of actions; watching it is a second, tiny program
+/// whose state is only a position in the list of the game's states.
+module Snake.Replay
+
+open Snake.Model
+open Snake.View
+open Snake.IO
+open Snake.Stream
+
+type Recording = { Seed: uint32; Actions: Action list }
+
+type Viewer = { Index: int; Last: int; Playing: bool }
+
+[]
+type ViewerAction =
+ | Next
+ | Prev
+ | First
+ | Last
+ | TogglePlay
+ | Beat
+
+let REPLAY_MS = 100L
+
+// Every state of the recorded game, from the new game to the end: the recording replayed with `history`.
+let frames (recording: Recording) : GameState list =
+ Game.history (Spawn.newGame recording.Seed) recording.Actions
+
+let viewerStart (last: int) : Viewer = { Index = 0; Last = last; Playing = false }
+
+// Where each action moves the viewer, and whether it plays afterwards (before the end-of-recording rule).
+let private move (v: Viewer) (action: ViewerAction) : Viewer =
+ let clamp i = min v.Last (max 0 i)
+
+ match action with
+ | ViewerAction.Next -> { v with Index = clamp (v.Index + 1) }
+ | ViewerAction.Prev -> { v with Index = clamp (v.Index - 1) }
+ | ViewerAction.First -> { v with Index = 0 }
+ | ViewerAction.Last -> { v with Index = v.Last }
+ // Play from the end means: watch it again from the start.
+ | ViewerAction.TogglePlay ->
+ if v.Playing then
+ { v with Playing = false }
+ else
+ { v with
+ Index = (if v.Index = v.Last then 0 else v.Index)
+ Playing = true }
+ | ViewerAction.Beat -> if v.Playing then { v with Index = clamp (v.Index + 1) } else v
+
+// The index never leaves [0, Last], and the viewer never plays on the last frame, so autoplay stops by itself.
+let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer =
+ let next = move viewer action
+ { next with Playing = next.Playing && next.Index < next.Last }
+
+let viewerKey (key: string) : ViewerAction option =
+ match key with
+ | "ArrowRight" | "d" | "D" -> Some ViewerAction.Next
+ | "ArrowLeft" | "a" | "A" -> Some ViewerAction.Prev
+ | "Home" -> Some ViewerAction.First
+ | "End" -> Some ViewerAction.Last
+ | " " -> Some ViewerAction.TogglePlay
+ | _ -> None
+
+// The game's own picture of the current frame, then the position at the top right and a hint at the bottom.
+let viewerView (frames: GameState list) (viewer: Viewer) : Scene =
+ let game = List.item viewer.Index frames
+ let w = game.Width * CELL
+
+ let hint =
+ if viewer.Playing then
+ "Space: pause"
+ else
+ "Left/Right: step Home/End: jump Space: play"
+
+ View.view game
+ @ [ Text(w - 6, 18, $"step {viewer.Index} / {viewer.Last}", "#ffffff", 16, "right")
+ Text(w / 2, game.Height * CELL - 8, hint, "#aaaaaa", 12, "center") ]
+
+let replayActions: Stream =
+ Stream.merge
+ (Stream.interval REPLAY_MS |> Stream.map (fun _ -> ViewerAction.Beat))
+ (Stream.keyDowns |> Stream.choose viewerKey)
+
+let viewerStates (last: int) (actions: Stream) : Stream =
+ actions |> Stream.scan viewerUpdate (viewerStart last) |> Stream.startWith (viewerStart last)
+
+// It needs no seed from the world (the recording has one), so it starts with `pure`.
+// The frames are computed once, here, and every drawing picks one of them.
+let replayMain (recording: Recording) : IO>> =
+ let fs = frames recording
+
+ IO.pure (
+ viewerStates (List.length fs - 1) replayActions
+ |> Stream.map (fun v -> IO.drawScene (viewerView fs v))
+ |> Stream.clampTime App.MAX_GAP_MS
+ )
diff --git a/fsharp/solutions/Runtime.fs b/fsharp/solutions/Runtime.fs
index 896b11e..f73fea1 100644
--- a/fsharp/solutions/Runtime.fs
+++ b/fsharp/solutions/Runtime.fs
@@ -11,14 +11,23 @@ type Platform =
{ FillRect: int -> int -> int -> int -> string -> unit
FillText: int -> int -> string -> string -> int -> string -> unit
RandomSeed: unit -> uint32
+ // m10: the stored best score; reading must never fail (anything unreadable is 0).
+ LoadBest: unit -> int
+ SaveBest: int -> unit
StartClock: (int64 -> unit) -> unit
OnKeyDown: (string -> unit) -> unit
- OnKeyUp: (string -> unit) -> unit }
+ OnKeyUp: (string -> unit) -> unit
+ // m13: clicks on the canvas, in canvas pixels.
+ OnClick: (int -> int -> unit) -> unit }
// Perform one op on the platform and return its answer.
let private perform (p: Platform) (op: Op) : OpResult =
match op with
| RandomSeed -> Seed(p.RandomSeed())
+ | LoadBest -> Best(p.LoadBest())
+ | SaveBest score ->
+ p.SaveBest score
+ Done
| Draw(Rect(x, y, w, h, color)) ->
p.FillRect x y w h color
Done
@@ -43,4 +52,6 @@ let runApp (p: Platform) (main: IO>>) : unit =
p.OnKeyDown(fun key -> feed (KeyDown key))
p.OnKeyUp(fun key -> feed (KeyUp key))
+ // m13: a fourth kind of event. Programs that do not listen for clicks simply ignore them.
+ p.OnClick(fun x y -> feed (Click(x, y)))
p.StartClock(fun t -> feed (Time t))
diff --git a/fsharp/solutions/SafeIO.fs b/fsharp/solutions/SafeIO.fs
new file mode 100644
index 0000000..457652c
--- /dev/null
+++ b/fsharp/solutions/SafeIO.fs
@@ -0,0 +1,65 @@
+/// m07b safeio (optional): a stack-safe IO. Same idea as m07's IO, plus one more case, so that `bind` only builds
+/// data and no program shape can overflow the stack, however deeply its `bind`s are nested.
+/// - `Pure a` done, with a value
+/// - `Effect (op, k)` perform `op`, then continue with `k result`
+/// - `FlatMap (io, f)` run `io`, then continue with `f result`
+/// The ops, their answers and the result of `trace` are m07's `IO.Op`, `IO.OpResult` and `IO.Traced`.
+/// (Not `open Snake.IO`: its cases are also called Pure and Effect.)
+module Snake.SafeIO
+
+/// `FlatMap`'s inner program produces some type `X` that only its continuation knows. A union case cannot have a
+/// type parameter of its own, so `X` is erased to `obj`; only `bind` builds a `FlatMap`, and it keeps the two in step.
+type SafeIO<'a> =
+ | Pure of 'a
+ | Effect of IO.Op * (IO.OpResult -> SafeIO<'a>)
+ | FlatMap of SafeIO * (obj -> SafeIO<'a>)
+
+// Given: the same program, seen as a SafeIO. Constant work: continuations are wrapped, not called.
+let rec private erase (io: SafeIO<'a>) : SafeIO =
+ match io with
+ | Pure a -> Pure(box a)
+ | Effect(op, k) -> Effect(op, fun r -> erase (k r))
+ | FlatMap(m, g) -> FlatMap(m, fun x -> erase (g x))
+
+let pure (a: 'a) : SafeIO<'a> = Pure a
+
+let effect (op: IO.Op) : SafeIO = Effect(op, pure)
+
+// Only builds a node: never calls f, not even when io is Pure. The unbox cannot fail: x is the result of `io`.
+let bind (f: 'a -> SafeIO<'b>) (io: SafeIO<'a>) : SafeIO<'b> = FlatMap(erase io, fun x -> f (unbox<'a> x))
+
+let map (f: 'a -> 'b) (io: SafeIO<'a>) : SafeIO<'b> = io |> bind (fun a -> Pure(f a))
+
+// A left fold on purpose: with SafeIO the nesting no longer matters.
+let sequence (ios: SafeIO list) : SafeIO = List.fold (fun acc io -> acc |> bind (fun () -> io)) (pure ()) ios
+
+// One rewrite towards a program whose top is a Pure or an Effect. Constant work, no recursion.
+// FlatMap(Pure a, f) -> f a
+// FlatMap(Effect(op, k), f) -> Effect(op, fun r -> FlatMap(k r, f))
+// FlatMap(FlatMap(m, g), f) -> FlatMap(m, fun x -> FlatMap(g x, f)) (re-association)
+// Pure and Effect are already in that shape and come back unchanged.
+let step (io: SafeIO<'a>) : SafeIO<'a> =
+ match io with
+ | FlatMap(Pure x, f) -> f x
+ | FlatMap(Effect(op, k), f) -> Effect(op, fun r -> FlatMap(k r, f))
+ | FlatMap(FlatMap(m, g), f) -> FlatMap(m, fun x -> FlatMap(g x, f))
+ | other -> other
+
+// Given: the canned answer of the pure interpreter, as in m07. SafeIO keeps Module 7's two operations; the stored
+// best score of Module 10 is not part of it, so its operations are refused loudly rather than given a meaning here.
+let private answer (seed: uint32) (op: IO.Op) : IO.OpResult =
+ match op with
+ | IO.RandomSeed -> IO.Seed seed
+ | IO.Draw _ -> IO.Done
+ | IO.LoadBest
+ | IO.SaveBest _ -> failwith "SafeIO.trace answers only Module 7's operations (Draw and RandomSeed)"
+
+// `loop` calls itself only in tail position, so the compiler turns it into a loop: the stack stays flat.
+let trace (seed: uint32) (io: SafeIO<'a>) : IO.Traced<'a> =
+ let rec loop (current: SafeIO<'a>) (performed: IO.Op list) : IO.Traced<'a> =
+ match current with
+ | Pure a -> { IO.Result = a; IO.Ops = List.rev performed }
+ | Effect(op, k) -> loop (k (answer seed op)) (op :: performed)
+ | FlatMap _ -> loop (step current) performed
+
+ loop io []
diff --git a/fsharp/solutions/Snake.fsproj b/fsharp/solutions/Snake.fsproj
index 8aa517e..470c7e9 100644
--- a/fsharp/solutions/Snake.fsproj
+++ b/fsharp/solutions/Snake.fsproj
@@ -10,17 +10,25 @@
+
+
+
+
+
+
+
+
diff --git a/fsharp/solutions/State.fs b/fsharp/solutions/State.fs
new file mode 100644
index 0000000..b28ed9b
--- /dev/null
+++ b/fsharp/solutions/State.fs
@@ -0,0 +1,27 @@
+/// m04 state (optional, Exercise 4.7): the general pattern behind Random. A `State<'s, 'a>` wraps a function
+/// `s -> (value, nextS)`: it reads a state, produces a value and hands on a new state. `Random<'a>` is
+/// `State`. Nothing in the game uses this file; it is here to show that Random was one example of a bigger idea.
+module Snake.State
+
+type State<'s, 'a> = State of ('s -> 'a * 's)
+
+let run (s: 's) (State f: State<'s, 'a>) : 'a * 's = f s
+
+let pure (a: 'a) : State<'s, 'a> = State(fun s -> (a, s))
+
+let map (f: 'a -> 'b) (st: State<'s, 'a>) : State<'s, 'b> =
+ State(fun s ->
+ let a, next = run s st
+ (f a, next))
+
+let bind (f: 'a -> State<'s, 'b>) (st: State<'s, 'a>) : State<'s, 'b> =
+ State(fun s ->
+ let a, next = run s st
+ run next (f a))
+
+// A function of unit: a generic module-level value is not allowed in F#.
+let get () : State<'s, 's> = State(fun s -> (s, s))
+
+let put (s: 's) : State<'s, unit> = State(fun _ -> ((), s))
+
+let modify (f: 's -> 's) : State<'s, unit> = State(fun s -> ((), f s))
diff --git a/fsharp/solutions/Stream.fs b/fsharp/solutions/Stream.fs
index 48c3305..a546760 100644
--- a/fsharp/solutions/Stream.fs
+++ b/fsharp/solutions/Stream.fs
@@ -2,11 +2,13 @@
/// There is no hidden state: whatever a stream "remembers" lives in the next stream it returns.
module Snake.Stream
-/// Something that happens to the program: time passing (ms since start) or a key going down or up.
+/// Something that happens to the program: time passing (ms since start) or a key going down or up, and from
+/// Module 13 a mouse click at `(x, y)` in canvas pixels. Every stream ignores the kinds of event it does not use.
type Event =
| Time of int64
| KeyDown of string
| KeyUp of string
+ | Click of int * int
type Stream<'a> = Stream of (Event -> 'a list * Stream<'a>)
@@ -31,6 +33,16 @@ let keyUps: Stream =
source ()
+// (m13, given in src/) The position of every Click event, in canvas pixels. Like the key sources it remembers nothing.
+let clicks: Stream =
+ let rec source () =
+ Stream(fun e ->
+ match e with
+ | Click(x, y) -> [ (x, y) ], source ()
+ | _ -> [], source ())
+
+ source ()
+
// `last` is the time of the previous Time event. Multiples of ms in (last, t] fire, ascending.
let rec private intervalFrom (ms: int64) (last: int64) : Stream =
Stream(fun e ->
@@ -76,6 +88,23 @@ let startWith (v: 'a) (s: Stream<'a>) : Stream<'a> =
let out, next = step e s
v :: out, next)
+// (m10) `last` is the previous real time, `clock` the time the inner stream has been told. A long silence
+// (a background tab) moves `clock` by at most `maxGap`, so the inner stream never has to catch up on minutes of beats.
+let rec private clampFrom (maxGap: int64) (last: int64) (clock: int64) (s: Stream<'a>) : Stream<'a> =
+ Stream(fun e ->
+ match e with
+ | Time t ->
+ let now = clock + min (max (t - last) 0L) maxGap
+ let out, next = step (Time now) s
+ out, clampFrom maxGap t now next
+ | KeyDown _
+ | KeyUp _
+ | Click _ ->
+ let out, next = step e s
+ out, clampFrom maxGap last clock next)
+
+let clampTime (maxGap: int64) (s: Stream<'a>) : Stream<'a> = clampFrom maxGap 0L 0L s
+
// mapFold threads the stream through the events like fold, and keeps each step's outputs.
let run (events: Event list) (s: Stream<'a>) : 'a list =
List.mapFold (fun stream e -> step e stream) s events |> fst |> List.concat
diff --git a/fsharp/src/App.fs b/fsharp/src/App.fs
index 6311886..eb22fea 100644
--- a/fsharp/src/App.fs
+++ b/fsharp/src/App.fs
@@ -2,6 +2,7 @@
module Snake.App
open Snake.Model
+open Snake.View
open Snake.IO
open Snake.Stream
@@ -11,6 +12,9 @@ let TICK_MS = 150L
/// Milliseconds between animation frames (m10).
let FRAME_MS = 25L
+/// (m10) The longest step of time the game accepts at once, in milliseconds (see `clampTime` in Stream.fs).
+let MAX_GAP_MS = 250L
+
/// Stream of everything that can happen to the game: `Tick` every TICK_MS (from `Stream.interval`),
/// merged with the action for each key press that `Input.keyToAction` recognises (from `Stream.keyDowns`).
/// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.)
@@ -27,7 +31,40 @@ let states (seed: uint32) (actions: Stream) : Stream = failwi
/// The drawing of one state, as an IO: `IO.drawScene (View.view state)`.
let render (state: GameState) : IO = failwith "TODO m09"
+/// (m10) A session: the game, the best score so far, and whether the action that produced this session raised the best.
+type Session = { Game: GameState; Best: int; Improved: bool }
+
+/// (m10) One action: `game' = Game.update session.Game action`, `best' = max session.Best game'.Score`,
+/// `improved = best' > session.Best` (strictly greater: equalling the record is not a new record).
+let sessionUpdate (session: Session) (action: Action) : Session = failwith "TODO m10"
+
+/// (m10) Like `states`, one level up: scan with `sessionUpdate` from `s0`, and emit `s0` first (startWith),
+/// with `s0 = { Game = Spawn.newGame seed; Best = best; Improved = false }`.
+let sessions (seed: uint32) (best: int) (actions: Stream) : Stream = failwith "TODO m10"
+
+/// (m10) `View.view session.Game` followed by one more shape: the text `Best: ` at the top right,
+/// `Text(game.Width * CELL - 6, 18, "Best: 3", "#ffffff", 16, "right")`.
+let sessionView (session: Session) : Scene = failwith "TODO m10"
+
+/// (m10) `IO.drawScene (sessionView session)`, followed by `IO.saveBest session.Best` when `session.Improved`
+/// (and only then).
+let renderSession (session: Session) : IO = failwith "TODO m10"
+
/// The whole program as a value: 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`). Nothing runs here.
-/// A function of `unit`, like `IO.randomSeed`.
+/// in m09, from `speedActions` after m10) into a stream of drawings (`render`).
+/// (m10) The whole stream is wrapped in `Stream.clampTime MAX_GAP_MS`.
+/// (m10, Exercise 10.7) After the seed, ask for the stored best (`IO.loadBest ()`), and draw the stream of
+/// `sessions seed best speedActions` with `renderSession` instead of the states with `render`.
+/// Nothing runs here. A function of `unit`, like `IO.randomSeed`.
let main () : IO>> = failwith "TODO m09"
+
+/// (m10) `play` that also passes `best` to both traces (`IO.traceWith seed best`), so that `LoadBest` is answered
+/// with it.
+let playWith (seed: uint32) (best: int) (events: Event list) (main: IO>>) : Op list list =
+ failwith "TODO m10"
+
+/// (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 `IO.trace seed`, `Stream.run` the stream it
+/// produces on `events`, and trace each emitted IO with the same seed, keeping its `Ops`.
+/// (m10) Once `playWith` (above) is written, `play` becomes its "no stored best" case: `playWith seed 0 events main`.
+let play (seed: uint32) (events: Event list) (main: IO>>) : Op list list = failwith "TODO m09"
diff --git a/fsharp/src/Check.fs b/fsharp/src/Check.fs
new file mode 100644
index 0000000..db7eb82
--- /dev/null
+++ b/fsharp/src/Check.fs
@@ -0,0 +1,126 @@
+/// m12 check: property-based testing, built from the course's own parts.
+/// A generator of test data is nothing new: it is a `Random<'a>` from Random.fs (`seed -> (value, nextSeed)`).
+module Snake.Check
+
+open Snake.Model
+open Snake.Random
+
+/// What `forAll` reports: every value passed (`Passed runs`), or the first one that did not (`Failed`, see `forAll`).
+type CheckResult<'a> =
+ | Passed of runs: int
+ | Failed of run: int * seed: uint32 * value: 'a
+
+/// A Random that gives one of the elements of the non-empty list `xs`: the element at index `i`,
+/// where `i` is drawn with `Random.intBelow (List.length xs)`.
+let elementOf (xs: 'a list) : Random<'a> = failwith "TODO m12"
+
+/// A Random that gives a list 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 0 gen` gives `[]` and leaves the seed alone. No loop: recursion on `n`, with `Random.bind` and `Random.map`.
+let listOf (n: int) (gen: Random<'a>) : Random<'a list> = failwith "TODO m12"
+
+// Ticks appear three times so that the snake moves more often than it turns.
+let private actionPool: Action list =
+ [ Tick; Tick; Tick; Turn Up; Turn Down; Turn Left; Turn Right ]
+
+/// A generator of `n` random actions (given; it works once `elementOf` and `listOf` do).
+let genActions (n: int) : Random = listOf n (elementOf actionPool)
+
+/// Check the property `prop` (a function value -> bool) 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: `Passed runs` when every value passed, otherwise `Failed(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 `Random.run seed gen |> fst` gives `value` again).
+/// Write it with a tail-recursive helper that carries the run number and the seed.
+let forAll (runs: int) (seed: uint32) (prop: 'a -> bool) (gen: Random<'a>) : CheckResult<'a> = failwith "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 `[]`.
+let removals (xs: 'a list) : 'a list list = failwith "TODO m12"
+
+/// Shrink a failing list. `fails` is a function list -> bool 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.
+let minimize (fails: 'a list -> bool) (xs: 'a list) : 'a list = failwith "TODO m12"
+
+/// The names of the invariants that `state` breaks, in this order (an empty list 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 (`Some`), is on the board
+/// "apple-not-on-snake" the apple, if there is one (`Some`), is not on a cell of the snake
+/// "turn-not-reversed" `NextDir` is not the opposite of `Dir`
+/// "queued-not-reversed" `Queued` is `None`, or is not the opposite of `NextDir`
+/// No apple (`None`) breaks neither apple invariant.
+let violations (state: GameState) : string list = failwith "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 `[]`.
+let moveGaps (states: GameState list) : int list = failwith "TODO m12"
+
+// ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7)
+
+/// (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 `Random.intBelow width`, then `y` with `Random.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 (`Rules.move last dir`), and keep those that are on the board and not yet in the snake.
+/// If none is left, stop early (with `Random.pure`, no draw). 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 `Random.bind`.
+let genSnake (width: int) (height: int) (maxLen: int) : Random = failwith "TODO m12"
+
+let private directions: Direction list = [ Up; Down; Left; Right ]
+
+// The direction the head moved in to get from the second cell to the first (`Right` for a one-cell snake).
+let private headingOf (snake: Point list) : Direction =
+ match snake with
+ | head :: second :: _ -> directions |> List.find (fun d -> Rules.move second d = head)
+ | _ -> Right
+
+/// A random 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 `Random.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 `Random.intBelow 2`: 0 means no apple, 1 means an apple at `elementOf (Spawn.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 `None`.
+let genState (width: int) (height: int) : Random =
+ Random.intBelow (width * height)
+ |> Random.bind (fun n ->
+ genSnake width height (n + 1)
+ |> Random.bind (fun snake ->
+ let dir = headingOf snake
+
+ let state =
+ { Width = width
+ Height = height
+ Snake = snake
+ Dir = dir
+ NextDir = dir
+ Apple = None
+ Seed = 0u
+ Score = 0
+ Status = Playing
+ Frame = 0
+ Queued = None }
+
+ let free = Spawn.freeCells state
+
+ elementOf (directions |> List.filter (fun d -> d <> opposite dir))
+ |> Random.bind (fun nextDir ->
+ Random.intBelow 2
+ |> Random.bind (fun coin ->
+ (if coin = 0 || List.isEmpty free then Random.pure None else elementOf free |> Random.map Some)
+ |> Random.map (fun apple -> { state with NextDir = nextDir; Apple = apple })))))
+
+/// (Exercise 12.7) The step of m03, written straight from the rules, as a second opinion on `Rules.advance`.
+/// Not Playing: the state unchanged. Otherwise build the snake the step would produce: the new head
+/// (`Rules.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 m03's game over (`Dir = NextDir`, `Status = GameOver`, nothing else changed);
+/// otherwise the state with the new snake, `Dir = NextDir`, and, when eating, `Apple = None` and the score one higher.
+/// Do not call `Rules.advance`. On every sane state the result must equal `Rules.advance state`.
+let advanceSpec (state: GameState) : GameState = failwith "TODO m12"
diff --git a/fsharp/src/Game.fs b/fsharp/src/Game.fs
index 4cc8d7a..096acfa 100644
--- a/fsharp/src/Game.fs
+++ b/fsharp/src/Game.fs
@@ -3,6 +3,13 @@ module Snake.Game
open Snake.Model
+/// (m10, Exercise 10.6) What `update` does with `Turn dir` from m10 on (the turn queue).
+/// - not Playing: the state unchanged;
+/// - no turn pending (`NextDir = Dir`): `Rules.turn state dir`, the m03 rule;
+/// - a turn pending (`NextDir <> Dir`): if `dir` is neither `NextDir` nor `opposite NextDir`, the state with
+/// `Queued = Some dir` (a later key replaces an earlier queued one); otherwise the state unchanged.
+let steer (state: GameState) (dir: Direction) : GameState = failwith "TODO m10"
+
/// How many frames pass between game steps (m10): `max 2 (8 - score / 3)` (integer division).
/// Score 0-2 -> 8, 3-5 -> 7, ..., 18 and up -> 2.
let movesEvery (score: int) : int = failwith "TODO m10"
@@ -14,8 +21,13 @@ let movesEvery (score: int) : int = failwith "TODO m10"
///
/// (m10) `TogglePause` switches Playing <-> Paused and does nothing in any other status.
/// (m10) `Restart` gives `Spawn.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 (`Spawn.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 `Spawn.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 `Rules.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 `Some d`,
+/// the result gets `NextDir = d` and `Queued = None`. A result that is not Playing keeps `Queued` as it is.
/// In m10, match every case of `Action` explicitly (no `| _ ->`), so the compiler tells you if one is forgotten.
let update (state: GameState) (action: Action) : GameState = failwith "TODO m05"
diff --git a/fsharp/src/IO.fs b/fsharp/src/IO.fs
index 5831d4d..6016f27 100644
--- a/fsharp/src/IO.fs
+++ b/fsharp/src/IO.fs
@@ -1,6 +1,8 @@
/// m07 io: effects as values. An `IO<'a>` is a description of a program that, when interpreted, produces an `'a`:
/// - `Pure a` finished, nothing left to do
/// - `Effect (op, k)` perform `op`, then continue with `k result` (which returns another IO)
+/// (m10) Two more operations, for a best score that survives a reload: `LoadBest` (answered with `Best value`, the
+/// stored best) and `SaveBest score` (answered with `Done`).
/// Building an IO never performs anything; only an interpreter (`trace` here, `Runtime.runIO` in m09) does.
module Snake.IO
@@ -10,11 +12,17 @@ open Snake.View
type Op =
| Draw of Shape
| RandomSeed
+ /// (m10) Read the stored best score.
+ | LoadBest
+ /// (m10) Store `score` as the new best.
+ | SaveBest of int
-/// What the outside world answers: `Done` for a `Draw`, `Seed value` (an unsigned 32-bit number) for a `RandomSeed`.
+/// What the outside world answers: `Done` for a `Draw`, `Seed value` (an unsigned 32-bit number) for a `RandomSeed`;
+/// (m10) `Best value` for a `LoadBest`, `Done` for a `SaveBest`.
type OpResult =
| Done
| Seed of uint32
+ | Best of int
type IO<'a> =
| Pure of 'a
@@ -50,7 +58,18 @@ let randomSeed () : IO = failwith "TODO m07"
/// Draw every shape of a scene in order: `sequence (List.map draw scene)`.
let drawScene (scene: Scene) : IO = failwith "TODO m07"
+/// (m10) An IO that reads the stored best score and produces it (the `value` of the `Best` answer).
+/// A function of `unit`, like `randomSeed ()`; an answer other than `Best` is a broken interpreter: fail loudly.
+let loadBest () : IO = failwith "TODO m10"
+
+/// (m10) An IO that stores `score` as the new best and produces `()`: built like `draw`, from `SaveBest score`.
+let saveBest (score: int) : IO = failwith "TODO m10"
+
+/// (m10) `trace` that also answers every `LoadBest` with `Best best` and every `SaveBest` with `Done`.
+let traceWith (seed: uint32) (best: int) (io: IO<'a>) : Traced<'a> = failwith "TODO m10"
+
/// A pure interpreter for tests. Answers every `RandomSeed` with `Seed seed` and every `Draw` with `Done`.
/// Returns the final value and the operations in the order they were executed.
/// Implemented by recursion, with no mutation.
+/// (m10) Once `traceWith` (above) is written, `trace` becomes its "no stored best" case: `traceWith seed 0 io`.
let trace (seed: uint32) (io: IO<'a>) : Traced<'a> = failwith "TODO m07"
diff --git a/fsharp/src/Life/App.fs b/fsharp/src/Life/App.fs
new file mode 100644
index 0000000..8fc2023
--- /dev/null
+++ b/fsharp/src/Life/App.fs
@@ -0,0 +1,34 @@
+/// m13 life app: the same wiring as Snake and Pong, with a third kind of input, the mouse.
+module Snake.Life.App
+
+open Snake
+open Snake.IO
+open Snake.Stream
+open Snake.Life.Model
+open Snake.Life.Rules
+open Snake.Life.View
+
+/// Milliseconds between two generations while the world runs.
+let GEN_MS = 150L
+
+/// A key press to a `LifeAction`, if any: " " -> TogglePause, "n"/"N" -> Step, "c"/"C" -> Clear,
+/// "r"/"R" -> Randomize. Any other key -> `None`.
+let lifeKey (key: string) : LifeAction option = failwith "TODO m13"
+
+/// A click at canvas pixel `(x, y)` to `Toggle` of the cell under it (`x / LCELL` and `y / LCELL`, rounded down),
+/// or `None` when the pixel is not on the board (left of it, above it, right of it, or in the status strip below it).
+/// Careful: F# integer division rounds towards zero, so `-1 / 16` is 0. Check the pixel before you divide.
+let lifeClick (x: int) (y: int) : LifeAction option = failwith "TODO m13"
+
+/// Everything that can happen to the world: `Frame` every GEN_MS (from `Stream.interval`), merged with `lifeKey` of
+/// every key press, merged with `lifeClick` of every click (`Stream.clicks`), in that order.
+/// (The stub is wrapped in `Stream` so the TODO only fires when the stream is stepped.)
+let lifeActions: Stream = Stream(fun _ -> failwith "TODO m13")
+
+/// Fold the actions into a stream of states: start from `initialLife seed`, `lifeUpdate` for each action, emit the
+/// initial state first.
+let lifeStates (seed: uint32) (actions: Stream) : Stream = failwith "TODO m13"
+
+/// The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings
+/// of `lifeView`, and wrap that stream in `Stream.clampTime App.MAX_GAP_MS`. A function of `unit`, like `App.main`.
+let lifeMain () : IO>> = failwith "TODO m13"
diff --git a/fsharp/src/Life/Model.fs b/fsharp/src/Life/Model.fs
new file mode 100644
index 0000000..24aa6f5
--- /dev/null
+++ b/fsharp/src/Life/Model.fs
@@ -0,0 +1,38 @@
+/// m13 life model: the whole world is one value, like the snake and the match.
+module Snake.Life.Model
+
+open Snake.Model
+
+/// The whole world. `Cells` are the live cells, always canonical: sorted row-major (by `Y`, then by `X`), each cell
+/// at most once, all on the board. `Running` says whether generations follow each other on their own; `Seed` is the
+/// unsigned 32-bit random seed used by `Randomize`; `Generation` counts the generations since the last Clear or
+/// Randomize.
+type LifeState =
+ { Cells: Point list
+ Running: bool
+ Seed: uint32
+ Generation: int }
+
+/// Everything that can happen to the world, as data. `Toggle` carries the cell that was clicked.
+/// (RequireQualifiedAccess: Snake's `Action` also has `Frame` and `TogglePause`.)
+[]
+type LifeAction =
+ | Frame
+ | Toggle of Point
+ | TogglePause
+ | Step
+ | Clear
+ | Randomize
+
+// The board, in cells: LW columns, LH rows. Below it the page shows a status strip.
+let LW = 40
+let LH = 30
+
+/// Pixels per cell.
+let LCELL = 16
+
+/// Height of the status strip under the board, in pixels.
+let STRIP = 24
+
+/// A fresh world: no live cells (`Cells = []`), paused (`Running = false`), the given seed, generation 0.
+let initialLife (seed: uint32) : LifeState = failwith "TODO m13"
diff --git a/fsharp/src/Life/Rules.fs b/fsharp/src/Life/Rules.fs
new file mode 100644
index 0000000..c9dffa5
--- /dev/null
+++ b/fsharp/src/Life/Rules.fs
@@ -0,0 +1,27 @@
+/// m13 life rules: the next generation is a function of the whole old board, and nothing else.
+module Snake.Life.Rules
+
+open Snake
+open Snake.Model
+open Snake.Life.Model
+
+/// 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.
+let lifeStep (cells: Point list) : Point list = failwith "TODO m13"
+
+/// The one function that says how the world changes.
+/// - `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 `Random.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.
+let lifeUpdate (state: LifeState) (action: LifeAction) : LifeState = failwith "TODO m13"
diff --git a/fsharp/src/Life/View.fs b/fsharp/src/Life/View.fs
new file mode 100644
index 0000000..2e121a0
--- /dev/null
+++ b/fsharp/src/Life/View.fs
@@ -0,0 +1,11 @@
+/// m13 life view: live cells as squares, and a status strip under the board.
+module Snake.Life.View
+
+open Snake.View
+open Snake.Life.Model
+
+/// 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 ", align "left")`;
+/// `Text(634, 497, "Running: Space pauses"` when running, else `"Paused: Space runs, N steps", align "right")`.
+let lifeView (state: LifeState) : Scene = failwith "TODO m13"
diff --git a/fsharp/src/Model.fs b/fsharp/src/Model.fs
index ed8f0c8..b35ad0d 100644
--- a/fsharp/src/Model.fs
+++ b/fsharp/src/Model.fs
@@ -25,6 +25,7 @@ type Action =
| Frame
/// The whole game. `Snake` is head first; `Seed` is the unsigned 32-bit random seed.
+/// `Frame` and `Queued` stay unused until m10 (`Queued` holds a turn waiting for the next step).
type GameState =
{ Width: int
Height: int
@@ -35,7 +36,8 @@ type GameState =
Seed: uint32
Score: int
Status: Status
- Frame: int }
+ Frame: int
+ Queued: Direction option }
/// The unit step for a direction: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0). Y grows downward.
let delta (dir: Direction) : Point = failwith "TODO m02"
@@ -44,5 +46,5 @@ let delta (dir: Direction) : Point = failwith "TODO m02"
let opposite (dir: Direction) : Direction = failwith "TODO m02"
/// A fresh game: 20x20, snake (10,10),(9,10),(8,10) heading Right (`Dir` and `NextDir`),
-/// no apple, the given seed, score 0, status Playing, frame 0.
+/// no apple, the given seed, score 0, status Playing, frame 0, nothing queued (`Queued = None`).
let initialState (seed: uint32) : GameState = failwith "TODO m02"
diff --git a/fsharp/src/Pong/App.fs b/fsharp/src/Pong/App.fs
index 8659292..547a80b 100644
--- a/fsharp/src/Pong/App.fs
+++ b/fsharp/src/Pong/App.fs
@@ -25,5 +25,5 @@ let pongActions: Stream = Stream(fun _ -> failwith "TODO m11")
let pongStates (seed: uint32) (actions: Stream) : Stream = failwith "TODO m11"
/// The whole program as a value, like `App.main`: ask for a seed, turn the stream of states into a stream of drawings
-/// of `pongView`. A function of `unit`, like `App.main`.
+/// of `pongView`, and wrap that stream in `Stream.clampTime App.MAX_GAP_MS`. A function of `unit`, like `App.main`.
let pongMain () : IO>> = failwith "TODO m11"
diff --git a/fsharp/src/Pong/Rules.fs b/fsharp/src/Pong/Rules.fs
index fd4b5dc..f7f4a97 100644
--- a/fsharp/src/Pong/Rules.fs
+++ b/fsharp/src/Pong/Rules.fs
@@ -11,7 +11,7 @@ let clamp (lo: int) (hi: int) (x: int) : int = failwith "TODO m11"
/// The one function that says how a match changes: `state -> action -> state`.
///
/// - `Press key` / `Release key`: set `Held.Up` or `Held.Down` to true / false (even when the match is over).
-/// - `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),
diff --git a/fsharp/src/Replay.fs b/fsharp/src/Replay.fs
new file mode 100644
index 0000000..bdbd5bc
--- /dev/null
+++ b/fsharp/src/Replay.fs
@@ -0,0 +1,66 @@
+/// m10 replay viewer: a recorded game is a seed and a list of actions; watching it is a second, tiny program
+/// whose state is only a position in the list of the game's states.
+module Snake.Replay
+
+open Snake.Model
+open Snake.View
+open Snake.IO
+open Snake.Stream
+
+/// A recorded game: the seed of the game and every action, in order (see `fixtures/replay-sample.json`).
+type Recording = { Seed: uint32; Actions: Action list }
+
+/// The viewer: which frame is shown (`Index`, 0 = the new game, k = after k actions), the index of the last frame
+/// (`Last`, the number of actions), and whether autoplay is on (`Playing`).
+type Viewer = { Index: int; Last: int; Playing: bool }
+
+/// Everything the viewer can be asked to do, as data. `Beat` is one beat of the autoplay clock.
+/// (RequireQualifiedAccess: `ViewerAction.Last` must never be confused with anything else in scope.)
+[]
+type ViewerAction =
+ | Next
+ | Prev
+ | First
+ | Last
+ | TogglePlay
+ | Beat
+
+/// Milliseconds between two frames of autoplay.
+let REPLAY_MS = 100L
+
+/// Every state of the recorded game: `Game.history (Spawn.newGame seed) actions`. Its length is the number of
+/// actions + 1.
+let frames (recording: Recording) : GameState list = failwith "TODO m10"
+
+/// The viewer at the first frame, not playing: `{ Index = 0; Last = last; Playing = false }`.
+let viewerStart (last: int) : Viewer = failwith "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).
+let viewerUpdate (viewer: Viewer) (action: ViewerAction) : Viewer = failwith "TODO m10"
+
+/// A key to a viewer action, or `None`: "ArrowRight"/"d"/"D" -> Next, "ArrowLeft"/"a"/"A" -> Prev, "Home" -> First,
+/// "End" -> Last, " " -> TogglePlay. Any other key -> `None`.
+let viewerKey (key: string) : ViewerAction option = failwith "TODO m10"
+
+/// The picture: `View.view` of the frame at `Index`, followed by two texts. With `w = Width * CELL` of that frame:
+/// `Text(w - 6, 18, "step /