178 lines
6.4 KiB
JavaScript
178 lines
6.4 KiB
JavaScript
// 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}`);
|