#!/usr/bin/env node "use strict"; /** * Swedish-style crossword generator (mask + fill) * Spec: * - Grid 9x8 * - Letter cells: '#' (empty) or 'A'..'Z' (fixed) * - Clue cells: '1'..'4' indicating direction: * 1=up, 2=right, 3=down, 4=left * - Word length: 2..8 * - Words from word-list.txt (one per line, uppercase A-Z) * * Output: * - GENERATED MASK (digits + #) * - FILLED PUZZLE (digits + letters) * - clue -> word mapping "r,c:d = WORD" */ const fs = require("fs"); const W = 9, H = 8; const MIN_LEN = 2, MAX_LEN = 8; const DIRS = { "1": [-1, 0], "2": [0, 1], "3": [1, 0], "4": [0, -1], }; const IS_DIGIT = (ch) => ch >= "1" && ch <= "4"; const IS_LETTER = (ch) => ch >= "A" && ch <= "Z"; const IS_LETTER_CELL = (ch) => ch === "#" || IS_LETTER(ch); function usage() { console.log(`Usage: node swedish_generator.js [--seed N] [--pop N] [--gens N] [--tries N] [--words word-list.txt] Defaults: --seed 1 --pop 18 --gens 220 --tries 25 --words ./word-list.txt `); } function parseArgs(argv) { const out = {seed: 1, pop: 18, gens: 220, tries: 25, wordsPath: "./word-list.txt"}; for (let i = 2; i < argv.length; i++) { const a = argv[i]; const v = argv[i + 1]; if (a === "--help" || a === "-h") { usage(); process.exit(0); } if (a === "--seed") out.seed = parseInt(v, 10), i++; else if (a === "--pop") out.pop = parseInt(v, 10), i++; else if (a === "--gens") out.gens = parseInt(v, 10), i++; else if (a === "--tries") out.tries = parseInt(v, 10), i++; else if (a === "--words") out.wordsPath = v, i++; else throw new Error(`Unknown arg: ${a}`); } return out; } /** seeded RNG (xorshift32) */ function makeRng(seed) { let x = (seed >>> 0) || 1; return { nextU32() { x ^= x << 13; x >>>= 0; x ^= x >>> 17; x >>>= 0; x ^= x << 5; x >>>= 0; return x >>> 0; }, int(min, max) { const r = this.nextU32(); return min + (r % (max - min + 1)); }, float() { return this.nextU32() / 0xFFFFFFFF; }, pick(arr) { return arr[this.int(0, arr.length - 1)]; } }; } function deepCopyGrid(g) { return g.map(r => r.slice()); } function gridToString(g) { return g.map(r => r.join("")).join("\n"); } function makeEmptyGrid() { return Array.from({length: H}, () => Array.from({length: W}, () => "#")); } /** Load words and build fast length+pos index */ function loadWords(wordsPath) { let raw = ""; try { raw = fs.readFileSync(wordsPath, "utf8"); } catch { // fallback raw = "EU\nUUR\nAUTO\nBOOM\nHUIS\nKAT\nZEE\nRODE\nDRAAD\nKENNIS\nNETWERK\nPAKTE\n"; } const words = raw .split(/\r?\n/g) .map(s => s.trim().toUpperCase()) .filter(s => /^[A-Z]{2,8}$/.test(s)); // index[len] = { words: string[], pos: Array(len) of [26 arrays of indices] } const index = new Map(); for (const w of words) { const L = w.length; if (!index.has(L)) { const pos = Array.from({length: L}, () => Array.from({length: 26}, () => []) ); index.set(L, {words: [], pos}); } const entry = index.get(L); const idx = entry.words.length; entry.words.push(w); for (let i = 0; i < L; i++) { entry.pos[i][w.charCodeAt(i) - 65].push(idx); } } return {words, index}; } function intersectSorted(a, b) { const out = []; let i = 0, j = 0; while (i < a.length && j < b.length) { const x = a[i], y = b[j]; if (x === y) { out.push(x); i++; j++; } else if (x < y) i++; else j++; } return out; } function candidateIndicesForPattern(indexEntry, pattern /* array char|null */) { const lists = []; for (let i = 0; i < pattern.length; i++) { const ch = pattern[i]; if (ch && IS_LETTER(ch)) { lists.push(indexEntry.pos[i][ch.charCodeAt(0) - 65]); } } if (lists.length === 0) { // all candidates return Array.from({length: indexEntry.words.length}, (_, i) => i); } lists.sort((a, b) => a.length - b.length); let cur = lists[0]; for (let k = 1; k < lists.length; k++) { cur = intersectSorted(cur, lists[k]); if (cur.length === 0) break; } return cur; } /** Slot extraction: from each clue cell (digit), read letters away while letter-cells (# or A-Z) */ function extractSlots(grid) { const slots = []; for (let r = 0; r < H; r++) { for (let c = 0; c < W; c++) { const d = grid[r][c]; if (!IS_DIGIT(d)) continue; const [dr, dc] = DIRS[d]; let rr = r + dr, cc = c + dc; if (rr < 0 || rr >= H || cc < 0 || cc >= W) continue; if (!IS_LETTER_CELL(grid[rr][cc])) continue; const cells = []; while (rr >= 0 && rr < H && cc >= 0 && cc < W) { const ch = grid[rr][cc]; if (!IS_LETTER_CELL(ch)) break; // stops at clue digits (or other non-letter) cells.push([rr, cc]); rr += dr; cc += dc; if (cells.length > MAX_LEN) break; } slots.push({ clue: [r, c, d], dir: d, cells, len: cells.length }); } } return slots; } /** Fitness: penalty-based mask evaluation + dictionary-based feasibility hints */ function fitness(grid, dictIndex) { let penalty = 0; // 1) clue density (avoid "all digits") let clueCount = 0, letterCount = 0; for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { const ch = grid[r][c]; if (IS_DIGIT(ch)) clueCount++; else if (IS_LETTER_CELL(ch)) letterCount++; } const targetClues = Math.round(W * H * 0.25); // ~18 penalty += 8 * Math.abs(clueCount - targetClues); // 2) slots + length + candidate viability const slots = extractSlots(grid); if (slots.length === 0) return 1e9; // coverage counts per letter cell: horizontal vs vertical const covH = Array.from({length: H}, () => Array(W).fill(0)); const covV = Array.from({length: H}, () => Array(W).fill(0)); for (const s of slots) { const horizontal = (s.dir === "2" || s.dir === "4"); if (s.len < MIN_LEN) penalty += 8000; else if (s.len > MAX_LEN) penalty += 8000 + (s.len - MAX_LEN) * 500; // candidate count hint (not full fill, but strong signal) if (s.len >= MIN_LEN && s.len <= MAX_LEN && dictIndex && dictIndex.has(s.len)) { const entry = dictIndex.get(s.len); const pattern = s.cells.map(([r, c]) => { const ch = grid[r][c]; return IS_LETTER(ch) ? ch : null; }); const candIdx = candidateIndicesForPattern(entry, pattern); const n = candIdx.length; if (n === 0) penalty += 12000; // impossible slot else penalty += Math.floor(400 / Math.log2(n + 2)); // prefer higher branching } else { // no words of that length available if (s.len >= MIN_LEN && s.len <= MAX_LEN) penalty += 12000; } // mark coverage for (const [r, c] of s.cells) { if (horizontal) covH[r][c] += 1; else covV[r][c] += 1; } } // 3) coverage penalties per letter cell for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { const ch = grid[r][c]; if (!IS_LETTER_CELL(ch)) continue; const h = covH[r][c], v = covV[r][c]; if (h === 0 && v === 0) penalty += 1500; else if (h > 0 && v > 0) penalty += 0; else if (h + v === 1) penalty += 200; else penalty += 600; // multiple same-direction passes } // 4) clue clustering penalty (8-connected components) const seen = Array.from({length: H}, () => Array(W).fill(false)); const nbrs8 = [ [-1, -1], [-1, 0], [-1, 1], [0, -1], [0, 1], [1, -1], [1, 0], [1, 1] ]; for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { if (!IS_DIGIT(grid[r][c]) || seen[r][c]) continue; const q = [[r, c]]; seen[r][c] = true; let size = 0; while (q.length) { const [x, y] = q.pop(); size++; for (const [dr, dc] of nbrs8) { const nx = x + dr, ny = y + dc; if (nx < 0 || nx >= H || ny < 0 || ny >= W) continue; if (seen[nx][ny]) continue; if (!IS_DIGIT(grid[nx][ny])) continue; seen[nx][ny] = true; q.push([nx, ny]); } } if (size >= 2) penalty += (size - 1) * 120; } // 5) dead-end-ish penalty: letter cell surrounded by non-letters on 3+ sides const nbrs4 = [[-1, 0], [1, 0], [0, -1], [0, 1]]; for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { if (!IS_LETTER_CELL(grid[r][c])) continue; let walls = 0; for (const [dr, dc] of nbrs4) { const rr = r + dr, cc = c + dc; if (rr < 0 || rr >= H || cc < 0 || cc >= W) { walls++; continue; } const ch = grid[rr][cc]; if (!IS_LETTER_CELL(ch)) walls++; } if (walls >= 3) penalty += 400; } return penalty; } /** Helpers to ensure a clue has room for at least MIN_LEN */ function hasRoomForClue(grid, r, c, d) { const [dr, dc] = DIRS[d]; let rr = r + dr, cc = c + dc; let run = 0; while (rr >= 0 && rr < H && cc >= 0 && cc < W && IS_LETTER_CELL(grid[rr][cc]) && run < MAX_LEN) { run++; rr += dr; cc += dc; } return run >= MIN_LEN; } /** Random initialization: mostly letters, some clues with room */ function randomMask(rng) { const g = makeEmptyGrid(); const targetClues = Math.round(W * H * 0.25); // ~18 let placed = 0; let guard = 0; while (placed < targetClues && guard++ < 2000) { const r = rng.int(0, H - 1); const c = rng.int(0, W - 1); if (IS_DIGIT(g[r][c])) continue; const d = String(rng.int(1, 4)); g[r][c] = d; if (!hasRoomForClue(g, r, c, d)) { g[r][c] = "#"; continue; } placed++; } return g; } /** Mutation: toggle a few cells with a "centralized" bias */ function mutate(rng, grid) { const g = deepCopyGrid(grid); // pick a center point to bias mutations (paper-style "centralized") const cx = rng.int(0, H - 1); const cy = rng.int(0, W - 1); const steps = 4; for (let k = 0; k < steps; k++) { // gaussian-ish around (cx,cy) using sum of uniforms trick const rr = clamp(cx + (rng.int(-2, 2) + rng.int(-2, 2)), 0, H - 1); const cc = clamp(cy + (rng.int(-2, 2) + rng.int(-2, 2)), 0, W - 1); const cur = g[rr][cc]; if (IS_DIGIT(cur)) { // remove clue g[rr][cc] = "#"; } else { // add clue with room const d = String(rng.int(1, 4)); g[rr][cc] = d; if (!hasRoomForClue(g, rr, cc, d)) g[rr][cc] = "#"; } } return g; } function clamp(x, a, b) { return Math.max(a, Math.min(b, x)); } /** Angled crossover through center (closer to the paper than row-split) */ function crossover(rng, a, b) { const out = makeEmptyGrid(); const cx = (H - 1) / 2; const cy = (W - 1) / 2; const theta = rng.float() * Math.PI; // 0..pi // line normal const nx = Math.cos(theta); const ny = Math.sin(theta); for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { const x = r - cx, y = c - cy; const side = x * nx + y * ny; out[r][c] = (side >= 0) ? a[r][c] : b[r][c]; } // cleanup: if a clue has no room, turn it back into '#' for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { const ch = out[r][c]; if (IS_DIGIT(ch) && !hasRoomForClue(out, r, c, ch)) out[r][c] = "#"; } return out; } /** Hillclimber (accept improving mutations) */ function hillclimb(rng, start, dictIndex, limit) { let best = deepCopyGrid(start); let bestF = fitness(best, dictIndex); let fails = 0; while (fails < limit) { const cand = mutate(rng, best); const f = fitness(cand, dictIndex); if (f < bestF) { best = cand; bestF = f; fails = 0; } else { fails++; } } return best; } /** Similarity filter to avoid duplicates */ function similarity(a, b) { let same = 0; for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) { if (a[r][c] === b[r][c]) same++; } return same / (W * H); } /** Memetic mask generator */ function generateMask(rng, dictIndex, popSize, gens) { // init + strong hillclimb let pop = []; for (let i = 0; i < popSize; i++) { const g = randomMask(rng); pop.push(hillclimb(rng, g, dictIndex, 400)); // strong-ish } for (let gen = 0; gen < gens; gen++) { const children = []; // create children from random pairs const pairs = Math.max(popSize, Math.floor(popSize * 1.5)); for (let k = 0; k < pairs; k++) { const p1 = pop[rng.int(0, pop.length - 1)]; const p2 = pop[rng.int(0, pop.length - 1)]; const child = crossover(rng, p1, p2); children.push(hillclimb(rng, child, dictIndex, 120)); // weak repair } // merge + sort by fitness pop = pop.concat(children); pop.sort((x, y) => fitness(x, dictIndex) - fitness(y, dictIndex)); // similarity cull const next = []; for (const cand of pop) { if (next.length >= popSize) break; let ok = true; for (const kept of next) { if (similarity(cand, kept) > 0.92) { ok = false; break; } } if (ok) next.push(cand); } pop = next; } pop.sort((x, y) => fitness(x, dictIndex) - fitness(y, dictIndex)); return pop[0]; } /** CSP fill: MRV + backtracking, using dictionary index */ function fillMask(mask, dictIndex) { const grid = deepCopyGrid(mask); const slots = extractSlots(grid) .filter(s => s.len >= MIN_LEN && s.len <= MAX_LEN); // precompute slot directions for coverage sanity for (const s of slots) { const entry = dictIndex.get(s.len); if (!entry) return {ok: false, grid, clueMap: {}}; } const used = new Set(); const assigned = new Map(); // slotKey -> word function slotKey(s) { const [r, c, d] = s.clue; return `${r},${c}:${d}`; } function patternForSlot(s) { return s.cells.map(([r, c]) => { const ch = grid[r][c]; return IS_LETTER(ch) ? ch : null; }); } function candidatesForSlot(s) { const entry = dictIndex.get(s.len); const pat = patternForSlot(s); const idxs = candidateIndicesForPattern(entry, pat); // map to words; filter repeats const out = []; for (const idx of idxs) { const w = entry.words[idx]; if (!used.has(w)) out.push(w); } return out; } function placeWord(s, w) { const undo = []; for (let i = 0; i < s.cells.length; i++) { const [r, c] = s.cells[i]; const prev = grid[r][c]; const ch = w[i]; if (prev === "#") { undo.push([r, c, prev]); grid[r][c] = ch; } else if (prev !== ch) { return null; // conflict } } return undo; } function undoPlace(undo) { for (const [r, c, prev] of undo) grid[r][c] = prev; } function chooseMRV() { let best = null; let bestCands = null; for (const s of slots) { const k = slotKey(s); if (assigned.has(k)) continue; const cands = candidatesForSlot(s); if (cands.length === 0) return {slot: null, cands: null}; // dead end if (!best || cands.length < bestCands.length) { best = s; bestCands = cands; if (cands.length === 1) break; } } return {slot: best, cands: bestCands}; } function backtrack() { const {slot, cands} = chooseMRV(); if (slot === null && cands === null) return false; if (!slot) return true; // all assigned const k = slotKey(slot); // simple LCV-ish: prefer words that introduce more crosses (more fixed letters) cands.sort((a, b) => scoreWord(slot, b) - scoreWord(slot, a)); for (const w of cands) { const undo = placeWord(slot, w); if (!undo) continue; used.add(w); assigned.set(k, w); if (backtrack()) return true; assigned.delete(k); used.delete(w); undoPlace(undo); } return false; } function scoreWord(slot, word) { // higher score: matches more already-fixed letters (less disruptive) and creates constraints let score = 0; for (let i = 0; i < slot.cells.length; i++) { const [r, c] = slot.cells[i]; const prev = grid[r][c]; if (prev !== "#" && prev === word[i]) score += 2; // prefer setting letters in cells that are crossed by another slot: // approximate by counting adjacent clue cells? keep cheap: score += 0.1; } return score; } const ok = backtrack(); const clueMap = {}; for (const [k, v] of assigned.entries()) clueMap[k] = v; return {ok, grid, clueMap}; } /** Top-level: try generating mask + fill until success */ function generatePuzzle(opts) { const rng = makeRng(opts.seed); const dict = loadWords(opts.wordsPath); let best = null; for (let attempt = 1; attempt <= opts.tries; attempt++) { const mask = generateMask(rng, dict.index, opts.pop, opts.gens); const filled = fillMask(mask, dict.index); if (filled.ok) { best = {mask, filled}; break; } } return best; } // ---- main ---- (function main() { const opts = parseArgs(process.argv); const res = generatePuzzle(opts); if (!res) { console.error("Failed to generate a fillable puzzle. Try increasing --tries/--gens, or provide a richer word-list.txt."); process.exit(1); } const maskStr = gridToString(res.mask); const filledStr = gridToString(res.filled.grid); console.log("=== GENERATED MASK ==="); console.log(maskStr); console.log("\n=== FILLED PUZZLE ==="); console.log(filledStr); console.log("\n=== CLUE -> WORD ==="); // stable-ish ordering Object.keys(res.filled.clueMap) .sort((a, b) => a.localeCompare(b)) .forEach(k => console.log(`${k} = ${res.filled.clueMap[k]}`)); })();