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.
@@ -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('--stdout flag', () => {
20
- it('does not show instructions when stdout is true', () => {
21
- const { lastFrame } = render(
22
- <Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false} stdout={true} />
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
- it('shows instructions when stdout is false', () => {
28
- const { lastFrame } = render(
29
- <Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false} stdout={false} />
30
- )
31
- expect(lastFrame()).toContain('Controls:')
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} stdout={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} stdout={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
- * Ensure the grid data is consistent with a valid Hashiwokakero puzzle.
35
- */
36
- export function validateGrid({ rows, numNodes }: HashiGridProps): void {
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 key={i} nodes={nodes} />
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 ? <Messages hasSolution={hasSolution} /> : null}
91
+ {showInstructions ? (
92
+ <Messages hasSolution={hasSolution} selectionState={selectionState} />
93
+ ) : null}
108
94
  </Box>
109
95
  )
110
96
  }