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 +4 -5
- package/README.md +13 -7
- package/package.json +3 -3
- package/src/Game.tsx +141 -11
- package/src/__tests__/Game.test.tsx +482 -24
- package/src/components/HashiGrid.tsx +30 -44
- package/src/components/HashiRow.tsx +54 -66
- package/src/components/Header.tsx +48 -2
- package/src/components/Messages.tsx +15 -8
- package/src/components/__tests__/HashiGrid.test.tsx +1 -0
- package/src/components/__tests__/HashiRow.test.tsx +100 -112
- package/src/components/__tests__/Header.test.tsx +156 -0
- package/src/components/__tests__/Messages.test.tsx +46 -1
- package/src/demo.tsx +20 -0
- package/src/index.tsx +25 -7
- 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 +171 -0
- package/src/utils/puzzle-encoding.ts +286 -0
- package/src/utils/usePuzzleInput.ts +385 -13
- package/src/utils/parsePuzzle.ts +0 -178
- package/src/utils/samplePuzzles.ts +0 -59
|
@@ -3,9 +3,11 @@ import { render } from 'ink-testing-library'
|
|
|
3
3
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
4
|
|
|
5
5
|
import Game from '../Game.tsx'
|
|
6
|
+
import { type PuzzleData, samplePuzzles } from '../utils/puzzle-encoding.ts'
|
|
6
7
|
|
|
7
8
|
const TEST_PUZZLE = { encoding: '3x3:1a1.c.2a2' }
|
|
8
9
|
const TEST_PUZZLE_2 = { encoding: '3x3:3a3.c.1a1' }
|
|
10
|
+
const SMALL_PUZZLE_3X3 = { encoding: '3x3:2a3.c.1a2' }
|
|
9
11
|
|
|
10
12
|
describe('Game', () => {
|
|
11
13
|
beforeEach(() => {
|
|
@@ -16,33 +18,127 @@ describe('Game', () => {
|
|
|
16
18
|
vi.spyOn(process.stdin, 'isTTY', 'get').mockReturnValue(true)
|
|
17
19
|
})
|
|
18
20
|
|
|
19
|
-
describe('
|
|
20
|
-
it('
|
|
21
|
-
const { lastFrame } = render(
|
|
22
|
-
<Game puzzles={[
|
|
21
|
+
describe('game controls - toggle solution', () => {
|
|
22
|
+
it('pressing s toggles the solution on and off', async () => {
|
|
23
|
+
const { stdin, lastFrame } = render(
|
|
24
|
+
<Game puzzles={[samplePuzzles[0] as PuzzleData]} hasCustomPuzzle={false} />
|
|
23
25
|
)
|
|
24
|
-
expect(lastFrame()).not.toContain('Controls:')
|
|
25
|
-
})
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
28
|
+
• Type a number [1-8] to select a node
|
|
29
|
+
|
|
30
|
+
┌─────────────────────────────────────┐
|
|
31
|
+
│ ╭───╮ ╭───╮ ╭───╮ ╭───╮ │
|
|
32
|
+
│ │ 4 │ │ 3 │ │ 3 │ │ 3 │ │
|
|
33
|
+
│ ╰───╯ ╰───╯ ╰───╯ ╰───╯ │
|
|
34
|
+
│ ╭───╮ ╭───╮ │
|
|
35
|
+
│ │ 2 │ │ 4 │ │
|
|
36
|
+
│ ╰───╯ ╰───╯ │
|
|
37
|
+
│ ╭───╮ ╭───╮ ╭───╮ │
|
|
38
|
+
│ │ 3 │ │ 3 │ │ 3 │ │
|
|
39
|
+
│ ╰───╯ ╰───╯ ╰───╯ │
|
|
40
|
+
│ │
|
|
41
|
+
│ │
|
|
42
|
+
│ │
|
|
43
|
+
│ ╭───╮ ╭───╮ ╭───╮ │
|
|
44
|
+
│ │ 2 │ │ 8 │ │ 4 │ │
|
|
45
|
+
│ ╰───╯ ╰───╯ ╰───╯ │
|
|
46
|
+
│ ╭───╮ ╭───╮ │
|
|
47
|
+
│ │ 1 │ │ 3 │ │
|
|
48
|
+
│ ╰───╯ ╰───╯ │
|
|
49
|
+
│ ╭───╮ ╭───╮ ╭───╮ │
|
|
50
|
+
│ │ 1 │ │ 4 │ │ 1 │ │
|
|
51
|
+
│ ╰───╯ ╰───╯ ╰───╯ │
|
|
52
|
+
└─────────────────────────────────────┘
|
|
53
|
+
|
|
54
|
+
Controls:
|
|
55
|
+
p: Previous puzzle
|
|
56
|
+
n: Next puzzle
|
|
57
|
+
s: Show solution
|
|
58
|
+
q: Quit`)
|
|
59
|
+
|
|
60
|
+
// Now toggle the solution on
|
|
61
|
+
stdin.write('s')
|
|
62
|
+
await setTimeout(5)
|
|
63
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
64
|
+
• Viewing solution (press s to return to puzzle)
|
|
65
|
+
|
|
66
|
+
┌─────────────────────────────────────┐
|
|
67
|
+
│ ╭───╮ ╭───╮ ╭───╮ ╭───╮ │
|
|
68
|
+
│ │ 4 ╞═════╡ 3 ├─────┤ 3 ╞═════╡ 3 │ │
|
|
69
|
+
│ ╰─╥─╯ ╰───╯ ╰───╯ ╰─┬─╯ │
|
|
70
|
+
│ ║ ╭───╮ ╭───╮ │ │
|
|
71
|
+
│ ║ │ 2 ╞═══════════════╡ 4 │ │ │
|
|
72
|
+
│ ║ ╰───╯ ╰─╥─╯ │ │
|
|
73
|
+
│ ╭─╨─╮ ╭───╮ ║ ╭─┴─╮ │
|
|
74
|
+
│ │ 3 ├──────────┤ 3 │ ║ │ 3 │ │
|
|
75
|
+
│ ╰───╯ ╰─╥─╯ ║ ╰─╥─╯ │
|
|
76
|
+
│ ║ ║ ║ │
|
|
77
|
+
│ ║ ║ ║ │
|
|
78
|
+
│ ║ ║ ║ │
|
|
79
|
+
│ ╭───╮ ╭─╨─╮ ╭─╨─╮ ║ │
|
|
80
|
+
│ │ 2 ╞══════════╡ 8 ╞═════╡ 4 │ ║ │
|
|
81
|
+
│ ╰───╯ ╰─╥─╯ ╰───╯ ║ │
|
|
82
|
+
│ ║ ╭───╮ ╭─╨─╮ │
|
|
83
|
+
│ ║ │ 1 ├─────┤ 3 │ │
|
|
84
|
+
│ ║ ╰───╯ ╰───╯ │
|
|
85
|
+
│ ╭───╮ ╭─╨─╮ ╭───╮ │
|
|
86
|
+
│ │ 1 ├─────┤ 4 ├─────┤ 1 │ │
|
|
87
|
+
│ ╰───╯ ╰───╯ ╰───╯ │
|
|
88
|
+
└─────────────────────────────────────┘
|
|
89
|
+
|
|
90
|
+
Controls:
|
|
91
|
+
p: Previous puzzle
|
|
92
|
+
n: Next puzzle
|
|
93
|
+
s: Show solution
|
|
94
|
+
q: Quit`)
|
|
95
|
+
|
|
96
|
+
// And toggle it off again
|
|
97
|
+
stdin.write('s')
|
|
98
|
+
await setTimeout(5)
|
|
99
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
100
|
+
• Type a number [1-8] to select a node
|
|
101
|
+
|
|
102
|
+
┌─────────────────────────────────────┐
|
|
103
|
+
│ ╭───╮ ╭───╮ ╭───╮ ╭───╮ │
|
|
104
|
+
│ │ 4 │ │ 3 │ │ 3 │ │ 3 │ │
|
|
105
|
+
│ ╰───╯ ╰───╯ ╰───╯ ╰───╯ │
|
|
106
|
+
│ ╭───╮ ╭───╮ │
|
|
107
|
+
│ │ 2 │ │ 4 │ │
|
|
108
|
+
│ ╰───╯ ╰───╯ │
|
|
109
|
+
│ ╭───╮ ╭───╮ ╭───╮ │
|
|
110
|
+
│ │ 3 │ │ 3 │ │ 3 │ │
|
|
111
|
+
│ ╰───╯ ╰───╯ ╰───╯ │
|
|
112
|
+
│ │
|
|
113
|
+
│ │
|
|
114
|
+
│ │
|
|
115
|
+
│ ╭───╮ ╭───╮ ╭───╮ │
|
|
116
|
+
│ │ 2 │ │ 8 │ │ 4 │ │
|
|
117
|
+
│ ╰───╯ ╰───╯ ╰───╯ │
|
|
118
|
+
│ ╭───╮ ╭───╮ │
|
|
119
|
+
│ │ 1 │ │ 3 │ │
|
|
120
|
+
│ ╰───╯ ╰───╯ │
|
|
121
|
+
│ ╭───╮ ╭───╮ ╭───╮ │
|
|
122
|
+
│ │ 1 │ │ 4 │ │ 1 │ │
|
|
123
|
+
│ ╰───╯ ╰───╯ ╰───╯ │
|
|
124
|
+
└─────────────────────────────────────┘
|
|
125
|
+
|
|
126
|
+
Controls:
|
|
127
|
+
p: Previous puzzle
|
|
128
|
+
n: Next puzzle
|
|
129
|
+
s: Show solution
|
|
130
|
+
q: Quit`)
|
|
32
131
|
})
|
|
33
132
|
})
|
|
34
133
|
|
|
35
|
-
describe('game controls', () => {
|
|
134
|
+
describe('game controls - next/previous', () => {
|
|
36
135
|
it('navigates to next puzzle with n key when interactive', async () => {
|
|
37
136
|
const { stdin, lastFrame } = render(
|
|
38
|
-
<Game
|
|
39
|
-
puzzles={[TEST_PUZZLE, TEST_PUZZLE_2]}
|
|
40
|
-
hasCustomPuzzle={false}
|
|
41
|
-
stdout={false}
|
|
42
|
-
/>
|
|
137
|
+
<Game puzzles={[TEST_PUZZLE, TEST_PUZZLE_2]} hasCustomPuzzle={false} />
|
|
43
138
|
)
|
|
44
139
|
|
|
45
140
|
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
141
|
+
• Type a number [1-2] to select a node
|
|
46
142
|
|
|
47
143
|
┌─────────────────┐
|
|
48
144
|
│ ╭───╮ ╭───╮ │
|
|
@@ -65,6 +161,7 @@ q: Quit`)
|
|
|
65
161
|
stdin.write('n')
|
|
66
162
|
await setTimeout(5)
|
|
67
163
|
expect(lastFrame()).toEqual(`Bridges: Puzzle #2
|
|
164
|
+
• Type a number [1-3] to select a node
|
|
68
165
|
|
|
69
166
|
┌─────────────────┐
|
|
70
167
|
│ ╭───╮ ╭───╮ │
|
|
@@ -87,16 +184,13 @@ q: Quit`)
|
|
|
87
184
|
|
|
88
185
|
it('navigates to previous puzzle with p key when interactive', async () => {
|
|
89
186
|
const { stdin, lastFrame } = render(
|
|
90
|
-
<Game
|
|
91
|
-
puzzles={[TEST_PUZZLE, TEST_PUZZLE_2]}
|
|
92
|
-
hasCustomPuzzle={false}
|
|
93
|
-
stdout={false}
|
|
94
|
-
/>
|
|
187
|
+
<Game puzzles={[TEST_PUZZLE, TEST_PUZZLE_2]} hasCustomPuzzle={false} />
|
|
95
188
|
)
|
|
96
189
|
|
|
97
190
|
stdin.write('n')
|
|
98
191
|
await setTimeout(5)
|
|
99
192
|
expect(lastFrame()).toEqual(`Bridges: Puzzle #2
|
|
193
|
+
• Type a number [1-3] to select a node
|
|
100
194
|
|
|
101
195
|
┌─────────────────┐
|
|
102
196
|
│ ╭───╮ ╭───╮ │
|
|
@@ -119,6 +213,7 @@ q: Quit`)
|
|
|
119
213
|
stdin.write('p')
|
|
120
214
|
await setTimeout(5)
|
|
121
215
|
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
216
|
+
• Type a number [1-2] to select a node
|
|
122
217
|
|
|
123
218
|
┌─────────────────┐
|
|
124
219
|
│ ╭───╮ ╭───╮ │
|
|
@@ -141,12 +236,13 @@ q: Quit`)
|
|
|
141
236
|
|
|
142
237
|
it('does not navigate past last puzzle', async () => {
|
|
143
238
|
const { stdin, lastFrame } = render(
|
|
144
|
-
<Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false}
|
|
239
|
+
<Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false} />
|
|
145
240
|
)
|
|
146
241
|
|
|
147
242
|
stdin.write('n')
|
|
148
243
|
await setTimeout(5)
|
|
149
244
|
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
245
|
+
• Type a number [1-2] to select a node
|
|
150
246
|
|
|
151
247
|
┌─────────────────┐
|
|
152
248
|
│ ╭───╮ ╭───╮ │
|
|
@@ -169,12 +265,13 @@ q: Quit`)
|
|
|
169
265
|
|
|
170
266
|
it('does not navigate before first puzzle', async () => {
|
|
171
267
|
const { stdin, lastFrame } = render(
|
|
172
|
-
<Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false}
|
|
268
|
+
<Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false} />
|
|
173
269
|
)
|
|
174
270
|
|
|
175
271
|
stdin.write('p')
|
|
176
272
|
await setTimeout(5)
|
|
177
273
|
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
274
|
+
• Type a number [1-2] to select a node
|
|
178
275
|
|
|
179
276
|
┌─────────────────┐
|
|
180
277
|
│ ╭───╮ ╭───╮ │
|
|
@@ -195,4 +292,365 @@ s: Show solution
|
|
|
195
292
|
q: Quit`)
|
|
196
293
|
})
|
|
197
294
|
})
|
|
295
|
+
|
|
296
|
+
describe('game controls - node selection', () => {
|
|
297
|
+
it('selects node immediately when there is only one of that number', async () => {
|
|
298
|
+
const { stdin, lastFrame } = render(
|
|
299
|
+
<Game puzzles={[SMALL_PUZZLE_3X3]} hasCustomPuzzle={false} />
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// Press '3' to select single node of value 3
|
|
303
|
+
stdin.write('3')
|
|
304
|
+
await setTimeout(5)
|
|
305
|
+
expect(lastFrame()).toContain('Select direction with h/j/k/l')
|
|
306
|
+
expect(lastFrame()).not.toContain('╭a──╮')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('shows disambiguation labels when multiple nodes have the same number', async () => {
|
|
310
|
+
const { stdin, lastFrame } = render(
|
|
311
|
+
<Game puzzles={[SMALL_PUZZLE_3X3]} hasCustomPuzzle={false} />
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
// Press '2' to select from multiple nodes with value 2
|
|
315
|
+
stdin.write('2')
|
|
316
|
+
await setTimeout(5)
|
|
317
|
+
expect(lastFrame()).toContain('Press label shown to select that node')
|
|
318
|
+
expect(lastFrame()).toContain('╭a──╮')
|
|
319
|
+
expect(lastFrame()).toContain('╭b──╮')
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
it('selects a specific node when disambiguation label is pressed', async () => {
|
|
323
|
+
const { stdin, lastFrame } = render(
|
|
324
|
+
<Game puzzles={[SMALL_PUZZLE_3X3]} hasCustomPuzzle={false} />
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
// Press '1' to select from multiple nodes with value 1
|
|
328
|
+
stdin.write('1')
|
|
329
|
+
await setTimeout(5)
|
|
330
|
+
|
|
331
|
+
// Press 'a' to select the second node
|
|
332
|
+
stdin.write('b')
|
|
333
|
+
await setTimeout(5)
|
|
334
|
+
expect(lastFrame()).toContain('Select direction with h/j/k/l')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('draws a bridge when a valid direction is selected', async () => {
|
|
338
|
+
const { stdin, lastFrame } = render(
|
|
339
|
+
<Game puzzles={[SMALL_PUZZLE_3X3]} hasCustomPuzzle={false} />
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
// Press '1' to enter disambiguation mode
|
|
343
|
+
stdin.write('1')
|
|
344
|
+
await setTimeout(5)
|
|
345
|
+
|
|
346
|
+
// Press 'b' to select the second node
|
|
347
|
+
stdin.write('b')
|
|
348
|
+
await setTimeout(5)
|
|
349
|
+
|
|
350
|
+
// Press 'l' to draw a bridge to the right
|
|
351
|
+
stdin.write('l')
|
|
352
|
+
await setTimeout(5)
|
|
353
|
+
expect(lastFrame()).toContain('Drew horizontal bridge')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('shows an invalid message for a bad bridge direction off the grid', async () => {
|
|
357
|
+
const { stdin, lastFrame } = render(
|
|
358
|
+
<Game puzzles={[SMALL_PUZZLE_3X3]} hasCustomPuzzle={false} />
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
// Press '1' to enter disambiguation mode
|
|
362
|
+
stdin.write('1')
|
|
363
|
+
await setTimeout(5)
|
|
364
|
+
|
|
365
|
+
// Press 'b' to select the second node
|
|
366
|
+
stdin.write('b')
|
|
367
|
+
await setTimeout(5)
|
|
368
|
+
|
|
369
|
+
// Press 'h' to draw a bridge to the left
|
|
370
|
+
stdin.write('h')
|
|
371
|
+
await setTimeout(5)
|
|
372
|
+
expect(lastFrame()).toContain('Cannot draw bridge left from node')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('shows an invalid message for horizontal bridge colliding with bridge', async () => {
|
|
376
|
+
const puzzleWithBarrier = { encoding: '4x3:2a2a.a1|1.b1a' }
|
|
377
|
+
const { stdin, lastFrame } = render(
|
|
378
|
+
<Game puzzles={[puzzleWithBarrier]} hasCustomPuzzle={false} />
|
|
379
|
+
)
|
|
380
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
381
|
+
• Type a number [1-2] to select a node
|
|
382
|
+
|
|
383
|
+
┌──────────────────────┐
|
|
384
|
+
│ ╭───╮ ╭───╮ │
|
|
385
|
+
│ │ 2 │ │ 2 │ │
|
|
386
|
+
│ ╰───╯ ╰─┬─╯ │
|
|
387
|
+
│ ╭───╮ │ ╭───╮ │
|
|
388
|
+
│ │ 1 │ │ │ 1 │ │
|
|
389
|
+
│ ╰───╯ │ ╰───╯ │
|
|
390
|
+
│ ╭─┴─╮ │
|
|
391
|
+
│ │ 1 │ │
|
|
392
|
+
│ ╰───╯ │
|
|
393
|
+
└──────────────────────┘
|
|
394
|
+
|
|
395
|
+
Controls:
|
|
396
|
+
p: Previous puzzle
|
|
397
|
+
n: Next puzzle
|
|
398
|
+
s: Show solution
|
|
399
|
+
q: Quit`)
|
|
400
|
+
|
|
401
|
+
stdin.write('1')
|
|
402
|
+
await setTimeout(5)
|
|
403
|
+
stdin.write('a')
|
|
404
|
+
await setTimeout(5)
|
|
405
|
+
stdin.write('l')
|
|
406
|
+
await setTimeout(5)
|
|
407
|
+
expect(lastFrame()).toContain('Cannot draw bridge right from node')
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('shows an invalid message for vertical bridge colliding with bridge', async () => {
|
|
411
|
+
const puzzleWithBarrier = { encoding: '4x3:2a2a.a1=1.b1a' }
|
|
412
|
+
const { stdin, lastFrame } = render(
|
|
413
|
+
<Game puzzles={[puzzleWithBarrier]} hasCustomPuzzle={false} />
|
|
414
|
+
)
|
|
415
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
416
|
+
• Type a number [1-2] to select a node
|
|
417
|
+
|
|
418
|
+
┌──────────────────────┐
|
|
419
|
+
│ ╭───╮ ╭───╮ │
|
|
420
|
+
│ │ 2 │ │ 2 │ │
|
|
421
|
+
│ ╰───╯ ╰───╯ │
|
|
422
|
+
│ ╭───╮ ╭───╮ │
|
|
423
|
+
│ │ 1 ╞═════╡ 1 │ │
|
|
424
|
+
│ ╰───╯ ╰───╯ │
|
|
425
|
+
│ ╭───╮ │
|
|
426
|
+
│ │ 1 │ │
|
|
427
|
+
│ ╰───╯ │
|
|
428
|
+
└──────────────────────┘
|
|
429
|
+
|
|
430
|
+
Controls:
|
|
431
|
+
p: Previous puzzle
|
|
432
|
+
n: Next puzzle
|
|
433
|
+
s: Show solution
|
|
434
|
+
q: Quit`)
|
|
435
|
+
|
|
436
|
+
stdin.write('2')
|
|
437
|
+
await setTimeout(5)
|
|
438
|
+
stdin.write('b')
|
|
439
|
+
await setTimeout(5)
|
|
440
|
+
stdin.write('j')
|
|
441
|
+
await setTimeout(5)
|
|
442
|
+
expect(lastFrame()).toContain('Cannot draw bridge down from node')
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
it('resets selection when Escape is pressed', async () => {
|
|
446
|
+
const { stdin, lastFrame } = render(
|
|
447
|
+
<Game puzzles={[SMALL_PUZZLE_3X3]} hasCustomPuzzle={false} />
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
stdin.write('2')
|
|
451
|
+
await setTimeout(5)
|
|
452
|
+
expect(lastFrame()).toContain('Press label shown')
|
|
453
|
+
|
|
454
|
+
// Note: Escape key handling may not work in test environment
|
|
455
|
+
// Testing that we entered disambiguation mode successfully
|
|
456
|
+
stdin.write('\x1b')
|
|
457
|
+
await setTimeout(5)
|
|
458
|
+
expect(lastFrame()).toContain('Type a number')
|
|
459
|
+
})
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
describe('game controls - drawing each kind of bridge', () => {
|
|
463
|
+
it('draws horizontal bridge', async () => {
|
|
464
|
+
const puzzleWithEachBridge = { encoding: '3x3:2a3.c.3a4' }
|
|
465
|
+
const { stdin, lastFrame } = render(
|
|
466
|
+
<Game puzzles={[puzzleWithEachBridge]} hasCustomPuzzle={false} />
|
|
467
|
+
)
|
|
468
|
+
stdin.write('3')
|
|
469
|
+
await setTimeout(5)
|
|
470
|
+
expect(lastFrame()).toContain('Press label shown to select that node')
|
|
471
|
+
stdin.write('a')
|
|
472
|
+
await setTimeout(5)
|
|
473
|
+
expect(lastFrame()).toContain('Select direction with h/j/k/l')
|
|
474
|
+
stdin.write('h')
|
|
475
|
+
await setTimeout(5)
|
|
476
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
477
|
+
• Drew horizontal bridge
|
|
478
|
+
|
|
479
|
+
┌─────────────────┐
|
|
480
|
+
│ \x1b[2m╭───╮\x1b[22m \x1b[1m╭───╮\x1b[22m │
|
|
481
|
+
│ \x1b[2m│ 2 ├─────\x1b[22m\x1b[1m┤ 3 │\x1b[22m │
|
|
482
|
+
│ \x1b[2m╰───╯\x1b[22m \x1b[1m╰───╯\x1b[22m │
|
|
483
|
+
│ │
|
|
484
|
+
│ │
|
|
485
|
+
│ │
|
|
486
|
+
│ \x1b[2m╭───╮\x1b[22m \x1b[2m╭───╮\x1b[22m │
|
|
487
|
+
│ \x1b[2m│ 3 │\x1b[22m \x1b[2m│ 4 │\x1b[22m │
|
|
488
|
+
│ \x1b[2m╰───╯\x1b[22m \x1b[2m╰───╯\x1b[22m │
|
|
489
|
+
└─────────────────┘
|
|
490
|
+
|
|
491
|
+
Controls:
|
|
492
|
+
p: Previous puzzle
|
|
493
|
+
n: Next puzzle
|
|
494
|
+
s: Show solution
|
|
495
|
+
q: Quit`)
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('draws a vertical bridge', async () => {
|
|
499
|
+
const puzzleWithEachBridge = { encoding: '3x3:2a3.c.3a4' }
|
|
500
|
+
const { stdin, lastFrame } = render(
|
|
501
|
+
<Game puzzles={[puzzleWithEachBridge]} hasCustomPuzzle={false} />
|
|
502
|
+
)
|
|
503
|
+
stdin.write('2')
|
|
504
|
+
await setTimeout(5)
|
|
505
|
+
stdin.write('j')
|
|
506
|
+
await setTimeout(5)
|
|
507
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
508
|
+
• Drew vertical bridge
|
|
509
|
+
|
|
510
|
+
┌─────────────────┐
|
|
511
|
+
│ \x1b[1m╭───╮\x1b[22m \x1b[2m╭───╮\x1b[22m │
|
|
512
|
+
│ \x1b[1m│ 2 │\x1b[22m \x1b[2m│ 3 │\x1b[22m │
|
|
513
|
+
│ \x1b[1m╰─┬─╯\x1b[22m \x1b[2m╰───╯\x1b[22m │
|
|
514
|
+
│ \x1b[2m │ \x1b[22m │
|
|
515
|
+
│ \x1b[2m │ \x1b[22m │
|
|
516
|
+
│ \x1b[2m │ \x1b[22m │
|
|
517
|
+
│ \x1b[2m╭─┴─╮\x1b[22m \x1b[2m╭───╮\x1b[22m │
|
|
518
|
+
│ \x1b[2m│ 3 │\x1b[22m \x1b[2m│ 4 │\x1b[22m │
|
|
519
|
+
│ \x1b[2m╰───╯\x1b[22m \x1b[2m╰───╯\x1b[22m │
|
|
520
|
+
└─────────────────┘
|
|
521
|
+
|
|
522
|
+
Controls:
|
|
523
|
+
p: Previous puzzle
|
|
524
|
+
n: Next puzzle
|
|
525
|
+
s: Show solution
|
|
526
|
+
q: Quit`)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('draws a double horizontal bridge', async () => {
|
|
530
|
+
const puzzleWithEachBridge = { encoding: '3x3:2a3.c.3a4' }
|
|
531
|
+
const { stdin, lastFrame } = render(
|
|
532
|
+
<Game puzzles={[puzzleWithEachBridge]} hasCustomPuzzle={false} />
|
|
533
|
+
)
|
|
534
|
+
stdin.write('3')
|
|
535
|
+
await setTimeout(5)
|
|
536
|
+
expect(lastFrame()).toContain('Press label shown to select that node')
|
|
537
|
+
stdin.write('a')
|
|
538
|
+
await setTimeout(5)
|
|
539
|
+
expect(lastFrame()).toContain('Select direction with h/j/k/l')
|
|
540
|
+
stdin.write('H')
|
|
541
|
+
await setTimeout(5)
|
|
542
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
543
|
+
• Drew double horizontal bridge
|
|
544
|
+
|
|
545
|
+
┌─────────────────┐
|
|
546
|
+
│ \x1b[2m╭───╮\x1b[22m \x1b[1m╭───╮\x1b[22m │
|
|
547
|
+
│ \x1b[2m│ 2 ╞═════\x1b[22m\x1b[1m╡ 3 │\x1b[22m │
|
|
548
|
+
│ \x1b[2m╰───╯\x1b[22m \x1b[1m╰───╯\x1b[22m │
|
|
549
|
+
│ │
|
|
550
|
+
│ │
|
|
551
|
+
│ │
|
|
552
|
+
│ \x1b[2m╭───╮\x1b[22m \x1b[2m╭───╮\x1b[22m │
|
|
553
|
+
│ \x1b[2m│ 3 │\x1b[22m \x1b[2m│ 4 │\x1b[22m │
|
|
554
|
+
│ \x1b[2m╰───╯\x1b[22m \x1b[2m╰───╯\x1b[22m │
|
|
555
|
+
└─────────────────┘
|
|
556
|
+
|
|
557
|
+
Controls:
|
|
558
|
+
p: Previous puzzle
|
|
559
|
+
n: Next puzzle
|
|
560
|
+
s: Show solution
|
|
561
|
+
q: Quit`)
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
it('draws a double vertical bridge', async () => {
|
|
565
|
+
const puzzleWithEachBridge = { encoding: '3x3:2a3.c.3a4' }
|
|
566
|
+
const { stdin, lastFrame } = render(
|
|
567
|
+
<Game puzzles={[puzzleWithEachBridge]} hasCustomPuzzle={false} />
|
|
568
|
+
)
|
|
569
|
+
stdin.write('2')
|
|
570
|
+
await setTimeout(5)
|
|
571
|
+
stdin.write('J')
|
|
572
|
+
await setTimeout(5)
|
|
573
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
574
|
+
• Drew double vertical bridge
|
|
575
|
+
|
|
576
|
+
┌─────────────────┐
|
|
577
|
+
│ \x1b[1m╭───╮\x1b[22m \x1b[2m╭───╮\x1b[22m │
|
|
578
|
+
│ \x1b[1m│ 2 │\x1b[22m \x1b[2m│ 3 │\x1b[22m │
|
|
579
|
+
│ \x1b[1m╰─╥─╯\x1b[22m \x1b[2m╰───╯\x1b[22m │
|
|
580
|
+
│ \x1b[2m ║ \x1b[22m │
|
|
581
|
+
│ \x1b[2m ║ \x1b[22m │
|
|
582
|
+
│ \x1b[2m ║ \x1b[22m │
|
|
583
|
+
│ \x1b[2m╭─╨─╮\x1b[22m \x1b[2m╭───╮\x1b[22m │
|
|
584
|
+
│ \x1b[2m│ 3 │\x1b[22m \x1b[2m│ 4 │\x1b[22m │
|
|
585
|
+
│ \x1b[2m╰───╯\x1b[22m \x1b[2m╰───╯\x1b[22m │
|
|
586
|
+
└─────────────────┘
|
|
587
|
+
|
|
588
|
+
Controls:
|
|
589
|
+
p: Previous puzzle
|
|
590
|
+
n: Next puzzle
|
|
591
|
+
s: Show solution
|
|
592
|
+
q: Quit`)
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it('does not draw a bridge over an existing bridge', async () => {
|
|
596
|
+
const puzzleWithEachBridge = { encoding: '4x3:1a3a.a2#2.3a4a' }
|
|
597
|
+
const { stdin, lastFrame } = render(
|
|
598
|
+
<Game puzzles={[puzzleWithEachBridge]} hasCustomPuzzle={false} />
|
|
599
|
+
)
|
|
600
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
601
|
+
• Type a number [1-4] to select a node
|
|
602
|
+
|
|
603
|
+
┌──────────────────────┐
|
|
604
|
+
│ ╭───╮ ╭───╮ │
|
|
605
|
+
│ │ 1 │ │ 3 │ │
|
|
606
|
+
│ ╰───╯ ╰─╥─╯ │
|
|
607
|
+
│ ╭───╮ ║ ╭───╮ │
|
|
608
|
+
│ │ 2 │ ║ │ 2 │ │
|
|
609
|
+
│ ╰───╯ ║ ╰───╯ │
|
|
610
|
+
│ ╭───╮ ╭─╨─╮ │
|
|
611
|
+
│ │ 3 │ │ 4 │ │
|
|
612
|
+
│ ╰───╯ ╰───╯ │
|
|
613
|
+
└──────────────────────┘
|
|
614
|
+
|
|
615
|
+
Controls:
|
|
616
|
+
p: Previous puzzle
|
|
617
|
+
n: Next puzzle
|
|
618
|
+
s: Show solution
|
|
619
|
+
q: Quit`)
|
|
620
|
+
|
|
621
|
+
stdin.write('2')
|
|
622
|
+
await setTimeout(5)
|
|
623
|
+
stdin.write('a')
|
|
624
|
+
await setTimeout(5)
|
|
625
|
+
stdin.write('l')
|
|
626
|
+
await setTimeout(5)
|
|
627
|
+
expect(lastFrame()).toContain('Cannot draw bridge right from node')
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('erases a bridge', async () => {
|
|
631
|
+
const puzzleWithEachBridge = { encoding: '3x3:2a3.c.3a4' }
|
|
632
|
+
const { stdin, lastFrame } = render(
|
|
633
|
+
<Game puzzles={[puzzleWithEachBridge]} hasCustomPuzzle={false} />
|
|
634
|
+
)
|
|
635
|
+
stdin.write('3')
|
|
636
|
+
await setTimeout(5)
|
|
637
|
+
stdin.write('a')
|
|
638
|
+
await setTimeout(5)
|
|
639
|
+
stdin.write('h')
|
|
640
|
+
await setTimeout(5)
|
|
641
|
+
expect(lastFrame()).toContain('Drew horizontal bridge')
|
|
642
|
+
|
|
643
|
+
stdin.write('3')
|
|
644
|
+
await setTimeout(5)
|
|
645
|
+
stdin.write('a')
|
|
646
|
+
await setTimeout(5)
|
|
647
|
+
stdin.write('h')
|
|
648
|
+
await setTimeout(5)
|
|
649
|
+
expect(lastFrame()).toContain('Erased bridge')
|
|
650
|
+
|
|
651
|
+
// Verify the bridge was actually erased (grid shows no bridge)
|
|
652
|
+
expect(lastFrame()).not.toContain('╞═════')
|
|
653
|
+
expect(lastFrame()).not.toContain('═╡')
|
|
654
|
+
})
|
|
655
|
+
})
|
|
198
656
|
})
|
|
@@ -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,12 @@ type HashiGridProps = {
|
|
|
28
30
|
hasSolution?: boolean
|
|
29
31
|
/** Whether to show the solution */
|
|
30
32
|
showSolution?: boolean
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
*/
|
|
36
|
-
|
|
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
|
+
/** Current selection state for highlighting */
|
|
34
|
+
selectionState?: SelectionState
|
|
35
|
+
/** Minimum number value in the puzzle */
|
|
36
|
+
minNumber?: number
|
|
37
|
+
/** Maximum number value in the puzzle */
|
|
38
|
+
maxNumber?: number
|
|
67
39
|
}
|
|
68
40
|
|
|
69
41
|
export default function HashiGrid({
|
|
@@ -75,6 +47,9 @@ export default function HashiGrid({
|
|
|
75
47
|
isCustomPuzzle = false,
|
|
76
48
|
hasSolution = false,
|
|
77
49
|
showSolution = false,
|
|
50
|
+
selectionState,
|
|
51
|
+
minNumber,
|
|
52
|
+
maxNumber,
|
|
78
53
|
}: HashiGridProps) {
|
|
79
54
|
validateGrid({ rows, numNodes })
|
|
80
55
|
|
|
@@ -92,6 +67,9 @@ export default function HashiGrid({
|
|
|
92
67
|
puzzle={puzzle}
|
|
93
68
|
isCustomPuzzle={isCustomPuzzle}
|
|
94
69
|
showSolution={showSolution}
|
|
70
|
+
selectionState={selectionState}
|
|
71
|
+
minNumber={minNumber}
|
|
72
|
+
maxNumber={maxNumber}
|
|
95
73
|
/>
|
|
96
74
|
<Box
|
|
97
75
|
borderStyle="single"
|
|
@@ -101,10 +79,18 @@ export default function HashiGrid({
|
|
|
101
79
|
flexDirection="column"
|
|
102
80
|
>
|
|
103
81
|
{rows.map((nodes, i) => (
|
|
104
|
-
<HashiRow
|
|
82
|
+
<HashiRow
|
|
83
|
+
key={i}
|
|
84
|
+
nodes={nodes}
|
|
85
|
+
rowIndex={i}
|
|
86
|
+
highlightedNode={selectionState?.selectedNumber ?? undefined}
|
|
87
|
+
selectionState={selectionState}
|
|
88
|
+
/>
|
|
105
89
|
))}
|
|
106
90
|
</Box>
|
|
107
|
-
{showInstructions ?
|
|
91
|
+
{showInstructions ? (
|
|
92
|
+
<Messages hasSolution={hasSolution} selectionState={selectionState} />
|
|
93
|
+
) : null}
|
|
108
94
|
</Box>
|
|
109
95
|
)
|
|
110
96
|
}
|