bridges-cli 0.0.1 → 0.1.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 -5
- package/README.md +13 -7
- package/package.json +3 -3
- package/src/Game.tsx +141 -11
- package/src/__tests__/Game.test.tsx +482 -24
- package/src/components/HashiGrid.tsx +30 -44
- package/src/components/HashiRow.tsx +54 -66
- package/src/components/Header.tsx +48 -2
- package/src/components/Messages.tsx +15 -8
- package/src/components/__tests__/HashiGrid.test.tsx +1 -0
- package/src/components/__tests__/HashiRow.test.tsx +100 -112
- package/src/components/__tests__/Header.test.tsx +156 -0
- package/src/components/__tests__/Messages.test.tsx +46 -1
- package/src/demo.tsx +20 -0
- package/src/index.tsx +25 -7
- 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 +171 -0
- package/src/utils/puzzle-encoding.ts +286 -0
- package/src/utils/usePuzzleInput.ts +385 -13
- package/src/utils/parsePuzzle.ts +0 -178
- package/src/utils/samplePuzzles.ts +0 -59
|
@@ -1,7 +1,7 @@
|
|
|
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
7
|
it('renders the legend', () => {
|
|
@@ -9,6 +9,51 @@ describe('Messages', () => {
|
|
|
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()).toContain('s: Show solution')
|
|
12
13
|
expect(lastFrame()).toContain('q: Quit')
|
|
13
14
|
})
|
|
15
|
+
|
|
16
|
+
describe('disabled state in selection mode', () => {
|
|
17
|
+
it('shows n/p/s as enabled when idle', () => {
|
|
18
|
+
const items = legendItems(true, false)
|
|
19
|
+
const p = items.find(i => i.key === 'p')
|
|
20
|
+
const n = items.find(i => i.key === 'n')
|
|
21
|
+
const s = items.find(i => i.key === 's')
|
|
22
|
+
expect(p?.disabled).toBe(false)
|
|
23
|
+
expect(n?.disabled).toBe(false)
|
|
24
|
+
expect(s?.disabled).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('shows n/p/s as disabled in selecting-node mode', () => {
|
|
28
|
+
const items = legendItems(true, true)
|
|
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(true)
|
|
33
|
+
expect(n?.disabled).toBe(true)
|
|
34
|
+
expect(s?.disabled).toBe(true)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('shows n/p/s as disabled in disambiguation mode', () => {
|
|
38
|
+
const items = legendItems(true, 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?.disabled).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('shows s as disabled when no solution exists', () => {
|
|
48
|
+
const items = legendItems(false, false)
|
|
49
|
+
const s = items.find(i => i.key === 's')
|
|
50
|
+
expect(s?.disabled).toBe(true)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('shows s as enabled when solution exists', () => {
|
|
54
|
+
const items = legendItems(true, false)
|
|
55
|
+
const s = items.find(i => i.key === 's')
|
|
56
|
+
expect(s?.disabled).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
14
59
|
})
|
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,13 +1,16 @@
|
|
|
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
|
|
12
15
|
}
|
|
13
16
|
|
|
@@ -16,8 +19,25 @@ const program = new Command()
|
|
|
16
19
|
program
|
|
17
20
|
.name('bridges')
|
|
18
21
|
.description('Bridges (Hashiwokakero) puzzle game')
|
|
19
|
-
.
|
|
20
|
-
.option(
|
|
22
|
+
.version(packageJson.version, '-v, --version')
|
|
23
|
+
.option(
|
|
24
|
+
'-p, --puzzle <puzzle>',
|
|
25
|
+
`Puzzle shorthand encoding
|
|
26
|
+
Format: "WIDTHxHEIGHT:row1.row2.row3..."
|
|
27
|
+
|
|
28
|
+
Node encoding:
|
|
29
|
+
- Digits (1-8): island with that value
|
|
30
|
+
- Letters (a-z): space between islands (b=2, etc.)
|
|
31
|
+
|
|
32
|
+
Bridge encoding (optional):
|
|
33
|
+
- "-": single horizontal bridge
|
|
34
|
+
- "=": double horizontal bridge
|
|
35
|
+
- "|": single vertical bridge
|
|
36
|
+
- "#": double vertical bridge
|
|
37
|
+
|
|
38
|
+
Example (3x3 with corner islands):
|
|
39
|
+
--puzzle "3x3:1a2.c.1a2"`
|
|
40
|
+
)
|
|
21
41
|
.parse(process.argv)
|
|
22
42
|
|
|
23
43
|
const options = program.opts<CliOptions>()
|
|
@@ -29,6 +49,4 @@ if (options.puzzle) {
|
|
|
29
49
|
puzzles = [{ encoding: options.puzzle }, ...samplePuzzles]
|
|
30
50
|
}
|
|
31
51
|
|
|
32
|
-
render(
|
|
33
|
-
<Game puzzles={puzzles} hasCustomPuzzle={hasCustomPuzzle} stdout={options.stdout || false} />
|
|
34
|
-
)
|
|
52
|
+
render(<Game puzzles={puzzles} hasCustomPuzzle={hasCustomPuzzle} />)
|
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
|
+
}
|
|
@@ -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,171 @@
|
|
|
1
|
+
import type { HashiNodeData, HashiNodeDisplayMode } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
export const ROW_HEIGHT = 3
|
|
4
|
+
export const NODE_WIDTH = 5
|
|
5
|
+
export const SPACE_BETWEEN = 0
|
|
6
|
+
export const OUTER_PADDING = 1
|
|
7
|
+
|
|
8
|
+
export const TOP_ROW = 0
|
|
9
|
+
export const MIDDLE_ROW = 1
|
|
10
|
+
export const BOTTOM_ROW = 2
|
|
11
|
+
|
|
12
|
+
export type HashiGridValidationProps = {
|
|
13
|
+
rows: HashiNodeData[][]
|
|
14
|
+
numNodes: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensure the grid data is consistent with a valid Bridges puzzle.
|
|
19
|
+
*/
|
|
20
|
+
export function validateGrid({ rows, numNodes }: HashiGridValidationProps): void {
|
|
21
|
+
if (!rows || rows.length === 0) {
|
|
22
|
+
throw new Error('HashiGrid: empty data supplied')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let rowCount = 0
|
|
26
|
+
for (const nodes of rows) {
|
|
27
|
+
const prefix = `HashiGrid row ${rowCount}: `
|
|
28
|
+
|
|
29
|
+
if (nodes.length !== numNodes) {
|
|
30
|
+
throw new Error(`${prefix}expected ${numNodes} nodes, got ${nodes.length}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
34
|
+
const node = nodes[i]
|
|
35
|
+
if (!node) {
|
|
36
|
+
throw new Error(`${prefix}node at position ${i} is undefined`)
|
|
37
|
+
}
|
|
38
|
+
if (
|
|
39
|
+
typeof node.value !== 'number' &&
|
|
40
|
+
node.value !== '-' &&
|
|
41
|
+
node.value !== '=' &&
|
|
42
|
+
node.value !== ' ' &&
|
|
43
|
+
node.value !== '#' &&
|
|
44
|
+
node.value !== '|'
|
|
45
|
+
) {
|
|
46
|
+
throw new Error(`${prefix}node at position ${i} has invalid value: ${node.value}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
rowCount++
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Determines the display mode for a node based on the highlighted node value.
|
|
55
|
+
*/
|
|
56
|
+
export function getDisplayMode(
|
|
57
|
+
node: HashiNodeData,
|
|
58
|
+
highlightedNode?: number,
|
|
59
|
+
row?: number,
|
|
60
|
+
col?: number,
|
|
61
|
+
selectedNode?: { row: number; col: number } | null,
|
|
62
|
+
mode?: string
|
|
63
|
+
): HashiNodeDisplayMode {
|
|
64
|
+
// In selecting-node, selected, or invalid mode, highlight only the specific selected node
|
|
65
|
+
if (
|
|
66
|
+
(mode === 'selecting-node' || mode === 'selected' || mode === 'invalid') &&
|
|
67
|
+
selectedNode &&
|
|
68
|
+
row !== undefined &&
|
|
69
|
+
col !== undefined
|
|
70
|
+
) {
|
|
71
|
+
if (row === selectedNode.row && col === selectedNode.col) {
|
|
72
|
+
return 'highlight'
|
|
73
|
+
}
|
|
74
|
+
return 'dim'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (highlightedNode === undefined) {
|
|
78
|
+
return 'normal'
|
|
79
|
+
}
|
|
80
|
+
if (typeof node.value === 'number' && node.value === highlightedNode) {
|
|
81
|
+
return 'highlight'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Bridge values are strings, so they can never match a number highlightedNode
|
|
85
|
+
return 'dim'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the HashiGrid node with its value and borders. Options:
|
|
90
|
+
* - node with a value (always 1 digit)
|
|
91
|
+
* - empty node - render just spaces
|
|
92
|
+
* - horizontal line - single and double
|
|
93
|
+
* - vertical line - single and double
|
|
94
|
+
*/
|
|
95
|
+
export function constructNode(
|
|
96
|
+
node: HashiNodeData,
|
|
97
|
+
line: 0 | 1 | 2,
|
|
98
|
+
displayMode: HashiNodeDisplayMode = 'normal',
|
|
99
|
+
disambiguationLabel?: string
|
|
100
|
+
): string {
|
|
101
|
+
// Horizontal line
|
|
102
|
+
if (node.value === '-') {
|
|
103
|
+
if (displayMode === 'dim') {
|
|
104
|
+
return line === MIDDLE_ROW ? `\x1b[2m─────\x1b[22m` : ' '.repeat(NODE_WIDTH)
|
|
105
|
+
}
|
|
106
|
+
return line === MIDDLE_ROW ? '─────' : ' '.repeat(NODE_WIDTH)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Double horizontal line
|
|
110
|
+
if (node.value === '=') {
|
|
111
|
+
if (displayMode === 'dim') {
|
|
112
|
+
return line === MIDDLE_ROW ? `\x1b[2m═════\x1b[22m` : ' '.repeat(NODE_WIDTH)
|
|
113
|
+
}
|
|
114
|
+
return line === MIDDLE_ROW ? '═════' : ' '.repeat(NODE_WIDTH)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Vertical line
|
|
118
|
+
if (node.value === '|') {
|
|
119
|
+
if (displayMode === 'dim') {
|
|
120
|
+
return `\x1b[2m │ \x1b[22m`
|
|
121
|
+
}
|
|
122
|
+
return ' │ '
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Double vertical line
|
|
126
|
+
if (node.value === '#') {
|
|
127
|
+
if (displayMode === 'dim') {
|
|
128
|
+
return `\x1b[2m ║ \x1b[22m`
|
|
129
|
+
}
|
|
130
|
+
return ' ║ '
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Node with value to render
|
|
134
|
+
if (node.value !== ' ') {
|
|
135
|
+
if (line === TOP_ROW) {
|
|
136
|
+
const up = node.lineUp === 2 ? '╨' : node.lineUp === 1 ? '┴' : '─'
|
|
137
|
+
const label = disambiguationLabel ? disambiguationLabel : '─'
|
|
138
|
+
const border = `╭${label}${up}─╮`
|
|
139
|
+
if (displayMode === 'highlight') {
|
|
140
|
+
return `\x1b[1m${border}\x1b[22m`
|
|
141
|
+
}
|
|
142
|
+
if (displayMode === 'dim') {
|
|
143
|
+
return `\x1b[2m${border}\x1b[22m`
|
|
144
|
+
}
|
|
145
|
+
return border
|
|
146
|
+
} else if (line === MIDDLE_ROW) {
|
|
147
|
+
const left = node.lineLeft === 2 ? '╡' : node.lineLeft === 1 ? '┤' : '│'
|
|
148
|
+
const right = node.lineRight === 2 ? '╞' : node.lineRight === 1 ? '├' : '│'
|
|
149
|
+
if (displayMode === 'highlight') {
|
|
150
|
+
return `\x1b[1m${left} ${node.value} ${right}\x1b[22m`
|
|
151
|
+
}
|
|
152
|
+
if (displayMode === 'dim') {
|
|
153
|
+
return `\x1b[2m${left} ${node.value} ${right}\x1b[22m`
|
|
154
|
+
}
|
|
155
|
+
return `${left} ${node.value} ${right}`
|
|
156
|
+
} else if (line === BOTTOM_ROW) {
|
|
157
|
+
const down = node.lineDown === 2 ? '╥' : node.lineDown === 1 ? '┬' : '─'
|
|
158
|
+
const border = `╰─${down}─╯`
|
|
159
|
+
if (displayMode === 'highlight') {
|
|
160
|
+
return `\x1b[1m${border}\x1b[22m`
|
|
161
|
+
}
|
|
162
|
+
if (displayMode === 'dim') {
|
|
163
|
+
return `\x1b[2m${border}\x1b[22m`
|
|
164
|
+
}
|
|
165
|
+
return border
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Empty node
|
|
170
|
+
return ' '.repeat(NODE_WIDTH)
|
|
171
|
+
}
|