97 lines
3.4 KiB
JavaScript
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}`);
|