// Goo Tower — level bank generator. // // Wave 4 replaced the flat 12-level bank with a CURRICULUM: five named // chapters of fifteen levels, built from blocks that introduce one mechanic, // drill it, then combine it with what came before. // // A physics sandbox has no BFS solver, so levels are not random-filled the way // Jell-o Monsters' are. Instead each level is instantiated from a HAND-DESIGNED // TEMPLATE (tower, bridge, overhang, updraft, cogs, demolition, cliff…) with // its parameters swept across a difficulty band, and then has to survive four // gates: // // 1. CLEAN — settles without snapping a strand, losing goo, starting // anything inside terrain, or starting two goo overlapping. // 2. WINNABLE — the reference player in GooTowerAuto.js beats it. The // gate is one-directional: if a naive builder can win, a // human can. A failure means "redesign", never "impossible". // 3. LOAD-BEARING — for enabling mechanics (updraft, wall-anchors), strip the // element and the level must STOP being winnable. This is // what keeps a "fan level" from being an ordinary level // with a fan blowing somewhere irrelevant. // 4. GENTLE — a teaching level may not open with an instant-fail: no // hazard within reach of the starting structure. // // Levels 1-6 are the original hand-tuned teaching levels and are emitted // VERBATIM, exactly as genPeggleLevels.js freezes Peggle's hand-tuned 1-25. // // Usage: // node tools/genGooTower.js [seed] [outDir] // // Deterministic: same seed -> same bank. import { writeFileSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { createState, settlePhysics, insideSolid, gooType, liveStrands, } from '../src/games/gootower/GooTowerLogic.js'; import { autoPlay } from '../src/games/gootower/GooTowerAuto.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x600d1e; const outDir = process.argv[3] || join(__dirname, '..', 'assets', 'gamedata', 'gootower'); const W = 1600; const H = 1000; const GROUND = 850; // ── Seeded RNG (mulberry32) ───────────────────────────────────────────────── function mulberry32(seed) { let a = seed >>> 0; return function rng() { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const lerp = (a, b, t) => a + (b - a) * t; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); const rect = (x1, y1, x2, y2, kind = 'solid') => ({ kind, poly: [[x1, y1], [x2, y1], [x2, y2], [x1, y2]] }); const ground = (top = GROUND) => rect(0, top, W, H); // Side walls. EVERY template includes these: a heap that topples and rolls off // the edge of the world loses goo before the player has touched anything, and // it is always the top row of the heap that goes -- which is exactly where the // scarce goo types sit. const bounds = () => [rect(-30, 0, 6, H), rect(W - 6, 0, W + 30, H)]; // A planted tripod: two pinned feet and a free apex, already triangulated so // the player has something legal to attach to on frame one. function footing(x, y, type = 'common') { return { balls: [ { x: x - 31, y, type, pinned: true }, { x: x + 31, y, type, pinned: true }, { x, y: y - 54, type }, ], strands: [[2, 0], [2, 1], [0, 1]], }; } // A heap of loose goo. `mix` is [[type, count], ...]. Laid out in rows and kept // clear of `avoid` (the footing's x): goo that starts inside other goo is // ejected hard enough to snap its own strands. function heap(mix, startX, surfaceY, { perRow = null, gap = 30, avoid = null } = {}) { const out = []; const total = mix.reduce((n, [, c]) => n + c, 0); // Wide and low: a tall narrow heap topples and spills. Roughly 3 rows. const cols = perRow || clamp(Math.round(total / 3), 6, 14); // Interleave the types. Laid out in blocks, the rarest goo ends up in the // top row -- the row most likely to roll away. const order = []; const pools = mix.map(([type, count]) => ({ type, left: count })); while (pools.some((p) => p.left > 0)) { for (const p of pools) { if (p.left > 0) { order.push(p.type); p.left -= 1; } } } let i = 0; for (const [, count] of [[null, order.length]]) { for (let k = 0; k < count; k += 1, i += 1) { const type = order[k]; const row = Math.floor(i / cols); const col = i % cols; out.push({ x: startX + col * gap, y: surfaceY - 16 - row * 30, type }); } } // Shift the WHOLE heap clear of the footing rather than nudging individual // balls: nudging piles two columns onto each other, and overlapping goo is // ejected hard enough to snap strands. if (avoid != null && out.length) { const lo = Math.min(...out.map((b) => b.x)); const hi = Math.max(...out.map((b) => b.x)); const clearL = avoid - 110; const clearR = avoid + 110; if (hi > clearL && lo < clearR) { const shift = (lo + hi) / 2 < avoid ? clearL - hi : clearR - lo; for (const b of out) b.x += shift; } } return out.filter((b) => b.x > 30 && b.x < W - 30); } const heapSize = (mix) => mix.reduce((n, [, c]) => n + c, 0); // ── Templates ─────────────────────────────────────────────────────────────── // Each returns a level body. `d` is difficulty in 0..1 within its block. // `gate` says how the load-bearing check should treat the featured element. export const TEMPLATES = { // Straight-up climb. The bread and butter. tower(rng, d, mix) { const x = Math.round(lerp(430, 860, rng())); const climb = Math.round(lerp(200, 470, d)); const pileX = x + 260 < W - 300 ? x + 260 : x - 420; return { element: null, gate: 'none', terrain: [ground(), ...bounds()], ...footing(x, GROUND - 14), pipe: { x, y: GROUND - 68 - climb, r: 46 }, pile: heap(mix, clamp(pileX, 60, W - 300), GROUND, { avoid: x }), tip: 'Brace as you go. An unbraced column leans, and once it leans it keeps leaning.', }; }, // A spike pit to bridge. bridge(rng, d, mix) { const ledgeY = 700; const gap = Math.round(lerp(190, 330, d)); const left = Math.round(lerp(560, 640, rng())); const right = left + gap; const fx = left - 190; return { element: null, gate: 'none', terrain: [ ...bounds(), rect(0, ledgeY, left, H), rect(right, ledgeY, W, H), rect(left, 930, right, H, 'spike'), rect(0, ledgeY - 90, 44, ledgeY), rect(left - 44, ledgeY - 90, left, ledgeY), ], ...footing(fx, ledgeY - 14), pipe: { x: right + 110, y: ledgeY - 60, r: 46 }, pile: heap(mix, 90, ledgeY, { perRow: 6, avoid: fx }), waypoints: [{ x: (left + right) / 2, y: ledgeY - 95 }], tip: 'Triangles carry weight across a gap. Long chains just sag into the spikes.', }; }, // A slab overhead: come out from under it before climbing. overhang(rng, d, mix) { const fx = Math.round(lerp(470, 600, rng())); const slabY = Math.round(lerp(620, 540, d)); const slabR = Math.round(lerp(740, 860, d)); return { element: null, gate: 'none', terrain: [ground(), ...bounds(), rect(260, slabY, slabR, slabY + 70)], ...footing(fx, GROUND - 14), pipe: { x: slabR + 150, y: slabY - 60, r: 46 }, pile: heap(mix, slabR + 120, GROUND, { avoid: fx }), waypoints: [{ x: slabR + 40, y: slabY + 20 }], tip: 'You cannot build through rock. Come out from under it first.', }; }, // Spiked ceiling: stop short of it. ceiling(rng, d, mix) { const x = Math.round(lerp(500, 900, rng())); const ceilY = Math.round(lerp(340, 240, d)); return { element: null, gate: 'none', terrain: [ground(), ...bounds(), rect(clamp(x - 420, 0, W), ceilY, clamp(x + 420, 0, W), ceilY + 60, 'spike')], ...footing(x, GROUND - 14), pipe: { x, y: ceilY + 165, r: 46 }, pile: heap(mix, clamp(x + 280, 60, W - 300), GROUND, { avoid: x }), tip: 'The ceiling bites. Stop short of it.', }; }, // An updraft that lightens everything inside it, so the span reaches further // than bare goo could manage. updraft(rng, d, mix) { const ledgeY = 700; const gap = Math.round(lerp(360, 470, d)); const left = 560; const right = left + gap; const fx = left - 190; return { element: 'fan', gate: 'enabling', terrain: [ ...bounds(), rect(0, ledgeY, left, H), rect(right, ledgeY, W, H), rect(left, 930, right, H, 'spike'), rect(0, ledgeY - 90, 44, ledgeY), rect(left - 44, ledgeY - 90, left, ledgeY), ], fans: [{ x: left, y: 260, w: gap, h: 660, dx: 0, dy: -1, force: Math.round(lerp(1380, 1290, d)), }], ...footing(fx, ledgeY - 14), pipe: { x: clamp(right + 110, 0, W - 70), y: ledgeY - 60, r: 46 }, pile: heap(mix, 90, ledgeY, { perRow: 6, avoid: fx }), waypoints: [{ x: (left + right) / 2, y: ledgeY - 120 }], tip: 'The updraft takes weight off everything inside it. Spans reach further in the wind.', }; }, // A turning cog in the way. cogs(rng, d, mix) { const fx = Math.round(lerp(280, 380, rng())); const gx = Math.round(lerp(720, 820, rng())); const omega = (rng() < 0.5 ? -1 : 1) * lerp(1.1, 2.0, d); return { element: 'gear', gate: 'obstructing', terrain: [ground(), ...bounds()], gears: [{ x: gx, y: Math.round(lerp(520, 450, d)), r: Math.round(lerp(95, 125, d)), teeth: 9, omega, }], ...footing(fx, GROUND - 14), pipe: { x: Math.round(lerp(1160, 1290, d)), y: Math.round(lerp(620, 550, d)), r: 46 }, pile: heap(mix, 1000, GROUND, { perRow: 9, avoid: fx }), tip: 'The cog is solid and it is turning. Whatever it touches gets dragged along.', }; }, // Soft rock in the way, a fire pit beside it, bombs in the heap. The bomb is // a SHORTCUT, never the only route -- a mechanic that is the sole solution is // a level nobody can find their way through. demolition(rng, d, mix) { const fx = Math.round(lerp(360, 440, rng())); const rockX = Math.round(lerp(680, 760, rng())); const rockTop = Math.round(lerp(700, 640, d)); return { element: 'bomb', gate: 'obstructing', terrain: [ ground(), ...bounds(), { ...rect(rockX, rockTop, rockX + 80, GROUND), destructible: true }, rect(rockX - 110, GROUND - 44, rockX - 30, GROUND, 'fire'), ], ...footing(fx, GROUND - 14), pipe: { x: Math.round(lerp(980, 1120, d)), y: 700, r: 46 }, pile: heap(mix, 120, GROUND, { perRow: 7, avoid: fx }), waypoints: [{ x: rockX + 40, y: rockTop - 75 }], tip: 'Drop a bomb by the fire and the soft rock goes with it. Or take the long way over.', }; }, // A wall start: no ground worth building from, so goo that grips rock is the // only way to begin. cliff(rng, d, mix) { const wallX = 300; const startY = Math.round(lerp(600, 520, d)); return { element: 'anchor', gate: 'enabling', terrain: [ ground(), ...bounds(), rect(wallX - 90, 180, wallX, 800), rect(1200, 180, 1290, 800), rect(wallX, 930, 1200, H, 'spike'), // A shelf directly under the starting structure. Without it the heap // sits on the ground far below a wall-mounted start, loose goo walks to // the foot of the wall and can never climb, and the pipe drinks nothing. rect(wallX, startY + 44, wallX + 300, startY + 120), ], balls: [ { x: wallX + 40, y: startY, type: 'pokey', pinned: true }, { x: wallX + 40, y: startY - 62, type: 'pokey', pinned: true }, { x: wallX + 98, y: startY - 31, type: 'common' }, ], strands: [[2, 0], [2, 1], [0, 1]], pipe: { x: 1150, y: Math.round(lerp(460, 400, d)), r: 46 }, pile: heap(mix, wallX + 130, startY + 44, { perRow: 6 }), waypoints: [{ x: 740, y: Math.round(lerp(450, 410, d)) }], tip: 'Pokey goo grips bare rock and needs no friends. Start from the wall.', }; }, // Pillars to anchor off on the way up. pillars(rng, d, mix) { const fx = 760; const top = Math.round(lerp(360, 280, d)); return { element: 'anchor', gate: 'none', terrain: [ground(), ...bounds(), rect(420, top, 480, GROUND), rect(1040, top, 1100, GROUND)], ...footing(fx, GROUND - 14), pipe: { x: fx, y: Math.round(lerp(420, 340, d)), r: 46 }, pile: heap(mix, 830, GROUND, { perRow: 6, avoid: fx }), tip: 'Anchor goo sticks straight to rock. The pillars are fair game.', }; }, }; // ── The curriculum ────────────────────────────────────────────────────────── export const MIX = { basic: [['common', 24]], ivy: [['common', 18], ['ivy', 10]], airy: [['common', 20], ['balloon', 6]], windy: [['common', 22], ['balloon', 5], ['ivy', 4]], works: [['common', 22], ['block', 8], ['bomb', 4]], cog: [['common', 24], ['block', 8]], sticky: [['pokey', 8], ['anchor', 5], ['common', 22]], deep: [['common', 20], ['skull', 6], ['bit', 6], ['balloon', 4], ['block', 4]], }; const CHAPTERS = [ { id: 1, name: 'The Rolling Hills', blurb: 'Learn to build. Everything after this assumes you can.', frozen: 6, blocks: [ { template: 'tower', count: 3, d0: 0.15, d1: 0.45, mix: 'basic' }, { template: 'bridge', count: 3, d0: 0.10, d1: 0.40, mix: 'ivy' }, { template: 'overhang', count: 3, d0: 0.10, d1: 0.45, mix: 'ivy' }, ], names: ['Uphill', 'Higher Ground', 'The Long Climb', 'Across', 'Wider Still', 'The Drop', 'Under It', 'Round the Back', 'Out and Up'], }, { id: 2, name: 'Windward', blurb: 'Wind, balloons, and things that would rather float away.', blocks: [ { template: 'tower', count: 2, d0: 0.35, d1: 0.55, mix: 'airy', teach: true }, { template: 'ceiling', count: 3, d0: 0.20, d1: 0.55, mix: 'airy' }, { template: 'updraft', count: 4, d0: 0.10, d1: 0.55, mix: 'windy', teach: true }, { template: 'bridge', count: 3, d0: 0.45, d1: 0.70, mix: 'windy' }, { template: 'updraft', count: 3, d0: 0.55, d1: 0.85, mix: 'windy' }, ], names: ['Light Head', 'Buoyant', 'Low Ceiling', 'Mind Your Head', 'Overhead', 'First Gust', 'Updraft', 'Riding the Wind', 'Thermals', 'Long Reach', 'The Wide Crossing', 'Windswept', 'Gale', 'Squall', 'Headwind'], }, { id: 3, name: 'The Works', blurb: 'Machinery. It does not care what you built.', blocks: [ { template: 'cogs', count: 4, d0: 0.10, d1: 0.55, mix: 'cog', teach: true }, { template: 'tower', count: 2, d0: 0.55, d1: 0.75, mix: 'cog' }, { template: 'demolition', count: 4, d0: 0.10, d1: 0.55, mix: 'works', teach: true }, { template: 'cogs', count: 3, d0: 0.55, d1: 0.85, mix: 'works' }, { template: 'overhang', count: 2, d0: 0.55, d1: 0.80, mix: 'works' }, ], names: ['First Cog', 'Gearworks', 'Clockwise', 'Widdershins', 'Rigid Thinking', 'Girders', 'Soft Rock', 'Demolition', 'Blast Radius', 'Controlled Burn', 'The Machine', 'Grinding Gears', 'Flywheel', 'Under the Works', 'Assembly'], }, { id: 4, name: 'The Sticky Cliffs', blurb: 'No ground worth standing on. Grip the rock.', blocks: [ { template: 'pillars', count: 3, d0: 0.10, d1: 0.45, mix: 'sticky', teach: true }, { template: 'cliff', count: 4, d0: 0.10, d1: 0.50, mix: 'sticky', teach: true }, { template: 'tower', count: 2, d0: 0.60, d1: 0.80, mix: 'sticky' }, { template: 'cliff', count: 3, d0: 0.50, d1: 0.85, mix: 'sticky' }, { template: 'bridge', count: 3, d0: 0.55, d1: 0.85, mix: 'sticky' }, ], names: ['Handhold', 'Two Pillars', 'Stepping Up', 'The Face', 'Sheer', 'Traverse', 'Grip', 'Overhanging', 'Cliffhanger', 'The Chimney', 'Freeclimb', 'The Notch', 'Crevasse', 'Exposure', 'Summit'], }, { id: 5, name: 'Down the Deep', blurb: 'Everything you have learned, all at once.', blocks: [ { template: 'ceiling', count: 3, d0: 0.60, d1: 0.90, mix: 'deep' }, { template: 'updraft', count: 3, d0: 0.60, d1: 0.90, mix: 'deep' }, { template: 'cogs', count: 3, d0: 0.65, d1: 0.95, mix: 'deep' }, { template: 'demolition', count: 3, d0: 0.60, d1: 0.90, mix: 'deep' }, { template: 'tower', count: 3, d0: 0.70, d1: 1.00, mix: 'deep' }, ], names: ['Descent', 'Low Roof', 'The Squeeze', 'Deep Wind', 'The Shaft', 'Downdraft', 'Deep Cogs', 'The Grinder', 'Millstone', 'Deep Rock', 'Cave-In', 'The Last Charge', 'The Long Way Up', 'Ascent', 'Out'], }, ]; // ── Gates ─────────────────────────────────────────────────────────────────── function cleanCheck(def) { const st = createState(def); for (const b of st.balls) { if (insideSolid(st, b.x, b.y)) return 'goo starts inside terrain'; } for (let i = 0; i < st.balls.length; i += 1) { for (let j = i + 1; j < st.balls.length; j += 1) { const a = st.balls[i]; const b = st.balls[j]; if (Math.hypot(b.x - a.x, b.y - a.y) < (a.r + b.r) * 0.6) return 'goo starts overlapping'; } } if (st.pipe.open) return 'pipe starts open'; settlePhysics(st, 10); if (liveStrands(st).length !== st.strands.length) return 'a strand snaps on its own'; const lost = st.balls.filter((b) => b.dead); if (lost.length) { const kinds = [...new Set(lost.map((b) => b.type))].join('/'); return `goo dies before the player touches anything (${lost.length} ${kinds})`; } if (st.pile.length < def.ocdTarget) return 'not enough goo left for OCD'; return null; } // A teaching level may not open with an instant-fail: nothing lethal within // reach of the starting structure. function gentleCheck(def) { const st = createState(def); const start = st.balls.filter((b) => b.attached); for (const t of st.terrain) { if (t.kind !== 'spike' && t.kind !== 'fire') continue; for (const b of start) { const bb = t.bounds; const dx = Math.max(bb.minX - b.x, 0, b.x - bb.maxX); const dy = Math.max(bb.minY - b.y, 0, b.y - bb.maxY); if (Math.hypot(dx, dy) < 140) return 'a hazard sits on the doorstep'; } } return null; } // Strip the featured element; an "enabling" level must stop being winnable. function loadBearingCheck(def, element) { const stripped = JSON.parse(JSON.stringify(def)); if (element === 'fan') stripped.fans = []; else if (element === 'anchor') { stripped.pile = stripped.pile.filter((p) => !gooType(p.type).sticksToTerrain); stripped.balls = stripped.balls.map((b) => (gooType(b.type).sticksToTerrain ? { ...b, type: 'common', pinned: false } : b)); } else return null; const r = autoPlay(stripped, { maxBalls: 50, maxSeconds: 30 }); return r.won ? 'winnable without its own mechanic' : null; } // ── Build ─────────────────────────────────────────────────────────────────── const rng = mulberry32(SEED); const levels = frozenLevels(); const stats = { tried: 0, rejected: {} }; const reject = (why) => { stats.rejected[why] = (stats.rejected[why] || 0) + 1; }; for (const chapter of CHAPTERS) { let nameIdx = 0; const wanted = 15 - (chapter.frozen || 0); let made = 0; for (const block of chapter.blocks) { for (let k = 0; k < block.count && made < wanted; k += 1) { const d = block.count > 1 ? lerp(block.d0, block.d1, k / (block.count - 1)) : block.d0; const mix = MIX[block.mix]; let accepted = null; for (let attempt = 0; attempt < 14 && !accepted; attempt += 1) { stats.tried += 1; // Jitter difficulty per attempt so retries actually differ. const dd = clamp(d + (rng() - 0.5) * 0.12, 0, 1); const body = TEMPLATES[block.template](rng, dd, mix); const size = heapSize(mix); const required = Math.round(lerp(4, 8, dd)); const ocdTarget = clamp(Math.round(lerp(required + 4, required + 9, dd)), required, size - 5); const def = { world: { w: W, h: H }, level: levels.length + 1, name: chapter.names[nameIdx] || `${chapter.name} ${nameIdx + 1}`, chapter: chapter.id, tip: body.tip, element: body.element || undefined, terrain: body.terrain, balls: body.balls, strands: body.strands, pile: body.pile, pipe: body.pipe, fans: body.fans, gears: body.gears, waypoints: body.waypoints, required, ocdTarget, }; const dirty = cleanCheck(def); if (dirty) { reject(dirty); continue; } if (block.teach) { const harsh = gentleCheck(def); if (harsh) { reject(harsh); continue; } } const r = autoPlay(def, { maxBalls: 70, maxSeconds: 45 }); if (!r.won) { reject(`unwinnable (${r.pipeOpen ? 'pipe open' : 'pipe shut'})`); continue; } if (body.gate === 'enabling') { const weak = loadBearingCheck(def, body.element); if (weak) { reject(weak); continue; } } accepted = def; } if (!accepted) { // Never leave a hole in the curriculum: fall back to a plain tower at // the same difficulty rather than shipping a 13-level chapter. for (let attempt = 0; attempt < 10 && !accepted; attempt += 1) { const dd = clamp(d * 0.8 + (rng() - 0.5) * 0.1, 0, 1); const body = TEMPLATES.tower(rng, dd, mix); const size = heapSize(mix); const required = Math.round(lerp(4, 8, dd)); const def = { world: { w: W, h: H }, level: levels.length + 1, name: chapter.names[nameIdx] || `${chapter.name} ${nameIdx + 1}`, chapter: chapter.id, tip: body.tip, terrain: body.terrain, balls: body.balls, strands: body.strands, pile: body.pile, pipe: body.pipe, required, ocdTarget: clamp(Math.round(lerp(required + 4, required + 9, dd)), required, size - 5), }; if (cleanCheck(def)) continue; if (!autoPlay(def, { maxBalls: 70, maxSeconds: 45 }).won) continue; accepted = def; } if (accepted) reject(`fell back to tower (${block.template})`); } if (!accepted) { console.warn(`\n[gen] ! ch${chapter.id} ${block.template} slot ${k} — no candidate survived`); continue; } levels.push(accepted); nameIdx += 1; made += 1; process.stdout.write(`\r[gen] ${levels.length} levels… `); } } // Top-up: every chapter ships exactly fifteen. If the blocks came up short, // fill the remainder with plain towers rather than a ragged chapter. let guard = 0; while (made < wanted && guard < 60) { guard += 1; const dd = clamp(0.3 + rng() * 0.4, 0, 1); const mix = MIX[chapter.blocks[0].mix]; const body = TEMPLATES.tower(rng, dd, mix); const size = heapSize(mix); const required = Math.round(lerp(4, 8, dd)); const def = { world: { w: W, h: H }, level: levels.length + 1, name: chapter.names[nameIdx] || `${chapter.name} ${nameIdx + 1}`, chapter: chapter.id, tip: body.tip, terrain: body.terrain, balls: body.balls, strands: body.strands, pile: body.pile, pipe: body.pipe, required, ocdTarget: clamp(Math.round(lerp(required + 4, required + 9, dd)), required, size - 5), }; if (cleanCheck(def)) continue; if (!autoPlay(def, { maxBalls: 70, maxSeconds: 45 }).won) continue; levels.push(def); nameIdx += 1; made += 1; reject('top-up tower'); process.stdout.write(`\r[gen] ${levels.length} levels… `); } } // ── Emit ──────────────────────────────────────────────────────────────────── mkdirSync(outDir, { recursive: true }); const manifest = { version: 2, seed: SEED, levels: [] }; for (let i = 0; i < levels.length; i += 1) { const lv = levels[i]; lv.level = i + 1; const file = `level-${String(lv.level).padStart(3, '0')}.json`; writeFileSync(join(outDir, file), `${JSON.stringify(lv, null, 2)}\n`); manifest.levels.push({ level: lv.level, name: lv.name, chapter: lv.chapter, file, required: lv.required, ocdTarget: lv.ocdTarget, element: lv.element || undefined, }); } manifest.chapters = CHAPTERS.map((c) => { const own = manifest.levels.filter((m) => m.chapter === c.id); return { id: c.id, name: c.name, blurb: c.blurb, from: own.length ? own[0].level : 0, to: own.length ? own[own.length - 1].level : 0, }; }).filter((c) => c.from > 0); writeFileSync(join(outDir, 'levels.json'), `${JSON.stringify(manifest, null, 2)}\n`); console.log(`\n[gen] wrote ${levels.length} levels + manifest to ${outDir}`); console.log(`[gen] ${stats.tried} candidates tried`); for (const [why, n] of Object.entries(stats.rejected).sort((a, b) => b[1] - a[1])) { console.log(`[gen] rejected ${String(n).padStart(4)} ${why}`); } // ── The frozen hand-tuned openers ─────────────────────────────────────────── // Emitted verbatim. These were authored as the teaching set; the generator must // never rewrite them. function frozenLevels() { const out = []; { const f = footing(771, GROUND - 14); out.push({ world: { w: W, h: H }, level: 1, name: 'Ground Floor', chapter: 1, tip: 'Drag goo from the heap onto the structure. Each ball needs two nearby friends to hold on to.', terrain: [ground()], ...f, pipe: { x: 771, y: 600, r: 46 }, pile: heap([['common', 16]], 980, GROUND), required: 4, ocdTarget: 9, }); } { const f = footing(430, 686); out.push({ world: { w: W, h: H }, level: 2, name: 'Mind the Gap', chapter: 1, tip: 'Spikes below. Build ACROSS — triangles carry weight, long chains do not.', terrain: [ rect(0, 700, 600, H), rect(860, 700, W, H), rect(600, 930, 860, H, 'spike'), // Lip on the outer rim: without it the heap spreads left, rolls off the // edge of the world and the level quietly loses goo. rect(0, 620, 40, 700), ], ...f, pipe: { x: 980, y: 640, r: 46 }, pile: heap([['common', 20]], 150, 700, { perRow: 7 }), required: 5, ocdTarget: 12, }); } { const f = footing(560, GROUND - 14); out.push({ world: { w: W, h: H }, level: 3, name: 'Headroom', chapter: 1, tip: 'You cannot build through the slab. Come out from under it first.', terrain: [ground(), rect(300, 540, 820, 610)], ...f, pipe: { x: 980, y: 470, r: 46 }, pile: heap([['common', 24]], 1020, GROUND, { perRow: 8 }), required: 5, ocdTarget: 12, }); } { const f = footing(331, GROUND - 14); out.push({ world: { w: W, h: H }, level: 4, name: 'Second Thoughts', chapter: 1, tip: 'Green ivy goo can be pulled back off and used again. Grey goo is there for good.', terrain: [ground()], ...f, pipe: { x: 660, y: 560, r: 46 }, pile: [ ...heap([['ivy', 12]], 540, GROUND, { perRow: 6 }), ...heap([['common', 8]], 860, GROUND, { perRow: 8 }), ], required: 4, ocdTarget: 9, }); } { const f = footing(791, GROUND - 14); out.push({ world: { w: W, h: H }, level: 5, name: 'Overhead', chapter: 1, tip: 'The ceiling bites. Stop short of it.', terrain: [ground(), rect(400, 180, 1200, 240, 'spike')], ...f, pipe: { x: 791, y: 340, r: 46 }, pile: heap([['common', 18]], 1000, GROUND, { perRow: 9 }), required: 5, ocdTarget: 11, }); } { const f = footing(791, GROUND - 14); out.push({ world: { w: W, h: H }, level: 6, name: 'Tall Order', chapter: 1, tip: 'A tower stands on compression and falls on sway. Brace every level.', terrain: [ground()], ...f, pipe: { x: 791, y: 180, r: 46 }, pile: heap([['common', 26]], 1020, GROUND, { perRow: 9 }), required: 6, ocdTarget: 14, }); } return out; }