Files
puzzle-generator/export_format.js
2025-12-19 14:02:07 +01:00

192 lines
5.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// export_format.js
"use strict";
const DIRS = {
"1": [-1, 0], // up
"2": [0, 1], // right
"3": [1, 0], // down
"4": [0, -1], // left
};
const isDigit = (ch) => ch >= "1" && ch <= "4";
const isLetter = (ch) => ch >= "A" && ch <= "Z";
function toGrid2D(grid) {
if (Array.isArray(grid) && typeof grid[0] === "string") return grid.map(r => r.split(""));
return grid; // assume 2D char array
}
function inBounds(H, W, r, c) {
return r >= 0 && r < H && c >= 0 && c < W;
}
/**
* Extract a word run for a clue cell at (r,c) with direction digit d.
* Returns canonical representation where:
* - direction is only "horizontal"(right) or "vertical"(down)
* - startRow/startCol is the first letter cell in that canonical direction
* - arrowRow/arrowCol is immediately before the start (left or above)
* - word is read from grid in canonical order (start -> end)
*/
function extractPlacedFromClue(g, r, c, d, maxLen = 8, minLen = 2) {
const H = g.length, W = g[0].length;
const [dr, dc] = DIRS[d];
// collect letter cells in the ORIGINAL direction away from the clue
const cells = [];
let rr = r + dr, cc = c + dc;
while (inBounds(H, W, rr, cc) && isLetter(g[rr][cc]) && cells.length < maxLen) {
cells.push([rr, cc]);
rr += dr;
cc += dc;
}
if (cells.length < minLen) return null;
// Canonicalize so we always output right/down runs
// If original was right (2) or down (3): start is first cell, arrow is clue cell
// If original was left (4): start is the farthest-left cell, arrow is one cell left of start
// If original was up (1): start is the topmost cell, arrow is one cell above start
let startRow, startCol, arrowRow, arrowCol, direction;
if (d === "2") { // right
direction = "horizontal";
[startRow, startCol] = cells[0];
arrowRow = r;
arrowCol = c; // clue cell is before start
} else if (d === "3") { // down
direction = "vertical";
[startRow, startCol] = cells[0];
arrowRow = r;
arrowCol = c;
} else if (d === "4") { // left => canonical right
direction = "horizontal";
// farthest left is last in cells list (because we walked left)
[startRow, startCol] = cells[cells.length - 1];
arrowRow = startRow;
arrowCol = startCol - 1;
} else if (d === "1") { // up => canonical down
direction = "vertical";
[startRow, startCol] = cells[cells.length - 1]; // topmost
arrowRow = startRow - 1;
arrowCol = startCol;
} else {
return null;
}
// Read the word from the grid in canonical order (right or down)
const wordChars = [];
if (direction === "horizontal") {
for (let i = 0; i < cells.length; i++) {
const ch = (inBounds(H, W, startRow, startCol + i) ? g[startRow][startCol + i] : "#");
if (!isLetter(ch)) break;
wordChars.push(ch);
}
} else {
for (let i = 0; i < cells.length; i++) {
const ch = (inBounds(H, W, startRow + i, startCol) ? g[startRow + i][startCol] : "#");
if (!isLetter(ch)) break;
wordChars.push(ch);
}
}
const word = wordChars.join("");
if (word.length < minLen || word.length > maxLen) return null;
return {
word,
clue: word, // placeholder; youll replace later
startRow,
startCol,
direction,
answer: word,
arrowRow,
arrowCol,
// For cropping:
_cells: direction === "horizontal"
? Array.from({length: word.length}, (_, i) => [startRow, startCol + i])
: Array.from({length: word.length}, (_, i) => [startRow + i, startCol]),
_arrow: [arrowRow, arrowCol],
};
}
/**
* Transform your generator output into the JSON format you showed.
* @param {Object} puz - { grid: string[]|char[][], clueMap?: object }
*/
function exportFormatFromFilled(puz, difficulty = 1, rewards = {coins: 50, stars: 2, hints: 1}) {
const g = toGrid2D(puz.grid);
const H = g.length, W = g[0].length;
// 1) extract "placed" list from all clue digits in the filled grid
const placed = [];
const seen = new Set(); // avoid duplicates by start+dir
for (let r = 0; r < H; r++) {
for (let c = 0; c < W; c++) {
const ch = g[r][c];
if (!isDigit(ch)) continue;
const p = extractPlacedFromClue(g, r, c, ch, 8, 2);
if (!p) continue;
const key = `${p.startRow},${p.startCol}:${p.direction}:${p.word}`;
if (seen.has(key)) continue;
seen.add(key);
placed.push(p);
}
}
if (placed.length === 0) {
return {gridv2: g.map(row => row.map(ch => (isLetter(ch) ? ch : "#")).join("")), words: [], difficulty, rewards};
}
// 2) compute bounding box around all word cells + arrow cells, with 1-cell margin
const allCells = [];
for (const p of placed) {
for (const [rr, cc] of p._cells) allCells.push([rr, cc]);
allCells.push(p._arrow);
}
let minR = Math.min(...allCells.map(([r]) => r)) - 1;
let minC = Math.min(...allCells.map(([, c]) => c)) - 1;
let maxR = Math.max(...allCells.map(([r]) => r)) + 1;
let maxC = Math.max(...allCells.map(([, c]) => c)) + 1;
// 3) build a map of only the used letter cells (so everything else becomes '#')
const letterAt = new Map();
for (const p of placed) {
for (const [rr, cc] of p._cells) {
if (inBounds(H, W, rr, cc) && isLetter(g[rr][cc])) {
letterAt.set(`${rr},${cc}`, g[rr][cc]);
}
}
}
// 4) render gridv2
const gridv2 = [];
for (let r = minR; r <= maxR; r++) {
let row = "";
for (let c = minC; c <= maxC; c++) {
const ch = letterAt.get(`${r},${c}`);
row += ch ? ch : "#";
}
gridv2.push(row);
}
// 5) words output with cropped coordinates
const words_out = placed.map(p => ({
word: p.word,
clue: p.clue, // currently word itself
startRow: p.startRow - minR,
startCol: p.startCol - minC,
direction: p.direction,
answer: p.word,
arrowRow: p.arrowRow - minR,
arrowCol: p.arrowCol - minC,
}));
return {gridv2, words: words_out, difficulty, rewards};
}
module.exports = {exportFormatFromFilled};