// Offline generator for Pudding Monsters levels. // // For each difficulty tier it random-fills a grid with walls, spikes and K // monsters (the board edge is open — only walls and monsters stop a slide, so // every tier carries walls), runs the BFS solver to (a) reject // unsolvable/trivial layouts and // (b) label each survivor with its minimum flick count (par), then marks 3 cells // of that solution's final footprint as yellow target squares and writes ordered // levels to public/data/puddingmonsters.json. Stars at play time require BOTH: // the final merged blob covering the targets AND solving in par (min of the two // medals) — so the on-par solution lands on all 3 targets and scores 3 stars. // // Usage: // node server/scripts/genPuddingMonsters.js [seed] [outFile] // // Deterministic: same seed -> same bank. Re-run after changing the curve. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { newState, solve } from '../../public/src/games/puddingmonsters/PuddingMonstersLogic.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUT_FILE = process.argv[3] ? path.resolve(process.argv[3]) : path.join(__dirname, '../../public/data/puddingmonsters.json'); const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0x5eed1234; // ── Seeded RNG (mulberry32) ────────────────────────────────────────────────── function makeRng(seed) { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const rng = makeRng(SEED); const randInt = (n) => Math.floor(rng() * n); // Difficulty curve. Ordered tiers ramp grid size, monsters and obstacles; each // keeps `count` levels whose par falls in [minPar, maxPar]. The board edge is // OPEN (sliding off is fatal), so walls — the only terrain that stops a slide — // appear from tier 1, like the original game. Spikes arrive in later tiers. // Levels are numbered tier-by-tier. const TIERS = [ { count: 8, cols: 5, rows: 5, monsters: 3, walls: 3, spikes: 0, minPar: 2, maxPar: 3 }, { count: 8, cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0, minPar: 3, maxPar: 5 }, { count: 8, cols: 6, rows: 6, monsters: 4, walls: 5, spikes: 0, minPar: 4, maxPar: 7 }, { count: 8, cols: 7, rows: 7, monsters: 4, walls: 6, spikes: 1, minPar: 5, maxPar: 9 }, { count: 8, cols: 7, rows: 7, monsters: 5, walls: 7, spikes: 2, minPar: 7, maxPar: 14 }, ]; const MAX_ATTEMPTS = 6000000; const MAX_SECONDS = 200; const SOLVE_MAX_STATES = 80000; const keyOf = (x, y) => `${x},${y}`; // Place `n` distinct random cells avoiding `taken`; returns null if it can't. function placeCells(n, cols, rows, taken) { const out = []; let tries = 0; while (out.length < n && tries < 400) { tries++; const x = randInt(cols), y = randInt(rows); const k = keyOf(x, y); if (taken.has(k)) continue; taken.add(k); out.push([x, y]); } return out.length === n ? out : null; } // Build one random candidate level for a tier (or null on failure). function randomLevel(tier) { const taken = new Set(); const walls = placeCells(tier.walls, tier.cols, tier.rows, taken); if (!walls) return null; const spikes = placeCells(tier.spikes, tier.cols, tier.rows, taken); if (!spikes) return null; const monsters = placeCells(tier.monsters, tier.cols, tier.rows, taken); if (!monsters) return null; return { cols: tier.cols, rows: tier.rows, walls, spikes, monsters, }; } function canonKey(lvl) { const s = (arr) => arr.map(([x, y]) => keyOf(x, y)).sort().join(' '); return `${lvl.cols}x${lvl.rows}|M:${s(lvl.monsters)}|W:${s(lvl.walls)}|X:${s(lvl.spikes)}`; } // 3 spread-out cells of the solution's final footprint -> yellow target squares. // The footprint always has >= 3 cells (>= 3 monsters), so this yields 3 distinct. function chooseTargets(footprint) { const sorted = footprint.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0])); const idx = [...new Set([0, Math.floor(sorted.length / 2), sorted.length - 1])]; return idx.map((i) => [sorted[i][0], sorted[i][1]]); } // ── Generate pool ──────────────────────────────────────────────────────────── console.log(`[pudding] generating with seed ${SEED}…`); const target = TIERS.reduce((t, x) => t + x.count, 0); const buckets = TIERS.map(() => []); const seen = new Set(); let attempts = 0; let solved = 0; const startedAt = Date.now(); const tiersFull = () => buckets.every((b, i) => b.length >= TIERS[i].count); while (attempts < MAX_ATTEMPTS && !tiersFull()) { // Round-robin the tiers that still need levels so every tier gets airtime. for (let ti = 0; ti < TIERS.length; ti++) { if (buckets[ti].length >= TIERS[ti].count) continue; attempts++; if ((attempts & 0x1ff) === 0 && (Date.now() - startedAt) / 1000 > MAX_SECONDS) { console.log('\n[pudding] time budget reached, stopping early'); break; } const tier = TIERS[ti]; const lvl = randomLevel(tier); if (!lvl) continue; const ck = canonKey(lvl); if (seen.has(ck)) continue; seen.add(ck); const state = newState(lvl); if (state.blobs.length !== tier.monsters) continue; // started adjacent -> skip const res = solve(state, { maxStates: SOLVE_MAX_STATES }); if (res.moves < Math.max(2, tier.minPar) || res.moves > tier.maxPar) continue; solved++; lvl.targets = chooseTargets(res.footprint); if (lvl.targets.length !== 3) continue; lvl.par = res.moves; buckets[ti].push(lvl); if (solved % 200 === 0) { const kept = buckets.reduce((t, b) => t + b.length, 0); process.stdout.write(`\r[pudding] attempts=${attempts} kept=${kept}/${target} `); } } if ((Date.now() - startedAt) / 1000 > MAX_SECONDS) break; } process.stdout.write('\n'); // ── Assemble ordered levels (tier order, then par ascending within tier) ────── const chosen = []; buckets.forEach((b) => { b.sort((p, q) => p.par - q.par); chosen.push(...b); }); const levels = chosen.map((lvl, i) => ({ level: i + 1, cols: lvl.cols, rows: lvl.rows, walls: lvl.walls, spikes: lvl.spikes, targets: lvl.targets, monsters: lvl.monsters, par: lvl.par, })); const payload = { generatedAt: new Date().toISOString(), seed: SEED, count: levels.length, 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(`[pudding] attempts=${attempts} solvable=${solved}`); console.log(`[pudding] tiers filled: ${perTier}`); console.log(`[pudding] wrote ${levels.length} levels (par ${levels[0]?.par}..${levels[levels.length - 1]?.par}) -> ${OUT_FILE}`);