// 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}`);