feat(rushhour): redesign level bank, procedural art, and tiered curriculum

- Generate 60 levels across 5 themed tiers (Downtown, Freight Yard, Airport Apron,
  Construction, Night City) with guaranteed minimal and unsolved board states
- Rewrite generator (tools/genRushHour.js) with refine() pipeline that strips
  redundant vehicles and re-hardens boards, plus hill-climbing phase B
- Add comprehensive verifier (tools/verifyRushHour.js) checking solver correctness,
  structural criteria, MINIMAL/UNSOLVED properties, and curriculum shape
- Implement procedural vehicle art (RushHourArt.js) with baked textures, per-theme
  decals (hazard, hivis, freight, neon), and themed board surfaces
- Move level bank to assets/gamedata/rushhour/levels.json and fetch on entry
  instead of preloading at boot
- Restructure level JSON with tiers, level names, par, and difficulty metrics
  (decoyDensity, targetRetreats, firstMoveFanout, carsMoved)
- Update level select to display tiers with themed swatches and color-coded labels
- Show level name and theme in HUD during gameplay
- Tighten hint solver with maxStates budget to prevent main-thread stalls
- Update PreloadScene to remove rushhour.json from boot-time assets
This commit is contained in:
Brian Fertig 2026-07-28 22:12:55 -06:00
parent dbc1c64d51
commit 052417aa9a
13 changed files with 5493 additions and 2576 deletions

View File

@ -278,9 +278,11 @@ from the repo root (Node.js 20+):
Runtime JSON fetched by the games:
- **Per-game banks & artwork maps** — e.g. `rushhour.json`, `zuma.json`,
`katamino.json`, `dotlink.json`, `jumble.json`, `spireclimb-artwork.json`,
`slots-artwork.json`, `shift-artwork.json`.
- **Per-game banks & artwork maps** — e.g. `katamino.json`, `dotlink.json`,
`jumble.json`, `spireclimb-artwork.json`, `slots-artwork.json`,
`shift-artwork.json`. Newer games keep their level banks under
`assets/gamedata/<game>/` and fetch them on entry instead (Rush Hour, Zuma,
Peggle, Goo Tower).
- **Shared UI options**`playfields.json`, `colored-playfields.json`,
`card-backs.json`, `opponents.json`, `music.json`.
- **Word data**`wordlists/enable1.txt` (main dictionary),

File diff suppressed because it is too large Load Diff

View File

@ -1,400 +1,105 @@
{
"level": 3,
"name": "Twin Pools",
"name": "Dual Strike",
"frog": [
620,
600
966,
579
],
"colors": 4,
"background": "zuma-background-1",
"seed": 24757,
"seed": 12345,
"starScores": [
970,
1460,
1940
640,
960,
1280
],
"paths": [
{
"shape": "doubleLoop",
"shape": "custom",
"points": [
[
-80,
300
-49,
235
],
[
-10,
300
669,
132
],
[
60,
300
1515,
190
],
[
130,
300
1726,
411
],
[
200,
300
1710,
667
],
[
270,
300
],
[
340,
300
],
[
410,
300
],
[
480,
300
],
[
550,
300
],
[
620,
300
1210,
817
],
[
689,
308
813
],
[
755,
332
],
[
813,
370
],
[
861,
421
],
[
895,
481
],
[
915,
548
],
[
919,
617
],
[
907,
686
],
[
880,
750
],
[
838,
806
],
[
785,
851
],
[
723,
882
],
[
655,
898
],
[
585,
898
],
[
517,
882
],
[
455,
851
],
[
402,
806
],
[
360,
750
],
[
333,
686
],
[
321,
617
],
[
325,
548
],
[
345,
481
],
[
379,
421
],
[
427,
370
],
[
485,
332
],
[
551,
308
],
[
620,
300
],
[
691,
300
],
[
762,
300
],
[
833,
300
],
[
904,
300
],
[
975,
300
],
[
1045,
300
],
[
1116,
300
],
[
1187,
300
],
[
1258,
300
],
[
1329,
300
],
[
1400,
300
],
[
1469,
308
],
[
1535,
332
],
[
1593,
370
],
[
1641,
421
],
[
1675,
481
],
[
1695,
548
],
[
1699,
617
],
[
1687,
686
],
[
1660,
750
],
[
1618,
806
],
[
1565,
851
],
[
1503,
882
],
[
1435,
898
],
[
1365,
898
],
[
1297,
882
],
[
1235,
851
],
[
1182,
806
],
[
1140,
750
],
[
1113,
686
],
[
1101,
617
],
[
1105,
548
],
[
1125,
481
],
[
1159,
421
],
[
1207,
370
],
[
1265,
332
],
[
1331,
308
],
[
1400,
300
],
[
1473,
300
],
[
1547,
300
],
[
1620,
300
],
[
1691,
311
],
[
1755,
344
],
[
1806,
395
],
[
1839,
459
],
[
1850,
530
],
[
1839,
601
],
[
1806,
665
],
[
1755,
716
],
[
1691,
749
],
[
1620,
760
],
[
1555,
760
],
[
1490,
760
],
[
1425,
760
],
[
1360,
760
482,
604
]
],
"tunnels": [],
"quota": 30,
"introBalls": 9,
"pushSpeed": 84,
"powerUpRate": 0.065
"quota": 20,
"introBalls": 8,
"pushSpeed": 30,
"powerUpRate": 0.06
},
{
"shape": "custom",
"points": [
[
1995,
920
],
[
1113,
954
],
[
265,
940
],
[
113,
633
],
[
330,
341
],
[
628,
249
],
[
1103,
251
],
[
1361,
350
],
[
1466,
491
]
],
"tunnels": [],
"quota": 20,
"introBalls": 8,
"pushSpeed": 30,
"powerUpRate": 0.06
}
]
}

View File

@ -1,364 +1,84 @@
{
"level": 4,
"name": "Switchbacks",
"level": 21,
"name": "New Path",
"frog": [
1620,
550
1661,
544
],
"colors": 4,
"background": "zuma-background-2",
"seed": 32676,
"seed": 12345,
"starScores": [
1040,
1550,
2070
640,
960,
1280
],
"paths": [
{
"shape": "zigzag",
"shape": "custom",
"points": [
[
-80,
130
-65,
93
],
[
-11,
130
466,
132
],
[
58,
130
1034,
374
],
[
126,
130
1116,
817
],
[
195,
130
706,
956
],
[
264,
130
156,
663
],
[
333,
130
316,
309
],
[
401,
130
1095,
143
],
[
470,
130
1484,
780
],
[
539,
130
1210,
979
],
[
608,
130
853,
719
],
[
676,
130
],
[
745,
130
],
[
814,
130
],
[
882,
130
],
[
951,
130
],
[
1020,
130
],
[
1089,
130
],
[
1158,
130
],
[
1226,
130
],
[
1295,
130
],
[
1365,
149
],
[
1416,
200
],
[
1435,
270
],
[
1416,
340
],
[
1365,
391
],
[
1295,
410
],
[
1225,
410
],
[
1156,
410
],
[
1086,
410
],
[
1016,
410
],
[
947,
410
],
[
877,
410
],
[
807,
410
],
[
738,
410
],
[
668,
410
],
[
598,
410
],
[
529,
410
],
[
459,
410
],
[
389,
410
],
[
320,
410
],
[
250,
410
],
[
180,
429
],
[
129,
480
],
[
110,
550
],
[
129,
620
],
[
180,
671
],
[
250,
690
],
[
320,
690
],
[
389,
690
],
[
459,
690
],
[
529,
690
],
[
598,
690
],
[
668,
690
],
[
738,
690
],
[
807,
690
],
[
877,
690
],
[
947,
690
],
[
1016,
690
],
[
1086,
690
],
[
1156,
690
],
[
1225,
690
],
[
1295,
690
],
[
1365,
709
],
[
1416,
760
],
[
1435,
830
],
[
1416,
900
],
[
1365,
951
],
[
1295,
970
],
[
1225,
970
],
[
1156,
970
],
[
1086,
970
],
[
1016,
970
],
[
947,
970
],
[
877,
970
],
[
807,
970
],
[
738,
970
],
[
668,
970
],
[
598,
970
],
[
529,
970
],
[
459,
970
],
[
389,
970
],
[
320,
970
],
[
250,
970
552,
397
]
],
"tunnels": [],
"quota": 32,
"introBalls": 10,
"pushSpeed": 84,
"powerUpRate": 0.065
"tunnels": [
[
3182,
3834
],
[
5104,
5615
]
],
"quota": 24,
"introBalls": 8,
"pushSpeed": 30,
"powerUpRate": 0.06
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

View File

@ -90,6 +90,11 @@ export const MANIFEST = {
image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'),
],
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')],
rushhour: [
// Carries its own title, so the level-select screen draws no heading.
// In-play art is procedural (see games/rushhour/RushHourArt.js).
image('bg-rushhour-menu', 'assets/images/background-rushhour.png'),
],
dominion: [
// Dominion card art. One 270×390 cell per card (art fills the top ~60%;
// the title/icon band is drawn at runtime). Optional — the scene falls

View File

@ -331,6 +331,8 @@ export default class JewelQuestGame extends Phaser.Scene {
this.replayQueue = [];
this.selected = null;
this.dragFrom = null;
this.rowsTween?.stop();
this.rowsTween = null;
this.layer.removeAll(true);
if (this.calloutText) { this.calloutText.destroy(); this.calloutText = null; }
this.cellSprites = null;
@ -496,6 +498,11 @@ export default class JewelQuestGame extends Phaser.Scene {
const left = cx - gridW / 2 + TILE / 2;
const top = 530;
// The grid starts invisible so the background art gets a beat on its own
// before the level tiles fade in on top of it.
const rows = this.add.container(0, 0).setAlpha(0);
this.layer.add(rows);
this.bank.forEach((lv, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
@ -525,7 +532,7 @@ export default class JewelQuestGame extends Phaser.Scene {
color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b',
}).setOrigin(0.5);
objs.push(tag);
this.layer.add(objs);
rows.add(objs);
if (playable) {
tile.setInteractive({ useHandCursor: true });
@ -535,6 +542,10 @@ export default class JewelQuestGame extends Phaser.Scene {
}
});
this.rowsTween = this.tweens.add({
targets: rows, alpha: 1, delay: 2000, duration: 5000, ease: 'Linear',
});
const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Fight Level ${nextLevel}`, () => this.showIntro(nextLevel),
{ width: 280, height: 58, fontSize: 24 });
const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'),

View File

@ -0,0 +1,461 @@
// Rush Hour — procedural vehicle and board art.
//
// Every vehicle is painted once into a canvas texture and then rendered as a
// plain Image, the same bake-then-blit approach used by Mini Motorways and
// Peggle. Raw Canvas 2D rather than Phaser Graphics because gradients, curved
// bodywork and rotation are what make a block read as a car; the closest
// existing reference in the repo is drawKart() in superkart/SuperKartRaster.js.
//
// ── Orientation ──────────────────────────────────────────────────────────────
// Everything is painted once nose-up and then rotated into place, so a
// horizontal car is genuinely the same car turned ninety degrees rather than a
// separately-drawn shape. Facing alternates by colour slot to keep the board
// looking like traffic instead of a parade.
//
// ── The red car ──────────────────────────────────────────────────────────────
// The target is deliberately exempt from theming. Same silhouette, same red,
// same white roof stripe in all five themes, always nose-right toward the exit.
// It is the one constant on the board, so it stays instantly identifiable as
// the car that has to get out.
export const CELL = 132;
export const PAD = 10; // inset of a vehicle body within its cells
export const TARGET_BODY = '#d92b2b';
export const TARGET_DARK = '#8c1414';
// ── Colour helpers ───────────────────────────────────────────────────────────
// Local by convention — each game in this repo carries its own shade helper
// rather than sharing one.
function shade(hex, amt) {
const v = parseInt(hex.slice(1), 16);
const ch = (sh) => {
let c = (v >> sh) & 0xff;
c = Math.round(amt >= 0 ? c + (255 - c) * amt : c * (1 + amt));
return Math.max(0, Math.min(255, c));
};
return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
}
const lighten = (hex, a) => shade(hex, a);
const darken = (hex, a) => shade(hex, -a);
function makeCanvas(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
return c;
}
// ── Themes ───────────────────────────────────────────────────────────────────
//
// A theme is data, not a code branch: a palette, a decal style applied over the
// finished bodywork, and the board tones. Tiers map onto themes by id, so the
// look is a visible reward for progressing rather than a menu setting.
export const THEMES = {
downtown: {
id: 'downtown',
name: 'Downtown',
decal: 'none',
palette: ['#f2b705', '#2d7dd2', '#3fa34d', '#8367c7', '#e8833a', '#20a4a4', '#d94f8a', '#8a929c'],
glass: '#bfe3f2',
board: { felt: 0x101a26, frame: 0x1b2836, cell: 0x2b3947, grid: 0x3b4c5c, gridStyle: 'lane' },
},
freightyard: {
id: 'freightyard',
name: 'Freight Yard',
decal: 'freight',
palette: ['#c1553b', '#3d6e9c', '#6f7a86', '#b08a3e', '#4a7a5e', '#9c5b8a', '#5a6472', '#a8632f'],
glass: '#a8c4d4',
board: { felt: 0x14161a, frame: 0x24282e, cell: 0x33383f, grid: 0x464d56, gridStyle: 'plain' },
},
airport: {
id: 'airport',
name: 'Airport Apron',
decal: 'hivis',
palette: ['#e8c33a', '#e6e9ec', '#3f7fbf', '#d97a2b', '#5c9e6f', '#b9bec4', '#c9553f', '#4a5560'],
glass: '#cfe6f2',
board: { felt: 0x0e1418, frame: 0x1e262b, cell: 0x2f383e, grid: 0x47535b, gridStyle: 'runway' },
},
construction: {
id: 'construction',
name: 'Construction',
decal: 'hazard',
palette: ['#e09b1a', '#d4761f', '#b8b0a2', '#7a8b52', '#a85a32', '#6d747c', '#c8a94c', '#8a5a3c'],
glass: '#c2d2c8',
board: { felt: 0x1a160f, frame: 0x2c2517, cell: 0x3d3524, grid: 0x554a32, gridStyle: 'dirt' },
},
nightcity: {
id: 'nightcity',
name: 'Night City',
decal: 'neon',
palette: ['#25d0d8', '#c644d8', '#3f5ce0', '#1fbf6a', '#e0483f', '#d8a520', '#8a4fe0', '#4a5a6e'],
glass: '#1e3a4a',
board: { felt: 0x05070d, frame: 0x0d1220, cell: 0x141c2c, grid: 0x24314a, gridStyle: 'wet' },
},
};
export const THEME_ORDER = ['downtown', 'freightyard', 'airport', 'construction', 'nightcity'];
export function themeFor(id) {
return THEMES[id] ?? THEMES.downtown;
}
// ── Body painting (always nose-up, in a cell x len*cell box) ─────────────────
function roundRectPath(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, r);
}
// Wheels sit outside the body on both flanks so the silhouette reads as a
// vehicle rather than a rectangle.
function drawWheels(ctx, bx, by, bw, bh, positions, wheelLen) {
ctx.fillStyle = '#15171a';
for (const t of positions) {
const cy = by + bh * t - wheelLen / 2;
roundRectPath(ctx, bx - 5, cy, 9, wheelLen, 4);
ctx.fill();
roundRectPath(ctx, bx + bw - 4, cy, 9, wheelLen, 4);
ctx.fill();
}
}
// Tapered shell: narrower at the nose, full width through the cabin, slightly
// drawn in at the tail. This is the shape that stops it looking like a block.
function bodyPath(ctx, bx, by, bw, bh, noseInset, tailInset) {
const l = bx + noseInset;
const r = bx + bw - noseInset;
const tl = bx + tailInset;
const tr = bx + bw - tailInset;
const shoulder = by + bh * 0.16;
const hip = by + bh * 0.86;
ctx.beginPath();
ctx.moveTo(bx + bw / 2, by);
ctx.quadraticCurveTo(r, by + bh * 0.015, r, shoulder);
ctx.lineTo(bx + bw, by + bh * 0.30);
ctx.lineTo(bx + bw, hip);
ctx.quadraticCurveTo(bx + bw, by + bh - 2, tr, by + bh);
ctx.lineTo(tl, by + bh);
ctx.quadraticCurveTo(bx, by + bh - 2, bx, hip);
ctx.lineTo(bx, by + bh * 0.30);
ctx.lineTo(l, shoulder);
ctx.quadraticCurveTo(l, by + bh * 0.015, bx + bw / 2, by);
ctx.closePath();
}
function drawCar(ctx, bx, by, bw, bh, color, theme, isTarget) {
drawWheels(ctx, bx, by, bw, bh, [0.22, 0.78], Math.min(34, bh * 0.16));
// Shell with a cross-axis gradient so the roof looks crowned.
bodyPath(ctx, bx, by, bw, bh, bw * 0.07, bw * 0.03);
const grad = ctx.createLinearGradient(bx, 0, bx + bw, 0);
grad.addColorStop(0, darken(color, 0.34));
grad.addColorStop(0.34, lighten(color, 0.14));
grad.addColorStop(0.62, color);
grad.addColorStop(1, darken(color, 0.40));
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.lineWidth = 3;
ctx.stroke();
// Cabin: an inset darker panel carrying the two windows.
const cx0 = bx + bw * 0.12;
const cw = bw * 0.76;
const cy0 = by + bh * 0.28;
const chh = bh * 0.40;
ctx.fillStyle = darken(color, 0.52);
roundRectPath(ctx, cx0, cy0, cw, chh, Math.min(16, cw * 0.18));
ctx.fill();
const glass = isTarget ? '#d8ecf5' : theme.glass;
ctx.fillStyle = glass;
// Windshield (nose side) — trapezoid, wider at the base.
ctx.beginPath();
ctx.moveTo(cx0 + cw * 0.17, cy0 + chh * 0.30);
ctx.lineTo(cx0 + cw * 0.83, cy0 + chh * 0.30);
ctx.lineTo(cx0 + cw * 0.92, cy0 + chh * 0.05);
ctx.lineTo(cx0 + cw * 0.08, cy0 + chh * 0.05);
ctx.closePath();
ctx.fill();
// Rear window.
ctx.beginPath();
ctx.moveTo(cx0 + cw * 0.14, cy0 + chh * 0.70);
ctx.lineTo(cx0 + cw * 0.86, cy0 + chh * 0.70);
ctx.lineTo(cx0 + cw * 0.94, cy0 + chh * 0.95);
ctx.lineTo(cx0 + cw * 0.06, cy0 + chh * 0.95);
ctx.closePath();
ctx.fill();
// Roof panel between the windows.
ctx.fillStyle = lighten(color, 0.06);
roundRectPath(ctx, cx0 + cw * 0.06, cy0 + chh * 0.34, cw * 0.88, chh * 0.32, 6);
ctx.fill();
// Mirrors.
ctx.fillStyle = darken(color, 0.45);
roundRectPath(ctx, bx - 3, by + bh * 0.30, 7, 12, 3);
ctx.fill();
roundRectPath(ctx, bx + bw - 4, by + bh * 0.30, 7, 12, 3);
ctx.fill();
lamps(ctx, bx, by, bw, bh);
specular(ctx, bx, by, bw, bh);
}
function drawTruck(ctx, bx, by, bw, bh, color, theme) {
const cabH = bh * 0.26;
drawWheels(ctx, bx, by, bw, bh, [0.14, 0.66, 0.86], Math.min(32, bh * 0.11));
// Cab.
bodyPath(ctx, bx, by, bw, cabH * 1.6, bw * 0.06, 0);
const cabGrad = ctx.createLinearGradient(bx, 0, bx + bw, 0);
cabGrad.addColorStop(0, darken(color, 0.36));
cabGrad.addColorStop(0.36, lighten(color, 0.16));
cabGrad.addColorStop(1, darken(color, 0.42));
ctx.fillStyle = cabGrad;
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.lineWidth = 3;
ctx.stroke();
// Windshield across the cab.
ctx.fillStyle = theme.glass;
roundRectPath(ctx, bx + bw * 0.14, by + cabH * 0.42, bw * 0.72, cabH * 0.46, 7);
ctx.fill();
// Cargo box — the structural tell that separates a truck from a long car.
const boxY = by + cabH * 1.32;
const boxH = by + bh - boxY;
const boxCol = darken(color, 0.18);
const boxGrad = ctx.createLinearGradient(bx, 0, bx + bw, 0);
boxGrad.addColorStop(0, darken(boxCol, 0.34));
boxGrad.addColorStop(0.36, lighten(boxCol, 0.12));
boxGrad.addColorStop(1, darken(boxCol, 0.40));
ctx.fillStyle = boxGrad;
roundRectPath(ctx, bx, boxY, bw, boxH, 8);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.lineWidth = 3;
ctx.stroke();
// Seam where the cab meets the box, plus panel ribs along the box.
ctx.strokeStyle = 'rgba(0,0,0,0.38)';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(bx + 2, boxY + 2);
ctx.lineTo(bx + bw - 2, boxY + 2);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.lineWidth = 2;
for (let i = 1; i <= 3; i++) {
const ry = boxY + (boxH * i) / 4;
ctx.beginPath();
ctx.moveTo(bx + 8, ry);
ctx.lineTo(bx + bw - 8, ry);
ctx.stroke();
}
lamps(ctx, bx, by, bw, bh);
specular(ctx, bx, by, bw, bh);
}
function lamps(ctx, bx, by, bw, bh) {
ctx.fillStyle = '#fdf3c8';
roundRectPath(ctx, bx + bw * 0.10, by + 4, bw * 0.20, 9, 4);
ctx.fill();
roundRectPath(ctx, bx + bw * 0.70, by + 4, bw * 0.20, 9, 4);
ctx.fill();
ctx.fillStyle = '#c9302c';
roundRectPath(ctx, bx + bw * 0.08, by + bh - 12, bw * 0.22, 8, 3);
ctx.fill();
roundRectPath(ctx, bx + bw * 0.70, by + bh - 12, bw * 0.22, 8, 3);
ctx.fill();
}
function specular(ctx, bx, by, bw, bh) {
ctx.fillStyle = 'rgba(255,255,255,0.13)';
roundRectPath(ctx, bx + bw * 0.16, by + bh * 0.06, bw * 0.10, bh * 0.86, bw * 0.05);
ctx.fill();
}
// ── Theme decals, applied over finished bodywork ─────────────────────────────
function applyDecal(ctx, style, bx, by, bw, bh, color) {
if (style === 'none') return;
ctx.save();
roundRectPath(ctx, bx, by, bw, bh, 10);
ctx.clip();
if (style === 'hazard') {
// Diagonal chevrons across the tail.
ctx.globalAlpha = 0.85;
const band = by + bh * 0.74;
ctx.beginPath();
ctx.rect(bx, band, bw, bh * 0.26);
ctx.clip();
const step = 18;
for (let i = -Math.ceil(bh / step); i * step < bw + bh; i++) {
ctx.fillStyle = i % 2 === 0 ? '#1c1c1c' : '#f0b323';
ctx.beginPath();
ctx.moveTo(bx + i * step, band + bh * 0.26);
ctx.lineTo(bx + i * step + step, band + bh * 0.26);
ctx.lineTo(bx + i * step + step + bh * 0.26, band);
ctx.lineTo(bx + i * step + bh * 0.26, band);
ctx.closePath();
ctx.fill();
}
} else if (style === 'hivis') {
// Reflective bands, the airside ground-vehicle look.
ctx.fillStyle = 'rgba(255,255,255,0.75)';
ctx.fillRect(bx, by + bh * 0.60, bw, 7);
ctx.fillStyle = 'rgba(240,160,40,0.85)';
ctx.fillRect(bx, by + bh * 0.67, bw, 5);
} else if (style === 'freight') {
// Blank livery panel on the cargo box.
ctx.fillStyle = 'rgba(255,255,255,0.14)';
roundRectPath(ctx, bx + bw * 0.16, by + bh * 0.52, bw * 0.68, bh * 0.22, 5);
ctx.fill();
} else if (style === 'neon') {
// Underglow plus a bright light strip along the flanks.
ctx.fillStyle = lighten(color, 0.55);
ctx.fillRect(bx + 1, by + bh * 0.20, 4, bh * 0.60);
ctx.fillRect(bx + bw - 5, by + bh * 0.20, 4, bh * 0.60);
ctx.fillStyle = 'rgba(255,255,255,0.30)';
ctx.fillRect(bx + bw * 0.20, by + bh - 7, bw * 0.60, 4);
}
ctx.restore();
}
// The red car's own marking — never themed.
function targetStripe(ctx, bx, by, bw, bh) {
ctx.save();
roundRectPath(ctx, bx, by, bw, bh, 10);
ctx.clip();
ctx.fillStyle = 'rgba(255,255,255,0.92)';
ctx.fillRect(bx + bw * 0.42, by, bw * 0.06, bh);
ctx.fillRect(bx + bw * 0.52, by, bw * 0.06, bh);
ctx.restore();
}
// ── Baking ───────────────────────────────────────────────────────────────────
export function vehicleTextureKey(themeId, slot, len, orient, isTarget) {
return isTarget ? `rh-veh-target-${len}${orient}` : `rh-veh-${themeId}-${slot}-${len}${orient}`;
}
// Facing alternates by slot so a board reads as traffic. The target is always
// nose-right, pointing at the exit it is trying to reach.
function facingFor(slot, orient, isTarget) {
if (orient === 'h') return isTarget || slot % 2 === 0 ? 'right' : 'left';
return slot % 2 === 0 ? 'down' : 'up';
}
function bakeVehicle(scene, key, { themeId, slot, len, orient, isTarget }) {
if (scene.textures.exists(key)) return key;
const theme = themeFor(themeId);
const longSide = len * CELL;
const w = orient === 'h' ? longSide : CELL;
const h = orient === 'h' ? CELL : longSide;
const canvas = makeCanvas(w, h);
const ctx = canvas.getContext('2d');
const facing = facingFor(slot, orient, isTarget);
ctx.save();
ctx.translate(w / 2, h / 2);
if (orient === 'h') ctx.rotate(facing === 'right' ? Math.PI / 2 : -Math.PI / 2);
else if (facing === 'down') ctx.rotate(Math.PI);
// Paint in nose-up space: a CELL-wide, len*CELL-tall box.
ctx.translate(-CELL / 2, -longSide / 2);
const bx = PAD;
const by = PAD;
const bw = CELL - PAD * 2;
const bh = longSide - PAD * 2;
// Contact shadow first, offset down-right, so pieces sit on the board.
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.42)';
ctx.filter = 'blur(4px)';
roundRectPath(ctx, bx + 4, by + 6, bw, bh, 14);
ctx.fill();
ctx.restore();
const color = isTarget ? TARGET_BODY : theme.palette[slot % theme.palette.length];
if (len >= 3) drawTruck(ctx, bx, by, bw, bh, color, theme);
else drawCar(ctx, bx, by, bw, bh, color, theme, isTarget);
if (isTarget) targetStripe(ctx, bx, by, bw, bh);
else applyDecal(ctx, theme.decal, bx, by, bw, bh, color);
ctx.restore();
scene.textures.addCanvas(key, canvas);
return key;
}
// Bake every texture a level needs. Cheap to call per level — each key is
// guarded by textures.exists(), so a repeat visit costs nothing.
export function ensureVehicleTextures(scene, themeId, vehicles) {
const keys = new Map();
let slot = 0;
for (const v of vehicles) {
const s = v.isTarget ? 0 : slot++;
const key = vehicleTextureKey(themeId, s, v.len, v.orient, v.isTarget);
bakeVehicle(scene, key, { themeId, slot: s, len: v.len, orient: v.orient, isTarget: v.isTarget });
keys.set(v.id, key);
}
return keys;
}
// ── Board chrome ─────────────────────────────────────────────────────────────
// Road markings drawn straight onto the playfield. Each theme gets a different
// surface treatment so the tier is readable at a glance.
export function drawBoardSurface(g, theme, left, top, sizePx, grid) {
const cell = sizePx / grid;
const b = theme.board;
g.fillStyle(b.cell, 1);
g.fillRect(left, top, sizePx, sizePx);
if (b.gridStyle === 'lane') {
// Dashed white lane markings.
g.lineStyle(2, 0xffffff, 0.13);
for (let i = 1; i < grid; i++) {
const x = left + i * cell;
for (let d = 0; d < grid * 2; d++) {
const y = top + d * (cell / 2) + cell / 8;
g.lineBetween(x, y, x, y + cell / 4);
}
}
} else if (b.gridStyle === 'runway') {
g.lineStyle(3, 0xe8c33a, 0.16);
for (let i = 1; i < grid; i++) {
g.lineBetween(left + i * cell, top, left + i * cell, top + sizePx);
}
} else if (b.gridStyle === 'dirt') {
g.lineStyle(2, 0x000000, 0.16);
for (let i = 1; i < grid; i++) {
g.lineBetween(left, top + i * cell, left + sizePx, top + i * cell);
}
} else if (b.gridStyle === 'wet') {
// Reflected light streaks on wet asphalt.
g.lineStyle(3, 0x25d0d8, 0.10);
for (let i = 0; i < grid * 2; i++) {
const y = top + i * (cell / 2);
g.lineBetween(left + cell * 0.2, y, left + sizePx - cell * 0.2, y);
}
}
g.lineStyle(2, b.grid, 0.55);
for (let i = 0; i <= grid; i++) {
g.lineBetween(left + i * cell, top, left + i * cell, top + sizePx);
g.lineBetween(left, top + i * cell, left + sizePx, top + i * cell);
}
}

View File

@ -7,23 +7,20 @@ import { api } from '../../services/api.js';
import {
GRID, EXIT_ROW, cloneVehicles, slideRange, isSolved, solve,
} from './RushHourLogic.js';
import {
CELL, themeFor, ensureVehicleTextures, drawBoardSurface,
} from './RushHourArt.js';
const BANK_URL = 'assets/gamedata/rushhour/levels.json';
const CELL = 132;
const BOARD_PX = CELL * GRID;
const BOARD_LEFT = Math.round((GAME_WIDTH - BOARD_PX) / 2);
const BOARD_TOP = 188;
const PAD = 10; // inset of a vehicle body within its cells
const FELT = 0x0e2233;
const FRAME = 0x0a1722;
const CELLBG = 0x14304a;
const GRIDLN = 0x21466a;
const TARGET_COLOR = 0xe03131;
const CAR_COLORS = [
0x4a90d9, 0x2ecc71, 0xf1c40f, 0x9b59b6, 0xe67e22, 0x1abc9c,
0x3498db, 0xe84393, 0x16a085, 0xd35400, 0x27ae60, 0x8e44ad,
0x7f8c8d, 0x2980b9, 0xf39c12, 0x6ab04c,
];
// Hints solve from wherever the player currently is, which on a tier-5 board
// can be a large reachable space. Bound it so a press can never stall the
// main thread; an unsolvable-within-budget hint just does nothing.
const HINT_MAX_STATES = 120000;
const D = { felt: -2, frame: -1, grid: 0, exit: 1, vehicle: 10, ui: 30, banner: 34, overlay: 60, overlayUI: 62 };
@ -33,6 +30,7 @@ export default class RushHourGame extends Phaser.Scene {
init(data) {
this.gameDef = data.game ?? { slug: 'rushhour', name: 'Rush Hour' };
this.bank = [];
this.tiers = [];
this.levelsCompleted = 0; // highest contiguous level cleared
this.canPersist = true;
this.view = 'select';
@ -54,10 +52,20 @@ export default class RushHourGame extends Phaser.Scene {
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt);
this.bg = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT,
themeFor('downtown').board.felt).setDepth(D.felt);
const raw = this.cache.json.get('rushhour');
this.bank = (raw?.puzzles ?? []).slice().sort((a, b) => a.level - b.level);
// Fetched on entry rather than preloaded at boot — the bank is dead weight
// for every player who never opens this game.
try {
const res = await fetch(BANK_URL);
const data = await res.json();
this.bank = (data?.levels ?? []).slice().sort((a, b) => a.level - b.level);
this.tiers = data?.tiers ?? [];
} catch (_) {
this.bank = [];
this.tiers = [];
}
try {
const res = await api.get('/puzzles/rushhour/progress');
@ -74,6 +82,8 @@ export default class RushHourGame extends Phaser.Scene {
clearLayer() {
this.sprites.clear();
this.rowsTween?.stop();
this.rowsTween = null;
this.layer.removeAll(true);
// These were just destroyed; drop the stale references so updateMoves()
// doesn't poke a dead Button (whose scene is now undefined) before drawHud
@ -89,18 +99,33 @@ export default class RushHourGame extends Phaser.Scene {
this.overlayUp = false;
this.busy = false;
this.clearLayer();
// Level select is theme-neutral; otherwise it keeps whatever tier colour
// the last level painted the background.
this.bg.setFillStyle(themeFor('downtown').board.felt);
const cx = GAME_WIDTH / 2;
// The artwork carries the wordmark, so no heading is drawn over it. A
// scrim keeps the level grid legible against a busy illustration.
const hasArt = this.textures.exists('bg-rushhour-menu');
if (hasArt) {
this.layer.add(this.add.image(cx, GAME_HEIGHT / 2, 'bg-rushhour-menu')
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
this.layer.add(this.add.rectangle(cx, GAME_HEIGHT / 2 + 90, GAME_WIDTH, GAME_HEIGHT - 180,
0x000000, 0.62));
} else {
const title = this.add.text(cx, 84, 'RUSH HOUR', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 138, 'Slide the cars aside and drive the red car out. Clear each level to unlock the next.', {
this.layer.add(title);
}
const sub = this.add.text(cx, hasArt ? 214 : 138,
'Slide the cars aside and drive the red car out. Clear each level to unlock the next.', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add([title, sub]);
this.layer.add(sub);
if (!this.bank.length) {
const msg = this.add.text(cx, 520, 'No puzzles found.\nRun: node server/scripts/genRushHour.js', {
const msg = this.add.text(cx, 520, 'No puzzles found.\nRun: node tools/genRushHour.js', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
}).setOrigin(0.5);
this.layer.add(msg);
@ -110,47 +135,78 @@ export default class RushHourGame extends Phaser.Scene {
}
const nextLevel = Math.min(this.levelsCompleted + 1, this.bank.length);
const prog = this.add.text(cx, 182, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
const prog = this.add.text(cx, hasArt ? 252 : 182, `Completed ${this.levelsCompleted} / ${this.bank.length}`, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5);
this.layer.add(prog);
const COLS = 12;
const SIZE = 104;
const GAP = 14;
const gridW = COLS * SIZE + (COLS - 1) * GAP;
const left = cx - gridW / 2 + SIZE / 2;
const top = 268;
// One row per tier, headed by the tier name in its own theme colour, so the
// themes still to come are visible from the start.
const SIZE = 88;
const GAP = 12;
const ROW_STRIDE = 128;
const top = hasArt ? 330 : 262;
this.bank.forEach((p, i) => {
const col = i % COLS;
const row = Math.floor(i / COLS);
const x = left + col * (SIZE + GAP);
const y = top + row * (SIZE + GAP);
const tierList = this.tiers.length
? this.tiers
: [{ id: 'downtown', name: 'Levels', from: 1, to: this.bank.length }];
// The rows start invisible so the background art gets a beat on its own
// before the level grid fades in on top of it.
const rows = this.add.container(0, 0).setAlpha(0);
this.layer.add(rows);
tierList.forEach((tier, ti) => {
const theme = themeFor(tier.theme ?? tier.id);
const levels = this.bank.filter((p) => p.level >= tier.from && p.level <= tier.to);
if (!levels.length) return;
const gridW = levels.length * SIZE + (levels.length - 1) * GAP;
const left = cx - gridW / 2 + SIZE / 2;
const rowY = top + ti * ROW_STRIDE;
const unlockedHere = levels.some((p) => p.level <= nextLevel);
const label = this.add.text(left - SIZE / 2, rowY - SIZE / 2 - 22, tier.name.toUpperCase(), {
fontFamily: 'Righteous', fontSize: '22px',
color: unlockedHere ? COLORS.goldHex : '#54606b',
}).setOrigin(0, 0.5);
const swatch = this.add.rectangle(left - SIZE / 2 - 16, rowY - SIZE / 2 - 22, 8, 22,
Phaser.Display.Color.HexStringToColor(theme.palette[0]).color)
.setAlpha(unlockedHere ? 1 : 0.3);
rows.add([label, swatch]);
levels.forEach((p, i) => {
const x = left + i * (SIZE + GAP);
const level = p.level;
const cleared = level <= this.levelsCompleted;
const playable = level <= nextLevel;
const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b;
const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744;
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill).setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
const num = this.add.text(x, y - 8, String(level), {
fontFamily: 'Righteous', fontSize: '34px',
const tile = this.add.rectangle(x, rowY, SIZE, SIZE, fill)
.setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
const num = this.add.text(x, rowY - 8, String(level), {
fontFamily: 'Righteous', fontSize: '30px',
color: playable || cleared ? COLORS.textHex : '#54606b',
}).setOrigin(0.5);
const tag = this.add.text(x, y + 28, cleared ? '✓ cleared' : playable ? `par ${p.minMoves}` : 'locked', {
fontFamily: '"Julius Sans One"', fontSize: '14px',
const tag = this.add.text(x, rowY + 24, cleared ? '✓' : playable ? `par ${p.par}` : 'locked', {
fontFamily: '"Julius Sans One"', fontSize: '13px',
color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b',
}).setOrigin(0.5);
this.layer.add([tile, num, tag]);
rows.add([tile, num, tag]);
if (playable) {
tile.setInteractive({ useHandCursor: true });
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
tile.on('pointerout', () => tile.setStrokeStyle(cleared ? 3 : 3, stroke, 1));
tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1));
tile.on('pointerup', () => this.playLevel(level));
}
});
});
this.rowsTween = this.tweens.add({
targets: rows, alpha: 1, delay: 2000, duration: 5000, ease: 'Linear',
});
const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Play Level ${nextLevel}`, () => this.playLevel(nextLevel),
{ width: 280, height: 58, fontSize: 24 });
@ -206,7 +262,10 @@ export default class RushHourGame extends Phaser.Scene {
this.level = level;
this.startVehicles = cloneVehicles(puzzle.vehicles);
this.vehicles = cloneVehicles(puzzle.vehicles);
this.par = puzzle.minMoves;
this.par = puzzle.par;
this.levelName = puzzle.name ?? '';
this.theme = themeFor(this.tierFor(level)?.theme ?? 'downtown');
this.bg.setFillStyle(this.theme.board.felt);
this.undoStack = [];
this.moves = 0;
this.overlayUp = false;
@ -219,27 +278,22 @@ export default class RushHourGame extends Phaser.Scene {
}
drawBoardChrome() {
const board = this.theme.board;
const g = this.add.graphics().setDepth(D.frame);
// Outer frame
g.fillStyle(FRAME, 1);
g.fillStyle(board.frame, 1);
g.fillRoundedRect(BOARD_LEFT - 22, BOARD_TOP - 22, BOARD_PX + 44, BOARD_PX + 44, 18);
// Playfield
g.fillStyle(CELLBG, 1);
g.fillRect(BOARD_LEFT, BOARD_TOP, BOARD_PX, BOARD_PX);
this.layer.add(g);
// Playfield surface + per-theme road markings.
const grid = this.add.graphics().setDepth(D.grid);
grid.lineStyle(2, GRIDLN, 0.9);
for (let i = 0; i <= GRID; i++) {
grid.lineBetween(BOARD_LEFT + i * CELL, BOARD_TOP, BOARD_LEFT + i * CELL, BOARD_TOP + BOARD_PX);
grid.lineBetween(BOARD_LEFT, BOARD_TOP + i * CELL, BOARD_LEFT + BOARD_PX, BOARD_TOP + i * CELL);
}
drawBoardSurface(grid, this.theme, BOARD_LEFT, BOARD_TOP, BOARD_PX, GRID);
this.layer.add(grid);
// Exit gap on the right wall at EXIT_ROW
const exitY = BOARD_TOP + (EXIT_ROW + 0.5) * CELL;
const ex = this.add.graphics().setDepth(D.exit);
ex.fillStyle(FELT, 1);
ex.fillStyle(board.felt, 1);
ex.fillRect(BOARD_LEFT + BOARD_PX - 1, BOARD_TOP + EXIT_ROW * CELL + 6, 46, CELL - 12);
ex.fillStyle(COLORS.gold, 0.9);
ex.fillTriangle(
@ -255,10 +309,14 @@ export default class RushHourGame extends Phaser.Scene {
}
drawHud() {
const title = this.add.text(BOARD_LEFT - 22, 92, `Level ${this.level}`, {
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.goldHex,
const title = this.add.text(BOARD_LEFT - 22, 84, `Level ${this.level}`, {
fontFamily: 'Righteous', fontSize: '48px', color: COLORS.goldHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.layer.add(title);
const sub = this.add.text(BOARD_LEFT - 20, 124,
`${this.levelName}${this.levelName ? ' · ' : ''}${this.theme.name}`, {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(D.ui);
this.layer.add([title, sub]);
this.movesText = this.add.text(BOARD_LEFT + BOARD_PX + 22, 92, '', {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
@ -310,36 +368,17 @@ export default class RushHourGame extends Phaser.Scene {
this.sprites.forEach((c) => c.destroy());
this.sprites.clear();
let carIdx = 0;
// Textures are baked once per theme+slot+shape and reused; a repeat visit
// to a tier costs nothing.
const keys = ensureVehicleTextures(this, this.theme.id, this.vehicles);
for (const v of this.vehicles) {
const wCells = v.orient === 'h' ? v.len : 1;
const hCells = v.orient === 'v' ? v.len : 1;
const w = wCells * CELL - PAD * 2;
const h = hCells * CELL - PAD * 2;
const color = v.isTarget ? TARGET_COLOR : CAR_COLORS[carIdx++ % CAR_COLORS.length];
const { x, y } = this.centerFor(v);
const c = this.add.container(x, y).setDepth(D.vehicle);
const body = this.add.graphics();
body.fillStyle(color, 1);
body.fillRoundedRect(-w / 2, -h / 2, w, h, 16);
body.fillStyle(0xffffff, 0.14);
body.fillRoundedRect(-w / 2 + 8, -h / 2 + 8, w - 16, Math.max(10, h * 0.22), 10);
body.lineStyle(3, 0x000000, 0.28);
body.strokeRoundedRect(-w / 2, -h / 2, w, h, 16);
c.add(body);
if (v.isTarget) {
c.add(this.add.text(0, 0, '★', { fontFamily: 'serif', fontSize: '40px', color: '#ffffff' }).setOrigin(0.5).setAlpha(0.9));
}
// The body graphics are centered on the container origin, but setSize()
// makes Phaser offset the input hit test by the display origin (w/2, h/2).
// So the hit area must be specified top-left at (0,0) — NOT (-w/2,-h/2) —
// to line up with the visible car (mirrors Battleship's draggable ships).
c.setSize(w, h);
c.setInteractive(new Phaser.Geom.Rectangle(0, 0, w, h), Phaser.Geom.Rectangle.Contains);
// A plain Image rather than a Container: its origin is 0.5 and its hit
// area is the texture bounds, which sidesteps the top-left-hit-rect quirk
// the old Container version needed a comment to explain.
const c = this.add.image(x, y, keys.get(v.id)).setDepth(D.vehicle);
c.setInteractive({ useHandCursor: true });
c.input.cursor = 'grab';
this.input.setDraggable(c);
@ -350,6 +389,10 @@ export default class RushHourGame extends Phaser.Scene {
}
}
tierFor(level) {
return this.tiers.find((t) => level >= t.from && level <= t.to) ?? null;
}
attachDrag(c, _v) {
c.on('dragstart', () => {
if (this.busy || this.overlayUp) { c._noDrag = true; return; }
@ -438,7 +481,7 @@ export default class RushHourGame extends Phaser.Scene {
showHint() {
if (this.busy || this.overlayUp) return;
const { path } = solve(this.vehicles);
const { path } = solve(this.vehicles, { maxStates: HINT_MAX_STATES });
if (!path || !path.length) return;
const mv = path[0];
const c = this.sprites.get(mv.id);

View File

@ -1,5 +1,5 @@
// Rush Hour — pure board model + BFS solver. No Phaser, no DOM.
// Shared by the client scene and the offline puzzle generator (both ESM).
// Rush Hour — pure board model + solver. No Phaser, no DOM.
// Shared by the client scene, the offline generator and the verifier (all ESM).
//
// Grid is 6x6. x = column (0 left .. 5 right), y = row (0 top .. 5 bottom).
// The red target car is horizontal in EXIT_ROW and escapes through a gap in
@ -9,12 +9,26 @@
// horizontal -> occupies (x..x+len-1, y); vertical -> occupies (x, y..y+len-1)
//
// Move metric (matches published Rush Hour difficulty): sliding one car any
// number of squares in a single direction counts as ONE move.
// number of squares in a single direction counts as ONE move. Fogleman calls
// this a "move" as opposed to a "step" (one square); the published ThinkFun
// difficulties are quoted in moves.
//
// ── Representation ───────────────────────────────────────────────────────────
// A vehicle's orientation, length and fixed axis never change, so a whole board
// state is just the moving coordinate of each vehicle. Everything below packs a
// state into a Uint8Array of those coordinates plus a single numeric key
// (base-7, since each coordinate is 0..5 and 7^18 still lands inside the exact
// integer range of a double). Occupancy is a reused Uint8Array(36) repainted
// per expansion rather than an array of cloned objects — that is what makes a
// full-cluster analysis affordable.
export const GRID = 6;
export const EXIT_ROW = 2;
export const TARGET_ID = 'X';
const CELLS = GRID * GRID;
const MAX_PACKED_VEHICLES = 18; // 7^18 < 2^53, so keys stay exact
export function vehicleCells(v) {
const cells = [];
for (let i = 0; i < v.len; i++) {
@ -89,47 +103,246 @@ export function cloneVehicles(vehicles) {
return vehicles.map((v) => ({ ...v }));
}
// ── Packed engine ────────────────────────────────────────────────────────────
// Freezes the immutable facts about a vehicle set (length, orientation, fixed
// axis, id) and exposes cheap operations over packed position vectors.
export function packBoard(vehicles) {
const n = vehicles.length;
if (n > MAX_PACKED_VEHICLES) {
throw new Error(`RushHour: ${n} vehicles exceeds the packed limit of ${MAX_PACKED_VEHICLES}`);
}
const len = new Uint8Array(n);
const horiz = new Uint8Array(n);
const fixed = new Uint8Array(n);
const maxPos = new Uint8Array(n);
const pow = new Float64Array(n);
const ids = new Array(n);
let targetIdx = -1;
for (let i = 0; i < n; i++) {
const v = vehicles[i];
len[i] = v.len;
horiz[i] = v.orient === 'h' ? 1 : 0;
fixed[i] = horiz[i] ? v.y : v.x;
maxPos[i] = GRID - v.len;
pow[i] = 7 ** i;
ids[i] = v.id;
if (v.isTarget) targetIdx = i;
}
const start = new Uint8Array(n);
for (let i = 0; i < n; i++) start[i] = horiz[i] ? vehicles[i].x : vehicles[i].y;
// Cell index of the k-th square of vehicle i when its head sits at p.
const cell = (i, p, k) => (horiz[i] ? fixed[i] * GRID + p + k : (p + k) * GRID + fixed[i]);
const goalPos = targetIdx >= 0 ? GRID - len[targetIdx] : -1;
const targetEscapes = targetIdx >= 0 && horiz[targetIdx] === 1 && fixed[targetIdx] === EXIT_ROW;
function key(P) {
let s = 0;
for (let i = 0; i < n; i++) s += P[i] * pow[i];
return s;
}
function paint(P, occ) {
occ.fill(0);
for (let i = 0; i < n; i++) {
for (let k = 0; k < len[i]; k++) occ[cell(i, P[i], k)] = 1;
}
}
function isGoal(P) {
return targetEscapes && P[targetIdx] === goalPos;
}
function toVehicles(P) {
return vehicles.map((v, i) => ({
...v,
x: horiz[i] ? P[i] : fixed[i],
y: horiz[i] ? fixed[i] : P[i],
}));
}
return {
n, len, horiz, fixed, maxPos, pow, ids, targetIdx, goalPos, targetEscapes,
start, cell, key, paint, isGoal, toVehicles,
};
}
// Every one-slide neighbour of P, given occupancy already painted into `occ`.
// Calls visit(vehicleIdx, newPos) — no allocation per neighbour.
function forEachSlide(B, P, occ, visit) {
for (let i = 0; i < B.n; i++) {
const head = P[i];
// Toward 0: each step exposes exactly one new leading cell at `np`.
for (let np = head - 1; np >= 0; np--) {
if (occ[B.cell(i, np, 0)]) break;
visit(i, np);
}
// Toward GRID: the new leading cell is the tail square, np + len - 1.
const tail = B.len[i] - 1;
for (let np = head + 1; np <= B.maxPos[i]; np++) {
if (occ[B.cell(i, np, tail)]) break;
visit(i, np);
}
}
}
// Breadth-first shortest solution. Returns { moves, path }.
// moves: minimum number of slides to solve (0 if already solved, -1 if none)
// path: array of { id, x, y } optimal moves (null if unsolvable)
// The 6x6 state space is small, but packed unsolvable layouts can still have a
// large reachable space. `maxStates` bounds the search so a single solve stays
// cheap (used heavily by the generator); exceeding it returns moves:-1.
// `maxStates` bounds the search so a single solve stays cheap; exceeding it
// returns moves:-1, which is indistinguishable from "unsolvable" by design —
// callers treat both as "reject this board".
export function solve(vehicles, { maxStates = 600000 } = {}) {
const start = cloneVehicles(vehicles);
const startKey = stateKey(start);
if (isSolved(start)) return { moves: 0, path: [] };
const B = packBoard(vehicles);
if (B.isGoal(B.start)) return { moves: 0, path: [] };
if (!B.targetEscapes) return { moves: -1, path: null };
const meta = new Map([[startKey, null]]); // key -> { parentKey, move }
const stateByKey = new Map([[startKey, start]]);
let frontier = [startKey];
let depth = 0;
const states = [B.start];
const keys = [B.key(B.start)];
const parent = [-1];
const mvVeh = [-1];
const mvPos = [-1];
const depth = [0];
const seen = new Map([[keys[0], 0]]);
const occ = new Uint8Array(CELLS);
while (frontier.length) {
depth++;
const next = [];
for (const key of frontier) {
const state = stateByKey.get(key);
for (const mv of legalMoves(state)) {
const ns = cloneVehicles(state);
ns[mv.idx].x = mv.x;
ns[mv.idx].y = mv.y;
const nk = stateKey(ns);
if (meta.has(nk)) continue;
meta.set(nk, { parentKey: key, move: { id: mv.id, x: mv.x, y: mv.y } });
stateByKey.set(nk, ns);
if (isSolved(ns)) {
const build = (endIdx) => {
const path = [];
let cur = nk;
while (meta.get(cur)) { const e = meta.get(cur); path.unshift(e.move); cur = e.parentKey; }
return { moves: depth, path };
for (let at = endIdx; at > 0; at = parent[at]) {
const i = mvVeh[at];
const p = mvPos[at];
path.push({
id: B.ids[i],
x: B.horiz[i] ? p : B.fixed[i],
y: B.horiz[i] ? B.fixed[i] : p,
});
}
next.push(nk);
}
if (meta.size > maxStates) return { moves: -1, path: null };
}
frontier = next;
if (depth > 100) break;
path.reverse();
return path;
};
for (let qi = 0; qi < states.length; qi++) {
const P = states[qi];
const baseKey = keys[qi];
const d = depth[qi];
B.paint(P, occ);
let done = -1;
forEachSlide(B, P, occ, (i, np) => {
if (done >= 0) return;
const k = baseKey + (np - P[i]) * B.pow[i];
if (seen.has(k)) return;
const Q = Uint8Array.from(P);
Q[i] = np;
const idx = states.length;
seen.set(k, idx);
states.push(Q); keys.push(k); parent.push(qi); mvVeh.push(i); mvPos.push(np); depth.push(d + 1);
if (B.isGoal(Q)) done = idx;
});
if (done >= 0) return { moves: depth[done], path: build(done) };
if (states.length > maxStates) return { moves: -1, path: null };
}
return { moves: -1, path: null };
}
// Full reachable cluster of a board, plus each state's distance to the goal.
//
// Every slide is reversible, so the state graph is undirected: a multi-source
// BFS from the goal states yields optimal distance-to-goal for the whole
// cluster in one pass. Neighbours are recomputed rather than stored — clusters
// run to hundreds of thousands of states and an explicit adjacency list costs
// far more memory than the second enumeration costs time.
//
// Returns null if the cluster exceeds maxStates or contains no goal state.
// { size, states, dist, startDist, maxDist, hardest, keyOf, indexOf, board }
// `hardest` is a state at maximum distance from the goal — i.e. the hardest
// arrangement of this piece set, which is what makes a puzzle "unsolved" in
// Fogleman's sense: no rearrangement of these pieces is harder.
export function analyzeCluster(vehicles, { maxStates = 400000 } = {}) {
const B = packBoard(vehicles);
if (!B.targetEscapes) return null;
const states = [B.start];
const keys = [B.key(B.start)];
const index = new Map([[keys[0], 0]]);
const occ = new Uint8Array(CELLS);
for (let qi = 0; qi < states.length; qi++) {
const P = states[qi];
const baseKey = keys[qi];
B.paint(P, occ);
forEachSlide(B, P, occ, (i, np) => {
const k = baseKey + (np - P[i]) * B.pow[i];
if (index.has(k)) return;
const Q = Uint8Array.from(P);
Q[i] = np;
index.set(k, states.length);
states.push(Q);
keys.push(k);
});
if (states.length > maxStates) return null;
}
const size = states.length;
const dist = new Int32Array(size).fill(-1);
let frontier = [];
for (let i = 0; i < size; i++) {
if (B.isGoal(states[i])) { dist[i] = 0; frontier.push(i); }
}
if (!frontier.length) return null;
let d = 0;
while (frontier.length) {
d++;
const next = [];
for (const qi of frontier) {
const P = states[qi];
const baseKey = keys[qi];
B.paint(P, occ);
forEachSlide(B, P, occ, (i, np) => {
const k = baseKey + (np - P[i]) * B.pow[i];
const ni = index.get(k);
if (ni === undefined || dist[ni] >= 0) return;
dist[ni] = d;
next.push(ni);
});
}
frontier = next;
}
let maxDist = 0;
let hardestIdx = 0;
for (let i = 0; i < size; i++) {
if (dist[i] > maxDist) { maxDist = dist[i]; hardestIdx = i; }
}
// One-slide neighbours of a state, as { index, vehicleIdx, pos }. Used by the
// generator to measure how many legal moves at each step are dead ends.
const nbrOcc = new Uint8Array(CELLS);
function neighbors(qi) {
const P = states[qi];
const baseKey = keys[qi];
B.paint(P, nbrOcc);
const out = [];
forEachSlide(B, P, nbrOcc, (i, np) => {
out.push({ index: index.get(baseKey + (np - P[i]) * B.pow[i]), vehicleIdx: i, pos: np });
});
return out;
}
return {
board: B,
size,
states,
dist,
startDist: dist[0],
maxDist,
hardest: states[hardestIdx],
hardestIndex: hardestIdx,
neighbors,
indexOf: (P) => index.get(B.key(P)),
toVehicles: (P) => B.toVehicles(P),
};
}

View File

@ -52,7 +52,6 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('advancewars-artwork', 'data/advancewars-artwork.json');
this.load.json('tetrisattack-artwork', 'data/tetrisattack-artwork.json');
this.load.json('totalannihilation-artwork', 'data/totalannihilation-artwork.json');
this.load.json('rushhour', 'data/rushhour.json');
this.load.json('bloxorz', 'data/bloxorz.json');
this.load.json('puddingmonsters', 'data/puddingmonsters.json');
this.load.json('gootower-levels', 'assets/gamedata/gootower/levels.json');

View File

@ -1,29 +1,101 @@
// 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 data/rushhour.json.
// Offline curriculum generator for Rush Hour.
//
// Usage:
// node server/scripts/genRushHour.js [seed] [outFile]
// node tools/genRushHour.js [seed] [outFile]
//
// Deterministic: same seed -> same bank. Re-run after changing the curve.
// 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, isSolved, solve, vehicleCells,
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, '../data/rushhour.json');
: 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;
@ -36,22 +108,30 @@ function makeRng(seed) {
}
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)
// ── Board helpers ────────────────────────────────────────────────────────────
const LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop';
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) {
@ -61,117 +141,359 @@ function canonKey(vehicles) {
.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;
// 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;
}
function stamp(grid, v) {
for (const [x, y] of vehicleCells(v)) grid[y][x] = v.id;
// 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 })),
];
}
// 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;
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);
const v = { id: LETTERS[letterIdx], x, y, len, orient, isTarget: false };
if (!fits(grid, v)) continue;
stamp(grid, v);
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;
}
// ── Generate pool ────────────────────────────────────────────────────────────
console.log(`[rushhour] generating with seed ${SEED}`);
const target = TIERS.reduce((t, x) => t + x.count, 0);
// ── 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;
let solved = 0;
function tierIndexFor(moves) {
function tierFor(par) {
for (let i = 0; i < TIERS.length; i++) {
if (moves >= TIERS[i].min && moves <= TIERS[i].max) return i;
if (par >= TIERS[i].par[0] && par <= TIERS[i].par[1]) 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
function consider(result) {
if (!result) return null;
const { vehicles, par } = result;
if (!structurallyOk(vehicles)) return null;
const key = canonKey(vehicles);
if (seen.has(key)) continue;
if (seen.has(key)) return null;
seen.add(key);
refined++;
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 });
// 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;
if (solved % 2000 === 0) {
process.stdout.write(`\r[rushhour] attempts=${attempts} kept=${buckets.reduce((t, b) => t + b.length, 0)}/${target} `);
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 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);
// ── Assemble ─────────────────────────────────────────────────────────────────
const puzzles = chosen.map((p, i) => ({
level: i + 1,
minMoves: p.minMoves,
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 = {
generatedAt: new Date().toISOString(),
version: 1,
seed: SEED,
count: puzzles.length,
puzzles,
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));
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}`);
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);
}

392
tools/verifyRushHour.js Normal file
View File

@ -0,0 +1,392 @@
// Headless verification for Rush Hour.
// node tools/verifyRushHour.js
// Exits non-zero on any failure.
//
// 1. Solver correctness against an independent reference BFS.
// 2. Board model invariants (slideRange, legalMoves, isSolved).
// 3. Cluster analysis (distance-to-goal, hardest-state extraction).
// 4. Level bank schema and geometry.
// 5. Structural criteria (exit row, no frozen lines).
// 6. Par matches the solver, and the shipped optimal path replays cleanly.
// 7. MINIMAL — removing any vehicle changes the solution.
// 8. UNSOLVED — the start is the farthest state from the goal in its cluster.
// 9. Curriculum shape (tiers, monotonic par, difficulty metrics).
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import {
GRID, EXIT_ROW, TARGET_ID,
vehicleCells, buildGrid, isSolved, stateKey, legalMoves, slideRange,
cloneVehicles, solve, analyzeCluster, packBoard,
} from '../src/games/rushhour/RushHourLogic.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
let failures = 0;
let checks = 0;
function check(name, cond, detail = '') {
checks += 1;
if (cond) return;
failures += 1;
console.error(`FAIL ${name}${detail ? `${detail}` : ''}`);
}
function section(title) {
console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 62 - title.length))}`);
}
// ── Reference solver ─────────────────────────────────────────────────────────
//
// Deliberately naive: clones an array of plain objects per state and keys it
// with a string join, exactly the way the shipped solver used to. It exists
// only to cross-check the packed rewrite. If these two ever disagree, the fast
// path is wrong — this is the whole reason it lives in the verifier rather
// than being deleted.
function referenceSolve(vehicles, maxStates = 400000) {
const start = cloneVehicles(vehicles);
if (isSolved(start)) return 0;
const seen = new Set([stateKey(start)]);
let frontier = [start];
let depth = 0;
while (frontier.length) {
depth += 1;
const next = [];
for (const state of frontier) {
for (const mv of legalMoves(state)) {
const ns = cloneVehicles(state);
ns[mv.idx].x = mv.x;
ns[mv.idx].y = mv.y;
const k = stateKey(ns);
if (seen.has(k)) continue;
seen.add(k);
if (isSolved(ns)) return depth;
next.push(ns);
}
if (seen.size > maxStates) return -1;
}
frontier = next;
if (depth > 120) break;
}
return -1;
}
// ── Fixtures ─────────────────────────────────────────────────────────────────
// The canonical ThinkFun "card 1" shape: target boxed in behind one truck.
const FIXTURE = [
{ id: TARGET_ID, x: 1, y: 2, len: 2, orient: 'h', isTarget: true },
{ id: 'A', x: 3, y: 1, len: 3, orient: 'v', isTarget: false },
{ id: 'B', x: 0, y: 0, len: 2, orient: 'h', isTarget: false },
];
// ── 1. Solver vs reference ───────────────────────────────────────────────────
section('1. Solver correctness');
{
const trivial = [{ id: TARGET_ID, x: 4, y: 2, len: 2, orient: 'h', isTarget: true }];
check('already-solved board reports 0 moves', solve(trivial).moves === 0);
check('already-solved board returns an empty path', solve(trivial).path.length === 0);
const clear = [{ id: TARGET_ID, x: 0, y: 2, len: 2, orient: 'h', isTarget: true }];
check('unobstructed target solves in one move', solve(clear).moves === 1);
// A vertical wall spanning the exit row with no way past is unsolvable.
const walled = [
{ id: TARGET_ID, x: 0, y: 2, len: 2, orient: 'h', isTarget: true },
{ id: 'A', x: 5, y: 0, len: 3, orient: 'v', isTarget: false },
{ id: 'B', x: 5, y: 3, len: 3, orient: 'v', isTarget: false },
];
check('permanently blocked board is unsolvable', solve(walled).moves === -1);
check('fixture par matches the reference solver',
solve(FIXTURE).moves === referenceSolve(FIXTURE),
`fast=${solve(FIXTURE).moves} ref=${referenceSolve(FIXTURE)}`);
// A target not on the exit row can never escape.
const offRow = [{ id: TARGET_ID, x: 0, y: 3, len: 2, orient: 'h', isTarget: true }];
check('target off the exit row is unsolvable', solve(offRow).moves === -1);
// maxStates is a budget, not a claim of unsolvability — but it must not lie
// in the other direction by reporting a solution it did not find.
const budgeted = solve(FIXTURE, { maxStates: 1 });
check('exhausted budget reports -1 rather than a wrong answer',
budgeted.moves === -1 || budgeted.moves === solve(FIXTURE).moves);
}
// ── 2. Board model invariants ────────────────────────────────────────────────
section('2. Board model');
{
const cells = vehicleCells({ x: 2, y: 3, len: 3, orient: 'v' });
check('vertical vehicleCells runs down the column',
JSON.stringify(cells) === JSON.stringify([[2, 3], [2, 4], [2, 5]]));
const hcells = vehicleCells({ x: 2, y: 3, len: 2, orient: 'h' });
check('horizontal vehicleCells runs along the row',
JSON.stringify(hcells) === JSON.stringify([[2, 3], [3, 3]]));
const grid = buildGrid(FIXTURE);
check('buildGrid marks every occupied square',
grid.flat().filter(Boolean).length === FIXTURE.reduce((s, v) => s + v.len, 0));
const r = slideRange(FIXTURE, 0);
check('slideRange bounds the target by the blocking truck', r.min === 0 && r.max === 1,
`got ${r.min}..${r.max}`);
// Every legal move must land somewhere different and stay on the board.
const moves = legalMoves(FIXTURE);
check('legal moves stay on the board',
moves.every((m) => m.x >= 0 && m.y >= 0 && m.x < GRID && m.y < GRID));
check('legal moves actually move a piece',
moves.every((m) => {
const v = FIXTURE[m.idx];
return m.x !== v.x || m.y !== v.y;
}));
// packBoard must round-trip positions unchanged.
const B = packBoard(FIXTURE);
const round = B.toVehicles(B.start);
check('packBoard round-trips vehicle positions',
round.every((v, i) => v.x === FIXTURE[i].x && v.y === FIXTURE[i].y && v.id === FIXTURE[i].id));
}
// ── 3. Cluster analysis ──────────────────────────────────────────────────────
section('3. Cluster analysis');
{
const C = analyzeCluster(FIXTURE);
check('cluster analysis succeeds on a solvable board', !!C);
check('start distance equals the solver par', C.startDist === solve(FIXTURE).moves,
`dist=${C.startDist} par=${solve(FIXTURE).moves}`);
check('hardest state is at least as far as the start', C.maxDist >= C.startDist);
check('every state in the cluster can reach the goal',
Array.from(C.dist).every((d) => d >= 0));
// The hardest state must really solve in maxDist moves.
const hardVehicles = C.toVehicles(C.hardest);
check('hardest state solves in exactly maxDist moves',
solve(hardVehicles).moves === C.maxDist,
`solve=${solve(hardVehicles).moves} maxDist=${C.maxDist}`);
// Neighbour distances must differ by at most one — the defining property of
// a BFS layering, and the thing the decoy metric relies on.
let layered = true;
for (let i = 0; i < C.size && layered; i++) {
for (const nb of C.neighbors(i)) {
if (Math.abs(C.dist[nb.index] - C.dist[i]) > 1) { layered = false; break; }
}
}
check('neighbouring states differ by at most one in distance', layered);
}
// ── 4-9. The shipped bank ────────────────────────────────────────────────────
const BANK_PATH = join(__dirname, '../assets/gamedata/rushhour/levels.json');
let bank;
try {
bank = JSON.parse(readFileSync(BANK_PATH, 'utf8'));
} catch (err) {
console.error(`FAIL level bank is missing or unreadable — ${err.message}`);
console.error(' run: node tools/genRushHour.js');
process.exit(1);
}
section('4. Bank schema and geometry');
{
check('bank declares a version', bank.version === 1);
check('bank declares tiers', Array.isArray(bank.tiers) && bank.tiers.length > 0);
check('count matches the level array', bank.count === bank.levels.length);
check('levels are numbered 1..N contiguously',
bank.levels.every((p, i) => p.level === i + 1));
const names = new Set(bank.levels.map((p) => p.name));
check('every level has a distinct name', names.size === bank.levels.length);
for (const p of bank.levels) {
const tag = `level ${p.level}`;
const targets = p.vehicles.filter((v) => v.isTarget);
check(`${tag}: exactly one target`, targets.length === 1);
check(`${tag}: target is horizontal in the exit row`,
targets[0]?.orient === 'h' && targets[0]?.y === EXIT_ROW);
let ok = true;
for (const v of p.vehicles) {
if (v.len !== 2 && v.len !== 3) ok = false;
if (v.orient !== 'h' && v.orient !== 'v') ok = false;
for (const [x, y] of vehicleCells(v)) {
if (x < 0 || y < 0 || x >= GRID || y >= GRID) ok = false;
}
}
check(`${tag}: all vehicles are well-formed and on the board`, ok);
const occupied = buildGrid(p.vehicles).flat().filter(Boolean).length;
check(`${tag}: no two vehicles overlap`,
occupied === p.vehicles.reduce((s, v) => s + v.len, 0));
const ids = new Set(p.vehicles.map((v) => v.id));
check(`${tag}: vehicle ids are unique`, ids.size === p.vehicles.length);
check(`${tag}: does not start already solved`, !isSolved(p.vehicles));
}
}
section('5. Structural criteria');
{
for (const p of bank.levels) {
const tag = `level ${p.level}`;
// A horizontal piece sharing the exit row either blocks the exit forever
// or is pure decoration; either way it has no place on the board.
check(`${tag}: nothing but the target on the exit row`,
!p.vehicles.some((v) => !v.isTarget && v.orient === 'h' && v.y === EXIT_ROW));
// A row filled entirely with horizontal pieces (or a column with vertical
// ones) can never move.
const grid = buildGrid(p.vehicles);
const byId = new Map(p.vehicles.map((v) => [v.id, v]));
let frozenRow = -1;
let frozenCol = -1;
for (let y = 0; y < GRID; y++) {
if (grid[y].every((id) => id !== null && byId.get(id).orient === 'h')) frozenRow = y;
}
for (let x = 0; x < GRID; x++) {
let all = true;
for (let y = 0; y < GRID; y++) {
const id = grid[y][x];
if (id === null || byId.get(id).orient !== 'v') { all = false; break; }
}
if (all) frozenCol = x;
}
check(`${tag}: no frozen row of horizontal pieces`, frozenRow === -1, `row ${frozenRow}`);
check(`${tag}: no frozen column of vertical pieces`, frozenCol === -1, `col ${frozenCol}`);
}
}
section('6. Par is honest');
{
for (const p of bank.levels) {
const tag = `level ${p.level}`;
const { moves, path } = solve(p.vehicles);
check(`${tag}: shipped par matches the solver`, moves === p.par, `shipped=${p.par} solver=${moves}`);
// Replay the optimal path and confirm it both stays legal and finishes.
const state = cloneVehicles(p.vehicles);
let legal = true;
for (const mv of path ?? []) {
const idx = state.findIndex((v) => v.id === mv.id);
const range = slideRange(state, idx);
const axis = state[idx].orient === 'h' ? mv.x : mv.y;
if (axis < range.min || axis > range.max) { legal = false; break; }
state[idx].x = mv.x;
state[idx].y = mv.y;
}
check(`${tag}: optimal path is a legal sequence of slides`, legal);
check(`${tag}: optimal path ends solved`, legal && isSolved(state));
}
}
section('7. Every vehicle is load-bearing (MINIMAL)');
{
let offenders = 0;
for (const p of bank.levels) {
const spare = [];
for (const v of p.vehicles) {
if (v.isTarget) continue;
const reduced = p.vehicles.filter((w) => w.id !== v.id);
if (solve(reduced).moves === p.par) spare.push(v.id);
}
if (spare.length) offenders += 1;
check(`level ${p.level}: removing any vehicle changes the solution`,
spare.length === 0, `redundant: ${spare.join(',')}`);
}
check('no level in the bank carries a decorative vehicle', offenders === 0,
`${offenders} level(s) affected`);
}
section('8. Start is the hardest arrangement (UNSOLVED)');
{
for (const p of bank.levels) {
const C = analyzeCluster(p.vehicles);
check(`level ${p.level}: cluster analysis succeeds`, !!C);
if (!C) continue;
check(`level ${p.level}: start is at maximum distance from the goal`,
C.startDist === C.maxDist, `start=${C.startDist} max=${C.maxDist}`);
}
}
section('9. Curriculum shape');
{
const pars = bank.levels.map((p) => p.par);
check('par never decreases as levels advance',
pars.every((v, i) => i === 0 || v >= pars[i - 1]));
check('the curriculum actually ramps', pars[pars.length - 1] > pars[0] * 2,
`${pars[0]} -> ${pars[pars.length - 1]}`);
// Tier ranges must tile the bank exactly, in order, with no gaps.
let cursor = 1;
let tiled = true;
for (const t of bank.tiers) {
if (t.from !== cursor || t.to < t.from) tiled = false;
cursor = t.to + 1;
}
check('tier ranges tile the bank contiguously', tiled);
check('tiers cover every level', cursor - 1 === bank.levels.length);
check('every level carries the tier id that contains it',
bank.levels.every((p) => {
const t = bank.tiers.find((q) => p.level >= q.from && p.level <= q.to);
return t && t.id === p.tier;
}));
// Difficulty must rise between tiers, not just within them.
const tierMedian = bank.tiers.map((t) => {
const d = bank.levels.filter((p) => p.tier === t.id).map((p) => p.difficulty).sort((a, b) => a - b);
return d[Math.floor(d.length / 2)];
});
check('median difficulty rises with every tier',
tierMedian.every((v, i) => i === 0 || v > tierMedian[i - 1]),
tierMedian.join(' -> '));
// Metrics must be self-consistent with the board they describe.
for (const p of bank.levels) {
check(`level ${p.level}: carsMoved does not exceed the vehicles present`,
p.carsMoved <= p.vehicles.length && p.carsMoved > 0);
check(`level ${p.level}: decoyDensity is a fraction`,
p.decoyDensity >= 0 && p.decoyDensity <= 1);
}
const avgShare = bank.levels.reduce((s, p) => s + p.carsMoved / p.vehicles.length, 0) / bank.levels.length;
check('on average almost every vehicle has to move', avgShare > 0.85, avgShare.toFixed(3));
}
section('10. Fast solver agrees with the reference on every level');
{
// The expensive one, so it runs last: an independent BFS over the shipped
// bank. Capped to the levels the naive solver can reach in reasonable time.
let compared = 0;
for (const p of bank.levels) {
const ref = referenceSolve(p.vehicles, 300000);
if (ref === -1) continue; // reference ran out of budget
compared += 1;
check(`level ${p.level}: packed solver matches reference BFS`, ref === p.par,
`ref=${ref} shipped=${p.par}`);
}
check('the reference cross-check covered the whole bank', compared === bank.levels.length,
`covered ${compared}/${bank.levels.length}`);
}
// ── Summary ──────────────────────────────────────────────────────────────────
console.log(`\n${'─'.repeat(68)}`);
const pars = bank.levels.map((p) => p.par);
const cars = bank.levels.map((p) => p.vehicles.length);
console.log(`levels ${bank.levels.length} par ${Math.min(...pars)}..${Math.max(...pars)} ` +
`vehicles ${Math.min(...cars)}..${Math.max(...cars)}`);
for (const t of bank.tiers) {
const ps = bank.levels.filter((p) => p.tier === t.id).map((p) => p.par);
console.log(` ${t.name.padEnd(15)} levels ${String(t.from).padStart(2)}-${String(t.to).padEnd(2)} par ${Math.min(...ps)}..${Math.max(...ps)}`);
}
console.log(`${'─'.repeat(68)}`);
if (failures) {
console.error(`\n${failures} FAILED of ${checks} checks`);
process.exit(1);
}
console.log(`\nall ${checks} checks passed`);