testspace / frontend /src /components /PuzzleEditor.tsx
nilshoehing
Fix bridges crossing state model
1abec04
Raw
History Blame Contribute Delete
36.5 kB
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,
};