nilshoehing commited on
Commit
1abec04
·
1 Parent(s): b8d1810

Fix bridges crossing state model

Browse files
frontend/src/components/PuzzleEditor.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
2
 
3
  import type { PuzzleType } from "../lib/api";
4
 
@@ -191,6 +191,18 @@ function sortedBridgeCandidates(opportunities: BridgesOpportunity[]): BridgesOpp
191
  });
192
  }
193
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  function bridgeClickCountForOpportunity(
195
  opportunities: BridgesOpportunity[],
196
  opportunity: BridgesOpportunity,
@@ -209,7 +221,6 @@ function cycleBridgesOpportunities(
209
  parsed: ReturnType<typeof parseBridges>,
210
  states: Record<string, 0 | 1 | 2>,
211
  opportunities: BridgesOpportunity[],
212
- clickedChar = ".",
213
  ): Record<string, 0 | 1 | 2> {
214
  if (opportunities.length === 0) {
215
  return states;
@@ -223,11 +234,7 @@ function cycleBridgesOpportunities(
223
  const level = (((nextStates[opportunity.id] ?? 0) + 1) % 3) as 0 | 1 | 2;
224
  nextStates[opportunity.id] = level;
225
  if (level > 0) {
226
- for (const other of parsed.opportunities) {
227
- if (other.id !== opportunity.id && bridgeOpportunitiesShareCell(other, opportunity)) {
228
- nextStates[other.id] = 0;
229
- }
230
- }
231
  }
232
  return nextStates;
233
  }
@@ -239,18 +246,25 @@ function cycleBridgesOpportunities(
239
  ]),
240
  null,
241
  ];
242
- const currentIndex =
243
- sequence.findIndex(
244
- (entry) => entry !== null && bridgeSymbol(entry.opportunity, entry.level) === clickedChar,
245
- ) ?? -1;
246
- const normalizedCurrentIndex = currentIndex === -1 ? sequence.length - 1 : currentIndex;
 
 
 
 
 
 
247
 
248
- const nextEntry = sequence[(normalizedCurrentIndex + 1) % sequence.length];
249
  for (const opportunity of candidates) {
250
  nextStates[opportunity.id] = 0;
251
  }
252
  if (nextEntry) {
253
  nextStates[nextEntry.opportunity.id] = nextEntry.level;
 
254
  }
255
  return nextStates;
256
  }
@@ -553,17 +567,25 @@ function legendColor(letter: string) {
553
 
554
  function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
555
  const parsed = parseBridges(problemAscii);
556
- const states = bridgesStatesFromBoard(problemAscii, boardAscii);
557
- const boardLines = splitLines(boardAscii);
 
 
558
 
559
- function cycle(opportunities: BridgesOpportunity[], r: number, c: number) {
560
- const nextStates = cycleBridgesOpportunities(
561
- parsed,
562
- states,
563
- opportunities,
564
- boardLines[r]?.[c] ?? ".",
565
- );
566
- onChange(bridgesBoardFromStates(problemAscii, nextStates));
 
 
 
 
 
 
567
  }
568
 
569
  return (
@@ -589,7 +611,7 @@ function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzz
589
  key={`${r}-${c}`}
590
  type="button"
591
  className={className}
592
- onClick={() => cycle(opportunities, r, c)}
593
  disabled={opportunities.length === 0}
594
  >
595
  {isClueChar(char) ? char : display}
 
1
+ import { useEffect, useRef, useState } from "react";
2
 
3
  import type { PuzzleType } from "../lib/api";
4
 
 
191
  });
192
  }
193
 
194
+ function clearBridgeConflicts(
195
+ parsed: ReturnType<typeof parseBridges>,
196
+ states: Record<string, 0 | 1 | 2>,
197
+ selected: BridgesOpportunity,
198
+ ) {
199
+ for (const opportunity of parsed.opportunities) {
200
+ if (opportunity.id !== selected.id && bridgeOpportunitiesShareCell(opportunity, selected)) {
201
+ states[opportunity.id] = 0;
202
+ }
203
+ }
204
+ }
205
+
206
  function bridgeClickCountForOpportunity(
207
  opportunities: BridgesOpportunity[],
208
  opportunity: BridgesOpportunity,
 
221
  parsed: ReturnType<typeof parseBridges>,
222
  states: Record<string, 0 | 1 | 2>,
223
  opportunities: BridgesOpportunity[],
 
224
  ): Record<string, 0 | 1 | 2> {
225
  if (opportunities.length === 0) {
226
  return states;
 
234
  const level = (((nextStates[opportunity.id] ?? 0) + 1) % 3) as 0 | 1 | 2;
235
  nextStates[opportunity.id] = level;
236
  if (level > 0) {
237
+ clearBridgeConflicts(parsed, nextStates, opportunity);
 
 
 
 
238
  }
239
  return nextStates;
240
  }
 
246
  ]),
247
  null,
248
  ];
249
+ const currentIndex = sequence.findIndex((entry) =>
250
+ candidates.every((opportunity) => {
251
+ const expected = entry?.opportunity.id === opportunity.id ? entry.level : 0;
252
+ return (nextStates[opportunity.id] ?? 0) === expected;
253
+ }),
254
+ );
255
+ const fallbackIndex = sequence.findIndex(
256
+ (entry) => entry !== null && (nextStates[entry.opportunity.id] ?? 0) === entry.level,
257
+ );
258
+ const safeCurrentIndex =
259
+ currentIndex === -1 ? (fallbackIndex === -1 ? sequence.length - 1 : fallbackIndex) : currentIndex;
260
 
261
+ const nextEntry = sequence[(safeCurrentIndex + 1) % sequence.length];
262
  for (const opportunity of candidates) {
263
  nextStates[opportunity.id] = 0;
264
  }
265
  if (nextEntry) {
266
  nextStates[nextEntry.opportunity.id] = nextEntry.level;
267
+ clearBridgeConflicts(parsed, nextStates, nextEntry.opportunity);
268
  }
269
  return nextStates;
270
  }
 
567
 
568
  function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzzleType">) {
569
  const parsed = parseBridges(problemAscii);
570
+ const [states, setStates] = useState(() => bridgesStatesFromBoard(problemAscii, boardAscii));
571
+ const lastEmittedBoard = useRef<string | null>(null);
572
+ const renderedBoardAscii = bridgesBoardFromStates(problemAscii, states);
573
+ const boardLines = splitLines(renderedBoardAscii);
574
 
575
+ useEffect(() => {
576
+ if (lastEmittedBoard.current === boardAscii) {
577
+ return;
578
+ }
579
+ setStates(bridgesStatesFromBoard(problemAscii, boardAscii));
580
+ lastEmittedBoard.current = null;
581
+ }, [problemAscii, boardAscii]);
582
+
583
+ function cycle(opportunities: BridgesOpportunity[]) {
584
+ const nextStates = cycleBridgesOpportunities(parsed, states, opportunities);
585
+ const nextBoard = bridgesBoardFromStates(problemAscii, nextStates);
586
+ setStates(nextStates);
587
+ lastEmittedBoard.current = nextBoard;
588
+ onChange(nextBoard);
589
  }
590
 
591
  return (
 
611
  key={`${r}-${c}`}
612
  type="button"
613
  className={className}
614
+ onClick={() => cycle(opportunities)}
615
  disabled={opportunities.length === 0}
616
  >
617
  {isClueChar(char) ? char : display}
frontend/test/pipeline/bridges_clickplan.json CHANGED
@@ -125,6 +125,9 @@
125
  43
126
  ],
127
  "bridges_7x7dm_24cecc91.txt": [
 
 
 
128
  1,
129
  7,
130
  7,
@@ -134,9 +137,6 @@
134
  9,
135
  22,
136
  22,
137
- 17,
138
- 17,
139
- 17,
140
  21,
141
  37,
142
  37,
@@ -170,6 +170,14 @@
170
  93
171
  ],
172
  "bridges_10x10dh_dbb54860.txt": [
 
 
 
 
 
 
 
 
173
  1,
174
  1,
175
  10,
@@ -186,14 +194,6 @@
186
  31,
187
  24,
188
  33,
189
- 35,
190
- 35,
191
- 35,
192
- 35,
193
- 44,
194
- 44,
195
- 44,
196
- 44,
197
  49,
198
  49,
199
  55,
@@ -211,6 +211,17 @@
211
  94
212
  ],
213
  "bridges_10x10dh_9a2eb1a6.txt": [
 
 
 
 
 
 
 
 
 
 
 
214
  2,
215
  4,
216
  13,
@@ -234,21 +245,10 @@
234
  62,
235
  62,
236
  64,
237
- 73,
238
- 73,
239
- 73,
240
  66,
241
- 75,
242
- 75,
243
- 75,
244
  79,
245
- 71,
246
- 71,
247
  80,
248
  80,
249
- 84,
250
- 84,
251
- 84,
252
  86,
253
  82,
254
  91,
@@ -257,11 +257,11 @@
257
  97
258
  ],
259
  "bridges_10x10dh_1e764d30.txt": [
 
 
260
  2,
261
  2,
262
  19,
263
- 11,
264
- 11,
265
  20,
266
  13,
267
  13,
@@ -287,6 +287,13 @@
287
  95
288
  ],
289
  "bridges_10x10dh_714c4838.txt": [
 
 
 
 
 
 
 
290
  1,
291
  10,
292
  3,
@@ -312,15 +319,8 @@
312
  54,
313
  63,
314
  63,
315
- 65,
316
- 65,
317
- 67,
318
- 67,
319
  76,
320
  76,
321
- 84,
322
- 84,
323
- 88,
324
  91,
325
  95,
326
  97
 
125
  43
126
  ],
127
  "bridges_7x7dm_24cecc91.txt": [
128
+ 17,
129
+ 17,
130
+ 17,
131
  1,
132
  7,
133
  7,
 
137
  9,
138
  22,
139
  22,
 
 
 
140
  21,
141
  37,
142
  37,
 
170
  93
171
  ],
172
  "bridges_10x10dh_dbb54860.txt": [
173
+ 35,
174
+ 35,
175
+ 35,
176
+ 35,
177
+ 44,
178
+ 44,
179
+ 44,
180
+ 44,
181
  1,
182
  1,
183
  10,
 
194
  31,
195
  24,
196
  33,
 
 
 
 
 
 
 
 
197
  49,
198
  49,
199
  55,
 
211
  94
212
  ],
213
  "bridges_10x10dh_9a2eb1a6.txt": [
214
+ 73,
215
+ 73,
216
+ 73,
217
+ 75,
218
+ 75,
219
+ 75,
220
+ 71,
221
+ 71,
222
+ 84,
223
+ 84,
224
+ 84,
225
  2,
226
  4,
227
  13,
 
245
  62,
246
  62,
247
  64,
 
 
 
248
  66,
 
 
 
249
  79,
 
 
250
  80,
251
  80,
 
 
 
252
  86,
253
  82,
254
  91,
 
257
  97
258
  ],
259
  "bridges_10x10dh_1e764d30.txt": [
260
+ 11,
261
+ 11,
262
  2,
263
  2,
264
  19,
 
 
265
  20,
266
  13,
267
  13,
 
287
  95
288
  ],
289
  "bridges_10x10dh_714c4838.txt": [
290
+ 65,
291
+ 65,
292
+ 67,
293
+ 67,
294
+ 84,
295
+ 84,
296
+ 88,
297
  1,
298
  10,
299
  3,
 
319
  54,
320
  63,
321
  63,
 
 
 
 
322
  76,
323
  76,
 
 
 
324
  91,
325
  95,
326
  97
frontend/test/pipeline/roundtrip.test.ts CHANGED
@@ -66,15 +66,16 @@ for (const f of fixtures.filter((x) => x.family === "bridges")) {
66
  const lineLens: number[] = parsed.lines.map((l: string) => l.length);
67
  const indexOf = (r: number, c: number) =>
68
  lineLens.slice(0, r).reduce((a: number, b: number) => a + b, 0) + c;
69
- const clicks: number[] = [];
70
  for (const opp of parsed.opportunities) {
71
  const level = target[opp.id] ?? 0;
72
  if (level === 0) continue;
 
 
 
 
73
  const owned =
74
- opp.cells.find(([r, c]: [number, number]) => {
75
- const candidates = parsed.cellToOpportunities.get(`${r}:${c}`) ?? [];
76
- return candidates.length === 1 && candidates[0].id === opp.id;
77
- }) ??
78
  opp.cells.find(([r, c]: [number, number]) =>
79
  (parsed.cellToOpportunities.get(`${r}:${c}`) ?? []).some(
80
  (candidate: { id: string }) => candidate.id === opp.id,
@@ -83,8 +84,15 @@ for (const f of fixtures.filter((x) => x.family === "bridges")) {
83
  opp.cells[0];
84
  const idx = indexOf(owned[0], owned[1]);
85
  const candidates = parsed.cellToOpportunities.get(`${owned[0]}:${owned[1]}`) ?? [];
86
- const clickCount = T.bridgeClickCountForOpportunity(candidates, opp, level);
87
- for (let i = 0; i < clickCount; i += 1) clicks.push(idx);
 
 
 
 
 
 
 
88
  }
89
  bridgesClickPlan[f.filename] = clicks;
90
  }
@@ -124,18 +132,14 @@ describe("bridges generated click plans", () => {
124
  throw new Error(`click index out of bounds: ${idx}`);
125
  };
126
 
127
- let board = f.problem;
128
  let states: Record<string, 0 | 1 | 2> = {};
129
  for (const idx of bridgesClickPlan[f.filename] ?? []) {
130
  const [r, c] = indexToCoord(idx);
131
- const clickedChar = T.splitLines(board)[r]?.[c] ?? ".";
132
  states = T.cycleBridgesOpportunities(
133
  parsed,
134
  states,
135
  parsed.cellToOpportunities.get(`${r}:${c}`) ?? [],
136
- clickedChar,
137
  );
138
- board = T.bridgesBoardFromStates(f.problem, states);
139
  }
140
 
141
  for (const opportunity of parsed.opportunities) {
@@ -171,49 +175,61 @@ describe("bridges ambiguous route cells", () => {
171
  expect(vertical).toBeTruthy();
172
 
173
  let states: Record<string, 0 | 1 | 2> = {};
174
- states = T.cycleBridgesOpportunities(parsed, states, centerCandidates, ".");
175
  expect(states[horizontal!.id]).toBe(1);
176
  expect(states[vertical!.id] ?? 0).toBe(0);
177
 
178
- states = T.cycleBridgesOpportunities(parsed, states, centerCandidates, "-");
179
  expect(states[horizontal!.id]).toBe(2);
180
  expect(states[vertical!.id] ?? 0).toBe(0);
181
 
182
- states = T.cycleBridgesOpportunities(parsed, states, centerCandidates, "=");
183
  expect(states[horizontal!.id] ?? 0).toBe(0);
184
  expect(states[vertical!.id]).toBe(1);
185
 
186
- states = T.cycleBridgesOpportunities(parsed, states, centerCandidates, "|");
187
  expect(states[horizontal!.id] ?? 0).toBe(0);
188
  expect(states[vertical!.id]).toBe(2);
189
 
190
- states = T.cycleBridgesOpportunities(parsed, states, centerCandidates, "\"");
191
  expect(states[horizontal!.id] ?? 0).toBe(0);
192
  expect(states[vertical!.id] ?? 0).toBe(0);
193
  });
194
 
195
- it("cycles a shared cell through all visible states after each board render", () => {
196
  const parsed = T.parseBridges(crossingProblem);
197
  const centerCandidates = parsed.cellToOpportunities.get("2:2") ?? [];
198
- let board = crossingProblem;
199
  const visible: string[] = [];
200
 
201
  for (let i = 0; i < 5; i += 1) {
202
- const states = T.bridgesStatesFromBoard(crossingProblem, board);
203
- const nextStates = T.cycleBridgesOpportunities(
204
- parsed,
205
- states,
206
- centerCandidates,
207
- T.splitLines(board)[2]?.[2] ?? ".",
208
- );
209
- board = T.bridgesBoardFromStates(crossingProblem, nextStates);
210
  visible.push(T.splitLines(board)[2]?.[2] ?? ".");
211
  }
212
 
213
  expect(visible).toEqual(["-", "=", "|", "\"", "."]);
214
  });
215
 
216
- it("cycles a unique cell route independently", () => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  const parsed = T.parseBridges(crossingProblem);
218
  const centerCandidates = parsed.cellToOpportunities.get("2:2") ?? [];
219
  const verticalCandidates = parsed.cellToOpportunities.get("1:2") ?? [];
@@ -227,8 +243,8 @@ describe("bridges ambiguous route cells", () => {
227
  expect(vertical).toBeTruthy();
228
 
229
  let states: Record<string, 0 | 1 | 2> = {};
230
- states = T.cycleBridgesOpportunities(parsed, states, centerCandidates, ".");
231
- states = T.cycleBridgesOpportunities(parsed, states, verticalCandidates, ".");
232
 
233
  expect(states[horizontal!.id] ?? 0).toBe(0);
234
  expect(states[vertical!.id]).toBe(1);
 
66
  const lineLens: number[] = parsed.lines.map((l: string) => l.length);
67
  const indexOf = (r: number, c: number) =>
68
  lineLens.slice(0, r).reduce((a: number, b: number) => a + b, 0) + c;
69
+ const clickEntries: Array<{ clickCount: number; idx: number; ambiguousOnly: boolean }> = [];
70
  for (const opp of parsed.opportunities) {
71
  const level = target[opp.id] ?? 0;
72
  if (level === 0) continue;
73
+ const uniqueCell = opp.cells.find(([r, c]: [number, number]) => {
74
+ const candidates = parsed.cellToOpportunities.get(`${r}:${c}`) ?? [];
75
+ return candidates.length === 1 && candidates[0].id === opp.id;
76
+ });
77
  const owned =
78
+ uniqueCell ??
 
 
 
79
  opp.cells.find(([r, c]: [number, number]) =>
80
  (parsed.cellToOpportunities.get(`${r}:${c}`) ?? []).some(
81
  (candidate: { id: string }) => candidate.id === opp.id,
 
84
  opp.cells[0];
85
  const idx = indexOf(owned[0], owned[1]);
86
  const candidates = parsed.cellToOpportunities.get(`${owned[0]}:${owned[1]}`) ?? [];
87
+ clickEntries.push({
88
+ ambiguousOnly: !uniqueCell && candidates.length > 1,
89
+ clickCount: T.bridgeClickCountForOpportunity(candidates, opp, level),
90
+ idx,
91
+ });
92
+ }
93
+ const clicks: number[] = [];
94
+ for (const entry of clickEntries.sort((a, b) => Number(b.ambiguousOnly) - Number(a.ambiguousOnly))) {
95
+ for (let i = 0; i < entry.clickCount; i += 1) clicks.push(entry.idx);
96
  }
97
  bridgesClickPlan[f.filename] = clicks;
98
  }
 
132
  throw new Error(`click index out of bounds: ${idx}`);
133
  };
134
 
 
135
  let states: Record<string, 0 | 1 | 2> = {};
136
  for (const idx of bridgesClickPlan[f.filename] ?? []) {
137
  const [r, c] = indexToCoord(idx);
 
138
  states = T.cycleBridgesOpportunities(
139
  parsed,
140
  states,
141
  parsed.cellToOpportunities.get(`${r}:${c}`) ?? [],
 
142
  );
 
143
  }
144
 
145
  for (const opportunity of parsed.opportunities) {
 
175
  expect(vertical).toBeTruthy();
176
 
177
  let states: Record<string, 0 | 1 | 2> = {};
178
+ states = T.cycleBridgesOpportunities(parsed, states, centerCandidates);
179
  expect(states[horizontal!.id]).toBe(1);
180
  expect(states[vertical!.id] ?? 0).toBe(0);
181
 
182
+ states = T.cycleBridgesOpportunities(parsed, states, centerCandidates);
183
  expect(states[horizontal!.id]).toBe(2);
184
  expect(states[vertical!.id] ?? 0).toBe(0);
185
 
186
+ states = T.cycleBridgesOpportunities(parsed, states, centerCandidates);
187
  expect(states[horizontal!.id] ?? 0).toBe(0);
188
  expect(states[vertical!.id]).toBe(1);
189
 
190
+ states = T.cycleBridgesOpportunities(parsed, states, centerCandidates);
191
  expect(states[horizontal!.id] ?? 0).toBe(0);
192
  expect(states[vertical!.id]).toBe(2);
193
 
194
+ states = T.cycleBridgesOpportunities(parsed, states, centerCandidates);
195
  expect(states[horizontal!.id] ?? 0).toBe(0);
196
  expect(states[vertical!.id] ?? 0).toBe(0);
197
  });
198
 
199
+ it("keeps the shared-cell cycle across board renders", () => {
200
  const parsed = T.parseBridges(crossingProblem);
201
  const centerCandidates = parsed.cellToOpportunities.get("2:2") ?? [];
202
+ let states: Record<string, 0 | 1 | 2> = {};
203
  const visible: string[] = [];
204
 
205
  for (let i = 0; i < 5; i += 1) {
206
+ states = T.cycleBridgesOpportunities(parsed, states, centerCandidates);
207
+ const board = T.bridgesBoardFromStates(crossingProblem, states);
 
 
 
 
 
 
208
  visible.push(T.splitLines(board)[2]?.[2] ?? ".");
209
  }
210
 
211
  expect(visible).toEqual(["-", "=", "|", "\"", "."]);
212
  });
213
 
214
+
215
+ it("never leaves two active bridge routes sharing a cell", () => {
216
+ const parsed = T.parseBridges(crossingProblem);
217
+ const centerCandidates = parsed.cellToOpportunities.get("2:2") ?? [];
218
+ const verticalCandidates = parsed.cellToOpportunities.get("1:2") ?? [];
219
+ let states: Record<string, 0 | 1 | 2> = {};
220
+
221
+ for (const candidates of [centerCandidates, centerCandidates, verticalCandidates, centerCandidates]) {
222
+ states = T.cycleBridgesOpportunities(parsed, states, candidates);
223
+ for (const candidatesAtCell of parsed.cellToOpportunities.values()) {
224
+ const activeAtCell = candidatesAtCell.filter(
225
+ (opportunity: { id: string }) => (states[opportunity.id] ?? 0) > 0,
226
+ );
227
+ expect(activeAtCell.length).toBeLessThanOrEqual(1);
228
+ }
229
+ }
230
+ });
231
+
232
+ it("clears crossing bridges when cycling a unique cell route", () => {
233
  const parsed = T.parseBridges(crossingProblem);
234
  const centerCandidates = parsed.cellToOpportunities.get("2:2") ?? [];
235
  const verticalCandidates = parsed.cellToOpportunities.get("1:2") ?? [];
 
243
  expect(vertical).toBeTruthy();
244
 
245
  let states: Record<string, 0 | 1 | 2> = {};
246
+ states = T.cycleBridgesOpportunities(parsed, states, centerCandidates);
247
+ states = T.cycleBridgesOpportunities(parsed, states, verticalCandidates);
248
 
249
  expect(states[horizontal!.id] ?? 0).toBe(0);
250
  expect(states[vertical!.id]).toBe(1);