explodorama / files / tools / report-replay.mjs
report-replay.mjs
| 1 | #!/usr/bin/env node |
| 2 | // Turns a bug report into a replay URL (Q103): node tools/report-replay.mjs <mail.eml | report.txt | record.json> |
| 3 | // Finds the "Run record: {...}" line (the mail's quoted-printable text is decoded first), or a bare |
| 4 | // record, writes replays/<name>.json (git-ignored; served by `npm run dev` as /__replay/<name>.json) |
| 5 | // and prints the addresses of the pages that play it (the page has two players, red and blue): |
| 6 | // ?replay=<name> the report's last turns (red) against today's autopilot (blue); |
| 7 | // ?replay=<name>&rules=<commit> the whole run played by the rules of that commit (red) and by today's (blue). |
| 8 | // The commit is the one the report says it was made on (or `--rules <commit>`); its `src/sim` is |
| 9 | // taken out of git into rules-versions/<commit>/ (git-ignored) for the page to load. |
| 10 | import { execFileSync } from 'node:child_process' |
| 11 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' |
| 12 | import { basename } from 'node:path' |
| 13 | import { fileURLToPath } from 'node:url' |
| 14 | |
| 15 | const args = process.argv.slice(2) |
| 16 | const flag = args.indexOf('--rules') |
| 17 | const wanted = flag >= 0 ? args.splice(flag, 2)[1] : null |
| 18 | const file = args[0] |
| 19 | if (!file) { |
| 20 | console.error('usage: node tools/report-replay.mjs <bug report mail or text file>') |
| 21 | process.exit(1) |
| 22 | } |
| 23 | const raw = readFileSync(file, 'latin1') |
| 24 | |
| 25 | /** Quoted-printable to text: soft line breaks removed, =XX bytes read as UTF-8. */ |
| 26 | function decodeQp(text) { |
| 27 | const joined = text.replace(/=\r?\n/g, '') |
| 28 | const bytes = [] |
| 29 | for (let i = 0; i < joined.length; i++) { |
| 30 | if (joined[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(joined.slice(i + 1, i + 3))) { |
| 31 | bytes.push(parseInt(joined.slice(i + 1, i + 3), 16)) |
| 32 | i += 2 |
| 33 | } else bytes.push(...Buffer.from(joined[i], 'latin1')) |
| 34 | } |
| 35 | return Buffer.from(bytes).toString('utf8') |
| 36 | } |
| 37 | |
| 38 | const text = /quoted-printable/i.test(raw) ? decodeQp(raw) : raw |
| 39 | const found = text.match(/Run record: (\{.*\})\s*$/m) |
| 40 | let record |
| 41 | try { |
| 42 | record = JSON.parse(found ? found[1] : text.trim()) |
| 43 | } catch { |
| 44 | console.error('No run record found in', file) |
| 45 | process.exit(1) |
| 46 | } |
| 47 | // The record comes from a mail: nothing in it may end up in a path unchecked. |
| 48 | if (!Array.isArray(record.actions) || !Number.isInteger(record.seed) || !Number.isInteger(record.simVersion)) { |
| 49 | console.error('That is not a run record (it needs an integer seed and simVersion, and actions).') |
| 50 | process.exit(1) |
| 51 | } |
| 52 | const name = `seed${record.seed}-v${record.simVersion}-${record.actions.length}` |
| 53 | mkdirSync(new URL('../replays/', import.meta.url), { recursive: true }) |
| 54 | writeFileSync(new URL(`../replays/${name}.json`, import.meta.url), JSON.stringify(record)) |
| 55 | console.log(`Record: ${record.actions.length} actions, seed ${record.seed}, rules ${record.simVersion} (from ${basename(file)})`) |
| 56 | console.log(`Replay (with \`npm run dev\` running): http://localhost:5173/?replay=${name}`) |
| 57 | |
| 58 | // The rules the report was made on: its "commit abc1234" line, or --rules. |
| 59 | const commit = wanted ?? text.match(/commit ([0-9a-f]{7,40})/)?.[1] |
| 60 | if (commit && !/^[0-9a-f]{7,40}$/.test(commit)) { |
| 61 | console.error('--rules takes a commit hash (7 to 40 hex digits), not a name like a tag or HEAD~1.') |
| 62 | process.exit(1) |
| 63 | } |
| 64 | if (commit) { |
| 65 | const root = fileURLToPath(new URL('..', import.meta.url)) |
| 66 | const dir = new URL(`../rules-versions/${commit}/`, import.meta.url) |
| 67 | try { |
| 68 | const full = execFileSync('git', ['rev-parse', '--verify', `${commit}^{commit}`], { cwd: root, encoding: 'utf8' }).trim() |
| 69 | if (!existsSync(new URL('src/sim/turn.ts', dir))) { |
| 70 | mkdirSync(dir, { recursive: true }) |
| 71 | const tar = execFileSync('git', ['archive', full, 'src/sim'], { cwd: root, maxBuffer: 1 << 28 }) |
| 72 | execFileSync('tar', ['-x', '-C', fileURLToPath(dir)], { input: tar }) |
| 73 | } |
| 74 | console.log(`Rules of ${commit} against today's, the whole run: http://localhost:5173/?replay=${name}&rules=${commit}`) |
| 75 | } catch { |
| 76 | console.log(`(No rules page: commit ${commit} is not in this repository.)`) |
| 77 | } |
| 78 | } |