Spaces:
Running
Running
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 |
-
|
| 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 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
-
const nextEntry = sequence[(
|
| 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
|
|
|
|
|
|
|
| 558 |
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 70 |
for (const opp of parsed.opportunities) {
|
| 71 |
const level = target[opp.id] ?? 0;
|
| 72 |
if (level === 0) continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
const owned =
|
| 74 |
-
|
| 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 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 196 |
const parsed = T.parseBridges(crossingProblem);
|
| 197 |
const centerCandidates = parsed.cellToOpportunities.get("2:2") ?? [];
|
| 198 |
-
let
|
| 199 |
const visible: string[] = [];
|
| 200 |
|
| 201 |
for (let i = 0; i < 5; i += 1) {
|
| 202 |
-
|
| 203 |
-
const
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|