multi-puzzle-solver 1.1.8__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.
Files changed (106) hide show
  1. multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
  2. multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
  3. multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +184 -0
  6. puzzle_solver/core/utils.py +298 -0
  7. puzzle_solver/core/utils_ortools.py +333 -0
  8. puzzle_solver/core/utils_visualizer.py +575 -0
  9. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  10. puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
  11. puzzle_solver/puzzles/area_51/area_51.py +159 -0
  12. puzzle_solver/puzzles/battleships/battleships.py +139 -0
  13. puzzle_solver/puzzles/binairo/binairo.py +98 -0
  14. puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
  15. puzzle_solver/puzzles/black_box/black_box.py +243 -0
  16. puzzle_solver/puzzles/branches/branches.py +64 -0
  17. puzzle_solver/puzzles/bridges/bridges.py +104 -0
  18. puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
  19. puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
  20. puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
  21. puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
  22. puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
  23. puzzle_solver/puzzles/clouds/clouds.py +81 -0
  24. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
  25. puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
  26. puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
  27. puzzle_solver/puzzles/filling/filling.py +94 -0
  28. puzzle_solver/puzzles/flip/flip.py +64 -0
  29. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  30. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
  31. puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
  32. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
  33. puzzle_solver/puzzles/guess/guess.py +232 -0
  34. puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
  35. puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
  36. puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
  37. puzzle_solver/puzzles/inertia/inertia.py +121 -0
  38. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
  39. puzzle_solver/puzzles/inertia/tsp.py +400 -0
  40. puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
  41. puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
  42. puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
  43. puzzle_solver/puzzles/keen/keen.py +76 -0
  44. puzzle_solver/puzzles/kropki/kropki.py +94 -0
  45. puzzle_solver/puzzles/light_up/light_up.py +58 -0
  46. puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
  47. puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
  48. puzzle_solver/puzzles/lits/lits.py +138 -0
  49. puzzle_solver/puzzles/magnets/magnets.py +96 -0
  50. puzzle_solver/puzzles/map/map.py +56 -0
  51. puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
  52. puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
  53. puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
  54. puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
  55. puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
  56. puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
  57. puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
  58. puzzle_solver/puzzles/norinori/norinori.py +96 -0
  59. puzzle_solver/puzzles/number_path/number_path.py +76 -0
  60. puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
  61. puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
  62. puzzle_solver/puzzles/palisade/palisade.py +91 -0
  63. puzzle_solver/puzzles/pearl/pearl.py +107 -0
  64. puzzle_solver/puzzles/pipes/pipes.py +82 -0
  65. puzzle_solver/puzzles/range/range.py +59 -0
  66. puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
  67. puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
  68. puzzle_solver/puzzles/rooms/rooms.py +75 -0
  69. puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
  70. puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
  71. puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
  72. puzzle_solver/puzzles/signpost/signpost.py +93 -0
  73. puzzle_solver/puzzles/singles/singles.py +53 -0
  74. puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
  75. puzzle_solver/puzzles/slant/slant.py +111 -0
  76. puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
  77. puzzle_solver/puzzles/snail/snail.py +97 -0
  78. puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
  79. puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
  80. puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
  81. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
  82. puzzle_solver/puzzles/stitches/stitches.py +96 -0
  83. puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
  84. puzzle_solver/puzzles/suguru/suguru.py +55 -0
  85. puzzle_solver/puzzles/suko/suko.py +54 -0
  86. puzzle_solver/puzzles/tapa/tapa.py +97 -0
  87. puzzle_solver/puzzles/tatami/tatami.py +64 -0
  88. puzzle_solver/puzzles/tents/tents.py +80 -0
  89. puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
  90. puzzle_solver/puzzles/towers/towers.py +89 -0
  91. puzzle_solver/puzzles/tracks/tracks.py +88 -0
  92. puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
  93. puzzle_solver/puzzles/troix/dumplings.py +7 -0
  94. puzzle_solver/puzzles/troix/troix.py +75 -0
  95. puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
  96. puzzle_solver/puzzles/undead/undead.py +130 -0
  97. puzzle_solver/puzzles/unequal/unequal.py +128 -0
  98. puzzle_solver/puzzles/unruly/unruly.py +54 -0
  99. puzzle_solver/puzzles/vectors/vectors.py +94 -0
  100. puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
  101. puzzle_solver/puzzles/walls/walls.py +52 -0
  102. puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
  103. puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
  104. puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
  105. puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
  106. puzzle_solver/utils/visualizer.py +155 -0
@@ -0,0 +1,81 @@
1
+ from typing import Iterator
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+ from ortools.sat.python.cp_model import LinearExpr as lxp
6
+
7
+ from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, get_char
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+ from puzzle_solver.core.utils_visualizer import combined_function
10
+
11
+
12
+ class Board:
13
+ def __init__(self, board: np.array, row_sums: list[list[int]], col_sums: list[list[int]], N: int = 9):
14
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
15
+ assert all((c.item() in ['#', ' ', '1', '2', '3', '4', '5', '6', '7', '8', '9']) for c in np.nditer(board)), 'board must contain only #, space, or digits'
16
+ assert len(row_sums) == board.shape[0] and all(isinstance(i, list) and all(isinstance(j, int) or j == '#' for j in i) for i in row_sums), 'row_sums must be a list of lists of integers or #'
17
+ assert len(col_sums) == board.shape[1] and all(isinstance(i, list) and all(isinstance(j, int) or j == '#' for j in i) for i in col_sums), 'col_sums must be a list of lists of integers or #'
18
+ self.board = board
19
+ self.row_sums = row_sums
20
+ self.col_sums = col_sums
21
+ self.V, self.H = board.shape
22
+ self.N = N
23
+ self.model = cp_model.CpModel()
24
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
25
+
26
+ self.create_vars()
27
+ self.add_all_constraints()
28
+
29
+ def create_vars(self):
30
+ for pos in get_all_pos(self.V, self.H):
31
+ if get_char(self.board, pos) == '#':
32
+ continue
33
+ self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
34
+
35
+ def get_consecutives(self, pos: Pos, direction: Direction) -> Iterator[list[Pos]]:
36
+ consecutive = []
37
+ while in_bounds(pos, self.V, self.H):
38
+ if get_char(self.board, pos) == '#':
39
+ if len(consecutive) > 0:
40
+ yield consecutive
41
+ consecutive = []
42
+ else:
43
+ consecutive.append(pos)
44
+ pos = get_next_pos(pos, direction)
45
+ if len(consecutive) > 0:
46
+ yield consecutive
47
+
48
+ def add_all_constraints(self):
49
+ for row in range(self.V):
50
+ row_consecutives = self.get_consecutives(get_pos(x=0, y=row), Direction.RIGHT)
51
+ for i, consecutive in enumerate(row_consecutives):
52
+ # print('row', row, 'i', i, 'consecutive', consecutive)
53
+ self.model.AddAllDifferent([self.model_vars[p] for p in consecutive])
54
+ clue = self.row_sums[row][i]
55
+ if clue != '#':
56
+ self.model.Add(lxp.sum([self.model_vars[p] for p in consecutive]) == clue)
57
+ assert len(self.row_sums[row]) == i + 1, f'row_sums[{row}] has {len(self.row_sums[row])} clues, but {i + 1} consecutive cells'
58
+ for col in range(self.H):
59
+ col_consecutives = self.get_consecutives(get_pos(x=col, y=0), Direction.DOWN)
60
+ for i, consecutive in enumerate(col_consecutives):
61
+ # print('col', col, 'i', i, 'consecutive', consecutive)
62
+ self.model.AddAllDifferent([self.model_vars[p] for p in consecutive])
63
+ clue = self.col_sums[col][i]
64
+ if clue != '#':
65
+ self.model.Add(lxp.sum([self.model_vars[p] for p in consecutive]) == clue)
66
+ assert len(self.col_sums[col]) == i + 1, f'col_sums[{col}] has {len(self.col_sums[col])} clues, but {i + 1} consecutive cells'
67
+
68
+ def solve_and_print(self, verbose: bool = True):
69
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
70
+ assignment: dict[Pos, int] = {}
71
+ for pos, var in board.model_vars.items():
72
+ assignment[pos] = solver.Value(var)
73
+ return SingleSolution(assignment=assignment)
74
+ def callback(single_res: SingleSolution):
75
+ print("Solution found")
76
+ print(combined_function(self.V, self.H,
77
+ is_shaded=lambda r, c: self.board[r, c] == '#',
78
+ center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)]),
79
+ text_on_shaded_cells=False
80
+ ))
81
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,95 @@
1
+ from typing import Iterator
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+ from ortools.sat.python.cp_model import LinearExpr as lxp
6
+
7
+ from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, get_char
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+ from puzzle_solver.core.utils_visualizer import combined_function
10
+
11
+
12
+ class Board:
13
+ def __init__(self, board: np.array, row_sums: list[list[str]], col_sums: list[list[str]], characters: list[str], min_value: int = 0, max_value: int = 9):
14
+ legal_chars = characters + ['#'] + [str(i) for i in range(min_value, max_value + 1)]
15
+ assert (len(row_sums), len(col_sums)) == board.shape, f'row_sums and col_sums must be the same shape as board, got {len(row_sums)}x{len(col_sums)} and {board.shape}'
16
+ assert all(all(cc in legal_chars for cc in c.item().strip()) for c in np.nditer(board)), 'board must contain only #, space, or characters'
17
+ assert all(all(all(cc in legal_chars for cc in c) for c in s) for s in row_sums), 'row_sums must be a list of lists of strings containing only # or characters'
18
+ assert all(all(all(cc in legal_chars for cc in c) for c in s) for s in col_sums), 'col_sums must be a list of lists of strings containing only # or characters'
19
+ self.board = board
20
+ self.row_sums, self.col_sums = row_sums, col_sums
21
+ self.characters = characters
22
+ self.min_value, self.max_value = min_value, max_value
23
+ assert (self.max_value - self.min_value + 1) == len(self.characters), f'max_value - min_value + 1 must be equal to the number of characters, got {self.max_value - self.min_value + 1} != {len(self.characters)}'
24
+ self.V, self.H = board.shape
25
+ self.model = cp_model.CpModel()
26
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
27
+ self.dictionary: dict[str, cp_model.IntVar] = {}
28
+
29
+ self.create_vars()
30
+ self.add_all_constraints()
31
+
32
+ def create_vars(self):
33
+ for char in self.characters:
34
+ self.dictionary[char] = self.model.NewIntVar(self.min_value, self.max_value, f'{char}')
35
+ for pos in get_all_pos(self.V, self.H):
36
+ if get_char(self.board, pos) == '#':
37
+ continue
38
+ self.model_vars[pos] = self.model.NewIntVar(self.min_value, self.max_value, f'{pos}')
39
+
40
+ def get_consecutives(self, pos: Pos, direction: Direction) -> Iterator[list[Pos]]:
41
+ consecutive = []
42
+ while in_bounds(pos, self.V, self.H):
43
+ if get_char(self.board, pos) == '#':
44
+ if len(consecutive) > 0:
45
+ yield consecutive
46
+ consecutive = []
47
+ else:
48
+ consecutive.append(pos)
49
+ pos = get_next_pos(pos, direction)
50
+ if len(consecutive) > 0:
51
+ yield consecutive
52
+
53
+ def clue_to_var(self, clue: str) -> cp_model.IntVar:
54
+ res = []
55
+ for i,c in enumerate(clue[::-1]):
56
+ res.append(self.dictionary[c] * 10**i)
57
+ return lxp.sum(res)
58
+
59
+ def add_all_constraints(self):
60
+ self.model.AddAllDifferent(list(self.dictionary.values())) # dictionary must be unique
61
+ for pos in get_all_pos(self.V, self.H):
62
+ c = get_char(self.board, pos).strip()
63
+ if c not in ['', '#']:
64
+ self.model.Add(self.model_vars[pos] == (self.dictionary[c] if c in self.dictionary else int(c)))
65
+ for row in range(self.V): # for row clues
66
+ row_consecutives = self.get_consecutives(get_pos(x=0, y=row), Direction.RIGHT)
67
+ for i, consecutive in enumerate(row_consecutives):
68
+ self.model.AddAllDifferent([self.model_vars[p] for p in consecutive])
69
+ clue = self.row_sums[row][i]
70
+ if clue != '#':
71
+ self.model.Add(lxp.sum([self.model_vars[p] for p in consecutive]) == self.clue_to_var(clue))
72
+ assert len(self.row_sums[row]) == i + 1, f'row_sums[{row}] has {len(self.row_sums[row])} clues, but {i + 1} consecutive cells'
73
+ for col in range(self.H): # for column clues
74
+ col_consecutives = self.get_consecutives(get_pos(x=col, y=0), Direction.DOWN)
75
+ for i, consecutive in enumerate(col_consecutives):
76
+ self.model.AddAllDifferent([self.model_vars[p] for p in consecutive])
77
+ clue = self.col_sums[col][i]
78
+ if clue != '#':
79
+ self.model.Add(lxp.sum([self.model_vars[p] for p in consecutive]) == self.clue_to_var(clue))
80
+ assert len(self.col_sums[col]) == i + 1, f'col_sums[{col}] has {len(self.col_sums[col])} clues, but {i + 1} consecutive cells'
81
+
82
+ def solve_and_print(self, verbose: bool = True):
83
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
84
+ assignment: dict[Pos, int] = {}
85
+ for pos, var in board.model_vars.items():
86
+ assignment[pos] = solver.Value(var)
87
+ return SingleSolution(assignment=assignment)
88
+ def callback(single_res: SingleSolution):
89
+ print("Solution found")
90
+ print(combined_function(self.V, self.H,
91
+ is_shaded=lambda r, c: self.board[r, c] == '#',
92
+ center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)]),
93
+ text_on_shaded_cells=False
94
+ ))
95
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,76 @@
1
+ from typing import Optional
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_pos, get_row_pos, get_col_pos
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+ from puzzle_solver.core.utils_visualizer import combined_function
9
+
10
+
11
+ def add_opcode_constraint(model: cp_model.CpModel, vlist: list[cp_model.IntVar], opcode: str, result: int):
12
+ assert opcode in ['+', '-', '*', '/'], "Invalid opcode"
13
+ assert opcode not in ['-', '/'] or len(vlist) == 2, f"Opcode '{opcode}' requires exactly 2 variables"
14
+ if opcode == '+':
15
+ model.Add(sum(vlist) == result)
16
+ elif opcode == '*':
17
+ model.AddMultiplicationEquality(result, vlist)
18
+ elif opcode == '-':
19
+ # either vlist[0] - vlist[1] == result OR vlist[1] - vlist[0] == result
20
+ b = model.NewBoolVar('sub_gate')
21
+ model.Add(vlist[0] - vlist[1] == result).OnlyEnforceIf(b)
22
+ model.Add(vlist[1] - vlist[0] == result).OnlyEnforceIf(b.Not())
23
+ elif opcode == '/':
24
+ # either v0 / v1 == result or v1 / v0 == result
25
+ b = model.NewBoolVar('div_gate')
26
+ # Ensure no division by zero
27
+ model.Add(vlist[0] != 0)
28
+ model.Add(vlist[1] != 0)
29
+ # case 1: v0 / v1 == result → v0 == v1 * result
30
+ model.Add(vlist[0] == vlist[1] * result).OnlyEnforceIf(b)
31
+ # case 2: v1 / v0 == result → v1 == v0 * result
32
+ model.Add(vlist[1] == vlist[0] * result).OnlyEnforceIf(b.Not())
33
+
34
+
35
+ class Board:
36
+ def __init__(self, board: np.ndarray, block_results: dict[str, tuple[str, int]], clues: Optional[np.ndarray] = None):
37
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
38
+ assert clues is None or clues.shape == board.shape, f'clues must be 2d, got {clues.shape}'
39
+ assert all((c.item().startswith('d') and c.item()[1:].isdecimal()) for c in np.nditer(board)), "board must contain 'd' prefixed digits"
40
+ block_names = set(c.item() for c in np.nditer(board))
41
+ assert set(block_results.keys()).issubset(block_names), f'block results must contain all block names, {block_names - set(block_results.keys())}'
42
+ self.board = board
43
+ self.clues = clues
44
+ self.V, self.H = board.shape
45
+ self.block_results = {block: (op, result) for block, (op, result) in block_results.items()}
46
+
47
+ self.model = cp_model.CpModel()
48
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
49
+ self.create_vars()
50
+ self.add_all_constraints()
51
+
52
+ def create_vars(self):
53
+ for pos in get_all_pos(self.V, self.H):
54
+ self.model_vars[pos] = self.model.NewIntVar(1, max(self.V, self.H), f'{pos}')
55
+ if self.clues is not None:
56
+ for pos in get_all_pos(self.V, self.H):
57
+ c = get_char(self.clues, pos).strip()
58
+ if int(c) >= 1:
59
+ self.model.Add(self.model_vars[pos] == int(c))
60
+
61
+ def add_all_constraints(self):
62
+ for row in range(self.V): # 1 number per row
63
+ self.model.AddAllDifferent([self.model_vars[pos] for pos in get_row_pos(row, self.H)])
64
+ for col in range(self.H): # 1 number per column
65
+ self.model.AddAllDifferent([self.model_vars[pos] for pos in get_col_pos(col, self.V)])
66
+ for block, (op, result) in self.block_results.items(): # cage op code
67
+ block_vars = [self.model_vars[p] for p in get_all_pos(self.V, self.H) if get_char(self.board, p) == block]
68
+ add_opcode_constraint(self.model, vlist=block_vars, opcode=op, result=result)
69
+
70
+ def solve_and_print(self, verbose: bool = True):
71
+ def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
72
+ return SingleSolution(assignment={pos: solver.Value(board.model_vars[pos]) for pos in get_all_pos(board.V, board.H)})
73
+ def callback(single_res: SingleSolution):
74
+ print("Solution found")
75
+ print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment[get_pos(x=c, y=r)]))
76
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=10)
@@ -0,0 +1,94 @@
1
+ from typing import Optional
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_pos, Direction, in_bounds, get_next_pos
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, or_constraint
8
+ from puzzle_solver.core.utils_visualizer import combined_function
9
+
10
+
11
+ def get_eq_var(model: cp_model.CpModel, a: cp_model.IntVar, b: cp_model.IntVar) -> cp_model.IntVar:
12
+ eq_var = model.NewBoolVar(f'{a}:{b}:eq')
13
+ model.Add(a == b).OnlyEnforceIf(eq_var)
14
+ model.Add(a != b).OnlyEnforceIf(eq_var.Not())
15
+ return eq_var
16
+
17
+
18
+ class Board:
19
+ def __init__(self, board: np.array, horiz_board: np.array, vert_board: np.array, digits: Optional[list[int]] = None):
20
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
21
+ assert all((str(c.item()).strip() == '') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
22
+ self.board = board
23
+ self.V, self.H = board.shape
24
+ assert horiz_board.shape == (self.V, self.H - 1), f'horiz_board must be {(self.V, self.H - 1)}, got {horiz_board.shape}'
25
+ assert all((str(c.item()).strip() == '') or str(c.item()).strip() in ['B', 'W'] for c in np.nditer(horiz_board)), 'horiz_board must contain only space or digits'
26
+ assert vert_board.shape == (self.V - 1, self.H), f'vert_board must be {(self.V - 1, self.H)}, got {vert_board.shape}'
27
+ assert all((str(c.item()).strip() == '') or str(c.item()).strip() in ['B', 'W'] for c in np.nditer(vert_board)), 'vert_board must contain only space or digits'
28
+ self.horiz_board = horiz_board
29
+ self.vert_board = vert_board
30
+ if digits is None:
31
+ digits = list(range(1, max(self.V, self.H) + 1))
32
+ assert len(digits) >= max(self.V, self.H), 'digits must be at least as long as the board'
33
+ self.digits = digits
34
+
35
+ self.model = cp_model.CpModel()
36
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
37
+ self.digits_2_1_ratio: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
38
+ self.digits_consecutive: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
39
+ self.create_vars()
40
+ self.add_all_constraints()
41
+
42
+ def create_vars(self):
43
+ allowed_values = cp_model.Domain.FromValues(self.digits)
44
+ for pos in get_all_pos(self.V, self.H): # force clues
45
+ self.model_vars[pos] = self.model.NewIntVarFromDomain(allowed_values, f'{pos}')
46
+ for direction in [Direction.RIGHT, Direction.DOWN]:
47
+ neighbor = get_next_pos(pos, direction)
48
+ if not in_bounds(neighbor, self.V, self.H):
49
+ continue
50
+ self.digits_2_1_ratio[(pos, neighbor)] = self.model.NewBoolVar(f'{pos}:{neighbor}:2_1')
51
+ self.digits_consecutive[(pos, neighbor)] = self.model.NewBoolVar(f'{pos}:{neighbor}:consecutive')
52
+
53
+ def add_all_constraints(self):
54
+ for pos in get_all_pos(self.V, self.H): # force clues
55
+ c = get_char(self.board, pos)
56
+ if not str(c).isdecimal():
57
+ continue
58
+ self.model.Add(self.model_vars[pos] == int(c))
59
+ # all columns and rows are unique
60
+ for row in range(self.V):
61
+ self.model.AddAllDifferent([self.model_vars[get_pos(x=c, y=row)] for c in range(self.H)])
62
+ for col in range(self.H):
63
+ self.model.AddAllDifferent([self.model_vars[get_pos(x=col, y=r)] for r in range(self.V)])
64
+ for p in get_all_pos(self.V, self.H): # force horiz and vert relationships between digits
65
+ for direction in [Direction.RIGHT, Direction.DOWN]:
66
+ neighbor = get_next_pos(p, direction)
67
+ if not in_bounds(neighbor, self.V, self.H):
68
+ continue
69
+ self.setup_aux(p, direction)
70
+ c = get_char(self.horiz_board if direction == Direction.RIGHT else self.vert_board, p)
71
+ if c == 'B': # 2:1 ratio
72
+ self.model.Add(self.digits_2_1_ratio[(p, neighbor)] == 1)
73
+ elif c == 'W': # consecutive
74
+ self.model.Add(self.digits_consecutive[(p, neighbor)] == 1)
75
+ else: # neither
76
+ self.model.Add(self.digits_2_1_ratio[(p, neighbor)] == 0)
77
+ self.model.Add(self.digits_consecutive[(p, neighbor)] == 0)
78
+
79
+ def setup_aux(self, pos: Pos, direction: Direction):
80
+ neighbor = get_next_pos(pos, direction)
81
+ a_plus_one_b = get_eq_var(self.model, self.model_vars[pos] + 1, self.model_vars[neighbor])
82
+ b_plus_one_a = get_eq_var(self.model, self.model_vars[neighbor] + 1, self.model_vars[pos])
83
+ or_constraint(self.model, self.digits_consecutive[(pos, neighbor)], [a_plus_one_b, b_plus_one_a]) # consecutive aux
84
+ a_twice_b = get_eq_var(self.model, self.model_vars[pos], 2 * self.model_vars[neighbor])
85
+ b_twice_a = get_eq_var(self.model, self.model_vars[neighbor], 2 * self.model_vars[pos])
86
+ or_constraint(self.model, self.digits_2_1_ratio[(pos, neighbor)], [a_twice_b, b_twice_a]) # 2:1 ratio aux
87
+
88
+ def solve_and_print(self, verbose: bool = True):
89
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
90
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
91
+ def callback(single_res: SingleSolution):
92
+ print("Solution found")
93
+ print(combined_function(self.V, self.H, center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)])))
94
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=1)
@@ -0,0 +1,58 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+ from ortools.sat.python.cp_model import LinearExpr as lxp
4
+
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, in_bounds, get_next_pos, get_neighbors4, Direction, get_pos
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
+ from puzzle_solver.core.utils_visualizer import combined_function
8
+
9
+
10
+ def laser_out(board: np.array, init_pos: Pos) -> list[Pos]:
11
+ 'laser out in all 4 directions until we hit a wall or out of bounds'
12
+ V, H = board.shape
13
+ result = []
14
+ for direction in Direction:
15
+ cur_pos = init_pos
16
+ while True:
17
+ cur_pos = get_next_pos(cur_pos, direction)
18
+ if not in_bounds(cur_pos, V, H) or get_char(board, cur_pos).strip() != '':
19
+ break
20
+ result.append(cur_pos)
21
+ return result
22
+
23
+
24
+ class Board:
25
+ def __init__(self, board: np.array):
26
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
27
+ assert all((c.item().strip() in ['', 'W']) or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or W or numbers'
28
+ self.board = board
29
+ self.V, self.H = board.shape
30
+
31
+ self.model = cp_model.CpModel()
32
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
33
+ self.create_vars()
34
+ self.add_all_constraints()
35
+
36
+ def create_vars(self):
37
+ for pos in get_all_pos(self.V, self.H):
38
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
39
+
40
+ def add_all_constraints(self):
41
+ for pos in get_all_pos(self.V, self.H): # force N lights touching the number N
42
+ c = get_char(self.board, pos).strip()
43
+ if c not in ['', 'W']:
44
+ self.model.Add(self.model_vars[pos] == 0)
45
+ self.model.Add(lxp.Sum([self.model_vars[p] for p in get_neighbors4(pos, self.V, self.H)]) == int(c))
46
+ else: # not numbered, must be lit
47
+ orthoginals = laser_out(self.board, pos)
48
+ self.model.AddAtLeastOne([self.model_vars[p] for p in orthoginals] + [self.model_vars[pos]])
49
+ for ortho in orthoginals:
50
+ self.model.Add(self.model_vars[ortho] == 0).OnlyEnforceIf(self.model_vars[pos])
51
+
52
+ def solve_and_print(self, verbose: bool = True):
53
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
54
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
55
+ def callback(single_res: SingleSolution):
56
+ print("Solution found")
57
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, center_char=lambda r, c: str(self.board[r, c]).strip()))
58
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,71 @@
1
+ from collections import defaultdict
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+ from ortools.sat.python.cp_model import LinearExpr as lxp
6
+
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors8, get_pos, Direction, get_next_pos, get_opposite_direction, in_bounds
8
+ from puzzle_solver.core.utils_ortools import force_connected_component, generic_solve_all, SingleSolution
9
+ from puzzle_solver.core.utils_visualizer import combined_function
10
+
11
+
12
+ class Board:
13
+ def __init__(self, board: np.array):
14
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
15
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
16
+ self.board = board
17
+ self.V, self.H = board.shape
18
+
19
+ self.model = cp_model.CpModel()
20
+ self.cell_active: dict[Pos, cp_model.IntVar] = {}
21
+ self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
22
+ self.create_vars()
23
+ self.add_all_constraints()
24
+
25
+ def create_vars(self):
26
+ for pos in get_all_pos(self.V, self.H):
27
+ self.cell_active[pos] = self.model.NewBoolVar(f'{pos}')
28
+ for direction in Direction:
29
+ next_pos = get_next_pos(pos, direction)
30
+ opposite_direction = get_opposite_direction(direction)
31
+ if (next_pos, opposite_direction) in self.cell_direction:
32
+ self.cell_direction[(pos, direction)] = self.cell_direction[(next_pos, opposite_direction)]
33
+ elif not in_bounds(next_pos, self.V, self.H):
34
+ self.cell_direction[(pos, direction)] = self.model.NewConstant(0)
35
+ else:
36
+ self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
37
+
38
+ def add_all_constraints(self):
39
+ for pos in get_all_pos(self.V, self.H): # force state
40
+ sum_directions = lxp.Sum([self.cell_direction[(pos, direction)] for direction in Direction])
41
+ self.model.Add(sum_directions == 2).OnlyEnforceIf(self.cell_active[pos])
42
+ self.model.Add(sum_directions == 0).OnlyEnforceIf(self.cell_active[pos].Not())
43
+ c = get_char(self.board, pos).strip() # force clues
44
+ if c:
45
+ self.model.Add(self.cell_active[pos] == 0)
46
+ self.model.Add(lxp.Sum([self.cell_active[n] for n in get_neighbors8(pos, self.V, self.H, include_self=False)]) == int(c))
47
+ def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
48
+ p1, d1 = pd1
49
+ p2, d2 = pd2
50
+ if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
51
+ return True
52
+ if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
53
+ return True
54
+ return False
55
+ force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
56
+
57
+ def solve_and_print(self, verbose: bool = True):
58
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
59
+ assignment: dict[Pos, str] = defaultdict(str)
60
+ for pos in get_all_pos(self.V, self.H):
61
+ for direction in Direction:
62
+ if (pos, direction) in board.cell_direction and solver.Value(board.cell_direction[(pos, direction)]) == 1:
63
+ assignment[pos] += direction.name[0]
64
+ return SingleSolution(assignment=assignment)
65
+ def callback(single_res: SingleSolution):
66
+ print("Solution found")
67
+ print(combined_function(self.V, self.H, show_grid=False,
68
+ special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)],
69
+ center_char=lambda r, c: str(self.board[r, c]).strip())
70
+ )
71
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,91 @@
1
+ from dataclasses import dataclass
2
+ from collections import defaultdict
3
+
4
+ import numpy as np
5
+ from ortools.sat.python import cp_model
6
+
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_pos, shapes_between, Shape
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ShapeOnBoard:
14
+ uid: int
15
+ is_active: cp_model.IntVar
16
+ size: int
17
+ shape: Shape
18
+
19
+
20
+ class Board:
21
+ def __init__(self, board: np.array):
22
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
23
+ self.board = board
24
+ self.V, self.H = board.shape
25
+ self.pos_to_char: dict[Pos, str] = {p: get_char(board, p).strip() for p in get_all_pos(self.V, self.H) if get_char(board, p).strip() != ''}
26
+
27
+ self.model = cp_model.CpModel()
28
+ self.all_shapes: list[ShapeOnBoard] = []
29
+ self.pos_to_shapes: dict[Pos, set[ShapeOnBoard]] = defaultdict(set)
30
+ self.create_shapes()
31
+ self.add_all_constraints()
32
+
33
+ def create_shapes(self):
34
+ num_to_pos: dict[tuple[str, int], list[Pos]] = defaultdict(list)
35
+ single_to_pos: list[tuple[str, Pos]] = []
36
+ for p, s in self.pos_to_char.items(): # all cells that arent empty
37
+ if s.isdecimal(): # black cell
38
+ color = 'black'
39
+ N = int(s)
40
+ else: # colored cell
41
+ color = s.split('_')[0]
42
+ N = int(s.split('_')[1])
43
+ if N == 1: # cell with a 1
44
+ single_to_pos.append((color, p))
45
+ else: # cell with a number >= 2
46
+ num_to_pos[(color, N)].append(p)
47
+
48
+ for color, p in single_to_pos: # all cells with a 1
49
+ s = ShapeOnBoard(uid=len(self.all_shapes), is_active=self.model.NewBoolVar(f'{color}_{p}'), size=1, shape=frozenset([p]))
50
+ self.all_shapes.append(s)
51
+ self.pos_to_shapes[p].add(s)
52
+
53
+ for (_, N), plist in num_to_pos.items(): # all cells with a number >= 2
54
+ assert len(plist) % 2 == 0, f'{s} has {len(plist)} positions, must be even'
55
+ for i, pi in enumerate(plist):
56
+ for _j, pj in enumerate(plist[i+1:]): # don't double count
57
+ self.populate_pair(pi, pj, N)
58
+
59
+ def populate_pair(self, pos1: Pos, pos2: Pos, N: int):
60
+ for shape in shapes_between(pos1, pos2, N):
61
+ number_cells_hit = {p for p in shape if p in self.pos_to_char}
62
+ assert number_cells_hit.issuperset({pos1, pos2}), f'Not possible! shape {shape} should always hit pos1 and pos2; this error means there\'s a bug in shapes_between'
63
+ if number_cells_hit != {pos1, pos2}: # shape hit some numbered cells other than pos1 and pos2
64
+ continue
65
+ s = ShapeOnBoard(uid=len(self.all_shapes), is_active=self.model.NewBoolVar(f'{shape}'), size=N, shape=shape)
66
+ self.all_shapes.append(s)
67
+ self.pos_to_shapes[pos1].add(s)
68
+ self.pos_to_shapes[pos2].add(s)
69
+
70
+ def add_all_constraints(self):
71
+ for pos in self.pos_to_char.keys(): # every numbered cell must have exactly one shape active touch it
72
+ shapes_on_pos = [s.is_active for s in self.pos_to_shapes[pos]]
73
+ assert len(shapes_on_pos) >= 1, f'pos {pos} has no shapes on it. No solution possible!!!'
74
+ self.model.AddExactlyOne(shapes_on_pos)
75
+ for s1 in self.all_shapes: # active shapes can't collide
76
+ for s2 in self.all_shapes:
77
+ if s1.uid != s2.uid and s1.shape.intersection(s2.shape):
78
+ self.model.Add(s1.is_active + s2.is_active <= 1)
79
+
80
+ def solve_and_print(self, verbose: bool = True):
81
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
82
+ return SingleSolution(assignment={pos: (s.uid, s.size) for s in self.all_shapes for pos in s.shape if solver.Value(s.is_active) == 1})
83
+ def callback(single_res: SingleSolution):
84
+ print("Solution found")
85
+ arr_dict = {k: v[0] for k, v in single_res.assignment.items()}
86
+ arr_size = {k: v[1] for k, v in single_res.assignment.items()}
87
+ print(combined_function(self.V, self.H,
88
+ cell_flags=id_board_to_wall_fn(np.array([[arr_dict.get(get_pos(x=c, y=r), "") for c in range(self.H)] for r in range(self.V)])),
89
+ center_char=lambda r, c: f'{arr_size.get(get_pos(x=c, y=r), "")}'
90
+ ))
91
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=99)