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,119 +1,12 @@
|
|
|
1
1
|
import { render } from 'ink-testing-library'
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
-
|
|
4
|
-
import type {
|
|
5
|
-
import HashiRow, { constructNode } from '../HashiRow.tsx'
|
|
6
|
-
|
|
7
|
-
describe('constructNode()', () => {
|
|
8
|
-
describe('empty node', () => {
|
|
9
|
-
it('renders space value with no lines', () => {
|
|
10
|
-
const node: HashiNodeData = { value: ' ' }
|
|
11
|
-
expect(constructNode(node, 0)).toEqual(' ')
|
|
12
|
-
expect(constructNode(node, 1)).toEqual(' ')
|
|
13
|
-
expect(constructNode(node, 2)).toEqual(' ')
|
|
14
|
-
})
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
describe('horizontal line node', () => {
|
|
18
|
-
it('renders a horizontal line in the middle', () => {
|
|
19
|
-
const node: HashiNodeData = { value: '-' }
|
|
20
|
-
expect(constructNode(node, 0)).toEqual(' ')
|
|
21
|
-
expect(constructNode(node, 1)).toEqual('─────')
|
|
22
|
-
expect(constructNode(node, 2)).toEqual(' ')
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('renders a double horizontal line in the middle', () => {
|
|
26
|
-
const node: HashiNodeData = { value: '=' }
|
|
27
|
-
expect(constructNode(node, 0)).toEqual(' ')
|
|
28
|
-
expect(constructNode(node, 1)).toEqual('═════')
|
|
29
|
-
expect(constructNode(node, 2)).toEqual(' ')
|
|
30
|
-
})
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
describe('vertical line node', () => {
|
|
34
|
-
it('renders a vertical line in the center', () => {
|
|
35
|
-
const node: HashiNodeData = { value: '|' }
|
|
36
|
-
expect(constructNode(node, 0)).toEqual(' │ ')
|
|
37
|
-
expect(constructNode(node, 1)).toEqual(' │ ')
|
|
38
|
-
expect(constructNode(node, 2)).toEqual(' │ ')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('renders a double vertical line in the center', () => {
|
|
42
|
-
const node: HashiNodeData = { value: '#' }
|
|
43
|
-
expect(constructNode(node, 0)).toEqual(' ║ ')
|
|
44
|
-
expect(constructNode(node, 1)).toEqual(' ║ ')
|
|
45
|
-
expect(constructNode(node, 2)).toEqual(' ║ ')
|
|
46
|
-
})
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
describe('node with value', () => {
|
|
50
|
-
describe('TOP_ROW', () => {
|
|
51
|
-
it('renders top border', () => {
|
|
52
|
-
const node: HashiNodeData = { value: 5 }
|
|
53
|
-
expect(constructNode(node, 0)).toEqual('╭───╮')
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('renders border with vertical line up', () => {
|
|
57
|
-
const node: HashiNodeData = { value: 5, lineUp: 1 }
|
|
58
|
-
expect(constructNode(node, 0)).toEqual('╭─┴─╮')
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it('renders border with double vertical line up', () => {
|
|
62
|
-
const node: HashiNodeData = { value: 5, lineUp: 2 }
|
|
63
|
-
expect(constructNode(node, 0)).toEqual('╭─╨─╮')
|
|
64
|
-
})
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
describe('MIDDLE_ROW', () => {
|
|
68
|
-
it('renders middle row - value with vertical borders', () => {
|
|
69
|
-
const node: HashiNodeData = { value: 5 }
|
|
70
|
-
expect(constructNode(node, 1)).toEqual('│ 5 │')
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('renders value with horizontal line on left', () => {
|
|
74
|
-
const node: HashiNodeData = { value: 5, lineLeft: 1 }
|
|
75
|
-
expect(constructNode(node, 1)).toEqual('┤ 5 │')
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
it('renders value with horizontal line on right', () => {
|
|
79
|
-
const node: HashiNodeData = { value: 5, lineRight: 1 }
|
|
80
|
-
expect(constructNode(node, 1)).toEqual('│ 5 ├')
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('renders value with horizontal lines on both sides', () => {
|
|
84
|
-
const node: HashiNodeData = { value: 5, lineLeft: 1, lineRight: 1 }
|
|
85
|
-
expect(constructNode(node, 1)).toEqual('┤ 5 ├')
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
it('renders value with double horizontal lines on both sides', () => {
|
|
89
|
-
const node: HashiNodeData = { value: 5, lineLeft: 2, lineRight: 2 }
|
|
90
|
-
expect(constructNode(node, 1)).toEqual('╡ 5 ╞')
|
|
91
|
-
})
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
describe('BOTTOM_ROW', () => {
|
|
95
|
-
it('renders bottom border without lines', () => {
|
|
96
|
-
const node: HashiNodeData = { value: 5 }
|
|
97
|
-
expect(constructNode(node, 2)).toEqual('╰───╯')
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('renders border with vertical line down', () => {
|
|
101
|
-
const node: HashiNodeData = { value: 5, lineDown: 1 }
|
|
102
|
-
expect(constructNode(node, 2)).toEqual('╰─┬─╯')
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
it('renders border with double vertical line down', () => {
|
|
106
|
-
const node: HashiNodeData = { value: 5, lineDown: 2 }
|
|
107
|
-
expect(constructNode(node, 2)).toEqual('╰─╥─╯')
|
|
108
|
-
})
|
|
109
|
-
})
|
|
110
|
-
})
|
|
111
|
-
})
|
|
3
|
+
import HashiRow from '../../components/HashiRow.tsx'
|
|
4
|
+
import type { SelectionState } from '../../types.ts'
|
|
112
5
|
|
|
113
6
|
describe('HashiRow component', () => {
|
|
114
7
|
it('renders three nodes', () => {
|
|
115
8
|
const { lastFrame } = render(
|
|
116
|
-
<HashiRow nodes={[{ value: 1 }, { value: 2 }, { value: 3 }]} />
|
|
9
|
+
<HashiRow nodes={[{ value: 1 }, { value: 2 }, { value: 3 }]} rowIndex={0} />
|
|
117
10
|
)
|
|
118
11
|
expect(lastFrame()).toEqual(
|
|
119
12
|
` ╭───╮╭───╮╭───╮
|
|
@@ -126,16 +19,17 @@ describe('HashiRow component', () => {
|
|
|
126
19
|
const { lastFrame } = render(
|
|
127
20
|
<HashiRow
|
|
128
21
|
nodes={[{ value: 1, lineRight: 1 }, { value: '-' }, { value: 3, lineLeft: 1 }]}
|
|
22
|
+
rowIndex={0}
|
|
129
23
|
/>
|
|
130
24
|
)
|
|
131
|
-
expect(lastFrame()).toEqual(`
|
|
132
|
-
│ 1
|
|
133
|
-
|
|
25
|
+
expect(lastFrame()).toEqual(` \x1b[32m╭───╮\x1b[39m ╭───╮
|
|
26
|
+
\x1b[32m│ 1 ├\x1b[39m─────┤ 3 │
|
|
27
|
+
\x1b[32m╰───╯\x1b[39m ╰───╯`)
|
|
134
28
|
})
|
|
135
29
|
|
|
136
30
|
it('renders a vertical node', () => {
|
|
137
31
|
const { lastFrame } = render(
|
|
138
|
-
<HashiRow nodes={[{ value: 1 }, { value: '|' }, { value: 3 }]} />
|
|
32
|
+
<HashiRow nodes={[{ value: 1 }, { value: '|' }, { value: 3 }]} rowIndex={0} />
|
|
139
33
|
)
|
|
140
34
|
expect(lastFrame()).toEqual(` ╭───╮ │ ╭───╮
|
|
141
35
|
│ 1 │ │ │ 3 │
|
|
@@ -144,7 +38,7 @@ describe('HashiRow component', () => {
|
|
|
144
38
|
|
|
145
39
|
it('renders empty positions as spaces', () => {
|
|
146
40
|
const { lastFrame } = render(
|
|
147
|
-
<HashiRow nodes={[{ value: 1 }, { value: ' ' }, { value: 3 }]} />
|
|
41
|
+
<HashiRow nodes={[{ value: 1 }, { value: ' ' }, { value: 3 }]} rowIndex={0} />
|
|
148
42
|
)
|
|
149
43
|
expect(lastFrame()).toEqual(
|
|
150
44
|
` ╭───╮ ╭───╮
|
|
@@ -152,4 +46,123 @@ describe('HashiRow component', () => {
|
|
|
152
46
|
╰───╯ ╰───╯`
|
|
153
47
|
)
|
|
154
48
|
})
|
|
49
|
+
|
|
50
|
+
describe('highlighted nodes', () => {
|
|
51
|
+
it('renders highlighted node with bold when value matches', () => {
|
|
52
|
+
const { lastFrame } = render(
|
|
53
|
+
<HashiRow nodes={[{ value: 1 }]} rowIndex={0} highlightedNode={1} />
|
|
54
|
+
)
|
|
55
|
+
expect(lastFrame()).toEqual(
|
|
56
|
+
` \x1b[1m╭───╮\x1b[22m
|
|
57
|
+
\x1b[1m│ 1 │\x1b[22m
|
|
58
|
+
\x1b[1m╰───╯\x1b[22m`
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('renders dimmed node when value does not match', () => {
|
|
63
|
+
const { lastFrame } = render(
|
|
64
|
+
<HashiRow nodes={[{ value: 1 }]} rowIndex={0} highlightedNode={2} />
|
|
65
|
+
)
|
|
66
|
+
expect(lastFrame()).toEqual(
|
|
67
|
+
` \x1b[2m╭───╮\x1b[22m
|
|
68
|
+
\x1b[2m│ 1 │\x1b[22m
|
|
69
|
+
\x1b[2m╰───╯\x1b[22m`
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('renders multiple nodes with one highlighted and others dimmed', () => {
|
|
74
|
+
const { lastFrame } = render(
|
|
75
|
+
<HashiRow
|
|
76
|
+
nodes={[{ value: 1 }, { value: 2 }, { value: 3 }]}
|
|
77
|
+
rowIndex={0}
|
|
78
|
+
highlightedNode={2}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
expect(lastFrame()).toEqual(
|
|
82
|
+
` \x1b[2m╭───╮\x1b[22m\x1b[1m╭───╮\x1b[22m\x1b[2m╭───╮\x1b[22m
|
|
83
|
+
\x1b[2m│ 1 │\x1b[22m\x1b[1m│ 2 │\x1b[22m\x1b[2m│ 3 │\x1b[22m
|
|
84
|
+
\x1b[2m╰───╯\x1b[22m\x1b[1m╰───╯\x1b[22m\x1b[2m╰───╯\x1b[22m`
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('renders normal when highlightedNode is undefined', () => {
|
|
89
|
+
const { lastFrame } = render(<HashiRow nodes={[{ value: 1 }]} rowIndex={0} />)
|
|
90
|
+
expect(lastFrame()).toEqual(
|
|
91
|
+
` ╭───╮
|
|
92
|
+
│ 1 │
|
|
93
|
+
╰───╯`
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('highlights only specific node in selecting-node mode', () => {
|
|
98
|
+
const selectionState: SelectionState = {
|
|
99
|
+
mode: 'selecting-node',
|
|
100
|
+
selectedNumber: 1,
|
|
101
|
+
direction: null,
|
|
102
|
+
matchingNodes: [{ row: 0, col: 1 }],
|
|
103
|
+
disambiguationLabels: [],
|
|
104
|
+
selectedNode: { row: 0, col: 1 },
|
|
105
|
+
}
|
|
106
|
+
const { lastFrame } = render(
|
|
107
|
+
<HashiRow
|
|
108
|
+
nodes={[{ value: 1 }, { value: 1 }, { value: 1 }]}
|
|
109
|
+
rowIndex={0}
|
|
110
|
+
selectionState={selectionState}
|
|
111
|
+
/>
|
|
112
|
+
)
|
|
113
|
+
expect(lastFrame()).toEqual(
|
|
114
|
+
` \x1b[2m╭───╮\x1b[22m\x1b[1m╭───╮\x1b[22m\x1b[2m╭───╮\x1b[22m
|
|
115
|
+
\x1b[2m│ 1 │\x1b[22m\x1b[1m│ 1 │\x1b[22m\x1b[2m│ 1 │\x1b[22m
|
|
116
|
+
\x1b[2m╰───╯\x1b[22m\x1b[1m╰───╯\x1b[22m\x1b[2m╰───╯\x1b[22m`
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('dims non-selected nodes in invalid mode but keeps selected highlighted', () => {
|
|
121
|
+
const selectionState: SelectionState = {
|
|
122
|
+
mode: 'invalid',
|
|
123
|
+
selectedNumber: 1,
|
|
124
|
+
direction: 'h',
|
|
125
|
+
matchingNodes: [{ row: 0, col: 1 }],
|
|
126
|
+
disambiguationLabels: [],
|
|
127
|
+
selectedNode: { row: 0, col: 1 },
|
|
128
|
+
}
|
|
129
|
+
const { lastFrame } = render(
|
|
130
|
+
<HashiRow
|
|
131
|
+
nodes={[{ value: 1 }, { value: 1 }, { value: 1 }]}
|
|
132
|
+
rowIndex={0}
|
|
133
|
+
selectionState={selectionState}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
expect(lastFrame()).toEqual(
|
|
137
|
+
` \x1b[2m╭───╮\x1b[22m\x1b[1m╭───╮\x1b[22m\x1b[2m╭───╮\x1b[22m
|
|
138
|
+
\x1b[2m│ 1 │\x1b[22m\x1b[1m│ 1 │\x1b[22m\x1b[2m│ 1 │\x1b[22m
|
|
139
|
+
\x1b[2m╰───╯\x1b[22m\x1b[1m╰───╯\x1b[22m\x1b[2m╰───╯\x1b[22m`
|
|
140
|
+
)
|
|
141
|
+
})
|
|
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
|
+
})
|
|
155
168
|
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { render } from 'ink-testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import type { SelectionState } from '../../types.ts'
|
|
4
|
+
import Header from '../Header.tsx'
|
|
5
|
+
|
|
6
|
+
describe('Header', () => {
|
|
7
|
+
it('shows idle message when no selection state and no number range', () => {
|
|
8
|
+
const { lastFrame } = render(<Header puzzleIndex={0} puzzle="5x5:1a1" />)
|
|
9
|
+
expect(lastFrame()).toContain('Type a number to select a node')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('shows dynamic number range when provided', () => {
|
|
13
|
+
const { lastFrame } = render(
|
|
14
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" minNumber={2} maxNumber={7} />
|
|
15
|
+
)
|
|
16
|
+
expect(lastFrame()).toContain('Type a number [2-7] to select a node')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('selection state messages', () => {
|
|
20
|
+
it('shows "select direction" message after we disambiguate which node', () => {
|
|
21
|
+
const selectionState: SelectionState = {
|
|
22
|
+
mode: 'selecting-node',
|
|
23
|
+
selectedNumber: 1,
|
|
24
|
+
direction: null,
|
|
25
|
+
matchingNodes: [
|
|
26
|
+
{ row: 0, col: 0 },
|
|
27
|
+
{ row: 1, col: 1 },
|
|
28
|
+
],
|
|
29
|
+
disambiguationLabels: [],
|
|
30
|
+
selectedNode: { row: 0, col: 0 },
|
|
31
|
+
}
|
|
32
|
+
const { lastFrame } = render(
|
|
33
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
34
|
+
)
|
|
35
|
+
expect(lastFrame()).toContain(`Select direction with`)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('shows disambiguation message without direction', () => {
|
|
39
|
+
const selectionState: SelectionState = {
|
|
40
|
+
mode: 'disambiguation',
|
|
41
|
+
selectedNumber: 1,
|
|
42
|
+
direction: null,
|
|
43
|
+
matchingNodes: [
|
|
44
|
+
{ row: 0, col: 0 },
|
|
45
|
+
{ row: 1, col: 2 },
|
|
46
|
+
],
|
|
47
|
+
disambiguationLabels: ['a', 'b'],
|
|
48
|
+
selectedNode: null,
|
|
49
|
+
}
|
|
50
|
+
const { lastFrame } = render(
|
|
51
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
52
|
+
)
|
|
53
|
+
expect(lastFrame()).toContain('Press label shown to select that node')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows disambiguation message with direction', () => {
|
|
57
|
+
const selectionState: SelectionState = {
|
|
58
|
+
mode: 'disambiguation',
|
|
59
|
+
selectedNumber: 1,
|
|
60
|
+
direction: 'h',
|
|
61
|
+
matchingNodes: [
|
|
62
|
+
{ row: 0, col: 0 },
|
|
63
|
+
{ row: 1, col: 2 },
|
|
64
|
+
],
|
|
65
|
+
disambiguationLabels: ['a', 'b'],
|
|
66
|
+
selectedNode: null,
|
|
67
|
+
}
|
|
68
|
+
const { lastFrame } = render(
|
|
69
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
70
|
+
)
|
|
71
|
+
expect(lastFrame()).toContain('Press label shown to select that node')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('shows selected message with direction (horizontal)', () => {
|
|
75
|
+
const selectionState: SelectionState = {
|
|
76
|
+
mode: 'selected',
|
|
77
|
+
selectedNumber: 1,
|
|
78
|
+
direction: 'l',
|
|
79
|
+
matchingNodes: [{ row: 0, col: 0 }],
|
|
80
|
+
disambiguationLabels: [],
|
|
81
|
+
selectedNode: { row: 0, col: 0 },
|
|
82
|
+
}
|
|
83
|
+
const { lastFrame } = render(
|
|
84
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
85
|
+
)
|
|
86
|
+
expect(lastFrame()).toContain('Drew horizontal bridge')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('shows selected message with direction (vertical)', () => {
|
|
90
|
+
const selectionState: SelectionState = {
|
|
91
|
+
mode: 'selected',
|
|
92
|
+
selectedNumber: 1,
|
|
93
|
+
direction: 'k',
|
|
94
|
+
matchingNodes: [{ row: 0, col: 0 }],
|
|
95
|
+
disambiguationLabels: [],
|
|
96
|
+
selectedNode: { row: 0, col: 0 },
|
|
97
|
+
}
|
|
98
|
+
const { lastFrame } = render(
|
|
99
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
100
|
+
)
|
|
101
|
+
expect(lastFrame()).toContain('Drew vertical bridge')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('shows erased bridge message when bridgeErased is true', () => {
|
|
105
|
+
const selectionState: SelectionState = {
|
|
106
|
+
mode: 'selected',
|
|
107
|
+
selectedNumber: 1,
|
|
108
|
+
direction: 'l',
|
|
109
|
+
matchingNodes: [{ row: 0, col: 0 }],
|
|
110
|
+
disambiguationLabels: [],
|
|
111
|
+
selectedNode: { row: 0, col: 0 },
|
|
112
|
+
bridgeErased: true,
|
|
113
|
+
}
|
|
114
|
+
const { lastFrame } = render(
|
|
115
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
116
|
+
)
|
|
117
|
+
expect(lastFrame()).toContain('Erased bridge')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('shows invalid message when no node in direction', () => {
|
|
121
|
+
const selectionState: SelectionState = {
|
|
122
|
+
mode: 'invalid',
|
|
123
|
+
selectedNumber: 1,
|
|
124
|
+
direction: 'h',
|
|
125
|
+
matchingNodes: [{ row: 0, col: 0 }],
|
|
126
|
+
disambiguationLabels: [],
|
|
127
|
+
selectedNode: { row: 0, col: 0 },
|
|
128
|
+
}
|
|
129
|
+
const { lastFrame } = render(
|
|
130
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
131
|
+
)
|
|
132
|
+
expect(lastFrame()).toContain('Cannot draw bridge left from node')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('puzzle display', () => {
|
|
137
|
+
it('shows puzzle number for indexed puzzles', () => {
|
|
138
|
+
const { lastFrame } = render(<Header puzzleIndex={2} puzzle="5x5:1a1" />)
|
|
139
|
+
expect(lastFrame()).toContain('Bridges: Puzzle #3')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('shows solution suffix when showSolution is true', () => {
|
|
143
|
+
const { lastFrame } = render(
|
|
144
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" showSolution={true} />
|
|
145
|
+
)
|
|
146
|
+
expect(lastFrame()).toContain('Viewing solution (press s to return to puzzle)')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('shows custom puzzle label for custom puzzles', () => {
|
|
150
|
+
const { lastFrame } = render(
|
|
151
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" isCustomPuzzle={true} />
|
|
152
|
+
)
|
|
153
|
+
expect(lastFrame()).toContain('Bridges: Puzzle - 5x5:1a1')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -1,14 +1,82 @@
|
|
|
1
1
|
import { render } from 'ink-testing-library'
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
3
|
|
|
4
|
-
import Messages from '../Messages.tsx'
|
|
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
9
|
expect(lastFrame()).toContain('Controls:')
|
|
10
10
|
expect(lastFrame()).toContain('p: Previous puzzle')
|
|
11
11
|
expect(lastFrame()).toContain('n: Next puzzle')
|
|
12
|
+
expect(lastFrame()).not.toContain('s: Show solution')
|
|
12
13
|
expect(lastFrame()).toContain('q: Quit')
|
|
13
14
|
})
|
|
15
|
+
|
|
16
|
+
it('shows solution option when enableSolutions is true', () => {
|
|
17
|
+
const { lastFrame } = render(<Messages enableSolutions={true} />)
|
|
18
|
+
expect(lastFrame()).toContain('Controls:')
|
|
19
|
+
expect(lastFrame()).toContain('p: Previous puzzle')
|
|
20
|
+
expect(lastFrame()).toContain('n: Next puzzle')
|
|
21
|
+
expect(lastFrame()).toContain('s: Show solution')
|
|
22
|
+
expect(lastFrame()).toContain('q: Quit')
|
|
23
|
+
})
|
|
24
|
+
|
|
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
|
+
})
|
|
36
|
+
|
|
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
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
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
|
+
})
|
|
58
|
+
|
|
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
|
+
})
|
|
74
|
+
|
|
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
|
+
})
|
|
80
|
+
})
|
|
81
|
+
})
|
|
14
82
|
})
|
package/src/demo.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env -S bun run
|
|
2
|
+
|
|
3
|
+
import { render } from 'ink'
|
|
4
|
+
|
|
5
|
+
import HashiRow from './components/HashiRow.tsx'
|
|
6
|
+
|
|
7
|
+
render(
|
|
8
|
+
<HashiRow
|
|
9
|
+
nodes={[
|
|
10
|
+
{ value: 1 },
|
|
11
|
+
{ value: '-' },
|
|
12
|
+
{ value: 2 },
|
|
13
|
+
{ value: '#' },
|
|
14
|
+
{ value: 2 },
|
|
15
|
+
{ value: 3 },
|
|
16
|
+
]}
|
|
17
|
+
rowIndex={0}
|
|
18
|
+
highlightedNode={2}
|
|
19
|
+
/>
|
|
20
|
+
)
|
package/src/index.tsx
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env -S bun run
|
|
2
2
|
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { resolve } from 'node:path'
|
|
3
5
|
import { Command } from 'commander'
|
|
4
6
|
import { render } from 'ink'
|
|
5
7
|
|
|
6
8
|
import Game from './Game.tsx'
|
|
7
|
-
import { type PuzzleData, samplePuzzles } from './utils/
|
|
9
|
+
import { type PuzzleData, samplePuzzles } from './utils/puzzle-encoding.ts'
|
|
10
|
+
|
|
11
|
+
const packageJson = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8'))
|
|
8
12
|
|
|
9
13
|
type CliOptions = {
|
|
10
|
-
stdout: boolean
|
|
11
14
|
puzzle: string | undefined
|
|
15
|
+
enableSolutions: boolean
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
const program = new Command()
|
|
@@ -16,8 +20,26 @@ const program = new Command()
|
|
|
16
20
|
program
|
|
17
21
|
.name('bridges')
|
|
18
22
|
.description('Bridges (Hashiwokakero) puzzle game')
|
|
19
|
-
.
|
|
20
|
-
.option(
|
|
23
|
+
.version(packageJson.version, '-v, --version')
|
|
24
|
+
.option(
|
|
25
|
+
'-p, --puzzle <puzzle>',
|
|
26
|
+
`Puzzle shorthand encoding
|
|
27
|
+
Format: "WIDTHxHEIGHT:row1.row2.row3..."
|
|
28
|
+
|
|
29
|
+
Node encoding:
|
|
30
|
+
- Digits (1-8): island with that value
|
|
31
|
+
- Letters (a-z): space between islands (b=2, etc.)
|
|
32
|
+
|
|
33
|
+
Bridge encoding (optional):
|
|
34
|
+
- "-": single horizontal bridge
|
|
35
|
+
- "=": double horizontal bridge
|
|
36
|
+
- "|": single vertical bridge
|
|
37
|
+
- "#": double vertical bridge
|
|
38
|
+
|
|
39
|
+
Example (3x3 with corner islands):
|
|
40
|
+
--puzzle "3x3:1a2.c.1a2"`
|
|
41
|
+
)
|
|
42
|
+
.option('--enable-solutions', 'Enable the solution viewing feature', false)
|
|
21
43
|
.parse(process.argv)
|
|
22
44
|
|
|
23
45
|
const options = program.opts<CliOptions>()
|
|
@@ -30,5 +52,9 @@ if (options.puzzle) {
|
|
|
30
52
|
}
|
|
31
53
|
|
|
32
54
|
render(
|
|
33
|
-
<Game
|
|
55
|
+
<Game
|
|
56
|
+
puzzles={puzzles}
|
|
57
|
+
hasCustomPuzzle={hasCustomPuzzle}
|
|
58
|
+
enableSolutions={options.enableSolutions}
|
|
59
|
+
/>
|
|
34
60
|
)
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HashiNode types
|
|
3
|
+
*/
|
|
1
4
|
export type HashiNodeData = {
|
|
2
5
|
value: number | '-' | '=' | '#' | ' ' | '|'
|
|
3
6
|
/** Num lines connected on left, undefined if 0. */
|
|
@@ -9,3 +12,34 @@ export type HashiNodeData = {
|
|
|
9
12
|
/** Num lines connected below, undefined if 0. */
|
|
10
13
|
lineDown?: 1 | 2
|
|
11
14
|
}
|
|
15
|
+
|
|
16
|
+
export type HashiNodeDisplayMode = 'normal' | 'highlight' | 'dim'
|
|
17
|
+
|
|
18
|
+
export type HashiNodeOptions = {
|
|
19
|
+
displayMode?: HashiNodeDisplayMode
|
|
20
|
+
label?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Game operation types
|
|
25
|
+
*/
|
|
26
|
+
export type SelectionMode = 'idle' | 'selecting-node' | 'disambiguation' | 'selected' | 'invalid'
|
|
27
|
+
|
|
28
|
+
export type Direction = 'h' | 'j' | 'k' | 'l'
|
|
29
|
+
|
|
30
|
+
export type PlacedBridge = {
|
|
31
|
+
from: { row: number; col: number }
|
|
32
|
+
to: { row: number; col: number }
|
|
33
|
+
count?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type SelectionState = {
|
|
37
|
+
mode: SelectionMode
|
|
38
|
+
selectedNumber: number | null
|
|
39
|
+
direction: Direction | null
|
|
40
|
+
matchingNodes: { row: number; col: number }[]
|
|
41
|
+
disambiguationLabels: string[]
|
|
42
|
+
selectedNode: { row: number; col: number } | null
|
|
43
|
+
bridgeErased?: boolean
|
|
44
|
+
isDoubleBridge?: boolean
|
|
45
|
+
}
|