fertig-classic-games/server/words/miniCrosswordEngine.js

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] })),
};
}