fertig-classic-games/tools/genRushHour.js

500 lines
19 KiB
JavaScript

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