fertig-classic-games/tools/genPeggleLevels.js

1117 lines
44 KiB
JavaScript

// Generates the Peggle campaign's second wave: levels 26-85, 5 per friend.
// node tools/genPeggleLevels.js
// Writes assets/gamedata/peggle/level-026..085.json and levels.json.
//
// Levels 1-25 (Ethel/Kona/Zanthor/Fireball/Victor) were generated once and
// then HAND-TUNED in the editor (index.html?peggle-editor=1) — this script
// never rewrites their files and carries their levels.json entries over
// verbatim. The original 1-25 builders live in git history.
//
// Blocks: 26-30 Smasher (bodyslam), 31-35 Maurice (beaverdam), 36-40 Schooner
// (tailwind), 41-45 Terry (meteor), 46-50 Gerome (extremeball), 51-55
// Blackwind (cannonball), 56-60 Steve (beamup), 61-65 Nicole (overclock),
// 66-70 DV-8-2303 (lasergrid), 71-75 Kage (shadowslash), 76-80 Aiko
// (cosmicbloom), 81-85 Nadia (rewind).
//
// Layouts are starting points meant to be hand-tuned later in the editor;
// pegs violating spacing are dropped, so shapes may thin slightly where
// strokes intersect. Every generated level is validated against the same
// constraints tools/verifyPeggle.js lints (bounds, overlap, brick clearance,
// orange-eligible counts, reachability) and the script fails if any level
// breaks one.
import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { TUNING, tessellateCurves, createRound, scoreShot } from '../src/games/peggle/PeggleLogic.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = join(__dirname, '..', 'assets', 'gamedata', 'peggle');
const W = TUNING.BOARD_W; // 1200
const MIN_Y = 190; // clear of the launcher
const MAX_Y = 790; // clear of the bucket lane
const GAP = 6; // required clearance between peg rims
const R = TUNING.PEG_R;
const D = Math.PI / 180; // layouts below think in degrees
// ── Shape helpers ────────────────────────────────────────────────────────────
function arc(cx, cy, radius, a0, a1, n, opts = {}) {
const pegs = [];
for (let i = 0; i < n; i++) {
const a = a0 + ((a1 - a0) * i) / Math.max(1, n - 1);
pegs.push({ x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a), ...opts });
}
return pegs;
}
function line(x0, y0, x1, y1, n, opts = {}) {
const pegs = [];
for (let i = 0; i < n; i++) {
const t = n === 1 ? 0 : i / (n - 1);
pegs.push({ x: x0 + (x1 - x0) * t, y: y0 + (y1 - y0) * t, ...opts });
}
return pegs;
}
function grid(x, y, cols, rows, dx, dy, opts = {}) {
const pegs = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
pegs.push({ x: x + c * dx, y: y + r * dy, ...opts });
}
}
return pegs;
}
function circle(cx, cy, radius, n, opts = {}) {
return arc(cx, cy, radius, 0, Math.PI * 2 * (1 - 1 / n), n, opts);
}
// Full ring starting at `startDeg` (for interleaving concentric rings).
function ringAt(cx, cy, radius, n, startDeg, opts = {}) {
return arc(cx, cy, radius, startDeg * D, (startDeg + 360 * (1 - 1 / n)) * D, n, opts);
}
function ellipseRing(cx, cy, rx, ry, n, opts = {}) {
const pegs = [];
for (let i = 0; i < n; i++) {
const a = (i * Math.PI * 2) / n - Math.PI / 2;
pegs.push({ x: cx + rx * Math.cos(a), y: cy + ry * Math.sin(a), ...opts });
}
return pegs;
}
// Drop pegs out of bounds or too close to an earlier peg.
function settle(pegs) {
const out = [];
for (const p of pegs) {
const x = Math.round(p.x);
const y = Math.round(p.y);
if (x < R + GAP || x > W - R - GAP || y < MIN_Y || y > MAX_Y) continue;
const minD = 2 * R + GAP;
if (out.some((q) => (q.x - x) ** 2 + (q.y - y) ** 2 < minD * minD)) continue;
out.push({ x, y, shape: 'circle', r: R, orangeEligible: !!p.orangeEligible });
}
return out;
}
function levelFile(pegs, over = {}) {
const curves = over.curves ?? [];
let placed = pegs;
if (curves.length) {
// Cull circle pegs that would collide with the brick curves.
const bricks = tessellateCurves(curves);
placed = pegs.filter((p) => {
const min = p.r + TUNING.BRICK_WID / 2 + GAP + 4;
return !bricks.some((b) => (p.x - b.x) ** 2 + (p.y - b.y) ** 2 < min * min);
});
}
return {
version: 1,
board: { width: TUNING.BOARD_W, height: TUNING.BOARD_H },
orangeCount: 25,
greenCount: 2,
pegs: placed,
walls: [],
moving: [],
...over,
curves,
};
}
// Brick-curve shorthand: anchors + per-segment bends, orange-eligible.
const wave = (anchors, bends, orangeEligible = true) => ({ anchors, bends, orangeEligible });
const seg = (x0, y0, x1, y1, bend = 0) => wave([{ x: x0, y: y0 }, { x: x1, y: y1 }], [bend]);
const el = { orangeEligible: true };
// ── The Smasher (26-30): the wrestling arena ─────────────────────────────────
function smasherLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Squared Circle — the ring from above: ropes, posts, mat
pegs.push(
...line(350, 260, 850, 260, 11, el),
...line(350, 640, 850, 640, 11, el),
...line(350, 315, 350, 585, 6, el),
...line(850, 315, 850, 585, 6, el),
{ x: 295, y: 210 }, { x: 905, y: 210 }, { x: 295, y: 690 }, { x: 905, y: 690 },
);
curves.push(seg(280, 180, 920, 180, 35), seg(280, 725, 920, 725, -35));
break;
case 1: // Top Rope — three sagging ropes over a roaring crowd
pegs.push(
...line(150, 680, 1050, 680, 14, el),
...line(185, 745, 1015, 745, 13, el),
...line(150, 240, 150, 520, 5, el),
...line(1050, 240, 1050, 520, 5, el),
);
curves.push(
seg(220, 250, 980, 250, 55),
seg(220, 380, 980, 380, 55),
seg(220, 510, 980, 510, 55),
);
break;
case 2: // Turnbuckle — crossed slam trajectories through a center ring
pegs.push(
...line(210, 195, 990, 760, 13, el),
...line(990, 195, 210, 760, 13, el),
...circle(600, 470, 100, 8, el),
{ x: 160, y: 230 }, { x: 1040, y: 230 }, { x: 160, y: 720 }, { x: 1040, y: 720 },
);
curves.push(seg(430, 800, 770, 800, 0));
break;
case 3: // Championship Belt — the big gold buckle
pegs.push(
...circle(600, 480, 115, 12, el),
...circle(600, 480, 58, 6, el),
{ x: 600, y: 480, ...el },
...arc(600, 480, 205, 200 * D, 340 * D, 9, el),
...arc(600, 480, 205, 25 * D, 155 * D, 7, el),
);
curves.push(seg(140, 480, 420, 480, 25), seg(780, 480, 1060, 480, 25));
break;
default: // Steel Cage — the mesh comes down
pegs.push(
...grid(260, 220, 8, 3, 100, 200, el),
...grid(320, 320, 8, 3, 100, 200, el),
);
curves.push(seg(190, 195, 190, 760, 0), seg(1010, 195, 1010, 760, 0));
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Maurice (31-35): the riverside ───────────────────────────────────────────
function mauriceLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Timber! — felled logs mid-topple
pegs.push(
...line(170, 250, 520, 350, 7, el),
...line(690, 200, 1040, 320, 7, el),
...line(150, 530, 500, 450, 7, el),
...line(660, 500, 1050, 580, 8, el),
...line(300, 690, 420, 690, 3),
...line(750, 715, 870, 715, 3),
);
curves.push(seg(200, 790, 1000, 790, 0));
break;
case 1: // Lazy River — the water winds between grassy banks
pegs.push(
...line(150, 210, 1050, 210, 10, el),
...line(150, 680, 1050, 680, 10, el),
...circle(300, 580, 45, 5, el),
...circle(880, 605, 45, 5, el),
);
curves.push(wave(
[{ x: 110, y: 320 }, { x: 420, y: 460 }, { x: 800, y: 370 }, { x: 1090, y: 500 }],
[100, -100, 100],
));
break;
case 2: // The Lodge — the stick dome on the water
pegs.push(
...arc(600, 560, 225, 180 * D, 360 * D, 12, el),
...arc(600, 560, 152, 180 * D, 360 * D, 8, el),
...arc(600, 560, 82, 180 * D, 360 * D, 5, el),
...line(330, 590, 870, 590, 8, el),
);
curves.push(wave([{ x: 230, y: 705 }, { x: 600, y: 705 }, { x: 970, y: 705 }], [45, -45]));
break;
case 3: // Log Jam — timber stacked every which way
pegs.push(
...line(150, 250, 400, 250, 5, el),
...line(500, 225, 820, 225, 6, el),
...line(890, 290, 1075, 290, 4, el),
...line(200, 420, 520, 420, 6, el),
...line(620, 455, 1000, 455, 7, el),
...line(150, 610, 450, 610, 6, el),
...line(560, 645, 900, 645, 7, el),
...line(300, 780, 900, 780, 7),
);
curves.push(seg(985, 615, 1080, 735, 0));
break;
default: // Grand Dam — Maurice's masterpiece holds back the flood
pegs.push(
...arc(600, 430, 135, 195 * D, 345 * D, 8, el),
...arc(600, 430, 210, 200 * D, 340 * D, 10, el),
...line(250, 700, 950, 700, 8, el),
...line(300, 765, 900, 765, 7, el),
);
curves.push(wave([{ x: 150, y: 565 }, { x: 600, y: 460 }, { x: 1050, y: 565 }], [30, 30]));
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Schooner (36-40): the high seas ──────────────────────────────────────────
function schoonerLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Ahoy! — the anchor drops
pegs.push(
...circle(600, 245, 50, 6, el),
...line(600, 335, 600, 555, 4, el),
...line(480, 335, 720, 335, 5, el),
...arc(600, 470, 165, 25 * D, 155 * D, 8, el),
{ x: 415, y: 505, ...el }, { x: 785, y: 505, ...el },
);
curves.push(seg(140, 660, 460, 660, 40), seg(740, 660, 1060, 660, 40));
break;
case 1: // Full Sail — canvas stretched before the wind
pegs.push(
...line(600, 200, 600, 700, 8, el),
...line(650, 240, 950, 590, 7, el),
...line(660, 630, 940, 630, 5, el),
...line(550, 300, 310, 590, 6, el),
...line(320, 630, 540, 630, 4, el),
);
curves.push(seg(300, 725, 900, 725, 45));
break;
case 2: // Crow's Nest — rigging up to the lookout
pegs.push(
...circle(600, 265, 72, 8, el),
...line(600, 380, 600, 700, 5, el),
...line(552, 345, 210, 700, 7, el),
...line(648, 345, 990, 700, 7, el),
...line(410, 520, 530, 520, 3, el),
...line(670, 520, 790, 520, 3, el),
...line(320, 630, 520, 630, 3, el),
...line(680, 630, 880, 630, 3, el),
);
curves.push(seg(200, 780, 1000, 780, 30));
break;
case 3: // Rolling Swells — wave after wave after wave
pegs.push(
...arc(280, 245, 45, 200 * D, 340 * D, 3, el),
...arc(560, 220, 45, 200 * D, 340 * D, 3, el),
...arc(840, 250, 45, 200 * D, 340 * D, 3, el),
{ x: 1055, y: 210, ...el },
);
curves.push(
wave([{ x: 120, y: 360 }, { x: 600, y: 360 }, { x: 1080, y: 360 }], [70, -70]),
wave([{ x: 120, y: 530 }, { x: 600, y: 530 }, { x: 1080, y: 530 }], [-70, 70]),
wave([{ x: 120, y: 700 }, { x: 600, y: 700 }, { x: 1080, y: 700 }], [70, -70]),
);
break;
default: // Maiden Voyage — the good ship herself
pegs.push(
...line(300, 465, 900, 465, 8, el),
...line(450, 200, 450, 415, 4, el),
...line(750, 200, 750, 415, 4, el),
...arc(450, 315, 85, -80 * D, 80 * D, 5, el),
...arc(750, 315, 85, -80 * D, 80 * D, 5, el),
{ x: 280, y: 430, ...el }, { x: 930, y: 430, ...el },
);
curves.push(
seg(250, 520, 950, 520, 120),
seg(180, 720, 480, 720, 35),
seg(720, 720, 1020, 720, 35),
);
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Terry (41-45): the Mesozoic ──────────────────────────────────────────────
const footprint = (x, y) => [
{ x, y, ...el },
{ x: x - 46, y: y - 48, ...el },
{ x, y: y - 60, ...el },
{ x: x + 46, y: y - 48, ...el },
];
function terryLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Footprints — something big walked through here
pegs.push(
...footprint(250, 720), ...footprint(450, 595), ...footprint(330, 465),
...footprint(510, 335), ...footprint(390, 255),
...line(850, 280, 850, 700, 5, el),
...line(950, 230, 950, 740, 6, el),
...line(1050, 330, 1050, 650, 4, el),
);
curves.push(seg(640, 775, 1060, 775, 20));
break;
case 1: // Tar Pits — bubbling and hungry
pegs.push(
...circle(390, 640, 112, 9, el),
...circle(390, 640, 55, 4, el),
...circle(860, 590, 100, 8, el),
{ x: 860, y: 590, ...el },
{ x: 350, y: 450, ...el }, { x: 430, y: 380, ...el }, { x: 300, y: 315, ...el },
{ x: 520, y: 300, ...el }, { x: 830, y: 430, ...el }, { x: 910, y: 350, ...el },
{ x: 770, y: 265, ...el }, { x: 600, y: 200, ...el }, { x: 600, y: 330, ...el },
);
curves.push(seg(150, 800, 1050, 800, 0));
break;
case 2: // Bone Yard — ribs, femurs, and one grinning skull
pegs.push(
...line(280, 250, 880, 250, 8, el),
...arc(320, 260, 160, 75 * D, 105 * D, 3, el),
...arc(460, 260, 160, 75 * D, 105 * D, 3, el),
...arc(600, 260, 160, 75 * D, 105 * D, 3, el),
...arc(740, 260, 160, 75 * D, 105 * D, 3, el),
...arc(880, 260, 160, 75 * D, 105 * D, 3, el),
...circle(960, 620, 72, 7, el),
{ x: 935, y: 600 }, { x: 985, y: 600 },
...line(150, 650, 400, 755, 5, el),
{ x: 120, y: 610, ...el }, { x: 440, y: 785, ...el },
);
curves.push(wave([{ x: 140, y: 330 }, { x: 260, y: 215 }], [-25]));
break;
case 3: // Volcano Day — duck and cover
pegs.push(
...line(350, 760, 560, 420, 6, el),
...line(850, 760, 640, 420, 6, el),
...arc(600, 395, 120, 205 * D, 335 * D, 7, el),
...arc(600, 395, 190, 210 * D, 330 * D, 9, el),
...line(150, 780, 1050, 780, 11, el),
);
curves.push(seg(480, 170, 720, 170, -20));
break;
default: // Extinction Event — the sky is falling
pegs.push(
...circle(300, 620, 58, 6, el),
{ x: 300, y: 620, ...el },
...line(420, 530, 1040, 200, 8, el),
...line(520, 655, 1080, 320, 7, el),
...arc(300, 620, 190, -60 * D, 35 * D, 6, el),
);
curves.push(seg(150, 785, 1050, 785, 0));
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Gerome (46-50): the skate park ───────────────────────────────────────────
function geromeLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Kickflip — board's up, wheels spinning
pegs.push(
...circle(430, 590, 46, 6, el),
...circle(770, 590, 46, 6, el),
...circle(600, 300, 95, 8, el),
{ x: 600, y: 300, ...el },
...line(200, 745, 1000, 745, 9, el),
);
curves.push(wave([{ x: 290, y: 490 }, { x: 600, y: 465 }, { x: 910, y: 490 }], [-22, -22]));
break;
case 1: // Half-Pipe — drop in!
pegs.push(
...arc(180, 285, 90, -60 * D, 15 * D, 4, el),
...arc(1020, 285, 90, 165 * D, 240 * D, 4, el),
...circle(600, 335, 85, 7, el),
...line(450, 780, 750, 780, 4, el),
);
curves.push(
wave([{ x: 180, y: 275 }, { x: 600, y: 620 }, { x: 1020, y: 275 }], [85, -85]),
wave([{ x: 265, y: 300 }, { x: 600, y: 540 }, { x: 935, y: 300 }], [60, -60]),
);
break;
case 2: // Slalom — thread the gates
pegs.push(
...line(200, 200, 500, 320, 6, el),
...line(500, 320, 250, 450, 5, el),
...line(250, 450, 550, 570, 6, el),
...line(550, 570, 300, 700, 5, el),
...line(1000, 200, 700, 320, 6, el),
...line(700, 320, 950, 450, 5, el),
...line(950, 450, 650, 570, 6, el),
...line(650, 570, 900, 700, 5, el),
);
curves.push(seg(440, 775, 760, 775, 0));
break;
case 3: // Big Air — launch, soar, land
pegs.push(
...arc(600, 520, 175, 215 * D, 325 * D, 7, el),
...circle(600, 260, 62, 6, el),
{ x: 600, y: 400, ...el },
...line(480, 765, 720, 765, 4, el),
...line(220, 260, 220, 400, 3, el),
...line(980, 260, 980, 400, 3, el),
);
curves.push(
wave([{ x: 140, y: 700 }, { x: 440, y: 460 }], [-55]),
wave([{ x: 760, y: 460 }, { x: 1060, y: 700 }], [-55]),
);
break;
default: // Mega Ramp — the one they write about
pegs.push(
...line(800, 300, 1080, 560, 5, el),
...line(1080, 300, 800, 560, 5, el),
...arc(660, 520, 155, 245 * D, 325 * D, 5, el),
...line(200, 770, 1000, 770, 9, el),
{ x: 940, y: 200, ...el }, { x: 760, y: 210, ...el },
);
curves.push(wave([{ x: 130, y: 190 }, { x: 460, y: 590 }, { x: 720, y: 680 }], [-75, -30]));
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Blackwind (51-55): the pirate cove ───────────────────────────────────────
function blackwindLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Jolly Roger — hoist the colors
pegs.push(
...circle(600, 330, 128, 11, el),
{ x: 552, y: 310 }, { x: 648, y: 310 },
...arc(600, 370, 55, 55 * D, 125 * D, 2, el),
...line(380, 545, 820, 705, 6, el),
...line(820, 545, 380, 705, 6, el),
{ x: 330, y: 520, ...el }, { x: 870, y: 520, ...el },
{ x: 330, y: 730, ...el }, { x: 870, y: 730, ...el },
);
curves.push(seg(300, 178, 900, 178, -22));
break;
case 1: // Broadside — cannons roar across the water
pegs.push(
...line(370, 330, 830, 330, 6, el),
...line(370, 480, 830, 480, 6, el),
...line(370, 630, 830, 630, 6, el),
...line(400, 720, 800, 275, 5, el),
{ x: 210, y: 210, ...el }, { x: 990, y: 210, ...el },
);
curves.push(seg(250, 290, 250, 700, 45), seg(950, 290, 950, 700, -45));
break;
case 2: // Treasure Hold — X marks the peg
pegs.push(
...line(400, 700, 800, 700, 6, el),
...line(440, 640, 760, 640, 5, el),
...line(480, 580, 720, 580, 4, el),
...line(520, 520, 680, 520, 3, el),
...line(560, 460, 640, 460, 2, el),
{ x: 600, y: 405, ...el },
...arc(600, 430, 175, 190 * D, 350 * D, 8, el),
);
curves.push(seg(210, 280, 210, 700, 0), seg(990, 280, 990, 700, 0));
break;
case 3: // Ship's Wheel — steady as she goes
pegs.push(
...circle(600, 450, 45, 6, el),
...ringAt(600, 450, 190, 14, -90, el),
...ringAt(600, 450, 100, 8, -67.5, el),
...ringAt(600, 450, 145, 8, -67.5, el),
...ringAt(600, 450, 238, 8, -67.5, el),
);
curves.push(seg(250, 762, 950, 762, 30));
break;
default: // Davy Jones — beware what waits below
pegs.push(
...arc(600, 340, 140, 180 * D, 360 * D, 9, el),
{ x: 548, y: 335 }, { x: 652, y: 335 },
{ x: 190, y: 250, ...el }, { x: 255, y: 195, ...el }, { x: 160, y: 400, ...el },
{ x: 1010, y: 250, ...el }, { x: 945, y: 195, ...el }, { x: 1040, y: 400, ...el },
...line(150, 785, 1050, 785, 10, el),
);
curves.push(
wave([{ x: 480, y: 425 }, { x: 420, y: 585 }, { x: 470, y: 725 }], [55, -55]),
wave([{ x: 560, y: 445 }, { x: 560, y: 610 }, { x: 520, y: 740 }], [-45, 45]),
wave([{ x: 640, y: 445 }, { x: 640, y: 610 }, { x: 680, y: 740 }], [45, -45]),
wave([{ x: 720, y: 425 }, { x: 780, y: 585 }, { x: 730, y: 725 }], [-55, 55]),
);
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Steve (56-60): low Earth orbit ───────────────────────────────────────────
function steveLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Crop Circles — the wheat field tells a story
pegs.push(
...circle(350, 320, 112, 9, el),
{ x: 350, y: 320, ...el },
...circle(830, 285, 82, 7, el),
...circle(600, 610, 150, 11, el),
...circle(600, 610, 76, 6, el),
{ x: 600, y: 610, ...el },
);
curves.push(seg(150, 795, 1050, 795, 0));
break;
case 1: // Little Green Pegs — they came in peace(?)
pegs.push(
...grid(250, 220, 6, 3, 140, 100, el),
...grid(320, 270, 5, 2, 140, 100, el),
...arc(300, 690, 72, 180 * D, 360 * D, 5, el),
...arc(600, 690, 72, 180 * D, 360 * D, 5, el),
...arc(900, 690, 72, 180 * D, 360 * D, 5, el),
{ x: 600, y: 775 },
);
break;
case 2: // Tractor Beam — hold still, Earth creature
pegs.push(
...arc(600, 300, 100, 180 * D, 360 * D, 7, el),
...line(380, 330, 820, 330, 7, el),
...line(520, 390, 380, 760, 6, el),
...line(680, 390, 820, 760, 6, el),
{ x: 600, y: 500, ...el }, { x: 555, y: 600, ...el },
{ x: 645, y: 650, ...el }, { x: 600, y: 730, ...el },
{ x: 180, y: 220, ...el }, { x: 1020, y: 220, ...el },
{ x: 150, y: 450, ...el }, { x: 1050, y: 450, ...el },
);
curves.push(seg(430, 800, 770, 800, 0));
break;
case 3: // Mothership — now THAT'S a saucer
pegs.push(
...arc(600, 450, 225, 195 * D, 345 * D, 10, el),
...line(250, 450, 950, 450, 8, el),
...arc(600, 430, 122, 190 * D, 350 * D, 6, el),
...line(350, 505, 850, 505, 6, el),
);
curves.push(
seg(400, 580, 355, 765, 20),
seg(600, 585, 600, 785, 0),
seg(800, 580, 845, 765, -20),
);
break;
default: // Close Encounter — a ring of lights over the field
pegs.push(
...circle(600, 450, 232, 16, el),
{ x: 520, y: 380, ...el }, { x: 655, y: 345, ...el }, { x: 700, y: 470, ...el },
{ x: 560, y: 525, ...el }, { x: 478, y: 462, ...el },
{ x: 200, y: 195, ...el }, { x: 1000, y: 195, ...el }, { x: 150, y: 660, ...el },
{ x: 1050, y: 660, ...el }, { x: 320, y: 760, ...el }, { x: 880, y: 760, ...el },
);
curves.push(wave([{ x: 150, y: 275 }, { x: 450, y: 175 }], [-20]));
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Nicole (61-65): inside the mainframe ─────────────────────────────────────
function nicoleLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Access Granted — the login screen falls
pegs.push(
...line(230, 200, 590, 200, 5, el),
...line(230, 280, 790, 280, 8, el),
...line(230, 360, 500, 360, 4, el),
...line(230, 440, 710, 440, 7, el),
...line(230, 520, 590, 520, 5, el),
...line(230, 600, 870, 600, 9, el),
{ x: 950, y: 600, ...el },
...line(300, 720, 900, 720, 7, el),
);
curves.push(seg(160, 190, 160, 640, 0));
break;
case 1: // Firewall — burn through it
pegs.push(
{ x: 250, y: 235, ...el }, { x: 450, y: 220, ...el }, { x: 650, y: 235, ...el },
{ x: 850, y: 215, ...el }, { x: 350, y: 285, ...el }, { x: 550, y: 290, ...el },
{ x: 750, y: 285, ...el }, { x: 950, y: 275, ...el },
...line(250, 680, 950, 680, 8, el),
...line(300, 755, 900, 755, 7, el),
);
curves.push(
seg(200, 380, 430, 380, 0), seg(510, 380, 740, 380, 0), seg(820, 380, 1050, 380, 0),
seg(160, 480, 390, 480, 0), seg(470, 480, 700, 480, 0), seg(780, 480, 1010, 480, 0),
seg(200, 580, 430, 580, 0), seg(510, 580, 740, 580, 0), seg(820, 580, 1050, 580, 0),
);
break;
case 2: { // Binary Rain — ones and zeroes all the way down
const cols = [5, 3, 6, 4, 7, 4, 6, 3, 5];
cols.forEach((count, c) => {
const x = 200 + c * 100;
const y0 = c % 2 ? 240 : 195;
for (let i = 0; i < count; i++) pegs.push({ x, y: y0 + i * 88, ...el });
});
curves.push(seg(150, 795, 1050, 795, 0));
break;
}
case 3: // Motherboard — don't touch the CPU
pegs.push(
...line(460, 295, 740, 295, 5, el),
...line(460, 605, 740, 605, 5, el),
...line(395, 360, 395, 540, 4, el),
...line(805, 360, 805, 540, 4, el),
{ x: 520, y: 420, ...el }, { x: 680, y: 420, ...el },
{ x: 520, y: 480, ...el }, { x: 680, y: 480, ...el },
...line(330, 235, 180, 235, 3, el), ...line(870, 235, 1020, 235, 3, el),
...line(330, 665, 180, 665, 3, el), ...line(870, 665, 1020, 665, 3, el),
{ x: 180, y: 300, ...el }, { x: 1020, y: 300, ...el },
{ x: 180, y: 730, ...el }, { x: 1020, y: 730, ...el },
);
curves.push(
seg(450, 350, 750, 350, 0), seg(750, 350, 750, 550, 0),
seg(450, 550, 750, 550, 0), seg(450, 350, 450, 550, 0),
);
break;
default: // Root Access — follow the tree to its root
pegs.push(
{ x: 600, y: 195, ...el },
...arc(600, 195, 62, 35 * D, 145 * D, 3, el),
{ x: 400, y: 300, ...el }, { x: 800, y: 300, ...el },
{ x: 500, y: 245, ...el }, { x: 700, y: 245, ...el },
{ x: 280, y: 440, ...el }, { x: 520, y: 440, ...el },
{ x: 680, y: 440, ...el }, { x: 920, y: 440, ...el },
{ x: 340, y: 370, ...el }, { x: 460, y: 370, ...el },
{ x: 740, y: 370, ...el }, { x: 860, y: 370, ...el },
...line(200, 600, 480, 600, 3, el), ...line(720, 600, 1000, 600, 3, el),
...line(250, 740, 950, 740, 6, el),
{ x: 150, y: 300, ...el }, { x: 1050, y: 300, ...el },
{ x: 150, y: 480, ...el }, { x: 1050, y: 480, ...el },
);
curves.push(seg(250, 800, 950, 800, 0));
break;
}
return levelFile(settle(pegs), { curves });
}
// ── DV-8-2303 (66-70): the robot works ───────────────────────────────────────
function dvLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Assembly Line — units roll off the belt
pegs.push(
...line(250, 370, 950, 370, 8, el),
...line(300, 570, 1000, 570, 8, el),
...line(350, 195, 350, 300, 3, el),
...line(750, 195, 750, 300, 3, el),
...circle(600, 250, 55, 5, el),
...line(300, 740, 900, 740, 6, el),
);
curves.push(seg(150, 425, 1050, 425, 0), seg(150, 625, 1050, 625, 0));
break;
case 1: // Gear Works — teeth within teeth
pegs.push(
...circle(400, 350, 120, 10, el),
...circle(400, 350, 52, 5, el),
...ringAt(400, 350, 165, 8, -67.5, el),
...circle(800, 560, 100, 8, el),
{ x: 800, y: 560, ...el },
...ringAt(800, 560, 145, 7, -65, el),
);
curves.push(seg(230, 730, 1010, 730, 25));
break;
case 2: // Servo Loop — round and round the circuit
pegs.push(
...ellipseRing(600, 480, 380, 250, 18, el),
...ellipseRing(600, 480, 245, 140, 12, el),
...circle(600, 480, 62, 5, el),
);
curves.push(seg(460, 158, 740, 158, 0));
break;
case 3: // Laser Lattice — mind the beams
pegs.push(
...line(200, 195, 840, 780, 9, el),
...line(450, 195, 1050, 745, 8, el),
...line(1000, 195, 360, 780, 9, el),
...line(750, 195, 150, 745, 8, el),
);
break;
default: // Defeat Protocol — face the machine
pegs.push(
...circle(510, 355, 46, 5, el),
...circle(690, 355, 46, 5, el),
{ x: 600, y: 450, ...el },
...line(480, 535, 720, 535, 4, el),
...line(480, 580, 720, 580, 4, el),
{ x: 325, y: 300, ...el }, { x: 325, y: 430, ...el }, { x: 325, y: 560, ...el },
{ x: 875, y: 300, ...el }, { x: 875, y: 430, ...el }, { x: 875, y: 560, ...el },
...line(300, 750, 900, 750, 6, el),
);
curves.push(
seg(415, 215, 785, 215, 0),
seg(415, 655, 785, 655, 0),
seg(390, 240, 390, 630, 0),
seg(810, 240, 810, 630, 0),
);
break;
}
return levelFile(settle(pegs), { curves });
}
// ── Kage (71-75): the moonlit dojo ───────────────────────────────────────────
function kageLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: { // Bamboo Grove — stalks in the mist
const cols = [[250, 195, 6], [400, 240, 6], [550, 195, 7], [700, 240, 6], [850, 195, 7], [1000, 240, 6]];
for (const [x, y0, n] of cols) for (let i = 0; i < n; i++) pegs.push({ x, y: y0 + i * 92, ...el });
curves.push(seg(150, 800, 1050, 800, 0));
break;
}
case 1: // Shuriken — thrown true
pegs.push(
...circle(600, 470, 62, 6, el),
// four blades: two edge pegs per side + a tip
{ x: 560, y: 335, ...el }, { x: 545, y: 245, ...el }, { x: 640, y: 335, ...el },
{ x: 655, y: 245, ...el }, { x: 600, y: 195, ...el },
{ x: 735, y: 430, ...el }, { x: 825, y: 415, ...el }, { x: 735, y: 510, ...el },
{ x: 825, y: 525, ...el }, { x: 875, y: 470, ...el },
{ x: 560, y: 605, ...el }, { x: 545, y: 695, ...el }, { x: 640, y: 605, ...el },
{ x: 655, y: 695, ...el }, { x: 600, y: 745, ...el },
{ x: 465, y: 430, ...el }, { x: 375, y: 415, ...el }, { x: 465, y: 510, ...el },
{ x: 375, y: 525, ...el }, { x: 325, y: 470, ...el },
{ x: 770, y: 300, ...el }, { x: 770, y: 640, ...el },
{ x: 430, y: 300, ...el }, { x: 430, y: 640, ...el },
);
curves.push(wave([{ x: 150, y: 215 }, { x: 340, y: 170 }], [-18]));
break;
case 2: // Torii Gate — pass beneath with honor
pegs.push(
...line(400, 400, 400, 760, 5, el),
...line(800, 400, 800, 760, 5, el),
...circle(600, 560, 92, 8, el),
{ x: 200, y: 660, ...el }, { x: 200, y: 730, ...el },
{ x: 1000, y: 660, ...el }, { x: 1000, y: 730, ...el },
);
curves.push(seg(280, 255, 920, 255, -40), seg(345, 345, 855, 345, 0));
break;
case 3: // Twin Katanas — crossed steel
pegs.push(
...line(250, 700, 950, 220, 9, el),
...line(250, 220, 950, 700, 9, el),
{ x: 168, y: 757, ...el }, { x: 1032, y: 757, ...el },
...arc(600, 255, 60, 180 * D, 360 * D, 4, el),
{ x: 380, y: 195, ...el }, { x: 820, y: 195, ...el },
{ x: 150, y: 460, ...el }, { x: 1050, y: 460, ...el }, { x: 600, y: 780, ...el },
);
break;
default: { // Silent Strike — one clean cut through the dark
// A dense field with a diagonal slash of empty space through it.
const ax = 150; const ay = 150; const bx = 1050; const by = 820;
for (let r = 0; r < 8; r++) {
const y = 200 + r * 80;
const off = r % 2 ? 60 : 0;
for (let c = 0; c < 7; c++) {
const x = 220 + off + c * 120;
const t = ((x - ax) * (bx - ax) + (y - ay) * (by - ay)) / ((bx - ax) ** 2 + (by - ay) ** 2);
const px = ax + t * (bx - ax);
const py = ay + t * (by - ay);
if (Math.hypot(x - px, y - py) > 82) pegs.push({ x, y, ...el });
}
}
curves.push(seg(255, 245, 955, 745, 0));
break;
}
}
return levelFile(settle(pegs), { curves });
}
// ── Aiko (76-80): a garden between galaxies ──────────────────────────────────
const triad = (x, y) => [
{ x, y: y - 32, ...el },
{ x: x - 30, y: y + 22, ...el },
{ x: x + 30, y: y + 22, ...el },
];
function aikoLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // First Contact — two worlds, fingertips apart
pegs.push(
...arc(340, 450, 152, -55 * D, 55 * D, 6, el),
...arc(860, 450, 152, 125 * D, 235 * D, 6, el),
...circle(600, 450, 48, 5, el),
{ x: 600, y: 450, ...el },
{ x: 230, y: 210, ...el }, { x: 970, y: 210, ...el }, { x: 180, y: 660, ...el },
{ x: 1020, y: 660, ...el }, { x: 430, y: 195, ...el }, { x: 770, y: 195, ...el },
);
curves.push(seg(320, 720, 880, 720, -35));
break;
case 1: // Spore Drift — seeds on the solar wind
pegs.push(
...triad(250, 260), ...triad(520, 225), ...triad(800, 265), ...triad(1010, 420),
...triad(300, 480), ...triad(645, 450), ...triad(900, 630),
...triad(400, 700), ...triad(690, 725), ...triad(160, 600),
);
curves.push(
wave([{ x: 130, y: 345 }, { x: 480, y: 320 }, { x: 880, y: 375 }], [45, -45]),
wave([{ x: 260, y: 590 }, { x: 620, y: 565 }, { x: 1040, y: 735 }], [-45, 45]),
);
break;
case 2: { // Spiral Galaxy — arms of stars
pegs.push(...circle(600, 470, 46, 5, el));
for (const phase of [0, 180]) {
for (let k = 1; k <= 12; k++) {
const a = (phase + k * 40) * D;
const r = 60 + k * 23;
pegs.push({ x: 600 + r * Math.cos(a), y: 470 + r * Math.sin(a) * 0.82, ...el });
}
}
curves.push(wave([{ x: 150, y: 200 }, { x: 400, y: 260 }], [22]));
break;
}
case 3: { // Double Helix — life, but not as we know it
for (let i = 0; i <= 10; i++) {
const y = 195 + i * 56;
const s = Math.sin(i * 0.63);
pegs.push({ x: 600 + 150 * s, y, ...el });
pegs.push({ x: 600 - 150 * s, y, ...el });
if (Math.abs(s) > 0.85) pegs.push({ x: 600, y, ...el });
}
pegs.push(
{ x: 200, y: 250, ...el }, { x: 1000, y: 250, ...el },
{ x: 200, y: 550, ...el }, { x: 1000, y: 550, ...el },
);
curves.push(seg(300, 800, 900, 800, 0));
break;
}
default: { // Full Bloom — the garden opens all at once
pegs.push(...circle(600, 440, 56, 6, el), { x: 600, y: 440, ...el });
for (let k = 0; k < 6; k++) {
const a = -90 + k * 60;
const cx = 600 + 152 * Math.cos(a * D);
const cy = 440 + 152 * Math.sin(a * D);
pegs.push(...arc(cx, cy, 46, (a - 80) * D, (a + 80) * D, 4, el));
}
pegs.push(
{ x: 210, y: 220, ...el }, { x: 255, y: 250, ...el },
{ x: 945, y: 220, ...el }, { x: 990, y: 250, ...el },
);
curves.push(seg(615, 700, 570, 800, 18));
break;
}
}
return levelFile(settle(pegs), { curves });
}
// ── Nadia (81-85): the end of time ───────────────────────────────────────────
function nadiaLevel(v) {
const pegs = [];
const curves = [];
switch (v) {
case 0: // Hourglass — the sand runs out
pegs.push(
...line(400, 210, 800, 210, 6, el),
...line(410, 260, 578, 428, 4, el),
...line(790, 260, 622, 428, 4, el),
...line(578, 492, 410, 660, 4, el),
...line(622, 492, 790, 660, 4, el),
...line(400, 710, 800, 710, 6, el),
{ x: 600, y: 470, ...el }, { x: 600, y: 545, ...el }, { x: 600, y: 620, ...el },
{ x: 555, y: 655, ...el }, { x: 645, y: 655, ...el },
{ x: 230, y: 400, ...el }, { x: 970, y: 400, ...el },
{ x: 230, y: 550, ...el }, { x: 970, y: 550, ...el },
);
curves.push(seg(370, 165, 830, 165, 0), seg(370, 762, 830, 762, 0));
break;
case 1: { // Pendulum — tick... tock...
pegs.push({ x: 600, y: 195, ...el });
for (const deg of [-40, 0, 40]) {
const s = Math.sin(deg * D);
const c = Math.cos(deg * D);
for (const r of [120, 240, 360]) pegs.push({ x: 600 + r * s, y: 195 + r * c, ...el });
pegs.push(...circle(600 + 455 * s, 195 + 455 * c, 55, 6, el));
}
pegs.push(...arc(600, 195, 560, 52 * D, 128 * D, 7, el));
curves.push(seg(350, 150, 850, 150, 0));
break;
}
case 2: { // Time Spiral — it all comes around again
pegs.push({ x: 600, y: 470, ...el }, ...circle(600, 470, 46, 5, el));
for (let k = 0; k <= 15; k++) {
const a = k * 35 * D;
const r = 330 - k * 16;
pegs.push({ x: 600 + r * Math.cos(a), y: 470 + r * Math.sin(a) * 0.95, ...el });
}
pegs.push(
{ x: 200, y: 200, ...el }, { x: 1000, y: 200, ...el },
{ x: 200, y: 740, ...el }, { x: 1000, y: 740, ...el },
);
curves.push(wave([{ x: 890, y: 175 }, { x: 1050, y: 265 }], [20]));
break;
}
case 3: // Paradox — two truths, one board
pegs.push(
...circle(450, 420, 160, 12, el),
...circle(750, 520, 160, 12, el),
{ x: 200, y: 200, ...el }, { x: 240, y: 300, ...el }, { x: 180, y: 400, ...el },
{ x: 230, y: 500, ...el }, { x: 170, y: 600, ...el },
{ x: 1000, y: 200, ...el }, { x: 960, y: 300, ...el }, { x: 1020, y: 400, ...el },
{ x: 970, y: 500, ...el }, { x: 1030, y: 600, ...el },
);
curves.push(seg(370, 745, 830, 745, 35));
break;
default: { // End of Time — the great clock strikes
pegs.push(...circle(600, 470, 258, 18, el), { x: 600, y: 470, ...el });
for (let h = 0; h < 12; h++) {
const a = (h * 30 - 90) * D;
pegs.push({ x: 600 + 208 * Math.cos(a), y: 470 + 208 * Math.sin(a), ...el });
}
pegs.push(
{ x: 600, y: 410, ...el }, { x: 600, y: 350, ...el }, // minute hand → XII
{ x: 652, y: 500, ...el }, { x: 704, y: 530, ...el }, // hour hand → IV
{ x: 170, y: 195, ...el }, { x: 1030, y: 195, ...el },
{ x: 170, y: 760, ...el }, { x: 1030, y: 760, ...el },
);
curves.push(
wave([{ x: 200, y: 215 }, { x: 335, y: 168 }], [-15]),
wave([{ x: 865, y: 168 }, { x: 1000, y: 215 }], [-15]),
);
break;
}
}
return levelFile(settle(pegs), { curves });
}
// ── Validation (mirrors the verifyPeggle.js data lint) ───────────────────────
function validate(lvl, tag) {
const errs = [];
const T = TUNING;
const bricks = tessellateCurves(lvl.curves ?? []);
const boundR = Math.hypot(T.BRICK_LEN / 2, T.BRICK_WID / 2);
for (const b of bricks) {
if (b.x < boundR || b.x > T.BOARD_W - boundR || b.y < 140 || b.y > 820) {
errs.push(`brick out of bounds at ${Math.round(b.x)},${Math.round(b.y)}`);
}
}
for (const c of lvl.curves ?? []) {
if ((c.anchors?.length ?? 0) < 2 || (c.bends?.length ?? -1) !== c.anchors.length - 1) errs.push('bad curve schema');
}
for (const p of lvl.pegs) {
if (p.x < p.r || p.x > T.BOARD_W - p.r || p.y < 140 || p.y > 820) errs.push(`peg out of bounds at ${p.x},${p.y}`);
for (const b of bricks) {
if ((p.x - b.x) ** 2 + (p.y - b.y) ** 2 < (p.r + T.BRICK_WID / 2) ** 2) errs.push(`peg ${p.x},${p.y} clashes a brick`);
}
}
for (let i = 0; i < lvl.pegs.length; i++) {
for (let j = i + 1; j < lvl.pegs.length; j++) {
const a = lvl.pegs[i];
const b = lvl.pegs[j];
if ((a.x - b.x) ** 2 + (a.y - b.y) ** 2 < (a.r + b.r + 6) ** 2) errs.push(`pegs overlap at ${a.x},${a.y} / ${b.x},${b.y}`);
}
}
const eligible = lvl.pegs.filter((p) => p.orangeEligible).length
+ bricks.filter((b) => b.orangeEligible).length;
if (eligible < lvl.orangeCount) errs.push(`only ${eligible} orange-eligible pegs+bricks (< ${lvl.orangeCount})`);
if (lvl.pegs.length + bricks.length < lvl.orangeCount + lvl.greenCount + 1) errs.push('too few pegs overall');
// Playability: a straight-ish shot must reach at least one peg.
const st = createRound(lvl, { seed: 1 });
let anyHit = false;
for (const ang of [-0.5, -0.25, 0, 0.25, 0.5]) {
if (scoreShot(st, ang) > 0) { anyHit = true; break; }
}
if (!anyHit) errs.push('no straight-ish shot reaches a peg');
if (errs.length) {
throw new Error(`${tag}: ${errs.slice(0, 4).join('; ')}${errs.length > 4 ? ` (+${errs.length - 4} more)` : ''}`);
}
return { bricks: bricks.length, eligible };
}
// ── Manifest + write ─────────────────────────────────────────────────────────
const BLOCKS = [
{
masterId: 'smasher', powerId: 'bodyslam', background: 'arena', build: smasherLevel,
names: ['Squared Circle', 'Top Rope', 'Turnbuckle', 'Championship Belt', 'Steel Cage'],
},
{
masterId: 'maurice', powerId: 'beaverdam', background: 'river', build: mauriceLevel,
names: ['Timber!', 'Lazy River', 'The Lodge', 'Log Jam', 'Grand Dam'],
},
{
masterId: 'schooner', powerId: 'tailwind', background: 'harbor', build: schoonerLevel,
names: ['Ahoy!', 'Full Sail', "Crow's Nest", 'Rolling Swells', 'Maiden Voyage'],
},
{
masterId: 'terry', powerId: 'meteor', background: 'jurassic', build: terryLevel,
names: ['Footprints', 'Tar Pits', 'Bone Yard', 'Volcano Day', 'Extinction Event'],
},
{
masterId: 'gerome', powerId: 'extremeball', background: 'xtreme', build: geromeLevel,
names: ['Kickflip', 'Half-Pipe', 'Slalom', 'Big Air', 'Mega Ramp'],
},
{
masterId: 'blackwind', powerId: 'cannonball', background: 'cove', build: blackwindLevel,
names: ['Jolly Roger', 'Broadside', 'Treasure Hold', "Ship's Wheel", 'Davy Jones'],
},
{
masterId: 'steve', powerId: 'beamup', background: 'orbit', build: steveLevel,
names: ['Crop Circles', 'Little Green Pegs', 'Tractor Beam', 'Mothership', 'Close Encounter'],
},
{
masterId: 'nicole', powerId: 'overclock', background: 'mainframe', build: nicoleLevel,
names: ['Access Granted', 'Firewall', 'Binary Rain', 'Motherboard', 'Root Access'],
},
{
masterId: 'dv-8-2303', powerId: 'lasergrid', background: 'factory', build: dvLevel,
names: ['Assembly Line', 'Gear Works', 'Servo Loop', 'Laser Lattice', 'Defeat Protocol'],
},
{
masterId: 'kage', powerId: 'shadowslash', background: 'dojo', build: kageLevel,
names: ['Bamboo Grove', 'Shuriken', 'Torii Gate', 'Twin Katanas', 'Silent Strike'],
},
{
masterId: 'aiko', powerId: 'cosmicbloom', background: 'nebula', build: aikoLevel,
names: ['First Contact', 'Spore Drift', 'Spiral Galaxy', 'Double Helix', 'Full Bloom'],
},
{
masterId: 'nadia', powerId: 'rewind', background: 'chrono', build: nadiaLevel,
names: ['Hourglass', 'Pendulum', 'Time Spiral', 'Paradox', 'End of Time'],
},
];
const FIRST_NEW_LEVEL = 26;
// Carry the hand-tuned first wave (1-25) over verbatim from the live manifest.
const existing = JSON.parse(readFileSync(join(OUT_DIR, 'levels.json'), 'utf8'));
const carried = existing.levels.filter((l) => l.level < FIRST_NEW_LEVEL);
if (carried.length !== FIRST_NEW_LEVEL - 1) {
throw new Error(`expected ${FIRST_NEW_LEVEL - 1} existing levels in levels.json, found ${carried.length}`);
}
mkdirSync(OUT_DIR, { recursive: true });
const manifestLevels = [...carried];
let levelNo = FIRST_NEW_LEVEL - 1;
for (const block of BLOCKS) {
for (let v = 0; v < 5; v++) {
levelNo++;
const file = `level-${String(levelNo).padStart(3, '0')}.json`;
const data = block.build(v);
if (levelNo >= 33) data.orangeCount -= 7;
const info = validate(data, `${file} (${block.names[v]})`);
writeFileSync(join(OUT_DIR, file), `${JSON.stringify(data, null, 2)}\n`);
console.log(`${file}: ${block.names[v]} [${block.masterId}] — ${data.pegs.length} pegs + ${info.bricks} bricks (${info.eligible} orange-eligible)`);
manifestLevels.push({
level: levelNo,
name: block.names[v],
masterId: block.masterId,
powerId: block.powerId,
file,
background: block.background,
});
}
}
const manifest = { version: 1, levels: manifestLevels };
writeFileSync(join(OUT_DIR, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`);
console.log(`levels.json: ${manifest.levels.length} levels (1-${FIRST_NEW_LEVEL - 1} carried over untouched)`);