nilshoehing commited on
Commit
dc70f91
·
1 Parent(s): 1ecac33

Fix bridges crossing cell selection

Browse files
frontend/src/components/PuzzleEditor.tsx CHANGED
@@ -85,10 +85,31 @@ function isClueChar(char: string): boolean {
85
  return /[0-9A-G]/.test(char);
86
  }
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  function parseBridges(problemAscii: string) {
89
  const lines = splitLines(problemAscii);
90
  const opportunities: BridgesOpportunity[] = [];
91
- const cellToOpportunity = new Map<string, BridgesOpportunity>();
92
  for (let r = 0; r < lines.length; r += 1) {
93
  for (let c = 0; c < lines[r].length; c += 1) {
94
  if (!isClueChar(lines[r][c])) {
@@ -109,7 +130,7 @@ function parseBridges(problemAscii: string) {
109
  };
110
  for (let cell = c + 1; cell < next; cell += 1) {
111
  opp.cells.push([r, cell]);
112
- cellToOpportunity.set(`${r}:${cell}`, opp);
113
  }
114
  opportunities.push(opp);
115
  }
@@ -133,13 +154,106 @@ function parseBridges(problemAscii: string) {
133
  };
134
  for (let cell = r + 1; cell < nextRow; cell += 1) {
135
  opp.cells.push([cell, c]);
136
- cellToOpportunity.set(`${cell}:${c}`, opp);
137
  }
138
  opportunities.push(opp);
139
  }
140
  }
141
  }
142
- return { lines, opportunities, cellToOpportunity };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  }
144
 
145
  function bridgesBoardFromStates(
@@ -158,8 +272,7 @@ function bridgesBoardFromStates(
158
  if (level === 0) {
159
  continue;
160
  }
161
- const symbol =
162
- opportunity.kind === "horizontal" ? (level === 2 ? "=" : "-") : level === 2 ? '"' : "|";
163
  for (const [r, c] of opportunity.cells) {
164
  grid[r][c] = symbol;
165
  }
@@ -175,13 +288,7 @@ function bridgesStatesFromBoard(
175
  const boardLines = splitLines(boardAscii);
176
  const states: Record<string, 0 | 1 | 2> = {};
177
  for (const opportunity of parsed.opportunities) {
178
- const [r, c] = opportunity.cells[0];
179
- const char = boardLines[r]?.[c] ?? ".";
180
- if (opportunity.kind === "horizontal") {
181
- states[opportunity.id] = char === "=" ? 2 : char === "-" ? 1 : 0;
182
- } else {
183
- states[opportunity.id] = char === '"' ? 2 : char === "|" ? 1 : 0;
184
- }
185
  }
186
  return states;
187
  }
@@ -449,10 +556,8 @@ function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzz
449
  const parsed = parseBridges(problemAscii);
450
  const states = bridgesStatesFromBoard(problemAscii, boardAscii);
451
 
452
- function cycle(opportunity: BridgesOpportunity) {
453
- const nextStates = { ...states };
454
- const current = nextStates[opportunity.id] ?? 0;
455
- nextStates[opportunity.id] = ((current + 1) % 3) as 0 | 1 | 2;
456
  onChange(bridgesBoardFromStates(problemAscii, nextStates));
457
  }
458
 
@@ -465,11 +570,11 @@ function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzz
465
  >
466
  {parsed.lines.flatMap((line, r) =>
467
  [...line].map((char, c) => {
468
- const opportunity = parsed.cellToOpportunity.get(`${r}:${c}`);
469
  const boardChar = splitLines(boardAscii)[r]?.[c] ?? char;
470
  const className = isClueChar(char)
471
  ? "board-cell fixed island-cell"
472
- : opportunity
473
  ? "board-cell bridge-cell"
474
  : "board-cell water";
475
  const display =
@@ -479,8 +584,8 @@ function BridgesEditor({ problemAscii, boardAscii, onChange }: Omit<Props, "puzz
479
  key={`${r}-${c}`}
480
  type="button"
481
  className={className}
482
- onClick={() => opportunity && cycle(opportunity)}
483
- disabled={!opportunity}
484
  >
485
  {isClueChar(char) ? char : display}
486
  </button>
@@ -952,6 +1057,8 @@ export const __testables = {
952
  cloneGrid,
953
  joinGrid,
954
  parseBridges,
 
 
955
  bridgesBoardFromStates,
956
  bridgesStatesFromBoard,
957
  parseLoopy,
 
85
  return /[0-9A-G]/.test(char);
86
  }
87
 
88
+ function addBridgeOpportunityForCell(
89
+ cellToOpportunities: Map<string, BridgesOpportunity[]>,
90
+ r: number,
91
+ c: number,
92
+ opportunity: BridgesOpportunity,
93
+ ) {
94
+ const key = `${r}:${c}`;
95
+ cellToOpportunities.set(key, [...(cellToOpportunities.get(key) ?? []), opportunity]);
96
+ }
97
+
98
+ function bridgeSymbol(opportunity: BridgesOpportunity, level: 1 | 2): string {
99
+ return opportunity.kind === "horizontal" ? (level === 2 ? "=" : "-") : level === 2 ? '"' : "|";
100
+ }
101
+
102
+ function bridgeLevelFromChar(opportunity: BridgesOpportunity, char: string): 0 | 1 | 2 {
103
+ if (opportunity.kind === "horizontal") {
104
+ return char === "=" ? 2 : char === "-" ? 1 : 0;
105
+ }
106
+ return char === '"' ? 2 : char === "|" ? 1 : 0;
107
+ }
108
+
109
  function parseBridges(problemAscii: string) {
110
  const lines = splitLines(problemAscii);
111
  const opportunities: BridgesOpportunity[] = [];
112
+ const cellToOpportunities = new Map<string, BridgesOpportunity[]>();
113
  for (let r = 0; r < lines.length; r += 1) {
114
  for (let c = 0; c < lines[r].length; c += 1) {
115
  if (!isClueChar(lines[r][c])) {
 
130
  };
131
  for (let cell = c + 1; cell < next; cell += 1) {
132
  opp.cells.push([r, cell]);
133
+ addBridgeOpportunityForCell(cellToOpportunities, r, cell, opp);
134
  }
135
  opportunities.push(opp);
136
  }
 
154
  };
155
  for (let cell = r + 1; cell < nextRow; cell += 1) {
156
  opp.cells.push([cell, c]);
157
+ addBridgeOpportunityForCell(cellToOpportunities, cell, c, opp);
158
  }
159
  opportunities.push(opp);
160
  }
161
  }
162
  }
163
+ return { lines, opportunities, cellToOpportunities };
164
+ }
165
+
166
+ function bridgeLevelFromCells(opportunity: BridgesOpportunity, boardLines: string[]): 0 | 1 | 2 {
167
+ let level: 0 | 1 | 2 = 0;
168
+ for (const [r, c] of opportunity.cells) {
169
+ const cellLevel = bridgeLevelFromChar(opportunity, boardLines[r]?.[c] ?? ".");
170
+ if (cellLevel === 2) {
171
+ return 2;
172
+ }
173
+ if (cellLevel === 1) {
174
+ level = 1;
175
+ }
176
+ }
177
+ return level;
178
+ }
179
+
180
+ function sortedBridgeCandidates(opportunities: BridgesOpportunity[]): BridgesOpportunity[] {
181
+ return [...opportunities].sort((a, b) => {
182
+ if (a.kind !== b.kind) {
183
+ return a.kind === "horizontal" ? -1 : 1;
184
+ }
185
+ return a.id.localeCompare(b.id);
186
+ });
187
+ }
188
+
189
+ function bridgeClickCountForOpportunity(
190
+ opportunities: BridgesOpportunity[],
191
+ opportunity: BridgesOpportunity,
192
+ level: 0 | 1 | 2,
193
+ ): number {
194
+ if (level === 0 || opportunities.length <= 1) {
195
+ return level;
196
+ }
197
+ const index = sortedBridgeCandidates(opportunities).findIndex(
198
+ (candidate) => candidate.id === opportunity.id,
199
+ );
200
+ return index === -1 ? level : index * 2 + level;
201
+ }
202
+
203
+ function cycleBridgesOpportunities(
204
+ states: Record<string, 0 | 1 | 2>,
205
+ opportunities: BridgesOpportunity[],
206
+ ): Record<string, 0 | 1 | 2> {
207
+ if (opportunities.length === 0) {
208
+ return states;
209
+ }
210
+
211
+ const nextStates = { ...states };
212
+ const candidates = sortedBridgeCandidates(opportunities);
213
+
214
+ if (candidates.length === 1) {
215
+ const opportunity = candidates[0];
216
+ const level = (((nextStates[opportunity.id] ?? 0) + 1) % 3) as 0 | 1 | 2;
217
+ nextStates[opportunity.id] = level;
218
+ return nextStates;
219
+ }
220
+
221
+ const sequence: Array<{ opportunity: BridgesOpportunity; level: 1 | 2 } | null> = [
222
+ ...candidates.flatMap((opportunity) => [
223
+ { opportunity, level: 1 as const },
224
+ { opportunity, level: 2 as const },
225
+ ]),
226
+ null,
227
+ ];
228
+ const isSequenceMatch = (entry: (typeof sequence)[number]) =>
229
+ candidates.every((opportunity) => {
230
+ const expected = entry?.opportunity.id === opportunity.id ? entry.level : 0;
231
+ return (nextStates[opportunity.id] ?? 0) === expected;
232
+ });
233
+ let currentIndex = sequence.findIndex(isSequenceMatch);
234
+
235
+ if (currentIndex === -1) {
236
+ const active = candidates.find((opportunity) => (nextStates[opportunity.id] ?? 0) > 0);
237
+ currentIndex = active
238
+ ? sequence.findIndex(
239
+ (entry) =>
240
+ entry?.opportunity.id === active.id &&
241
+ entry.level === (nextStates[active.id] ?? 0),
242
+ )
243
+ : sequence.length - 1;
244
+ if (currentIndex === -1) {
245
+ currentIndex = sequence.length - 1;
246
+ }
247
+ }
248
+
249
+ const nextEntry = sequence[(currentIndex + 1) % sequence.length];
250
+ for (const opportunity of candidates) {
251
+ nextStates[opportunity.id] = 0;
252
+ }
253
+ if (nextEntry) {
254
+ nextStates[nextEntry.opportunity.id] = nextEntry.level;
255
+ }
256
+ return nextStates;
257
  }
258
 
259
  function bridgesBoardFromStates(
 
272
  if (level === 0) {
273
  continue;
274
  }
275
+ const symbol = bridgeSymbol(opportunity, level);
 
276
  for (const [r, c] of opportunity.cells) {
277
  grid[r][c] = symbol;
278
  }
 
288
  const boardLines = splitLines(boardAscii);
289
  const states: Record<string, 0 | 1 | 2> = {};
290
  for (const opportunity of parsed.opportunities) {
291
+ states[opportunity.id] = bridgeLevelFromCells(opportunity, boardLines);
 
 
 
 
 
 
292
  }
293
  return states;
294
  }
 
556
  const parsed = parseBridges(problemAscii);
557
  const states = bridgesStatesFromBoard(problemAscii, boardAscii);
558
 
559
+ function cycle(opportunities: BridgesOpportunity[]) {
560
+ const nextStates = cycleBridgesOpportunities(states, opportunities);
 
 
561
  onChange(bridgesBoardFromStates(problemAscii, nextStates));
562
  }
563
 
 
570
  >
571
  {parsed.lines.flatMap((line, r) =>
572
  [...line].map((char, c) => {
573
+ const opportunities = parsed.cellToOpportunities.get(`${r}:${c}`) ?? [];
574
  const boardChar = splitLines(boardAscii)[r]?.[c] ?? char;
575
  const className = isClueChar(char)
576
  ? "board-cell fixed island-cell"
577
+ : opportunities.length > 0
578
  ? "board-cell bridge-cell"
579
  : "board-cell water";
580
  const display =
 
584
  key={`${r}-${c}`}
585
  type="button"
586
  className={className}
587
+ onClick={() => cycle(opportunities)}
588
+ disabled={opportunities.length === 0}
589
  >
590
  {isClueChar(char) ? char : display}
591
  </button>
 
1057
  cloneGrid,
1058
  joinGrid,
1059
  parseBridges,
1060
+ cycleBridgesOpportunities,
1061
+ bridgeClickCountForOpportunity,
1062
  bridgesBoardFromStates,
1063
  bridgesStatesFromBoard,
1064
  parseLoopy,
frontend/test/pipeline/bridges_clickplan.json CHANGED
@@ -135,6 +135,8 @@
135
  22,
136
  22,
137
  17,
 
 
138
  21,
139
  37,
140
  37,
@@ -186,6 +188,10 @@
186
  33,
187
  35,
188
  35,
 
 
 
 
189
  44,
190
  44,
191
  49,
@@ -214,7 +220,7 @@
214
  15,
215
  18,
216
  18,
217
- 21,
218
  30,
219
  30,
220
  32,
@@ -229,14 +235,20 @@
229
  62,
230
  64,
231
  73,
 
 
232
  66,
233
  75,
 
 
234
  79,
235
  71,
236
  71,
237
  80,
238
  80,
239
  84,
 
 
240
  86,
241
  82,
242
  91,
@@ -263,7 +275,7 @@
263
  63,
264
  67,
265
  76,
266
- 71,
267
  80,
268
  80,
269
  82,
 
135
  22,
136
  22,
137
  17,
138
+ 17,
139
+ 17,
140
  21,
141
  37,
142
  37,
 
188
  33,
189
  35,
190
  35,
191
+ 35,
192
+ 35,
193
+ 44,
194
+ 44,
195
  44,
196
  44,
197
  49,
 
220
  15,
221
  18,
222
  18,
223
+ 22,
224
  30,
225
  30,
226
  32,
 
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,
 
275
  63,
276
  67,
277
  76,
278
+ 72,
279
  80,
280
  80,
281
  82,
frontend/test/pipeline/roundtrip.test.ts CHANGED
@@ -71,11 +71,20 @@ for (const f of fixtures.filter((x) => x.family === "bridges")) {
71
  const level = target[opp.id] ?? 0;
72
  if (level === 0) continue;
73
  const owned =
74
- opp.cells.find(
75
- ([r, c]: [number, number]) => parsed.cellToOpportunity.get(`${r}:${c}`)?.id === opp.id,
76
- ) ?? opp.cells[0];
 
 
 
 
 
 
 
77
  const idx = indexOf(owned[0], owned[1]);
78
- for (let i = 0; i < level; i += 1) clicks.push(idx);
 
 
79
  }
80
  bridgesClickPlan[f.filename] = clicks;
81
  }
@@ -97,3 +106,120 @@ describe("editor round-trip preserves a valid solution (idempotency)", () => {
97
  });
98
  }
99
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
81
+ ),
82
+ ) ??
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
  }
 
106
  });
107
  }
108
  });
109
+
110
+ describe("bridges generated click plans", () => {
111
+ for (const f of fixtures.filter((fixture) => fixture.family === "bridges")) {
112
+ it(`${f.filename}: replays to the reference solution state`, () => {
113
+ const parsed = T.parseBridges(f.problem);
114
+ const target = T.bridgesStatesFromBoard(f.problem, f.solution);
115
+ const lineLens: number[] = parsed.lines.map((line: string) => line.length);
116
+ const indexToCoord = (idx: number): [number, number] => {
117
+ let offset = idx;
118
+ for (let r = 0; r < lineLens.length; r += 1) {
119
+ if (offset < lineLens[r]) {
120
+ return [r, offset];
121
+ }
122
+ offset -= lineLens[r];
123
+ }
124
+ throw new Error(`click index out of bounds: ${idx}`);
125
+ };
126
+
127
+ let states: Record<string, 0 | 1 | 2> = {};
128
+ for (const idx of bridgesClickPlan[f.filename] ?? []) {
129
+ const [r, c] = indexToCoord(idx);
130
+ states = T.cycleBridgesOpportunities(
131
+ states,
132
+ parsed.cellToOpportunities.get(`${r}:${c}`) ?? [],
133
+ );
134
+ }
135
+
136
+ for (const opportunity of parsed.opportunities) {
137
+ expect(states[opportunity.id] ?? 0).toBe(target[opportunity.id] ?? 0);
138
+ }
139
+ });
140
+ }
141
+ });
142
+
143
+ describe("bridges ambiguous route cells", () => {
144
+ const crossingProblem = ["..2..", ".....", "2...2", ".....", "..2.."].join("\n");
145
+
146
+ it("keeps both crossing bridge opportunities selectable from the shared cell", () => {
147
+ const parsed = T.parseBridges(crossingProblem);
148
+ const centerCandidates = parsed.cellToOpportunities.get("2:2") ?? [];
149
+
150
+ expect(centerCandidates.map((candidate: { kind: string }) => candidate.kind).sort()).toEqual([
151
+ "horizontal",
152
+ "vertical",
153
+ ]);
154
+ });
155
+
156
+ it("cycles a shared cell through both bridge directions and back to empty", () => {
157
+ const parsed = T.parseBridges(crossingProblem);
158
+ const centerCandidates = parsed.cellToOpportunities.get("2:2") ?? [];
159
+ const horizontal = centerCandidates.find(
160
+ (candidate: { kind: string }) => candidate.kind === "horizontal",
161
+ );
162
+ const vertical = centerCandidates.find(
163
+ (candidate: { kind: string }) => candidate.kind === "vertical",
164
+ );
165
+ expect(horizontal).toBeTruthy();
166
+ expect(vertical).toBeTruthy();
167
+
168
+ let states: Record<string, 0 | 1 | 2> = {};
169
+ states = T.cycleBridgesOpportunities(states, centerCandidates);
170
+ expect(states[horizontal!.id]).toBe(1);
171
+ expect(states[vertical!.id] ?? 0).toBe(0);
172
+
173
+ states = T.cycleBridgesOpportunities(states, centerCandidates);
174
+ expect(states[horizontal!.id]).toBe(2);
175
+ expect(states[vertical!.id] ?? 0).toBe(0);
176
+
177
+ states = T.cycleBridgesOpportunities(states, centerCandidates);
178
+ expect(states[horizontal!.id] ?? 0).toBe(0);
179
+ expect(states[vertical!.id]).toBe(1);
180
+
181
+ states = T.cycleBridgesOpportunities(states, centerCandidates);
182
+ expect(states[horizontal!.id] ?? 0).toBe(0);
183
+ expect(states[vertical!.id]).toBe(2);
184
+
185
+ states = T.cycleBridgesOpportunities(states, centerCandidates);
186
+ expect(states[horizontal!.id] ?? 0).toBe(0);
187
+ expect(states[vertical!.id] ?? 0).toBe(0);
188
+ });
189
+
190
+ it("cycles a unique cell route independently", () => {
191
+ const parsed = T.parseBridges(crossingProblem);
192
+ const centerCandidates = parsed.cellToOpportunities.get("2:2") ?? [];
193
+ const verticalCandidates = parsed.cellToOpportunities.get("1:2") ?? [];
194
+ const horizontal = centerCandidates.find(
195
+ (candidate: { kind: string }) => candidate.kind === "horizontal",
196
+ );
197
+ const vertical = centerCandidates.find(
198
+ (candidate: { kind: string }) => candidate.kind === "vertical",
199
+ );
200
+ expect(horizontal).toBeTruthy();
201
+ expect(vertical).toBeTruthy();
202
+
203
+ let states: Record<string, 0 | 1 | 2> = {};
204
+ states = T.cycleBridgesOpportunities(states, centerCandidates);
205
+ states = T.cycleBridgesOpportunities(states, verticalCandidates);
206
+
207
+ expect(states[horizontal!.id]).toBe(1);
208
+ expect(states[vertical!.id]).toBe(1);
209
+ });
210
+
211
+ it("decodes bridge state from non-leading route cells", () => {
212
+ const parsed = T.parseBridges(crossingProblem);
213
+ const horizontal = parsed.cellToOpportunities
214
+ .get("2:2")
215
+ ?.find((candidate: { kind: string }) => candidate.kind === "horizontal");
216
+ expect(horizontal).toBeTruthy();
217
+
218
+ const states = T.bridgesStatesFromBoard(
219
+ crossingProblem,
220
+ ["..2..", ".....", "2..-2", ".....", "..2.."].join("\n"),
221
+ );
222
+
223
+ expect(states[horizontal!.id]).toBe(1);
224
+ });
225
+ });