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
package/AGENTS.md
CHANGED
|
@@ -5,20 +5,22 @@ This is a CLI renderer for Hashiwokakero (Bridges) puzzles. It renders a grid of
|
|
|
5
5
|
## Commands
|
|
6
6
|
|
|
7
7
|
- `bun start` - Run the puzzle (interactive mode)
|
|
8
|
-
- `bun start --stdout` - Run puzzle and output to stdout (for testing)
|
|
9
8
|
- `bun start --puzzle <encoding>` - Run with a specific puzzle encoding
|
|
10
9
|
- `bun run test` - Run unit tests
|
|
11
|
-
- `bun run
|
|
10
|
+
- `bun run typecheck` - typescript typechecker
|
|
12
11
|
- `bun run lint` - Lint and format
|
|
13
12
|
|
|
14
13
|
## Testing
|
|
15
14
|
|
|
16
|
-
Tests use Vitest with `@testing-library/react` patterns via `ink-testing-library`.
|
|
15
|
+
- Tests use Vitest with `@testing-library/react` patterns via `ink-testing-library`.
|
|
16
|
+
- The output is a grid drawn with ascii, and tests often do exact matches. When nodes are
|
|
17
|
+
highlighted, they have ANSI codes to appear as bold/dim or red/green. The tests will put
|
|
18
|
+
these codes directly in the test expectation around the nodes.
|
|
17
19
|
|
|
18
20
|
## Code Style
|
|
19
21
|
|
|
20
|
-
-
|
|
21
|
-
- Run linting before committing
|
|
22
|
-
- No custom ESLint/Prettier config - Biome handles everything
|
|
22
|
+
- Run linting before committing - the lint command uses Biome.js
|
|
23
23
|
- Add comments to separate code blocks. Don't remove existing comments.
|
|
24
|
+
- Add a comment above functions that explain the intention of the function.
|
|
25
|
+
- Don't re-export imports - no barrel files
|
|
24
26
|
|
package/README.md
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
1
|
# BridgesCLI
|
|
2
|
-
A CLI-based Bridges
|
|
3
|
-
gain more experience
|
|
2
|
+
A CLI-based Bridges puzzle renderer built with React and Ink. This exists mostly to
|
|
3
|
+
gain more experience using an agent-based workflow.
|
|
4
4
|
|
|
5
|
-
## What is
|
|
6
|
-
|
|
5
|
+
## What is Bridges?
|
|
6
|
+
Bridges (also known as Hashiwokakero) is a logic puzzle where you connect islands with bridges.
|
|
7
7
|
Each island has a number indicating how many bridges must connect to it.
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
11
|
-
## Run the
|
|
11
|
+
## Run the game
|
|
12
|
+
``` bash
|
|
13
|
+
npm install -g bridges-cli
|
|
14
|
+
bridges
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Local Development
|
|
18
|
+
Clone the repo then run:
|
|
12
19
|
``` bash
|
|
13
20
|
bun start
|
|
14
21
|
```
|
|
15
22
|
|
|
16
23
|
### CLI Options
|
|
17
|
-
- `-s, --stdout` - Output to stdout and exit immediately (for testing)
|
|
18
24
|
- `-p, --puzzle <identifier>` - Render a puzzle via shorthand encoding (see `samplePuzzles.ts`)
|
|
25
|
+
- `--enable-solutions` - Enable the show solution toggle in the game (disabled by default)
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
### Run tests and linter
|
|
21
28
|
``` bash
|
|
22
29
|
bun run typecheck
|
|
23
30
|
bun run test
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bridges-cli",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "index.js",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Bridges CLI puzzle game",
|
|
6
5
|
"bin": {
|
|
7
6
|
"bridges": "src/index.tsx"
|
|
8
7
|
},
|
|
9
8
|
"scripts": {
|
|
10
9
|
"start": "bun src/index.tsx",
|
|
10
|
+
"demo": "bun src/demo.tsx",
|
|
11
11
|
"test": "vitest",
|
|
12
12
|
"typecheck": "tsc --noEmit",
|
|
13
13
|
"lint": "biome check --write"
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"keywords": [
|
|
16
16
|
"game",
|
|
17
17
|
"puzzle",
|
|
18
|
-
"
|
|
18
|
+
"hashiwokakero",
|
|
19
19
|
"bridges",
|
|
20
20
|
"cli"
|
|
21
21
|
],
|
package/src/Game.tsx
CHANGED
|
@@ -1,37 +1,137 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react'
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
2
2
|
|
|
3
3
|
import HashiGrid from './components/HashiGrid.tsx'
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
4
|
+
import type { HashiNodeData, PlacedBridge } from './types.ts'
|
|
5
|
+
import { areAllNodesFilled, isConnected } from './utils/bridges.ts'
|
|
6
|
+
import { type PuzzleData, parsePuzzle } from './utils/puzzle-encoding.ts'
|
|
6
7
|
import usePuzzleInput from './utils/usePuzzleInput.ts'
|
|
7
8
|
|
|
8
9
|
type GameProps = {
|
|
9
10
|
puzzles: PuzzleData[]
|
|
10
11
|
hasCustomPuzzle: boolean
|
|
11
|
-
|
|
12
|
+
enableSolutions: boolean
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
// Compares two bridges for equality, treating bridges in either direction as equivalent.
|
|
16
|
+
// A bridge from A→B is considered equal to a bridge from B→A.
|
|
17
|
+
function bridgesEqual(a: PlacedBridge, b: PlacedBridge): boolean {
|
|
18
|
+
return (
|
|
19
|
+
(a.from.row === b.from.row &&
|
|
20
|
+
a.from.col === b.from.col &&
|
|
21
|
+
a.to.row === b.to.row &&
|
|
22
|
+
a.to.col === b.to.col) ||
|
|
23
|
+
(a.from.row === b.to.row &&
|
|
24
|
+
a.from.col === b.to.col &&
|
|
25
|
+
a.to.row === b.from.row &&
|
|
26
|
+
a.to.col === b.from.col)
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Toggles a bridge: removes it if it already exists, otherwise adds it.
|
|
31
|
+
// Returns true if bridge was erased, false if it was added.
|
|
32
|
+
function toggleBridge(
|
|
33
|
+
bridges: PlacedBridge[],
|
|
34
|
+
bridge: PlacedBridge
|
|
35
|
+
): { bridges: PlacedBridge[]; erased: boolean } {
|
|
36
|
+
const exists = bridges.some(b => bridgesEqual(b, bridge))
|
|
37
|
+
if (exists) {
|
|
38
|
+
return { bridges: bridges.filter(b => !bridgesEqual(b, bridge)), erased: true }
|
|
39
|
+
}
|
|
40
|
+
return { bridges: [...bridges, bridge], erased: false }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Merges user-placed bridges with the original puzzle rows.
|
|
44
|
+
// Creates a new grid with both original bridges (from solution) and user-drawn bridges.
|
|
45
|
+
// This preserves the original puzzle state for undo/reset functionality.
|
|
46
|
+
function mergeBridges(originalRows: HashiNodeData[][], bridges: PlacedBridge[]): HashiNodeData[][] {
|
|
47
|
+
// Deep clone the rows
|
|
48
|
+
const rows = originalRows.map(row => row.map(cell => ({ ...cell })))
|
|
49
|
+
|
|
50
|
+
// Apply each bridge
|
|
51
|
+
for (const bridge of bridges) {
|
|
52
|
+
const { from, to } = bridge
|
|
53
|
+
const bridgeCount = bridge.count || 1
|
|
54
|
+
|
|
55
|
+
if (from.row === to.row) {
|
|
56
|
+
// Horizontal bridge
|
|
57
|
+
const row = rows[from.row]
|
|
58
|
+
if (!row) continue
|
|
59
|
+
const minCol = Math.min(from.col, to.col)
|
|
60
|
+
const maxCol = Math.max(from.col, to.col)
|
|
61
|
+
|
|
62
|
+
// Set lineRight on the left node
|
|
63
|
+
if (minCol >= 0 && minCol < row.length) {
|
|
64
|
+
const cell = row[minCol]
|
|
65
|
+
if (cell) cell.lineRight = bridgeCount as 1 | 2
|
|
66
|
+
}
|
|
67
|
+
// Set lineLeft on the right node
|
|
68
|
+
if (maxCol >= 0 && maxCol < row.length) {
|
|
69
|
+
const cell = row[maxCol]
|
|
70
|
+
if (cell) cell.lineLeft = bridgeCount as 1 | 2
|
|
71
|
+
}
|
|
72
|
+
// Fill in bridge cells
|
|
73
|
+
for (let c = minCol + 1; c < maxCol; c++) {
|
|
74
|
+
if (c >= 0 && c < row.length) {
|
|
75
|
+
const cell = row[c]
|
|
76
|
+
if (cell) cell.value = bridgeCount === 2 ? '=' : '-'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else if (from.col === to.col) {
|
|
80
|
+
// Vertical bridge
|
|
81
|
+
const minRow = Math.min(from.row, to.row)
|
|
82
|
+
const maxRow = Math.max(from.row, to.row)
|
|
83
|
+
|
|
84
|
+
// Set lineDown on the top node
|
|
85
|
+
const topNode = rows[minRow]?.[from.col]
|
|
86
|
+
if (topNode) topNode.lineDown = bridgeCount as 1 | 2
|
|
87
|
+
|
|
88
|
+
// Set lineUp on the bottom node
|
|
89
|
+
const bottomNode = rows[maxRow]?.[from.col]
|
|
90
|
+
if (bottomNode) bottomNode.lineUp = bridgeCount as 1 | 2
|
|
91
|
+
|
|
92
|
+
// Fill in bridge cells
|
|
93
|
+
for (let r = minRow + 1; r < maxRow; r++) {
|
|
94
|
+
if (r >= 0 && r < rows.length) {
|
|
95
|
+
const rowNode = rows[r]?.[from.col]
|
|
96
|
+
if (rowNode) rowNode.value = bridgeCount === 2 ? '#' : '|'
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return rows
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default function Game({ puzzles, hasCustomPuzzle, enableSolutions }: GameProps) {
|
|
15
106
|
const [puzzleIndex, setPuzzleIndex] = useState(0)
|
|
16
107
|
const [showSolution, setShowSolution] = useState(false)
|
|
108
|
+
const [userBridges, setUserBridges] = useState<PlacedBridge[]>([])
|
|
109
|
+
const [solutionReached, setSolutionReached] = useState(false)
|
|
110
|
+
const [gridNotConnected, setGridNotConnected] = useState(false)
|
|
17
111
|
|
|
18
112
|
const handlePrev = useCallback(() => {
|
|
19
113
|
setPuzzleIndex(i => i - 1)
|
|
20
114
|
setShowSolution(false)
|
|
115
|
+
setSolutionReached(false)
|
|
116
|
+
setGridNotConnected(false)
|
|
21
117
|
}, [])
|
|
22
118
|
const handleNext = useCallback(() => {
|
|
23
119
|
setPuzzleIndex(i => i + 1)
|
|
24
120
|
setShowSolution(false)
|
|
121
|
+
setSolutionReached(false)
|
|
122
|
+
setGridNotConnected(false)
|
|
25
123
|
}, [])
|
|
26
124
|
const handleToggleSolution = useCallback(() => {
|
|
27
|
-
setShowSolution(s =>
|
|
125
|
+
setShowSolution(s => {
|
|
126
|
+
if (!s) {
|
|
127
|
+
setUserBridges([])
|
|
128
|
+
setSolutionReached(false)
|
|
129
|
+
setGridNotConnected(false)
|
|
130
|
+
}
|
|
131
|
+
return !s
|
|
132
|
+
})
|
|
28
133
|
}, [])
|
|
29
134
|
|
|
30
|
-
const canUseInput = Boolean(process.stdin.isTTY) && !stdout
|
|
31
|
-
if (canUseInput) {
|
|
32
|
-
usePuzzleInput(puzzleIndex, puzzles.length, handlePrev, handleNext, handleToggleSolution)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
135
|
const puzzle = puzzles[puzzleIndex]
|
|
36
136
|
if (!puzzle) throw new Error('HashiGrid: no puzzle found')
|
|
37
137
|
|
|
@@ -39,16 +139,72 @@ export default function Game({ puzzles, hasCustomPuzzle, stdout }: GameProps) {
|
|
|
39
139
|
const dimensions = encoding.split(':')[0] ?? '5x5'
|
|
40
140
|
const numNodes = Number(dimensions.split('x')[0]) || 5
|
|
41
141
|
|
|
142
|
+
const originalRows = useMemo(() => parsePuzzle(encoding), [encoding])
|
|
143
|
+
|
|
144
|
+
const rows = useMemo(() => mergeBridges(originalRows, userBridges), [originalRows, userBridges])
|
|
145
|
+
|
|
146
|
+
const handleBridgePlaced = useCallback(
|
|
147
|
+
(bridge: PlacedBridge) => {
|
|
148
|
+
const result = toggleBridge(userBridges, bridge)
|
|
149
|
+
setUserBridges(result.bridges)
|
|
150
|
+
|
|
151
|
+
const mergedRows = mergeBridges(originalRows, result.bridges)
|
|
152
|
+
const allFilled = areAllNodesFilled(mergedRows)
|
|
153
|
+
const connected = isConnected(mergedRows)
|
|
154
|
+
setSolutionReached(allFilled && connected)
|
|
155
|
+
setGridNotConnected(allFilled && !connected)
|
|
156
|
+
|
|
157
|
+
return result.erased
|
|
158
|
+
},
|
|
159
|
+
[userBridges, originalRows]
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
// Compute min and max numbers in the puzzle
|
|
163
|
+
const { minNumber, maxNumber } = useMemo(() => {
|
|
164
|
+
let min = 9
|
|
165
|
+
let max = 1
|
|
166
|
+
for (const row of rows) {
|
|
167
|
+
for (const node of row) {
|
|
168
|
+
if (typeof node.value === 'number') {
|
|
169
|
+
if (node.value < min) min = node.value
|
|
170
|
+
if (node.value > max) max = node.value
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { minNumber: min, maxNumber: max }
|
|
175
|
+
}, [rows])
|
|
176
|
+
|
|
177
|
+
const canUseInput = Boolean(process.stdin.isTTY)
|
|
178
|
+
const { selectionState } = canUseInput
|
|
179
|
+
? usePuzzleInput({
|
|
180
|
+
puzzleIndex,
|
|
181
|
+
puzzlesLength: puzzles.length,
|
|
182
|
+
rows: rows,
|
|
183
|
+
showSolution,
|
|
184
|
+
enableSolutions,
|
|
185
|
+
onPrev: handlePrev,
|
|
186
|
+
onNext: handleNext,
|
|
187
|
+
onToggleSolution: handleToggleSolution,
|
|
188
|
+
onBridgePlaced: handleBridgePlaced,
|
|
189
|
+
})
|
|
190
|
+
: { selectionState: undefined }
|
|
191
|
+
|
|
42
192
|
return (
|
|
43
193
|
<HashiGrid
|
|
44
194
|
numNodes={numNodes}
|
|
45
|
-
rows={
|
|
46
|
-
showInstructions={
|
|
195
|
+
rows={rows}
|
|
196
|
+
showInstructions={true}
|
|
47
197
|
puzzleIndex={puzzleIndex}
|
|
48
198
|
puzzle={encoding}
|
|
49
199
|
isCustomPuzzle={hasCustomPuzzle && puzzleIndex === 0}
|
|
50
200
|
hasSolution={!!puzzle.solution}
|
|
51
201
|
showSolution={showSolution}
|
|
202
|
+
enableSolutions={enableSolutions}
|
|
203
|
+
selectionState={selectionState}
|
|
204
|
+
minNumber={minNumber}
|
|
205
|
+
maxNumber={maxNumber}
|
|
206
|
+
solutionReached={solutionReached}
|
|
207
|
+
gridNotConnected={gridNotConnected}
|
|
52
208
|
/>
|
|
53
209
|
)
|
|
54
210
|
}
|