134 lines
5.1 KiB
JavaScript
134 lines
5.1 KiB
JavaScript
// 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] })),
|
|
};
|
|
}
|