Audit pass: - Frame rule counts frames since the last move, so every step lasts exactly movesEvery(score) frames, also right after a speed-up - clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap - Pong Restart keeps held keys - Fact corrections (mulberry32 output coverage, Haskell IO, uniform) - New fixture cases from a mutation analysis Extensions (all three tracks, shared fixtures, course text): - Turn queue (Exercise 10.6), persisted high score (10.7), replay viewer (10.8), play() end-to-end helper (9.5) - Optional State (4.7) and optional Module 7b, stack-safe IO - Module 12, property-based testing with generators, shrinking, invariants and a model-based oracle - Module 13, Conway's Life with a Click event - JS type checking via jsconfig, JSDoc and types.d.ts - Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh, Playwright end-to-end suite (e2e/, scripts/e2e.sh) Docs: docs/contract.md is the canonical contract; specs and build log under docs/superpowers. Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
137 lines
7.4 KiB
JavaScript
137 lines
7.4 KiB
JavaScript
import { todo } from './todo.js'
|
|
import { Action, pointEquals, opposite } from './model.js'
|
|
import { move } from './rules.js'
|
|
import { freeCells } from './spawn.js'
|
|
import { pure, map, flatMap, intBelow } from './random.js'
|
|
/** @import { Point, Direction, GameState, Random, CheckResult } from './types' */
|
|
|
|
/**
|
|
* m12 check: property-based testing, built from the course's own parts.
|
|
* A generator of test data is nothing new: it is a `Random<A>` from random.js (`seed -> [A, nextSeed]`).
|
|
*/
|
|
|
|
/**
|
|
* A Random that gives one of the elements of the non-empty array `xs`: the element at index `i`,
|
|
* where `i` is drawn with `intBelow(xs.length)`.
|
|
* @type {<A>(xs: A[]) => Random<A>}
|
|
*/
|
|
export const elementOf = (xs) => todo('m12')
|
|
|
|
/**
|
|
* A Random that gives an array of `n` values drawn from `gen`, one after another: the first draw starts from the
|
|
* incoming seed, each later draw from the seed the previous one left behind, and the last draw's seed is the result's.
|
|
* `listOf(gen, 0)` gives `[]` and leaves the seed alone. No loop: recursion on `n`, with `flatMap` and `map`.
|
|
* @type {<A>(gen: Random<A>, n: number) => Random<A[]>}
|
|
*/
|
|
export const listOf = (gen, n) => todo('m12')
|
|
|
|
// Ticks appear three times so that the snake moves more often than it turns.
|
|
const actionPool = [
|
|
Action.Tick, Action.Tick, Action.Tick,
|
|
Action.Turn('Up'), Action.Turn('Down'), Action.Turn('Left'), Action.Turn('Right'),
|
|
]
|
|
|
|
/** A generator of `n` random actions (given; it works once `elementOf` and `listOf` do).
|
|
* @type {(n: number) => Random<Action[]>} */
|
|
export const genActions = (n) => listOf(elementOf(actionPool), n)
|
|
|
|
/**
|
|
* Check the property `prop` (a function value -> boolean) on `runs` values drawn from `gen`.
|
|
* The first value is drawn from `seed`, each later one from the seed the previous draw left behind.
|
|
* Stop at the first value for which `prop` is false.
|
|
* Result: `{ ok: true, runs }` when every value passed, otherwise `{ ok: false, run, seed, value }`, where
|
|
* `run` is the 0-based number of the failing draw, `value` is the counterexample and `seed` is the seed that
|
|
* draw started from (so `run(gen, seed)[0]` gives `value` again).
|
|
* @type {<A>(gen: Random<A>, prop: (a: A) => boolean, runs: number, seed: number) => CheckResult<A>}
|
|
*/
|
|
export const forAll = (gen, prop, runs, seed) => todo('m12')
|
|
|
|
/**
|
|
* Every list that is `xs` with exactly one element removed, in the order of the removed index:
|
|
* `removals([1, 2, 3])` is `[[2, 3], [1, 3], [1, 2]]`; `removals([])` is `[]`.
|
|
* @type {<A>(xs: A[]) => A[][]}
|
|
*/
|
|
export const removals = (xs) => todo('m12')
|
|
|
|
/**
|
|
* Shrink a failing list. `fails` is a function list -> boolean and `fails(xs)` is true.
|
|
* If some list in `removals(xs)` still fails, continue with the first one that does; when none fails, return `xs`.
|
|
* The result is "1-minimal": it fails, and removing any single element makes it pass.
|
|
* @type {<A>(xs: A[], fails: (xs: A[]) => boolean) => A[]}
|
|
*/
|
|
export const minimize = (xs, fails) => todo('m12')
|
|
|
|
/**
|
|
* The names of the invariants that `state` breaks, in this order (an empty array means the state is sane):
|
|
* 'snake-not-empty' the snake has at least one cell
|
|
* 'snake-in-bounds' every cell of the snake is on the board
|
|
* 'snake-no-overlap' no two cells of the snake are equal
|
|
* 'snake-connected' each cell of the snake is exactly one step (up, down, left or right) from the one before it
|
|
* 'apple-in-bounds' the apple, if there is one, is on the board
|
|
* 'apple-not-on-snake' the apple, if there is one, is not on a cell of the snake
|
|
* 'turn-not-reversed' `nextDir` is not the opposite of `dir`
|
|
* 'queued-not-reversed' `queued` is null, or is not the opposite of `nextDir`
|
|
* @type {(state: GameState) => string[]}
|
|
*/
|
|
export const violations = (state) => todo('m12')
|
|
|
|
/**
|
|
* Given the states of a game in order, the snake "moves at index i" (i >= 1) when the head of `states[i]`
|
|
* differs from the head of `states[i - 1]`. `moveGaps(states)` is the list of differences between consecutive
|
|
* such indices: moves at 2, 5 and 12 give `[3, 7]`. Fewer than two moves give `[]`.
|
|
* @type {(states: GameState[]) => number[]}
|
|
*/
|
|
export const moveGaps = (states) => todo('m12')
|
|
|
|
/**
|
|
* (Exercise 12.6) A Random that gives a snake, head first, on a `width` x `height` board, with at most `maxLen` cells
|
|
* (`maxLen >= 1`). The draws, in this exact order (so that every track gets the same snake from the same seed):
|
|
* 1. the head: `x` with `intBelow(width)`, then `y` with `intBelow(height)`;
|
|
* 2. then, while the snake has fewer than `maxLen` cells: look at the neighbours of its *last* cell, in the order
|
|
* Up, Down, Left, Right (`move(last, dir)`), and keep those that are on the board and not yet in the snake.
|
|
* If none is left, stop early. Otherwise append the one picked with `elementOf(neighbours)` and go on.
|
|
* So the result is always on the board, connected and free of overlap. No loop: recursion, with `flatMap`.
|
|
* @type {(width: number, height: number, maxLen: number) => Random<Point[]>}
|
|
*/
|
|
export const genSnake = (width, height, maxLen) => todo('m12')
|
|
|
|
/** @type {Direction[]} */
|
|
const directions = ['Up', 'Down', 'Left', 'Right']
|
|
|
|
// The direction the head moved in to get from the second cell to the first ('Right' for a one-cell snake).
|
|
const headingOf = (snake) =>
|
|
snake.length < 2 ? 'Right' : /** @type {Direction} */ (directions.find((d) => pointEquals(move(snake[1], d), snake[0])))
|
|
|
|
/**
|
|
* A random sane Playing state on a `width` x `height` board (given; it works once `genSnake` does). Draws in order:
|
|
* the snake's length limit `n + 1` with `n` from `intBelow(width * height)`; the snake with `genSnake`; `nextDir` with
|
|
* `elementOf` among Up, Down, Left, Right without the opposite of `dir` (so: `dir` or a perpendicular direction);
|
|
* a coin `intBelow(2)`: 0 means no apple, 1 means an apple at `elementOf(freeCells)` (no apple if no cell is free).
|
|
* `dir` is the direction from the snake's second cell to its head ('Right' for a one-cell snake); seed, score and
|
|
* frame are 0, `queued` is null.
|
|
* @type {(width: number, height: number) => Random<GameState>}
|
|
*/
|
|
export const genState = (width, height) =>
|
|
flatMap(intBelow(width * height), (n) =>
|
|
flatMap(genSnake(width, height, n + 1), (snake) => {
|
|
const dir = headingOf(snake)
|
|
const free = freeCells({ width, height, snake })
|
|
return flatMap(elementOf(directions.filter((d) => d !== opposite(dir))), (nextDir) =>
|
|
flatMap(intBelow(2), (coin) =>
|
|
map(coin === 0 || free.length === 0 ? pure(null) : elementOf(free), (apple) => ({
|
|
width, height, snake, dir, nextDir, apple, seed: 0, score: 0, status: 'Playing', frame: 0, queued: null,
|
|
}))))
|
|
}))
|
|
|
|
/**
|
|
* (Exercise 12.7) The step of Module 3, written straight from the rules, as a second opinion on `advance`.
|
|
* Not Playing: the state unchanged. Otherwise build the snake the step would produce: the new head
|
|
* (`move(head, nextDir)`) in front, followed by the old snake, without its last cell unless the new head is on the
|
|
* apple. Then ask `violations` about the state with that snake: if the list contains 'snake-in-bounds' or
|
|
* 'snake-no-overlap', the result is Module 3's game over (`dir: nextDir`, status 'GameOver', nothing else changed);
|
|
* otherwise the state with the new snake, `dir: nextDir`, and, when eating, the apple gone and the score one higher.
|
|
* Do not call `advance`. On every sane state the result must equal `advance(state)`.
|
|
* @type {(state: GameState) => GameState}
|
|
*/
|
|
export const advanceSpec = (state) => todo('m12')
|