Spaces:
Running
Running
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
|
| 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 |
-
|
| 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 |
-
|
| 137 |
}
|
| 138 |
opportunities.push(opp);
|
| 139 |
}
|
| 140 |
}
|
| 141 |
}
|
| 142 |
-
return { lines, opportunities,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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(
|
| 453 |
-
const nextStates =
|
| 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
|
| 469 |
const boardChar = splitLines(boardAscii)[r]?.[c] ?? char;
|
| 470 |
const className = isClueChar(char)
|
| 471 |
? "board-cell fixed island-cell"
|
| 472 |
-
:
|
| 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={() =>
|
| 483 |
-
disabled={
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
const idx = indexOf(owned[0], owned[1]);
|
| 78 |
-
|
|
|
|
|
|
|
| 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 |
+
});
|