From 50628386040b424800417bf79643a0859fd12bd3 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Wed, 11 Nov 2026 12:13:03 +0100 Subject: [PATCH] chore: keep the fixture generator scripts and the replay-sample bot scripts/fixtures/ holds the scripts that produced the 2026-11-11 fixtures, with a README on running them safely into a copy. Run in order they reproduce the content of every fixture they touch. Co-Authored-By: Claude Fable 5.2 --- README.md | 2 +- docs/HANDOFF.md | 3 +- docs/contract.md | 2 +- .../superpowers/notes/2026-10-29-build-log.md | 1 + scripts/fixtures/README.md | 29 +++++++ scripts/fixtures/add-queued-field.mjs | 23 ++++++ .../fixtures/audit-frame-pong-tick-cases.mjs | 62 +++++++++++++++ .../golden-events/ev-42-0-2-restart.json | 1 + .../golden-events/ev-42-0-3-stop.json | 1 + .../fixtures/golden-events/ev-7-2-4-wall.json | 1 + scripts/fixtures/m10-session-and-golden.mjs | 56 +++++++++++++ scripts/fixtures/m10-update-queue-cases.mjs | 41 ++++++++++ scripts/fixtures/m10-viewer.mjs | 42 ++++++++++ .../fixtures/m12-violations-queue-cases.mjs | 20 +++++ scripts/fixtures/m12-violations.mjs | 33 ++++++++ scripts/fixtures/m13-life.mjs | 78 +++++++++++++++++++ scripts/fixtures/replay-sample-bot.mjs | 40 ++++++++++ 17 files changed, 432 insertions(+), 3 deletions(-) create mode 100644 scripts/fixtures/README.md create mode 100644 scripts/fixtures/add-queued-field.mjs create mode 100644 scripts/fixtures/audit-frame-pong-tick-cases.mjs create mode 100644 scripts/fixtures/golden-events/ev-42-0-2-restart.json create mode 100644 scripts/fixtures/golden-events/ev-42-0-3-stop.json create mode 100644 scripts/fixtures/golden-events/ev-7-2-4-wall.json create mode 100644 scripts/fixtures/m10-session-and-golden.mjs create mode 100644 scripts/fixtures/m10-update-queue-cases.mjs create mode 100644 scripts/fixtures/m10-viewer.mjs create mode 100644 scripts/fixtures/m12-violations-queue-cases.mjs create mode 100644 scripts/fixtures/m12-violations.mjs create mode 100644 scripts/fixtures/m13-life.mjs create mode 100644 scripts/fixtures/replay-sample-bot.mjs diff --git a/README.md b/README.md index e029973..15f3d58 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ fsharp/ src/, solutions/ (with Snake.fsproj), tests/ (Snake.Tests.fsproj, MNN* web/ Fable project and browser glue (given), build output; build-web.sh; index.html, pong.html scripts/ serve.mjs a tiny static web server (port 8000) for playing the game verify.sh, check-stubs.sh, check-progression.sh, smoke.mjs, check-links.mjs, - mutate.mjs (+ mutate-allow.txt), typecheck-js.sh, e2e.sh maintainer tools + mutate.mjs (+ mutate-allow.txt), typecheck-js.sh, fixtures/ (generators), e2e.sh maintainer tools e2e/ maintainer tooling: Playwright end-to-end tests of every track's pages against the JS model ``` diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 27b6437..2b6e165 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -17,6 +17,7 @@ The course is complete and green. It teaches functional programming by building | `/src`, `/solutions`, tests | Stubs, finished code, tests, for `js`, `scala`, `fsharp`. Same file names in `src` and `solutions`. | | `fixtures/` | Shared JSON test cases, read by all three tracks. | | `e2e/`, `scripts/e2e.sh` | Playwright end-to-end tests (maintainers only; the tracks have no dependencies). | +| `scripts/fixtures/` | The small scripts that generated the 2026-11-11 fixtures, the bot behind the replay sample, and a README saying how to use them safely. | | `scripts/` | `verify.sh`, `check-stubs.sh`, `check-progression.sh`, `typecheck-js.sh`, `mutate.mjs` (+ `mutate-allow.txt`), `smoke.mjs`, `check-links.mjs`, `serve.mjs`. | | `docs/contract.md` | **The canonical contract**: data shapes, every module's rules, constants, pinned values, fixture inventory, required checks. | | `docs/superpowers/specs/` | `2026-10-28-…-design.md` (original design), `2026-11-11-audit-pass.md`, `2026-11-11-course-extensions.md` (twelve items, with the rules every builder followed). | @@ -43,7 +44,7 @@ Environment on the machine this was built on: Node 24; `scala-cli` at `~/.local/ ## How a change is made here 1. **Decide the behaviour** and write it into `docs/contract.md` (and a spec under `docs/superpowers/specs/` if it is more than a fix). -2. **JavaScript first**: stub with its doc comment, solution, tests, fixtures (generated from the JS solution with a throwaway script, then read by hand), course text. +2. **JavaScript first**: stub with its doc comment, solution, tests, fixtures (generated from the JS solution with a small script — see `scripts/fixtures/README.md` — then read by hand), course text. 3. **Port to Scala and F#**, touching only that track's directory. Use the same seeds and run counts so pinned values match; if a pinned value differs, the draw order differs — find out why before changing a test. 4. **Run every check above** for every track you touched, and the e2e suite for anything a page shows. 5. **Record** what you decided in the build log. diff --git a/docs/contract.md b/docs/contract.md index 63911e4..858d36f 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -157,7 +157,7 @@ A second, independent IO: `Pure | Effect(op, k) | FlatMap(io, f)`. `flatMap` onl ## Fixtures -Format: `{function, cases: [{name, args, expected}]}`; names are kebab-case sentences. Generated from the JS solutions with throwaway scripts and then read by hand; formatting is `JSON.stringify(x, null, 2)` plus a newline. +Format: `{function, cases: [{name, args, expected}]}`; names are kebab-case sentences. Generated from the JS solutions with small scripts (those of 2026-11-11 are kept in `scripts/fixtures/`, with a README) and then read by hand; formatting is `JSON.stringify(x, null, 2)` plus a newline. | File | Function | Cases | |---|---|---| diff --git a/docs/superpowers/notes/2026-10-29-build-log.md b/docs/superpowers/notes/2026-10-29-build-log.md index 546ed6d..758d68e 100644 --- a/docs/superpowers/notes/2026-10-29-build-log.md +++ b/docs/superpowers/notes/2026-10-29-build-log.md @@ -137,3 +137,4 @@ Owner approved eleven extensions; spec: `docs/superpowers/specs/2026-11-11-cours - Specs: `specs/2026-11-11-audit-pass.md` (written after the fact) and `specs/2026-11-11-course-extensions.md` (twelve items, marked as built). - Committed on branch `audit-and-extensions` (`edd57f0`), merged into `main` (`c67d81b`) and pushed to origin on the owner's instruction. - `docs/HANDOFF.md` is the starting point for the next maintainer; `notes/2026-11-11-session-log.md` records the day. +- The fixture generator scripts and the replay-sample bot, first treated as throwaway, were saved to `scripts/fixtures/` with a README. Run in order into a copy of the fixtures they reproduce every file's content (checked 2026-11-11; `m13-life-update.json` differs in formatting only). diff --git a/scripts/fixtures/README.md b/scripts/fixtures/README.md new file mode 100644 index 0000000..a862095 --- /dev/null +++ b/scripts/fixtures/README.md @@ -0,0 +1,29 @@ +# Fixture regeneration aids + +The files in `fixtures/` are the contract (see `docs/contract.md`). They were produced by the small scripts in this folder, which import the JavaScript reference solutions from `js/solutions`, compute the expected value of each case, and write the JSON. The scripts are kept so that the *inputs* chosen for each case are not lost and so that new cases can be added the same way. They are maintainer aids, not part of any check. + +**Before running one, read it.** Each writes into `fixtures/` unless the environment variable `FIXTURES_OUT` names another directory (with a trailing slash). The safe way to use them: + +```sh +tmp=$(mktemp -d) && cp fixtures/*.json "$tmp/" +FIXTURES_OUT="$tmp/" node scripts/fixtures/m13-life.mjs +diff -r fixtures "$tmp" # read the difference by hand before copying anything back +``` + +A changed expectation in an existing case is a contract change: decide it on purpose, port it to Scala and F#, and record it in the build log. + +| Script | Writes | Notes | +|---|---|---| +| `audit-frame-pong-tick-cases.mjs` | `m10-update` (the `frame-…` cases), `m04-tick` (`eating-the-last-free-cell-wins`), `m11-pong-update` (restart and edge cases) | Replaces cases by name inside existing files. | +| `m12-violations.mjs` | `m12-violations` | The base cases; overwrites the file. Run the queue cases after it. | +| `add-queued-field.mjs` | every fixture that contains a game state | One-off migration that added `"queued": null`. Idempotent. | +| `m10-update-queue-cases.mjs` | `m10-update` (the 16 turn-queue cases) | Replaces by name, appends at the end. | +| `m12-violations-queue-cases.mjs` | `m12-violations` (the 3 queue cases) | Replaces by name, appends at the end. | +| `m10-session-and-golden.mjs` | `m10-session-update`, `m10-session-view`, `m10-golden` | The golden cases replay the event lists in `golden-events/` through `play(main, …)`. | +| `m10-viewer.mjs` | `m10-viewer-update`, `m10-viewer-key`, `m10-viewer-view` | Overwrites the three files. | +| `m13-life.mjs` | `m13-life-step`, `-update`, `-key`, `-click`, `-view` | Overwrites the five files, one case per line. `m13-life-update.json` in the repo has the same content in the indented format. | +| `replay-sample-bot.mjs [seed]` | `replay-sample.json` when a seed is given | A greedy bot that plays the pure game. Without an argument it only lists seeds whose game has 150–400 actions and at least 5 apples. The shipped sample is seed 1. | + +Run in the order of the table (the first five build on each other), into a copy of the current fixtures, they reproduce the content of every file they touch. This was checked on 2026-11-11. + +Fixtures from before 2026-11-11 (`m00`–`m09`, `m10-input`, `m10-moves-every`, `m11-pong-key`, `m11-pong-view`, and the original cases of the others) have no generator here; their scripts were not kept. Add cases to them with a small script in the same style. diff --git a/scripts/fixtures/add-queued-field.mjs b/scripts/fixtures/add-queued-field.mjs new file mode 100644 index 0000000..3bf5d12 --- /dev/null +++ b/scripts/fixtures/add-queued-field.mjs @@ -0,0 +1,23 @@ +import fs from 'node:fs' +// Fixture regeneration aid (see scripts/fixtures/README.md). Written during the 2026-11-11 build, kept for reference. +// Writes into fixtures/ unless FIXTURES_OUT names another directory (with a trailing slash). +const FIX = process.env.FIXTURES_OUT ?? new URL('../../fixtures/', import.meta.url).pathname +const SOL = new URL('../../js/solutions/', import.meta.url).href +const HERE = new URL('./', import.meta.url).pathname +const dir = FIX +const walk = (x) => { + if (Array.isArray(x)) return x.map(walk) + if (x && typeof x === 'object') { + const o = Object.fromEntries(Object.entries(x).map(([k, v]) => [k, walk(v)])) + return ('snake' in o && 'nextDir' in o && !('queued' in o)) ? { ...o, queued: null } : o + } + return x +} +for (const f of fs.readdirSync(dir)) { + const txt = fs.readFileSync(dir + f, 'utf8') + if (!f.endsWith('.json')) continue + const before = JSON.parse(txt) + const after = walk(before) + // Only rewrite a file whose content changed; a file with no game states keeps its formatting. + if (JSON.stringify(after) !== JSON.stringify(before)) { fs.writeFileSync(dir + f, JSON.stringify(after, null, 2) + '\n'); console.log('updated', f) } +} diff --git a/scripts/fixtures/audit-frame-pong-tick-cases.mjs b/scripts/fixtures/audit-frame-pong-tick-cases.mjs new file mode 100644 index 0000000..3da0baa --- /dev/null +++ b/scripts/fixtures/audit-frame-pong-tick-cases.mjs @@ -0,0 +1,62 @@ +import { readFileSync, writeFileSync } from 'node:fs' +// Fixture regeneration aid (see scripts/fixtures/README.md). Written during the 2026-11-11 build, kept for reference. +// Writes into fixtures/ unless FIXTURES_OUT names another directory (with a trailing slash). +const FIX = process.env.FIXTURES_OUT ?? new URL('../../fixtures/', import.meta.url).pathname +const SOL = new URL('../../js/solutions/', import.meta.url).href +const HERE = new URL('./', import.meta.url).pathname +const { update } = await import(SOL + 'game.js') +const { tick } = await import(SOL + 'spawn.js') +const { pongUpdate } = await import(SOL + 'pong/rules.js') +const load = (n) => JSON.parse(readFileSync(`${FIX}${n}.json`, 'utf8')) +const save = (n, fx) => writeFileSync(`${FIX}${n}.json`, JSON.stringify(fx, null, 2) + '\n') +const clone = (x) => JSON.parse(JSON.stringify(x)) + +// ---- m10-update +{ + const fx = load('m10-update') + const base = fx.cases.find((c) => c.name === 'frame-below-threshold-only-counts-the-frame').args[0] + const mk = (name, patch) => { const s = { ...clone(base), ...patch }; return { name, args: [s, { tag: 'Frame' }], expected: update(s, { tag: 'Frame' }) } } + const frames = [ + mk('frame-below-threshold-only-counts-the-frame', { frame: 0 }), + mk('frame-just-before-threshold-only-counts-the-frame', { frame: 6 }), + mk('frame-at-threshold-ticks-the-game-and-restarts-the-count', { frame: 7 }), + mk('frame-after-a-move-counts-from-zero-again', { snake: [{ x: 1, y: 0 }], frame: 0 }), + mk('frame-tick-that-eats-restarts-the-count', { frame: 7, score: 2, apple: { x: 1, y: 0 } }), + mk('frame-just-before-the-shrunk-threshold-only-counts-the-frame', { frame: 5, score: 3 }), + mk('frame-threshold-shrinks-as-score-grows', { frame: 6, score: 3 }), + mk('frame-at-top-speed-first-frame-does-not-tick', { frame: 0, score: 18 }), + mk('frame-at-top-speed-ticks-every-second-frame', { frame: 1, score: 18 }), + mk('frame-count-past-the-threshold-still-ticks', { frame: 9 }), + mk('frame-tick-can-end-the-game', { snake: [{ x: 2, y: 0 }], frame: 7 }), + ] + const keep = fx.cases.filter((c) => !c.name.startsWith('frame-') || c.name.startsWith('frame-when-')) + const i = keep.findIndex((c) => c.name.startsWith('frame-when-')) + fx.cases = [...keep.slice(0, i), ...frames, ...keep.slice(i)] + save('m10-update', fx) +} +// ---- m04-tick +{ + const fx = load('m04-tick') + const s = { 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 } + fx.cases = [...fx.cases.filter((c) => c.name !== 'eating-the-last-free-cell-wins'), + { name: 'eating-the-last-free-cell-wins', args: [s], expected: tick(s) }] + save('m04-tick', fx) +} +// ---- m11-pong-update +{ + const fx = load('m11-pong-update') + const base = fx.cases.find((c) => c.name === 'ball-bounces-off-the-player-paddle').args[0] + const mk = (name, patch, action = { tag: 'Frame' }) => { const s = { ...clone(base), ...patch }; return { name, args: [s, action], expected: pongUpdate(s, action) } } + fx.cases.forEach((c) => { if (c.name.startsWith('restart-')) c.expected = pongUpdate(c.args[0], c.args[1]) }) + const extra = [ + mk('player-paddle-catches-the-ball-at-its-first-row', { ball: { x: 3, y: 19 } }), + mk('ball-may-enter-the-top-row', { ball: { x: 40, y: 1 }, vel: { dx: 1, dy: -1 } }), + mk('ball-may-enter-the-bottom-row', { ball: { x: 40, y: 48 }, vel: { dx: 1, dy: 1 } }), + mk('ball-in-column-zero-is-still-in-play', { ball: { x: 1, y: 40 }, vel: { dx: -1, dy: 1 } }), + mk('ball-in-the-last-column-is-still-in-play', { ball: { x: 78, y: 40 }, vel: { dx: 1, dy: 1 } }), + mk('restart-keeps-the-held-keys', { held: { up: true, down: false }, score: { player: 1, cpu: 4 }, frame: 250, player: 3 }, { tag: 'Restart' }), + ] + fx.cases = [...fx.cases.filter((c) => !extra.some((e) => e.name === c.name)), ...extra] + save('m11-pong-update', fx) +} diff --git a/scripts/fixtures/golden-events/ev-42-0-2-restart.json b/scripts/fixtures/golden-events/ev-42-0-2-restart.json new file mode 100644 index 0000000..f0a0124 --- /dev/null +++ b/scripts/fixtures/golden-events/ev-42-0-2-restart.json @@ -0,0 +1 @@ +[{"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"}] diff --git a/scripts/fixtures/golden-events/ev-42-0-3-stop.json b/scripts/fixtures/golden-events/ev-42-0-3-stop.json new file mode 100644 index 0000000..b9b74f4 --- /dev/null +++ b/scripts/fixtures/golden-events/ev-42-0-3-stop.json @@ -0,0 +1 @@ +[{"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}] diff --git a/scripts/fixtures/golden-events/ev-7-2-4-wall.json b/scripts/fixtures/golden-events/ev-7-2-4-wall.json new file mode 100644 index 0000000..e4fd1f8 --- /dev/null +++ b/scripts/fixtures/golden-events/ev-7-2-4-wall.json @@ -0,0 +1 @@ +[{"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}] diff --git a/scripts/fixtures/m10-session-and-golden.mjs b/scripts/fixtures/m10-session-and-golden.mjs new file mode 100644 index 0000000..02f55a9 --- /dev/null +++ b/scripts/fixtures/m10-session-and-golden.mjs @@ -0,0 +1,56 @@ +import { writeFileSync, readFileSync } from 'node:fs' +// Fixture regeneration aid (see scripts/fixtures/README.md). Written during the 2026-11-11 build, kept for reference. +// Writes into fixtures/ unless FIXTURES_OUT names another directory (with a trailing slash). +const FIX = process.env.FIXTURES_OUT ?? new URL('../../fixtures/', import.meta.url).pathname +const SOL = new URL('../../js/solutions/', import.meta.url).href +const HERE = new URL('./', import.meta.url).pathname +const R = SOL +const F = FIX +const { sessionUpdate, sessionView, play, main } = await import(R + 'app.js') +const { Action } = await import(R + 'model.js') +const { update } = await import(R + 'game.js') + +const g = (o) => ({ 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: 0, status: 'Playing', frame: 0, queued: null, ...o }) +const eating = (score) => g({ score, apple: { x: 2, y: 1 } }) +const S = (game, best, improved = false) => ({ game, best, improved }) +const su = (name, s, a) => ({ name, args: [s, a], expected: sessionUpdate(s, a) }) +const suCases = [ + su('a-step-without-eating-keeps-the-best', S(g({ score: 2 }), 5), Action.Tick), + su('the-first-apple-with-no-record-is-an-improvement', S(eating(0), 0), Action.Tick), + su('eating-past-the-record-raises-the-best', S(eating(5), 5), Action.Tick), + su('eating-below-the-record-keeps-the-best', S(eating(1), 5), Action.Tick), + su('equalling-the-record-is-not-an-improvement', S(eating(4), 5), Action.Tick), + su('the-next-action-clears-improved', S(g({ score: 6 }), 6, true), Action.Turn('Up')), + su('a-frame-that-steps-and-eats-raises-the-best', S({ ...eating(3), frame: 6 }, 3), Action.Frame), + su('a-frame-that-only-counts-keeps-the-best-and-clears-improved', S(g({ score: 3, frame: 1 }), 3, true), Action.Frame), + su('game-over-keeps-the-best', S(g({ snake: [{ x: 3, y: 1 }, { x: 2, y: 1 }], score: 4 }), 4), Action.Tick), + su('restart-keeps-the-best', S(g({ status: 'GameOver', score: 6 }), 6), Action.Restart), + su('pausing-keeps-the-best', S(g({ score: 2 }), 9, true), Action.TogglePause), +] +const fx = (fn, cases) => JSON.stringify({ function: fn, cases }, null, 2) + '\n' +writeFileSync(F + 'm10-session-update.json', fx('sessionUpdate', suCases)) + +const v = (o) => ({ 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, ...o }) +const sv = (name, s) => ({ name, args: [s], expected: sessionView(s) }) +writeFileSync(F + 'm10-session-view.json', fx('sessionView', [ + sv('playing-adds-the-best-at-the-top-right', S(v({}), 12)), + sv('a-best-of-zero-is-shown-too', S(v({ score: 0 }), 0)), + sv('game-over-shows-the-banner-and-then-the-best', S(v({ status: 'GameOver' }), 7, true)), +])) + +// golden: play the final main and keep the last drawing's scene +const golden = (name, file, seed, best) => { + const events = JSON.parse(readFileSync(HERE + 'golden-events/' + file, 'utf8')) + const drawings = play(main, seed, events, best) + const scene = drawings.at(-1).filter((o) => o.tag === 'Draw').map((o) => o.shape) + const saves = drawings.flat().filter((o) => o.tag === 'SaveBest') + console.error(name, 'drawings', drawings.length, 'saves', JSON.stringify(saves)) + return { name, args: [seed, events, best], expected: scene } +} +writeFileSync(F + 'm10-golden.json', fx('play', [ + golden('seed-42-eats-three-apples-with-no-stored-best', 'ev-42-0-3-stop.json', 42, 0), + golden('seed-7-beats-a-stored-best-of-2-and-hits-the-wall', 'ev-7-2-4-wall.json', 7, 2), + golden('seed-42-restart-after-game-over-keeps-the-best', 'ev-42-0-2-restart.json', 42, 0), +])) diff --git a/scripts/fixtures/m10-update-queue-cases.mjs b/scripts/fixtures/m10-update-queue-cases.mjs new file mode 100644 index 0000000..ddc6c83 --- /dev/null +++ b/scripts/fixtures/m10-update-queue-cases.mjs @@ -0,0 +1,41 @@ +import fs from 'node:fs' +// Fixture regeneration aid (see scripts/fixtures/README.md). Written during the 2026-11-11 build, kept for reference. +// Writes into fixtures/ unless FIXTURES_OUT names another directory (with a trailing slash). +const FIX = process.env.FIXTURES_OUT ?? new URL('../../fixtures/', import.meta.url).pathname +const SOL = new URL('../../js/solutions/', import.meta.url).href +const HERE = new URL('./', import.meta.url).pathname +const S = SOL +const { update } = await import(S + 'game.js') +const { Action } = await import(S + 'model.js') +const p = (x, y) => ({ x, y }) +const base = { + width: 10, height: 10, snake: [p(5, 5), p(4, 5), p(3, 5)], dir: 'Right', nextDir: 'Right', + apple: p(0, 0), seed: 1, score: 0, status: 'Playing', frame: 0, queued: null, +} +const pending = { ...base, nextDir: 'Up' } +const queuedLeft = { ...pending, queued: 'Left' } +const atTop = { ...queuedLeft, snake: [p(5, 0), p(4, 0), p(3, 0)] } +const cases = [ + ['turn-with-no-turn-pending-sets-next-dir', base, Action.Turn('Up')], + ['turn-straight-back-with-no-turn-pending-is-ignored', base, Action.Turn('Left')], + ['perpendicular-key-while-a-turn-is-pending-is-queued', pending, Action.Turn('Left')], + ['key-in-the-current-direction-while-a-turn-is-pending-is-queued', pending, Action.Turn('Right')], + ['a-later-key-replaces-the-queued-one', queuedLeft, Action.Turn('Right')], + ['repeating-the-pending-turn-changes-nothing', queuedLeft, Action.Turn('Up')], + ['reversing-the-pending-turn-changes-nothing', queuedLeft, Action.Turn('Down')], + ['turn-while-paused-with-a-turn-pending-is-not-queued', { ...pending, status: 'Paused' }, Action.Turn('Left')], + ['tick-moves-then-takes-the-queued-turn', queuedLeft, Action.Tick], + ['tick-with-nothing-queued-leaves-next-dir', pending, Action.Tick], + ['frame-step-takes-the-queued-turn', { ...queuedLeft, frame: 7 }, Action.Frame], + ['frame-without-a-step-keeps-the-queue', { ...queuedLeft, frame: 3 }, Action.Frame], + ['tick-that-ends-the-game-keeps-the-queue', atTop, Action.Tick], + ['tick-while-paused-keeps-the-queue', { ...queuedLeft, status: 'Paused' }, Action.Tick], + ['toggle-pause-keeps-the-queue', queuedLeft, Action.TogglePause], + ['restart-clears-the-queue', queuedLeft, Action.Restart], +].map(([name, s, a]) => ({ name, args: [s, a], expected: update(s, a) })) +const f = FIX + 'm10-update.json' +const fx = JSON.parse(fs.readFileSync(f, 'utf8')) +const names = new Set(cases.map((c) => c.name)) +const out = { ...fx, cases: [...fx.cases.filter((c) => !names.has(c.name)), ...cases] } +fs.writeFileSync(f, JSON.stringify(out, null, 2) + '\n') +for (const c of cases) { const [s] = c.args; const e = c.expected; console.log(c.name.padEnd(66), `${s.status} d=${s.dir} n=${s.nextDir} q=${s.queued} head=${JSON.stringify(s.snake[0])} f=${s.frame} -> ${e.status} d=${e.dir} n=${e.nextDir} q=${e.queued} head=${JSON.stringify(e.snake[0])} f=${e.frame} apple=${JSON.stringify(e.apple)} seed=${e.seed}`) } diff --git a/scripts/fixtures/m10-viewer.mjs b/scripts/fixtures/m10-viewer.mjs new file mode 100644 index 0000000..d0945c6 --- /dev/null +++ b/scripts/fixtures/m10-viewer.mjs @@ -0,0 +1,42 @@ +import { writeFileSync } from 'node:fs' +import { viewerUpdate, viewerKey, viewerView, frames, ViewerAction as A } from '../../js/solutions/replay.js' +// Fixture regeneration aid (see scripts/fixtures/README.md). Written during the 2026-11-11 build, kept for reference. +// Writes into fixtures/ unless FIXTURES_OUT names another directory (with a trailing slash). +const FIX = process.env.FIXTURES_OUT ?? new URL('../../fixtures/', import.meta.url).pathname +const SOL = new URL('../../js/solutions/', import.meta.url).href +const HERE = new URL('./', import.meta.url).pathname +const F = FIX +const fmt = (fn, cases) => '{\n "function": "' + fn + '",\n "cases": [\n' + cases.map((c) => ' ' + JSON.stringify(c)).join(',\n') + '\n ]\n}\n' +const v = (index, last, playing) => ({ index, last, playing }) +const u = [ + ['next-moves-one-frame-forward', v(3, 10, false), A.Next], + ['next-at-the-last-frame-stays', v(10, 10, false), A.Next], + ['prev-moves-one-frame-back', v(3, 10, false), A.Prev], + ['prev-at-the-first-frame-stays', v(0, 10, false), A.Prev], + ['first-goes-to-frame-zero', v(7, 10, false), A.First], + ['last-goes-to-the-last-frame', v(2, 10, false), A.Last], + ['toggle-play-starts-autoplay', v(4, 10, false), A.TogglePlay], + ['toggle-play-stops-autoplay', v(4, 10, true), A.TogglePlay], + ['toggle-play-at-the-end-starts-again-from-the-first-frame', v(10, 10, false), A.TogglePlay], + ['beat-while-playing-moves-one-frame-forward', v(4, 10, true), A.Beat], + ['beat-while-not-playing-is-ignored', v(4, 10, false), A.Beat], + ['beat-that-reaches-the-last-frame-stops-autoplay', v(9, 10, true), A.Beat], + ['next-while-playing-keeps-playing', v(4, 10, true), A.Next], + ['prev-while-playing-keeps-playing', v(4, 10, true), A.Prev], + ['last-while-playing-stops-autoplay', v(4, 10, true), A.Last], + ['first-while-playing-keeps-playing', v(4, 10, true), A.First], + ['a-recording-with-no-actions-cannot-play', v(0, 0, false), A.TogglePlay], + ['a-recording-with-no-actions-stays-at-frame-zero', v(0, 0, false), A.Next], +] +writeFileSync(F + 'm10-viewer-update.json', fmt('viewerUpdate', u.map(([name, s, a]) => ({ name, args: [s, a], expected: viewerUpdate(s, a) })))) +const keys = [['ArrowRight-is-next','ArrowRight'],['d-is-next','d'],['D-is-next','D'],['ArrowLeft-is-prev','ArrowLeft'],['a-is-prev','a'],['A-is-prev','A'], + ['Home-is-first','Home'],['End-is-last','End'],['space-toggles-play',' '],['ArrowUp-is-not-bound','ArrowUp'],['r-is-not-bound','r'], + ['constructor-is-not-bound','constructor'],['toString-is-not-bound','toString'],['empty-string-is-not-bound','']] +writeFileSync(F + 'm10-viewer-key.json', fmt('viewerKey', keys.map(([name, k]) => ({ name, args: [k], expected: viewerKey(k) })))) +const fs = frames({ seed: 42, actions: [{ tag: 'Tick' }, { tag: 'Turn', dir: 'Up' }] }) +const vw = [ + ['first-frame-not-playing', v(0, 2, false)], + ['middle-frame-while-playing', v(1, 2, true)], + ['last-frame', v(2, 2, false)], +] +writeFileSync(F + 'm10-viewer-view.json', fmt('viewerView', vw.map(([name, s]) => ({ name, args: [fs, s], expected: viewerView(fs, s) })))) diff --git a/scripts/fixtures/m12-violations-queue-cases.mjs b/scripts/fixtures/m12-violations-queue-cases.mjs new file mode 100644 index 0000000..01b66b4 --- /dev/null +++ b/scripts/fixtures/m12-violations-queue-cases.mjs @@ -0,0 +1,20 @@ +import fs from 'node:fs' +// Fixture regeneration aid (see scripts/fixtures/README.md). Written during the 2026-11-11 build, kept for reference. +// Writes into fixtures/ unless FIXTURES_OUT names another directory (with a trailing slash). +const FIX = process.env.FIXTURES_OUT ?? new URL('../../fixtures/', import.meta.url).pathname +const SOL = new URL('../../js/solutions/', import.meta.url).href +const HERE = new URL('./', import.meta.url).pathname +const { violations } = await import(SOL + 'check.js') +const f = FIX + 'm12-violations.json' +const fx = JSON.parse(fs.readFileSync(f, 'utf8')) +const sane = fx.cases.find((c) => c.name === 'a-pending-perpendicular-turn-is-fine').args[0] +console.log(JSON.stringify(sane)) +const mk = (name, patch) => { const s = { ...sane, ...patch }; return { name, args: [s], expected: violations(s) } } +const cases = [ + mk('a-queued-turn-is-fine', { dir: 'Right', nextDir: 'Up', queued: 'Left' }), + mk('queued-turn-straight-back-from-the-pending-turn', { dir: 'Right', nextDir: 'Up', queued: 'Down' }), + mk('reversed-pending-turn-and-reversed-queue-are-both-listed', { dir: 'Right', nextDir: 'Left', queued: 'Right' }), +] +cases.forEach((c) => console.log(c.name, JSON.stringify(c.expected))) +const names = new Set(cases.map((c) => c.name)) +fs.writeFileSync(f, JSON.stringify({ ...fx, cases: [...fx.cases.filter((c) => !names.has(c.name)), ...cases] }, null, 2) + '\n') diff --git a/scripts/fixtures/m12-violations.mjs b/scripts/fixtures/m12-violations.mjs new file mode 100644 index 0000000..7025449 --- /dev/null +++ b/scripts/fixtures/m12-violations.mjs @@ -0,0 +1,33 @@ +import { writeFileSync } from 'node:fs' +// Fixture regeneration aid (see scripts/fixtures/README.md). Written during the 2026-11-11 build, kept for reference. +// Writes into fixtures/ unless FIXTURES_OUT names another directory (with a trailing slash). +const FIX = process.env.FIXTURES_OUT ?? new URL('../../fixtures/', import.meta.url).pathname +const SOL = new URL('../../js/solutions/', import.meta.url).href +const HERE = new URL('./', import.meta.url).pathname +const { violations } = await import(SOL + 'check.js') +const base = { 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 } +const P = (x, y) => ({ x, y }) +const cases = [ + ['a-sane-state-has-no-violations', {}], + ['no-apple-is-fine', { apple: null }], + ['a-pending-perpendicular-turn-is-fine', { nextDir: 'Up' }], + ['a-one-cell-snake-is-fine', { snake: [P(0, 0)] }], + ['a-game-over-state-can-be-sane', { status: 'GameOver', snake: [P(4, 2), P(3, 2), P(2, 2)] }], + ['empty-snake', { snake: [] }], + ['head-off-the-right-edge', { snake: [P(5, 2), P(4, 2), P(3, 2)] }], + ['tail-above-the-board', { snake: [P(1, 1), P(1, 0), P(1, -1)] }], + ['head-on-its-own-body', { snake: [P(2, 2), P(2, 3), P(1, 3), P(1, 2), P(2, 2)] }], + ['snake-with-a-gap', { snake: [P(3, 2), P(1, 2), P(0, 2)] }], + ['snake-with-a-diagonal-step', { snake: [P(2, 2), P(1, 1), P(0, 1)] }], + ['apple-outside-the-board', { apple: P(5, 0) }], + ['apple-on-the-head', { apple: P(2, 2) }], + ['apple-on-the-tail', { apple: P(0, 2) }], + ['pending-turn-straight-back', { nextDir: 'Left' }], + ['pending-turn-straight-back-vertical', { dir: 'Up', nextDir: 'Down' }], + ['several-violations-are-listed-in-order', { snake: [P(5, 2), P(3, 2), P(3, 2)], apple: P(3, 2), nextDir: 'Left' }], + ['doubled-cell-counts-as-overlap-and-as-disconnected', { snake: [P(2, 2), P(2, 2)] }], +] +const fx = { function: 'violations', cases: cases.map(([name, patch]) => { const s = { ...base, ...patch }; return { name, args: [s], expected: violations(s) } }) } +writeFileSync(FIX + 'm12-violations.json', JSON.stringify(fx, null, 2) + '\n') +fx.cases.forEach((c) => console.log(c.name, JSON.stringify(c.expected))) diff --git a/scripts/fixtures/m13-life.mjs b/scripts/fixtures/m13-life.mjs new file mode 100644 index 0000000..00cbee3 --- /dev/null +++ b/scripts/fixtures/m13-life.mjs @@ -0,0 +1,78 @@ +import { writeFileSync } from 'node:fs' +import { lifeStep, lifeUpdate } from '../../js/solutions/life/rules.js' +import { lifeView } from '../../js/solutions/life/view.js' +import { lifeKey, lifeClick } from '../../js/solutions/life/app.js' +import { LifeAction as A, initialLife } from '../../js/solutions/life/model.js' +// Fixture regeneration aid (see scripts/fixtures/README.md). Written during the 2026-11-11 build, kept for reference. +// Writes into fixtures/ unless FIXTURES_OUT names another directory (with a trailing slash). +const FIX = process.env.FIXTURES_OUT ?? new URL('../../fixtures/', import.meta.url).pathname +const SOL = new URL('../../js/solutions/', import.meta.url).href +const HERE = new URL('./', import.meta.url).pathname +const F = FIX +const fmt = (fn, cases) => '{\n "function": "' + fn + '",\n "cases": [\n' + cases.map((c) => ' ' + JSON.stringify(c)).join(',\n') + '\n ]\n}\n' +const P = (...xy) => { const out = []; for (let i = 0; i < xy.length; i += 2) out.push({ x: xy[i], y: xy[i + 1] }); return out.sort((a, b) => a.y - b.y || a.x - b.x) } +const step = [ + ['an-empty-board-stays-empty', []], + ['a-lonely-cell-dies', P(5, 5)], + ['two-cells-die-of-loneliness', P(5, 5, 6, 5)], + ['a-block-is-stable', P(5, 5, 6, 5, 5, 6, 6, 6)], + ['a-horizontal-blinker-turns-vertical', P(4, 5, 5, 5, 6, 5)], + ['a-vertical-blinker-turns-horizontal', P(5, 4, 5, 5, 5, 6)], + ['three-cells-in-an-l-give-birth-to-a-block', P(5, 5, 6, 5, 5, 6)], + ['a-cell-with-four-neighbours-dies-of-overcrowding', P(5, 4, 4, 5, 5, 5, 6, 5, 5, 6)], + ['a-glider-moves-and-the-result-is-sorted-row-major', P(1, 0, 2, 1, 0, 2, 1, 2, 2, 2)], + ['a-block-in-the-corner-is-stable', P(0, 0, 1, 0, 0, 1, 1, 1)], + ['a-block-in-the-far-corner-is-stable', P(38, 28, 39, 28, 38, 29, 39, 29)], + ['a-blinker-on-the-top-edge-cannot-grow-beyond-it', P(4, 0, 5, 0, 6, 0)], + ['a-blinker-on-the-right-edge-does-not-wrap-to-the-left-edge', P(39, 9, 39, 10, 39, 11)], + ['cells-on-opposite-edges-are-not-neighbours', P(0, 10, 39, 9, 39, 10, 39, 11)], + ['a-blinker-on-the-bottom-edge-cannot-grow-beyond-it', P(10, 29, 11, 29, 12, 29)], +] +writeFileSync(F + 'm13-life-step.json', fmt('lifeStep', step.map(([name, c]) => ({ name, args: [c], expected: lifeStep(c) })))) + +const st = (cells, running, seed, generation) => ({ cells, running, seed, generation }) +const blinker = P(4, 5, 5, 5, 6, 5) +const upd = [ + ['frame-while-running-computes-the-next-generation', st(blinker, true, 7, 3), A.Frame], + ['frame-while-paused-is-unchanged', st(blinker, false, 7, 3), A.Frame], + ['frame-on-an-empty-running-board-still-counts-the-generation', st([], true, 7, 3), A.Frame], + ['step-while-paused-computes-one-generation', st(blinker, false, 7, 3), A.Step], + ['step-while-running-is-ignored', st(blinker, true, 7, 3), A.Step], + ['toggle-pause-starts-a-paused-world', st(blinker, false, 7, 3), A.TogglePause], + ['toggle-pause-pauses-a-running-world', st(blinker, true, 7, 3), A.TogglePause], + ['toggle-on-an-empty-board-adds-the-cell', st([], false, 7, 0), A.Toggle({ x: 3, y: 2 })], + ['toggle-adds-a-dead-cell-in-row-major-order', st(blinker, false, 7, 3), A.Toggle({ x: 9, y: 4 })], + ['toggle-adds-a-cell-between-two-on-the-same-row', st(P(4, 5, 6, 5), false, 7, 3), A.Toggle({ x: 5, y: 5 })], + ['toggle-adds-a-cell-before-all-others', st(blinker, false, 7, 3), A.Toggle({ x: 0, y: 0 })], + ['toggle-adds-a-cell-after-all-others', st(blinker, false, 7, 3), A.Toggle({ x: 39, y: 29 })], + ['toggle-removes-a-live-cell', st(blinker, false, 7, 3), A.Toggle({ x: 5, y: 5 })], + ['toggle-adds-a-cell-at-the-start-of-a-row', st(blinker, false, 7, 3), A.Toggle({ x: 4, y: 5 })], + ['toggle-works-while-running-too', st(blinker, true, 7, 3), A.Toggle({ x: 5, y: 6 })], + ['toggle-off-the-board-is-ignored', st(blinker, false, 7, 3), A.Toggle({ x: 40, y: 3 })], + ['toggle-above-the-board-is-ignored', st(blinker, false, 7, 3), A.Toggle({ x: 3, y: -1 })], + ['toggle-just-below-the-board-is-ignored', st(blinker, false, 7, 3), A.Toggle({ x: 3, y: 30 })], + ['clear-empties-the-board-and-restarts-the-count', st(blinker, true, 7, 3), A.Clear], + ['randomize-fills-the-board-from-the-seed-and-advances-it', st([], false, 42, 0), A.Randomize], + ['randomize-replaces-the-cells-restarts-the-count-and-keeps-running', st(blinker, true, 7, 12), A.Randomize], +] +// toggle-twice: apply on the result of removing (4,5): start state has blinker minus (4,5) +upd[13][1] = st(P(5, 5, 6, 5), false, 7, 3) +writeFileSync(F + 'm13-life-update.json', fmt('lifeUpdate', upd.map(([name, s, a]) => ({ name, args: [s, a], expected: lifeUpdate(s, a) })))) + +const vw = [ + ['a-new-world-is-an-empty-paused-board', initialLife(42)], + ['live-cells-in-row-major-order-while-running', st(P(0, 0, 5, 5, 39, 29), true, 1, 17)], + ['paused-after-some-generations', st(P(4, 5, 5, 5, 6, 5), false, 1, 4)], +] +writeFileSync(F + 'm13-life-view.json', fmt('lifeView', vw.map(([name, s]) => ({ name, args: [s], expected: lifeView(s) })))) + +const keys = [['space-toggles-pause', ' '], ['n-steps', 'n'], ['N-steps', 'N'], ['c-clears', 'c'], ['C-clears', 'C'], + ['r-randomizes', 'r'], ['R-randomizes', 'R'], ['ArrowUp-is-not-bound', 'ArrowUp'], ['Enter-is-not-bound', 'Enter'], + ['constructor-is-not-bound', 'constructor'], ['toString-is-not-bound', 'toString'], ['empty-string-is-not-bound', '']] +writeFileSync(F + 'm13-life-key.json', fmt('lifeKey', keys.map(([name, k]) => ({ name, args: [k], expected: lifeKey(k) })))) + +const clicks = [['the-top-left-pixel-is-cell-0-0', 0, 0], ['the-last-pixel-of-the-first-cell', 15, 15], ['the-next-pixel-is-the-next-cell', 16, 0], + ['the-row-below', 3, 16], ['a-pixel-in-the-middle', 100, 250], ['the-bottom-right-pixel-of-the-board', 639, 479], + ['right-of-the-board-is-nothing', 640, 10], ['the-status-strip-under-the-board-is-nothing', 10, 480], ['left-of-the-board-is-nothing', -1, 10], + ['above-the-board-is-nothing', 10, -1]] +writeFileSync(F + 'm13-life-click.json', fmt('lifeClick', clicks.map(([name, x, y]) => ({ name, args: [x, y], expected: lifeClick(x, y) })))) diff --git a/scripts/fixtures/replay-sample-bot.mjs b/scripts/fixtures/replay-sample-bot.mjs new file mode 100644 index 0000000..78a7691 --- /dev/null +++ b/scripts/fixtures/replay-sample-bot.mjs @@ -0,0 +1,40 @@ +import { newGame } from '../../js/solutions/spawn.js' +import { update } from '../../js/solutions/game.js' +import { opposite } from '../../js/solutions/model.js' +// Fixture regeneration aid (see scripts/fixtures/README.md). Written during the 2026-11-11 build, kept for reference. +// Writes into fixtures/ unless FIXTURES_OUT names another directory (with a trailing slash). +const FIX = process.env.FIXTURES_OUT ?? new URL('../../fixtures/', import.meta.url).pathname +const SOL = new URL('../../js/solutions/', import.meta.url).href +const HERE = new URL('./', import.meta.url).pathname +const dirs = ['Up', 'Down', 'Left', 'Right'] +const play = (seed) => { + let s = newGame(seed); const actions = [] + while (s.status === 'Playing' && actions.length < 600) { + const options = dirs.filter((d) => d !== opposite(s.dir)).map((d) => { + const t = update(update(s, { tag: 'Turn', dir: d }), { tag: 'Tick' }) + const h = t.snake[0] + const dist = s.apple ? Math.abs(h.x - s.apple.x) + Math.abs(h.y - s.apple.y) : 0 + return { d, alive: t.status !== 'GameOver', dist: dist + (d === s.dir ? -0.1 : 0) } + }) + const alive = options.filter((o) => o.alive).sort((a, b) => a.dist - b.dist) + const pick = (alive[0] ?? options[0]).d + if (pick !== s.dir) { actions.push({ tag: 'Turn', dir: pick }); s = update(s, actions.at(-1)) } + actions.push({ tag: 'Tick' }); s = update(s, actions.at(-1)) + } + return { seed, actions, score: s.score, status: s.status } +} +const results = [] +for (let seed = 1; seed < 3000; seed++) { + const r = play(seed) + if (r.status === 'GameOver' && r.actions.length >= 150 && r.actions.length <= 400 && r.score >= 5) results.push(r) +} +console.log(results.length, results.slice(0, 15).map((r) => [r.seed, r.actions.length, r.score])) +const best = results.sort((a, b) => b.score - a.score)[0] +console.log('best', best.seed, best.actions.length, best.score) +if (process.argv[2]) { + const r = play(Number(process.argv[2])) + const { writeFileSync } = await import('node:fs') + writeFileSync(FIX + 'replay-sample.json', + '{\n "seed": ' + r.seed + ',\n "actions": [\n' + r.actions.map((a) => ' ' + JSON.stringify(a)).join(',\n') + '\n ]\n}\n') + console.log('wrote', r.seed, r.actions.length, r.score, r.status) +}