Spaces:
Running
Running
| import { useEffect, useRef, 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]>; | |
| from: [number, number]; | |
| to: [number, number]; | |
| }; | |
| type LoopyEdge = { | |
| id: string; | |
| kind: "horizontal" | "vertical"; | |
| row: number; | |
| col: number; | |
| boardRow: number; | |
| boardCol: number; | |
| }; | |
| type GalaxyBoundary = { | |
| id: string; | |
| kind: "horizontal" | "vertical"; | |
| row: number; | |
| col: number; | |
| boardRow: number; | |
| boardCol: number; | |
| }; | |
| type GalaxyDot = { | |
| rowCoord: number; | |
| colCoord: number; | |
| }; | |
| type PatternRow = { | |
| prefix: string; | |
| cells: string[]; | |
| border: string; | |
| }; | |
| type UndeadRow = { | |
| left: string; | |
| cells: string[]; | |
| right: string; | |
| }; | |
| 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 padLines(lines: string[], width: number): string[] { | |
| return lines.map((line) => line.padEnd(width, " ")); | |
| } | |
| function isClueChar(char: string): boolean { | |
| return /[0-9A-G]/.test(char); | |
| } | |
| function addBridgeOpportunityForCell( | |
| cellToOpportunities: Map<string, BridgesOpportunity[]>, | |
| r: number, | |
| c: number, | |
| opportunity: BridgesOpportunity, | |
| ) { | |
| const key = `${r}:${c}`; | |
| cellToOpportunities.set(key, [...(cellToOpportunities.get(key) ?? []), opportunity]); | |
| } | |
| function bridgeSymbol(opportunity: BridgesOpportunity, level: 1 | 2): string { | |
| return opportunity.kind === "horizontal" ? (level === 2 ? "=" : "-") : level === 2 ? '"' : "|"; | |
| } | |
| function bridgeLevelFromChar(opportunity: BridgesOpportunity, char: string): 0 | 1 | 2 { | |
| if (opportunity.kind === "horizontal") { | |
| return char === "=" ? 2 : char === "-" ? 1 : 0; | |
| } | |
| return char === '"' ? 2 : char === "|" ? 1 : 0; | |
| } | |
| function bridgeOpportunitiesShareCell(a: BridgesOpportunity, b: BridgesOpportunity): boolean { | |
| const bCells = new Set(b.cells.map(([r, c]) => `${r}:${c}`)); | |
| return a.cells.some(([r, c]) => bCells.has(`${r}:${c}`)); | |
| } | |
| function parseBridges(problemAscii: string) { | |
| const lines = splitLines(problemAscii); | |
| const opportunities: BridgesOpportunity[] = []; | |
| const cellToOpportunities = new Map<string, BridgesOpportunity[]>(); | |
| 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: [], | |
| from: [r, c], | |
| to: [r, next], | |
| }; | |
| for (let cell = c + 1; cell < next; cell += 1) { | |
| opp.cells.push([r, cell]); | |
| addBridgeOpportunityForCell(cellToOpportunities, 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: [], | |
| from: [r, c], | |
| to: [nextRow, c], | |
| }; | |
| for (let cell = r + 1; cell < nextRow; cell += 1) { | |
| opp.cells.push([cell, c]); | |
| addBridgeOpportunityForCell(cellToOpportunities, cell, c, opp); | |
| } | |
| opportunities.push(opp); | |
| } | |
| } | |
| } | |
| return { lines, opportunities, cellToOpportunities }; | |
| } | |
| function bridgeLevelFromCells(opportunity: BridgesOpportunity, boardLines: string[]): 0 | 1 | 2 { | |
| let level: 0 | 1 | 2 = 0; | |
| for (const [r, c] of opportunity.cells) { | |
| const cellLevel = bridgeLevelFromChar(opportunity, boardLines[r]?.[c] ?? "."); | |
| if (cellLevel === 2) { | |
| return 2; | |
| } | |
| if (cellLevel === 1) { | |
| level = 1; | |
| } | |
| } | |
| return level; | |
| } | |
| function sortedBridgeCandidates(opportunities: BridgesOpportunity[]): BridgesOpportunity[] { | |
| return [...opportunities].sort((a, b) => { | |
| if (a.kind !== b.kind) { | |
| return a.kind === "horizontal" ? -1 : 1; | |
| } | |
| return a.id.localeCompare(b.id); | |
| }); | |
| } | |
| function clearBridgeConflicts( | |
| parsed: ReturnType<typeof parseBridges>, | |
| states: Record<string, 0 | 1 | 2>, | |
| selected: BridgesOpportunity, | |
| ) { | |
| for (const opportunity of parsed.opportunities) { | |
| if (opportunity.id !== selected.id && bridgeOpportunitiesShareCell(opportunity, selected)) { | |
| states[opportunity.id] = 0; | |
| } | |
| } | |
| } | |
| function bridgeClickCountForOpportunity( | |
| opportunities: BridgesOpportunity[], | |
| opportunity: BridgesOpportunity, | |
| level: 0 | 1 | 2, | |
| ): number { | |
| if (level === 0 || opportunities.length <= 1) { | |
| return level; | |
| } | |
| const index = sortedBridgeCandidates(opportunities).findIndex( | |
| (candidate) => candidate.id === opportunity.id, | |
| ); | |
| return index === -1 ? level : index * 2 + level; | |
| } | |
| function cycleBridgesOpportunities( | |
| parsed: ReturnType<typeof parseBridges>, | |
| states: Record<string, 0 | 1 | 2>, | |
| opportunities: BridgesOpportunity[], | |
| ): Record<string, 0 | 1 | 2> { | |
| if (opportunities.length === 0) { | |
| return states; | |
| } | |
| const nextStates = { ...states }; | |
| const candidates = sortedBridgeCandidates(opportunities); | |
| if (candidates.length === 1) { | |
| const opportunity = candidates[0]; | |
| const level = (((nextStates[opportunity.id] ?? 0) + 1) % 3) as 0 | 1 | 2; | |
| nextStates[opportunity.id] = level; | |
| if (level > 0) { | |
| clearBridgeConflicts(parsed, nextStates, opportunity); | |
| } | |
| return nextStates; | |
| } | |
| const sequence: Array<{ opportunity: BridgesOpportunity; level: 1 | 2 } | null> = [ | |
| ...candidates.flatMap((opportunity) => [ | |
| { opportunity, level: 1 as const }, | |
| { opportunity, level: 2 as const }, | |
| ]), | |
| null, | |
| ]; | |
| const currentIndex = sequence.findIndex((entry) => | |
| candidates.every((opportunity) => { | |
| const expected = entry?.opportunity.id === opportunity.id ? entry.level : 0; | |
| return (nextStates[opportunity.id] ?? 0) === expected; | |
| }), | |
| ); | |
| const fallbackIndex = sequence.findIndex( | |
| (entry) => entry !== null && (nextStates[entry.opportunity.id] ?? 0) === entry.level, | |
| ); | |
| const safeCurrentIndex = | |
| currentIndex === -1 ? (fallbackIndex === -1 ? sequence.length - 1 : fallbackIndex) : currentIndex; | |
| const nextEntry = sequence[(safeCurrentIndex + 1) % sequence.length]; | |
| for (const opportunity of candidates) { | |
| nextStates[opportunity.id] = 0; | |
| } | |
| if (nextEntry) { | |
| nextStates[nextEntry.opportunity.id] = nextEntry.level; | |
| clearBridgeConflicts(parsed, nextStates, nextEntry.opportunity); | |
| } | |
| return nextStates; | |
| } | |
| function bridgesBoardFromStates( | |
| problemAscii: string, | |
| states: Record<string, 0 | 1 | 2>, | |
| ): string { | |
| const parsed = parseBridges(problemAscii); | |
| const grid = cloneGrid(parsed.lines); | |
| for (const opportunity of parsed.opportunities) { | |
| const level = states[opportunity.id] ?? 0; | |
| // The grid starts as a fresh copy of the problem (non-clue cells are "."), | |
| // so a level-0 opportunity has nothing to add. Skip it: writing "." here | |
| // would clobber the crossing cell of an *active* perpendicular bridge that | |
| // shares this cell (e.g. a vertical bridge passing through a cell also owned | |
| // by an unused horizontal opportunity), leaving a gap in the drawn bridge. | |
| if (level === 0) { | |
| continue; | |
| } | |
| const symbol = bridgeSymbol(opportunity, level); | |
| for (const [r, c] of opportunity.cells) { | |
| grid[r][c] = symbol; | |
| } | |
| } | |
| return joinGrid(grid); | |
| } | |
| function bridgesStatesFromBoard( | |
| problemAscii: string, | |
| boardAscii: string, | |
| ): Record<string, 0 | 1 | 2> { | |
| const parsed = parseBridges(problemAscii); | |
| const boardLines = splitLines(boardAscii); | |
| const states: Record<string, 0 | 1 | 2> = {}; | |
| for (const opportunity of parsed.opportunities) { | |
| states[opportunity.id] = bridgeLevelFromCells(opportunity, boardLines); | |
| } | |
| return states; | |
| } | |
| function parseLoopy(problemAscii: string) { | |
| const lines = splitLines(problemAscii); | |
| const rows = Math.max(0, Math.floor((lines.length - 3) / 2)); | |
| const cols = Math.max(0, Math.floor(((lines[0]?.length ?? 0) - 3) / 2)); | |
| const clues = new Map<string, string>(); | |
| const horizontalEdges: LoopyEdge[] = []; | |
| const verticalEdges: LoopyEdge[] = []; | |
| for (let r = 0; r < rows; r += 1) { | |
| for (let c = 0; c < cols; c += 1) { | |
| const char = lines[2 + 2 * r]?.[2 + 2 * c] ?? " "; | |
| if (/\d/.test(char)) { | |
| clues.set(`${r}:${c}`, char); | |
| } | |
| } | |
| } | |
| for (let r = 0; r <= rows; r += 1) { | |
| for (let c = 0; c < cols; c += 1) { | |
| horizontalEdges.push({ | |
| id: `h-${r}-${c}`, | |
| kind: "horizontal", | |
| row: r, | |
| col: c, | |
| boardRow: 1 + 2 * r, | |
| boardCol: 2 + 2 * c, | |
| }); | |
| } | |
| } | |
| for (let r = 0; r < rows; r += 1) { | |
| for (let c = 0; c <= cols; c += 1) { | |
| verticalEdges.push({ | |
| id: `v-${r}-${c}`, | |
| kind: "vertical", | |
| row: r, | |
| col: c, | |
| boardRow: 2 + 2 * r, | |
| boardCol: 1 + 2 * c, | |
| }); | |
| } | |
| } | |
| return { lines, rows, cols, clues, horizontalEdges, verticalEdges }; | |
| } | |
| function loopyEdgeStates( | |
| problemAscii: string, | |
| boardAscii: string, | |
| ): Record<string, "unknown" | "line" | "blocked"> { | |
| const puzzle = parseLoopy(problemAscii); | |
| const width = puzzle.lines[0]?.length ?? 0; | |
| const boardLines = padLines(splitLines(boardAscii), width); | |
| const states: Record<string, "unknown" | "line" | "blocked"> = {}; | |
| for (const edge of [...puzzle.horizontalEdges, ...puzzle.verticalEdges]) { | |
| const char = boardLines[edge.boardRow]?.[edge.boardCol] ?? " "; | |
| states[edge.id] = char === "-" || char === "|" ? "line" : char === "x" ? "blocked" : "unknown"; | |
| } | |
| return states; | |
| } | |
| function loopyBoardFromStates( | |
| problemAscii: string, | |
| states: Record<string, "unknown" | "line" | "blocked">, | |
| ): string { | |
| const puzzle = parseLoopy(problemAscii); | |
| const width = puzzle.lines[0]?.length ?? 0; | |
| const grid = cloneGrid(padLines(puzzle.lines, width)); | |
| for (const edge of puzzle.horizontalEdges) { | |
| const state = states[edge.id] ?? "blocked"; | |
| grid[edge.boardRow][edge.boardCol] = state === "line" ? "-" : state === "blocked" ? "x" : " "; | |
| } | |
| for (const edge of puzzle.verticalEdges) { | |
| const state = states[edge.id] ?? "blocked"; | |
| grid[edge.boardRow][edge.boardCol] = state === "line" ? "|" : state === "blocked" ? "x" : " "; | |
| } | |
| return joinGrid(grid); | |
| } | |
| function parseGalaxies(problemAscii: string) { | |
| const lines = splitLines(problemAscii); | |
| const rows = Math.max(0, Math.floor(((lines.length ?? 0) - 1) / 2)); | |
| const cols = Math.max(0, Math.floor(((lines[0]?.length ?? 0) - 1) / 2)); | |
| const horizontalBoundaries: GalaxyBoundary[] = []; | |
| const verticalBoundaries: GalaxyBoundary[] = []; | |
| const dots: GalaxyDot[] = []; | |
| for (let boardRow = 0; boardRow < lines.length; boardRow += 1) { | |
| const line = lines[boardRow] ?? ""; | |
| for (let boardCol = 0; boardCol < line.length; boardCol += 1) { | |
| if (line[boardCol] === "o") { | |
| dots.push({ rowCoord: boardRow, colCoord: boardCol }); | |
| } | |
| } | |
| } | |
| for (let r = 1; r < rows; r += 1) { | |
| for (let c = 0; c < cols; c += 1) { | |
| if ((lines[2 * r]?.[1 + 2 * c] ?? " ") === "o") { | |
| continue; | |
| } | |
| horizontalBoundaries.push({ | |
| id: `h-${r}-${c}`, | |
| kind: "horizontal", | |
| row: r, | |
| col: c, | |
| boardRow: 2 * r, | |
| boardCol: 1 + 2 * c, | |
| }); | |
| } | |
| } | |
| for (let r = 0; r < rows; r += 1) { | |
| for (let c = 1; c < cols; c += 1) { | |
| if ((lines[1 + 2 * r]?.[2 * c] ?? " ") === "o") { | |
| continue; | |
| } | |
| verticalBoundaries.push({ | |
| id: `v-${r}-${c}`, | |
| kind: "vertical", | |
| row: r, | |
| col: c, | |
| boardRow: 1 + 2 * r, | |
| boardCol: 2 * c, | |
| }); | |
| } | |
| } | |
| return { lines, rows, cols, horizontalBoundaries, verticalBoundaries, dots }; | |
| } | |
| function galaxiesBoundaryStates( | |
| problemAscii: string, | |
| boardAscii: string, | |
| ): Record<string, boolean> { | |
| const puzzle = parseGalaxies(problemAscii); | |
| const width = puzzle.lines[0]?.length ?? 0; | |
| const boardLines = padLines(splitLines(boardAscii), width); | |
| const states: Record<string, boolean> = {}; | |
| for (const boundary of [...puzzle.horizontalBoundaries, ...puzzle.verticalBoundaries]) { | |
| const char = boardLines[boundary.boardRow]?.[boundary.boardCol] ?? " "; | |
| states[boundary.id] = char === "-" || char === "|"; | |
| } | |
| return states; | |
| } | |
| function galaxiesBoardFromStates(problemAscii: string, states: Record<string, boolean>): string { | |
| const puzzle = parseGalaxies(problemAscii); | |
| const width = puzzle.lines[0]?.length ?? 0; | |
| const grid = cloneGrid(padLines(puzzle.lines, width)); | |
| for (const boundary of puzzle.horizontalBoundaries) { | |
| grid[boundary.boardRow][boundary.boardCol] = states[boundary.id] ? "-" : " "; | |
| } | |
| for (const boundary of puzzle.verticalBoundaries) { | |
| grid[boundary.boardRow][boundary.boardCol] = states[boundary.id] ? "|" : " "; | |
| } | |
| return joinGrid(grid); | |
| } | |
| function parsePattern(boardAscii: string) { | |
| const lines = splitLines(boardAscii); | |
| const firstContentIndex = lines.findIndex((line) => line.includes("|")); | |
| const topLines = lines.slice(0, firstContentIndex); | |
| const rows: PatternRow[] = []; | |
| 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 normalizePatternAscii(boardAscii: string) { | |
| const template = parsePattern(boardAscii); | |
| let changed = false; | |
| for (const row of template.rows) { | |
| for (let index = 0; index < row.cells.length; index += 1) { | |
| if (row.cells[index] === " ") { | |
| row.cells[index] = ".."; | |
| changed = true; | |
| } | |
| } | |
| } | |
| return changed ? patternToAscii(template) : boardAscii; | |
| } | |
| function parsePatternColumnClues(template: ReturnType<typeof parsePattern>): string[][] { | |
| const topBorder = template.topLines.find((line) => line.includes("+")); | |
| const clueLines = template.topLines.filter((line) => !line.includes("+")); | |
| const firstRow = template.rows[0]; | |
| if (!topBorder || !firstRow) { | |
| return []; | |
| } | |
| const firstBar = topBorder.indexOf("+"); | |
| const columnCount = firstRow.cells.length; | |
| const columns: string[][] = Array.from({ length: columnCount }, () => []); | |
| for (const line of clueLines) { | |
| for (let col = 0; col < columnCount; col += 1) { | |
| const cellSlice = line.slice(firstBar + 1 + col * 3, firstBar + 3 + col * 3).trim(); | |
| if (cellSlice) { | |
| columns[col].push(cellSlice); | |
| } | |
| } | |
| } | |
| return columns; | |
| } | |
| function patternToAscii(template: ReturnType<typeof parsePattern>): 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 parseUndead(boardAscii: string) { | |
| const lines = splitLines(boardAscii); | |
| const header = lines[0] ?? ""; | |
| const topLine = lines[2] ?? ""; | |
| const bottomLine = lines.at(-1) ?? ""; | |
| const rows: UndeadRow[] = 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<typeof parseUndead>): 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 legendColor(letter: string) { | |
| return FLOW_FREE_COLORS[(letter.charCodeAt(0) - 65) % FLOW_FREE_COLORS.length]; | |
| } | |
| function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| const parsed = parseBridges(problemAscii); | |
| const [states, setStates] = useState(() => bridgesStatesFromBoard(problemAscii, boardAscii)); | |
| const lastEmittedBoard = useRef<string | null>(null); | |
| const renderedBoardAscii = bridgesBoardFromStates(problemAscii, states); | |
| const boardLines = splitLines(renderedBoardAscii); | |
| useEffect(() => { | |
| if (lastEmittedBoard.current === boardAscii) { | |
| return; | |
| } | |
| setStates(bridgesStatesFromBoard(problemAscii, boardAscii)); | |
| lastEmittedBoard.current = null; | |
| }, [problemAscii, boardAscii]); | |
| function cycle(opportunities: BridgesOpportunity[]) { | |
| const nextStates = cycleBridgesOpportunities(parsed, states, opportunities); | |
| const nextBoard = bridgesBoardFromStates(problemAscii, nextStates); | |
| setStates(nextStates); | |
| lastEmittedBoard.current = nextBoard; | |
| onChange(nextBoard); | |
| } | |
| return ( | |
| <div className="editor-stack"> | |
| <div className="puzzle-note">Click a route between islands to cycle no bridge, single bridge, and double bridge.</div> | |
| <div | |
| className="board-grid bridges-grid" | |
| style={{ gridTemplateColumns: `repeat(${parsed.lines[0]?.length ?? 0}, 40px)` }} | |
| > | |
| {parsed.lines.flatMap((line, r) => | |
| [...line].map((char, c) => { | |
| const opportunities = parsed.cellToOpportunities.get(`${r}:${c}`) ?? []; | |
| const boardChar = boardLines[r]?.[c] ?? char; | |
| const className = isClueChar(char) | |
| ? "board-cell fixed island-cell" | |
| : opportunities.length > 0 | |
| ? "board-cell bridge-cell" | |
| : "board-cell water"; | |
| const display = | |
| boardChar === "." ? "" : boardChar === '"' ? "‖" : boardChar === "=" ? "═" : boardChar; | |
| return ( | |
| <button | |
| key={`${r}-${c}`} | |
| type="button" | |
| className={className} | |
| onClick={() => cycle(opportunities)} | |
| disabled={opportunities.length === 0} | |
| > | |
| {isClueChar(char) ? char : display} | |
| </button> | |
| ); | |
| }), | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function FlowFreeEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| 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)); | |
| } | |
| return ( | |
| <div className="editor-stack"> | |
| <div className="puzzle-note">Pick a color, then fill open cells. Clicking an endpoint picks that color.</div> | |
| <div className="palette"> | |
| {letters.map((letter) => ( | |
| <button | |
| key={letter} | |
| type="button" | |
| className={activeColor === letter ? "active" : ""} | |
| style={{ background: legendColor(letter) }} | |
| onClick={() => setActiveColor(letter)} | |
| > | |
| {letter} | |
| </button> | |
| ))} | |
| <button | |
| type="button" | |
| className={activeColor === "." ? "active erase-swatch" : "erase-swatch"} | |
| onClick={() => setActiveColor(".")} | |
| > | |
| Erase | |
| </button> | |
| </div> | |
| <div | |
| className="board-grid flow-grid" | |
| style={{ gridTemplateColumns: `repeat(${problemLines[0]?.length ?? 0}, 44px)` }} | |
| > | |
| {boardLines.flatMap((line, r) => | |
| [...line].map((char, c) => { | |
| const fixed = problemLines[r][c] !== "."; | |
| const letter = fixed ? problemLines[r][c] : char; | |
| const color = letter === "." ? "transparent" : legendColor(letter); | |
| return ( | |
| <button | |
| key={`${r}-${c}`} | |
| type="button" | |
| className={`board-cell flow-cell ${fixed ? "fixed endpoint-cell" : "water"}`} | |
| style={{ background: letter === "." ? undefined : `${color}${fixed ? "" : "d8"}` }} | |
| onClick={() => updateCell(r, c)} | |
| > | |
| {letter === "." ? "" : letter} | |
| </button> | |
| ); | |
| }), | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function LoopyEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| const puzzle = parseLoopy(problemAscii); | |
| const states = loopyEdgeStates(problemAscii, boardAscii); | |
| const cell = 52; | |
| const margin = 28; | |
| const width = margin * 2 + puzzle.cols * cell; | |
| const height = margin * 2 + puzzle.rows * cell; | |
| function cycle(edge: LoopyEdge) { | |
| const current = states[edge.id] ?? "unknown"; | |
| const next = current === "line" ? "blocked" : "line"; | |
| onChange( | |
| loopyBoardFromStates(problemAscii, { | |
| ...states, | |
| [edge.id]: next, | |
| }), | |
| ); | |
| } | |
| return ( | |
| <div className="editor-stack"> | |
| <div className="puzzle-note">Click any segment, including the outer border, to toggle between part of the loop and definitely not part of it.</div> | |
| <div className="svg-board-shell"> | |
| <svg className="svg-board" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Loopy board"> | |
| {Array.from({ length: puzzle.rows + 1 }).map((_, row) => | |
| Array.from({ length: puzzle.cols + 1 }).map((__, col) => ( | |
| <circle | |
| key={`dot-${row}-${col}`} | |
| cx={margin + col * cell} | |
| cy={margin + row * cell} | |
| r={4.25} | |
| className="svg-vertex" | |
| /> | |
| )), | |
| )} | |
| {Array.from({ length: puzzle.rows }).map((_, row) => | |
| Array.from({ length: puzzle.cols }).map((__, col) => ( | |
| <g key={`clue-${row}-${col}`}> | |
| <rect | |
| x={margin + col * cell + 8} | |
| y={margin + row * cell + 8} | |
| width={cell - 16} | |
| height={cell - 16} | |
| rx={12} | |
| className="svg-cell-bg" | |
| /> | |
| {puzzle.clues.has(`${row}:${col}`) ? ( | |
| <text | |
| x={margin + col * cell + cell / 2} | |
| y={margin + row * cell + cell / 2 + 8} | |
| className="svg-clue-text" | |
| textAnchor="middle" | |
| > | |
| {puzzle.clues.get(`${row}:${col}`)} | |
| </text> | |
| ) : null} | |
| </g> | |
| )), | |
| )} | |
| {puzzle.horizontalEdges.map((edge) => { | |
| const x1 = margin + edge.col * cell; | |
| const y = margin + edge.row * cell; | |
| const x2 = x1 + cell; | |
| const state = states[edge.id] ?? "unknown"; | |
| return ( | |
| <g key={edge.id} className="svg-hit-target" onClick={() => cycle(edge)}> | |
| <line x1={x1} y1={y} x2={x2} y2={y} className="svg-hit-line" /> | |
| {state === "line" ? <line x1={x1} y1={y} x2={x2} y2={y} className="svg-line active" /> : null} | |
| {state === "unknown" ? <line x1={x1} y1={y} x2={x2} y2={y} className="svg-line ghost" /> : null} | |
| {state === "blocked" ? ( | |
| <text x={(x1 + x2) / 2} y={y + 6} className="svg-xmark" textAnchor="middle"> | |
| × | |
| </text> | |
| ) : null} | |
| </g> | |
| ); | |
| })} | |
| {puzzle.verticalEdges.map((edge) => { | |
| const x = margin + edge.col * cell; | |
| const y1 = margin + edge.row * cell; | |
| const y2 = y1 + cell; | |
| const state = states[edge.id] ?? "unknown"; | |
| return ( | |
| <g key={edge.id} className="svg-hit-target" onClick={() => cycle(edge)}> | |
| <line x1={x} y1={y1} x2={x} y2={y2} className="svg-hit-line" /> | |
| {state === "line" ? <line x1={x} y1={y1} x2={x} y2={y2} className="svg-line active" /> : null} | |
| {state === "unknown" ? <line x1={x} y1={y1} x2={x} y2={y2} className="svg-line ghost" /> : null} | |
| {state === "blocked" ? ( | |
| <text x={x} y={(y1 + y2) / 2 + 6} className="svg-xmark" textAnchor="middle"> | |
| × | |
| </text> | |
| ) : null} | |
| </g> | |
| ); | |
| })} | |
| </svg> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function GalaxiesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| const puzzle = parseGalaxies(problemAscii); | |
| const states = galaxiesBoundaryStates(problemAscii, boardAscii); | |
| const cell = 56; | |
| const half = cell / 2; | |
| const margin = 28; | |
| const width = margin * 2 + puzzle.cols * cell; | |
| const height = margin * 2 + puzzle.rows * cell; | |
| function toggle(boundary: GalaxyBoundary) { | |
| onChange( | |
| galaxiesBoardFromStates(problemAscii, { | |
| ...states, | |
| [boundary.id]: !states[boundary.id], | |
| }), | |
| ); | |
| } | |
| return ( | |
| <div className="editor-stack"> | |
| <div className="puzzle-note">Click an interior wall segment to add or remove it. Dots mark each galaxy center.</div> | |
| <div className="svg-board-shell"> | |
| <svg className="svg-board" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Galaxies board"> | |
| {Array.from({ length: puzzle.rows }).map((_, row) => | |
| Array.from({ length: puzzle.cols }).map((__, col) => ( | |
| <rect | |
| key={`cell-${row}-${col}`} | |
| x={margin + col * cell} | |
| y={margin + row * cell} | |
| width={cell} | |
| height={cell} | |
| rx={14} | |
| className="galaxy-cell-bg" | |
| /> | |
| )), | |
| )} | |
| {Array.from({ length: puzzle.rows + 1 }).map((_, row) => | |
| Array.from({ length: puzzle.cols + 1 }).map((__, col) => ( | |
| <circle | |
| key={`vertex-${row}-${col}`} | |
| cx={margin + col * cell} | |
| cy={margin + row * cell} | |
| r={3.6} | |
| className="svg-vertex" | |
| /> | |
| )), | |
| )} | |
| {Array.from({ length: puzzle.rows + 1 }).map((_, row) => | |
| Array.from({ length: puzzle.cols }).map((__, col) => ( | |
| <line | |
| key={`outer-h-${row}-${col}`} | |
| x1={margin + col * cell} | |
| y1={margin + row * cell} | |
| x2={margin + (col + 1) * cell} | |
| y2={margin + row * cell} | |
| className={`svg-wall ${row === 0 || row === puzzle.rows ? "fixed" : "ghost"}`} | |
| /> | |
| )), | |
| )} | |
| {Array.from({ length: puzzle.rows }).map((_, row) => | |
| Array.from({ length: puzzle.cols + 1 }).map((__, col) => ( | |
| <line | |
| key={`outer-v-${row}-${col}`} | |
| x1={margin + col * cell} | |
| y1={margin + row * cell} | |
| x2={margin + col * cell} | |
| y2={margin + (row + 1) * cell} | |
| className={`svg-wall ${col === 0 || col === puzzle.cols ? "fixed" : "ghost"}`} | |
| /> | |
| )), | |
| )} | |
| {puzzle.horizontalBoundaries.map((boundary) => { | |
| const x1 = margin + boundary.col * cell; | |
| const y = margin + boundary.row * cell; | |
| const x2 = x1 + cell; | |
| return ( | |
| <g key={boundary.id} className="svg-hit-target" onClick={() => toggle(boundary)}> | |
| <line x1={x1} y1={y} x2={x2} y2={y} className="svg-hit-line" /> | |
| <line x1={x1} y1={y} x2={x2} y2={y} className={`svg-wall ${states[boundary.id] ? "active" : "ghost"}`} /> | |
| </g> | |
| ); | |
| })} | |
| {puzzle.verticalBoundaries.map((boundary) => { | |
| const x = margin + boundary.col * cell; | |
| const y1 = margin + boundary.row * cell; | |
| const y2 = y1 + cell; | |
| return ( | |
| <g key={boundary.id} className="svg-hit-target" onClick={() => toggle(boundary)}> | |
| <line x1={x} y1={y1} x2={x} y2={y2} className="svg-hit-line" /> | |
| <line x1={x} y1={y1} x2={x} y2={y2} className={`svg-wall ${states[boundary.id] ? "active" : "ghost"}`} /> | |
| </g> | |
| ); | |
| })} | |
| {puzzle.dots.map((dot) => ( | |
| <circle | |
| key={`dot-${dot.rowCoord}-${dot.colCoord}`} | |
| cx={margin + dot.colCoord * half} | |
| cy={margin + dot.rowCoord * half} | |
| r={7} | |
| className="galaxy-dot" | |
| /> | |
| ))} | |
| </svg> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function PatternEditor({ boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| const normalizedBoard = normalizePatternAscii(boardAscii); | |
| useEffect(() => { | |
| if (normalizedBoard !== boardAscii) { | |
| onChange(normalizedBoard); | |
| } | |
| }, [boardAscii, normalizedBoard, onChange]); | |
| const template = parsePattern(normalizedBoard); | |
| const columnClues = parsePatternColumnClues(template); | |
| const maxColumnClueDepth = Math.max(...columnClues.map((column) => column.length), 1); | |
| function cycle(rowIndex: number, cellIndex: number) { | |
| const next = parsePattern(normalizedBoard); | |
| const current = next.rows[rowIndex].cells[cellIndex]; | |
| next.rows[rowIndex].cells[cellIndex] = | |
| current === "##" ? ".." : "##"; | |
| onChange(patternToAscii(next)); | |
| } | |
| return ( | |
| <div className="editor-stack"> | |
| <div className="puzzle-note">Every cell starts white. Click a square to toggle it between white and filled black.</div> | |
| <div className="pattern-layout"> | |
| <div className="pattern-top-spacer" /> | |
| <div className="pattern-top-clues" style={{ gridTemplateColumns: `repeat(${columnClues.length}, 46px)` }}> | |
| {columnClues.map((column, index) => ( | |
| <div | |
| key={`column-${index}`} | |
| className="pattern-column-stack" | |
| style={{ gridTemplateRows: `repeat(${maxColumnClueDepth}, 22px)` }} | |
| > | |
| {Array.from({ length: maxColumnClueDepth }).map((_, rowIndex) => { | |
| const clue = column[rowIndex - (maxColumnClueDepth - column.length)] ?? ""; | |
| return ( | |
| <div key={`${index}-${rowIndex}`} className="pattern-clue-pill"> | |
| {clue} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ))} | |
| </div> | |
| {template.rows.map((row, rowIndex) => ( | |
| <div key={`row-wrap-${rowIndex}`} className="pattern-row-pair"> | |
| <div className="pattern-clue pattern-row-clue"> | |
| {row.prefix | |
| .trim() | |
| .split(/\s+/) | |
| .filter(Boolean) | |
| .map((clue, clueIndex) => ( | |
| <span key={`${rowIndex}-${clueIndex}`} className="pattern-clue-pill"> | |
| {clue} | |
| </span> | |
| ))} | |
| </div> | |
| <div | |
| className="board-grid pattern-board" | |
| style={{ gridTemplateColumns: `repeat(${row.cells.length}, 46px)` }} | |
| > | |
| {row.cells.map((cell, cellIndex) => ( | |
| <button | |
| key={`${rowIndex}-${cellIndex}`} | |
| type="button" | |
| className={`board-square ${cell === "##" ? "fill" : "empty"}`} | |
| onClick={() => cycle(rowIndex, cellIndex)} | |
| > | |
| {cell === "##" ? "■" : ""} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| function UndeadEditor({ boardAscii, onChange }: Omit<Props, "puzzleType">) { | |
| const template = parseUndead(boardAscii); | |
| const topClues = template.topLine.trim().split(/\s+/); | |
| const bottomClues = template.bottomLine.trim().split(/\s+/); | |
| const boardWidth = template.rows[0]?.cells.length ?? 0; | |
| const rowTemplate = `40px repeat(${boardWidth}, 38px) 40px`; | |
| 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 ( | |
| <div className="editor-stack"> | |
| <div className="puzzle-note">Click a cell to cycle ghost, vampire, zombie, and blank. Mirror cells are fixed.</div> | |
| <div className="undead-legend">{template.header}</div> | |
| <div className="undead-grid-wrap"> | |
| <div className="undead-clue-row" style={{ gridTemplateColumns: rowTemplate }}> | |
| <div className="undead-clue-spacer" /> | |
| {topClues.map((clue, index) => ( | |
| <div key={`top-${index}`} className="undead-clue-box"> | |
| {clue} | |
| </div> | |
| ))} | |
| <div className="undead-clue-spacer" /> | |
| </div> | |
| {template.rows.map((row, rowIndex) => ( | |
| <div key={rowIndex} className="undead-clue-row" style={{ gridTemplateColumns: rowTemplate }}> | |
| <div className="undead-clue-box side">{row.left}</div> | |
| {row.cells.map((cell, cellIndex) => { | |
| const fixedMirror = cell === "/" || cell === "\\"; | |
| return ( | |
| <button | |
| key={`${rowIndex}-${cellIndex}`} | |
| type="button" | |
| className={`board-cell undead-cell ${fixedMirror ? "mirror" : "monster-cell"}`} | |
| onClick={() => cycle(rowIndex, cellIndex)} | |
| > | |
| {cell === "." ? "" : cell} | |
| </button> | |
| ); | |
| })} | |
| <div className="undead-clue-box side">{row.right}</div> | |
| </div> | |
| ))} | |
| <div className="undead-clue-row" style={{ gridTemplateColumns: rowTemplate }}> | |
| <div className="undead-clue-spacer" /> | |
| {bottomClues.map((clue, index) => ( | |
| <div key={`bottom-${index}`} className="undead-clue-box"> | |
| {clue} | |
| </div> | |
| ))} | |
| <div className="undead-clue-spacer" /> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| export function PuzzleEditor(props: Props) { | |
| if (props.puzzleType === "bridges") { | |
| return <BridgesEditor {...props} />; | |
| } | |
| if (props.puzzleType === "flow_free") { | |
| return <FlowFreeEditor {...props} />; | |
| } | |
| if (props.puzzleType === "galaxies") { | |
| return <GalaxiesEditor {...props} />; | |
| } | |
| if (props.puzzleType === "loopy") { | |
| return <LoopyEditor {...props} />; | |
| } | |
| if (props.puzzleType === "pattern") { | |
| return <PatternEditor {...props} />; | |
| } | |
| if (props.puzzleType === "undead") { | |
| return <UndeadEditor {...props} />; | |
| } | |
| return null; | |
| } | |
| // Test-only barrel: exposes the pure encode/decode helpers so the round-trip | |
| // pipeline tests can exercise the *real* editor functions (not reimplementations). | |
| // These mirror exactly what each <Family>Editor's onChange emits. | |
| export const __testables = { | |
| splitLines, | |
| cloneGrid, | |
| joinGrid, | |
| parseBridges, | |
| cycleBridgesOpportunities, | |
| bridgeClickCountForOpportunity, | |
| bridgesBoardFromStates, | |
| bridgesStatesFromBoard, | |
| parseLoopy, | |
| loopyEdgeStates, | |
| loopyBoardFromStates, | |
| parseGalaxies, | |
| galaxiesBoundaryStates, | |
| galaxiesBoardFromStates, | |
| parsePattern, | |
| patternToAscii, | |
| normalizePatternAscii, | |
| parseUndead, | |
| undeadToAscii, | |
| }; | |