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,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
|
-
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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 ?
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
+
rowItems.push(' '.repeat(NODE_WIDTH))
|
|
73
54
|
continue
|
|
74
55
|
}
|
|
75
56
|
|
|
76
|
-
|
|
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
|
-
|
|
80
|
+
rowItems.push(' '.repeat(SPACE_BETWEEN))
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
|
-
|
|
84
|
-
|
|
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}
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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({
|
|
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
|
-
{
|
|
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}
|