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

112 lines
3.9 KiB
JavaScript

// Offline generator for Dot Link puzzles.
//
// Builds a smooth easy->legendary curve of 100 Flow-Free boards by growing a
// random Hamiltonian path over each grid (backbite) and cutting it into colour
// segments, then preferring boards the solver proves uniquely solvable. Writes
// ordered levels to public/data/dotlink.json.
//
// Usage:
// node server/scripts/genDotLink.js [seed] [outFile]
//
// Deterministic: same seed -> same bank.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { makeRng, generateBoard, isSolved } from '../../public/src/games/dotlink/DotLinkLogic.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/dotlink.json');
const SEED = process.argv[2] ? Number(process.argv[2]) >>> 0 : 0xd07117e5;
// Difficulty curve: grid size grows, colour count grows. counts sum to 100.
const TIERS = [
{ count: 12, rows: 5, cols: 5, colorsMin: 4, colorsMax: 5 },
{ count: 14, rows: 6, cols: 6, colorsMin: 5, colorsMax: 6 },
{ count: 14, rows: 7, cols: 7, colorsMin: 6, colorsMax: 7 },
{ count: 14, rows: 8, cols: 8, colorsMin: 7, colorsMax: 8 },
{ count: 14, rows: 9, cols: 9, colorsMin: 8, colorsMax: 9 },
{ count: 12, rows: 10, cols: 10, colorsMin: 9, colorsMax: 10 },
{ count: 12, rows: 11, cols: 11, colorsMin: 10, colorsMax: 11 },
{ count: 8, rows: 11, cols: 11, colorsMin: 12, colorsMax: 12 },
];
function canonKey(board) {
return board.endpoints
.map((e) => {
const a = e.a[0] * board.cols + e.a[1];
const b = e.b[0] * board.cols + e.b[1];
return a < b ? `${a}-${b}` : `${b}-${a}`;
})
.sort()
.join('|');
}
const rng = makeRng(SEED);
console.log(`[dotlink] generating with seed 0x${SEED.toString(16)}`);
const levels = [];
const seen = new Set();
let uniqueCount = 0;
const startedAt = Date.now();
for (const tier of TIERS) {
for (let i = 0; i < tier.count; i++) {
const span = tier.colorsMax - tier.colorsMin;
const colors = tier.count > 1
? tier.colorsMin + Math.round((i / (tier.count - 1)) * span)
: tier.colorsMin;
// generateBoard always returns a valid (solvable) board with its reference
// solution; prefer one that is uniquely solvable (only checked on small
// grids) and not a duplicate of an earlier level.
let chosen = null;
for (let attempt = 0; attempt < 4; attempt++) {
const g = generateBoard(tier.rows, tier.cols, colors, rng, { tries: 24, maxNodes: 200000 });
if (!g) continue;
const dup = seen.has(canonKey(g.board));
if (!chosen || (!dup && (g.unique || !chosen.unique))) chosen = g;
if (g.unique && !dup) break;
}
if (!chosen) {
console.error(`[dotlink] FAILED to generate a ${tier.rows}x${tier.cols}/${colors} board`);
process.exit(1);
}
seen.add(canonKey(chosen.board));
if (chosen.unique) uniqueCount++;
levels.push({
level: levels.length + 1,
rows: chosen.board.rows,
cols: chosen.board.cols,
colors,
unique: !!chosen.unique,
endpoints: chosen.board.endpoints,
solution: chosen.solution,
});
process.stdout.write(`\r[dotlink] built ${levels.length}/100 (unique ${uniqueCount}) `);
}
}
process.stdout.write('\n');
// Sanity pass: every level's stored reference solution must validate.
let unsolvable = 0;
for (const lv of levels) {
if (!isSolved(lv, lv.solution)) unsolvable++;
}
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 secs = ((Date.now() - startedAt) / 1000).toFixed(1);
console.log(`[dotlink] wrote ${levels.length} levels (${uniqueCount} unique, ${unsolvable} unsolvable) in ${secs}s -> ${OUT_FILE}`);
if (unsolvable > 0) process.exit(1);