multi-puzzle-solver 0.9.20__py3-none-any.whl → 0.9.22__py3-none-any.whl

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.

Potentially problematic release.


This version of multi-puzzle-solver might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-puzzle-solver
3
- Version: 0.9.20
3
+ Version: 0.9.22
4
4
  Summary: Efficient solvers for numerous popular and esoteric logic puzzles using CP-SAT
5
5
  Author: Ar-Kareem
6
6
  Project-URL: Homepage, https://github.com/Ar-Kareem/puzzle_solver
@@ -309,6 +309,11 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
309
309
  <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/slitherlink_solved.png" alt="Slitherlink" width="140">
310
310
  </a>
311
311
  </td>
312
+ <td align="center">
313
+ <a href="#yin-yang-puzzle-type-40"><b>Yin-Yang</b><br><br>
314
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/yin_yang_solved.png" alt="Yin-Yang" width="140">
315
+ </a>
316
+ </td>
312
317
  </tr>
313
318
  </table>
314
319
 
@@ -363,6 +368,7 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
363
368
  - [Unequal (Puzzle Type #37)](#unequal-puzzle-type-37)
364
369
  - [Norinori (Puzzle Type #38)](#norinori-puzzle-type-38)
365
370
  - [Slitherlink (Puzzle Type #39)](#slitherlink-puzzle-type-39)
371
+ - [Yin-Yang (Puzzle Type #40)](#yin-yang-puzzle-type-40)
366
372
  - [Why SAT / CP-SAT?](#why-sat--cp-sat)
367
373
  - [Testing](#testing)
368
374
  - [Contributing](#contributing)
@@ -488,6 +494,13 @@ You are given some of the numbers as clues; your aim is to place the rest of the
488
494
  <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/sudoku_unsolved.png" alt="Sudoku unsolved" width="500">
489
495
 
490
496
  Code to utilize this package and solve the puzzle:
497
+
498
+ Note:
499
+
500
+ - The solver also supports solving the ["Sandwich" sudoku variant](https://dkmgames.com/SandwichSudoku/) through the optional parameter ``sandwich={'side': [...], 'bottom': [...] }``。
501
+
502
+ - The solver also supports solving the ["Sudoku-X" variant](https://www.sudopedia.org/wiki/Sudoku-X) through the optional parameter ``unique_diagonal=True``。
503
+
491
504
  ```python
492
505
  import numpy as np
493
506
  from puzzle_solver import sudoku_solver as solver
@@ -2220,6 +2233,7 @@ The numbers outside the grid show the number of filled cells horizontally and ve
2220
2233
  Code to utilize this package and solve the puzzle:
2221
2234
 
2222
2235
  ```python
2236
+ import numpy as np
2223
2237
  from puzzle_solver import thermometers_solver as solver
2224
2238
  board = np.array([
2225
2239
  ['R', 'R', 'D', 'R', 'D', 'R', 'X', 'D', 'L', 'X', 'L', 'L', 'L', 'L', 'L'],
@@ -2299,6 +2313,7 @@ The numbers outside the grid show the number of filled cells horizontally and ve
2299
2313
  Code to utilize this package and solve the puzzle:
2300
2314
 
2301
2315
  ```python
2316
+ import numpy as np
2302
2317
  from puzzle_solver import aquarium_solver as solver
2303
2318
  board = np.array([
2304
2319
  ['01', '01', '01', '01', '02', '02', '02', '03', '03', '03', '03', '04', '05', '05', '05'],
@@ -2376,6 +2391,7 @@ Time taken: 0.02 seconds
2376
2391
  Code to utilize this package and solve the puzzle:
2377
2392
 
2378
2393
  ```python
2394
+ import numpy as np
2379
2395
  from puzzle_solver import stitches_solver as solver
2380
2396
  board = np.array([
2381
2397
  ["00", "00", "00", "00", "00", "01", "01", "01", "01", "01", "01", "01", "01", "02", "02"],
@@ -2456,6 +2472,7 @@ Time taken: 0.01 seconds
2456
2472
  Code to utilize this package and solve the puzzle:
2457
2473
 
2458
2474
  ```python
2475
+ import numpy as np
2459
2476
  from puzzle_solver import battleships_solver as solver
2460
2477
  board = np.array([
2461
2478
  [' ', ' ', ' ', ' ', ' ', 'S', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
@@ -2538,6 +2555,7 @@ The goal is to make some of the cells black in such a way that:
2538
2555
  Code to utilize this package and solve the puzzle:
2539
2556
 
2540
2557
  ```python
2558
+ import numpy as np
2541
2559
  from puzzle_solver import kakurasu_solver as solver
2542
2560
  side = np.array([27, 6, 1, 12, 37, 37, 11, 4, 29, 23, 66, 55])
2543
2561
  bottom = np.array([22, 1, 25, 36, 10, 22, 25, 35, 32, 28, 45, 45])
@@ -2598,6 +2616,7 @@ Code to utilize this package and solve the puzzle:
2598
2616
  Note that as usual the board is an id of the shape (id is meaningless, just used to identify one shape), and the `star_count` parameter depenends on the puzzle type.
2599
2617
 
2600
2618
  ```python
2619
+ import numpy as np
2601
2620
  from puzzle_solver import star_battle_solver as solver
2602
2621
  board = np.array([
2603
2622
  ['00', '00', '00', '00', '00', '01', '01', '01', '01', '01', '01', '01', '01', '01', '02', '02', '02', '03', '03', '03', '03', '03', '03', '03', '03'],
@@ -2697,7 +2716,8 @@ Code to utilize this package and solve the puzzle:
2697
2716
  The `star_count` parameter depenends on the puzzle type.
2698
2717
 
2699
2718
  ```python
2700
- from puzzle_solver import star_battle_shapeless_solver as shapeless_solver
2719
+ import numpy as np
2720
+ from puzzle_solver import star_battle_shapeless as solver
2701
2721
  board = np.array([
2702
2722
  [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
2703
2723
  ['B', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
@@ -2710,7 +2730,7 @@ board = np.array([
2710
2730
  ['B', 'B', ' ', ' ', ' ', ' ', 'B', 'B', 'B', ' '],
2711
2731
  ['B', ' ', ' ', ' ', 'B', ' ', ' ', ' ', ' ', ' '],
2712
2732
  ])
2713
- binst = shapeless_solver.Board(board=board, star_count=2)
2733
+ binst = solver.Board(board=board, star_count=2)
2714
2734
  solutions = binst.solve_and_print()
2715
2735
  ```
2716
2736
 
@@ -2767,6 +2787,8 @@ Note: The solver is capable of solving variations where the puzzle pieces the ma
2767
2787
  Code to utilize this package and solve the puzzle:
2768
2788
 
2769
2789
  ```python
2790
+ import numpy as np
2791
+ from puzzle_solver import lits_solver as solver
2770
2792
  board = np.array([
2771
2793
  ['00', '00', '00', '01', '01', '02', '02', '02', '03', '03', '03', '04', '04', '05', '06', '07', '07', '08', '08', '09'],
2772
2794
  ['00', '00', '00', '00', '01', '02', '03', '03', '03', '10', '04', '04', '05', '05', '06', '07', '08', '08', '09', '09'],
@@ -2963,6 +2985,7 @@ Code to utilize this package and solve the puzzle:
2963
2985
  Note: The number are arbitrary and simply number each galaxy as an integer.
2964
2986
 
2965
2987
  ```python
2988
+ import numpy as np
2966
2989
  from puzzle_solver import galaxies_solver as solver
2967
2990
  galaxies = np.array([
2968
2991
  [' ', ' ', '00', ' ', ' ', '01', '01', '02', '02', '03', '03', ' ', '04', '04', ' '],
@@ -3047,6 +3070,7 @@ Code to utilize this package and solve the puzzle:
3047
3070
  Note: For an NxM board you need an (N+1)x(M+1) array because the puzzle is to solve for the cells while the input is the values at the corners (there's always one more corner than cells in each dimension).
3048
3071
 
3049
3072
  ```python
3073
+ import numpy as np
3050
3074
  from puzzle_solver import slant_solver as solver
3051
3075
  board = np.array([
3052
3076
  [' ', ' ', '1', ' ', '1', ' ', '1', ' ', '1', ' ', ' ', ' ', ' '],
@@ -3131,6 +3155,7 @@ Code to utilize this package and solve the puzzle:
3131
3155
  Note: For an NxM board you need an (2N-1)x(2M-1) array because the puzzle involves input in between the cells. Each numbered cell has neighbors horizontally to represent ">", "<", and "|" (where "|" represents adjacency) and vertically to represent "∧", "∨" and "-" (where "-" represents adjacency). The "X" in the input are unused corners that shouldnt contain anything (just a corner). The numbers should never appear orthogonal to an "X", only diagonally to it. vice-versa for the comparison operators.
3132
3156
 
3133
3157
  ```python
3158
+ import numpy as np
3134
3159
  from puzzle_solver import unequal_solver as solver
3135
3160
  board = np.array([
3136
3161
  [' ', ' ', ' ', ' ', '9', ' ', '1', ' ', '7', '>', ' ', '>', ' ', ' ', ' ', ' ', ' ', '>', ' '],
@@ -3210,6 +3235,7 @@ You have to shade some of the cells in such a way that:
3210
3235
  Code to utilize this package and solve the puzzle:
3211
3236
 
3212
3237
  ```python
3238
+ import numpy as np
3213
3239
  from puzzle_solver import norinori_solver as solver
3214
3240
  board = np.array([
3215
3241
  ['00', '01', '01', '01', '01', '02', '03', '03', '04', '04', '04', '05', '05', '05', '06', '07', '08', '08', '09', '09'],
@@ -3299,6 +3325,7 @@ A line forming a single loop without crossings or branches means that every corn
3299
3325
  Code to utilize this package and solve the puzzle:
3300
3326
 
3301
3327
  ```python
3328
+ import numpy as np
3302
3329
  from puzzle_solver import slitherlink_solver as solver
3303
3330
  board = np.array([
3304
3331
  ['3', ' ', ' ', '2', ' ', ' ', ' ', ' ', ' ', '3', ' ', ' ', ' ', ' ', ' ', '3', ' ', ' ', '1', ' '],
@@ -3385,6 +3412,97 @@ Applying the solution to the puzzle visually:
3385
3412
 
3386
3413
  ---
3387
3414
 
3415
+
3416
+ ## Yin-Yang (Puzzle Type #40)
3417
+
3418
+ * [**Play online**](https://www.puzzle-yin-yang.com)
3419
+
3420
+ * [**Solver Code**][40]
3421
+
3422
+ <details>
3423
+ <summary><strong>Rules</strong></summary>
3424
+
3425
+ Yin-Yang is played on a rectangular grid with no standard size. Some cells start out filled with black or white. The rest of the cells are empty. The goal is to color all cells in such a way that:
3426
+ 1. All black cells should be connected orthogonally in a single group.
3427
+ 2. All white cells should be connected orthogonally in a single group.
3428
+ 3. 2x2 areas of the same color are not allowed.
3429
+
3430
+ </details>
3431
+
3432
+ **Unsolved puzzle**
3433
+
3434
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/yin_yang_unsolved.png" alt="Yin-Yang unsolved" width="500">
3435
+
3436
+ Code to utilize this package and solve the puzzle:
3437
+
3438
+ ```python
3439
+ import numpy as np
3440
+ from puzzle_solver import yin_yang_solver as solver
3441
+ board = np.array([
3442
+ [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
3443
+ [' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', 'B', ' ', ' ', 'W', ' ', 'W', ' ', ' ', 'W', ' ', ' '],
3444
+ [' ', ' ', 'B', ' ', 'B', ' ', 'W', ' ', ' ', 'W', 'B', ' ', ' ', ' ', ' ', 'W', ' ', 'W', ' ', ' '],
3445
+ [' ', ' ', ' ', ' ', ' ', 'W', ' ', 'W', ' ', 'B', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'W', ' ', ' '],
3446
+ [' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'W', 'W', ' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' '],
3447
+ [' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', 'W', 'B', ' ', ' ', ' ', ' ', ' ', 'W', ' ', 'W', ' ', ' '],
3448
+ [' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', 'W', ' ', 'W', ' ', ' '],
3449
+ [' ', ' ', 'B', 'W', 'W', ' ', 'W', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', ' ', ' ', ' ', ' '],
3450
+ [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
3451
+ [' ', 'W', ' ', 'W', ' ', ' ', 'W', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
3452
+ [' ', ' ', ' ', ' ', 'W', 'B', ' ', ' ', ' ', 'B', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
3453
+ [' ', ' ', 'B', ' ', ' ', ' ', 'B', 'B', ' ', 'W', 'B', ' ', 'B', ' ', 'B', ' ', ' ', 'B', ' ', ' '],
3454
+ [' ', 'W', 'W', 'W', ' ', 'B', ' ', 'W', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' ', ' ', ' ', 'B', ' '],
3455
+ [' ', ' ', ' ', ' ', ' ', ' ', 'W', ' ', ' ', 'B', ' ', 'B', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
3456
+ [' ', 'W', ' ', 'B', 'W', 'B', ' ', 'W', ' ', ' ', ' ', ' ', 'B', 'B', ' ', ' ', 'B', ' ', 'B', ' '],
3457
+ [' ', ' ', ' ', ' ', 'W', ' ', ' ', 'B', 'B', 'B', 'B', 'B', ' ', ' ', ' ', 'B', ' ', ' ', 'B', ' '],
3458
+ [' ', 'W', ' ', ' ', ' ', 'W', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'B', ' ', 'B', ' '],
3459
+ ['W', ' ', ' ', 'W', ' ', ' ', 'B', ' ', ' ', 'B', 'B', 'B', 'B', 'B', ' ', ' ', 'B', ' ', 'B', ' '],
3460
+ [' ', 'W', 'W', ' ', 'W', ' ', ' ', 'B', ' ', ' ', ' ', ' ', ' ', ' ', 'B', ' ', 'B', ' ', 'B', ' '],
3461
+ ['B', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'B', 'W']
3462
+ ])
3463
+ binst = solver.Board(board=board)
3464
+ solutions = binst.solve_and_print()
3465
+ ```
3466
+
3467
+ **Script Output**
3468
+
3469
+ ```python
3470
+ Solution found
3471
+ [
3472
+ [ 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W' ],
3473
+ [ 'W', 'B', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'B', 'W', 'B', 'W' ],
3474
+ [ 'W', 'W', 'B', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'B', 'W' ],
3475
+ [ 'W', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'B', 'W', 'B', 'W' ],
3476
+ [ 'W', 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'W', 'W', 'B', 'W', 'B', 'W' ],
3477
+ [ 'W', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'W', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
3478
+ [ 'W', 'B', 'B', 'B', 'W', 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
3479
+ [ 'W', 'W', 'B', 'W', 'W', 'B', 'W', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
3480
+ [ 'W', 'B', 'B', 'B', 'B', 'B', 'W', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
3481
+ [ 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
3482
+ [ 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'W', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
3483
+ [ 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'W' ],
3484
+ [ 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'W', 'W', 'W', 'B', 'W', 'B', 'W', 'B', 'W', 'W', 'W', 'B', 'W' ],
3485
+ [ 'W', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
3486
+ [ 'W', 'W', 'W', 'B', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'W', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W' ],
3487
+ [ 'W', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'B', 'B', 'W', 'B', 'W' ],
3488
+ [ 'W', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'B', 'W' ],
3489
+ [ 'W', 'B', 'B', 'W', 'B', 'B', 'B', 'B', 'W', 'B', 'B', 'B', 'B', 'B', 'B', 'W', 'B', 'W', 'B', 'W' ],
3490
+ [ 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'W', 'W', 'W', 'W', 'W', 'B', 'W', 'B', 'W', 'B', 'W' ],
3491
+ [ 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'B', 'W' ],
3492
+ ]
3493
+ Solutions found: 1
3494
+ status: OPTIMAL
3495
+ Time taken: 3.10 seconds
3496
+ ```
3497
+
3498
+ **Solved puzzle**
3499
+
3500
+ Applying the solution to the puzzle visually:
3501
+
3502
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/yin_yang_solved.png" alt="Yin-Yang solved" width="500">
3503
+
3504
+ ---
3505
+
3388
3506
  ---
3389
3507
 
3390
3508
  ## Why SAT / CP-SAT?
@@ -3475,3 +3593,4 @@ Issues and PRs welcome!
3475
3593
  [37]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/unequal "puzzle_solver/src/puzzle_solver/puzzles/unequal at master · Ar-Kareem/puzzle_solver · GitHub"
3476
3594
  [38]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/norinori "puzzle_solver/src/puzzle_solver/puzzles/norinori at master · Ar-Kareem/puzzle_solver · GitHub"
3477
3595
  [39]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/slitherlink "puzzle_solver/src/puzzle_solver/puzzles/slitherlink at master · Ar-Kareem/puzzle_solver · GitHub"
3596
+ [40]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/yin_yang "puzzle_solver/src/puzzle_solver/puzzles/yin_yang at master · Ar-Kareem/puzzle_solver · GitHub"
@@ -1,4 +1,4 @@
1
- puzzle_solver/__init__.py,sha256=EuBuJfRIA3IGQExkQ5h4BNYXWHLzdZh3j65ocCMy_64,2855
1
+ puzzle_solver/__init__.py,sha256=H7Tpw9yF9TDqwck6bXv1jiS9TGtO5nsVwcDd0Fp1Xyg,2926
2
2
  puzzle_solver/core/utils.py,sha256=_LA81kHrsgvqPvq7RISBeaurXmYMKAU9N6qmV8n0G7s,8063
3
3
  puzzle_solver/core/utils_ortools.py,sha256=2xEL9cMEKmNhRD9lhr2nGdZ3Lbmc9cnHY8xv6iLhUr0,10542
4
4
  puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
@@ -38,7 +38,7 @@ puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0
38
38
  puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
39
39
  puzzle_solver/puzzles/stitches/stitches.py,sha256=iK8t02q43gH3FPbuIDn4dK0sbaOgZOnw8yHNRNvNuIU,6534
40
40
  puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=1LNJkIqpcz1LvY0H0uRedABQWm44dgNf9XeQuKm36WM,10275
41
- puzzle_solver/puzzles/sudoku/sudoku.py,sha256=M_pry7XyKKzlfCF5rFi02lyOrj5GWZzXnDAxmD3NXvI,3588
41
+ puzzle_solver/puzzles/sudoku/sudoku.py,sha256=SE4TM_gic6Jj0fkDR_NzUJdX2XKyQ8eeOnVAQ011Xbo,8870
42
42
  puzzle_solver/puzzles/tents/tents.py,sha256=iyVK2WXfIT5j_9qqlQg0WmwvixwXlZSsHGK3XA-KpII,6283
43
43
  puzzle_solver/puzzles/thermometers/thermometers.py,sha256=nsvJZkm7G8FALT27bpaB0lv5E_AWawqmvapQI8QcYXw,4015
44
44
  puzzle_solver/puzzles/towers/towers.py,sha256=QvL0Pp-Z2ewCeq9ZkNrh8MShKOh-Y52sFBSudve68wk,6496
@@ -46,8 +46,10 @@ puzzle_solver/puzzles/tracks/tracks.py,sha256=98xds9SKNqtOLFTRUX_KSMC7XYmZo567LO
46
46
  puzzle_solver/puzzles/undead/undead.py,sha256=IrCUfzQFBem658P5KKqldG7vd2TugTHehcwseCarerM,6604
47
47
  puzzle_solver/puzzles/unequal/unequal.py,sha256=ExY2XDCrqROCDpRLfHo8uVr1zuli1QvbCdNCiDhlCac,6978
48
48
  puzzle_solver/puzzles/unruly/unruly.py,sha256=sDF0oKT50G-NshyW2DYrvAgD9q9Ku9ANUyNhGSAu7cQ,3827
49
+ puzzle_solver/puzzles/yin_yang/yin_yang.py,sha256=WrRdNhmKhIARdGOt_36gpRxRzrfLGv3wl7igBpPFM64,5259
50
+ puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py,sha256=l4b6eO30kspmiP20b60WbvxEEKNKgLQU1SgwDNhhLOA,6459
49
51
  puzzle_solver/utils/visualizer.py,sha256=tsX1yEKwmwXBYuBJpx_oZGe2UUt1g5yV73G3UbtmvtE,6817
50
- multi_puzzle_solver-0.9.20.dist-info/METADATA,sha256=R36gUfx8jHLCnOMt4QEYf-wtBYskwk46IxxPJGMapjI,180363
51
- multi_puzzle_solver-0.9.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
52
- multi_puzzle_solver-0.9.20.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
53
- multi_puzzle_solver-0.9.20.dist-info/RECORD,,
52
+ multi_puzzle_solver-0.9.22.dist-info/METADATA,sha256=X8hwi1PYI_Yka0rUuxUy5vck0_lSHt-Gwy0m1w8tS_k,187121
53
+ multi_puzzle_solver-0.9.22.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
54
+ multi_puzzle_solver-0.9.22.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
55
+ multi_puzzle_solver-0.9.22.dist-info/RECORD,,
puzzle_solver/__init__.py CHANGED
@@ -37,7 +37,8 @@ from puzzle_solver.puzzles.tracks import tracks as tracks_solver
37
37
  from puzzle_solver.puzzles.undead import undead as undead_solver
38
38
  from puzzle_solver.puzzles.unequal import unequal as unequal_solver
39
39
  from puzzle_solver.puzzles.unruly import unruly as unruly_solver
40
+ from puzzle_solver.puzzles.yin_yang import yin_yang as yin_yang_solver
40
41
 
41
42
  from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
42
43
 
43
- __version__ = '0.9.20'
44
+ __version__ = '0.9.22'
@@ -1,14 +1,14 @@
1
- from typing import Union
1
+ from typing import Union, Optional
2
2
 
3
3
  import numpy as np
4
4
  from ortools.sat.python import cp_model
5
5
 
6
6
  from puzzle_solver.core.utils import Pos, get_pos, get_all_pos, get_char, set_char, get_row_pos, get_col_pos
7
- from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, or_constraint, SingleSolution
8
8
 
9
9
 
10
10
  def get_value(board: np.array, pos: Pos) -> Union[int, str]:
11
- c = get_char(board, pos)
11
+ c = get_char(board, pos).lower()
12
12
  if c == ' ':
13
13
  return c
14
14
  if str(c).isdecimal():
@@ -27,22 +27,40 @@ def set_value(board: np.array, pos: Pos, value: Union[int, str]):
27
27
  set_char(board, pos, value)
28
28
 
29
29
 
30
- def get_block_pos(i: int, B: int) -> list[Pos]:
31
- top_left_x = (i%B)*B
32
- top_left_y = (i//B)*B
33
- return [get_pos(x=top_left_x + x, y=top_left_y + y) for x in range(B) for y in range(B)]
30
+ def get_block_pos(i: int, Bv: int, Bh: int) -> list[Pos]:
31
+ # Think: Bv=3 and Bh=4 while the board has 4 vertical blocks and 3 horizontal blocks
32
+ top_left_x = (i%Bv)*Bh
33
+ top_left_y = (i//Bv)*Bv
34
+ return [get_pos(x=top_left_x + x, y=top_left_y + y) for x in range(Bh) for y in range(Bv)]
34
35
 
35
36
 
36
37
  class Board:
37
- def __init__(self, board: np.array):
38
+ def __init__(self, board: np.array, block_size: Optional[tuple[int, int]] = None, sandwich: Optional[dict[str, list[int]]] = None, unique_diagonal: bool = False):
38
39
  assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
39
40
  assert board.shape[0] == board.shape[1], 'board must be square'
40
41
  assert all(isinstance(i.item(), str) and len(i.item()) == 1 and (i.item().isalnum() or i.item() == ' ') for i in np.nditer(board)), 'board must contain only alphanumeric characters or space'
41
42
  self.board = board
42
- self.N = board.shape[0]
43
- self.B = np.sqrt(self.N) # block size
44
- assert self.B.is_integer(), 'board size must be a perfect square'
45
- self.B = int(self.B)
43
+ self.V, self.H = board.shape
44
+ if block_size is None:
45
+ B = np.sqrt(self.V) # block size
46
+ assert B.is_integer(), 'board size must be a perfect square or provide block_size'
47
+ Bv, Bh = int(B), int(B)
48
+ else:
49
+ Bv, Bh = block_size
50
+ assert Bv * Bh == self.V, 'block size must be a factor of board size'
51
+ # can be different in 4x3 for example
52
+ self.Bv = Bv
53
+ self.Bh = Bh
54
+ self.B = Bv * Bh # block count
55
+ if sandwich is not None:
56
+ assert set(sandwich.keys()) == set(['side', 'bottom']), 'sandwich must contain only side and bottom'
57
+ assert len(sandwich['side']) == self.H, 'side must be equal to board width'
58
+ assert len(sandwich['bottom']) == self.V, 'bottom must be equal to board height'
59
+ self.sandwich = sandwich
60
+ else:
61
+ self.sandwich = None
62
+ self.unique_diagonal = unique_diagonal
63
+
46
64
  self.model = cp_model.CpModel()
47
65
  self.model_vars: dict[Pos, cp_model.IntVar] = {}
48
66
 
@@ -50,28 +68,50 @@ class Board:
50
68
  self.add_all_constraints()
51
69
 
52
70
  def create_vars(self):
53
- for pos in get_all_pos(self.N):
54
- self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
71
+ for pos in get_all_pos(self.V, self.H):
72
+ self.model_vars[pos] = self.model.NewIntVar(1, self.B, f'{pos}')
55
73
 
56
74
  def add_all_constraints(self):
57
75
  # some squares are already filled
58
- for pos in get_all_pos(self.N):
76
+ for pos in get_all_pos(self.V, self.H):
59
77
  c = get_value(self.board, pos)
60
78
  if c != ' ':
61
79
  self.model.Add(self.model_vars[pos] == c)
62
80
  # every number appears exactly once in each row, each column and each block
63
81
  # each row
64
- for row in range(self.N):
65
- row_vars = [self.model_vars[pos] for pos in get_row_pos(row, self.N)]
82
+ for row in range(self.V):
83
+ row_vars = [self.model_vars[pos] for pos in get_row_pos(row, H=self.H)]
66
84
  self.model.AddAllDifferent(row_vars)
67
85
  # each column
68
- for col in range(self.N):
69
- col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.N)]
86
+ for col in range(self.H):
87
+ col_vars = [self.model_vars[pos] for pos in get_col_pos(col, V=self.V)]
70
88
  self.model.AddAllDifferent(col_vars)
71
89
  # each block
72
- for block_i in range(self.N):
73
- block_vars = [self.model_vars[p] for p in get_block_pos(block_i, self.B)]
90
+ for block_i in range(self.B):
91
+ block_vars = [self.model_vars[p] for p in get_block_pos(block_i, Bv=self.Bv, Bh=self.Bh)]
74
92
  self.model.AddAllDifferent(block_vars)
93
+ if self.sandwich is not None:
94
+ self.add_sandwich_constraints()
95
+ if self.unique_diagonal:
96
+ self.add_unique_diagonal_constraints()
97
+
98
+ def add_sandwich_constraints(self):
99
+ for c, clue in enumerate(self.sandwich['bottom']):
100
+ if clue is None or int(clue) < 0:
101
+ continue
102
+ col_vars = [self.model_vars[p] for p in get_col_pos(c, V=self.V)]
103
+ add_single_sandwich(col_vars, int(clue), self.model, name=f"sand_side_{c}")
104
+ for r, clue in enumerate(self.sandwich['side']):
105
+ if clue is None or int(clue) < 0:
106
+ continue
107
+ row_vars = [self.model_vars[p] for p in get_row_pos(r, H=self.H)]
108
+ add_single_sandwich(row_vars, int(clue), self.model, name=f"sand_bottom_{r}")
109
+
110
+ def add_unique_diagonal_constraints(self):
111
+ main_diagonal_vars = [self.model_vars[get_pos(x=i, y=i)] for i in range(min(self.V, self.H))]
112
+ self.model.AddAllDifferent(main_diagonal_vars)
113
+ anti_diagonal_vars = [self.model_vars[get_pos(x=i, y=self.V-i-1)] for i in range(min(self.V, self.H))]
114
+ self.model.AddAllDifferent(anti_diagonal_vars)
75
115
 
76
116
  def solve_and_print(self, verbose: bool = True):
77
117
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
@@ -81,10 +121,83 @@ class Board:
81
121
  return SingleSolution(assignment=assignment)
82
122
  def callback(single_res: SingleSolution):
83
123
  print("Solution found")
84
- res = np.full((self.N, self.N), ' ', dtype=object)
85
- for pos in get_all_pos(self.N):
124
+ res = np.full((self.V, self.H), ' ', dtype=object)
125
+ for pos in get_all_pos(self.V, self.H):
86
126
  c = get_value(self.board, pos)
87
127
  c = single_res.assignment[pos]
88
128
  set_value(res, pos, c)
89
129
  print(res)
90
130
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
131
+
132
+
133
+
134
+ def add_single_sandwich(vars_line: list[cp_model.IntVar], clue: int, model: cp_model.CpModel, name: str):
135
+ # VAR count:
136
+ # is_min: L
137
+ # is_max: L
138
+ # pos_min/max/lt: 1+1+1
139
+ # between: L
140
+ # a1/a2/case_a: L+L+L
141
+ # b1/b2/case_b: L+L+L
142
+ # take: L
143
+ # 10L+3 per 1 call of the function (i.e. per 1 line)
144
+ # entire board will have 2L lines (rows and columns)
145
+ # in total: 20L^2+6L
146
+
147
+ L = len(vars_line)
148
+ is_min = [model.NewBoolVar(f"{name}_ismin_{i}") for i in range(L)]
149
+ is_max = [model.NewBoolVar(f"{name}_ismax_{i}") for i in range(L)]
150
+ for i, v in enumerate(vars_line):
151
+ model.Add(v == 1).OnlyEnforceIf(is_min[i])
152
+ model.Add(v != 1).OnlyEnforceIf(is_min[i].Not())
153
+ model.Add(v == L).OnlyEnforceIf(is_max[i])
154
+ model.Add(v != L).OnlyEnforceIf(is_max[i].Not())
155
+
156
+ # index of the minimum and maximum values (sum of the values inbetween must = clue)
157
+ pos_min = model.NewIntVar(0, L - 1, f"{name}_pos_min")
158
+ pos_max = model.NewIntVar(0, L - 1, f"{name}_pos_max")
159
+ model.Add(pos_min == sum(i * is_min[i] for i in range(L)))
160
+ model.Add(pos_max == sum(i * is_max[i] for i in range(L)))
161
+
162
+ # used later to handle both cases (A. pos_min < pos_max and B. pos_max < pos_min)
163
+ lt = model.NewBoolVar(f"{name}_lt") # pos_min < pos_max ?
164
+ model.Add(pos_min < pos_max).OnlyEnforceIf(lt)
165
+ model.Add(pos_min >= pos_max).OnlyEnforceIf(lt.Not())
166
+
167
+ between = [model.NewBoolVar(f"{name}_between_{i}") for i in range(L)]
168
+ for i in range(L):
169
+ # Case A: pos_min < i < pos_max (AND lt is true)
170
+ a1 = model.NewBoolVar(f"{name}_a1_{i}") # pos_min < i
171
+ a2 = model.NewBoolVar(f"{name}_a2_{i}") # i < pos_max
172
+
173
+ model.Add(pos_min < i).OnlyEnforceIf(a1)
174
+ model.Add(pos_min >= i).OnlyEnforceIf(a1.Not())
175
+ model.Add(i < pos_max).OnlyEnforceIf(a2)
176
+ model.Add(i >= pos_max).OnlyEnforceIf(a2.Not())
177
+
178
+ case_a = model.NewBoolVar(f"{name}_caseA_{i}")
179
+ and_constraint(model, case_a, [lt, a1, a2])
180
+
181
+ # Case B: pos_max < i < pos_min (AND lt is false)
182
+ b1 = model.NewBoolVar(f"{name}_b1_{i}") # pos_max < i
183
+ b2 = model.NewBoolVar(f"{name}_b2_{i}") # i < pos_min
184
+
185
+ model.Add(pos_max < i).OnlyEnforceIf(b1)
186
+ model.Add(pos_max >= i).OnlyEnforceIf(b1.Not())
187
+ model.Add(i < pos_min).OnlyEnforceIf(b2)
188
+ model.Add(i >= pos_min).OnlyEnforceIf(b2.Not())
189
+
190
+ case_b = model.NewBoolVar(f"{name}_caseB_{i}")
191
+ and_constraint(model, case_b, [lt.Not(), b1, b2])
192
+
193
+ # between[i] is true if we're in case A or case B
194
+ or_constraint(model, between[i], [case_a, case_b])
195
+
196
+ # sum values at indices that are "between"
197
+ take = [model.NewIntVar(0, L, f"{name}_take_{i}") for i in range(L)]
198
+ for i, v in enumerate(vars_line):
199
+ # take[i] = v if between[i] else 0
200
+ model.Add(take[i] == v).OnlyEnforceIf(between[i])
201
+ model.Add(take[i] == 0).OnlyEnforceIf(between[i].Not())
202
+
203
+ model.Add(sum(take) == clue)
@@ -0,0 +1,169 @@
1
+
2
+ def extract_lines(bw):
3
+ horizontal = np.copy(bw)
4
+ vertical = np.copy(bw)
5
+
6
+ cols = horizontal.shape[1]
7
+ horizontal_size = max(5, cols // 20)
8
+ h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (horizontal_size, 1))
9
+ horizontal = cv2.erode(horizontal, h_kernel)
10
+ horizontal = cv2.dilate(horizontal, h_kernel)
11
+ h_means = np.mean(horizontal, axis=1)
12
+ h_idx = np.where(h_means > np.percentile(h_means, 70))[0]
13
+
14
+ rows = vertical.shape[0]
15
+ verticalsize = max(5, rows // 20)
16
+ v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, verticalsize))
17
+ vertical = cv2.erode(vertical, v_kernel)
18
+ vertical = cv2.dilate(vertical, v_kernel)
19
+ v_means = np.mean(vertical, axis=0)
20
+ v_idx = np.where(v_means > np.percentile(v_means, 70))[0]
21
+ return h_idx, v_idx
22
+
23
+
24
+ def _cluster_line_indices(indices, min_run=3):
25
+ """Group consecutive indices into line positions (take the mean of each run)."""
26
+ if len(indices) == 0:
27
+ return []
28
+ indices = np.sort(indices)
29
+ runs = []
30
+ run = [indices[0]]
31
+ for k in indices[1:]:
32
+ if k == run[-1] + 1:
33
+ run.append(k)
34
+ else:
35
+ if len(run) >= min_run:
36
+ runs.append(int(np.mean(run)))
37
+ run = [k]
38
+ if len(run) >= min_run:
39
+ runs.append(int(np.mean(run)))
40
+ # De-duplicate lines that are too close (rare)
41
+ dedup = []
42
+ for x in runs:
43
+ if not dedup or x - dedup[-1] > 2:
44
+ dedup.append(x)
45
+ return dedup
46
+
47
+
48
+ def extract_yinyang_board(image_path, debug=False):
49
+ # Load and pre-process
50
+ img = cv2.imread(str(image_path))
51
+ assert img is not None, f"Failed to read image: {image_path}"
52
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
53
+
54
+ # Light grid lines → enhance lines using adaptive threshold
55
+ # (binary inverted so lines/dots become white)
56
+ bw = cv2.adaptiveThreshold(
57
+ gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
58
+ cv2.THRESH_BINARY_INV, 35, 5
59
+ )
60
+
61
+ # Detect grid line indices (no guessing)
62
+ h_idx, v_idx = extract_lines(bw)
63
+ print(f"h_idx: {h_idx}")
64
+ print(f"v_idx: {v_idx}")
65
+ h_lines = h_idx
66
+ v_lines = v_idx
67
+ # h_lines = _cluster_line_indices(h_idx)
68
+ # v_lines = _cluster_line_indices(v_idx)
69
+ assert len(h_lines) >= 2 and len(v_lines) >= 2, "Could not detect grid lines"
70
+
71
+ # Cells are spans between successive grid lines
72
+ N_rows = len(h_lines) - 1
73
+ N_cols = len(v_lines) - 1
74
+ board = np.full((N_rows, N_cols), ' ', dtype='<U1')
75
+
76
+ # For robust per-cell analysis, also create a "dots" image with grid erased
77
+ # Remove thickened grid from bw
78
+ # Build masks for horizontal/vertical lines (reusing kernels sized by image dims)
79
+ cols = bw.shape[1]
80
+ rows = bw.shape[0]
81
+ h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (max(5, cols // 20), 1))
82
+ v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(5, rows // 20)))
83
+ horiz = cv2.morphologyEx(bw, cv2.MORPH_OPEN, h_kernel)
84
+ vert = cv2.morphologyEx(bw, cv2.MORPH_OPEN, v_kernel)
85
+ grid = cv2.bitwise_or(horiz, vert)
86
+ dots = cv2.bitwise_and(bw, cv2.bitwise_not(grid)) # mostly circles remain
87
+
88
+ # Iterate cells
89
+ print(f"N_rows: {N_rows}, N_cols: {N_cols}")
90
+ print(f"h_lines: {h_lines}")
91
+ print(f"v_lines: {v_lines}")
92
+ for r in range(N_rows):
93
+ y0, y1 = h_lines[r], h_lines[r + 1]
94
+ # shrink ROI to avoid line bleed
95
+ y0i = max(y0 + 2, 0)
96
+ y1i = max(min(y1 - 2, dots.shape[0]), y0i + 1)
97
+ for c in range(N_cols):
98
+ x0, x1 = v_lines[c], v_lines[c + 1]
99
+ x0i = max(x0 + 2, 0)
100
+ x1i = max(min(x1 - 2, dots.shape[1]), x0i + 1)
101
+
102
+ roi_gray = gray[y0i:y1i, x0i:x1i]
103
+ roi_dots = dots[y0i:y1i, x0i:x1i]
104
+ area = roi_dots.shape[0] * roi_dots.shape[1]
105
+ if area == 0:
106
+ continue
107
+
108
+ # If no meaningful foreground, it's empty
109
+ fg_area = int(np.count_nonzero(roi_dots))
110
+ if fg_area < 0.03 * area:
111
+ board[r, c] = ' '
112
+ continue
113
+
114
+ # Segment the largest blob (circle) inside the cell
115
+ contours, _ = cv2.findContours(roi_dots, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
116
+ if not contours:
117
+ board[r, c] = ' '
118
+ continue
119
+
120
+ cnt = max(contours, key=cv2.contourArea)
121
+ if cv2.contourArea(cnt) < 0.02 * area:
122
+ board[r, c] = ' '
123
+ continue
124
+
125
+ mask = np.zeros_like(roi_dots)
126
+ cv2.drawContours(mask, [cnt], -1, 255, thickness=-1)
127
+
128
+ mean_inside = float(cv2.mean(roi_gray, mask=mask)[0])
129
+
130
+ # Heuristic: black stones have dark interior; white stones bright interior
131
+ # (grid background is white; outlines contribute little to mean)
132
+ board[r, c] = 'B' if mean_inside < 150 else 'W'
133
+ non_empty_rows = []
134
+ non_empty_cols = []
135
+ for r in range(N_rows):
136
+ if not all(board[r, :] == ' '):
137
+ non_empty_rows.append(r)
138
+ for c in range(N_cols):
139
+ if not all(board[:, c] == ' '):
140
+ non_empty_cols.append(c)
141
+ board = board[non_empty_rows, :][:, non_empty_cols]
142
+
143
+ if debug:
144
+ for row in board:
145
+ print(row.tolist())
146
+ output_path = Path(__file__).parent / "input_output" / (image_path.stem + ".json")
147
+ with open(output_path, 'w') as f:
148
+ f.write('[\n')
149
+ for i, row in enumerate(board):
150
+ f.write(' ' + str(row.tolist()).replace("'", '"'))
151
+ if i != len(board) - 1:
152
+ f.write(',')
153
+ f.write('\n')
154
+ f.write(']')
155
+ print('output json: ', output_path)
156
+
157
+ return board
158
+
159
+ if __name__ == "__main__":
160
+ # python .\src\puzzle_solver\puzzles\yin_yang\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
161
+ import cv2
162
+ import numpy as np
163
+ from pathlib import Path
164
+ image_path = Path(__file__).parent / "input_output" / "MzoyLDcwMSw2NTY=.png"
165
+ # image_path = Path(__file__).parent / "input_output" / "Njo5MDcsNDk4.png"
166
+ # image_path = Path(__file__).parent / "input_output" / "MTE6Niw0NjEsMTIx.png"
167
+ assert image_path.exists(), f"Image file does not exist: {image_path}"
168
+ board = extract_yinyang_board(image_path, debug=True)
169
+ print(board)
@@ -0,0 +1,110 @@
1
+ import numpy as np
2
+
3
+ from ortools.sat.python import cp_model
4
+ from ortools.sat.python.cp_model import LinearExpr as lxp
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, in_bounds, Direction, get_next_pos, get_pos
7
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
8
+
9
+
10
+ class Board:
11
+ def __init__(self, board: np.array):
12
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
13
+ assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space, B, or W'
14
+ self.board = board
15
+ self.V, self.H = board.shape
16
+ self.model = cp_model.CpModel()
17
+ self.B: dict[Pos, cp_model.IntVar] = {}
18
+ self.W: dict[Pos, cp_model.IntVar] = {}
19
+
20
+ self.create_vars()
21
+ self.add_all_constraints()
22
+
23
+ def create_vars(self):
24
+ for pos in get_all_pos(self.V, self.H):
25
+ self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
26
+
27
+ def add_all_constraints(self):
28
+ self.force_clues()
29
+ self.disallow_2x2()
30
+ self.disallow_checkers()
31
+ self.force_connected_component()
32
+ self.force_border_transitions()
33
+
34
+ def force_clues(self):
35
+ for pos in get_all_pos(self.V, self.H): # force clues
36
+ c = get_char(self.board, pos)
37
+ if c not in ['B', 'W']:
38
+ continue
39
+ self.model.Add(self.B[pos] == (c == 'B'))
40
+
41
+ def disallow_2x2(self):
42
+ for pos in get_all_pos(self.V, self.H): # disallow 2x2 (WW/WW) and (BB/BB)
43
+ tl = pos
44
+ tr = get_next_pos(pos, Direction.RIGHT)
45
+ bl = get_next_pos(pos, Direction.DOWN)
46
+ br = get_next_pos(bl, Direction.RIGHT)
47
+ if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
48
+ continue
49
+ self.model.AddBoolOr([self.B[tl], self.B[tr], self.B[bl], self.B[br]])
50
+ self.model.AddBoolOr([self.B[tl].Not(), self.B[tr].Not(), self.B[bl].Not(), self.B[br].Not()])
51
+
52
+ def disallow_checkers(self):
53
+ # from https://ralphwaldo.github.io/yinyang_summary.html
54
+ for pos in get_all_pos(self.V, self.H): # disallow (WB/BW) and (BW/WB)
55
+ tl = pos
56
+ tr = get_next_pos(pos, Direction.RIGHT)
57
+ bl = get_next_pos(pos, Direction.DOWN)
58
+ br = get_next_pos(bl, Direction.RIGHT)
59
+ if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
60
+ continue
61
+ self.model.AddBoolOr([self.B[tl], self.B[tr].Not(), self.B[bl].Not(), self.B[br]]) # disallow (WB/BW)
62
+ self.model.AddBoolOr([self.B[tl].Not(), self.B[tr], self.B[bl], self.B[br].Not()]) # disallow (BW/WB)
63
+
64
+ def force_connected_component(self):
65
+ # force single connected component for both colors
66
+ force_connected_component(self.model, self.B)
67
+ force_connected_component(self.model, {k: v.Not() for k, v in self.B.items()})
68
+
69
+ def force_border_transitions(self):
70
+ # from https://ralphwaldo.github.io/yinyang_summary.html
71
+ # The border cells cannot be split into four (or more) separate blocks of colours
72
+ # It is therefore either split into two blocks (one of each colour), or is just a single block of one colour or the other
73
+ border_cells = [] # go in a ring clockwise from top left
74
+ for x in range(self.H):
75
+ border_cells.append(get_pos(x=x, y=0))
76
+ for y in range(1, self.V):
77
+ border_cells.append(get_pos(x=self.H-1, y=y))
78
+ for x in range(self.H-2, -1, -1):
79
+ border_cells.append(get_pos(x=x, y=self.V-1))
80
+ for y in range(self.V-2, 0, -1):
81
+ border_cells.append(get_pos(x=0, y=y))
82
+ # tie the knot
83
+ border_cells.append(border_cells[0])
84
+ # unequal sum is 0 or 2
85
+ deltas = []
86
+ for i in range(len(border_cells)-1):
87
+ aux = self.model.NewBoolVar(f'border_transition_{i}') # i is black while i+1 is white
88
+ and_constraint(self.model, aux, [self.B[border_cells[i]], self.B[border_cells[i+1]].Not()])
89
+ deltas.append(aux)
90
+ self.model.Add(lxp.Sum(deltas) <= 1)
91
+
92
+
93
+ def solve_and_print(self, verbose: bool = True):
94
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
95
+ assignment: dict[Pos, int] = {}
96
+ for pos, var in board.B.items():
97
+ assignment[pos] = 'B' if solver.BooleanValue(var) else 'W'
98
+ return SingleSolution(assignment=assignment)
99
+ def callback(single_res: SingleSolution):
100
+ print("Solution found")
101
+ res = np.full((self.V, self.H), ' ', dtype=object)
102
+ for pos in get_all_pos(self.V, self.H):
103
+ c = get_char(self.board, pos)
104
+ c = single_res.assignment[pos]
105
+ set_char(res, pos, c)
106
+ print('[')
107
+ for row in res:
108
+ print(" [ '" + "', '".join(row.tolist()) + "' ],")
109
+ print(']')
110
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)