fertig-classic-games/server/scripts/genRushHour.js

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}`);