// Mini Crossword engine: serves curated puzzles from a hand-authored bank. // Pure logic — no Express. Loaded once at server start. // // A puzzle is authored as { id, difficulty, grid:[row strings], across:[...], // down:[...] }. Difficulty sets the grid size: easy 5x5, medium 6x6, hard 7x7. // A '#' marks a black square. Across/Down clue arrays are ordered by the derived // crossword numbering (reading order). This engine derives that numbering and // pairs each clue with its slot. import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PUZZLE_PATH = path.join(__dirname, '../data/crosswords/minicrossword.json'); const BLOCK = '#'; // Difficulty no longer means clue obscurity — it sets the grid size. const TIER_SIZE = { easy: 5, medium: 6, hard: 7 }; const DIFFICULTIES = Object.keys(TIER_SIZE); let byDifficulty = { easy: [], medium: [], hard: [] }; let allPuzzles = []; // ── Slot extraction & numbering ─────────────────────────────────────────────── // Returns true when (r,c) is a letter cell (not a black square) within the grid. function isCell(grid, r, c) { return r >= 0 && r < grid.length && c >= 0 && c < grid[r].length && grid[r][c] !== BLOCK; } // Walks the grid in reading order and builds the numbered across/down slots. // A cell starts an across word when it has no playable neighbour to its left and // at least one to its right; likewise a down word vertically. Both kinds of // starting cell share a single incrementing clue number (standard convention). function deriveSlots(grid) { const across = []; const down = []; let number = 0; for (let r = 0; r < grid.length; r++) { for (let c = 0; c < grid[r].length; c++) { if (!isCell(grid, r, c)) continue; const startsAcross = !isCell(grid, r, c - 1) && isCell(grid, r, c + 1); const startsDown = !isCell(grid, r - 1, c) && isCell(grid, r + 1, c); if (!startsAcross && !startsDown) continue; number += 1; if (startsAcross) { let answer = ''; let cc = c; while (isCell(grid, r, cc)) { answer += grid[r][cc]; cc += 1; } across.push({ number, row: r, col: c, len: answer.length, answer }); } if (startsDown) { let answer = ''; let rr = r; while (isCell(grid, rr, c)) { answer += grid[rr][c]; rr += 1; } down.push({ number, row: r, col: c, len: answer.length, answer }); } } } return { across, down }; } // ── Validation ──────────────────────────────────────────────────────────────── function validatePuzzle(p) { const size = TIER_SIZE[p.difficulty]; if (!size) { throw new Error(`puzzle ${p.id}: unknown difficulty '${p.difficulty}'`); } if (!Array.isArray(p.grid) || p.grid.length !== size) { throw new Error(`puzzle ${p.id}: ${p.difficulty} grid must have ${size} rows`); } const rowRe = new RegExp(`^[A-Z#]{${size}}$`); for (const row of p.grid) { if (typeof row !== 'string' || !rowRe.test(row)) { throw new Error(`puzzle ${p.id}: each row must be ${size} chars of A-Z or '#'`); } } const { across, down } = deriveSlots(p.grid); if (!Array.isArray(p.across) || p.across.length !== across.length) { throw new Error(`puzzle ${p.id}: expected ${across.length} across clues, got ${p.across?.length}`); } if (!Array.isArray(p.down) || p.down.length !== down.length) { throw new Error(`puzzle ${p.id}: expected ${down.length} down clues, got ${p.down?.length}`); } return { across, down }; } // ── Initialization ──────────────────────────────────────────────────────────── export function initMiniCrosswordPuzzles() { byDifficulty = { easy: [], medium: [], hard: [] }; allPuzzles = []; let raw; try { raw = fs.readFileSync(PUZZLE_PATH, 'utf8'); } catch { console.warn('[words] Mini Crossword puzzle bank not found.'); return { puzzles: 0 }; } const bank = JSON.parse(raw); for (const p of bank) { validatePuzzle(p); byDifficulty[p.difficulty].push(p); allPuzzles.push(p); } return { puzzles: allPuzzles.length }; } // ── Puzzle selection ────────────────────────────────────────────────────────── // Returns a random puzzle for the requested difficulty, packaged with derived // numbering. Each clue entry carries its number, start cell, length and answer. export function getPuzzle(difficulty = 'easy') { const bucket = byDifficulty[difficulty]?.length ? byDifficulty[difficulty] : allPuzzles; if (!bucket.length) { return { id: null, difficulty, rows: 0, cols: 0, grid: [], across: [], down: [] }; } const p = bucket[Math.floor(Math.random() * bucket.length)]; const { across, down } = deriveSlots(p.grid); return { id: p.id, difficulty: p.difficulty, rows: p.grid.length, cols: p.grid[0].length, grid: p.grid, across: across.map((slot, i) => ({ ...slot, clue: p.across[i] })), down: down.map((slot, i) => ({ ...slot, clue: p.down[i] })), }; }