179 lines
6.8 KiB
JavaScript
179 lines
6.8 KiB
JavaScript
// 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 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]. 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,
|
|
};
|
|
}
|
|
|
|
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}`);
|