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

140 lines
5.4 KiB
JavaScript

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