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 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 typehceck` - typescript typechecker
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
- - Biome is configured in `biome.json`
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 (Hashiwokakero) puzzle renderer built with React and Ink. This exists mostly to
3
- gain more experience with an agent-based workflow.
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 Hashiwokakero?
6
- Hashiwokakero (橋をかけろ, "build bridges") is a logic puzzle where you connect islands with bridges.
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
  ![Demo](docs/readme-demo-1.png)
10
10
 
11
- ## Run the puzzle (interactive mode - press q to quit)
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
- ## Run tests and linter
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.1",
4
- "description": "Hashiwokakero (Bridges) CLI puzzle game",
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
- "hashi",
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 { parsePuzzle } from './utils/parsePuzzle.ts'
5
- import type { PuzzleData } from './utils/samplePuzzles.ts'
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
- stdout: boolean
12
+ enableSolutions: boolean
12
13
  }
13
14
 
14
- export default function Game({ puzzles, hasCustomPuzzle, stdout }: GameProps) {
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 => !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={parsePuzzle(encoding)}
46
- showInstructions={!stdout}
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
  }