// Offline curriculum generator for Rush Hour. // // Usage: // node tools/genRushHour.js [seed] [outFile] // // Deterministic: same seed -> same bank. // // ── Why this is not just "random boards, keep the solvable ones" ───────────── // The previous generator reject-sampled random layouts and kept whatever the // solver reported. That produces padded puzzles: measured on the old 45-level // bank, 32 levels carried at least one vehicle that could be deleted without // changing the solution, and level 1 had 10 vehicles of which 8 were pure // decoration and solved in 2 moves. A board that *looks* busy but is trivially // empty is exactly what makes a sliding-block game feel unengaging. // // Michael Fogleman's exhaustive study of the 6x6 board // (https://www.michaelfogleman.com/rush/) names the properties that separate a // real puzzle from a padded one. We enforce four: // // 1. MINIMAL — removing any vehicle changes the solution. // 2. UNSOLVED — the start is the state farthest from the goal in its // reachable cluster, so no rearrangement of these pieces is // harder and there is no shortcut to stumble into. // 3. No complete row of horizontal pieces, no complete column of vertical // pieces (such lines can never move). // 4. Nothing but the red car on the exit row. // // ── Pipeline ───────────────────────────────────────────────────────────────── // The key move is `refine()`. Rather than *rejecting* a board that carries a // spare vehicle, it strips the spare out — removing a redundant piece leaves // par unchanged by definition — and then re-hardens: with fewer pieces the // cluster often reaches a farther state, so par goes UP. Stripping and // hardening feed each other, and the loop terminates at a board that is // simultaneously minimal and unsolved. Car count therefore falls out of the // difficulty rather than being dialled in, which is what gives the curriculum // its shape: easy levels are genuinely small boards where every car matters, // not big boards with a two-move answer. // // Phase A seeds a pool by random sampling. Phase B hill-climbs from the best // boards found (add / relocate / lengthen a piece, then refine again), which is // what reaches the top tiers — pure sampling plateaus around par 24. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { GRID, EXIT_ROW, TARGET_ID, analyzeCluster, solve, } from '../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, '../assets/gamedata/rushhour/levels.json'); const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x9e3779b9; // ── Tuning ─────────────────────────────────────────────────────────────────── // Budgets are counted in iterations, not wall clock: a time-bounded loop makes // the output depend on how fast the machine happened to be, which would mean // the committed bank could not be reproduced from its seed. WALL_CLOCK_CAP is // only an emergency brake, and firing it is treated as a failure. const PHASE_A_ATTEMPTS = Number(process.env.RH_PHASE_A ?? 700); const PHASE_B_CLIMBS = Number(process.env.RH_PHASE_B ?? 3000); const WALL_CLOCK_CAP = Number(process.env.RH_MAX_SECONDS ?? 2400); const CLUSTER_MAX_STATES = 300000; const ELITE_POOL = 48; // Difficulty tiers. `par` is the move band; vehicle count is an *outcome* of // refine(), not an input, so it is reported rather than constrained. const TIERS = [ { id: 'downtown', name: 'Downtown', count: 12, par: [5, 9], names: ['First Gear', 'Fender Bender', 'One Way Out', 'Meter Maid', 'Corner Store', 'Crosswalk', 'Double Park', 'Side Street', 'Red Light', 'Delivery Van', 'Taxi Stand', 'Grid Lock'], }, { id: 'freightyard', name: 'Freight Yard', count: 12, par: [10, 14], names: ['Loading Dock', 'Long Haul', 'Container Row', 'Weigh Station', 'Flatbed', 'Coupling', 'Yard Shunt', 'Box Car', 'Diesel Lane', 'The Straddle', 'Cargo Jam', 'Last Wagon'], }, { id: 'airport', name: 'Airport Apron', count: 12, par: [15, 19], names: ['Pushback', 'Baggage Train', 'Fuel Bowser', 'Taxiway Bravo', 'Ground Hold', 'Jet Bridge', 'De-icer', 'Catering Lift', 'Stand 21', 'Runway Cross', 'Apron Shuffle', 'Final Approach'], }, { id: 'construction', name: 'Construction', count: 12, par: [20, 25], names: ['Ground Break', 'Skip Loader', 'Cement Mixer', 'Steel Beam', 'Backhoe', 'Scaffold', 'Tipper Truck', 'Crane Base', 'Rebar', 'Site Gate', 'Dozer Line', 'Hard Hat'], }, { id: 'nightcity', name: 'Night City', count: 12, par: [26, 99], names: ['Neon Mile', 'Last Call', 'Wet Asphalt', 'Midnight Run', 'Streetlight', 'After Hours', 'Rain Check', 'Chrome', 'Blackout', 'Red Line', 'The Long Night', 'Dead of Night'], }, ]; // ── 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); // ── Board helpers ──────────────────────────────────────────────────────────── const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVW'; function cellsOf(v) { const out = []; for (let i = 0; i < v.len; i++) out.push(v.orient === 'h' ? [v.x + i, v.y] : [v.x, v.y + i]); return out; } function occupancy(vehicles) { const occ = Array.from({ length: GRID }, () => Array(GRID).fill(null)); for (const v of vehicles) for (const [x, y] of cellsOf(v)) occ[y][x] = v.id; return occ; } function fits(occ, v) { for (const [x, y] of cellsOf(v)) { if (x < 0 || x >= GRID || y < 0 || y >= GRID) return false; if (occ[y][x] !== null) return false; } return true; } // 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('|'); } // Criteria 3 and 4. Orientation and fixed axis never change as pieces slide, so // the exit-row rule is invariant once placed; the frozen-line rule is not, and // must be checked on the final arrangement. function structurallyOk(vehicles) { for (const v of vehicles) { if (!v.isTarget && v.orient === 'h' && v.y === EXIT_ROW) return false; } const occ = occupancy(vehicles); const byId = new Map(vehicles.map((v) => [v.id, v])); for (let y = 0; y < GRID; y++) { let full = true; for (let x = 0; x < GRID; x++) { const id = occ[y][x]; if (id === null || byId.get(id).orient !== 'h') { full = false; break; } } if (full) return false; } for (let x = 0; x < GRID; x++) { let full = true; for (let y = 0; y < GRID; y++) { const id = occ[y][x]; if (id === null || byId.get(id).orient !== 'v') { full = false; break; } } if (full) return false; } return true; } // Relabel deterministically: target is X, the rest A.. in reading order. Keeps // the shipped JSON (and the colour slots the scene derives from it) stable. function normalize(vehicles) { const target = vehicles.find((v) => v.isTarget); const rest = vehicles.filter((v) => !v.isTarget) .sort((a, b) => (a.y - b.y) || (a.x - b.x) || (a.orient < b.orient ? -1 : 1)); return [ { id: TARGET_ID, x: target.x, y: target.y, len: target.len, orient: 'h', isTarget: true }, ...rest.map((v, i) => ({ id: LETTERS[i], x: v.x, y: v.y, len: v.len, orient: v.orient, isTarget: false })), ]; } function randomBlocker(id) { const orient = rng() < 0.55 ? 'v' : 'h'; const len = rng() < 0.35 ? 3 : 2; const x = orient === 'h' ? randInt(GRID - len + 1) : randInt(GRID); const y = orient === 'h' ? randInt(GRID) : randInt(GRID - len + 1); return { id, x, y, len, orient, isTarget: false }; } function randomLayout(nBlockers) { const vehicles = [{ id: TARGET_ID, x: randInt(2), y: EXIT_ROW, len: 2, orient: 'h', isTarget: true }]; const occ = occupancy(vehicles); let letterIdx = 0; let tries = 0; while (vehicles.length < nBlockers + 1 && tries < 400) { tries++; const v = randomBlocker(LETTERS[letterIdx]); if (v.orient === 'h' && v.y === EXIT_ROW) continue; // would block the exit forever if (!fits(occ, v)) continue; for (const [x, y] of cellsOf(v)) occ[y][x] = v.id; vehicles.push(v); letterIdx++; } return vehicles; } // ── The core: strip to minimal, harden to the cluster's farthest state ─────── // // Removing a redundant piece cannot change par (that is what redundant means), // but it can enlarge the cluster, so the re-harden on the next pass may find a // strictly harder start. Terminates when a full sweep strips nothing. function refine(vehicles) { let cur = vehicles; for (let round = 0; round < 24; round++) { const C = analyzeCluster(cur, { maxStates: CLUSTER_MAX_STATES }); if (!C || C.maxDist < 2) return null; cur = C.toVehicles(C.hardest); const par = C.maxDist; let stripped = false; for (const v of cur) { if (v.isTarget) continue; const reduced = cur.filter((w) => w.id !== v.id); if (reduced.length < 2) continue; if (solve(reduced, { maxStates: CLUSTER_MAX_STATES }).moves === par) { cur = reduced; stripped = true; break; } } if (!stripped) return { vehicles: normalize(cur), par }; } return null; } // ── Difficulty metrics ─────────────────────────────────────────────────────── // // Par alone is a poor proxy for how hard a puzzle *feels*: a 20-move solution // made of forced moves is easier than a 12-move one with three retreats. All of // these fall out of the distance-to-goal map the cluster analysis already // computed, so they cost nothing extra. function measure(vehicles, par) { const C = analyzeCluster(vehicles, { maxStates: CLUSTER_MAX_STATES }); if (!C || C.startDist !== par) return null; const targetIdx = C.board.targetIdx; const movedBy = new Set(); let targetRetreats = 0; let decoySum = 0; let decoySteps = 0; let at = 0; let guard = 0; while (C.dist[at] > 0 && guard++ < 200) { const nbrs = C.neighbors(at); const here = C.dist[at]; const dead = nbrs.filter((nb) => C.dist[nb.index] >= here).length; if (nbrs.length) { decoySum += dead / nbrs.length; decoySteps++; } const step = nbrs.find((nb) => C.dist[nb.index] === here - 1); if (!step) return null; movedBy.add(step.vehicleIdx); if (step.vehicleIdx === targetIdx && step.pos < C.states[at][targetIdx]) targetRetreats++; at = step.index; } const firstMoveFanout = C.neighbors(0).filter((nb) => C.dist[nb.index] === par - 1).length; return { carsMoved: movedBy.size, decoyDensity: decoySteps ? decoySum / decoySteps : 0, targetRetreats, firstMoveFanout, clusterSize: C.size, }; } // Composite ranking. Par dominates (it is the player-visible number), with the // "how easy is it to go wrong" terms breaking ties within a band. function composite(par, m) { return par + 0.8 * m.carsMoved + 10 * m.decoyDensity + 2.5 * m.targetRetreats - 0.4 * m.firstMoveFanout; } // ── Search ─────────────────────────────────────────────────────────────────── const buckets = TIERS.map(() => []); const seen = new Set(); const elites = []; let refined = 0; let attempts = 0; function tierFor(par) { for (let i = 0; i < TIERS.length; i++) { if (par >= TIERS[i].par[0] && par <= TIERS[i].par[1]) return i; } return -1; } function consider(result) { if (!result) return null; const { vehicles, par } = result; if (!structurallyOk(vehicles)) return null; const key = canonKey(vehicles); if (seen.has(key)) return null; seen.add(key); refined++; // Elite pool drives phase B regardless of whether this board lands in a tier. elites.push({ vehicles, par }); elites.sort((a, b) => b.par - a.par); if (elites.length > ELITE_POOL) elites.length = ELITE_POOL; const ti = tierFor(par); if (ti === -1) return { par }; const m = measure(vehicles, par); if (!m) return { par }; buckets[ti].push({ vehicles, par, ...m, score: composite(par, m) }); return { par, tier: ti }; } // `densify` biases toward adding and lengthening pieces. Dropping a piece // usually lowers par, so when the hard tiers are the ones still short there is // little point spending climbs on it. function mutate(vehicles, densify) { const out = vehicles.map((v) => ({ ...v })); const blockers = out.filter((v) => !v.isTarget); const roll = densify ? 0.55 + rng() * 0.45 : rng(); if (roll < 0.30 && blockers.length > 2) { // Relocate: drop one piece, place a fresh one somewhere it fits. const victim = blockers[randInt(blockers.length)]; const kept = out.filter((v) => v.id !== victim.id); const occ = occupancy(kept); for (let t = 0; t < 60; t++) { const v = randomBlocker('tmp'); if (v.orient === 'h' && v.y === EXIT_ROW) continue; if (!fits(occ, v)) continue; return normalize([...kept, v]); } return null; } if (roll < 0.55 && blockers.length > 2) { // Drop a piece outright and let refine() re-harden the sparser board. const victim = blockers[randInt(blockers.length)]; return normalize(out.filter((v) => v.id !== victim.id)); } if (roll < 0.80) { // Lengthen a car into a truck where there is room. const shorts = out.filter((v) => !v.isTarget && v.len === 2); if (!shorts.length) return null; const v = shorts[randInt(shorts.length)]; const kept = out.filter((w) => w.id !== v.id); const occ = occupancy(kept); for (const cand of [{ ...v, len: 3 }, { ...v, len: 3, x: v.orient === 'h' ? v.x - 1 : v.x, y: v.orient === 'v' ? v.y - 1 : v.y }]) { if (fits(occ, cand)) return normalize([...kept, cand]); } return null; } // Add a piece: denser boards give refine() more to strip and harden against. const occ = occupancy(out); for (let t = 0; t < 60; t++) { const v = randomBlocker('tmp'); if (v.orient === 'h' && v.y === EXIT_ROW) continue; if (!fits(occ, v)) continue; return normalize([...out, v]); } return null; } const tiersFull = () => buckets.every((b, i) => b.length >= TIERS[i].count); // Count toward the goal only what a tier can actually use, so the progress // line reads as "levels we can ship", not "candidates collected". const kept = () => buckets.reduce((t, b, i) => t + Math.min(b.length, TIERS[i].count), 0); const wanted = TIERS.reduce((t, x) => t + x.count, 0); console.log(`[rushhour] generating with seed ${SEED}…`); const startedAt = Date.now(); let timedOut = false; const overCap = () => { if ((Date.now() - startedAt) / 1000 < WALL_CLOCK_CAP) return false; timedOut = true; return true; }; // Phase A — random seeding. while (attempts < PHASE_A_ATTEMPTS && !tiersFull() && !overCap()) { attempts++; consider(refine(randomLayout(6 + randInt(8)))); if (attempts % 25 === 0) { process.stdout.write(`\r[rushhour] phase A attempts=${attempts} refined=${refined} kept=${kept()}/${wanted} `); } } process.stdout.write('\n'); console.log(`[rushhour] phase A done: ${kept()}/${wanted} kept, elite par max ${elites[0]?.par ?? 0}`); // Phase B — hill climb from the elite pool toward the tiers still short. let climbs = 0; while (climbs < PHASE_B_CLIMBS && !tiersFull() && !overCap()) { climbs++; // Bias seed choice toward the hardest boards when the top tiers are short. const shortHigh = buckets.some((b, i) => i >= 3 && b.length < TIERS[i].count); const pool = shortHigh ? elites.slice(0, Math.max(8, elites.length >> 1)) : elites; if (!pool.length) break; const seedBoard = pool[randInt(pool.length)]; const mutated = mutate(seedBoard.vehicles, shortHigh); if (!mutated) continue; consider(refine(mutated)); if (climbs % 25 === 0) { process.stdout.write(`\r[rushhour] phase B climbs=${climbs} refined=${refined} kept=${kept()}/${wanted} best par=${elites[0]?.par ?? 0} `); } } process.stdout.write('\n'); // ── Assemble ───────────────────────────────────────────────────────────────── const shortfall = []; const levels = []; const tiersOut = []; let levelNo = 1; TIERS.forEach((tier, ti) => { const pool = buckets[ti].slice().sort((a, b) => a.score - b.score); if (pool.length < tier.count) shortfall.push(`${tier.name}: ${pool.length}/${tier.count}`); // Spread the picks across the band rather than taking the 12 easiest. const picks = []; if (pool.length <= tier.count) { picks.push(...pool); } else { for (let i = 0; i < tier.count; i++) { picks.push(pool[Math.round((i * (pool.length - 1)) / (tier.count - 1))]); } } // Selection spreads across the composite score so a tier samples its whole // band, but presentation order is by par: par is the number printed on the // level tile, and a tile reading "par 22" after one reading "par 25" looks // like the ramp went backwards even when the later puzzle is genuinely // trickier. Composite breaks ties. picks.sort((a, b) => (a.par - b.par) || (a.score - b.score)); const from = levelNo; picks.forEach((p, i) => { levels.push({ level: levelNo++, name: tier.names[i] ?? `${tier.name} ${i + 1}`, tier: tier.id, par: p.par, carsMoved: p.carsMoved, decoyDensity: Number(p.decoyDensity.toFixed(3)), targetRetreats: p.targetRetreats, firstMoveFanout: p.firstMoveFanout, difficulty: Number(p.score.toFixed(2)), vehicles: p.vehicles, }); }); tiersOut.push({ id: tier.id, name: tier.name, theme: tier.id, from, to: levelNo - 1 }); }); const payload = { version: 1, seed: SEED, generatedAt: new Date().toISOString(), count: levels.length, tiers: tiersOut, levels, }; fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true }); fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2)); console.log(`[rushhour] attempts=${attempts} climbs=${climbs} distinct minimal boards=${refined}`); TIERS.forEach((t, i) => { const pool = buckets[i]; const pars = pool.map((p) => p.par); console.log(`[rushhour] ${t.name.padEnd(14)} ${String(pool.length).padStart(3)}/${t.count} candidates` + (pars.length ? ` par ${Math.min(...pars)}..${Math.max(...pars)}` : '')); }); console.log(`[rushhour] wrote ${levels.length} levels -> ${OUT_FILE}`); if (timedOut) { console.error(`\n[rushhour] WALL CLOCK CAP (${WALL_CLOCK_CAP}s) HIT — this bank is not reproducible from its seed.`); process.exit(1); } if (shortfall.length) { console.error(`\n[rushhour] TIERS SHORT: ${shortfall.join(', ')}`); console.error('[rushhour] raise RH_PHASE_B, or widen the par bands, and re-run.'); process.exit(1); }