import { useState } from "react"; import type { PuzzleType } from "../lib/api"; type Props = { puzzleType: PuzzleType; problemAscii: string; boardAscii: string; onChange: (nextAscii: string) => void; }; type BridgesOpportunity = { id: string; kind: "horizontal" | "vertical"; cells: Array<[number, number]>; }; const FLOW_FREE_COLORS = [ "#f15b5d", "#f5a623", "#f6df5a", "#7ed957", "#36cfc9", "#4b8ef7", "#7a5cff", "#ff8bd1", "#7d5a50", "#5f6a6b", ]; function splitLines(board: string): string[] { return board.replace(/\r/g, "").split("\n"); } function cloneGrid(lines: string[]): string[][] { return lines.map((line) => [...line]); } function joinGrid(grid: string[][]): string { return grid.map((row) => row.join("")).join("\n"); } function isClueChar(char: string): boolean { return /[0-9A-G]/.test(char); } function parseBridges(problemAscii: string) { const lines = splitLines(problemAscii); const opportunities: BridgesOpportunity[] = []; const cellToOpportunity = new Map(); for (let r = 0; r < lines.length; r += 1) { for (let c = 0; c < lines[r].length; c += 1) { if (!isClueChar(lines[r][c])) { continue; } let next = c + 1; while (next < lines[r].length && lines[r][next] === ".") { next += 1; } if (next < lines[r].length && isClueChar(lines[r][next]) && next > c + 1) { const opp: BridgesOpportunity = { id: `h-${r}-${c}-${next}`, kind: "horizontal", cells: [], }; for (let cell = c + 1; cell < next; cell += 1) { opp.cells.push([r, cell]); cellToOpportunity.set(`${r}:${cell}`, opp); } opportunities.push(opp); } let nextRow = r + 1; while (nextRow < lines.length && c < lines[nextRow].length && lines[nextRow][c] === ".") { nextRow += 1; } if ( nextRow < lines.length && c < lines[nextRow].length && isClueChar(lines[nextRow][c]) && nextRow > r + 1 ) { const opp: BridgesOpportunity = { id: `v-${r}-${c}-${nextRow}`, kind: "vertical", cells: [], }; for (let cell = r + 1; cell < nextRow; cell += 1) { opp.cells.push([cell, c]); cellToOpportunity.set(`${cell}:${c}`, opp); } opportunities.push(opp); } } } return { lines, opportunities, cellToOpportunity }; } function bridgesBoardFromStates( problemAscii: string, states: Record, ): string { const parsed = parseBridges(problemAscii); const grid = cloneGrid(parsed.lines); for (const opportunity of parsed.opportunities) { const level = states[opportunity.id] ?? 0; const symbol = opportunity.kind === "horizontal" ? level === 2 ? "=" : level === 1 ? "-" : "." : level === 2 ? '"' : level === 1 ? "|" : "."; for (const [r, c] of opportunity.cells) { grid[r][c] = symbol; } } return joinGrid(grid); } function bridgesStatesFromBoard( problemAscii: string, boardAscii: string, ): Record { const parsed = parseBridges(problemAscii); const boardLines = splitLines(boardAscii); const states: Record = {}; for (const opportunity of parsed.opportunities) { const [r, c] = opportunity.cells[0]; const char = boardLines[r]?.[c] ?? "."; if (opportunity.kind === "horizontal") { states[opportunity.id] = char === "=" ? 2 : char === "-" ? 1 : 0; } else { states[opportunity.id] = char === '"' ? 2 : char === "|" ? 1 : 0; } } return states; } function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit) { const parsed = parseBridges(problemAscii); const states = bridgesStatesFromBoard(problemAscii, boardAscii); function cycle(opportunity: BridgesOpportunity) { const nextStates = { ...states }; const current = nextStates[opportunity.id] ?? 0; nextStates[opportunity.id] = (((current + 1) % 3) as 0 | 1 | 2); onChange(bridgesBoardFromStates(problemAscii, nextStates)); } return (
{parsed.lines.flatMap((line, r) => [...line].map((char, c) => { const opportunity = parsed.cellToOpportunity.get(`${r}:${c}`); const boardChar = splitLines(boardAscii)[r]?.[c] ?? char; const className = isClueChar(char) ? "board-cell fixed" : opportunity ? "board-cell" : "board-cell water"; return ( ); }), )}
); } function FlowFreeEditor({ problemAscii, boardAscii, onChange }: Omit) { const problemLines = splitLines(problemAscii); const boardLines = splitLines(boardAscii); const letters = [...new Set(problemAscii.replace(/[^A-Z]/g, "").split(""))].sort(); const [activeColor, setActiveColor] = useState(letters[0] ?? "A"); function updateCell(r: number, c: number) { if (problemLines[r][c] !== ".") { setActiveColor(problemLines[r][c]); return; } const grid = cloneGrid(boardLines); grid[r][c] = grid[r][c] === activeColor ? "." : activeColor; onChange(joinGrid(grid)); } function swatchColor(letter: string) { return FLOW_FREE_COLORS[(letter.charCodeAt(0) - 65) % FLOW_FREE_COLORS.length]; } return (
{letters.map((letter) => ( ))}
{boardLines.flatMap((line, r) => [...line].map((char, c) => { const fixed = problemLines[r][c] !== "."; const letter = fixed ? problemLines[r][c] : char; return ( ); }), )}
); } function CharGridEditor({ problemAscii, boardAscii, onChange, canEdit, nextChar, }: { problemAscii: string; boardAscii: string; onChange: (nextAscii: string) => void; canEdit: (r: number, c: number, char: string) => boolean; nextChar: (r: number, c: number, current: string, problemChar: string) => string; }) { const problemLines = splitLines(problemAscii); const boardLines = splitLines(boardAscii); const width = Math.max(...boardLines.map((line) => line.length)); function updateCell(r: number, c: number) { const current = boardLines[r]?.[c] ?? " "; const problemChar = problemLines[r]?.[c] ?? " "; const grid = boardLines.map((line) => line.padEnd(width, " ").split("")); grid[r][c] = nextChar(r, c, current, problemChar); onChange(joinGrid(grid).replace(/\s+$/g, "").replace(/\n\s+$/g, "\n")); } return (
{Array.from({ length: boardLines.length }).flatMap((_, r) => Array.from({ length: width }).map((__, c) => { const problemChar = problemLines[r]?.[c] ?? " "; const current = boardLines[r]?.[c] ?? " "; const editable = canEdit(r, c, problemChar); return ( ); }), )}
); } function LoopyEditor({ problemAscii, boardAscii, onChange }: Omit) { return ( { if (char !== " ") { return false; } const isHorizontal = r > 0 && r < splitLines(problemAscii).length - 1 && r % 2 === 1 && c % 2 === 0; const isVertical = r > 0 && r < splitLines(problemAscii).length - 1 && r % 2 === 0 && c % 2 === 1; return isHorizontal || isVertical; }} nextChar={(r, c, current, problemChar) => { if (problemChar !== " ") { return current; } const isHorizontal = r % 2 === 1 && c % 2 === 0; if (isHorizontal) { return current === " " ? "-" : current === "-" ? "x" : " "; } return current === " " ? "|" : current === "|" ? "x" : " "; }} /> ); } function GalaxiesEditor({ problemAscii, boardAscii, onChange }: Omit) { return ( { if (char !== " ") { return false; } const isHorizontal = r % 2 === 0 && c % 2 === 1; const isVertical = r % 2 === 1 && c % 2 === 0; return isHorizontal || isVertical; }} nextChar={(r, _c, current, _problemChar) => { const isHorizontal = r % 2 === 0; if (isHorizontal) { return current === "-" ? " " : "-"; } return current === "|" ? " " : "|"; }} /> ); } function parsePattern(boardAscii: string) { const lines = splitLines(boardAscii); const firstContentIndex = lines.findIndex((line) => line.includes("|")); const topLines = lines.slice(0, firstContentIndex); const rows: Array<{ prefix: string; cells: string[]; border: string }> = []; for (let idx = firstContentIndex; idx < lines.length; idx += 2) { const content = lines[idx]; const border = lines[idx + 1]; if (!content || !border) { break; } const firstBar = content.indexOf("|"); const prefix = content.slice(0, firstBar); const parts = content.slice(firstBar).split("|").slice(1, -1); rows.push({ prefix, cells: parts, border }); } return { topLines, rows }; } function patternToAscii(template: ReturnType): string { const lines = [...template.topLines]; for (const row of template.rows) { lines.push(`${row.prefix}|${row.cells.join("|")}|`); lines.push(row.border); } return lines.join("\n"); } function PatternEditor({ boardAscii, onChange }: Omit & { problemAscii: string }) { const template = parsePattern(boardAscii); function cycle(rowIndex: number, cellIndex: number) { const next = parsePattern(boardAscii); const current = next.rows[rowIndex].cells[cellIndex]; next.rows[rowIndex].cells[cellIndex] = current === " " ? "##" : current === "##" ? ".." : " "; onChange(patternToAscii(next)); } return (
{template.topLines.join("\n")}
{template.rows.map((row, rowIndex) => (
{row.prefix.trim()}
{row.cells.map((cell, cellIndex) => ( ))}
))}
); } function parseUndead(boardAscii: string) { const lines = splitLines(boardAscii); const header = lines[0] ?? ""; const topLine = lines[2] ?? ""; const bottomLine = lines.at(-1) ?? ""; const rows = lines.slice(3, -1).map((line) => { const tokens = line.trim().split(/\s+/); return { left: tokens[0] ?? "", cells: tokens.slice(1, -1), right: tokens.at(-1) ?? "", }; }); return { header, topLine, bottomLine, rows }; } function undeadToAscii(template: ReturnType): string { const lines = [template.header, "", template.topLine]; for (const row of template.rows) { lines.push(` ${row.left} ${row.cells.join(" ")} ${row.right}`); } lines.push(template.bottomLine); return lines.join("\n"); } function UndeadEditor({ boardAscii, onChange }: Omit & { problemAscii: string }) { const template = parseUndead(boardAscii); function cycle(rowIndex: number, cellIndex: number) { const current = template.rows[rowIndex].cells[cellIndex]; if (current === "/" || current === "\\") { return; } const next = parseUndead(boardAscii); next.rows[rowIndex].cells[cellIndex] = current === "." ? "G" : current === "G" ? "V" : current === "V" ? "Z" : "."; onChange(undeadToAscii(next)); } return (
{`${template.header}\n\n${template.topLine}\n${template.bottomLine}`}
{template.rows.flatMap((row, rowIndex) => row.cells.map((cell, cellIndex) => { const fixedMirror = cell === "/" || cell === "\\"; return ( ); }), )}
); } export function PuzzleEditor(props: Props) { if (props.puzzleType === "bridges") { return ; } if (props.puzzleType === "flow_free") { return ; } if (props.puzzleType === "galaxies") { return ; } if (props.puzzleType === "loopy") { return ; } if (props.puzzleType === "pattern") { return ; } if (props.puzzleType === "undead") { return ; } return null; }