500 lines
19 KiB
JavaScript
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);
|
|
}
|