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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e3cea5eac
commit
5062838604
17 files changed
+432
-3
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ The course is complete and green. It teaches functional programming by building
|
||||
| `<track>/src`, `<track>/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.
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"}]
|
||||
@@ -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}]
|
||||
@@ -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}]
|
||||
@@ -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),
|
||||
]))
|
||||
@@ -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}`) }
|
||||
@@ -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) }))))
|
||||
@@ -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')
|
||||
@@ -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)))
|
||||
@@ -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) }))))
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user