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.
@@ -1,15 +1,17 @@
1
1
  import { Box } from 'ink'
2
2
 
3
- import type { HashiNodeData } from '../types.ts'
3
+ import type { HashiNodeData, SelectionState } from '../types.ts'
4
+ import {
5
+ NODE_WIDTH,
6
+ OUTER_PADDING,
7
+ ROW_HEIGHT,
8
+ SPACE_BETWEEN,
9
+ validateGrid,
10
+ } from '../utils/bridges.ts'
4
11
  import HashiRow from './HashiRow.tsx'
5
12
  import Header from './Header.tsx'
6
13
  import Messages from './Messages.tsx'
7
14
 
8
- export const ROW_HEIGHT = 3
9
- export const NODE_WIDTH = 5
10
- export const SPACE_BETWEEN = 0
11
- export const OUTER_PADDING = 1
12
-
13
15
  type HashiGridProps = {
14
16
  /** The full data structure needed to render the grid. Height of the grid is determined
15
17
  * by the number of rows here. */
@@ -28,42 +30,18 @@ type HashiGridProps = {
28
30
  hasSolution?: boolean
29
31
  /** Whether to show the solution */
30
32
  showSolution?: boolean
31
- }
32
-
33
- /**
34
- * Ensure the grid data is consistent with a valid Hashiwokakero puzzle.
35
- */
36
- export function validateGrid({ rows, numNodes }: HashiGridProps): void {
37
- if (!rows || rows.length === 0) {
38
- throw new Error('HashiGrid: empty data supplied')
39
- }
40
-
41
- let rowCount = 0
42
- for (const nodes of rows) {
43
- const prefix = `HashiGrid row ${rowCount}: `
44
-
45
- if (nodes.length !== numNodes) {
46
- throw new Error(`${prefix}expected ${numNodes} nodes, got ${nodes.length}`)
47
- }
48
-
49
- for (let i = 0; i < nodes.length; i++) {
50
- const node = nodes[i]
51
- if (!node) {
52
- throw new Error(`${prefix}node at position ${i} is undefined`)
53
- }
54
- if (
55
- typeof node.value !== 'number' &&
56
- node.value !== '-' &&
57
- node.value !== '=' &&
58
- node.value !== ' ' &&
59
- node.value !== '#' &&
60
- node.value !== '|'
61
- ) {
62
- throw new Error(`${prefix}node at position ${i} has invalid value: ${node.value}`)
63
- }
64
- }
65
- rowCount++
66
- }
33
+ /** Whether to enable the solution feature */
34
+ enableSolutions?: boolean
35
+ /** Current selection state for highlighting */
36
+ selectionState?: SelectionState
37
+ /** Minimum number value in the puzzle */
38
+ minNumber?: number
39
+ /** Maximum number value in the puzzle */
40
+ maxNumber?: number
41
+ /** Whether the puzzle solution has been reached */
42
+ solutionReached?: boolean
43
+ /** Whether the grid is not fully connected but all nodes are filled */
44
+ gridNotConnected?: boolean
67
45
  }
68
46
 
69
47
  export default function HashiGrid({
@@ -75,6 +53,12 @@ export default function HashiGrid({
75
53
  isCustomPuzzle = false,
76
54
  hasSolution = false,
77
55
  showSolution = false,
56
+ enableSolutions = false,
57
+ selectionState,
58
+ minNumber,
59
+ maxNumber,
60
+ solutionReached = false,
61
+ gridNotConnected = false,
78
62
  }: HashiGridProps) {
79
63
  validateGrid({ rows, numNodes })
80
64
 
@@ -92,6 +76,9 @@ export default function HashiGrid({
92
76
  puzzle={puzzle}
93
77
  isCustomPuzzle={isCustomPuzzle}
94
78
  showSolution={showSolution}
79
+ selectionState={selectionState}
80
+ minNumber={minNumber}
81
+ maxNumber={maxNumber}
95
82
  />
96
83
  <Box
97
84
  borderStyle="single"
@@ -101,10 +88,25 @@ export default function HashiGrid({
101
88
  flexDirection="column"
102
89
  >
103
90
  {rows.map((nodes, i) => (
104
- <HashiRow key={i} nodes={nodes} />
91
+ <HashiRow
92
+ key={i}
93
+ nodes={nodes}
94
+ rowIndex={i}
95
+ highlightedNode={selectionState?.selectedNumber ?? undefined}
96
+ selectionState={selectionState}
97
+ showSolution={showSolution}
98
+ />
105
99
  ))}
106
100
  </Box>
107
- {showInstructions ? <Messages hasSolution={hasSolution} /> : null}
101
+ {showInstructions ? (
102
+ <Messages
103
+ hasSolution={hasSolution}
104
+ enableSolutions={enableSolutions}
105
+ selectionState={selectionState}
106
+ solutionReached={solutionReached}
107
+ gridNotConnected={gridNotConnected}
108
+ />
109
+ ) : null}
108
110
  </Box>
109
111
  )
110
112
  }
@@ -1,94 +1,95 @@
1
1
  import { Box, Text } from 'ink'
2
+ import type React from 'react'
2
3
 
3
- import type { HashiNodeData } from '../types.ts'
4
- import { NODE_WIDTH, OUTER_PADDING, SPACE_BETWEEN } from './HashiGrid.tsx'
4
+ import type { HashiNodeData, SelectionState } from '../types.ts'
5
+ import {
6
+ constructNode,
7
+ getDisplayMode,
8
+ getNodeFilledState,
9
+ NODE_WIDTH,
10
+ OUTER_PADDING,
11
+ ROW_HEIGHT,
12
+ SPACE_BETWEEN,
13
+ } from '../utils/bridges.ts'
5
14
 
6
15
  type HashiRowProps = {
7
16
  nodes: HashiNodeData[]
17
+ highlightedNode?: number
18
+ rowIndex: number
19
+ selectionState?: SelectionState
20
+ showSolution?: boolean
8
21
  }
9
22
 
10
- const TOP_ROW = 0
11
- const MIDDLE_ROW = 1
12
- const BOTTOM_ROW = 2
13
- const ROW_HEIGHT = 3
14
-
15
- /**
16
- * Build the HashiGrid node with its value and borders. Options:
17
- * - node with a value (always 1 digit)
18
- * - empty node - render just spaces
19
- * - horizontal line - single and double
20
- * - vertical line - single and double
21
- */
22
- export function constructNode(node: HashiNodeData, line: 0 | 1 | 2): string {
23
- // Horizontal line
24
- if (node.value === '-') {
25
- return line === MIDDLE_ROW ? '─────' : ' '.repeat(NODE_WIDTH)
26
- }
27
-
28
- // Double horizontal line
29
- if (node.value === '=') {
30
- return line === MIDDLE_ROW ? '═════' : ' '.repeat(NODE_WIDTH)
31
- }
32
-
33
- // Vertical line
34
- if (node.value === '|') {
35
- return ' │ '
36
- }
37
-
38
- // Double vertical line
39
- if (node.value === '#') {
40
- return ' ║ '
41
- }
23
+ export default function HashiRow({
24
+ nodes,
25
+ highlightedNode,
26
+ rowIndex,
27
+ selectionState,
28
+ showSolution,
29
+ }: HashiRowProps) {
30
+ // Each row consists of multiple lines of terminal output
31
+ const lines: React.ReactNode[] = []
42
32
 
43
- // Node with value to render
44
- if (node.value !== ' ') {
45
- if (line === TOP_ROW) {
46
- const up = node.lineUp === 2 ? '╨' : node.lineUp === 1 ? '┴' : '─'
47
- return `╭─${up}─╮`
48
- } else if (line === MIDDLE_ROW) {
49
- const left = node.lineLeft === 2 ? '╡' : node.lineLeft === 1 ? '┤' : '│'
50
- const right = node.lineRight === 2 ? '' : node.lineRight === 1 ? '├' : '│'
51
- return `${left} ${node.value} ${right}`
52
- } else if (line === BOTTOM_ROW) {
53
- const down = node.lineDown === 2 ? '╥' : node.lineDown === 1 ? '┬' : '─'
54
- return `╰─${down}─╯`
33
+ // Build a map of col index to disambiguation label for this row
34
+ const disambiguationMap: Record<number, string> = {}
35
+ if (selectionState?.mode === 'disambiguation' && selectionState.matchingNodes) {
36
+ for (let i = 0; i < selectionState.matchingNodes.length; i++) {
37
+ const match = selectionState.matchingNodes[i]
38
+ const label = selectionState.disambiguationLabels[i]
39
+ if (match && match.row === rowIndex) {
40
+ disambiguationMap[match.col] = label ?? ''
41
+ }
55
42
  }
56
43
  }
57
44
 
58
- // Empty node
59
- return ' '.repeat(NODE_WIDTH)
60
- }
61
-
62
- export default function HashiRow({ nodes }: HashiRowProps) {
63
- // Each row consists of multiple lines of terminal output
64
- const lines: string[] = []
65
45
  for (let line = 0; line < ROW_HEIGHT; line++) {
66
- let rowStr = ' '.repeat(OUTER_PADDING)
46
+ const rowItems: React.ReactNode[] = []
67
47
 
68
48
  // Render each node into the terminal line output
69
49
  for (let i = 0; i < nodes.length; i++) {
70
50
  const node = nodes[i]
51
+
71
52
  if (!node) {
72
- rowStr += ' '.repeat(NODE_WIDTH)
53
+ rowItems.push(' '.repeat(NODE_WIDTH))
73
54
  continue
74
55
  }
75
56
 
76
- rowStr += constructNode(node, line as 0 | 1 | 2)
57
+ const displayMode = getDisplayMode(
58
+ node,
59
+ highlightedNode,
60
+ rowIndex,
61
+ i,
62
+ selectionState?.selectedNode ?? null,
63
+ selectionState?.mode
64
+ )
65
+ const label = disambiguationMap[i]
66
+ const filledState = getNodeFilledState(node)
67
+ rowItems.push(
68
+ constructNode(
69
+ node,
70
+ line as 0 | 1 | 2,
71
+ displayMode,
72
+ label,
73
+ filledState,
74
+ showSolution
75
+ )
76
+ )
77
77
 
78
78
  // Add space between columns except the last
79
79
  if (i < nodes.length - 1) {
80
- rowStr += ' '.repeat(SPACE_BETWEEN)
80
+ rowItems.push(' '.repeat(SPACE_BETWEEN))
81
81
  }
82
82
  }
83
- rowStr += ' '.repeat(OUTER_PADDING)
84
- lines.push(rowStr)
83
+
84
+ const rowStr = (
85
+ <>
86
+ {' '.repeat(OUTER_PADDING)}
87
+ {rowItems}
88
+ {' '.repeat(OUTER_PADDING)}
89
+ </>
90
+ )
91
+ lines.push(<Text key={line}>{rowStr}</Text>)
85
92
  }
86
93
 
87
- return (
88
- <Box flexDirection="column">
89
- {lines.map((line, i) => (
90
- <Text key={i}>{line}</Text>
91
- ))}
92
- </Box>
93
- )
94
+ return <Box flexDirection="column">{lines}</Box>
94
95
  }
@@ -1,10 +1,22 @@
1
1
  import { Box, Text } from 'ink'
2
2
 
3
+ import type { SelectionState } from '../types.ts'
4
+
3
5
  type HeaderProps = {
4
6
  puzzleIndex: number
5
7
  puzzle: string
6
8
  isCustomPuzzle?: boolean
7
9
  showSolution?: boolean
10
+ selectionState?: SelectionState
11
+ minNumber?: number
12
+ maxNumber?: number
13
+ }
14
+
15
+ const directionNames: Record<string, string> = {
16
+ h: 'left',
17
+ j: 'down',
18
+ k: 'up',
19
+ l: 'right',
8
20
  }
9
21
 
10
22
  export default function Header({
@@ -12,14 +24,48 @@ export default function Header({
12
24
  puzzle,
13
25
  isCustomPuzzle = false,
14
26
  showSolution = false,
27
+ selectionState,
28
+ minNumber,
29
+ maxNumber,
15
30
  }: HeaderProps) {
16
31
  const title = isCustomPuzzle
17
32
  ? `Bridges: Puzzle - ${puzzle}`
18
- : `Bridges: Puzzle #${puzzleIndex + 1}${showSolution ? ' (Solution)' : ''}`
33
+ : `Bridges: Puzzle #${puzzleIndex + 1}`
34
+
35
+ let statusText = ''
36
+ if (selectionState) {
37
+ const { mode, selectedNumber, direction, bridgeErased, isDoubleBridge } = selectionState
38
+ if (mode === 'selecting-node' && selectedNumber !== null) {
39
+ statusText = 'Select direction with h/j/k/l (H/J/K/L = double line)'
40
+ } else if (mode === 'disambiguation' && selectedNumber !== null) {
41
+ statusText = 'Press label shown to select that node'
42
+ } else if (mode === 'selected' && selectedNumber !== null && direction) {
43
+ if (bridgeErased) {
44
+ statusText = 'Erased bridge'
45
+ } else {
46
+ const lineType = direction === 'h' || direction === 'l' ? 'horizontal' : 'vertical'
47
+ const bridgeType = isDoubleBridge ? 'double ' : ''
48
+ statusText = `Drew ${bridgeType}${lineType} bridge`
49
+ }
50
+ } else if (mode === 'invalid' && selectedNumber !== null && direction) {
51
+ statusText = `Cannot draw bridge ${directionNames[direction]} from node`
52
+ }
53
+ }
54
+
55
+ const idleMessage = showSolution
56
+ ? 'Viewing solution (press s to return to puzzle)'
57
+ : minNumber !== undefined && maxNumber !== undefined
58
+ ? `Type a number [${minNumber}-${maxNumber}] to select a node`
59
+ : 'Type a number to select a node'
19
60
 
20
61
  return (
21
- <Box marginBottom={1}>
62
+ <Box flexDirection="column" marginBottom={1}>
22
63
  <Text bold>{title}</Text>
64
+ {statusText ? (
65
+ <Text dimColor>• {statusText}</Text>
66
+ ) : (
67
+ <Text dimColor>• {idleMessage}</Text>
68
+ )}
23
69
  </Box>
24
70
  )
25
71
  }
@@ -1,27 +1,67 @@
1
1
  import { Box, Text } from 'ink'
2
2
 
3
+ import type { SelectionState } from '../types.ts'
4
+
3
5
  type LegendItem = {
4
6
  key: string
5
7
  description: string
6
8
  disabled?: boolean
7
9
  }
8
10
 
9
- const LEGEND_ITEMS = (hasSolution: boolean): LegendItem[] => [
10
- { key: 'p', description: 'Previous puzzle' },
11
- { key: 'n', description: 'Next puzzle' },
12
- { key: 's', description: 'Show solution', disabled: !hasSolution },
13
- { key: 'q', description: 'Quit' },
14
- ]
11
+ export function legendItems(
12
+ hasSolution: boolean,
13
+ enableSolutions: boolean,
14
+ isSelecting: boolean
15
+ ): LegendItem[] {
16
+ const items: LegendItem[] = [
17
+ { key: 'p', description: 'Previous puzzle', disabled: isSelecting },
18
+ { key: 'n', description: 'Next puzzle', disabled: isSelecting },
19
+ ]
20
+
21
+ if (enableSolutions) {
22
+ items.push({
23
+ key: 's',
24
+ description: 'Show solution',
25
+ disabled: isSelecting || !hasSolution,
26
+ })
27
+ }
28
+
29
+ items.push({ key: 'q', description: 'Quit' })
30
+
31
+ return items
32
+ }
15
33
 
16
34
  type MessagesProps = {
17
35
  hasSolution?: boolean
36
+ enableSolutions?: boolean
37
+ selectionState?: SelectionState
38
+ solutionReached?: boolean
39
+ gridNotConnected?: boolean
18
40
  }
19
41
 
20
- export default function Messages({ hasSolution = false }: MessagesProps) {
42
+ export default function Messages({
43
+ hasSolution = false,
44
+ enableSolutions = false,
45
+ selectionState,
46
+ solutionReached = false,
47
+ gridNotConnected = false,
48
+ }: MessagesProps) {
49
+ const isSelecting = selectionState !== undefined && selectionState.mode !== 'idle'
50
+
21
51
  return (
22
52
  <Box flexDirection="column" marginTop={1}>
53
+ {solutionReached ? (
54
+ <Text bold color="green">
55
+ Solution reached!
56
+ </Text>
57
+ ) : null}
58
+ {gridNotConnected ? (
59
+ <Text bold color="yellow">
60
+ Grid is not fully connected
61
+ </Text>
62
+ ) : null}
23
63
  <Text bold>Controls:</Text>
24
- {LEGEND_ITEMS(hasSolution).map(item => (
64
+ {legendItems(hasSolution, enableSolutions, isSelecting).map(item => (
25
65
  <Box key={item.key}>
26
66
  <Text bold color={item.disabled ? 'gray' : undefined}>
27
67
  {item.key}
@@ -19,6 +19,7 @@ describe('HashiGrid', () => {
19
19
 
20
20
  expect(lastFrame()).toEqual(
21
21
  `Bridges: Puzzle #1
22
+ • Type a number to select a node
22
23
 
23
24
  ┌───────────────────────────┐
24
25
  │ ╭───╮ ╭───╮ │