// Offline generator for Pudding Monsters levels. // // For each difficulty tier it random-fills a grid with walls, spikes and K // monsters, runs the BFS solver to (a) reject unsolvable/trivial layouts and // (b) label each survivor with its minimum flick count (par), then places 3 // guaranteed-collectable stars on cells of that solution's final footprint and // writes ordered levels to public/data/puddingmonsters.json. // // 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]. Spikes appear only // in later tiers (yield + difficulty). Levels are numbered tier-by-tier. const TIERS = [ { count: 8, cols: 5, rows: 5, monsters: 3, walls: 0, spikes: 0, minPar: 2, maxPar: 3 }, { count: 8, cols: 6, rows: 6, monsters: 3, walls: 2, spikes: 0, minPar: 3, maxPar: 5 }, { count: 8, cols: 6, rows: 6, monsters: 4, walls: 3, spikes: 0, minPar: 4, maxPar: 7 }, { count: 8, cols: 7, rows: 7, monsters: 4, walls: 4, spikes: 1, minPar: 5, maxPar: 9 }, { count: 8, cols: 7, rows: 7, monsters: 5, walls: 5, 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, stars: [], }; } 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)}`; } // Pick 3 spread-out, ideally non-starting footprint cells -> guaranteed stars. function chooseStars(footprint, monsters) { const startSet = new Set(monsters.map(([x, y]) => keyOf(x, y))); const nonStart = footprint.filter(([x, y]) => !startSet.has(keyOf(x, y))); const pool = nonStart.length >= 3 ? nonStart : footprint; const sorted = pool.slice().sort((a, b) => (a[1] - b[1]) || (a[0] - b[0])); const idx = [0, Math.floor(sorted.length / 2), sorted.length - 1]; return [...new Set(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.stars = chooseStars(res.footprint, lvl.monsters); if (lvl.stars.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, stars: lvl.stars, 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}`);