// m12 check: property-based testing built from the course's own parts. // A generator of test data is nothing new: it is a Random from random.js (seed -> [A, nextSeed]). import { contains, dropLast } from './util.js' import { Action, point, pointEquals, opposite } from './model.js' import { inBounds, move } from './rules.js' import { freeCells } from './spawn.js' import { pure, map, flatMap, intBelow, run } from './random.js' /** @import { Point, Direction, GameState, Random, CheckResult } from './types' */ /** One of the elements of the non-empty array `xs`: the one at index `intBelow(xs.length)`. * @type {(xs: A[]) => Random} */ export const elementOf = (xs) => map(intBelow(xs.length), (i) => xs[i]) /** `n` values drawn from `gen`, one after another; the seed is threaded from each draw to the next. * @type {(gen: Random, n: number) => Random} */ export const listOf = (gen, n) => n <= 0 ? pure([]) : flatMap(gen, (a) => map(listOf(gen, n - 1), (rest) => [a, ...rest])) // Ticks appear three times so that the snake moves more often than it turns. const actionPool = [ Action.Tick, Action.Tick, Action.Tick, Action.Turn('Up'), Action.Turn('Down'), Action.Turn('Left'), Action.Turn('Right'), ] /** A generator of `n` random actions (given). * @type {(n: number) => Random} */ export const genActions = (n) => listOf(elementOf(actionPool), n) /** * Check `prop` on `runs` values drawn from `gen`, starting from `seed`; stop at the first value that fails. * Recursion carries the seed: no counter, no loop variable. * @type {(gen: Random, prop: (a: A) => boolean, runs: number, seed: number) => CheckResult} */ export const forAll = (gen, prop, runs, seed) => { const go = (i, s) => { if (i >= runs) return { ok: true, runs } const [value, next] = run(gen, s) return prop(value) ? go(i + 1, next) : { ok: false, run: i, seed: s, value } } return go(0, seed) } /** Every list that is `xs` with exactly one element removed, in the order of the removed index. * @type {(xs: A[]) => A[][]} */ export const removals = (xs) => xs.map((_, i) => [...xs.slice(0, i), ...xs.slice(i + 1)]) /** Keep removing one element for as long as the list still fails; the result is 1-minimal. * @type {(xs: A[], fails: (xs: A[]) => boolean) => A[]} */ export const minimize = (xs, fails) => { const smaller = removals(xs).find(fails) return smaller === undefined ? xs : minimize(smaller, fails) } const neighbours = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1 // Each invariant: its name, and a predicate that is true when the state is fine. /** @type {[string, (s: GameState) => boolean][]} */ const invariants = [ ['snake-not-empty', (s) => s.snake.length > 0], ['snake-in-bounds', (s) => s.snake.every((p) => inBounds(s, p))], ['snake-no-overlap', (s) => s.snake.every((p, i) => !contains(s.snake.slice(i + 1), p, pointEquals))], ['snake-connected', (s) => s.snake.slice(1).every((p, i) => neighbours(s.snake[i], p))], ['apple-in-bounds', (s) => s.apple === null || inBounds(s, s.apple)], ['apple-not-on-snake', (s) => s.apple === null || !contains(s.snake, s.apple, pointEquals)], ['turn-not-reversed', (s) => s.nextDir !== opposite(s.dir)], ['queued-not-reversed', (s) => s.queued === null || s.queued !== opposite(s.nextDir)], ] /** The names of the invariants that `state` breaks, in the order of the table above. * @type {(state: GameState) => string[]} */ export const violations = (state) => invariants.filter(([, holds]) => !holds(state)).map(([name]) => name) // Total also for an empty snake (an illegal state, but `moveGaps` should not be the one to crash on it). const sameHead = (a, b) => (a.length === 0 || b.length === 0 ? a.length === b.length : pointEquals(a[0], b[0])) /** The numbers of states between consecutive moves of the snake's head. * @type {(states: GameState[]) => number[]} */ export const moveGaps = (states) => { const moved = states .map((s, i) => (i > 0 && !sameHead(s.snake, states[i - 1].snake) ? i : -1)) .filter((i) => i >= 0) return moved.slice(1).map((i, k) => i - moved[k]) } // ---- random states (Exercise 12.6) and a second implementation of the step as an oracle (Exercise 12.7) /** @type {Direction[]} */ const directions = ['Up', 'Down', 'Left', 'Right'] // Extend `snake` at its tail by up to `left` more cells, each a random free in-bounds neighbour of the last cell. const grow = (width, height, snake, left) => { if (left <= 0) return pure(snake) const last = snake[snake.length - 1] const options = directions .map((d) => move(last, d)) .filter((p) => inBounds({ width, height }, p) && !contains(snake, p, pointEquals)) return options.length === 0 ? pure(snake) : flatMap(elementOf(options), (p) => grow(width, height, [...snake, p], left - 1)) } /** A random snake, head first: a random head, then a random walk that never leaves the board or crosses itself. * @type {(width: number, height: number, maxLen: number) => Random} */ export const genSnake = (width, height, maxLen) => flatMap(intBelow(width), (x) => flatMap(intBelow(height), (y) => grow(width, height, [point(x, y)], maxLen - 1))) // The direction the head moved in to get from the second cell to the first ('Right' for a one-cell snake). const headingOf = (snake) => snake.length < 2 ? 'Right' : /** @type {Direction} */ (directions.find((d) => pointEquals(move(snake[1], d), snake[0]))) /** A random sane Playing state on a `width` x `height` board (given). * @type {(width: number, height: number) => Random} */ export const genState = (width, height) => flatMap(intBelow(width * height), (n) => flatMap(genSnake(width, height, n + 1), (snake) => { const dir = headingOf(snake) const free = freeCells({ width, height, snake }) return flatMap(elementOf(directions.filter((d) => d !== opposite(dir))), (nextDir) => flatMap(intBelow(2), (coin) => map(coin === 0 || free.length === 0 ? pure(null) : elementOf(free), (apple) => ({ width, height, snake, dir, nextDir, apple, seed: 0, score: 0, status: 'Playing', frame: 0, queued: null, })))) })) /** * The step of Module 3, written from the rules instead of from `advance`: build the snake the step would give, * then ask `violations` whether that snake is on the board and does not overlap itself. * @type {(state: GameState) => GameState} */ export const advanceSpec = (state) => { if (state.status !== 'Playing') return state const head = move(state.snake[0], state.nextDir) const eating = state.apple !== null && pointEquals(head, state.apple) const snake = [head, ...(eating ? state.snake : dropLast(state.snake))] const broken = violations({ ...state, snake }).some((v) => v === 'snake-in-bounds' || v === 'snake-no-overlap') return broken ? { ...state, dir: state.nextDir, status: 'GameOver' } : { ...state, snake, dir: state.nextDir, apple: eating ? null : state.apple, score: state.score + (eating ? 1 : 0) } }