458 lines
20 KiB
JavaScript
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}`);
|