nilshoehing commited on
Commit
59c2724
·
verified ·
1 Parent(s): 3204772

Fix puzzle UI and verifier normalization

Browse files
frontend/src/components/PuzzleEditor 2.tsx ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+
3
+ import type { PuzzleType } from "../lib/api";
4
+
5
+ type Props = {
6
+ puzzleType: PuzzleType;
7
+ problemAscii: string;
8
+ boardAscii: string;
9
+ onChange: (nextAscii: string) => void;
10
+ };
11
+
12
+ type BridgesOpportunity = {
13
+ id: string;
14
+ kind: "horizontal" | "vertical";
15
+ cells: Array<[number, number]>;
16
+ };
17
+
18
+ const FLOW_FREE_COLORS = [
19
+ "#f15b5d",
20
+ "#f5a623",
21
+ "#f6df5a",
22
+ "#7ed957",
23
+ "#36cfc9",
24
+ "#4b8ef7",
25
+ "#7a5cff",
26
+ "#ff8bd1",
27
+ "#7d5a50",
28
+ "#5f6a6b",
29
+ ];
30
+
31
+ function splitLines(board: string): string[] {
32
+ return board.replace(/\r/g, "").split("\n");
33
+ }
34
+
35
+ function cloneGrid(lines: string[]): string[][] {
36
+ return lines.map((line) => [...line]);
37
+ }
38
+
39
+ function joinGrid(grid: string[][]): string {
40
+ return grid.map((row) => row.join("")).join("\n");
41
+ }
42
+
43
+ function isClueChar(char: string): boolean {
44
+ return /[0-9A-G]/.test(char);
45
+ }
46
+
47
+ function parseBridges(problemAscii: string) {
48
+ const lines = splitLines(problemAscii);
49
+ const opportunities: BridgesOpportunity[] = [];
50
+ const cellToOpportunity = new Map<string, BridgesOpportunity>();
51
+ for (let r = 0; r < lines.length; r += 1) {
52
+ for (let c = 0; c < lines[r].length; c += 1) {
53
+ if (!isClueChar(lines[r][c])) {
54
+ continue;
55
+ }
56
+ let next = c + 1;
57
+ while (next < lines[r].length && lines[r][next] === ".") {
58
+ next += 1;
59
+ }
60
+ if (next < lines[r].length && isClueChar(lines[r][next]) && next > c + 1) {
61
+ const opp: BridgesOpportunity = {
62
+ id: `h-${r}-${c}-${next}`,
63
+ kind: "horizontal",
64
+ cells: [],
65
+ };
66
+ for (let cell = c + 1; cell < next; cell += 1) {
67
+ opp.cells.push([r, cell]);
68
+ cellToOpportunity.set(`${r}:${cell}`, opp);
69
+ }
70
+ opportunities.push(opp);
71
+ }
72
+ let nextRow = r + 1;
73
+ while (nextRow < lines.length && c < lines[nextRow].length && lines[nextRow][c] === ".") {
74
+ nextRow += 1;
75
+ }
76
+ if (
77
+ nextRow < lines.length &&
78
+ c < lines[nextRow].length &&
79
+ isClueChar(lines[nextRow][c]) &&
80
+ nextRow > r + 1
81
+ ) {
82
+ const opp: BridgesOpportunity = {
83
+ id: `v-${r}-${c}-${nextRow}`,
84
+ kind: "vertical",
85
+ cells: [],
86
+ };
87
+ for (let cell = r + 1; cell < nextRow; cell += 1) {
88
+ opp.cells.push([cell, c]);
89
+ cellToOpportunity.set(`${cell}:${c}`, opp);
90
+ }
91
+ opportunities.push(opp);
92
+ }
93
+ }
94
+ }
95
+ return { lines, opportunities, cellToOpportunity };
96
+ }
97
+
98
+ function bridgesBoardFromStates(
99
+ problemAscii: string,
100
+ states: Record<string, 0 | 1 | 2>,
101
+ ): string {
102
+ const parsed = parseBridges(problemAscii);
103
+ const grid = cloneGrid(parsed.lines);
104
+ for (const opportunity of parsed.opportunities) {
105
+ const level = states[opportunity.id] ?? 0;
106
+ const symbol =
107
+ opportunity.kind === "horizontal"
108
+ ? level === 2
109
+ ? "="
110
+ : level === 1
111
+ ? "-"
112
+ : "."
113
+ : level === 2
114
+ ? '"'
115
+ : level === 1
116
+ ? "|"
117
+ : ".";
118
+ for (const [r, c] of opportunity.cells) {
119
+ grid[r][c] = symbol;
120
+ }
121
+ }
122
+ return joinGrid(grid);
123
+ }
124
+
125
+ function bridgesStatesFromBoard(
126
+ problemAscii: string,
127
+ boardAscii: string,
128
+ ): Record<string, 0 | 1 | 2> {
129
+ const parsed = parseBridges(problemAscii);
130
+ const boardLines = splitLines(boardAscii);
131
+ const states: Record<string, 0 | 1 | 2> = {};
132
+ for (const opportunity of parsed.opportunities) {
133
+ const [r, c] = opportunity.cells[0];
134
+ const char = boardLines[r]?.[c] ?? ".";
135
+ if (opportunity.kind === "horizontal") {
136
+ states[opportunity.id] = char === "=" ? 2 : char === "-" ? 1 : 0;
137
+ } else {
138
+ states[opportunity.id] = char === '"' ? 2 : char === "|" ? 1 : 0;
139
+ }
140
+ }
141
+ return states;
142
+ }
143
+
144
+ function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
145
+ const parsed = parseBridges(problemAscii);
146
+ const states = bridgesStatesFromBoard(problemAscii, boardAscii);
147
+
148
+ function cycle(opportunity: BridgesOpportunity) {
149
+ const nextStates = { ...states };
150
+ const current = nextStates[opportunity.id] ?? 0;
151
+ nextStates[opportunity.id] = (((current + 1) % 3) as 0 | 1 | 2);
152
+ onChange(bridgesBoardFromStates(problemAscii, nextStates));
153
+ }
154
+
155
+ return (
156
+ <div
157
+ className="board-grid"
158
+ style={{ gridTemplateColumns: `repeat(${parsed.lines[0]?.length ?? 0}, 38px)` }}
159
+ >
160
+ {parsed.lines.flatMap((line, r) =>
161
+ [...line].map((char, c) => {
162
+ const opportunity = parsed.cellToOpportunity.get(`${r}:${c}`);
163
+ const boardChar = splitLines(boardAscii)[r]?.[c] ?? char;
164
+ const className = isClueChar(char)
165
+ ? "board-cell fixed"
166
+ : opportunity
167
+ ? "board-cell"
168
+ : "board-cell water";
169
+ return (
170
+ <button
171
+ key={`${r}-${c}`}
172
+ type="button"
173
+ className={className}
174
+ onClick={() => opportunity && cycle(opportunity)}
175
+ disabled={!opportunity}
176
+ >
177
+ {isClueChar(char) ? char : boardChar === "." ? "" : boardChar}
178
+ </button>
179
+ );
180
+ }),
181
+ )}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ function FlowFreeEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
187
+ const problemLines = splitLines(problemAscii);
188
+ const boardLines = splitLines(boardAscii);
189
+ const letters = [...new Set(problemAscii.replace(/[^A-Z]/g, "").split(""))].sort();
190
+ const [activeColor, setActiveColor] = useState(letters[0] ?? "A");
191
+
192
+ function updateCell(r: number, c: number) {
193
+ if (problemLines[r][c] !== ".") {
194
+ setActiveColor(problemLines[r][c]);
195
+ return;
196
+ }
197
+ const grid = cloneGrid(boardLines);
198
+ grid[r][c] = grid[r][c] === activeColor ? "." : activeColor;
199
+ onChange(joinGrid(grid));
200
+ }
201
+
202
+ function swatchColor(letter: string) {
203
+ return FLOW_FREE_COLORS[(letter.charCodeAt(0) - 65) % FLOW_FREE_COLORS.length];
204
+ }
205
+
206
+ return (
207
+ <div className="pattern-layout">
208
+ <div className="palette">
209
+ {letters.map((letter) => (
210
+ <button
211
+ key={letter}
212
+ type="button"
213
+ className={activeColor === letter ? "active" : ""}
214
+ style={{ background: swatchColor(letter) }}
215
+ onClick={() => setActiveColor(letter)}
216
+ >
217
+ {letter}
218
+ </button>
219
+ ))}
220
+ </div>
221
+ <div
222
+ className="board-grid"
223
+ style={{ gridTemplateColumns: `repeat(${problemLines[0]?.length ?? 0}, 38px)` }}
224
+ >
225
+ {boardLines.flatMap((line, r) =>
226
+ [...line].map((char, c) => {
227
+ const fixed = problemLines[r][c] !== ".";
228
+ const letter = fixed ? problemLines[r][c] : char;
229
+ return (
230
+ <button
231
+ key={`${r}-${c}`}
232
+ type="button"
233
+ className={`board-cell ${fixed ? "fixed" : "water"}`}
234
+ style={{
235
+ background:
236
+ letter === "."
237
+ ? undefined
238
+ : `${swatchColor(letter)}cc`,
239
+ }}
240
+ onClick={() => updateCell(r, c)}
241
+ >
242
+ {letter === "." ? "" : letter}
243
+ </button>
244
+ );
245
+ }),
246
+ )}
247
+ </div>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ function CharGridEditor({
253
+ problemAscii,
254
+ boardAscii,
255
+ onChange,
256
+ canEdit,
257
+ nextChar,
258
+ }: {
259
+ problemAscii: string;
260
+ boardAscii: string;
261
+ onChange: (nextAscii: string) => void;
262
+ canEdit: (r: number, c: number, char: string) => boolean;
263
+ nextChar: (r: number, c: number, current: string, problemChar: string) => string;
264
+ }) {
265
+ const problemLines = splitLines(problemAscii);
266
+ const boardLines = splitLines(boardAscii);
267
+ const width = Math.max(...boardLines.map((line) => line.length));
268
+ function updateCell(r: number, c: number) {
269
+ const current = boardLines[r]?.[c] ?? " ";
270
+ const problemChar = problemLines[r]?.[c] ?? " ";
271
+ const grid = boardLines.map((line) => line.padEnd(width, " ").split(""));
272
+ grid[r][c] = nextChar(r, c, current, problemChar);
273
+ onChange(joinGrid(grid).replace(/\s+$/g, "").replace(/\n\s+$/g, "\n"));
274
+ }
275
+ return (
276
+ <div
277
+ className="char-board"
278
+ style={{ gridTemplateColumns: `repeat(${width}, 24px)` }}
279
+ >
280
+ {Array.from({ length: boardLines.length }).flatMap((_, r) =>
281
+ Array.from({ length: width }).map((__, c) => {
282
+ const problemChar = problemLines[r]?.[c] ?? " ";
283
+ const current = boardLines[r]?.[c] ?? " ";
284
+ const editable = canEdit(r, c, problemChar);
285
+ return (
286
+ <button
287
+ key={`${r}-${c}`}
288
+ type="button"
289
+ className={`board-char ${editable ? "clickable" : ""}`}
290
+ disabled={!editable}
291
+ onClick={() => updateCell(r, c)}
292
+ >
293
+ {current === " " ? "\u00A0" : current}
294
+ </button>
295
+ );
296
+ }),
297
+ )}
298
+ </div>
299
+ );
300
+ }
301
+
302
+ function LoopyEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
303
+ return (
304
+ <CharGridEditor
305
+ problemAscii={problemAscii}
306
+ boardAscii={boardAscii}
307
+ onChange={onChange}
308
+ canEdit={(r, c, char) => {
309
+ if (char !== " ") {
310
+ return false;
311
+ }
312
+ const isHorizontal = r > 0 && r < splitLines(problemAscii).length - 1 && r % 2 === 1 && c % 2 === 0;
313
+ const isVertical = r > 0 && r < splitLines(problemAscii).length - 1 && r % 2 === 0 && c % 2 === 1;
314
+ return isHorizontal || isVertical;
315
+ }}
316
+ nextChar={(r, c, current, problemChar) => {
317
+ if (problemChar !== " ") {
318
+ return current;
319
+ }
320
+ const isHorizontal = r % 2 === 1 && c % 2 === 0;
321
+ if (isHorizontal) {
322
+ return current === " " ? "-" : current === "-" ? "x" : " ";
323
+ }
324
+ return current === " " ? "|" : current === "|" ? "x" : " ";
325
+ }}
326
+ />
327
+ );
328
+ }
329
+
330
+ function GalaxiesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
331
+ return (
332
+ <CharGridEditor
333
+ problemAscii={problemAscii}
334
+ boardAscii={boardAscii}
335
+ onChange={onChange}
336
+ canEdit={(r, c, char) => {
337
+ if (char !== " ") {
338
+ return false;
339
+ }
340
+ const isHorizontal = r % 2 === 0 && c % 2 === 1;
341
+ const isVertical = r % 2 === 1 && c % 2 === 0;
342
+ return isHorizontal || isVertical;
343
+ }}
344
+ nextChar={(r, _c, current, _problemChar) => {
345
+ const isHorizontal = r % 2 === 0;
346
+ if (isHorizontal) {
347
+ return current === "-" ? " " : "-";
348
+ }
349
+ return current === "|" ? " " : "|";
350
+ }}
351
+ />
352
+ );
353
+ }
354
+
355
+ function parsePattern(boardAscii: string) {
356
+ const lines = splitLines(boardAscii);
357
+ const firstContentIndex = lines.findIndex((line) => line.includes("|"));
358
+ const topLines = lines.slice(0, firstContentIndex);
359
+ const rows: Array<{ prefix: string; cells: string[]; border: string }> = [];
360
+ for (let idx = firstContentIndex; idx < lines.length; idx += 2) {
361
+ const content = lines[idx];
362
+ const border = lines[idx + 1];
363
+ if (!content || !border) {
364
+ break;
365
+ }
366
+ const firstBar = content.indexOf("|");
367
+ const prefix = content.slice(0, firstBar);
368
+ const parts = content.slice(firstBar).split("|").slice(1, -1);
369
+ rows.push({ prefix, cells: parts, border });
370
+ }
371
+ return { topLines, rows };
372
+ }
373
+
374
+ function patternToAscii(template: ReturnType<typeof parsePattern>): string {
375
+ const lines = [...template.topLines];
376
+ for (const row of template.rows) {
377
+ lines.push(`${row.prefix}|${row.cells.join("|")}|`);
378
+ lines.push(row.border);
379
+ }
380
+ return lines.join("\n");
381
+ }
382
+
383
+ function PatternEditor({ boardAscii, onChange }: Omit<Props, "puzzleType" | "problemAscii"> & { problemAscii: string }) {
384
+ const template = parsePattern(boardAscii);
385
+
386
+ function cycle(rowIndex: number, cellIndex: number) {
387
+ const next = parsePattern(boardAscii);
388
+ const current = next.rows[rowIndex].cells[cellIndex];
389
+ next.rows[rowIndex].cells[cellIndex] =
390
+ current === " " ? "##" : current === "##" ? ".." : " ";
391
+ onChange(patternToAscii(next));
392
+ }
393
+
394
+ return (
395
+ <div className="pattern-layout">
396
+ <pre className="ascii-preview">{template.topLines.join("\n")}</pre>
397
+ <div className="pattern-grid">
398
+ {template.rows.map((row, rowIndex) => (
399
+ <div key={rowIndex} className="pattern-row">
400
+ <div className="pattern-clue">{row.prefix.trim()}</div>
401
+ <div className="board-grid" style={{ gridTemplateColumns: `repeat(${row.cells.length}, 44px)` }}>
402
+ {row.cells.map((cell, cellIndex) => (
403
+ <button
404
+ key={`${rowIndex}-${cellIndex}`}
405
+ type="button"
406
+ className={`board-square ${cell === "##" ? "fill" : cell === ".." ? "empty" : ""}`}
407
+ onClick={() => cycle(rowIndex, cellIndex)}
408
+ >
409
+ {cell === " " ? "" : cell}
410
+ </button>
411
+ ))}
412
+ </div>
413
+ </div>
414
+ ))}
415
+ </div>
416
+ </div>
417
+ );
418
+ }
419
+
420
+ function parseUndead(boardAscii: string) {
421
+ const lines = splitLines(boardAscii);
422
+ const header = lines[0] ?? "";
423
+ const topLine = lines[2] ?? "";
424
+ const bottomLine = lines.at(-1) ?? "";
425
+ const rows = lines.slice(3, -1).map((line) => {
426
+ const tokens = line.trim().split(/\s+/);
427
+ return {
428
+ left: tokens[0] ?? "",
429
+ cells: tokens.slice(1, -1),
430
+ right: tokens.at(-1) ?? "",
431
+ };
432
+ });
433
+ return { header, topLine, bottomLine, rows };
434
+ }
435
+
436
+ function undeadToAscii(template: ReturnType<typeof parseUndead>): string {
437
+ const lines = [template.header, "", template.topLine];
438
+ for (const row of template.rows) {
439
+ lines.push(` ${row.left} ${row.cells.join(" ")} ${row.right}`);
440
+ }
441
+ lines.push(template.bottomLine);
442
+ return lines.join("\n");
443
+ }
444
+
445
+ function UndeadEditor({ boardAscii, onChange }: Omit<Props, "puzzleType" | "problemAscii"> & { problemAscii: string }) {
446
+ const template = parseUndead(boardAscii);
447
+ function cycle(rowIndex: number, cellIndex: number) {
448
+ const current = template.rows[rowIndex].cells[cellIndex];
449
+ if (current === "/" || current === "\\") {
450
+ return;
451
+ }
452
+ const next = parseUndead(boardAscii);
453
+ next.rows[rowIndex].cells[cellIndex] =
454
+ current === "." ? "G" : current === "G" ? "V" : current === "V" ? "Z" : ".";
455
+ onChange(undeadToAscii(next));
456
+ }
457
+
458
+ return (
459
+ <div className="pattern-layout">
460
+ <pre className="ascii-preview">{`${template.header}\n\n${template.topLine}\n${template.bottomLine}`}</pre>
461
+ <div className="board-grid" style={{ gridTemplateColumns: `repeat(${template.rows[0]?.cells.length ?? 0}, 38px)` }}>
462
+ {template.rows.flatMap((row, rowIndex) =>
463
+ row.cells.map((cell, cellIndex) => {
464
+ const fixedMirror = cell === "/" || cell === "\\";
465
+ return (
466
+ <button
467
+ key={`${rowIndex}-${cellIndex}`}
468
+ type="button"
469
+ className={`board-cell ${fixedMirror ? "mirror" : ""}`}
470
+ onClick={() => cycle(rowIndex, cellIndex)}
471
+ >
472
+ {cell === "." ? "" : cell}
473
+ </button>
474
+ );
475
+ }),
476
+ )}
477
+ </div>
478
+ </div>
479
+ );
480
+ }
481
+
482
+ export function PuzzleEditor(props: Props) {
483
+ if (props.puzzleType === "bridges") {
484
+ return <BridgesEditor {...props} />;
485
+ }
486
+ if (props.puzzleType === "flow_free") {
487
+ return <FlowFreeEditor {...props} />;
488
+ }
489
+ if (props.puzzleType === "galaxies") {
490
+ return <GalaxiesEditor {...props} />;
491
+ }
492
+ if (props.puzzleType === "loopy") {
493
+ return <LoopyEditor {...props} />;
494
+ }
495
+ if (props.puzzleType === "pattern") {
496
+ return <PatternEditor {...props} />;
497
+ }
498
+ if (props.puzzleType === "undead") {
499
+ return <UndeadEditor {...props} />;
500
+ }
501
+ return null;
502
+ }
frontend/src/components/PuzzleEditor.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
2
 
3
  import type { PuzzleType } from "../lib/api";
4
 
@@ -259,11 +259,11 @@ function loopyBoardFromStates(
259
  const grid = cloneGrid(padLines(puzzle.lines, width));
260
 
261
  for (const edge of puzzle.horizontalEdges) {
262
- const state = states[edge.id] ?? "unknown";
263
  grid[edge.boardRow][edge.boardCol] = state === "line" ? "-" : state === "blocked" ? "x" : " ";
264
  }
265
  for (const edge of puzzle.verticalEdges) {
266
- const state = states[edge.id] ?? "unknown";
267
  grid[edge.boardRow][edge.boardCol] = state === "line" ? "|" : state === "blocked" ? "x" : " ";
268
  }
269
 
@@ -289,6 +289,9 @@ function parseGalaxies(problemAscii: string) {
289
 
290
  for (let r = 1; r < rows; r += 1) {
291
  for (let c = 0; c < cols; c += 1) {
 
 
 
292
  horizontalBoundaries.push({
293
  id: `h-${r}-${c}`,
294
  kind: "horizontal",
@@ -302,6 +305,9 @@ function parseGalaxies(problemAscii: string) {
302
 
303
  for (let r = 0; r < rows; r += 1) {
304
  for (let c = 1; c < cols; c += 1) {
 
 
 
305
  verticalBoundaries.push({
306
  id: `v-${r}-${c}`,
307
  kind: "vertical",
@@ -365,6 +371,44 @@ function parsePattern(boardAscii: string) {
365
  return { topLines, rows };
366
  }
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  function patternToAscii(template: ReturnType<typeof parsePattern>): string {
369
  const lines = [...template.topLines];
370
  for (const row of template.rows) {
@@ -526,8 +570,7 @@ function LoopyEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzle
526
 
527
  function cycle(edge: LoopyEdge) {
528
  const current = states[edge.id] ?? "unknown";
529
- const next =
530
- current === "unknown" ? "line" : current === "line" ? "blocked" : "unknown";
531
  onChange(
532
  loopyBoardFromStates(problemAscii, {
533
  ...states,
@@ -538,7 +581,7 @@ function LoopyEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzle
538
 
539
  return (
540
  <div className="editor-stack">
541
- <div className="puzzle-note">Click any segment, including the outer border, to cycle blank, line, and blocked.</div>
542
  <div className="svg-board-shell">
543
  <svg className="svg-board" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Loopy board">
544
  {Array.from({ length: puzzle.rows + 1 }).map((_, row) =>
@@ -736,35 +779,72 @@ function GalaxiesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puz
736
  }
737
 
738
  function PatternEditor({ boardAscii, onChange }: Omit<Props, "puzzleType">) {
739
- const template = parsePattern(boardAscii);
740
- const columnClues = template.topLines.map((line) => line.trimEnd()).join("\n");
 
 
 
 
 
 
 
 
 
741
 
742
  function cycle(rowIndex: number, cellIndex: number) {
743
- const next = parsePattern(boardAscii);
744
  const current = next.rows[rowIndex].cells[cellIndex];
745
  next.rows[rowIndex].cells[cellIndex] =
746
- current === " " ? "##" : current === "##" ? ".." : " ";
747
  onChange(patternToAscii(next));
748
  }
749
 
750
  return (
751
  <div className="editor-stack">
752
- <div className="puzzle-note">Click a square to cycle unknown, filled, and empty.</div>
753
  <div className="pattern-layout">
754
- <pre className="ascii-preview compact-preview">{columnClues}</pre>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  <div className="pattern-grid">
756
  {template.rows.map((row, rowIndex) => (
757
  <div key={rowIndex} className="pattern-row">
758
- <div className="pattern-clue">{row.prefix.trim()}</div>
 
 
 
 
 
 
 
 
 
 
759
  <div className="board-grid pattern-board" style={{ gridTemplateColumns: `repeat(${row.cells.length}, 46px)` }}>
760
  {row.cells.map((cell, cellIndex) => (
761
  <button
762
  key={`${rowIndex}-${cellIndex}`}
763
  type="button"
764
- className={`board-square ${cell === "##" ? "fill" : cell === ".." ? "empty" : "unknown"}`}
765
  onClick={() => cycle(rowIndex, cellIndex)}
766
  >
767
- {cell === "##" ? "■" : cell === ".." ? "·" : ""}
768
  </button>
769
  ))}
770
  </div>
 
1
+ import { useEffect, useState } from "react";
2
 
3
  import type { PuzzleType } from "../lib/api";
4
 
 
259
  const grid = cloneGrid(padLines(puzzle.lines, width));
260
 
261
  for (const edge of puzzle.horizontalEdges) {
262
+ const state = states[edge.id] ?? "blocked";
263
  grid[edge.boardRow][edge.boardCol] = state === "line" ? "-" : state === "blocked" ? "x" : " ";
264
  }
265
  for (const edge of puzzle.verticalEdges) {
266
+ const state = states[edge.id] ?? "blocked";
267
  grid[edge.boardRow][edge.boardCol] = state === "line" ? "|" : state === "blocked" ? "x" : " ";
268
  }
269
 
 
289
 
290
  for (let r = 1; r < rows; r += 1) {
291
  for (let c = 0; c < cols; c += 1) {
292
+ if ((lines[2 * r]?.[1 + 2 * c] ?? " ") === "o") {
293
+ continue;
294
+ }
295
  horizontalBoundaries.push({
296
  id: `h-${r}-${c}`,
297
  kind: "horizontal",
 
305
 
306
  for (let r = 0; r < rows; r += 1) {
307
  for (let c = 1; c < cols; c += 1) {
308
+ if ((lines[1 + 2 * r]?.[2 * c] ?? " ") === "o") {
309
+ continue;
310
+ }
311
  verticalBoundaries.push({
312
  id: `v-${r}-${c}`,
313
  kind: "vertical",
 
371
  return { topLines, rows };
372
  }
373
 
374
+ function normalizePatternAscii(boardAscii: string) {
375
+ const template = parsePattern(boardAscii);
376
+ let changed = false;
377
+ for (const row of template.rows) {
378
+ for (let index = 0; index < row.cells.length; index += 1) {
379
+ if (row.cells[index] === " ") {
380
+ row.cells[index] = "..";
381
+ changed = true;
382
+ }
383
+ }
384
+ }
385
+ return changed ? patternToAscii(template) : boardAscii;
386
+ }
387
+
388
+ function parsePatternColumnClues(template: ReturnType<typeof parsePattern>): string[][] {
389
+ const topBorder = template.topLines.find((line) => line.includes("+"));
390
+ const clueLines = template.topLines.filter((line) => !line.includes("+"));
391
+ const firstRow = template.rows[0];
392
+ if (!topBorder || !firstRow) {
393
+ return [];
394
+ }
395
+
396
+ const firstBar = topBorder.indexOf("+");
397
+ const columnCount = firstRow.cells.length;
398
+ const columns: string[][] = Array.from({ length: columnCount }, () => []);
399
+
400
+ for (const line of clueLines) {
401
+ for (let col = 0; col < columnCount; col += 1) {
402
+ const cellSlice = line.slice(firstBar + 1 + col * 3, firstBar + 3 + col * 3).trim();
403
+ if (cellSlice) {
404
+ columns[col].push(cellSlice);
405
+ }
406
+ }
407
+ }
408
+
409
+ return columns;
410
+ }
411
+
412
  function patternToAscii(template: ReturnType<typeof parsePattern>): string {
413
  const lines = [...template.topLines];
414
  for (const row of template.rows) {
 
570
 
571
  function cycle(edge: LoopyEdge) {
572
  const current = states[edge.id] ?? "unknown";
573
+ const next = current === "line" ? "blocked" : "line";
 
574
  onChange(
575
  loopyBoardFromStates(problemAscii, {
576
  ...states,
 
581
 
582
  return (
583
  <div className="editor-stack">
584
+ <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>
585
  <div className="svg-board-shell">
586
  <svg className="svg-board" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Loopy board">
587
  {Array.from({ length: puzzle.rows + 1 }).map((_, row) =>
 
779
  }
780
 
781
  function PatternEditor({ boardAscii, onChange }: Omit<Props, "puzzleType">) {
782
+ const normalizedBoard = normalizePatternAscii(boardAscii);
783
+
784
+ useEffect(() => {
785
+ if (normalizedBoard !== boardAscii) {
786
+ onChange(normalizedBoard);
787
+ }
788
+ }, [boardAscii, normalizedBoard, onChange]);
789
+
790
+ const template = parsePattern(normalizedBoard);
791
+ const columnClues = parsePatternColumnClues(template);
792
+ const maxColumnClueDepth = Math.max(...columnClues.map((column) => column.length), 1);
793
 
794
  function cycle(rowIndex: number, cellIndex: number) {
795
+ const next = parsePattern(normalizedBoard);
796
  const current = next.rows[rowIndex].cells[cellIndex];
797
  next.rows[rowIndex].cells[cellIndex] =
798
+ current === "##" ? ".." : "##";
799
  onChange(patternToAscii(next));
800
  }
801
 
802
  return (
803
  <div className="editor-stack">
804
+ <div className="puzzle-note">Every cell starts white. Click a square to toggle it between white and filled black.</div>
805
  <div className="pattern-layout">
806
+ <div className="pattern-top-clues" style={{ gridTemplateColumns: `92px repeat(${columnClues.length}, 46px)` }}>
807
+ <div />
808
+ {columnClues.map((column, index) => (
809
+ <div
810
+ key={`column-${index}`}
811
+ className="pattern-column-stack"
812
+ style={{ gridTemplateRows: `repeat(${maxColumnClueDepth}, 22px)` }}
813
+ >
814
+ {Array.from({ length: maxColumnClueDepth }).map((_, rowIndex) => {
815
+ const clue = column[rowIndex - (maxColumnClueDepth - column.length)] ?? "";
816
+ return (
817
+ <div key={`${index}-${rowIndex}`} className="pattern-clue-pill">
818
+ {clue}
819
+ </div>
820
+ );
821
+ })}
822
+ </div>
823
+ ))}
824
+ </div>
825
  <div className="pattern-grid">
826
  {template.rows.map((row, rowIndex) => (
827
  <div key={rowIndex} className="pattern-row">
828
+ <div className="pattern-clue pattern-row-clue">
829
+ {row.prefix
830
+ .trim()
831
+ .split(/\s+/)
832
+ .filter(Boolean)
833
+ .map((clue, clueIndex) => (
834
+ <span key={`${rowIndex}-${clueIndex}`} className="pattern-clue-pill">
835
+ {clue}
836
+ </span>
837
+ ))}
838
+ </div>
839
  <div className="board-grid pattern-board" style={{ gridTemplateColumns: `repeat(${row.cells.length}, 46px)` }}>
840
  {row.cells.map((cell, cellIndex) => (
841
  <button
842
  key={`${rowIndex}-${cellIndex}`}
843
  type="button"
844
+ className={`board-square ${cell === "##" ? "fill" : "empty"}`}
845
  onClick={() => cycle(rowIndex, cellIndex)}
846
  >
847
+ {cell === "##" ? "■" : ""}
848
  </button>
849
  ))}
850
  </div>
frontend/src/index.css CHANGED
@@ -413,6 +413,19 @@ a {
413
  gap: 16px;
414
  }
415
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  .pattern-board {
417
  padding: 10px;
418
  }
@@ -430,13 +443,34 @@ a {
430
  }
431
 
432
  .pattern-clue {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  font-family:
434
  "SFMono-Regular",
435
  "Menlo",
436
  "Consolas",
437
  monospace;
438
- color: #406257;
439
- text-align: right;
440
  }
441
 
442
  .palette {
 
413
  gap: 16px;
414
  }
415
 
416
+ .pattern-top-clues {
417
+ display: grid;
418
+ gap: 12px;
419
+ align-items: end;
420
+ }
421
+
422
+ .pattern-column-stack {
423
+ display: grid;
424
+ gap: 4px;
425
+ justify-items: center;
426
+ align-content: end;
427
+ }
428
+
429
  .pattern-board {
430
  padding: 10px;
431
  }
 
443
  }
444
 
445
  .pattern-clue {
446
+ color: #406257;
447
+ }
448
+
449
+ .pattern-row-clue {
450
+ display: flex;
451
+ justify-content: flex-end;
452
+ gap: 6px;
453
+ flex-wrap: wrap;
454
+ padding-right: 4px;
455
+ }
456
+
457
+ .pattern-clue-pill {
458
+ display: inline-flex;
459
+ min-width: 28px;
460
+ min-height: 22px;
461
+ align-items: center;
462
+ justify-content: center;
463
+ padding: 0 8px;
464
+ border-radius: 999px;
465
+ background: rgba(18, 54, 44, 0.08);
466
+ color: #214338;
467
  font-family:
468
  "SFMono-Regular",
469
  "Menlo",
470
  "Consolas",
471
  monospace;
472
+ font-size: 0.86rem;
473
+ font-weight: 700;
474
  }
475
 
476
  .palette {
frontend/src/routes/PlayPage.tsx CHANGED
@@ -8,8 +8,8 @@ const PUZZLE_HELP: Record<string, string> = {
8
  bridges: "Connect every numbered island into a single network. Each route cycles empty, single bridge, then double bridge.",
9
  flow_free: "Choose a color from the palette, then paint a continuous path between matching endpoints without changing the endpoints themselves.",
10
  galaxies: "Partition the board with interior walls so each region has exactly one dot-symmetry center.",
11
- loopy: "Build one single loop. Every segment cycles blank, line, then blocked, including the outer perimeter.",
12
- pattern: "Each square cycles unknown, filled, then empty so the row and column clues match the finished pattern.",
13
  undead: "Place ghosts, vampires, and zombies so the side clues and the global monster counts all line up.",
14
  };
15
 
@@ -131,7 +131,7 @@ export function PlayPage() {
131
  if (!session) {
132
  return;
133
  }
134
- setBoardAscii(session.payload.problem_ascii);
135
  setError(null);
136
  setStatusMessage("Board reset to the original puzzle.");
137
  }
 
8
  bridges: "Connect every numbered island into a single network. Each route cycles empty, single bridge, then double bridge.",
9
  flow_free: "Choose a color from the palette, then paint a continuous path between matching endpoints without changing the endpoints themselves.",
10
  galaxies: "Partition the board with interior walls so each region has exactly one dot-symmetry center.",
11
+ loopy: "Build one single loop. Every segment toggles between part of the loop and definitely not part of it, including the outer perimeter.",
12
+ pattern: "Every square starts white. Click a square to toggle it between white and filled black so the row and column clues match.",
13
  undead: "Place ghosts, vampires, and zombies so the side clues and the global monster counts all line up.",
14
  };
15
 
 
131
  if (!session) {
132
  return;
133
  }
134
+ setBoardAscii(session.payload.current_board_ascii);
135
  setError(null);
136
  setStatusMessage("Board reset to the original puzzle.");
137
  }