// 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; you’ll 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};