// Mini Crossword engine: serves curated 5x5 puzzles from a hand-authored bank. // Pure logic — no Express. Loaded once at server start. // // A puzzle is authored as { id, difficulty, grid:[5 row strings], across:[5], // down:[5] }. Grids are fixed 5x5; a '#' marks a black square. Across/Down clue // arrays are ordered by row index / column index respectively. This engine // derives the standard crossword 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 SIZE = 5; const BLOCK = '#'; const DIFFICULTIES = ['easy', 'medium', 'hard']; let byDifficulty = { easy: [], medium: [], hard: [] }; let allPuzzles = []; // ── Slot extraction & numbering ─────────────────────────────────────────────── // Returns true when (r,c) is a letter cell (not a black square). function isCell(grid, r, c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE && 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 < SIZE; r++) { for (let c = 0; c < SIZE; 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) { if (!Array.isArray(p.grid) || p.grid.length !== SIZE) { throw new Error(`puzzle ${p.id}: grid must have ${SIZE} rows`); } for (const row of p.grid) { if (typeof row !== 'string' || row.length !== SIZE || !/^[A-Z#]{5}$/.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); const diff = DIFFICULTIES.includes(p.difficulty) ? p.difficulty : 'medium'; byDifficulty[diff].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 = 'medium') { const bucket = byDifficulty[difficulty]?.length ? byDifficulty[difficulty] : allPuzzles; if (!bucket.length) { return { id: null, difficulty, rows: SIZE, cols: SIZE, 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: SIZE, cols: SIZE, grid: p.grid, across: across.map((slot, i) => ({ ...slot, clue: p.across[i] })), down: down.map((slot, i) => ({ ...slot, clue: p.down[i] })), }; }