bridges-cli 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +8 -6
- package/README.md +14 -7
- package/package.json +4 -4
- package/src/Game.tsx +169 -13
- package/src/__tests__/Game.test.tsx +681 -22
- package/src/components/HashiGrid.tsx +46 -44
- package/src/components/HashiRow.tsx +67 -66
- package/src/components/Header.tsx +48 -2
- package/src/components/Messages.tsx +48 -8
- package/src/components/__tests__/HashiGrid.test.tsx +1 -0
- package/src/components/__tests__/HashiRow.test.tsx +128 -115
- package/src/components/__tests__/Header.test.tsx +156 -0
- package/src/components/__tests__/Messages.test.tsx +71 -3
- package/src/demo.tsx +20 -0
- package/src/index.tsx +31 -5
- package/src/types.ts +34 -0
- package/src/utils/__tests__/bridges.test.tsx +137 -0
- package/src/utils/__tests__/findNodeInDirection.test.ts +75 -0
- package/src/utils/__tests__/{parsePuzzle.test.ts → puzzle-encoding.test.ts} +1 -1
- package/src/utils/bridges.ts +395 -0
- package/src/utils/puzzle-encoding.ts +368 -0
- package/src/utils/usePuzzleInput.ts +387 -13
- package/src/utils/parsePuzzle.ts +0 -178
- package/src/utils/samplePuzzles.ts +0 -59
|
@@ -1,32 +1,406 @@
|
|
|
1
1
|
import { useApp, useInput } from 'ink'
|
|
2
|
-
import { useRef } from 'react'
|
|
2
|
+
import { useCallback, useRef, useState } from 'react'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
import type { Direction, PlacedBridge, SelectionState } from '../types.ts'
|
|
5
|
+
|
|
6
|
+
type UsePuzzleInputProps = {
|
|
7
|
+
puzzleIndex: number
|
|
8
|
+
puzzlesLength: number
|
|
9
|
+
rows: { value: number | '-' | '=' | '#' | ' ' | '|' }[][]
|
|
10
|
+
showSolution: boolean
|
|
11
|
+
enableSolutions: boolean
|
|
12
|
+
onPrev: () => void
|
|
13
|
+
onNext: () => void
|
|
9
14
|
onToggleSolution: () => void
|
|
15
|
+
onBridgePlaced?: (bridge: PlacedBridge) => boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findMatchingNodes(
|
|
19
|
+
rows: { value: number | '-' | '=' | '#' | ' ' | '|' }[][],
|
|
20
|
+
number: number
|
|
10
21
|
) {
|
|
22
|
+
const matches: { row: number; col: number }[] = []
|
|
23
|
+
for (let row = 0; row < rows.length; row++) {
|
|
24
|
+
const currentRow = rows[row]
|
|
25
|
+
if (!currentRow) continue
|
|
26
|
+
for (let col = 0; col < currentRow.length; col++) {
|
|
27
|
+
const cell = currentRow[col]
|
|
28
|
+
if (cell && cell.value === number) {
|
|
29
|
+
matches.push({ row, col })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return matches
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function generateLabels(count: number): string[] {
|
|
37
|
+
const labels: string[] = []
|
|
38
|
+
for (let i = 0; i < count; i++) {
|
|
39
|
+
labels.push(String.fromCharCode(97 + i)) // a, b, c, ...
|
|
40
|
+
}
|
|
41
|
+
return labels
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function findNodeInDirection(
|
|
45
|
+
rows: { value: number | '-' | '=' | '#' | ' ' | '|' }[][],
|
|
46
|
+
fromRow: number,
|
|
47
|
+
fromCol: number,
|
|
48
|
+
direction: Direction
|
|
49
|
+
): { row: number; col: number } | null {
|
|
50
|
+
const rowCount = rows.length
|
|
51
|
+
const firstRow = rows[0]
|
|
52
|
+
if (!firstRow) return null
|
|
53
|
+
const colCount = firstRow.length
|
|
54
|
+
|
|
55
|
+
let checkRow = fromRow
|
|
56
|
+
let checkCol = fromCol
|
|
57
|
+
|
|
58
|
+
if (direction === 'h') {
|
|
59
|
+
// left
|
|
60
|
+
checkCol = fromCol - 1
|
|
61
|
+
while (checkCol >= 0) {
|
|
62
|
+
const row = rows[fromRow]
|
|
63
|
+
if (!row) return null
|
|
64
|
+
const cell = row[checkCol]
|
|
65
|
+
if (cell) {
|
|
66
|
+
if (cell.value === '|' || cell.value === '#') {
|
|
67
|
+
// Bridge in the way - invalid
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
if (typeof cell.value === 'number') {
|
|
71
|
+
return { row: fromRow, col: checkCol }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
checkCol--
|
|
75
|
+
}
|
|
76
|
+
} else if (direction === 'l') {
|
|
77
|
+
// right
|
|
78
|
+
checkCol = fromCol + 1
|
|
79
|
+
while (checkCol < colCount) {
|
|
80
|
+
const row = rows[fromRow]
|
|
81
|
+
if (!row) return null
|
|
82
|
+
const cell = row[checkCol]
|
|
83
|
+
if (cell) {
|
|
84
|
+
if (cell.value === '|' || cell.value === '#') {
|
|
85
|
+
// Bridge in the way - invalid
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
if (typeof cell.value === 'number') {
|
|
89
|
+
return { row: fromRow, col: checkCol }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
checkCol++
|
|
93
|
+
}
|
|
94
|
+
} else if (direction === 'j') {
|
|
95
|
+
// down
|
|
96
|
+
checkRow = fromRow + 1
|
|
97
|
+
while (checkRow < rowCount) {
|
|
98
|
+
const row = rows[checkRow]
|
|
99
|
+
if (!row) return null
|
|
100
|
+
const cell = row[fromCol]
|
|
101
|
+
if (cell) {
|
|
102
|
+
if (cell.value === '-' || cell.value === '=') {
|
|
103
|
+
// Bridge in the way - invalid
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
if (typeof cell.value === 'number') {
|
|
107
|
+
return { row: checkRow, col: fromCol }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
checkRow++
|
|
111
|
+
}
|
|
112
|
+
} else if (direction === 'k') {
|
|
113
|
+
// up
|
|
114
|
+
checkRow = fromRow - 1
|
|
115
|
+
while (checkRow >= 0) {
|
|
116
|
+
const row = rows[checkRow]
|
|
117
|
+
if (!row) return null
|
|
118
|
+
const cell = row[fromCol]
|
|
119
|
+
if (cell) {
|
|
120
|
+
if (cell.value === '-' || cell.value === '=') {
|
|
121
|
+
// Bridge in the way - invalid
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
if (typeof cell.value === 'number') {
|
|
125
|
+
return { row: checkRow, col: fromCol }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
checkRow--
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default function usePuzzleInput({
|
|
136
|
+
puzzleIndex,
|
|
137
|
+
puzzlesLength,
|
|
138
|
+
rows,
|
|
139
|
+
showSolution,
|
|
140
|
+
enableSolutions,
|
|
141
|
+
onPrev,
|
|
142
|
+
onNext,
|
|
143
|
+
onToggleSolution,
|
|
144
|
+
onBridgePlaced,
|
|
145
|
+
}: UsePuzzleInputProps) {
|
|
11
146
|
const { exit } = useApp()
|
|
12
147
|
const puzzleIndexRef = useRef(puzzleIndex)
|
|
13
148
|
puzzleIndexRef.current = puzzleIndex
|
|
14
149
|
|
|
15
|
-
|
|
150
|
+
const showSolutionRef = useRef(showSolution)
|
|
151
|
+
showSolutionRef.current = showSolution
|
|
152
|
+
|
|
153
|
+
const [selectionState, setSelectionState] = useState<SelectionState>({
|
|
154
|
+
mode: 'idle',
|
|
155
|
+
selectedNumber: null,
|
|
156
|
+
direction: null,
|
|
157
|
+
matchingNodes: [],
|
|
158
|
+
disambiguationLabels: [],
|
|
159
|
+
selectedNode: null,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Use a ref to track mode for synchronous access in input handler
|
|
163
|
+
const selectionStateRef = useRef(selectionState)
|
|
164
|
+
selectionStateRef.current = selectionState
|
|
165
|
+
|
|
166
|
+
// Store timeout ID so we can cancel it when user provides input
|
|
167
|
+
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
168
|
+
|
|
169
|
+
const resetSelection = useCallback(() => {
|
|
170
|
+
if (resetTimeoutRef.current) {
|
|
171
|
+
clearTimeout(resetTimeoutRef.current)
|
|
172
|
+
resetTimeoutRef.current = null
|
|
173
|
+
}
|
|
174
|
+
setSelectionState({
|
|
175
|
+
mode: 'idle',
|
|
176
|
+
selectedNumber: null,
|
|
177
|
+
direction: null,
|
|
178
|
+
matchingNodes: [],
|
|
179
|
+
disambiguationLabels: [],
|
|
180
|
+
selectedNode: null,
|
|
181
|
+
})
|
|
182
|
+
}, [])
|
|
183
|
+
|
|
184
|
+
// Clear the reset timeout when user changes mode (e.g., starts a new selection)
|
|
185
|
+
const clearResetTimeout = useCallback(() => {
|
|
186
|
+
if (resetTimeoutRef.current) {
|
|
187
|
+
clearTimeout(resetTimeoutRef.current)
|
|
188
|
+
resetTimeoutRef.current = null
|
|
189
|
+
}
|
|
190
|
+
}, [])
|
|
191
|
+
|
|
192
|
+
useInput((input, key) => {
|
|
193
|
+
// q always quits
|
|
16
194
|
if (input === 'q') {
|
|
17
195
|
exit()
|
|
196
|
+
return
|
|
18
197
|
}
|
|
19
198
|
|
|
20
|
-
|
|
21
|
-
onNext()
|
|
22
|
-
}
|
|
199
|
+
const currentMode = selectionStateRef.current.mode
|
|
23
200
|
|
|
24
|
-
|
|
25
|
-
|
|
201
|
+
// If not idle, handle selection keys
|
|
202
|
+
if (currentMode !== 'idle') {
|
|
203
|
+
// Esc resets to idle
|
|
204
|
+
if (key.escape) {
|
|
205
|
+
resetSelection()
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// In disambiguation mode, handle a-z
|
|
210
|
+
if (currentMode === 'disambiguation') {
|
|
211
|
+
const labelIndex = input.charCodeAt(0) - 97 // a=0, b=1, ...
|
|
212
|
+
const matches = selectionStateRef.current.matchingNodes
|
|
213
|
+
if (labelIndex >= 0 && labelIndex < matches.length) {
|
|
214
|
+
// Selected! Now enter selecting-node mode to choose direction
|
|
215
|
+
setSelectionState({
|
|
216
|
+
...selectionStateRef.current,
|
|
217
|
+
mode: 'selecting-node',
|
|
218
|
+
disambiguationLabels: [],
|
|
219
|
+
selectedNode: matches[labelIndex] ?? null,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// In selecting-node mode, handle direction keys
|
|
226
|
+
if (
|
|
227
|
+
currentMode === 'selecting-node' &&
|
|
228
|
+
selectionStateRef.current.selectedNumber !== null
|
|
229
|
+
) {
|
|
230
|
+
if (
|
|
231
|
+
input === 'h' ||
|
|
232
|
+
input === 'j' ||
|
|
233
|
+
input === 'k' ||
|
|
234
|
+
input === 'l' ||
|
|
235
|
+
input === 'H' ||
|
|
236
|
+
input === 'J' ||
|
|
237
|
+
input === 'K' ||
|
|
238
|
+
input === 'L'
|
|
239
|
+
) {
|
|
240
|
+
const isDouble = input === input.toUpperCase()
|
|
241
|
+
const direction = input.toLowerCase() as Direction
|
|
242
|
+
const selectedNode = selectionStateRef.current.selectedNode
|
|
243
|
+
const targetNode = selectedNode
|
|
244
|
+
? findNodeInDirection(rows, selectedNode.row, selectedNode.col, direction)
|
|
245
|
+
: null
|
|
246
|
+
|
|
247
|
+
let erased = false
|
|
248
|
+
if (targetNode && selectedNode && onBridgePlaced) {
|
|
249
|
+
// Toggle the bridge (add if not exists, remove if exists)
|
|
250
|
+
// The callback returns true if a bridge was erased
|
|
251
|
+
erased = onBridgePlaced({
|
|
252
|
+
from: selectedNode,
|
|
253
|
+
to: targetNode,
|
|
254
|
+
count: isDouble ? 2 : 1,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Show selected/invalid state, then reset after 1.5s
|
|
259
|
+
setSelectionState({
|
|
260
|
+
...selectionStateRef.current,
|
|
261
|
+
mode: targetNode ? 'selected' : 'invalid',
|
|
262
|
+
direction,
|
|
263
|
+
bridgeErased: erased,
|
|
264
|
+
isDoubleBridge: isDouble,
|
|
265
|
+
})
|
|
266
|
+
resetTimeoutRef.current = setTimeout(resetSelection, 1_500)
|
|
267
|
+
}
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// In selected/invalid mode, allow immediate input for next action
|
|
272
|
+
if (currentMode === 'selected' || currentMode === 'invalid') {
|
|
273
|
+
// Clear any pending timeout since user is providing input
|
|
274
|
+
clearResetTimeout()
|
|
275
|
+
|
|
276
|
+
// Allow n/p/s navigation
|
|
277
|
+
if (input === 'n' && puzzleIndexRef.current + 1 < puzzlesLength) {
|
|
278
|
+
onNext()
|
|
279
|
+
resetSelection()
|
|
280
|
+
return
|
|
281
|
+
} else if (input === 'p' && puzzleIndexRef.current - 1 >= 0) {
|
|
282
|
+
onPrev()
|
|
283
|
+
resetSelection()
|
|
284
|
+
return
|
|
285
|
+
} else if (input === 's' && enableSolutions) {
|
|
286
|
+
onToggleSolution()
|
|
287
|
+
resetSelection()
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Allow number keys to start a new selection
|
|
292
|
+
if (input >= '1' && input <= '8') {
|
|
293
|
+
if (showSolutionRef.current) return
|
|
294
|
+
|
|
295
|
+
const num = parseInt(input, 10)
|
|
296
|
+
const matches = findMatchingNodes(rows, num)
|
|
297
|
+
if (matches.length > 0) {
|
|
298
|
+
if (matches.length === 1) {
|
|
299
|
+
setSelectionState({
|
|
300
|
+
mode: 'selecting-node',
|
|
301
|
+
selectedNumber: num,
|
|
302
|
+
direction: null,
|
|
303
|
+
matchingNodes: matches,
|
|
304
|
+
disambiguationLabels: [],
|
|
305
|
+
selectedNode: matches[0] ?? null,
|
|
306
|
+
})
|
|
307
|
+
} else {
|
|
308
|
+
setSelectionState({
|
|
309
|
+
mode: 'disambiguation',
|
|
310
|
+
selectedNumber: num,
|
|
311
|
+
direction: null,
|
|
312
|
+
matchingNodes: matches,
|
|
313
|
+
disambiguationLabels: generateLabels(matches.length),
|
|
314
|
+
selectedNode: null,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Allow direction keys to draw another bridge immediately
|
|
322
|
+
if (
|
|
323
|
+
input === 'h' ||
|
|
324
|
+
input === 'j' ||
|
|
325
|
+
input === 'k' ||
|
|
326
|
+
input === 'l' ||
|
|
327
|
+
input === 'H' ||
|
|
328
|
+
input === 'J' ||
|
|
329
|
+
input === 'K' ||
|
|
330
|
+
input === 'L'
|
|
331
|
+
) {
|
|
332
|
+
// Get the previously selected node to use as starting point
|
|
333
|
+
const prevNode = selectionStateRef.current.selectedNode
|
|
334
|
+
if (prevNode) {
|
|
335
|
+
const isDouble = input === input.toUpperCase()
|
|
336
|
+
const direction = input.toLowerCase() as Direction
|
|
337
|
+
const targetNode = findNodeInDirection(
|
|
338
|
+
rows,
|
|
339
|
+
prevNode.row,
|
|
340
|
+
prevNode.col,
|
|
341
|
+
direction
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
let erased = false
|
|
345
|
+
if (targetNode && onBridgePlaced) {
|
|
346
|
+
erased = onBridgePlaced({
|
|
347
|
+
from: prevNode,
|
|
348
|
+
to: targetNode,
|
|
349
|
+
count: isDouble ? 2 : 1,
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
setSelectionState({
|
|
354
|
+
...selectionStateRef.current,
|
|
355
|
+
mode: targetNode ? 'selected' : 'invalid',
|
|
356
|
+
direction,
|
|
357
|
+
bridgeErased: erased,
|
|
358
|
+
isDoubleBridge: isDouble,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
}
|
|
26
364
|
}
|
|
27
365
|
|
|
28
|
-
|
|
366
|
+
// Normal mode key handling
|
|
367
|
+
if (input === 'n' && puzzleIndexRef.current + 1 < puzzlesLength) {
|
|
368
|
+
onNext()
|
|
369
|
+
} else if (input === 'p' && puzzleIndexRef.current - 1 >= 0) {
|
|
370
|
+
onPrev()
|
|
371
|
+
} else if (input === 's' && enableSolutions) {
|
|
29
372
|
onToggleSolution()
|
|
373
|
+
} else if (input >= '1' && input <= '8') {
|
|
374
|
+
if (showSolutionRef.current) return
|
|
375
|
+
|
|
376
|
+
// Number pressed - enter selecting-node or disambiguation mode
|
|
377
|
+
const num = parseInt(input, 10)
|
|
378
|
+
const matches = findMatchingNodes(rows, num)
|
|
379
|
+
if (matches.length > 0) {
|
|
380
|
+
if (matches.length === 1) {
|
|
381
|
+
// Single match - go directly to selecting-node mode
|
|
382
|
+
setSelectionState({
|
|
383
|
+
mode: 'selecting-node',
|
|
384
|
+
selectedNumber: num,
|
|
385
|
+
direction: null,
|
|
386
|
+
matchingNodes: matches,
|
|
387
|
+
disambiguationLabels: [],
|
|
388
|
+
selectedNode: matches[0] ?? null,
|
|
389
|
+
})
|
|
390
|
+
} else {
|
|
391
|
+
// Multiple matches - enter disambiguation mode
|
|
392
|
+
setSelectionState({
|
|
393
|
+
mode: 'disambiguation',
|
|
394
|
+
selectedNumber: num,
|
|
395
|
+
direction: null,
|
|
396
|
+
matchingNodes: matches,
|
|
397
|
+
disambiguationLabels: generateLabels(matches.length),
|
|
398
|
+
selectedNode: null,
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
}
|
|
30
402
|
}
|
|
31
403
|
})
|
|
404
|
+
|
|
405
|
+
return { selectionState, resetSelection }
|
|
32
406
|
}
|
package/src/utils/parsePuzzle.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import type { HashiNodeData } from '../types.ts'
|
|
2
|
-
|
|
3
|
-
function letterToNumber(char: string): number {
|
|
4
|
-
return char.charCodeAt(0) - 96
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Parses a puzzle encoding string into a 2D array of HashiNodeData.
|
|
9
|
-
*
|
|
10
|
-
* Encoding format: "WIDTHxHEIGHT:row1.row2.row3..."
|
|
11
|
-
* - Digits (0-9): explicit node values
|
|
12
|
-
* - Letters (a-z): skip positions (a=1, b=2, c=3, etc.)
|
|
13
|
-
* - "-": single horizontal line (connects to adjacent nodes)
|
|
14
|
-
* - "=": double horizontal line (connects to adjacent nodes)
|
|
15
|
-
* - "|": single vertical line (connects to node in row below)
|
|
16
|
-
* - "#": double vertical line (connects to node in row below)
|
|
17
|
-
*
|
|
18
|
-
* The letter repeat rule applies to lines: "-c" means 3 single horizontal lines
|
|
19
|
-
*
|
|
20
|
-
* Example: "3x3:1-1.1a|" creates a 3x3 grid with horizontal and vertical bridges
|
|
21
|
-
*/
|
|
22
|
-
export function parsePuzzle(encoding: string): HashiNodeData[][] {
|
|
23
|
-
const parts = encoding.split(':')
|
|
24
|
-
const dimensions = parts[0] || ''
|
|
25
|
-
const rest = parts[1] || ''
|
|
26
|
-
|
|
27
|
-
// Parse string for row width
|
|
28
|
-
const match: RegExpMatchArray | null = dimensions.match(/(\d+)x(\d+)/)
|
|
29
|
-
let numNodes = 0
|
|
30
|
-
if (match !== null && match[1] !== undefined) {
|
|
31
|
-
numNodes = parseInt(match[1], 10)
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Split grid data into rows
|
|
35
|
-
const rowStrings = rest.split('.')
|
|
36
|
-
const rows: HashiNodeData[][] = []
|
|
37
|
-
|
|
38
|
-
for (const rowStr of rowStrings) {
|
|
39
|
-
const nodes: HashiNodeData[] = Array(numNodes)
|
|
40
|
-
.fill(null)
|
|
41
|
-
.map(() => ({ value: ' ' }))
|
|
42
|
-
let position = 0
|
|
43
|
-
let i = 0
|
|
44
|
-
|
|
45
|
-
// Parse each character in the encoding
|
|
46
|
-
while (i < rowStr.length && position < numNodes) {
|
|
47
|
-
const char = rowStr[i] ?? ''
|
|
48
|
-
const charCode = char.charCodeAt(0)
|
|
49
|
-
|
|
50
|
-
// Digit: create a node with the numeric value
|
|
51
|
-
if (charCode >= 48 && charCode <= 57) {
|
|
52
|
-
const value = Number(char)
|
|
53
|
-
// Check if previous position has a line node and set lineLeft
|
|
54
|
-
if (
|
|
55
|
-
position > 0 &&
|
|
56
|
-
(nodes[position - 1]!.value === '-' || nodes[position - 1]!.value === '=')
|
|
57
|
-
) {
|
|
58
|
-
const lineCount: 1 | 2 = nodes[position - 1]!.value === '=' ? 2 : 1
|
|
59
|
-
nodes[position] = { value, lineLeft: lineCount }
|
|
60
|
-
} else {
|
|
61
|
-
nodes[position] = { value }
|
|
62
|
-
}
|
|
63
|
-
position++
|
|
64
|
-
i++
|
|
65
|
-
} else if (char === '-' || char === '=') {
|
|
66
|
-
// Horizontal line: single (-) or double (=)
|
|
67
|
-
const lineCount: 1 | 2 = char === '=' ? 2 : 1
|
|
68
|
-
|
|
69
|
-
// Set lineRight on current position if it's a number node
|
|
70
|
-
if (position > 0 && typeof nodes[position - 1]!.value === 'number') {
|
|
71
|
-
nodes[position - 1] = { ...nodes[position - 1]!, lineRight: lineCount }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Create the line node
|
|
75
|
-
nodes[position] = { value: char as '-' | '=' }
|
|
76
|
-
|
|
77
|
-
// Set lineRight on the line node and lineLeft on next position if it's a number node
|
|
78
|
-
if (position < numNodes - 1 && typeof nodes[position + 1]!.value === 'number') {
|
|
79
|
-
nodes[position + 1] = { ...nodes[position + 1]!, lineLeft: lineCount }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Check if next char is a letter (repeat count)
|
|
83
|
-
const nextChar = rowStr[i + 1]
|
|
84
|
-
if (nextChar && nextChar >= 'a' && nextChar <= 'z') {
|
|
85
|
-
const repeat = letterToNumber(nextChar)
|
|
86
|
-
// Create repeated line nodes (the first is already at 'position')
|
|
87
|
-
// We need to create 'repeat' more nodes starting at position+1
|
|
88
|
-
for (let r = 1; r <= repeat && position + r < numNodes; r++) {
|
|
89
|
-
nodes[position + r] = { value: char as '-' | '=' }
|
|
90
|
-
// Set lineLeft/lineRight on adjacent number nodes
|
|
91
|
-
if (
|
|
92
|
-
position + r > 0 &&
|
|
93
|
-
typeof nodes[position + r - 1]!.value === 'number'
|
|
94
|
-
) {
|
|
95
|
-
nodes[position + r - 1] = {
|
|
96
|
-
...nodes[position + r - 1]!,
|
|
97
|
-
lineRight: lineCount,
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
if (
|
|
101
|
-
position + r < numNodes - 1 &&
|
|
102
|
-
typeof nodes[position + r + 1]!.value === 'number'
|
|
103
|
-
) {
|
|
104
|
-
nodes[position + r + 1] = {
|
|
105
|
-
...nodes[position + r + 1]!,
|
|
106
|
-
lineLeft: lineCount,
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
// Set lineLeft on the last line node if next position has a number
|
|
111
|
-
const lastLinePos = position + repeat
|
|
112
|
-
if (
|
|
113
|
-
lastLinePos < numNodes - 1 &&
|
|
114
|
-
typeof nodes[lastLinePos + 1]!.value === 'number'
|
|
115
|
-
) {
|
|
116
|
-
nodes[lastLinePos + 1] = { ...nodes[lastLinePos + 1]!, lineLeft: lineCount }
|
|
117
|
-
}
|
|
118
|
-
position += repeat + 1
|
|
119
|
-
i += 2
|
|
120
|
-
} else {
|
|
121
|
-
position++
|
|
122
|
-
i++
|
|
123
|
-
}
|
|
124
|
-
} else if (char === '|' || char === '#') {
|
|
125
|
-
// Vertical line: single (|) or double (#)
|
|
126
|
-
// Create the line node (connections handled in second pass)
|
|
127
|
-
// Note: vertical lines do not support letter repeat
|
|
128
|
-
nodes[position] = { value: char as '|' | '#' }
|
|
129
|
-
position++
|
|
130
|
-
i++
|
|
131
|
-
} else {
|
|
132
|
-
// Letter: skip positions based on letter-to-number mapping
|
|
133
|
-
position += letterToNumber(char)
|
|
134
|
-
i++
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Fill any remaining undefined positions with empty nodes
|
|
139
|
-
for (let j = 0; j < numNodes; j++) {
|
|
140
|
-
if (nodes[j] === undefined || nodes[j] === null) {
|
|
141
|
-
nodes[j] = { value: ' ' }
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
rows.push(nodes)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Second pass: handle vertical line connections
|
|
149
|
-
// For each vertical line, connect to the number nodes above and below
|
|
150
|
-
for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
|
|
151
|
-
const currentRow = rows[rowIdx]!
|
|
152
|
-
|
|
153
|
-
for (let colIdx = 0; colIdx < currentRow.length; colIdx++) {
|
|
154
|
-
const node = currentRow[colIdx]!
|
|
155
|
-
if (node.value === '|' || node.value === '#') {
|
|
156
|
-
const lineCount: 1 | 2 = node.value === '#' ? 2 : 1
|
|
157
|
-
|
|
158
|
-
// Set lineDown on the number node in the row above (if exists)
|
|
159
|
-
if (rowIdx > 0) {
|
|
160
|
-
const nodeAbove = rows[rowIdx - 1]![colIdx]!
|
|
161
|
-
if (typeof nodeAbove.value === 'number') {
|
|
162
|
-
rows[rowIdx - 1]![colIdx] = { ...nodeAbove, lineDown: lineCount }
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Set lineUp on the number node in the row below (if exists)
|
|
167
|
-
if (rowIdx < rows.length - 1) {
|
|
168
|
-
const nodeBelow = rows[rowIdx + 1]![colIdx]!
|
|
169
|
-
if (typeof nodeBelow.value === 'number') {
|
|
170
|
-
rows[rowIdx + 1]![colIdx] = { ...nodeBelow, lineUp: lineCount }
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return rows
|
|
178
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
export interface PuzzleData {
|
|
2
|
-
encoding: string
|
|
3
|
-
solution?: string
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export const samplePuzzles: PuzzleData[] = [
|
|
7
|
-
{
|
|
8
|
-
encoding: '7x7:4a3a3a3.a2c4a.3b3b3.g.2b8a4a.d1a3.a1a4a1a',
|
|
9
|
-
solution: '7x7:4=3-3=3.#2=b4|.3-a3a#3.c#a##.2=a8=4#.c#1-3.a1-4-1a',
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
encoding: '9x9:3c1a3a3.b2b3c.3c2a2b.e4b6.3a4a8b3a.a1a3a2c.3a3a2b1a.c4a5b3.3a5a3b2a',
|
|
13
|
-
solution: '9x9:3-b1a3-3.#a2-a3#a#.3a|a2#2a#.|a|a#4=a6.3-4=8=a3#.|1-3#2a|#.3-3#2#a1#.|a#4=5-a3.3=5-3=a2a'
|
|
14
|
-
},
|
|
15
|
-
{ encoding: '9x9:2a5a6b2a.a1d1a2.3a2b1a2a.a4b8a4b.3g3.b3a3b2a.a2a1b3a3.1a3d2a.a3b4a3a2' },
|
|
16
|
-
{ encoding: '9x9:4b4a5b3.a2b3a1b.2h.a2a1a4c.4a4a3a2a3.c2a8a4a.h2.b2a1b2a.3b2a4b2' },
|
|
17
|
-
{ encoding: '9x9:a3d3a3.1a2a4b1a.a4a4a1b4.d4b3a.3b3b2b.a1f3.6a2a2a3b.c2c2a.3a2b2b3' },
|
|
18
|
-
{ encoding: '9x9:a2a3b6a2.b2a3d.a1g.b1c3a3.4c8b3a.e1c.3a3a2d.g1a.b3b4b4' },
|
|
19
|
-
{ encoding: '9x9:4b6b2a2.i.3a2a2c6.c3b2b.b5a4c4.f2b.2a8a4c3.c1b2b.b4e3' },
|
|
20
|
-
{ encoding: '9x9:a3b5b1a.1b2a2b3.i.2c2a2a6.a3a8a4a1a.5a2a4c5.i.4b4a2b3.a1b4b1a' },
|
|
21
|
-
{ encoding: '9x9:3c1a3a3.b2b3c.3c2a2b.e4b6.3a4a8b3a.a1a3a2c.3a3a2b1a.c4a5b3.3a5a3b2a' },
|
|
22
|
-
{ encoding: '9x9:4a3b3a1a.a3b4a1a3.4h.b3a3a4a6.a1a1a2a2a.5a8a5a3b.h3.1a2a4b4a.a1a2b3a3' },
|
|
23
|
-
{ encoding: '9x9:a1b4b3a.3b4d1.d2a1b.a3a8a3a4a.2c1c3.a4a3a3a4a.b1a3d.1d3b4.a4b4b1a' },
|
|
24
|
-
{ encoding: '9x9:2a5a6b2a.a1d1a2.3a2b1a2a.a4b8a4b.3g3.b3a3b2a.a2a1b3a3.1a3d2a.a3b4a3a2' },
|
|
25
|
-
{ encoding: '9x9:3a3a2c1.a3d3b.b2b1c.4b2b8a4.a2e2a.4a5b1b2.c1b3b.b3b2a3a.2c2a3a3' },
|
|
26
|
-
{ encoding: '9x9:4a2b1b3.a2b3a3b.5a4b1c.h3.b5a4a5b.1h.c1b3a4.b2a3b2a.1b3b2a3' },
|
|
27
|
-
{ encoding: '9x9:1a2a3a4a3.a2a4a4a2a.1c2d.a5a4a2b3.b3a8b3a.3b1d3.b1b3a3a.a2b2d.4b4a3b3' },
|
|
28
|
-
{ encoding: '9x9:a2b3a4a4.3a4b2a2a.a2a5b4b.5a4d3a.c2a3c.a2d2a4.b3b3a2a.a4a3b2a2.3a2a2b2a' },
|
|
29
|
-
{ encoding: '9x9:1a1a4a3a2.a3a2a2a3a.2g3.a2b3b3a.3b4a3b6.a3b4b3a.2g3.a4a2a1a2a.3a2a3a3a3' },
|
|
30
|
-
{ encoding: '9x9:2a3a4a3a2.a2a2a2a1a.b1a3d.2d1b2.a3b8b4a.4b4d2.d3a2b.a1a4a1a1a.1a1a2a3a2' },
|
|
31
|
-
{ encoding: '9x9:3a4a3a2a2.a2c3a3a.2a1e2.a3b2a1b.4a2b2a1a.a1b2a5a4.b2b3c.3c1a2b.a2a3a3b3' },
|
|
32
|
-
{ encoding: '9x9:2b4b5a4.b3b4a2a.f1b.1a2a3c4.a4a3a3a3a.2c4a5a4.b1f.a2a2b3b.3a5b4b3' },
|
|
33
|
-
{ encoding: '9x9:4a3a3b3a.a1a3b3a2.4a2a2b2a.a1a1e.2a5a8a4a4.e2a2a.a3b3a2a6.1a2b2a1a.a4b4a2a3' },
|
|
34
|
-
{ encoding: '9x9:2b5b4a2.a1e1a.d1a3b.a4a8a2a3a.3a1a3a3a3.a2a2a2a6a.b2a2d.a2e3a.4a3b1b2' },
|
|
35
|
-
{ encoding: '9x9:3a1a1a3a3.a2a1e.b2a7a4b.3d2b5.a2b4a3b.h3.3a2a3a4b.a2a3a3b4.2a3d2a' },
|
|
36
|
-
{ encoding: '9x9:1a3b3b3.a2g.2a3a1a2a6.a4a1a3a3a.b1a2c4.3d3a6a.a4b6a1a3.e1a2a.3a3a5a5a3' },
|
|
37
|
-
{
|
|
38
|
-
encoding:
|
|
39
|
-
'9x16:a3a2a5a2a.2a3a2a2a2.e3c.a2b1a1b.4a4b4b4.a3b1a1b.2a2b5c.a4a4b2a4.d2d.3b5a3a2a.b1e3.d4b2a.3a3c2a4.a1a2e.1a2a3a3b.a2c2b3',
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
encoding:
|
|
43
|
-
'9x16:a2a2a3a3a.2a2a4a1a2.e2a3a.a3b5a3a5.5a3b2a3a.a2f2.b3a4b3a.a3a1e.2a1a4a3a2.a4a2e.3a1a2b3a.a5a2b3a3.3a3b1c.a2a2c3a.2a3a2a3a2.a2a6a3a2a',
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
encoding:
|
|
47
|
-
'9x16:2a3b2b2.a1b2d.2e1a3.b4a8b2a.2b1b2a4.e1c.1a1a4b2a.a3a2d2.2d3a3a.a5b5a2a4.c1e.3a2b2b3.a2b3a1b.4a4e3.d2b1a.3b4b2a3.',
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
encoding:
|
|
51
|
-
'13x22:1a2b2b3a2a2.i1a2a.3b3b3a3a1b.a3c1f2.b3c4b1c.3g2b4a.b5b2f3.i2a3a.3a4a1a1a1a1a5.e3c4c.a3a3c1d3.2d2c4a1a.c5c2e.2a1a3a3a3a5a4.a4a2i.3f3b4b.a4b3g2.3b3b3c2b.g4c2a.b1a1a2b3b4.a1a1i.2a2a3b3b2a3',
|
|
52
|
-
},
|
|
53
|
-
{ encoding: '9x9:3b3b4a2.a2c1c.b1a4a2b.3d2b3.a2b5b1a.4b2a2b2.b1a3b2a.a1c2b1.2a3d3a' },
|
|
54
|
-
{ encoding: '9x9:4b4b4a4.b1a1d.2b1d3.d2a2b.4a4b3b4.a4b4b2a.2a1c2a2.a2e1a.2a4a3a4a2' },
|
|
55
|
-
{
|
|
56
|
-
encoding:
|
|
57
|
-
'21x35:a1a3b4a2a2a4b3a2b2.3c2b1e4b6a2b.b4c3a2c3d2a2a.a2a4a5a3b2b4a2d2.3a2a2a4a3c2c2d.a4a3a3a2b5b4a4a3a1a.3c4f4b3a3a2b.a2a2b3a4a3a2b5a4b5.4a2b4a1a3a7b2a1a1b.a2a3b5a4d1a3a3b4.3c4f2i.a2a2b4b5b5a3a3a4a4.e5b3a4f2a2a.4a3a5i1a2a2a3.a3a2a3a3a4b6b3a3a4a.3a2a3a2a2a4b2b4a3a3.a2a3e4k.4a4a2c2a3a5a3b5b3.a1a3b3b6c2b3d.1a3b3a3b1g2a2.a3a4b2b4b6b2a4c.2g2b2b2a2a3a2.a2a6a4a6d2b1a4c.5a4a4a2a2b4b8a3a1a2.a1a4a2a7b4b4a2a5a3a.b4e2b3b4c2b.3c6b7a3c2a2a1b3.a3a2o2a.3a5a5a3b3a4b5b2b3.e2a3e1d4b.3b2b3b2d2b2a',
|
|
58
|
-
},
|
|
59
|
-
]
|