init
This commit is contained in:
646
swedish_generator.js
Normal file
646
swedish_generator.js
Normal file
@@ -0,0 +1,646 @@
|
||||
#!/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]}`));
|
||||
})();
|
||||
Reference in New Issue
Block a user