fertig-classic-games/server/scripts/genPuddingMonsters.js

179 lines
6.7 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 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}`);