// Offline generator for Rush Hour puzzles. // // Random-fills a 6x6 board with a red target car plus cars (len 2) and trucks // (len 3), runs the BFS solver to (a) reject unsolvable/trivial layouts and // (b) label each survivor with its minimum-moves difficulty, then selects a // smooth easy->expert curve and writes ordered levels to public/data/rushhour.json. // // Usage: // node server/scripts/genRushHour.js [seed] [outFile] // // Deterministic: same seed -> same bank. Re-run after changing the curve. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { GRID, EXIT_ROW, TARGET_ID, isSolved, solve, vehicleCells, } from '../../public/src/games/rushhour/RushHourLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUT_FILE = process.argv[3] ? path.resolve(process.argv[3]) : path.join(__dirname, '../../public/data/rushhour.json'); const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x9e3779b9; // ── Seeded RNG (mulberry32) ────────────────────────────────────────────────── function makeRng(seed) { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rng = makeRng(SEED); const randInt = (n) => Math.floor(rng() * n); const pick = (arr) => arr[randInt(arr.length)]; // Difficulty curve: count + [min,max] minMoves per tier. Sums to the bank size. const TIERS = [ { count: 8, min: 2, max: 4 }, { count: 10, min: 5, max: 7 }, { count: 10, min: 8, max: 11 }, { count: 8, min: 12, max: 15 }, { count: 7, min: 16, max: 20 }, { count: 5, min: 21, max: 40 }, ]; const MAX_ATTEMPTS = 8000000; const MAX_SECONDS = 220; // wall-clock budget const SOLVE_MAX_STATES = 12000; // bound per-solve cost (rejects pathological layouts fast) const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop'; // Canonical, label-independent key for dedup. function canonKey(vehicles) { return vehicles .map((v) => `${v.x},${v.y},${v.len},${v.orient},${v.isTarget ? 1 : 0}`) .sort() .join('|'); } function fits(grid, v) { for (const [x, y] of vehicleCells(v)) { if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false; if (grid[y][x] !== null) return false; } return true; } function stamp(grid, v) { for (const [x, y] of vehicleCells(v)) grid[y][x] = v.id; } // Build one random candidate layout (target + blockers), or null if it failed // to place the target. function randomLayout() { const grid = Array.from({ length: GRID }, () => Array(GRID).fill(null)); const vehicles = []; // Target car: horizontal, length 2, starting far left so its escape path is // long and likely blocked -> richer puzzles. const target = { id: TARGET_ID, x: randInt(2), y: EXIT_ROW, len: 2, orient: 'h', isTarget: true }; stamp(grid, target); vehicles.push(target); const count = 6 + randInt(5); // 6..10 blockers let letterIdx = 0; let tries = 0; while (vehicles.length < count + 1 && tries < 200) { tries++; // Favour vertical cars: they cross the exit row and create real blockades. const orient = rng() < 0.6 ? 'v' : 'h'; const len = rng() < 0.3 ? 3 : 2; const x = orient === 'h' ? randInt(GRID - len + 1) : randInt(GRID); const y = orient === 'h' ? randInt(GRID) : randInt(GRID - len + 1); const v = { id: LETTERS[letterIdx], x, y, len, orient, isTarget: false }; if (!fits(grid, v)) continue; stamp(grid, v); vehicles.push(v); letterIdx++; } return vehicles; } // ── Generate pool ──────────────────────────────────────────────────────────── console.log(`[rushhour] generating with seed ${SEED}…`); const target = TIERS.reduce((t, x) => t + x.count, 0); const buckets = TIERS.map(() => []); const seen = new Set(); let attempts = 0; let solved = 0; function tierIndexFor(moves) { for (let i = 0; i < TIERS.length; i++) { if (moves >= TIERS[i].min && moves <= TIERS[i].max) return i; } return -1; } const tierFull = () => buckets.every((b, i) => b.length >= TIERS[i].count); const startedAt = Date.now(); while (attempts < MAX_ATTEMPTS && !tierFull()) { attempts++; if ((attempts & 0x3ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) { console.log('\n[rushhour] time budget reached, stopping early'); break; } const vehicles = randomLayout(); if (isSolved(vehicles)) continue; // already at exit -> trivial const key = canonKey(vehicles); if (seen.has(key)) continue; seen.add(key); const { moves } = solve(vehicles, { maxStates: SOLVE_MAX_STATES }); if (moves < 2) continue; // unsolvable or 1-move (no blockers) solved++; const ti = tierIndexFor(moves); if (ti === -1) continue; if (buckets[ti].length >= TIERS[ti].count) continue; buckets[ti].push({ minMoves: moves, vehicles }); if (solved % 2000 === 0) { process.stdout.write(`\r[rushhour] attempts=${attempts} kept=${buckets.reduce((t, b) => t + b.length, 0)}/${target} `); } } process.stdout.write('\n'); // ── Assemble ordered levels ────────────────────────────────────────────────── const chosen = []; buckets.forEach((b) => { b.sort((p, q) => p.minMoves - q.minMoves); chosen.push(...b); }); // Overall ascending so levels ramp smoothly even across tier boundaries. chosen.sort((p, q) => p.minMoves - q.minMoves); const puzzles = chosen.map((p, i) => ({ level: i + 1, minMoves: p.minMoves, vehicles: p.vehicles, })); const payload = { generatedAt: new Date().toISOString(), seed: SEED, count: puzzles.length, puzzles, }; fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2)); const perTier = buckets.map((b, i) => `${b.length}/${TIERS[i].count}`).join(' '); console.log(`[rushhour] attempts=${attempts} solvable=${solved}`); console.log(`[rushhour] tiers filled: ${perTier}`); console.log(`[rushhour] wrote ${puzzles.length} levels (minMoves ${puzzles[0]?.minMoves}..${puzzles[puzzles.length - 1]?.minMoves}) -> ${OUT_FILE}`);