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.
package/AGENTS.md CHANGED
@@ -5,10 +5,9 @@ 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
@@ -17,8 +16,8 @@ Tests use Vitest with `@testing-library/react` patterns via `ink-testing-library
17
16
 
18
17
  ## Code Style
19
18
 
20
- - Biome is configured in `biome.json`
21
- - Run linting before committing
22
- - No custom ESLint/Prettier config - Biome handles everything
19
+ - Run linting before committing - the lint command uses Biome.js
23
20
  - Add comments to separate code blocks. Don't remove existing comments.
21
+ - Add a comment above functions that explain the intention of the function.
22
+ - Don't re-export imports - no barrel files
24
23
 
package/README.md CHANGED
@@ -1,23 +1,29 @@
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`)
19
25
 
20
- ## Run tests and linter
26
+ ### Run tests and linter
21
27
  ``` bash
22
28
  bun run typecheck
23
29
  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.1.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"
package/src/Game.tsx CHANGED
@@ -1,19 +1,109 @@
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 { type PuzzleData, parsePuzzle } from './utils/puzzle-encoding.ts'
6
6
  import usePuzzleInput from './utils/usePuzzleInput.ts'
7
7
 
8
8
  type GameProps = {
9
9
  puzzles: PuzzleData[]
10
10
  hasCustomPuzzle: boolean
11
- stdout: boolean
12
11
  }
13
12
 
14
- export default function Game({ puzzles, hasCustomPuzzle, stdout }: GameProps) {
13
+ // Compares two bridges for equality, treating bridges in either direction as equivalent.
14
+ // A bridge from A→B is considered equal to a bridge from B→A.
15
+ function bridgesEqual(a: PlacedBridge, b: PlacedBridge): boolean {
16
+ return (
17
+ (a.from.row === b.from.row &&
18
+ a.from.col === b.from.col &&
19
+ a.to.row === b.to.row &&
20
+ a.to.col === b.to.col) ||
21
+ (a.from.row === b.to.row &&
22
+ a.from.col === b.to.col &&
23
+ a.to.row === b.from.row &&
24
+ a.to.col === b.from.col)
25
+ )
26
+ }
27
+
28
+ // Toggles a bridge: removes it if it already exists, otherwise adds it.
29
+ // Returns true if bridge was erased, false if it was added.
30
+ function toggleBridge(
31
+ bridges: PlacedBridge[],
32
+ bridge: PlacedBridge
33
+ ): { bridges: PlacedBridge[]; erased: boolean } {
34
+ const exists = bridges.some(b => bridgesEqual(b, bridge))
35
+ if (exists) {
36
+ return { bridges: bridges.filter(b => !bridgesEqual(b, bridge)), erased: true }
37
+ }
38
+ return { bridges: [...bridges, bridge], erased: false }
39
+ }
40
+
41
+ // Merges user-placed bridges with the original puzzle rows.
42
+ // Creates a new grid with both original bridges (from solution) and user-drawn bridges.
43
+ // This preserves the original puzzle state for undo/reset functionality.
44
+ function mergeBridges(originalRows: HashiNodeData[][], bridges: PlacedBridge[]): HashiNodeData[][] {
45
+ // Deep clone the rows
46
+ const rows = originalRows.map(row => row.map(cell => ({ ...cell })))
47
+
48
+ // Apply each bridge
49
+ for (const bridge of bridges) {
50
+ const { from, to } = bridge
51
+ const bridgeCount = bridge.count || 1
52
+
53
+ if (from.row === to.row) {
54
+ // Horizontal bridge
55
+ const row = rows[from.row]
56
+ if (!row) continue
57
+ const minCol = Math.min(from.col, to.col)
58
+ const maxCol = Math.max(from.col, to.col)
59
+
60
+ // Set lineRight on the left node
61
+ if (minCol >= 0 && minCol < row.length) {
62
+ const cell = row[minCol]
63
+ if (cell) cell.lineRight = bridgeCount as 1 | 2
64
+ }
65
+ // Set lineLeft on the right node
66
+ if (maxCol >= 0 && maxCol < row.length) {
67
+ const cell = row[maxCol]
68
+ if (cell) cell.lineLeft = bridgeCount as 1 | 2
69
+ }
70
+ // Fill in bridge cells
71
+ for (let c = minCol + 1; c < maxCol; c++) {
72
+ if (c >= 0 && c < row.length) {
73
+ const cell = row[c]
74
+ if (cell) cell.value = bridgeCount === 2 ? '=' : '-'
75
+ }
76
+ }
77
+ } else if (from.col === to.col) {
78
+ // Vertical bridge
79
+ const minRow = Math.min(from.row, to.row)
80
+ const maxRow = Math.max(from.row, to.row)
81
+
82
+ // Set lineDown on the top node
83
+ const topNode = rows[minRow]?.[from.col]
84
+ if (topNode) topNode.lineDown = bridgeCount as 1 | 2
85
+
86
+ // Set lineUp on the bottom node
87
+ const bottomNode = rows[maxRow]?.[from.col]
88
+ if (bottomNode) bottomNode.lineUp = bridgeCount as 1 | 2
89
+
90
+ // Fill in bridge cells
91
+ for (let r = minRow + 1; r < maxRow; r++) {
92
+ if (r >= 0 && r < rows.length) {
93
+ const rowNode = rows[r]?.[from.col]
94
+ if (rowNode) rowNode.value = bridgeCount === 2 ? '#' : '|'
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ return rows
101
+ }
102
+
103
+ export default function Game({ puzzles, hasCustomPuzzle }: GameProps) {
15
104
  const [puzzleIndex, setPuzzleIndex] = useState(0)
16
105
  const [showSolution, setShowSolution] = useState(false)
106
+ const [userBridges, setUserBridges] = useState<PlacedBridge[]>([])
17
107
 
18
108
  const handlePrev = useCallback(() => {
19
109
  setPuzzleIndex(i => i - 1)
@@ -27,10 +117,14 @@ export default function Game({ puzzles, hasCustomPuzzle, stdout }: GameProps) {
27
117
  setShowSolution(s => !s)
28
118
  }, [])
29
119
 
30
- const canUseInput = Boolean(process.stdin.isTTY) && !stdout
31
- if (canUseInput) {
32
- usePuzzleInput(puzzleIndex, puzzles.length, handlePrev, handleNext, handleToggleSolution)
33
- }
120
+ const handleBridgePlaced = useCallback(
121
+ (bridge: PlacedBridge) => {
122
+ const result = toggleBridge(userBridges, bridge)
123
+ setUserBridges(result.bridges)
124
+ return result.erased
125
+ },
126
+ [userBridges]
127
+ )
34
128
 
35
129
  const puzzle = puzzles[puzzleIndex]
36
130
  if (!puzzle) throw new Error('HashiGrid: no puzzle found')
@@ -39,16 +133,52 @@ export default function Game({ puzzles, hasCustomPuzzle, stdout }: GameProps) {
39
133
  const dimensions = encoding.split(':')[0] ?? '5x5'
40
134
  const numNodes = Number(dimensions.split('x')[0]) || 5
41
135
 
136
+ const originalRows = useMemo(() => parsePuzzle(encoding), [encoding])
137
+
138
+ const rows = useMemo(() => mergeBridges(originalRows, userBridges), [originalRows, userBridges])
139
+
140
+ // Compute min and max numbers in the puzzle
141
+ const { minNumber, maxNumber } = useMemo(() => {
142
+ let min = 9
143
+ let max = 1
144
+ for (const row of rows) {
145
+ for (const node of row) {
146
+ if (typeof node.value === 'number') {
147
+ if (node.value < min) min = node.value
148
+ if (node.value > max) max = node.value
149
+ }
150
+ }
151
+ }
152
+ return { minNumber: min, maxNumber: max }
153
+ }, [rows])
154
+
155
+ const canUseInput = Boolean(process.stdin.isTTY)
156
+ const { selectionState } = canUseInput
157
+ ? usePuzzleInput({
158
+ puzzleIndex,
159
+ puzzlesLength: puzzles.length,
160
+ rows: rows,
161
+ showSolution,
162
+ onPrev: handlePrev,
163
+ onNext: handleNext,
164
+ onToggleSolution: handleToggleSolution,
165
+ onBridgePlaced: handleBridgePlaced,
166
+ })
167
+ : { selectionState: undefined }
168
+
42
169
  return (
43
170
  <HashiGrid
44
171
  numNodes={numNodes}
45
- rows={parsePuzzle(encoding)}
46
- showInstructions={!stdout}
172
+ rows={rows}
173
+ showInstructions={true}
47
174
  puzzleIndex={puzzleIndex}
48
175
  puzzle={encoding}
49
176
  isCustomPuzzle={hasCustomPuzzle && puzzleIndex === 0}
50
177
  hasSolution={!!puzzle.solution}
51
178
  showSolution={showSolution}
179
+ selectionState={selectionState}
180
+ minNumber={minNumber}
181
+ maxNumber={maxNumber}
52
182
  />
53
183
  )
54
184
  }