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.
@@ -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/samplePuzzles.ts'
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
- .option('-s, --stdout', 'Output to stdout and exit (for testing)')
20
- .option('-p, --puzzle <puzzle>', 'Puzzle shorthand encoding')
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
+ })
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { parsePuzzle } from '../parsePuzzle.ts'
3
+ import { parsePuzzle } from '../puzzle-encoding.ts'
4
4
 
5
5
  describe('parsePuzzle', () => {
6
6
  describe('encodings without the solution (bridges)', () => {
@@ -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
+ }