testspace / frontend /src /components /PuzzleEditor 2.tsx
nilshoehing's picture
Fix puzzle UI and verifier normalization
59c2724 verified
Raw
History Blame Contribute Delete
15.8 kB
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<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: [],
};
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, 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;
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<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) {
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<Props, "puzzleType">) {
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 (
<div
className="board-grid"
style={{ gridTemplateColumns: `repeat(${parsed.lines[0]?.length ?? 0}, 38px)` }}
>
{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 (
<button
key={`${r}-${c}`}
type="button"
className={className}
onClick={() => opportunity && cycle(opportunity)}
disabled={!opportunity}
>
{isClueChar(char) ? char : boardChar === "." ? "" : boardChar}
</button>
);
}),
)}
</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));
}
function swatchColor(letter: string) {
return FLOW_FREE_COLORS[(letter.charCodeAt(0) - 65) % FLOW_FREE_COLORS.length];
}
return (
<div className="pattern-layout">
<div className="palette">
{letters.map((letter) => (
<button
key={letter}
type="button"
className={activeColor === letter ? "active" : ""}
style={{ background: swatchColor(letter) }}
onClick={() => setActiveColor(letter)}
>
{letter}
</button>
))}
</div>
<div
className="board-grid"
style={{ gridTemplateColumns: `repeat(${problemLines[0]?.length ?? 0}, 38px)` }}
>
{boardLines.flatMap((line, r) =>
[...line].map((char, c) => {
const fixed = problemLines[r][c] !== ".";
const letter = fixed ? problemLines[r][c] : char;
return (
<button
key={`${r}-${c}`}
type="button"
className={`board-cell ${fixed ? "fixed" : "water"}`}
style={{
background:
letter === "."
? undefined
: `${swatchColor(letter)}cc`,
}}
onClick={() => updateCell(r, c)}
>
{letter === "." ? "" : letter}
</button>
);
}),
)}
</div>
</div>
);
}
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 (
<div
className="char-board"
style={{ gridTemplateColumns: `repeat(${width}, 24px)` }}
>
{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 (
<button
key={`${r}-${c}`}
type="button"
className={`board-char ${editable ? "clickable" : ""}`}
disabled={!editable}
onClick={() => updateCell(r, c)}
>
{current === " " ? "\u00A0" : current}
</button>
);
}),
)}
</div>
);
}
function LoopyEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
return (
<CharGridEditor
problemAscii={problemAscii}
boardAscii={boardAscii}
onChange={onChange}
canEdit={(r, c, char) => {
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<Props, "puzzleType">) {
return (
<CharGridEditor
problemAscii={problemAscii}
boardAscii={boardAscii}
onChange={onChange}
canEdit={(r, c, char) => {
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<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 PatternEditor({ boardAscii, onChange }: Omit<Props, "puzzleType" | "problemAscii"> & { 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 (
<div className="pattern-layout">
<pre className="ascii-preview">{template.topLines.join("\n")}</pre>
<div className="pattern-grid">
{template.rows.map((row, rowIndex) => (
<div key={rowIndex} className="pattern-row">
<div className="pattern-clue">{row.prefix.trim()}</div>
<div className="board-grid" style={{ gridTemplateColumns: `repeat(${row.cells.length}, 44px)` }}>
{row.cells.map((cell, cellIndex) => (
<button
key={`${rowIndex}-${cellIndex}`}
type="button"
className={`board-square ${cell === "##" ? "fill" : cell === ".." ? "empty" : ""}`}
onClick={() => cycle(rowIndex, cellIndex)}
>
{cell === " " ? "" : cell}
</button>
))}
</div>
</div>
))}
</div>
</div>
);
}
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<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 UndeadEditor({ boardAscii, onChange }: Omit<Props, "puzzleType" | "problemAscii"> & { 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 (
<div className="pattern-layout">
<pre className="ascii-preview">{`${template.header}\n\n${template.topLine}\n${template.bottomLine}`}</pre>
<div className="board-grid" style={{ gridTemplateColumns: `repeat(${template.rows[0]?.cells.length ?? 0}, 38px)` }}>
{template.rows.flatMap((row, rowIndex) =>
row.cells.map((cell, cellIndex) => {
const fixedMirror = cell === "/" || cell === "\\";
return (
<button
key={`${rowIndex}-${cellIndex}`}
type="button"
className={`board-cell ${fixedMirror ? "mirror" : ""}`}
onClick={() => cycle(rowIndex, cellIndex)}
>
{cell === "." ? "" : cell}
</button>
);
}),
)}
</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;
}