fertig-classic-games/tools/genPuddingMonsters.js

458 lines
20 KiB
JavaScript

// Offline generator for Pudding Monsters / "Jell-o Monsters" levels.
//
// Wave 3 replaced the old flat tier ramp with a CURRICULUM: five named chapters
// of fifteen levels each, built from blocks that introduce one mechanic at a
// time, drill it, then combine it with what came before. See
// docs/puddingmonsters-mechanics-plan.md.
//
// Every candidate is random-filled, solved with the BFS solver for its `par`,
// and then has to survive three gates:
//
// 1. PAR BAND — the block says how hard its levels should be.
// 2. LOAD-BEARING — strip an element out and the level must solve in a
// different number of moves, or stop being solvable. This
// is what stops a "slime level" from being an ordinary
// level with a green monster standing in it. Every element
// in a combination level must pass this on its own.
// 3. GENTLE (teaching levels only) — NO first flick may be fatal. You cannot
// lose a teaching level on move one.
//
// Targets (the 3 yellow squares) are 3 cells of the optimal solution's final
// footprint, so the on-par solution is always a 3-star, crowned clear.
//
// Usage:
// node tools/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, planFlick, DIR_LIST } from '../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, '../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);
// ── Element catalogue ────────────────────────────────────────────────────────
// `typed` elements mark a subset of the monsters; the rest place extra terrain.
// `needs` pulls in the terrain a monster type is useless without.
const ELEMENTS = {
sleepers: { typed: 1, tip: 'Sleepyheads can\'t be flicked — bump one awake to move it.' },
green: { typed: 1, tip: 'Green monsters leave slime. Anything sliding onto it stops dead.' },
hypno: { typed: 2, tip: 'Hypno goos share one mind — flick one and they all go.' },
powerlifters: { typed: 1, terrain: { crates: 2 }, tip: 'Powerlifters shove crates around. Everyone else just stops.' },
ice: { terrain: { ice: 3 }, tip: 'Ice stops a slide once, then shatters. Spend it wisely.' },
springs: { terrain: { springs: 2 }, tip: 'Springs bounce you straight back — and you keep going.' },
tunnels: { terrain: { tunnels: 1 }, tip: 'Tunnels carry a monster across the table, still sliding.' },
buttons: { terrain: { bricks: 2, buttons: 1 }, tip: 'Roll over the button to raise or lower the bricks.' },
};
// ── The curriculum ───────────────────────────────────────────────────────────
// Each chapter is 15 levels of blocks. `teach` = one gentle introduction,
// `drill` = reinforcement of the same element, `mix` = combinations, `basic` =
// no elements at all. Chapter defaults are merged into every block.
const CHAPTERS = [
{
id: 1,
name: 'Cold Storage',
blurb: 'Wake up. Get out of the fridge.',
base: { cols: 5, rows: 5, monsters: 3, walls: 4, spikes: 0 },
blocks: [
{ kind: 'basic', count: 3, minPar: 2, maxPar: 3 },
{ kind: 'teach', element: 'sleepers', count: 1, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'sleepers', count: 3, minPar: 3, maxPar: 5 },
{ kind: 'teach', element: 'ice', count: 1, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'ice', count: 3, minPar: 3, maxPar: 5 },
{ kind: 'mix', elements: ['sleepers', 'ice'], count: 4, cols: 6, rows: 6, monsters: 4, walls: 5, minPar: 4, maxPar: 8 },
],
names: [
'First Wobble', 'Two of a Kind', 'Shelf Life',
'Rise and Shine', 'Snooze Button', 'Heavy Sleeper', 'Dream Team',
'Breaking the Ice', 'Cold Snap', 'Thin Ice', 'Freezer Burn',
'Chill Out', 'Frost and Friends', 'Deep Freeze', 'Door Ajar',
],
},
{
id: 2,
name: 'Kitchen Counter',
blurb: 'Mind the mess. Some of it is yours.',
base: { cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0 },
blocks: [
{ kind: 'basic', count: 2, minPar: 3, maxPar: 5 },
{ kind: 'teach', element: 'green', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'green', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'teach', element: 'hypno', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'hypno', count: 4, monsters: 4, walls: 5, minPar: 3, maxPar: 6 },
{ kind: 'mix', elements: ['green', 'hypno'], count: 3, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
],
names: [
'Countertop Caper', 'Spilled Milk',
'Sticky Business', 'Green Streak', 'Trail Mix', 'Slime Time', 'Gooey Detour',
'Group Think', 'All Together Now', 'Hive Mind', 'Synchronised Slipping', 'Follow the Leader',
'Slime and Punishment', 'One Mind, Many Blobs', 'Crumb Trail',
],
},
{
id: 3,
name: 'The Pantry',
blurb: 'Shortcuts, springs, and a long way down.',
base: { cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0 },
blocks: [
{ kind: 'basic', count: 1, minPar: 3, maxPar: 5 },
{ kind: 'teach', element: 'springs', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'springs', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'teach', element: 'tunnels', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'tunnels', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'mix', elements: ['springs', 'tunnels'], count: 2, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
{ kind: 'mix', elements: ['springs', 'ice'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
{ kind: 'mix', elements: ['tunnels', 'green'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
],
names: [
'Shelf Assembly',
'Boing', 'Bounce Back', 'Double Trouble', 'Springboard', 'Return to Sender',
'Down the Hatch', 'Wormhole', 'Pantry Portal', 'In One End', 'Shortcut',
'Bounce and Beyond', 'Jar Jam', 'Spice Rack', 'Pantry Panic',
],
},
{
id: 4,
name: 'Dining Room',
blurb: 'Push the furniture. Nobody is watching.',
base: { cols: 6, rows: 6, monsters: 3, walls: 4, spikes: 0 },
blocks: [
{ kind: 'basic', count: 1, minPar: 3, maxPar: 5 },
{ kind: 'teach', element: 'buttons', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'buttons', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'teach', element: 'powerlifters', count: 1, cols: 5, rows: 5, minPar: 2, maxPar: 3 },
{ kind: 'drill', element: 'powerlifters', count: 4, minPar: 3, maxPar: 6 },
{ kind: 'mix', elements: ['buttons', 'powerlifters'], count: 2, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
{ kind: 'mix', elements: ['powerlifters', 'springs'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
{ kind: 'mix', elements: ['buttons', 'sleepers'], count: 1, monsters: 4, walls: 5, minPar: 4, maxPar: 9 },
],
names: [
'Table Manners',
'Press Here', 'Trapdoor', 'Now You Don\'t', 'Push and Pull', 'Brickwork',
'Heavy Lifting', 'Crate Expectations', 'Muscle Memory', 'Shove Off', 'Furniture Shuffle',
'Dinner Service', 'Table for One', 'Centrepiece', 'Clean Plate',
],
},
{
id: 5,
name: 'Midnight Feast',
blurb: 'Everything you have learned, all at once.',
base: { cols: 7, rows: 7, monsters: 4, walls: 6, spikes: 1 },
blocks: [
{ kind: 'mix', elements: ['sleepers', 'springs'], count: 2, minPar: 4, maxPar: 10 },
{ kind: 'mix', elements: ['green', 'tunnels'], count: 2, minPar: 4, maxPar: 10 },
{ kind: 'mix', elements: ['ice', 'buttons'], count: 2, minPar: 4, maxPar: 10 },
{ kind: 'mix', elements: ['hypno', 'powerlifters'], count: 2, minPar: 4, maxPar: 10 },
{ kind: 'mix', elements: ['springs', 'tunnels', 'sleepers'], count: 2, monsters: 5, walls: 7, minPar: 5, maxPar: 12 },
{ kind: 'mix', elements: ['green', 'ice', 'buttons'], count: 2, monsters: 5, walls: 7, minPar: 5, maxPar: 12 },
{ kind: 'mix', elements: ['powerlifters', 'tunnels', 'hypno'], count: 3, monsters: 5, walls: 7, minPar: 5, maxPar: 14 },
],
names: [
'Night Shift', 'Leftovers', 'Midnight Snack', 'Everything Bagel', 'Kitchen Sink',
'The Long Way Round', 'Sleepwalking', 'Slippery Slope', 'Spring Cleaning', 'Tunnel Vision',
'Button Masher', 'Crate Escape', 'Full Fridge', 'Last Supper', 'Jell-o Monster',
],
},
];
const MAX_TRIES_PER_LEVEL = 400000;
const SECONDS_PER_BLOCK = 300;
const SOLVE_MAX_STATES = 80000;
const keyOf = (x, y) => `${x},${y}`;
// Place `n` distinct random cells avoiding `taken`; returns null if it can't.
// `interior` keeps cells off the rim, which is what makes guard rails possible.
function placeCells(n, cols, rows, taken, interior = false) {
const out = [];
let tries = 0;
const lo = interior ? 1 : 0;
const wx = interior ? cols - 2 : cols;
const wy = interior ? rows - 2 : rows;
if (wx <= 0 || wy <= 0) return null;
while (out.length < n && tries < 400) {
tries++;
const x = lo + randInt(wx), y = lo + randInt(wy);
const k = keyOf(x, y);
if (taken.has(k)) continue;
taken.add(k);
out.push([x, y]);
}
return out.length === n ? out : null;
}
// Teaching levels promise you cannot lose on move one. Random walls almost
// never deliver that on an open-edged table, so build it in: for every monster
// and every direction whose ray runs clean off the board, drop a wall on the
// last cell of that ray. The monster then stops one short of the rim instead of
// sailing over it. Monsters must already be off the rim themselves — a monster
// standing on the edge facing out cannot be saved by any wall.
function addGuardRails(lvl, taken) {
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
for (const [mx, my] of lvl.monsters) {
for (const [dx, dy] of dirs) {
let blocked = false;
let last = null;
for (let s = 1; ; s++) {
const x = mx + dx * s, y = my + dy * s;
if (x < 0 || y < 0 || x >= lvl.cols || y >= lvl.rows) break;
if (taken.has(keyOf(x, y))) { blocked = true; break; }
last = [x, y];
}
if (blocked) continue;
if (!last) return false; // monster is on the rim facing out
taken.add(keyOf(last[0], last[1]));
lvl.walls.push(last);
}
}
return true;
}
// Build one random candidate for a block (or null if the board is too crowded).
function randomLevel(spec) {
const elements = spec.elements ?? (spec.element ? [spec.element] : []);
const taken = new Set();
const walls = placeCells(spec.walls, spec.cols, spec.rows, taken);
if (!walls) return null;
const spikes = placeCells(spec.spikes ?? 0, spec.cols, spec.rows, taken);
if (!spikes) return null;
const lvl = { cols: spec.cols, rows: spec.rows, walls, spikes };
// Terrain each element needs.
for (const el of elements) {
const terrain = ELEMENTS[el].terrain ?? {};
for (const [field, count] of Object.entries(terrain)) {
const n = spec.counts?.[field] ?? count;
if (field === 'tunnels') {
const mouths = placeCells(n * 2, spec.cols, spec.rows, taken);
if (!mouths) return null;
lvl.tunnels = [];
for (let i = 0; i < mouths.length; i += 2) {
lvl.tunnels.push([mouths[i][0], mouths[i][1], mouths[i + 1][0], mouths[i + 1][1]]);
}
} else if (field === 'bricks' || field === 'buttons') {
const cells = placeCells(n, spec.cols, spec.rows, taken);
if (!cells) return null;
lvl[field] = cells.map(([x, y]) => [x, y, 1]); // one group keeps cause and effect readable
} else {
const cells = placeCells(n, spec.cols, spec.rows, taken);
if (!cells) return null;
lvl[field] = cells;
}
}
}
const monsters = placeCells(spec.monsters, spec.cols, spec.rows, taken, spec.kind === 'teach');
if (!monsters) return null;
lvl.monsters = monsters;
// Teaching levels get a rail on any line that would drop a monster off the
// table, so the opening position is survivable whatever the player tries.
if (spec.kind === 'teach' && !addGuardRails(lvl, taken)) return null;
// Typed monsters are a random subset of the monsters already placed. Never
// type every monster: something has to be flickable.
const pool = monsters.slice();
for (const el of elements) {
const n = spec.counts?.[el] ?? ELEMENTS[el].typed ?? 0;
if (!n) continue;
const picked = [];
while (picked.length < n && pool.length > 1) picked.push(pool.splice(randInt(pool.length), 1)[0]);
if (picked.length !== n) return null;
lvl[el] = picked;
}
return lvl;
}
// The element has to matter: with it removed the level must solve in a
// different number of moves, or stop being solvable at all.
function isLoadBearing(lvl, element, par) {
const stripped = { ...lvl };
delete stripped[element];
delete stripped.targets;
const state = newState(stripped);
if (state.blobs.length === 1) return true; // the element was holding them apart
return solve(state, { maxStates: SOLVE_MAX_STATES }).moves !== par;
}
// How many opening flicks kill you. Teaching levels must have none.
function fatalFirstMoves(state) {
let n = 0;
state.blobs.forEach((blob, idx) => {
if (blob.asleep) return;
for (const dir of DIR_LIST) {
const plan = planFlick(state, idx, dir);
if (plan.legal && plan.dead) n += 1;
}
});
return n;
}
function canonKey(lvl) {
const s = (arr) => (arr ?? []).map((c) => c.join(',')).sort().join(' ');
return [lvl.cols, lvl.rows, s(lvl.monsters), s(lvl.walls), s(lvl.spikes), s(lvl.ice),
s(lvl.sleepers), s(lvl.green), s(lvl.hypno), s(lvl.springs), s(lvl.tunnels),
s(lvl.bricks), s(lvl.buttons), s(lvl.crates), s(lvl.powerlifters)].join('|');
}
// 3 spread-out cells of the solution's final footprint -> yellow target squares.
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]]);
}
// ── Build the bank chapter by chapter ────────────────────────────────────────
console.log(`[pudding] generating with seed ${SEED}`);
const seen = new Set();
const bank = [];
let totalTries = 0;
for (const chapter of CHAPTERS) {
const produced = [];
for (const block of chapter.blocks) {
const spec = { ...chapter.base, ...block };
const elements = spec.elements ?? (spec.element ? [spec.element] : []);
const kept = [];
let tries = 0;
const started = Date.now();
while (kept.length < block.count
&& tries < MAX_TRIES_PER_LEVEL
&& (Date.now() - started) / 1000 < SECONDS_PER_BLOCK) {
tries++; totalTries++;
const lvl = randomLevel(spec);
if (!lvl) continue;
const ck = canonKey(lvl);
if (seen.has(ck)) continue;
seen.add(ck);
const state = newState(lvl);
if (state.blobs.length !== spec.monsters) continue; // started adjacent
if (block.kind === 'teach' && fatalFirstMoves(state) > 0) continue;
const res = solve(state, { maxStates: SOLVE_MAX_STATES });
if (res.moves < Math.max(2, spec.minPar) || res.moves > spec.maxPar) continue;
if (!elements.every((el) => isLoadBearing(lvl, el, res.moves))) continue;
lvl.targets = chooseTargets(res.footprint);
if (lvl.targets.length !== 3) continue;
lvl.par = res.moves;
lvl.chapter = chapter.id;
lvl.kind = block.kind;
if (elements.length) lvl.elements = elements.slice();
if (block.kind === 'teach') {
lvl.element = block.element;
lvl.tip = ELEMENTS[block.element].tip;
lvl.teaching = true;
} else if (elements.length) {
lvl.element = elements[0];
}
kept.push(lvl);
}
if (kept.length < block.count) {
console.error(`[pudding] WARNING ch${chapter.id} ${block.kind}`
+ `(${elements.join('+') || 'basic'}): only ${kept.length}/${block.count} after ${tries} tries`);
}
kept.sort((p, q) => p.par - q.par);
produced.push(...kept);
}
// Difficulty rises across the chapter without breaking the teach-then-drill
// order: blocks stay in curriculum order and sort by par inside themselves.
// The combination blocks all sit at the end, so sort that whole run together
// rather than letting each block restart the ramp.
let firstMix = produced.length;
while (firstMix > 0 && produced[firstMix - 1].kind === 'mix') firstMix -= 1;
const tail = produced.slice(firstMix).sort((p, q) => p.par - q.par);
produced.length = firstMix;
produced.push(...tail);
chapter.produced = produced;
bank.push(...produced);
console.log(`[pudding] chapter ${chapter.id} "${chapter.name}": ${produced.length}/15 levels`);
}
// ── Assemble ─────────────────────────────────────────────────────────────────
const levels = bank.map((lvl, i) => {
const out = {
level: i + 1,
chapter: lvl.chapter,
name: '',
cols: lvl.cols,
rows: lvl.rows,
walls: lvl.walls,
spikes: lvl.spikes,
targets: lvl.targets,
monsters: lvl.monsters,
par: lvl.par,
};
for (const f of ['ice', 'slime', 'sleepers', 'green', 'hypno',
'springs', 'tunnels', 'bricks', 'buttons', 'bricksDown', 'crates', 'powerlifters']) {
if (lvl[f]?.length) out[f] = lvl[f];
}
if (lvl.elements) out.elements = lvl.elements;
if (lvl.element) out.element = lvl.element;
if (lvl.tip) out.tip = lvl.tip;
if (lvl.teaching) out.teaching = true;
return out;
});
// Names run in chapter order, so a teaching level keeps the name written for it.
const chapters = [];
let cursor = 0;
for (const chapter of CHAPTERS) {
const n = chapter.produced.length;
if (!n) continue;
for (let i = 0; i < n; i++) {
levels[cursor + i].name = chapter.names[i] ?? `${chapter.name} ${i + 1}`;
}
chapters.push({
id: chapter.id,
name: chapter.name,
blurb: chapter.blurb,
from: cursor + 1,
to: cursor + n,
});
cursor += n;
}
const payload = {
generatedAt: new Date().toISOString(),
seed: SEED,
count: levels.length,
chapters,
levels,
};
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));
console.log(`[pudding] ${totalTries} candidates tried`);
for (const c of chapters) console.log(`[pudding] ch${c.id} ${c.name}: levels ${c.from}-${c.to}`);
console.log(`[pudding] wrote ${levels.length} levels (par ${Math.min(...levels.map((l) => l.par))}`
+ `..${Math.max(...levels.map((l) => l.par))}) -> ${OUT_FILE}`);