fertig-classic-games/tools/genTetrisAttackPuzzles.js

97 lines
3.4 KiB
JavaScript

// Generates data/tetrisattack-puzzles.json — a bank of solvable Puzzle-mode
// boards. Each puzzle is a small bottom-aligned stack of panels that can be
// cleared to nothing within `maxMoves` swaps. Every candidate is verified with
// the engine's BFS solver, so the shipped bank is guaranteed winnable and the
// stored `maxMoves` is the optimal (minimal) swap count.
//
// node tools/genTetrisAttackPuzzles.js
//
import { writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { COLS, mulberry32, solvePuzzle } from '../src/games/tetrisattack/TetrisAttackLogic.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = join(__dirname, '..', 'data', 'tetrisattack-puzzles.json');
const CH = ['r', 'y', 'g', 'c', 'p', 'b'];
// Build a random bottom-aligned grid: pick a per-color multiset whose counts are
// each a multiple of 3 (necessary to clear fully), scatter into column stacks.
function randomCandidate(rng, panels) {
// choose color counts (multiples of 3) summing to `panels`
const triples = panels / 3;
const counts = {};
for (let i = 0; i < triples; i++) {
const ch = CH[(rng() * 4) | 0]; // bias to first 4 colors for denser matches
counts[ch] = (counts[ch] ?? 0) + 3;
}
const bag = [];
for (const [ch, n] of Object.entries(counts)) for (let i = 0; i < n; i++) bag.push(ch);
// shuffle
for (let i = bag.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
[bag[i], bag[j]] = [bag[j], bag[i]];
}
// distribute into column heights
const heights = Array(COLS).fill(0);
for (let i = 0; i < bag.length; i++) heights[(rng() * COLS) | 0]++;
const maxH = Math.max(...heights);
if (maxH === 0) return null;
const grid = Array.from({ length: maxH }, () => Array(COLS).fill('.'));
let idx = 0;
for (let c = 0; c < COLS; c++) {
for (let h = 0; h < heights[c]; h++) {
// fill bottom-up: bottom row is last
const row = maxH - 1 - h;
grid[row][c] = bag[idx++];
}
}
return grid.map((r) => r.join(''));
}
function hasImmediateMatch(grid) {
// quick reject: a puzzle that already contains a 3-run needs no swap
const rows = grid.length;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < COLS; c++) {
const ch = grid[r][c];
if (ch === '.') continue;
if (c + 2 < COLS && grid[r][c + 1] === ch && grid[r][c + 2] === ch) return true;
if (r + 2 < rows && grid[r + 1][c] === ch && grid[r + 2][c] === ch) return true;
}
}
return false;
}
const rng = mulberry32(20260719);
const puzzles = [];
const targets = [
{ moves: 2, count: 8, panels: [6, 9] },
{ moves: 3, count: 8, panels: [9, 12] },
{ moves: 4, count: 8, panels: [12, 15] },
];
let id = 1;
for (const t of targets) {
let made = 0;
let attempts = 0;
while (made < t.count && attempts < 6000) {
attempts++;
const panels = t.panels[(rng() * t.panels.length) | 0];
const grid = randomCandidate(rng, panels);
if (!grid) continue;
if (hasImmediateMatch(grid)) continue;
const def = { grid, maxMoves: t.moves };
const sol = solvePuzzle(def, () => 0.5);
if (sol && sol.length === t.moves) {
puzzles.push({ id: id++, moves: t.moves, grid, maxMoves: t.moves });
made++;
}
}
console.log(` ${t.moves}-move puzzles: ${made}/${t.count} (in ${attempts} attempts)`);
}
writeFileSync(OUT, JSON.stringify({ puzzles }, null, 2) + '\n');
console.log(`Wrote ${puzzles.length} puzzles → ${OUT}`);