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>
107 lines
4.9 KiB
JavaScript
107 lines
4.9 KiB
JavaScript
// The oracle: the pure JS solutions, run in Node, say what a page must draw for a given seed, stored best and list of
|
|
// inputs. Nothing here touches a browser.
|
|
//
|
|
// A program (`main`, `pongMain`, `replayMain(recording)`, `lifeMain`) is an IO that yields a stream; `trace` answers
|
|
// its RandomSeed and LoadBest ops with the test's seed and best, exactly as the course's `play(main, seed, events,
|
|
// best)` does (js/solutions/app.js). The Model then steps that stream one input at a time and traces each IO it emits,
|
|
// so it knows, per input, which shapes are drawn and which scores are saved. Flattened, its output equals `play`'s
|
|
// (tests/techniques.spec.mjs checks that); grouped per input, it lines up with the page's recorded frames.
|
|
//
|
|
// Next to the program, the Model steps a stream of the program's *states*, built from the same pieces as the program
|
|
// (e.g. `clampTime(sessions(seed, best, speedActions), MAX_GAP_MS)` for Snake). It is used only to plan inputs and to
|
|
// state assertions in game terms ("the head moved one cell"); every judgement of the page is made on its drawing.
|
|
import { readFileSync } from 'node:fs'
|
|
import { trace } from '../../js/solutions/io.js'
|
|
import { clampTime } from '../../js/solutions/stream.js'
|
|
import { main, play, sessions, sessionView, speedActions, MAX_GAP_MS, FRAME_MS } from '../../js/solutions/app.js'
|
|
import { movesEvery } from '../../js/solutions/game.js'
|
|
import { newGame } from '../../js/solutions/spawn.js'
|
|
import { pongMain, pongStates, pongActions, FRAME_MS_PONG } from '../../js/solutions/pong/app.js'
|
|
import { pongView } from '../../js/solutions/pong/view.js'
|
|
import { replayMain, viewerStates, replayActions, viewerView, frames as replayFrames, REPLAY_MS } from '../../js/solutions/replay.js'
|
|
import { lifeMain, lifeStates, lifeActions, GEN_MS } from '../../js/solutions/life/app.js'
|
|
import { lifeView } from '../../js/solutions/life/view.js'
|
|
import { LCELL, LW, LH } from '../../js/solutions/life/model.js'
|
|
|
|
export const recording = JSON.parse(readFileSync(new URL('../../fixtures/replay-sample.json', import.meta.url), 'utf8'))
|
|
|
|
/** The four pages of a track, the program each runs, and the matching stream of states. */
|
|
export const programs = {
|
|
snake: {
|
|
page: 'index.html',
|
|
main: () => main,
|
|
states: (seed, best) => clampTime(sessions(seed, best, speedActions), MAX_GAP_MS),
|
|
},
|
|
pong: {
|
|
page: 'pong.html',
|
|
main: () => pongMain,
|
|
states: (seed) => clampTime(pongStates(seed, pongActions), MAX_GAP_MS),
|
|
},
|
|
replay: {
|
|
page: 'replay.html',
|
|
main: () => replayMain(recording),
|
|
states: () => clampTime(viewerStates(replayFrames(recording).length - 1, replayActions), MAX_GAP_MS),
|
|
},
|
|
life: {
|
|
page: 'life.html',
|
|
main: () => lifeMain,
|
|
states: (seed) => clampTime(lifeStates(seed, lifeActions), MAX_GAP_MS),
|
|
},
|
|
}
|
|
|
|
export class Model {
|
|
constructor(name, { seed, best = 0 }) {
|
|
const program = programs[name]
|
|
this.name = name
|
|
this.seed = seed
|
|
this.best = best
|
|
const traced = trace(program.main(), seed, best)
|
|
this.startOps = traced.ops // RandomSeed, LoadBest: what the program asks before it starts
|
|
this.stream = traced.result
|
|
this.stateStream = program.states(seed, best)
|
|
this.state = null // latest state the program emitted
|
|
this.inputs = [] // every input fed so far
|
|
this.frames = [] // per input that drew anything: {input, shapes}
|
|
this.saves = [] // scores passed to SaveBest, in order
|
|
}
|
|
|
|
/** Feed inputs, in order, as the page received them. */
|
|
feed(inputs) {
|
|
for (const input of inputs) {
|
|
const [ios, next] = this.stream.step(input)
|
|
this.stream = next
|
|
const ops = ios.flatMap((io) => trace(io, this.seed, this.best).ops)
|
|
const shapes = ops.filter((op) => op.tag === 'Draw').map((op) => op.shape)
|
|
this.saves.push(...ops.filter((op) => op.tag === 'SaveBest').map((op) => op.score))
|
|
if (shapes.length > 0) this.frames.push({ input, shapes })
|
|
const [states, nextStates] = this.stateStream.step(input)
|
|
this.stateStream = nextStates
|
|
if (states.length > 0) this.state = states[states.length - 1]
|
|
this.inputs.push(input)
|
|
}
|
|
return this
|
|
}
|
|
|
|
/** What the state would be after these inputs, without feeding them. Streams are values, so this is free. */
|
|
peek(inputs) {
|
|
const [states] = inputs.reduce(([acc, s], input) => {
|
|
const [out, next] = s.step(input)
|
|
return [out.length > 0 ? out[out.length - 1] : acc, next]
|
|
}, [this.state, this.stateStream])
|
|
return states
|
|
}
|
|
|
|
/** The course's own one-equality run of the program over every input fed so far. */
|
|
play() {
|
|
return play(programs[this.name].main(), this.seed, this.inputs, this.best)
|
|
}
|
|
|
|
lastFrame() {
|
|
return this.frames.length === 0 ? null : this.frames[this.frames.length - 1]
|
|
}
|
|
}
|
|
|
|
// Re-exported for tests that state expectations directly in model terms.
|
|
export { sessionView, newGame, movesEvery, pongView, viewerView, replayFrames, lifeView }
|
|
export { FRAME_MS, MAX_GAP_MS, FRAME_MS_PONG, REPLAY_MS, GEN_MS, LCELL, LW, LH }
|