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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { HashiNodeData } from '../../types.ts'
|
|
3
|
+
import { constructNode, getDisplayMode } from '../bridges.ts'
|
|
4
|
+
|
|
5
|
+
// biome-ignore lint/security/noSecrets: false positive
|
|
6
|
+
describe('getDisplayMode()', () => {
|
|
7
|
+
it('returns normal when highlightedNode is undefined', () => {
|
|
8
|
+
const node: HashiNodeData = { value: 1 }
|
|
9
|
+
expect(getDisplayMode(node, undefined)).toBe('normal')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('returns highlight when node value matches highlightedNode', () => {
|
|
13
|
+
const node: HashiNodeData = { value: 2 }
|
|
14
|
+
expect(getDisplayMode(node, 2)).toBe('highlight')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns dim when node value does not match highlightedNode', () => {
|
|
18
|
+
const node: HashiNodeData = { value: 1 }
|
|
19
|
+
expect(getDisplayMode(node, 2)).toBe('dim')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns dim for bridge nodes when highlightedNode is set', () => {
|
|
23
|
+
expect(getDisplayMode({ value: '-' }, 2)).toBe('dim')
|
|
24
|
+
expect(getDisplayMode({ value: '|' }, 2)).toBe('dim')
|
|
25
|
+
expect(getDisplayMode({ value: '#' }, 2)).toBe('dim')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns highlight for bridge nodes that are the highlighted value', () => {
|
|
29
|
+
expect(getDisplayMode({ value: '-' }, 2)).toBe('dim')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('constructNode()', () => {
|
|
34
|
+
describe('empty node', () => {
|
|
35
|
+
it('renders space value with no lines', () => {
|
|
36
|
+
const node: HashiNodeData = { value: ' ' }
|
|
37
|
+
expect(constructNode(node, 0)).toEqual(' ')
|
|
38
|
+
expect(constructNode(node, 1)).toEqual(' ')
|
|
39
|
+
expect(constructNode(node, 2)).toEqual(' ')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('horizontal line node', () => {
|
|
44
|
+
it('renders a horizontal line in the middle', () => {
|
|
45
|
+
const node: HashiNodeData = { value: '-' }
|
|
46
|
+
expect(constructNode(node, 0)).toEqual(' ')
|
|
47
|
+
expect(constructNode(node, 1)).toEqual('─────')
|
|
48
|
+
expect(constructNode(node, 2)).toEqual(' ')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('renders a double horizontal line in the middle', () => {
|
|
52
|
+
const node: HashiNodeData = { value: '=' }
|
|
53
|
+
expect(constructNode(node, 0)).toEqual(' ')
|
|
54
|
+
expect(constructNode(node, 1)).toEqual('═════')
|
|
55
|
+
expect(constructNode(node, 2)).toEqual(' ')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('vertical line node', () => {
|
|
60
|
+
it('renders a vertical line in the center', () => {
|
|
61
|
+
const node: HashiNodeData = { value: '|' }
|
|
62
|
+
expect(constructNode(node, 0)).toEqual(' │ ')
|
|
63
|
+
expect(constructNode(node, 1)).toEqual(' │ ')
|
|
64
|
+
expect(constructNode(node, 2)).toEqual(' │ ')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders a double vertical line in the center', () => {
|
|
68
|
+
const node: HashiNodeData = { value: '#' }
|
|
69
|
+
expect(constructNode(node, 0)).toEqual(' ║ ')
|
|
70
|
+
expect(constructNode(node, 1)).toEqual(' ║ ')
|
|
71
|
+
expect(constructNode(node, 2)).toEqual(' ║ ')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('node with value', () => {
|
|
76
|
+
describe('TOP_ROW', () => {
|
|
77
|
+
it('renders top border', () => {
|
|
78
|
+
const node: HashiNodeData = { value: 5 }
|
|
79
|
+
expect(constructNode(node, 0)).toEqual('╭───╮')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('renders border with vertical line up', () => {
|
|
83
|
+
const node: HashiNodeData = { value: 5, lineUp: 1 }
|
|
84
|
+
expect(constructNode(node, 0)).toEqual('╭─┴─╮')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('renders border with double vertical line up', () => {
|
|
88
|
+
const node: HashiNodeData = { value: 5, lineUp: 2 }
|
|
89
|
+
expect(constructNode(node, 0)).toEqual('╭─╨─╮')
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('MIDDLE_ROW', () => {
|
|
94
|
+
it('renders middle row - value with vertical borders', () => {
|
|
95
|
+
const node: HashiNodeData = { value: 5 }
|
|
96
|
+
expect(constructNode(node, 1)).toEqual('│ 5 │')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('renders value with horizontal line on left', () => {
|
|
100
|
+
const node: HashiNodeData = { value: 5, lineLeft: 1 }
|
|
101
|
+
expect(constructNode(node, 1)).toEqual('┤ 5 │')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('renders value with horizontal line on right', () => {
|
|
105
|
+
const node: HashiNodeData = { value: 5, lineRight: 1 }
|
|
106
|
+
expect(constructNode(node, 1)).toEqual('│ 5 ├')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('renders value with horizontal lines on both sides', () => {
|
|
110
|
+
const node: HashiNodeData = { value: 5, lineLeft: 1, lineRight: 1 }
|
|
111
|
+
expect(constructNode(node, 1)).toEqual('┤ 5 ├')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('renders value with double horizontal lines on both sides', () => {
|
|
115
|
+
const node: HashiNodeData = { value: 5, lineLeft: 2, lineRight: 2 }
|
|
116
|
+
expect(constructNode(node, 1)).toEqual('╡ 5 ╞')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('BOTTOM_ROW', () => {
|
|
121
|
+
it('renders bottom border without lines', () => {
|
|
122
|
+
const node: HashiNodeData = { value: 5 }
|
|
123
|
+
expect(constructNode(node, 2)).toEqual('╰───╯')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('renders border with vertical line down', () => {
|
|
127
|
+
const node: HashiNodeData = { value: 5, lineDown: 1 }
|
|
128
|
+
expect(constructNode(node, 2)).toEqual('╰─┬─╯')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('renders border with double vertical line down', () => {
|
|
132
|
+
const node: HashiNodeData = { value: 5, lineDown: 2 }
|
|
133
|
+
expect(constructNode(node, 2)).toEqual('╰─╥─╯')
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { findNodeInDirection } from '../usePuzzleInput.ts'
|
|
4
|
+
|
|
5
|
+
describe('findNodeInDirection', () => {
|
|
6
|
+
describe('1x2 grid (horizontal)', () => {
|
|
7
|
+
const grid = [[{ value: 1 }, { value: 2 }]]
|
|
8
|
+
|
|
9
|
+
it('finds node to the right', () => {
|
|
10
|
+
expect(findNodeInDirection(grid, 0, 0, 'l')).toEqual({ row: 0, col: 1 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('finds node to the left', () => {
|
|
14
|
+
expect(findNodeInDirection(grid, 0, 1, 'h')).toEqual({ row: 0, col: 0 })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('returns null when no node to the right', () => {
|
|
18
|
+
expect(findNodeInDirection(grid, 0, 1, 'l')).toBe(null)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns null when no node to the left', () => {
|
|
22
|
+
expect(findNodeInDirection(grid, 0, 0, 'h')).toBe(null)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns null for vertical directions (no rows above/below)', () => {
|
|
26
|
+
expect(findNodeInDirection(grid, 0, 0, 'j')).toBe(null)
|
|
27
|
+
expect(findNodeInDirection(grid, 0, 0, 'k')).toBe(null)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('2x1 grid (vertical)', () => {
|
|
32
|
+
const grid = [[{ value: 1 }], [{ value: 2 }]]
|
|
33
|
+
|
|
34
|
+
it('finds node below', () => {
|
|
35
|
+
expect(findNodeInDirection(grid, 0, 0, 'j')).toEqual({ row: 1, col: 0 })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('finds node above', () => {
|
|
39
|
+
expect(findNodeInDirection(grid, 1, 0, 'k')).toEqual({ row: 0, col: 0 })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns null when no node below', () => {
|
|
43
|
+
expect(findNodeInDirection(grid, 1, 0, 'j')).toBe(null)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns null when no node above', () => {
|
|
47
|
+
expect(findNodeInDirection(grid, 0, 0, 'k')).toBe(null)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns null for horizontal directions (no cols left/right)', () => {
|
|
51
|
+
expect(findNodeInDirection(grid, 0, 0, 'h')).toBe(null)
|
|
52
|
+
expect(findNodeInDirection(grid, 0, 0, 'l')).toBe(null)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('3x3 grid with empty cells', () => {
|
|
57
|
+
const grid: { value: number | '-' | '=' | '#' | ' ' | '|' }[][] = [
|
|
58
|
+
[{ value: 1 }, { value: ' ' }, { value: 2 }],
|
|
59
|
+
[{ value: ' ' }, { value: '#' }, { value: ' ' }],
|
|
60
|
+
[{ value: 3 }, { value: ' ' }, { value: 4 }],
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
it('finds node across empty cells', () => {
|
|
64
|
+
expect(findNodeInDirection(grid, 0, 0, 'l')).toEqual({ row: 0, col: 2 })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('finds node across empty cells (down)', () => {
|
|
68
|
+
expect(findNodeInDirection(grid, 0, 0, 'j')).toEqual({ row: 2, col: 0 })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('returns node when blocked by wall (#) but path around exists', () => {
|
|
72
|
+
expect(findNodeInDirection(grid, 0, 0, 'j')).toEqual({ row: 2, col: 0 })
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import type { HashiNodeData, HashiNodeDisplayMode } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
export type NodeFilledState = 'valid' | 'invalid' | 'incomplete'
|
|
4
|
+
|
|
5
|
+
export const ROW_HEIGHT = 3
|
|
6
|
+
export const NODE_WIDTH = 5
|
|
7
|
+
export const SPACE_BETWEEN = 0
|
|
8
|
+
export const OUTER_PADDING = 1
|
|
9
|
+
|
|
10
|
+
export const TOP_ROW = 0
|
|
11
|
+
export const MIDDLE_ROW = 1
|
|
12
|
+
export const BOTTOM_ROW = 2
|
|
13
|
+
|
|
14
|
+
export type HashiGridValidationProps = {
|
|
15
|
+
rows: HashiNodeData[][]
|
|
16
|
+
numNodes: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the grid data is consistent with a valid Bridges puzzle.
|
|
21
|
+
*/
|
|
22
|
+
export function validateGrid({ rows, numNodes }: HashiGridValidationProps): void {
|
|
23
|
+
if (!rows || rows.length === 0) {
|
|
24
|
+
throw new Error('HashiGrid: empty data supplied')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let rowCount = 0
|
|
28
|
+
for (const nodes of rows) {
|
|
29
|
+
const prefix = `HashiGrid row ${rowCount}: `
|
|
30
|
+
|
|
31
|
+
if (nodes.length !== numNodes) {
|
|
32
|
+
throw new Error(`${prefix}expected ${numNodes} nodes, got ${nodes.length}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
36
|
+
const node = nodes[i]
|
|
37
|
+
if (!node) {
|
|
38
|
+
throw new Error(`${prefix}node at position ${i} is undefined`)
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
typeof node.value !== 'number' &&
|
|
42
|
+
node.value !== '-' &&
|
|
43
|
+
node.value !== '=' &&
|
|
44
|
+
node.value !== ' ' &&
|
|
45
|
+
node.value !== '#' &&
|
|
46
|
+
node.value !== '|'
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(`${prefix}node at position ${i} has invalid value: ${node.value}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
rowCount++
|
|
52
|
+
}
|
|
53
|
+
}
|
|
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
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Determines the display mode for a node based on the highlighted node value.
|
|
221
|
+
*/
|
|
222
|
+
export function getDisplayMode(
|
|
223
|
+
node: HashiNodeData,
|
|
224
|
+
highlightedNode?: number,
|
|
225
|
+
row?: number,
|
|
226
|
+
col?: number,
|
|
227
|
+
selectedNode?: { row: number; col: number } | null,
|
|
228
|
+
mode?: string
|
|
229
|
+
): HashiNodeDisplayMode {
|
|
230
|
+
// In selecting-node, selected, or invalid mode, highlight only the specific selected node
|
|
231
|
+
if (
|
|
232
|
+
(mode === 'selecting-node' || mode === 'selected' || mode === 'invalid') &&
|
|
233
|
+
selectedNode &&
|
|
234
|
+
row !== undefined &&
|
|
235
|
+
col !== undefined
|
|
236
|
+
) {
|
|
237
|
+
if (row === selectedNode.row && col === selectedNode.col) {
|
|
238
|
+
return 'highlight'
|
|
239
|
+
}
|
|
240
|
+
return 'dim'
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (highlightedNode === undefined) {
|
|
244
|
+
return 'normal'
|
|
245
|
+
}
|
|
246
|
+
if (typeof node.value === 'number' && node.value === highlightedNode) {
|
|
247
|
+
return 'highlight'
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Bridge values are strings, so they can never match a number highlightedNode
|
|
251
|
+
return 'dim'
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Build the HashiGrid node with its value and borders. Options:
|
|
256
|
+
* - node with a value (always 1 digit)
|
|
257
|
+
* - empty node - render just spaces
|
|
258
|
+
* - horizontal line - single and double
|
|
259
|
+
* - vertical line - single and double
|
|
260
|
+
*/
|
|
261
|
+
export function constructNode(
|
|
262
|
+
node: HashiNodeData,
|
|
263
|
+
line: 0 | 1 | 2,
|
|
264
|
+
displayMode: HashiNodeDisplayMode = 'normal',
|
|
265
|
+
disambiguationLabel?: string,
|
|
266
|
+
validationState?: NodeFilledState | null,
|
|
267
|
+
showSolution?: boolean
|
|
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
|
+
|
|
284
|
+
// Horizontal line
|
|
285
|
+
if (node.value === '-') {
|
|
286
|
+
if (displayMode === 'dim') {
|
|
287
|
+
return line === MIDDLE_ROW ? `\x1b[2m─────\x1b[22m` : ' '.repeat(NODE_WIDTH)
|
|
288
|
+
}
|
|
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
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Double horizontal line
|
|
297
|
+
if (node.value === '=') {
|
|
298
|
+
if (displayMode === 'dim') {
|
|
299
|
+
return line === MIDDLE_ROW ? `\x1b[2m═════\x1b[22m` : ' '.repeat(NODE_WIDTH)
|
|
300
|
+
}
|
|
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
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Vertical line
|
|
309
|
+
if (node.value === '|') {
|
|
310
|
+
if (displayMode === 'dim') {
|
|
311
|
+
return `\x1b[2m │ \x1b[22m`
|
|
312
|
+
}
|
|
313
|
+
const content = ' │ '
|
|
314
|
+
if (showSolution) {
|
|
315
|
+
return `\x1b[32m${content}\x1b[39m`
|
|
316
|
+
}
|
|
317
|
+
return useColor ? `${getColorPrefix()}${content}${colorReset}` : content
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Double vertical line
|
|
321
|
+
if (node.value === '#') {
|
|
322
|
+
if (displayMode === 'dim') {
|
|
323
|
+
return `\x1b[2m ║ \x1b[22m`
|
|
324
|
+
}
|
|
325
|
+
const content = ' ║ '
|
|
326
|
+
if (showSolution) {
|
|
327
|
+
return `\x1b[32m${content}\x1b[39m`
|
|
328
|
+
}
|
|
329
|
+
return useColor ? `${getColorPrefix()}${content}${colorReset}` : content
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Node with value to render
|
|
333
|
+
if (node.value !== ' ') {
|
|
334
|
+
if (line === TOP_ROW) {
|
|
335
|
+
const up = node.lineUp === 2 ? '╨' : node.lineUp === 1 ? '┴' : '─'
|
|
336
|
+
const label = disambiguationLabel ? disambiguationLabel : '─'
|
|
337
|
+
const border = `╭${label}${up}─╮`
|
|
338
|
+
if (displayMode === 'highlight') {
|
|
339
|
+
const highlighted = `\x1b[1m${border}\x1b[22m`
|
|
340
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
341
|
+
return `${getColorPrefix()}${highlighted}${colorReset}`
|
|
342
|
+
}
|
|
343
|
+
return highlighted
|
|
344
|
+
}
|
|
345
|
+
if (displayMode === 'dim') {
|
|
346
|
+
const dimmedBorder = `\x1b[2m${border}\x1b[22m`
|
|
347
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
348
|
+
return `${getColorPrefix()}${dimmedBorder}${colorReset}`
|
|
349
|
+
}
|
|
350
|
+
return dimmedBorder
|
|
351
|
+
}
|
|
352
|
+
return useColor ? `${getColorPrefix()}${border}${colorReset}` : border
|
|
353
|
+
} else if (line === MIDDLE_ROW) {
|
|
354
|
+
const left = node.lineLeft === 2 ? '╡' : node.lineLeft === 1 ? '┤' : '│'
|
|
355
|
+
const right = node.lineRight === 2 ? '╞' : node.lineRight === 1 ? '├' : '│'
|
|
356
|
+
const content = `${left} ${node.value} ${right}`
|
|
357
|
+
if (displayMode === 'highlight') {
|
|
358
|
+
const highlighted = `\x1b[1m${content}\x1b[22m`
|
|
359
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
360
|
+
return `${getColorPrefix()}${highlighted}${colorReset}`
|
|
361
|
+
}
|
|
362
|
+
return highlighted
|
|
363
|
+
}
|
|
364
|
+
if (displayMode === 'dim') {
|
|
365
|
+
const dimmedContent = `\x1b[2m${content}\x1b[22m`
|
|
366
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
367
|
+
return `${getColorPrefix()}${dimmedContent}${colorReset}`
|
|
368
|
+
}
|
|
369
|
+
return dimmedContent
|
|
370
|
+
}
|
|
371
|
+
return useColor ? `${getColorPrefix()}${content}${colorReset}` : content
|
|
372
|
+
} else if (line === BOTTOM_ROW) {
|
|
373
|
+
const down = node.lineDown === 2 ? '╥' : node.lineDown === 1 ? '┬' : '─'
|
|
374
|
+
const border = `╰─${down}─╯`
|
|
375
|
+
if (displayMode === 'highlight') {
|
|
376
|
+
const highlighted = `\x1b[1m${border}\x1b[22m`
|
|
377
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
378
|
+
return `${getColorPrefix()}${highlighted}${colorReset}`
|
|
379
|
+
}
|
|
380
|
+
return highlighted
|
|
381
|
+
}
|
|
382
|
+
if (displayMode === 'dim') {
|
|
383
|
+
const dimmedBorder = `\x1b[2m${border}\x1b[22m`
|
|
384
|
+
if (validationState === 'valid' || validationState === 'invalid') {
|
|
385
|
+
return `${getColorPrefix()}${dimmedBorder}${colorReset}`
|
|
386
|
+
}
|
|
387
|
+
return dimmedBorder
|
|
388
|
+
}
|
|
389
|
+
return useColor ? `${getColorPrefix()}${border}${colorReset}` : border
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Empty node
|
|
394
|
+
return ' '.repeat(NODE_WIDTH)
|
|
395
|
+
}
|