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,97 @@
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 Direction, Pos, get_all_pos, get_char, get_next_pos, get_opposite_direction, get_pos, in_bounds
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
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() in [' ', '#']) or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
16
+ number_set = set(int(c.item()) for c in np.nditer(board) if str(c.item()).isdecimal())
17
+ self.N = max(number_set)
18
+ assert number_set == set(range(1, self.N + 1)), 'numbers must be consecutive integers starting from 1'
19
+ self.board = board
20
+ self.V, self.H = board.shape
21
+ self.fixed_pos: dict[Pos, int] = {pos: int(get_char(self.board, pos).strip()) for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos).strip() not in ['', '#']}
22
+ self.board_char: dict[Pos, str] = {pos: get_char(self.board, pos).strip() for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos).strip() != '#'}
23
+
24
+ self.model = cp_model.CpModel()
25
+ self.model_vars: dict[tuple[Pos, int], cp_model.IntVar] = {}
26
+ self.create_vars()
27
+ self.add_all_constraints()
28
+
29
+ def create_vars(self):
30
+ for pos in self.board_char:
31
+ for direction in Direction:
32
+ next_pos = get_next_pos(pos, direction)
33
+ opposite_direction = get_opposite_direction(direction)
34
+ if not in_bounds(next_pos, self.V, self.H):
35
+ continue
36
+ if get_char(self.board, next_pos).strip() == '#':
37
+ continue
38
+ if (next_pos, opposite_direction) in self.model_vars:
39
+ self.model_vars[(pos, direction)] = self.model_vars[(next_pos, opposite_direction)]
40
+ else:
41
+ self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
42
+
43
+ def is_neighbor(self, pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
44
+ p1, d1 = pd1
45
+ p2, d2 = pd2
46
+ if p1 == p2:
47
+ return True
48
+ p1_pointing_to_p2 = get_next_pos(p1, d1) == p2
49
+ if not p1_pointing_to_p2:
50
+ return False
51
+ is_fixed = d2 == 'FIXED_NODE' # pointing to a fixed node
52
+ return is_fixed or d2 == get_opposite_direction(d1)
53
+
54
+ def add_all_constraints(self):
55
+ for pos, c in self.fixed_pos.items():
56
+ target = 1 if (c == self.N) or (c == 1) else 2
57
+ self.model.Add(lxp.Sum([var for (p, _), var in self.model_vars.items() if p == pos]) == target)
58
+ for pos in self.board_char:
59
+ if pos in self.fixed_pos:
60
+ continue
61
+ self.model.Add(lxp.Sum([var for (p, _), var in self.model_vars.items() if p == pos]) == 2)
62
+ force_connected_component(self.model, self.model_vars, is_neighbor=self.is_neighbor)
63
+ self.implement_height_constraints()
64
+
65
+ def implement_height_constraints(self):
66
+ """Every node has a height equal to every other non-fixed node that is connected to it. or equal to (+0 or +1) of the height of a fixed node it is connected to"""
67
+ nodes = [(k, v) for k, v in self.model_vars.items() if k[0] not in self.fixed_pos] # filter out fixed positions
68
+ fixed_nodes = [(pos, 'FIXED_NODE') for pos in self.fixed_pos]
69
+ node_heights = {(pos, 'FIXED_NODE'): self.model.NewConstant(c) for pos, c in self.fixed_pos.items()}
70
+ for k, v in nodes:
71
+ node_heights[k] = self.model.NewIntVar(0, self.N, f'node_height[{k}]')
72
+ self.model.Add(node_heights[k] == 0).OnlyEnforceIf(v.Not())
73
+ for (k, v) in nodes:
74
+ h = node_heights[k]
75
+ connected_nodes = [(k2, v2) for k2, v2 in nodes if self.is_neighbor(k, k2)]
76
+ for (k2, v2) in connected_nodes: # all pairs of non-fixed nodes that are connected to each other must have the same height
77
+ self.model.Add(h == node_heights[k2]).OnlyEnforceIf([v, v2])
78
+ connected_fixed_nodes = [node_heights[k2] for k2 in fixed_nodes if self.is_neighbor(k, k2)]
79
+ for h2 in connected_fixed_nodes: # connected to fixed node must have height of fixed node or 1 higher
80
+ zero_or_one = self.model.NewBoolVar(f'zero_or_one[{k}]') # node with height h can be connected to fixed node with height h or h+1
81
+ self.model.Add(zero_or_one == 0).OnlyEnforceIf([v.Not()])
82
+ self.model.Add(h == h2 + zero_or_one).OnlyEnforceIf([v])
83
+
84
+ def solve_and_print(self, verbose: bool = True):
85
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
86
+ assignment: dict[Pos, str] = defaultdict(str)
87
+ for pos in get_all_pos(self.V, self.H):
88
+ for direction in Direction:
89
+ if (pos, direction) in board.model_vars and solver.Value(board.model_vars[(pos, direction)]) == 1:
90
+ assignment[pos] += direction.name[0]
91
+ return SingleSolution(assignment=assignment)
92
+ def callback(single_res: SingleSolution):
93
+ print("Solution found")
94
+ print(combined_function(self.V, self.H, show_border_only=True, is_shaded=lambda r, c: get_char(self.board, get_pos(x=c, y=r)).strip() == '#',
95
+ special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] if get_pos(x=c, y=r) in single_res.assignment else None,
96
+ center_char=lambda r, c: get_char(self.board, get_pos(x=c, y=r)).strip().replace('#', '')))
97
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,130 @@
1
+ from dataclasses import dataclass
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_neighbors4, get_pos, in_bounds, get_char, polyominoes, Shape, Direction, get_next_pos
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
8
+ from puzzle_solver.core.utils_visualizer import combined_function
9
+
10
+
11
+ @dataclass
12
+ class ShapeOnBoard:
13
+ is_active: cp_model.IntVar
14
+ N: int
15
+ body: set[Pos]
16
+ force_water: set[Pos]
17
+
18
+
19
+ class Board:
20
+ def __init__(self, board: np.array):
21
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
22
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() or c.item() == '#' for c in np.nditer(board)), 'board must contain only space, #, or digits'
23
+ self.board = board
24
+ self.V, self.H = board.shape
25
+ self.illegal_positions: set[Pos] = {pos for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos) == '#'}
26
+
27
+ unique_numbers: set[int] = {int(c) for c in np.nditer(board) if str(c).isdecimal()}
28
+ self.polyominoes: dict[int, set[Shape]] = {n: polyominoes(n) for n in unique_numbers}
29
+ self.hints = {pos: int(get_char(self.board, pos)) for pos in get_all_pos(self.V, self.H) if str(get_char(self.board, pos)).isdecimal()}
30
+ self.all_hint_pos: set[Pos] = set(self.hints.keys())
31
+
32
+ self.model = cp_model.CpModel()
33
+ self.W: dict[Pos, cp_model.IntVar] = {}
34
+ self.B: dict[Pos, cp_model.IntVar] = {}
35
+ self.shapes_on_board: list[ShapeOnBoard] = []
36
+
37
+ self.create_vars()
38
+ self.add_all_constraints()
39
+
40
+ def create_vars(self):
41
+ for pos in self.get_all_legal_pos():
42
+ self.W[pos] = self.model.NewBoolVar(f'W:{pos}')
43
+ self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
44
+ self.model.AddExactlyOne([self.W[pos], self.B[pos]])
45
+
46
+ def get_all_legal_pos(self) -> set[Pos]:
47
+ return {pos for pos in get_all_pos(self.V, self.H) if pos not in self.illegal_positions}
48
+
49
+ def in_bounds_and_legal(self, pos: Pos) -> bool:
50
+ return in_bounds(pos, self.V, self.H) and pos not in self.illegal_positions
51
+
52
+ def add_all_constraints(self):
53
+ for pos in self.W.keys():
54
+ self.model.AddExactlyOne([self.W[pos], self.B[pos]])
55
+
56
+ # init shapes on board for each hint
57
+ for hint_pos, hint_value in self.hints.items():
58
+ hint_shapes = []
59
+ for shape in self.polyominoes[hint_value]:
60
+ hint_single_shape = self.init_shape_on_board(shape, hint_pos, hint_value) # a "single shape" is translated many times
61
+ hint_shapes.extend(hint_single_shape)
62
+ assert len(hint_shapes) > 0, f'no shapes found for hint {hint_pos} with value {hint_value}'
63
+ self.model.AddExactlyOne([s.is_active for s in hint_shapes])
64
+ self.shapes_on_board.extend(hint_shapes)
65
+
66
+ # if no shape is active on the spot then it must be black
67
+ for pos in self.get_all_legal_pos():
68
+ shapes_here = [s for s in self.shapes_on_board if pos in s.body]
69
+ self.model.AddExactlyOne([s.is_active for s in shapes_here] + [self.B[pos]])
70
+
71
+ # if a shape is active, then all its body must be white and force water must be black
72
+ for shape_on_board in self.shapes_on_board:
73
+ for pos in shape_on_board.body:
74
+ self.model.Add(self.W[pos] == 1).OnlyEnforceIf(shape_on_board.is_active)
75
+ for pos in shape_on_board.force_water:
76
+ self.model.Add(self.B[pos] == 1).OnlyEnforceIf(shape_on_board.is_active)
77
+
78
+ # disallow 2x2 blacks
79
+ for pos in get_all_pos(self.V, self.H):
80
+ tl = pos
81
+ tr = get_next_pos(pos, Direction.RIGHT)
82
+ bl = get_next_pos(pos, Direction.DOWN)
83
+ br = get_next_pos(bl, Direction.RIGHT)
84
+ if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
85
+ continue
86
+ self.model.AddBoolOr([self.B[tl].Not(), self.B[tr].Not(), self.B[bl].Not(), self.B[br].Not()])
87
+
88
+ # all black is single connected component
89
+ force_connected_component(self.model, self.B)
90
+
91
+ def init_shape_on_board(self, shape: Shape, hint_pos: Pos, hint_value: int):
92
+ other_hint_pos: set[Pos] = self.all_hint_pos - {hint_pos}
93
+ max_x = max(p.x for p in shape)
94
+ max_y = max(p.y for p in shape)
95
+ hint_shapes = []
96
+ for dx in range(0, max_x + 1):
97
+ for dy in range(0, max_y + 1):
98
+ body = {get_pos(x=p.x + hint_pos.x - dx, y=p.y + hint_pos.y - dy) for p in shape} # translate shape by fixed hint position then dynamic moving dx and dy
99
+ if hint_pos not in body: # the hint must still be in the body after translation
100
+ continue
101
+ if any(not self.in_bounds_and_legal(p) for p in body): # illegal shape
102
+ continue
103
+ water = set(p for pos in body for p in get_neighbors4(pos, self.V, self.H))
104
+ water -= body
105
+ water -= self.illegal_positions
106
+ if any(p in other_hint_pos for p in body) or any(w in other_hint_pos for w in water): # shape touches another hint or forces water on another hint, illegal
107
+ continue
108
+ shape_on_board = ShapeOnBoard(
109
+ is_active=self.model.NewBoolVar(f'{hint_pos}:{dx}:{dy}:is_active'),
110
+ N=hint_value,
111
+ body=body,
112
+ force_water=water,
113
+ )
114
+ hint_shapes.append(shape_on_board)
115
+ return hint_shapes
116
+
117
+ def solve_and_print(self, verbose: bool = True):
118
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
119
+ assignment: dict[Pos, int] = {}
120
+ for pos, var in board.B.items():
121
+ assignment[pos] = solver.Value(var)
122
+ return SingleSolution(assignment=assignment)
123
+ def callback(single_res: SingleSolution):
124
+ print("Solution found")
125
+ print(combined_function(self.V, self.H,
126
+ is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
127
+ center_char=lambda r, c: str(self.board[r, c]),
128
+ text_on_shaded_cells=False
129
+ ))
130
+ 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_pos, in_bounds, get_next_pos, Direction, polyominoes
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
+ Shape = frozenset[Pos] # a shape on the 2d board is just a set of positions
13
+
14
+ @dataclass(frozen=True)
15
+ class ShapeOnBoard:
16
+ is_active: cp_model.IntVar
17
+ shape: Shape
18
+ shape_id: int
19
+ body: set[Pos]
20
+
21
+
22
+ def get_valid_translations(shape: Shape, board: np.array) -> set[Pos]:
23
+ # give a shape and a board, return all valid translations of the shape that are fully contained in the board AND consistent with the clues on the board
24
+ shape_list = list(shape)
25
+ shape_borders = [] # will contain the number of borders for each pos in the shape; this has to be consistent with the clues on the board
26
+ for pos in shape_list:
27
+ v = 0
28
+ for direction in Direction:
29
+ next_pos = get_next_pos(pos, direction)
30
+ if not in_bounds(next_pos, board.shape[0], board.shape[1]) or next_pos not in shape:
31
+ v += 1
32
+ shape_borders.append(v)
33
+ shape_list = [(p.x, p.y) for p in shape_list]
34
+ # min x/y is always 0
35
+ max_x = max(p[0] for p in shape_list)
36
+ max_y = max(p[1] for p in shape_list)
37
+ for dy in range(0, board.shape[0] - max_y):
38
+ for dx in range(0, board.shape[1] - max_x):
39
+ body = tuple((p[0] + dx, p[1] + dy) for p in shape_list)
40
+ for i, p in enumerate(body):
41
+ c = board[p[1], p[0]]
42
+ if c != ' ' and c != str(shape_borders[i]): # there is a clue and it doesn't match my translated shape, skip
43
+ break
44
+ else:
45
+ yield frozenset(get_pos(x=p[0], y=p[1]) for p in body)
46
+
47
+
48
+ class Board:
49
+ def __init__(self, board: np.array, region_size: int):
50
+ assert region_size >= 1 and isinstance(region_size, int), 'region_size must be an integer greater than or equal to 1'
51
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
52
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
53
+ self.board = board
54
+ self.V, self.H = board.shape
55
+ self.region_size = region_size
56
+ self.region_count = (self.V * self.H) // self.region_size
57
+ assert self.region_count * self.region_size == self.V * self.H, f'region_size must be a factor of the board size, got {self.region_size} and {self.region_count}'
58
+ self.polyominoes = polyominoes(self.region_size)
59
+
60
+ self.model = cp_model.CpModel()
61
+ self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
62
+ self.pos_to_shapes: dict[Pos, set[ShapeOnBoard]] = defaultdict(set)
63
+ self.create_vars()
64
+ self.add_all_constraints()
65
+
66
+ def create_vars(self):
67
+ for shape in self.polyominoes:
68
+ for body in get_valid_translations(shape, self.board):
69
+ uid = len(self.shapes_on_board)
70
+ shape_on_board = ShapeOnBoard(
71
+ is_active=self.model.NewBoolVar(f'{uid}:is_active'),
72
+ shape=shape, shape_id=uid, body=body
73
+ )
74
+ self.shapes_on_board.append(shape_on_board)
75
+ for pos in body:
76
+ self.pos_to_shapes[pos].add(shape_on_board)
77
+
78
+ def add_all_constraints(self):
79
+ for pos in get_all_pos(self.V, self.H): # each position has exactly one shape active
80
+ self.model.AddExactlyOne(shape.is_active for shape in self.pos_to_shapes[pos])
81
+
82
+ def solve_and_print(self, verbose: bool = True):
83
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
84
+ active_shapes = [shape for shape in board.shapes_on_board if solver.Value(shape.is_active) == 1]
85
+ return SingleSolution(assignment={pos: shape.shape_id for shape in active_shapes for pos in shape.body})
86
+ def callback(single_res: SingleSolution):
87
+ print("Solution found")
88
+ print(combined_function(self.V, self.H,
89
+ cell_flags=id_board_to_wall_fn(np.array([[single_res.assignment[get_pos(x=c, y=r)] for c in range(self.H)] for r in range(self.V)])),
90
+ center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else '·'))
91
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,107 @@
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, in_bounds, Direction, get_next_pos, get_char, get_opposite_direction, get_pos, set_char
8
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
9
+ from puzzle_solver.core.utils_visualizer import combined_function
10
+
11
+
12
+ class Board:
13
+ def __init__(self, board: np.ndarray):
14
+ assert board.ndim == 2 and board.shape[0] > 0 and board.shape[1] > 0, f'board must be 2d, got {board.ndim}'
15
+ assert all(c.item().strip() in ['', 'B', 'W'] for c in np.nditer(board)), f'board must be space, B, or W, got {list(np.nditer(board))}'
16
+ self.V, self.H = board.shape
17
+ self.board = board
18
+ self.model = cp_model.CpModel()
19
+ self.cell_active: dict[Pos, cp_model.IntVar] = {}
20
+ self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
21
+
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"a[{pos}]")
28
+ for direction in Direction:
29
+ self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f"b[{pos}]->({direction.name})")
30
+
31
+ def add_all_constraints(self):
32
+ self.force_direction_constraints()
33
+ self.force_wb_constraints()
34
+ self.force_connected_component()
35
+
36
+ def force_wb_constraints(self):
37
+ for pos in get_all_pos(self.V, self.H):
38
+ c = get_char(self.board, pos)
39
+ if not c.strip():
40
+ continue
41
+ self.model.Add(self.cell_active[pos] == 1) # cell must be active
42
+ if c == 'B': # black circle must be a corner not connected directly to another corner
43
+ self.model.Add(self.cell_direction[(pos, Direction.UP)] != self.cell_direction[(pos, Direction.DOWN)])
44
+ self.model.Add(self.cell_direction[(pos, Direction.LEFT)] != self.cell_direction[(pos, Direction.RIGHT)])
45
+ # must not be connected directly to another corner
46
+ for direction in Direction:
47
+ q = get_next_pos(pos, direction)
48
+ if not in_bounds(q, self.V, self.H):
49
+ continue
50
+ self.model.AddImplication(self.cell_direction[(pos, direction)], self.cell_direction[(q, direction)])
51
+ elif c == 'W': # white circle must be a straight which is connected to at least one corner
52
+ self.model.Add(self.cell_direction[(pos, Direction.UP)] == self.cell_direction[(pos, Direction.DOWN)])
53
+ self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == self.cell_direction[(pos, Direction.RIGHT)])
54
+ # must be connected to at least one corner (i.e. UP-RIGHT or UP-LEFT or DOWN-RIGHT or DOWN-LEFT or RIGHT-UP or RIGHT-DOWN or LEFT-UP or LEFT-DOWN)
55
+ aux_list: list[cp_model.IntVar] = []
56
+ for direction in Direction:
57
+ q = get_next_pos(pos, direction)
58
+ if not in_bounds(q, self.V, self.H):
59
+ continue
60
+ ortho_directions = {Direction.UP, Direction.DOWN, Direction.LEFT, Direction.RIGHT} - {direction, get_opposite_direction(direction)}
61
+ for ortho_direction in ortho_directions:
62
+ aux = self.model.NewBoolVar(f"A[{pos}]<-({q})")
63
+ and_constraint(self.model, target=aux, cs=[self.cell_direction[(q, ortho_direction)], self.cell_direction[(pos, direction)]])
64
+ aux_list.append(aux)
65
+ self.model.Add(lxp.Sum(aux_list) >= 1)
66
+
67
+ def force_direction_constraints(self):
68
+ for pos in get_all_pos(self.V, self.H):
69
+ # cell active means exactly 2 directions are active, cell not active means no directions are active
70
+ s = sum([self.cell_direction[(pos, direction)] for direction in Direction])
71
+ self.model.Add(s == 2).OnlyEnforceIf(self.cell_active[pos])
72
+ self.model.Add(s == 0).OnlyEnforceIf(self.cell_active[pos].Not())
73
+ # X having right means the cell to its right has left and so on for all directions
74
+ for direction in Direction:
75
+ q = get_next_pos(pos, direction)
76
+ if in_bounds(q, self.V, self.H):
77
+ self.model.Add(self.cell_direction[(pos, direction)] == self.cell_direction[(q, get_opposite_direction(direction))])
78
+ else:
79
+ self.model.Add(self.cell_direction[(pos, direction)] == 0)
80
+
81
+ def force_connected_component(self):
82
+ def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
83
+ p1, d1 = pd1
84
+ p2, d2 = pd2
85
+ if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
86
+ return True
87
+ if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
88
+ return True
89
+ return False
90
+ force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
91
+
92
+ def solve_and_print(self, verbose: bool = True):
93
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
94
+ assignment: dict[Pos, str] = defaultdict(str)
95
+ for (pos, direction), var in board.cell_direction.items():
96
+ assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
97
+ return SingleSolution(assignment=assignment)
98
+ def callback(single_res: SingleSolution):
99
+ print("Solution found")
100
+ output_board = np.full((self.V, self.H), '', dtype=str)
101
+ for pos in get_all_pos(self.V, self.H):
102
+ if get_char(self.board, pos) in ['B', 'W']: # if the main board has a white or black pearl, put it in the output
103
+ set_char(output_board, pos, get_char(self.board, pos))
104
+ if not single_res.assignment[pos].strip(): # if the cell does not the line through it, put a dot
105
+ set_char(output_board, pos, '.')
106
+ print(combined_function(self.V, self.H, show_grid=False, special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)], center_char=lambda r, c: output_board[r, c]))
107
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=20)
@@ -0,0 +1,82 @@
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, set_char, get_char, Direction, get_next_pos, get_opposite_direction
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
7
+ from puzzle_solver.core.utils_visualizer import combined_function
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().strip() in ['1', '2L', '2I', '3', '4'] for c in np.nditer(board)), 'board must contain only 1, 2L, 2I, 3, 4. Found:' + str(set(c.item().strip() for c in np.nditer(board)) - set(['1', '2L', '2I', '3', '4']))
14
+ self.board = board
15
+ self.V, self.H = board.shape
16
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
18
+
19
+ self.create_vars()
20
+ self.add_all_constraints()
21
+
22
+ def create_vars(self):
23
+ for pos in get_all_pos(self.V, self.H):
24
+ for direction in Direction:
25
+ mirrored = (get_next_pos(pos, direction), get_opposite_direction(direction))
26
+ if mirrored in self.model_vars:
27
+ self.model_vars[(pos, direction)] = self.model_vars[mirrored]
28
+ else:
29
+ self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
30
+
31
+ def add_all_constraints(self):
32
+ for pos in get_all_pos(self.V, self.H):
33
+ self.force_position(pos, get_char(self.board, pos).strip())
34
+ # single connected component
35
+ self.force_connected_component()
36
+
37
+ def force_connected_component(self):
38
+ def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
39
+ p1, d1 = pd1
40
+ p2, d2 = pd2
41
+ if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
42
+ return True
43
+ if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
44
+ return True
45
+ return False
46
+ force_connected_component(self.model, self.model_vars, is_neighbor=is_neighbor)
47
+
48
+ def force_position(self, pos: Pos, value: str):
49
+ # cells with 1 or 3 or 4 neighbors each only have 1 unique state under rotational symmetry
50
+ # cells with 2 neighbors can either be a straight line (2I) or curved line (2L)
51
+ if value == '1':
52
+ self.model.Add(lxp.sum([self.model_vars[(pos, direction)] for direction in Direction]) == 1)
53
+ elif value == '2L':
54
+ self.model.Add(self.model_vars[(pos, Direction.LEFT)] != self.model_vars[(pos, Direction.RIGHT)])
55
+ self.model.Add(self.model_vars[(pos, Direction.UP)] != self.model_vars[(pos, Direction.DOWN)])
56
+ elif value == '2I':
57
+ self.model.Add(self.model_vars[(pos, Direction.LEFT)] == self.model_vars[(pos, Direction.RIGHT)])
58
+ self.model.Add(self.model_vars[(pos, Direction.UP)] == self.model_vars[(pos, Direction.DOWN)])
59
+ self.model.Add(self.model_vars[(pos, Direction.UP)] != self.model_vars[(pos, Direction.RIGHT)])
60
+ elif value == '3':
61
+ self.model.Add(lxp.sum([self.model_vars[(pos, direction)] for direction in Direction]) == 3)
62
+ elif value == '4':
63
+ self.model.Add(lxp.sum([self.model_vars[(pos, direction)] for direction in Direction]) == 4)
64
+ else:
65
+ raise ValueError(f'invalid value: {value}')
66
+
67
+ def solve_and_print(self, verbose: bool = True):
68
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
69
+ assignment = {}
70
+ for pos in get_all_pos(self.V, self.H):
71
+ assignment[pos] = ''
72
+ for direction in Direction:
73
+ if solver.Value(board.model_vars[(pos, direction)]) == 1:
74
+ assignment[pos] += direction.name[0]
75
+ return SingleSolution(assignment=assignment)
76
+ def callback(single_res: SingleSolution):
77
+ print("Solution found")
78
+ res = np.full((self.V, self.H), ' ', dtype=object)
79
+ for pos in get_all_pos(self.V, self.H):
80
+ set_char(res, pos, single_res.assignment[pos])
81
+ print(combined_function(self.V, self.H, show_grid=False, show_axes=True, special_content=lambda r, c: res[r, c], center_char=lambda r, c: 'O' if len(res[r, c]) == 1 else '')),
82
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,59 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_neighbors4, Direction, get_char, get_ray
5
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
6
+ from puzzle_solver.core.utils_visualizer import combined_function
7
+
8
+
9
+ class Board:
10
+ def __init__(self, clues: np.ndarray):
11
+ assert clues.ndim == 2 and clues.shape[0] > 0 and clues.shape[1] > 0, f'clues must be 2d, got {clues.ndim}'
12
+ assert all(str(i.item()).strip() == '' or str(i.item()).strip().isdecimal() for i in np.nditer(clues)), f'clues must be empty or a decimal number, got {list(np.nditer(clues))}'
13
+ self.V, self.H = clues.shape
14
+ self.clues = clues
15
+
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
+ self.w[pos] = self.b[pos].Not()
27
+
28
+ def add_all_constraints(self):
29
+ self.no_adjacent_blacks()
30
+ self.range_clues()
31
+ force_connected_component(self.model, self.w)
32
+
33
+ def no_adjacent_blacks(self):
34
+ for p in get_all_pos(self.V, self.H):
35
+ for q in get_neighbors4(p, self.V, self.H):
36
+ self.model.Add(self.b[p] + self.b[q] <= 1)
37
+
38
+ def range_clues(self):
39
+ for pos in get_all_pos(self.V, self.H): # For each numbered cell c with value k
40
+ k = str(get_char(self.clues, pos)).strip()
41
+ if not k:
42
+ continue
43
+ self.model.Add(self.w[pos] == 1) # Force it white
44
+ vis_vars: list[cp_model.IntVar] = []
45
+ for direction in Direction: # Build visibility chains in four direction
46
+ ray = get_ray(pos, direction, self.V, self.H) # cells outward
47
+ for idx in range(len(ray)):
48
+ v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
49
+ and_constraint(self.model, target=v, cs=[self.w[p] for p in ray[:idx+1]])
50
+ vis_vars.append(v)
51
+ self.model.Add(1 + sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
52
+
53
+ def solve_and_print(self, verbose: bool = True):
54
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
55
+ return SingleSolution(assignment={pos: solver.Value(board.b[pos]) for pos in get_all_pos(board.V, board.H)})
56
+ def callback(single_res: SingleSolution):
57
+ print("Solution:")
58
+ 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: self.clues[r, c].strip(), text_on_shaded_cells=False))
59
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)