bridges-cli 0.1.0 → 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 +4 -1
- package/README.md +1 -0
- package/package.json +2 -2
- package/src/Game.tsx +37 -11
- package/src/__tests__/Game.test.tsx +298 -97
- package/src/components/HashiGrid.tsx +17 -1
- package/src/components/HashiRow.tsx +14 -1
- package/src/components/Messages.tsx +39 -6
- package/src/components/__tests__/HashiRow.test.tsx +28 -3
- package/src/components/__tests__/Messages.test.tsx +61 -38
- package/src/index.tsx +9 -1
- package/src/utils/bridges.ts +238 -14
- package/src/utils/puzzle-encoding.ts +101 -19
- package/src/utils/usePuzzleInput.ts +4 -2
|
@@ -30,12 +30,18 @@ type HashiGridProps = {
|
|
|
30
30
|
hasSolution?: boolean
|
|
31
31
|
/** Whether to show the solution */
|
|
32
32
|
showSolution?: boolean
|
|
33
|
+
/** Whether to enable the solution feature */
|
|
34
|
+
enableSolutions?: boolean
|
|
33
35
|
/** Current selection state for highlighting */
|
|
34
36
|
selectionState?: SelectionState
|
|
35
37
|
/** Minimum number value in the puzzle */
|
|
36
38
|
minNumber?: number
|
|
37
39
|
/** Maximum number value in the puzzle */
|
|
38
40
|
maxNumber?: number
|
|
41
|
+
/** Whether the puzzle solution has been reached */
|
|
42
|
+
solutionReached?: boolean
|
|
43
|
+
/** Whether the grid is not fully connected but all nodes are filled */
|
|
44
|
+
gridNotConnected?: boolean
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
export default function HashiGrid({
|
|
@@ -47,9 +53,12 @@ export default function HashiGrid({
|
|
|
47
53
|
isCustomPuzzle = false,
|
|
48
54
|
hasSolution = false,
|
|
49
55
|
showSolution = false,
|
|
56
|
+
enableSolutions = false,
|
|
50
57
|
selectionState,
|
|
51
58
|
minNumber,
|
|
52
59
|
maxNumber,
|
|
60
|
+
solutionReached = false,
|
|
61
|
+
gridNotConnected = false,
|
|
53
62
|
}: HashiGridProps) {
|
|
54
63
|
validateGrid({ rows, numNodes })
|
|
55
64
|
|
|
@@ -85,11 +94,18 @@ export default function HashiGrid({
|
|
|
85
94
|
rowIndex={i}
|
|
86
95
|
highlightedNode={selectionState?.selectedNumber ?? undefined}
|
|
87
96
|
selectionState={selectionState}
|
|
97
|
+
showSolution={showSolution}
|
|
88
98
|
/>
|
|
89
99
|
))}
|
|
90
100
|
</Box>
|
|
91
101
|
{showInstructions ? (
|
|
92
|
-
<Messages
|
|
102
|
+
<Messages
|
|
103
|
+
hasSolution={hasSolution}
|
|
104
|
+
enableSolutions={enableSolutions}
|
|
105
|
+
selectionState={selectionState}
|
|
106
|
+
solutionReached={solutionReached}
|
|
107
|
+
gridNotConnected={gridNotConnected}
|
|
108
|
+
/>
|
|
93
109
|
) : null}
|
|
94
110
|
</Box>
|
|
95
111
|
)
|
|
@@ -5,6 +5,7 @@ import type { HashiNodeData, SelectionState } from '../types.ts'
|
|
|
5
5
|
import {
|
|
6
6
|
constructNode,
|
|
7
7
|
getDisplayMode,
|
|
8
|
+
getNodeFilledState,
|
|
8
9
|
NODE_WIDTH,
|
|
9
10
|
OUTER_PADDING,
|
|
10
11
|
ROW_HEIGHT,
|
|
@@ -16,6 +17,7 @@ type HashiRowProps = {
|
|
|
16
17
|
highlightedNode?: number
|
|
17
18
|
rowIndex: number
|
|
18
19
|
selectionState?: SelectionState
|
|
20
|
+
showSolution?: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export default function HashiRow({
|
|
@@ -23,6 +25,7 @@ export default function HashiRow({
|
|
|
23
25
|
highlightedNode,
|
|
24
26
|
rowIndex,
|
|
25
27
|
selectionState,
|
|
28
|
+
showSolution,
|
|
26
29
|
}: HashiRowProps) {
|
|
27
30
|
// Each row consists of multiple lines of terminal output
|
|
28
31
|
const lines: React.ReactNode[] = []
|
|
@@ -60,7 +63,17 @@ export default function HashiRow({
|
|
|
60
63
|
selectionState?.mode
|
|
61
64
|
)
|
|
62
65
|
const label = disambiguationMap[i]
|
|
63
|
-
|
|
66
|
+
const filledState = getNodeFilledState(node)
|
|
67
|
+
rowItems.push(
|
|
68
|
+
constructNode(
|
|
69
|
+
node,
|
|
70
|
+
line as 0 | 1 | 2,
|
|
71
|
+
displayMode,
|
|
72
|
+
label,
|
|
73
|
+
filledState,
|
|
74
|
+
showSolution
|
|
75
|
+
)
|
|
76
|
+
)
|
|
64
77
|
|
|
65
78
|
// Add space between columns except the last
|
|
66
79
|
if (i < nodes.length - 1) {
|
|
@@ -8,27 +8,60 @@ type LegendItem = {
|
|
|
8
8
|
disabled?: boolean
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export function legendItems(
|
|
12
|
-
|
|
11
|
+
export function legendItems(
|
|
12
|
+
hasSolution: boolean,
|
|
13
|
+
enableSolutions: boolean,
|
|
14
|
+
isSelecting: boolean
|
|
15
|
+
): LegendItem[] {
|
|
16
|
+
const items: LegendItem[] = [
|
|
13
17
|
{ key: 'p', description: 'Previous puzzle', disabled: isSelecting },
|
|
14
18
|
{ key: 'n', description: 'Next puzzle', disabled: isSelecting },
|
|
15
|
-
{ key: 's', description: 'Show solution', disabled: isSelecting || !hasSolution },
|
|
16
|
-
{ key: 'q', description: 'Quit' },
|
|
17
19
|
]
|
|
20
|
+
|
|
21
|
+
if (enableSolutions) {
|
|
22
|
+
items.push({
|
|
23
|
+
key: 's',
|
|
24
|
+
description: 'Show solution',
|
|
25
|
+
disabled: isSelecting || !hasSolution,
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
items.push({ key: 'q', description: 'Quit' })
|
|
30
|
+
|
|
31
|
+
return items
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
type MessagesProps = {
|
|
21
35
|
hasSolution?: boolean
|
|
36
|
+
enableSolutions?: boolean
|
|
22
37
|
selectionState?: SelectionState
|
|
38
|
+
solutionReached?: boolean
|
|
39
|
+
gridNotConnected?: boolean
|
|
23
40
|
}
|
|
24
41
|
|
|
25
|
-
export default function Messages({
|
|
42
|
+
export default function Messages({
|
|
43
|
+
hasSolution = false,
|
|
44
|
+
enableSolutions = false,
|
|
45
|
+
selectionState,
|
|
46
|
+
solutionReached = false,
|
|
47
|
+
gridNotConnected = false,
|
|
48
|
+
}: MessagesProps) {
|
|
26
49
|
const isSelecting = selectionState !== undefined && selectionState.mode !== 'idle'
|
|
27
50
|
|
|
28
51
|
return (
|
|
29
52
|
<Box flexDirection="column" marginTop={1}>
|
|
53
|
+
{solutionReached ? (
|
|
54
|
+
<Text bold color="green">
|
|
55
|
+
Solution reached!
|
|
56
|
+
</Text>
|
|
57
|
+
) : null}
|
|
58
|
+
{gridNotConnected ? (
|
|
59
|
+
<Text bold color="yellow">
|
|
60
|
+
Grid is not fully connected
|
|
61
|
+
</Text>
|
|
62
|
+
) : null}
|
|
30
63
|
<Text bold>Controls:</Text>
|
|
31
|
-
{legendItems(hasSolution, isSelecting).map(item => (
|
|
64
|
+
{legendItems(hasSolution, enableSolutions, isSelecting).map(item => (
|
|
32
65
|
<Box key={item.key}>
|
|
33
66
|
<Text bold color={item.disabled ? 'gray' : undefined}>
|
|
34
67
|
{item.key}
|
|
@@ -22,9 +22,9 @@ describe('HashiRow component', () => {
|
|
|
22
22
|
rowIndex={0}
|
|
23
23
|
/>
|
|
24
24
|
)
|
|
25
|
-
expect(lastFrame()).toEqual(`
|
|
26
|
-
│ 1
|
|
27
|
-
|
|
25
|
+
expect(lastFrame()).toEqual(` \x1b[32m╭───╮\x1b[39m ╭───╮
|
|
26
|
+
\x1b[32m│ 1 ├\x1b[39m─────┤ 3 │
|
|
27
|
+
\x1b[32m╰───╯\x1b[39m ╰───╯`)
|
|
28
28
|
})
|
|
29
29
|
|
|
30
30
|
it('renders a vertical node', () => {
|
|
@@ -140,4 +140,29 @@ describe('HashiRow component', () => {
|
|
|
140
140
|
)
|
|
141
141
|
})
|
|
142
142
|
})
|
|
143
|
+
|
|
144
|
+
describe('validation state colors', () => {
|
|
145
|
+
it('shows green on valid nodes in selected mode', () => {
|
|
146
|
+
const selectionState: SelectionState = {
|
|
147
|
+
mode: 'selected',
|
|
148
|
+
selectedNumber: 1,
|
|
149
|
+
direction: 'h',
|
|
150
|
+
matchingNodes: [{ row: 0, col: 0 }],
|
|
151
|
+
disambiguationLabels: [],
|
|
152
|
+
selectedNode: { row: 0, col: 0 },
|
|
153
|
+
}
|
|
154
|
+
const { lastFrame } = render(
|
|
155
|
+
<HashiRow
|
|
156
|
+
nodes={[{ value: 1, lineRight: 1 }, { value: ' ' }, { value: 1, lineLeft: 1 }]}
|
|
157
|
+
rowIndex={0}
|
|
158
|
+
selectionState={selectionState}
|
|
159
|
+
/>
|
|
160
|
+
)
|
|
161
|
+
expect(lastFrame()).toEqual(
|
|
162
|
+
` \x1b[32m\x1b[1m╭───╮\x1b[22m\x1b[39m \x1b[32m\x1b[2m╭───╮\x1b[22m\x1b[39m
|
|
163
|
+
\x1b[32m\x1b[1m│ 1 ├\x1b[22m\x1b[39m \x1b[32m\x1b[2m┤ 1 │\x1b[22m\x1b[39m
|
|
164
|
+
\x1b[32m\x1b[1m╰───╯\x1b[22m\x1b[39m \x1b[32m\x1b[2m╰───╯\x1b[22m\x1b[39m`
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
143
168
|
})
|
|
@@ -4,8 +4,17 @@ import { describe, expect, it } from 'vitest'
|
|
|
4
4
|
import Messages, { legendItems } from '../Messages.tsx'
|
|
5
5
|
|
|
6
6
|
describe('Messages', () => {
|
|
7
|
-
it('
|
|
8
|
-
const { lastFrame } = render(<Messages />)
|
|
7
|
+
it('does not show solution option when enableSolutions is false', () => {
|
|
8
|
+
const { lastFrame } = render(<Messages enableSolutions={false} />)
|
|
9
|
+
expect(lastFrame()).toContain('Controls:')
|
|
10
|
+
expect(lastFrame()).toContain('p: Previous puzzle')
|
|
11
|
+
expect(lastFrame()).toContain('n: Next puzzle')
|
|
12
|
+
expect(lastFrame()).not.toContain('s: Show solution')
|
|
13
|
+
expect(lastFrame()).toContain('q: Quit')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('shows solution option when enableSolutions is true', () => {
|
|
17
|
+
const { lastFrame } = render(<Messages enableSolutions={true} />)
|
|
9
18
|
expect(lastFrame()).toContain('Controls:')
|
|
10
19
|
expect(lastFrame()).toContain('p: Previous puzzle')
|
|
11
20
|
expect(lastFrame()).toContain('n: Next puzzle')
|
|
@@ -13,47 +22,61 @@ describe('Messages', () => {
|
|
|
13
22
|
expect(lastFrame()).toContain('q: Quit')
|
|
14
23
|
})
|
|
15
24
|
|
|
16
|
-
describe('
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
describe('legendItems', () => {
|
|
26
|
+
describe('when enableSolutions is false', () => {
|
|
27
|
+
it('shows n/p as enabled when idle', () => {
|
|
28
|
+
const items = legendItems(true, false, false)
|
|
29
|
+
const p = items.find(i => i.key === 'p')
|
|
30
|
+
const n = items.find(i => i.key === 'n')
|
|
31
|
+
const s = items.find(i => i.key === 's')
|
|
32
|
+
expect(p?.disabled).toBe(false)
|
|
33
|
+
expect(n?.disabled).toBe(false)
|
|
34
|
+
expect(s).toBeUndefined()
|
|
35
|
+
})
|
|
26
36
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
it('shows n/p as disabled in selecting-node mode', () => {
|
|
38
|
+
const items = legendItems(true, false, true)
|
|
39
|
+
const p = items.find(i => i.key === 'p')
|
|
40
|
+
const n = items.find(i => i.key === 'n')
|
|
41
|
+
const s = items.find(i => i.key === 's')
|
|
42
|
+
expect(p?.disabled).toBe(true)
|
|
43
|
+
expect(n?.disabled).toBe(true)
|
|
44
|
+
expect(s).toBeUndefined()
|
|
45
|
+
})
|
|
35
46
|
})
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
describe('when enableSolutions is true', () => {
|
|
49
|
+
it('shows n/p/s as enabled when idle', () => {
|
|
50
|
+
const items = legendItems(true, true, false)
|
|
51
|
+
const p = items.find(i => i.key === 'p')
|
|
52
|
+
const n = items.find(i => i.key === 'n')
|
|
53
|
+
const s = items.find(i => i.key === 's')
|
|
54
|
+
expect(p?.disabled).toBe(false)
|
|
55
|
+
expect(n?.disabled).toBe(false)
|
|
56
|
+
expect(s?.disabled).toBe(false)
|
|
57
|
+
})
|
|
46
58
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
it('shows n/p/s as disabled in selecting-node mode', () => {
|
|
60
|
+
const items = legendItems(true, true, true)
|
|
61
|
+
const p = items.find(i => i.key === 'p')
|
|
62
|
+
const n = items.find(i => i.key === 'n')
|
|
63
|
+
const s = items.find(i => i.key === 's')
|
|
64
|
+
expect(p?.disabled).toBe(true)
|
|
65
|
+
expect(n?.disabled).toBe(true)
|
|
66
|
+
expect(s?.disabled).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('shows s as disabled when no solution exists', () => {
|
|
70
|
+
const items = legendItems(false, true, false)
|
|
71
|
+
const s = items.find(i => i.key === 's')
|
|
72
|
+
expect(s?.disabled).toBe(true)
|
|
73
|
+
})
|
|
52
74
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
75
|
+
it('shows s as enabled when solution exists', () => {
|
|
76
|
+
const items = legendItems(true, true, false)
|
|
77
|
+
const s = items.find(i => i.key === 's')
|
|
78
|
+
expect(s?.disabled).toBe(false)
|
|
79
|
+
})
|
|
57
80
|
})
|
|
58
81
|
})
|
|
59
82
|
})
|
package/src/index.tsx
CHANGED
|
@@ -12,6 +12,7 @@ const packageJson = JSON.parse(readFileSync(resolve(__dirname, '../package.json'
|
|
|
12
12
|
|
|
13
13
|
type CliOptions = {
|
|
14
14
|
puzzle: string | undefined
|
|
15
|
+
enableSolutions: boolean
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const program = new Command()
|
|
@@ -38,6 +39,7 @@ program
|
|
|
38
39
|
Example (3x3 with corner islands):
|
|
39
40
|
--puzzle "3x3:1a2.c.1a2"`
|
|
40
41
|
)
|
|
42
|
+
.option('--enable-solutions', 'Enable the solution viewing feature', false)
|
|
41
43
|
.parse(process.argv)
|
|
42
44
|
|
|
43
45
|
const options = program.opts<CliOptions>()
|
|
@@ -49,4 +51,10 @@ if (options.puzzle) {
|
|
|
49
51
|
puzzles = [{ encoding: options.puzzle }, ...samplePuzzles]
|
|
50
52
|
}
|
|
51
53
|
|
|
52
|
-
render(
|
|
54
|
+
render(
|
|
55
|
+
<Game
|
|
56
|
+
puzzles={puzzles}
|
|
57
|
+
hasCustomPuzzle={hasCustomPuzzle}
|
|
58
|
+
enableSolutions={options.enableSolutions}
|
|
59
|
+
/>
|
|
60
|
+
)
|
package/src/utils/bridges.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { HashiNodeData, HashiNodeDisplayMode } from '../types.ts'
|
|
2
2
|
|
|
3
|
+
export type NodeFilledState = 'valid' | 'invalid' | 'incomplete'
|
|
4
|
+
|
|
3
5
|
export const ROW_HEIGHT = 3
|
|
4
6
|
export const NODE_WIDTH = 5
|
|
5
7
|
export const SPACE_BETWEEN = 0
|
|
@@ -50,6 +52,170 @@ export function validateGrid({ rows, numNodes }: HashiGridValidationProps): void
|
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Check if the puzzle is solved - all numbered nodes have the correct number of bridges
|
|
57
|
+
* and all nodes form a connected graph.
|
|
58
|
+
*/
|
|
59
|
+
export function checkSolution(rows: HashiNodeData[][]): boolean {
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
for (const node of row) {
|
|
62
|
+
const state = getNodeFilledState(node)
|
|
63
|
+
if (state !== null && state !== 'valid') {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!isGraphConnected(rows)) {
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if all numbered nodes have the correct number of bridges (regardless of connectivity).
|
|
78
|
+
*/
|
|
79
|
+
export function areAllNodesFilled(rows: HashiNodeData[][]): boolean {
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
for (const node of row) {
|
|
82
|
+
const state = getNodeFilledState(node)
|
|
83
|
+
if (state !== null && state !== 'valid') {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if the graph is connected (regardless of whether nodes have correct bridge counts).
|
|
93
|
+
*/
|
|
94
|
+
export function isConnected(rows: HashiNodeData[][]): boolean {
|
|
95
|
+
return isGraphConnected(rows)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if all numbered nodes form a connected graph using BFS.
|
|
100
|
+
*/
|
|
101
|
+
function isGraphConnected(rows: HashiNodeData[][]): boolean {
|
|
102
|
+
const numRows = rows.length
|
|
103
|
+
if (numRows === 0) return true
|
|
104
|
+
const numCols = rows[0].length
|
|
105
|
+
|
|
106
|
+
const numberedNodes: [number, number][] = []
|
|
107
|
+
for (let r = 0; r < numRows; r++) {
|
|
108
|
+
for (let c = 0; c < numCols; c++) {
|
|
109
|
+
if (typeof rows[r][c].value === 'number') {
|
|
110
|
+
numberedNodes.push([r, c])
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (numberedNodes.length === 0) return true
|
|
116
|
+
|
|
117
|
+
const visited = new Set<string>()
|
|
118
|
+
const queue: [number, number][] = [[numberedNodes[0][0], numberedNodes[0][1]]]
|
|
119
|
+
visited.add(`${numberedNodes[0][0]},${numberedNodes[0][1]}`)
|
|
120
|
+
|
|
121
|
+
while (queue.length > 0) {
|
|
122
|
+
const next = queue.shift()
|
|
123
|
+
if (!next) continue
|
|
124
|
+
const [r, c] = next
|
|
125
|
+
const node = rows[r]?.[c]
|
|
126
|
+
if (!node) continue
|
|
127
|
+
|
|
128
|
+
if (node.lineRight === 1 || node.lineRight === 2) {
|
|
129
|
+
const dest = findNodeInDirection(rows, r, c, 0, 1)
|
|
130
|
+
if (dest) {
|
|
131
|
+
const key = `${dest[0]},${dest[1]}`
|
|
132
|
+
if (!visited.has(key)) {
|
|
133
|
+
visited.add(key)
|
|
134
|
+
queue.push(dest)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (node.lineLeft === 1 || node.lineLeft === 2) {
|
|
140
|
+
const dest = findNodeInDirection(rows, r, c, 0, -1)
|
|
141
|
+
if (dest) {
|
|
142
|
+
const key = `${dest[0]},${dest[1]}`
|
|
143
|
+
if (!visited.has(key)) {
|
|
144
|
+
visited.add(key)
|
|
145
|
+
queue.push(dest)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (node.lineDown === 1 || node.lineDown === 2) {
|
|
151
|
+
const dest = findNodeInDirection(rows, r, c, 1, 0)
|
|
152
|
+
if (dest) {
|
|
153
|
+
const key = `${dest[0]},${dest[1]}`
|
|
154
|
+
if (!visited.has(key)) {
|
|
155
|
+
visited.add(key)
|
|
156
|
+
queue.push(dest)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (node.lineUp === 1 || node.lineUp === 2) {
|
|
162
|
+
const dest = findNodeInDirection(rows, r, c, -1, 0)
|
|
163
|
+
if (dest) {
|
|
164
|
+
const key = `${dest[0]},${dest[1]}`
|
|
165
|
+
if (!visited.has(key)) {
|
|
166
|
+
visited.add(key)
|
|
167
|
+
queue.push(dest)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return visited.size === numberedNodes.length
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function findNodeInDirection(
|
|
177
|
+
rows: HashiNodeData[][],
|
|
178
|
+
startR: number,
|
|
179
|
+
startC: number,
|
|
180
|
+
dRow: number,
|
|
181
|
+
dCol: number
|
|
182
|
+
): [number, number] | null {
|
|
183
|
+
const numRows = rows.length
|
|
184
|
+
const numCols = rows[0].length
|
|
185
|
+
let r = startR + dRow
|
|
186
|
+
let c = startC + dCol
|
|
187
|
+
|
|
188
|
+
while (r >= 0 && r < numRows && c >= 0 && c < numCols) {
|
|
189
|
+
if (typeof rows[r][c].value === 'number') {
|
|
190
|
+
return [r, c]
|
|
191
|
+
}
|
|
192
|
+
r += dRow
|
|
193
|
+
c += dCol
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Determines if a node has the correct number of bridges, too many, or too few.
|
|
201
|
+
*/
|
|
202
|
+
export function getNodeFilledState(node: HashiNodeData): NodeFilledState | null {
|
|
203
|
+
if (typeof node.value !== 'number') {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const bridges =
|
|
208
|
+
(node.lineUp ?? 0) + (node.lineDown ?? 0) + (node.lineLeft ?? 0) + (node.lineRight ?? 0)
|
|
209
|
+
|
|
210
|
+
if (bridges === node.value) {
|
|
211
|
+
return 'valid'
|
|
212
|
+
}
|
|
213
|
+
if (bridges > node.value) {
|
|
214
|
+
return 'invalid'
|
|
215
|
+
}
|
|
216
|
+
return 'incomplete'
|
|
217
|
+
}
|
|
218
|
+
|
|
53
219
|
/**
|
|
54
220
|
* Determines the display mode for a node based on the highlighted node value.
|
|
55
221
|
*/
|
|
@@ -96,14 +262,35 @@ export function constructNode(
|
|
|
96
262
|
node: HashiNodeData,
|
|
97
263
|
line: 0 | 1 | 2,
|
|
98
264
|
displayMode: HashiNodeDisplayMode = 'normal',
|
|
99
|
-
disambiguationLabel?: string
|
|
265
|
+
disambiguationLabel?: string,
|
|
266
|
+
validationState?: NodeFilledState | null,
|
|
267
|
+
showSolution?: boolean
|
|
100
268
|
): string {
|
|
269
|
+
// Determine color prefix based on validation state
|
|
270
|
+
const getColorPrefix = (): string => {
|
|
271
|
+
if (validationState === 'valid') {
|
|
272
|
+
return '\x1b[32m' // green
|
|
273
|
+
}
|
|
274
|
+
if (validationState === 'invalid') {
|
|
275
|
+
return '\x1b[31m' // red
|
|
276
|
+
}
|
|
277
|
+
return ''
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const colorReset = '\x1b[39m'
|
|
281
|
+
const colorPrefix = displayMode === 'dim' ? '' : getColorPrefix()
|
|
282
|
+
const useColor = colorPrefix !== ''
|
|
283
|
+
|
|
101
284
|
// Horizontal line
|
|
102
285
|
if (node.value === '-') {
|
|
103
286
|
if (displayMode === 'dim') {
|
|
104
287
|
return line === MIDDLE_ROW ? `\x1b[2m─────\x1b[22m` : ' '.repeat(NODE_WIDTH)
|
|
105
288
|
}
|
|
106
|
-
|
|
289
|
+
const content = line === MIDDLE_ROW ? '─────' : ' '.repeat(NODE_WIDTH)
|
|
290
|
+
if (showSolution) {
|
|
291
|
+
return `\x1b[32m${content}\x1b[39m`
|
|
292
|
+
}
|
|
293
|
+
return useColor ? `${getColorPrefix()}${content}${colorReset}` : content
|
|
107
294
|
}
|
|
108
295
|
|
|
109
296
|
// Double horizontal line
|
|
@@ -111,7 +298,11 @@ export function constructNode(
|
|
|
111
298
|
if (displayMode === 'dim') {
|
|
112
299
|
return line === MIDDLE_ROW ? `\x1b[2m═════\x1b[22m` : ' '.repeat(NODE_WIDTH)
|
|
113
300
|
}
|
|
114
|
-
|
|
301
|
+
const content = line === MIDDLE_ROW ? '═════' : ' '.repeat(NODE_WIDTH)
|
|
302
|
+
if (showSolution) {
|
|
303
|
+
return `\x1b[32m${content}\x1b[39m`
|
|
304
|
+
}
|
|
305
|
+
return useColor ? `${getColorPrefix()}${content}${colorReset}` : content
|
|
115
306
|
}
|
|
116
307
|
|
|
117
308
|
// Vertical line
|
|
@@ -119,7 +310,11 @@ export function constructNode(
|
|
|
119
310
|
if (displayMode === 'dim') {
|
|
120
311
|
return `\x1b[2m │ \x1b[22m`
|
|
121
312
|
}
|
|
122
|
-
|
|
313
|
+
const content = ' │ '
|
|
314
|
+
if (showSolution) {
|
|
315
|
+
return `\x1b[32m${content}\x1b[39m`
|
|
316
|
+
}
|
|
317
|
+
return useColor ? `${getColorPrefix()}${content}${colorReset}` : content
|
|
123
318
|
}
|
|
124
319
|
|
|
125
320
|
// Double vertical line
|
|
@@ -127,7 +322,11 @@ export function constructNode(
|
|
|
127
322
|
if (displayMode === 'dim') {
|
|
128
323
|
return `\x1b[2m ║ \x1b[22m`
|
|
129
324
|
}
|
|
130
|
-
|
|
325
|
+
const content = ' ║ '
|
|
326
|
+
if (showSolution) {
|
|
327
|
+
return `\x1b[32m${content}\x1b[39m`
|
|
328
|
+
}
|
|
329
|
+
return useColor ? `${getColorPrefix()}${content}${colorReset}` : content
|
|
131
330
|
}
|
|
132
331
|
|
|
133
332
|
// Node with value to render
|
|
@@ -137,32 +336,57 @@ export function constructNode(
|
|
|
137
336
|
const label = disambiguationLabel ? disambiguationLabel : '─'
|
|
138
337
|
const border = `╭${label}${up}─╮`
|
|
139
338
|
if (displayMode === 'highlight') {
|
|
140
|
-
|
|
339
|
+
const highlighted = `\x1b[1m${border}\x1b[22m`
|
|
340
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
341
|
+
return `${getColorPrefix()}${highlighted}${colorReset}`
|
|
342
|
+
}
|
|
343
|
+
return highlighted
|
|
141
344
|
}
|
|
142
345
|
if (displayMode === 'dim') {
|
|
143
|
-
|
|
346
|
+
const dimmedBorder = `\x1b[2m${border}\x1b[22m`
|
|
347
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
348
|
+
return `${getColorPrefix()}${dimmedBorder}${colorReset}`
|
|
349
|
+
}
|
|
350
|
+
return dimmedBorder
|
|
144
351
|
}
|
|
145
|
-
return border
|
|
352
|
+
return useColor ? `${getColorPrefix()}${border}${colorReset}` : border
|
|
146
353
|
} else if (line === MIDDLE_ROW) {
|
|
147
354
|
const left = node.lineLeft === 2 ? '╡' : node.lineLeft === 1 ? '┤' : '│'
|
|
148
355
|
const right = node.lineRight === 2 ? '╞' : node.lineRight === 1 ? '├' : '│'
|
|
356
|
+
const content = `${left} ${node.value} ${right}`
|
|
149
357
|
if (displayMode === 'highlight') {
|
|
150
|
-
|
|
358
|
+
const highlighted = `\x1b[1m${content}\x1b[22m`
|
|
359
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
360
|
+
return `${getColorPrefix()}${highlighted}${colorReset}`
|
|
361
|
+
}
|
|
362
|
+
return highlighted
|
|
151
363
|
}
|
|
152
364
|
if (displayMode === 'dim') {
|
|
153
|
-
|
|
365
|
+
const dimmedContent = `\x1b[2m${content}\x1b[22m`
|
|
366
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
367
|
+
return `${getColorPrefix()}${dimmedContent}${colorReset}`
|
|
368
|
+
}
|
|
369
|
+
return dimmedContent
|
|
154
370
|
}
|
|
155
|
-
return `${
|
|
371
|
+
return useColor ? `${getColorPrefix()}${content}${colorReset}` : content
|
|
156
372
|
} else if (line === BOTTOM_ROW) {
|
|
157
373
|
const down = node.lineDown === 2 ? '╥' : node.lineDown === 1 ? '┬' : '─'
|
|
158
374
|
const border = `╰─${down}─╯`
|
|
159
375
|
if (displayMode === 'highlight') {
|
|
160
|
-
|
|
376
|
+
const highlighted = `\x1b[1m${border}\x1b[22m`
|
|
377
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
378
|
+
return `${getColorPrefix()}${highlighted}${colorReset}`
|
|
379
|
+
}
|
|
380
|
+
return highlighted
|
|
161
381
|
}
|
|
162
382
|
if (displayMode === 'dim') {
|
|
163
|
-
|
|
383
|
+
const dimmedBorder = `\x1b[2m${border}\x1b[22m`
|
|
384
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
385
|
+
return `${getColorPrefix()}${dimmedBorder}${colorReset}`
|
|
386
|
+
}
|
|
387
|
+
return dimmedBorder
|
|
164
388
|
}
|
|
165
|
-
return border
|
|
389
|
+
return useColor ? `${getColorPrefix()}${border}${colorReset}` : border
|
|
166
390
|
}
|
|
167
391
|
}
|
|
168
392
|
|