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
+ 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_char, get_neighbors4, get_row_pos, get_col_pos, get_pos
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
6
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
7
+
8
+
9
+ def _sanity_check(board: np.array): # percolation check
10
+ V, H = board.shape
11
+ visited: set[Pos] = set()
12
+ finished_islands: set[int] = set()
13
+ def dfs(pos: Pos, target_i: int):
14
+ if pos in visited:
15
+ return
16
+ visited.add(pos)
17
+ for neighbor in get_neighbors4(pos, V, H):
18
+ if neighbor in visited:
19
+ continue
20
+ neighbor_i = int(get_char(board, neighbor))
21
+ if neighbor_i == target_i:
22
+ dfs(neighbor, target_i)
23
+ for pos in get_all_pos(V, H):
24
+ if pos in visited:
25
+ continue
26
+ current_i = int(get_char(board, pos))
27
+ assert current_i not in finished_islands, f'island {current_i} already finished'
28
+ dfs(pos, current_i)
29
+ finished_islands.add(current_i)
30
+ assert len(finished_islands) == len(set(board.flatten())), 'board is not connected'
31
+
32
+ class Board:
33
+ def __init__(self, board: np.array, top: np.array, side: np.array):
34
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
35
+ _sanity_check(board)
36
+ self.V, self.H = board.shape
37
+ assert top.ndim == 1 and top.shape[0] == self.H, 'top must be a 1d array of length board width'
38
+ assert side.ndim == 1 and side.shape[0] == self.V, 'side must be a 1d array of length board height'
39
+ assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
40
+ self.board = board
41
+ self.top = top
42
+ self.side = side
43
+ self.aquarium_numbers = set([int(c.item()) for c in np.nditer(board)])
44
+ self.aquariums = {i: [pos for pos in get_all_pos(self.V, self.H) if int(get_char(self.board, pos)) == i] for i in self.aquarium_numbers}
45
+ self.aquariums_exist_in_row: dict[int, set[int]] = {aq_i: set() for aq_i in self.aquarium_numbers}
46
+ for aq_i in self.aquarium_numbers:
47
+ for row in range(self.V):
48
+ if any(pos.y == row for pos in self.aquariums[aq_i]):
49
+ self.aquariums_exist_in_row[aq_i].add(row)
50
+
51
+ self.model = cp_model.CpModel()
52
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
53
+ self.is_aquarium_here: dict[tuple[int, int], cp_model.IntVar] = {} # is the aquarium here?
54
+
55
+ self.create_vars()
56
+ self.add_all_constraints()
57
+
58
+ def create_vars(self):
59
+ for pos in get_all_pos(self.V, self.H):
60
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
61
+ for aq_i in self.aquarium_numbers:
62
+ for row in self.aquariums_exist_in_row[aq_i]:
63
+ self.is_aquarium_here[row, aq_i] = self.model.NewBoolVar(f'{row}:{aq_i}')
64
+
65
+ def add_all_constraints(self):
66
+ for aq_i in self.aquarium_numbers:
67
+ for pos in self.aquariums[aq_i]:
68
+ self.model.Add(self.is_aquarium_here[pos.y, aq_i] == 1).OnlyEnforceIf(self.model_vars[pos])
69
+ # aquarium always start from the bottom
70
+ for aq_i in self.aquarium_numbers:
71
+ for row in self.aquariums_exist_in_row[aq_i]:
72
+ if row + 1 not in self.aquariums_exist_in_row[aq_i]: # (row + 1) is below (row) thus currently (row) is the bottom of the aquarium
73
+ continue
74
+ self.model.Add(self.is_aquarium_here[row + 1, aq_i] == 1).OnlyEnforceIf(self.is_aquarium_here[row, aq_i])
75
+ for row in range(self.V):
76
+ for aq_i in self.aquarium_numbers:
77
+ aq_i_row_pos = [pos for pos in self.aquariums[aq_i] if pos.y == row]
78
+ for pos in aq_i_row_pos:
79
+ # if the aquarium is here, all the squares in the row of this aquarium must be filled
80
+ self.model.Add(self.model_vars[pos] == 1).OnlyEnforceIf(self.is_aquarium_here[row, aq_i])
81
+ # if the aquarium is here, at least one square in the row of this aquarium must be filled
82
+ if len(aq_i_row_pos) > 0:
83
+ self.model.Add(sum([self.model_vars[pos] for pos in aq_i_row_pos]) == len(aq_i_row_pos)).OnlyEnforceIf(self.is_aquarium_here[row, aq_i])
84
+ self.model.Add(sum([self.model_vars[pos] for pos in aq_i_row_pos]) == 0).OnlyEnforceIf(self.is_aquarium_here[row, aq_i].Not())
85
+ # force the top and side constraints
86
+ for col in range(self.H):
87
+ self.model.Add(sum([self.model_vars[pos] for pos in get_col_pos(col, self.V)]) == self.top[col])
88
+ for row in range(self.V):
89
+ self.model.Add(sum([self.model_vars[pos] for pos in get_row_pos(row, self.H)]) == self.side[row])
90
+
91
+ def solve_and_print(self, verbose: bool = True):
92
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
93
+ return SingleSolution(assignment={pos: solver.value(board.model_vars[pos]) for pos in board.model_vars.keys()})
94
+ def callback(single_res: SingleSolution):
95
+ print("Solution found")
96
+ print(combined_function(self.V, self.H, cell_flags=id_board_to_wall_fn(self.board), center_char=lambda r, c: 'O' if single_res.assignment[get_pos(x=c, y=r)] == 1 else ''))
97
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=99)
@@ -0,0 +1,159 @@
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, get_pos, in_bounds, Direction, get_next_pos, get_neighbors4, get_ray
6
+ from puzzle_solver.core.utils_ortools import and_constraint, force_connected_component, generic_solve_all, SingleSolution
7
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
8
+
9
+
10
+ def get_neq_var(model: cp_model.CpModel, a: cp_model.IntVar, b: cp_model.IntVar) -> cp_model.IntVar:
11
+ eq_var = model.NewBoolVar(f'{a}:{b}:eq')
12
+ model.Add(a == b).OnlyEnforceIf(eq_var)
13
+ model.Add(a != b).OnlyEnforceIf(eq_var.Not())
14
+ return eq_var.Not()
15
+
16
+ def enforce_groups_opposite_when(model: cp_model.CpModel, group_a: list[cp_model.IntVar], group_b: list[cp_model.IntVar]):
17
+ gate = model.NewBoolVar(f"gate_opposite_when_b[{group_a}]:{group_b}")
18
+ a0 = group_a[0]
19
+ b0 = group_b[0]
20
+ for v in group_a[1:]: # all A equal
21
+ model.Add(v == a0).OnlyEnforceIf(gate)
22
+ for v in group_b[1:]: # all B equal
23
+ model.Add(v == b0).OnlyEnforceIf(gate)
24
+ model.Add(a0 != b0).OnlyEnforceIf(gate) # A different from B
25
+ return gate
26
+
27
+ class Board:
28
+ def __init__(self, board: np.array, dots: dict[Pos, str]):
29
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
30
+ chars = [c.item().strip() for c in np.nditer(board)]
31
+ assert all(c in ['', 'A', 'C'] or c.isdecimal() or (c[0] == 'O' and c[1:].isdecimal()) for c in chars), 'board must contain only space or A or C or digits or O followed by a number'
32
+ self.board = board
33
+ self.V, self.H = board.shape
34
+ assert all(in_bounds(pos, self.V+1, self.H+1) and v.strip() in ['B', 'W'] for pos, v in dots.items()), 'dots must be a dictionary of Pos to B or W'
35
+ self.dots = dots
36
+
37
+ self.model = cp_model.CpModel()
38
+ self.b: dict[Pos, cp_model.IntVar] = {}
39
+ self.w: dict[Pos, cp_model.IntVar] = {}
40
+
41
+ self.create_vars()
42
+ self.add_all_constraints()
43
+
44
+ def create_vars(self):
45
+ for pos in get_all_pos(self.V, self.H):
46
+ self.b[pos] = self.model.NewBoolVar(f"b[{pos}]")
47
+ self.w[pos] = self.b[pos].Not()
48
+
49
+ def add_all_constraints(self):
50
+ for pos in get_all_pos(self.V, self.H): # For each numbered cell c with value k
51
+ k = str(get_char(self.board, pos)).strip()
52
+ if not k:
53
+ continue
54
+ if k[0] == 'O': # O{number} is a range clue
55
+ k = int(k[1:])
56
+ self.range_clues(pos, k)
57
+ elif k.isdecimal(): # {number} is a number clue
58
+ sum_white_neighbors = lxp.Sum([self.w[p] for p in get_neighbors4(pos, self.V, self.H)])
59
+ sum_black_neighbors = 4 - sum_white_neighbors # cells outside of border are black by default
60
+ self.model.Add(sum_white_neighbors == int(k)).OnlyEnforceIf(self.b[pos])
61
+ self.model.Add(sum_black_neighbors == int(k)).OnlyEnforceIf(self.w[pos])
62
+ elif k == 'A': # A is alien; must be inside fence
63
+ self.model.Add(self.w[pos] == 1)
64
+ elif k == 'C': # C is cactus; must be outside fence
65
+ self.model.Add(self.b[pos] == 1)
66
+ for pos, color in self.dots.items(): # this is the most complex part of the puzzle
67
+ assert color in ['W', 'B'], f'Invalid color: {color}'
68
+ if color == 'W':
69
+ self.add_white_dot_constraints(pos)
70
+ elif color == 'B':
71
+ self.add_black_dot_constraints(pos)
72
+ self.fence_is_single_block()
73
+
74
+ def range_clues(self, pos: Pos, k: int):
75
+ self.model.Add(self.w[pos] == 1) # Force it white
76
+ vis_vars: list[cp_model.IntVar] = []
77
+ for direction in Direction: # Build visibility chains in four direction
78
+ ray = get_ray(pos, direction, self.V, self.H) # cells outward
79
+ for idx in range(len(ray)):
80
+ v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
81
+ and_constraint(self.model, target=v, cs=[self.w[p] for p in ray[:idx+1]])
82
+ vis_vars.append(v)
83
+ self.model.Add(1 + sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
84
+
85
+ def get_2_by_2_block_vars(self, pos: Pos) -> tuple[cp_model.IntVar, ...]:
86
+ # returns: the following 2x2 Y's along with the 8 X's
87
+ # . X X .
88
+ # X Y Y X
89
+ # X Y Y X
90
+ # . X X .
91
+ br = pos
92
+ bl = get_next_pos(br, Direction.LEFT)
93
+ tr = get_next_pos(br, Direction.UP)
94
+ tl = get_next_pos(tr, Direction.LEFT)
95
+ tl_v = self.b.get(tl, self.model.NewConstant(1))
96
+ tr_v = self.b.get(tr, self.model.NewConstant(1))
97
+ bl_v = self.b.get(bl, self.model.NewConstant(1))
98
+ br_v = self.b.get(br, self.model.NewConstant(1))
99
+ tl_u_v = self.b.get(get_next_pos(tl, Direction.UP), self.model.NewConstant(1))
100
+ tl_l_v = self.b.get(get_next_pos(tl, Direction.LEFT), self.model.NewConstant(1))
101
+ tr_u_v = self.b.get(get_next_pos(tr, Direction.UP), self.model.NewConstant(1))
102
+ tr_r_v = self.b.get(get_next_pos(tr, Direction.RIGHT), self.model.NewConstant(1))
103
+ bl_l_v = self.b.get(get_next_pos(bl, Direction.LEFT), self.model.NewConstant(1))
104
+ bl_d_v = self.b.get(get_next_pos(bl, Direction.DOWN), self.model.NewConstant(1))
105
+ br_d_v = self.b.get(get_next_pos(br, Direction.DOWN), self.model.NewConstant(1))
106
+ br_r_v = self.b.get(get_next_pos(br, Direction.RIGHT), self.model.NewConstant(1))
107
+ return tl_v, tr_v, bl_v, br_v, tl_u_v, tl_l_v, tr_u_v, tr_r_v, bl_l_v, bl_d_v, br_d_v, br_r_v
108
+
109
+ def add_white_dot_constraints(self, pos: Pos):
110
+ # for the main 2x2 block, either two horizontal 1x2 rectangles on top of each other or two vertical 1x2 rectangles next to each other
111
+ tl, tr, bl, br, tl_u, tl_l, tr_u, tr_r, bl_l, bl_d, br_d, br_r = self.get_2_by_2_block_vars(pos)
112
+ # if the horizontal variant, then at least one of the X's on the left/right must be different
113
+ # horizontal variant will need at least one of these to be active
114
+ horiz_different = get_neq_var(self.model, tl, tl_l) + get_neq_var(self.model, tr, tr_r) + get_neq_var(self.model, bl, bl_l) + get_neq_var(self.model, br, br_r)
115
+ # if the vertical variant, then at least one of the X's on the top/bottom must be different
116
+ # vertical variant will need at least one of these to be active
117
+ vert_different = get_neq_var(self.model, tl, tl_u) + get_neq_var(self.model, tr, tr_u) + get_neq_var(self.model, bl, bl_d) + get_neq_var(self.model, br, br_d)
118
+ horiz_variant_gate = enforce_groups_opposite_when(self.model, [tl, tr], [bl, br])
119
+ self.model.Add(horiz_different >= 1).OnlyEnforceIf(horiz_variant_gate)
120
+ vert_variant_gate = enforce_groups_opposite_when(self.model, [tl, bl], [tr, br])
121
+ self.model.Add(vert_different >= 1).OnlyEnforceIf(vert_variant_gate)
122
+ self.model.AddBoolOr([horiz_variant_gate, vert_variant_gate])
123
+
124
+ def add_black_dot_constraints(self, pos: Pos):
125
+ # in the 2x2 block, one block is X and the other 3 are ~X
126
+ tl, tr, bl, br, tl_u, tl_l, tr_u, tr_r, bl_l, bl_d, br_d, br_r = self.get_2_by_2_block_vars(pos)
127
+ # that one the is X must also have it's 2 corresponding outward neighbors also be X
128
+ gate_1 = enforce_groups_opposite_when(self.model, [tl, tl_u, tl_l], [tr, bl, br, bl_l, tr_u]) # V1: block tl is the one that is different from the other 3
129
+ gate_2 = enforce_groups_opposite_when(self.model, [tr, tr_u, tr_r], [tl, bl, br, tl_u, br_r]) # V2: block tr is the one that is different from the other 3
130
+ gate_3 = enforce_groups_opposite_when(self.model, [bl, bl_l, bl_d], [tl, tr, br, tl_l, br_d]) # V3: block bl is the one that is different from the other 3
131
+ gate_4 = enforce_groups_opposite_when(self.model, [br, br_d, br_r], [tl, tr, bl, bl_d, tr_r]) # V4: block br is the one that is different from the other 3
132
+ self.model.AddBoolOr([gate_1, gate_2, gate_3, gate_4])
133
+
134
+ def fence_is_single_block(self):
135
+ # inside the fence, all cells must be connected
136
+ force_connected_component(self.model, self.w)
137
+ # outside the fence, all cells must be connected, + outside border is considered black + outside the fence must touch the border otherwise 'outside the fence' is completely enclosed by the fence which is invalid
138
+ def is_outside_neighbor(p1: Pos, p2: Pos) -> bool:
139
+ if abs(p1.x - p2.x) + abs(p1.y - p2.y) == 1: # manhattan distance is 1
140
+ return True
141
+ # both are on the border
142
+ p1_on_border = p1.x == 0 or p1.x == self.H - 1 or p1.y == 0 or p1.y == self.V - 1
143
+ p2_on_border = p2.x == 0 or p2.x == self.H - 1 or p2.y == 0 or p2.y == self.V - 1
144
+ return p1_on_border and p2_on_border
145
+ b_aug = self.b.copy()
146
+ # add a single fake cell on the outside
147
+ b_aug[get_pos(x=-1, y=0)] = self.model.NewConstant(1)
148
+ force_connected_component(self.model, b_aug, is_neighbor=is_outside_neighbor)
149
+
150
+ def solve_and_print(self, verbose: bool = True):
151
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
152
+ return SingleSolution(assignment={pos: solver.Value(board.w[pos]) for pos in get_all_pos(board.V, board.H)})
153
+ def callback(single_res: SingleSolution):
154
+ print("Solution:")
155
+ print(combined_function(self.V, self.H,
156
+ 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)]), border_is_wall=False, border_is=1),
157
+ center_char=lambda r, c: self.board[r, c].strip(),
158
+ ))
159
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,139 @@
1
+ from dataclasses import dataclass
2
+ from collections import defaultdict
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+ from ortools.sat.python import cp_model
7
+ from ortools.sat.python.cp_model import LinearExpr as lxp
8
+
9
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors8, get_row_pos, get_col_pos, get_pos, in_bounds
10
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
11
+ from puzzle_solver.core.utils_visualizer import combined_function
12
+
13
+
14
+ @dataclass
15
+ class Ship:
16
+ is_active: cp_model.IntVar
17
+ length: int
18
+ top_left_pos: Pos
19
+ body: set[Pos]
20
+ water: set[Pos]
21
+ mid_body: set[Pos]
22
+ top_tip: Optional[Pos]
23
+ bottom_tip: Optional[Pos]
24
+ left_tip: Optional[Pos]
25
+ right_tip: Optional[Pos]
26
+
27
+ class Board:
28
+ def __init__(self, board: np.array, top: np.array, side: np.array, ship_counts: dict[int, int]):
29
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
30
+ self.V, self.H = board.shape
31
+ assert top.ndim == 1 and top.shape[0] == self.H, 'top must be a 1d array of length board width'
32
+ assert side.ndim == 1 and side.shape[0] == self.V, 'side must be a 1d array of length board height'
33
+ assert all((str(c.item()) in [' ', 'W', 'O', 'S', 'U', 'D', 'L', 'R'] for c in np.nditer(board))), 'board must contain only spaces, W, O, S, U, D, L, R'
34
+ self.board = board
35
+ self.top = top
36
+ self.side = side
37
+ self.ship_counts = ship_counts
38
+
39
+ self.model = cp_model.CpModel()
40
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
41
+ self.shipyard: list[Ship] = [] # will contain every possible ship based on ship counts
42
+
43
+ self.create_vars()
44
+ self.init_shipyard()
45
+ self.add_all_constraints()
46
+
47
+ def create_vars(self):
48
+ for pos in get_all_pos(self.V, self.H):
49
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}:is_ship')
50
+
51
+ def get_ship(self, pos: Pos, length: int, orientation: str) -> Optional[Ship]:
52
+ if length == 1:
53
+ body = {pos}
54
+ top_tip = None
55
+ bottom_tip = None
56
+ left_tip = None
57
+ right_tip = None
58
+ elif orientation == 'horizontal':
59
+ body = set(get_pos(x=x, y=pos.y) for x in range(pos.x, pos.x + length))
60
+ top_tip = None
61
+ bottom_tip = None
62
+ left_tip = pos
63
+ right_tip = get_pos(x=pos.x + length - 1, y=pos.y)
64
+ elif orientation == 'vertical':
65
+ body = set(get_pos(x=pos.x, y=y) for y in range(pos.y, pos.y + length))
66
+ left_tip = None
67
+ right_tip = None
68
+ top_tip = pos
69
+ bottom_tip = get_pos(x=pos.x, y=pos.y + length - 1)
70
+ else:
71
+ raise ValueError(f'invalid orientation: {orientation}')
72
+ if any(not in_bounds(p, self.V, self.H) for p in body):
73
+ return None
74
+ water = set(p for pos in body for p in get_neighbors8(pos, self.V, self.H)) - body
75
+ mid_body = body - {top_tip, bottom_tip, left_tip, right_tip} if length > 1 else set()
76
+ return Ship(
77
+ is_active=self.model.NewBoolVar(f'{pos}:is_active'), length=length,
78
+ top_left_pos=pos, body=body, mid_body=mid_body, water=water,
79
+ top_tip=top_tip, bottom_tip=bottom_tip, left_tip=left_tip, right_tip=right_tip,
80
+ )
81
+
82
+ def init_shipyard(self):
83
+ for length in self.ship_counts.keys():
84
+ for pos in get_all_pos(self.V, self.H):
85
+ for orientation in ['horizontal', 'vertical']:
86
+ if length == 1 and orientation == 'vertical': # prevent double counting 1-length ships
87
+ continue
88
+ ship = self.get_ship(pos, length, orientation)
89
+ if ship is not None:
90
+ self.shipyard.append(ship)
91
+
92
+ def add_all_constraints(self):
93
+ # if a ship is active then all its body is active and all its water is inactive
94
+ pos_to_ships: dict[Pos, list[Ship]] = defaultdict(list)
95
+ for ship in self.shipyard:
96
+ for pos in ship.body:
97
+ self.model.Add(self.model_vars[pos] == 1).OnlyEnforceIf(ship.is_active)
98
+ pos_to_ships[pos].append(ship)
99
+ for pos in ship.water:
100
+ self.model.Add(self.model_vars[pos] == 0).OnlyEnforceIf(ship.is_active)
101
+ # if a pos is active then exactly one ship can be placed at that position
102
+ for pos in get_all_pos(self.V, self.H):
103
+ self.model.Add(lxp.Sum([ship.is_active for ship in pos_to_ships[pos]]) == 1).OnlyEnforceIf(self.model_vars[pos])
104
+ # force ship counts
105
+ for length, count in self.ship_counts.items():
106
+ self.model.Add(lxp.Sum([ship.is_active for ship in self.shipyard if ship.length == length]) == count)
107
+ # force the initial board placement
108
+ for pos in get_all_pos(self.V, self.H):
109
+ c = get_char(self.board, pos)
110
+ if c == 'S': # single-length ship
111
+ self.model.Add(lxp.Sum([ship.is_active for ship in self.shipyard if ship.length == 1 and ship.top_left_pos == pos]) == 1)
112
+ elif c == 'W': # water
113
+ self.model.Add(self.model_vars[pos] == 0)
114
+ elif c == 'O': # mid-body of a ship
115
+ self.model.Add(lxp.Sum([ship.is_active for ship in self.shipyard if pos in ship.mid_body]) == 1)
116
+ elif c == 'U': # top tip of a ship
117
+ self.model.Add(lxp.Sum([ship.is_active for ship in self.shipyard if ship.top_tip == pos]) == 1)
118
+ elif c == 'D': # bottom tip of a ship
119
+ self.model.Add(lxp.Sum([ship.is_active for ship in self.shipyard if ship.bottom_tip == pos]) == 1)
120
+ elif c == 'L': # left tip of a ship
121
+ self.model.Add(lxp.Sum([ship.is_active for ship in self.shipyard if ship.left_tip == pos]) == 1)
122
+ elif c == 'R': # right tip of a ship
123
+ self.model.Add(lxp.Sum([ship.is_active for ship in self.shipyard if ship.right_tip == pos]) == 1)
124
+ elif c == ' ': # empty cell
125
+ pass
126
+ else:
127
+ raise ValueError(f'invalid character: {c}')
128
+ for row in range(self.V): # force the top counts
129
+ self.model.Add(lxp.Sum([self.model_vars[p] for p in get_row_pos(row, self.H)]) == self.side[row])
130
+ for col in range(self.H): # force the side counts
131
+ self.model.Add(lxp.Sum([self.model_vars[p] for p in get_col_pos(col, self.V)]) == self.top[col])
132
+
133
+ def solve_and_print(self, verbose: bool = True):
134
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
135
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
136
+ def callback(single_res: SingleSolution):
137
+ print("Solution found")
138
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1))
139
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,98 @@
1
+ from typing import Optional
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, get_row_pos, get_col_pos
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, arith_rows: Optional[np.array] = None, arith_cols: Optional[np.array] = None, force_unique: bool = True, disallow_three: bool = True):
14
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
15
+ assert board.shape[0] % 2 == 0 and board.shape[1] % 2 == 0, f'board must have even number of rows and columns, got {board.shape[0]}x{board.shape[1]}'
16
+ assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space or B'
17
+ assert arith_rows is None or all(isinstance(c.item(), str) and c.item() in [' ', 'x', '='] for c in np.nditer(arith_rows)), 'arith_rows must contain only space, x, or ='
18
+ assert arith_cols is None or all(isinstance(c.item(), str) and c.item() in [' ', 'x', '='] for c in np.nditer(arith_cols)), 'arith_cols must contain only space, x, or ='
19
+ self.board = board
20
+ self.V, self.H = board.shape
21
+ self.arith_rows = arith_rows
22
+ self.arith_cols = arith_cols
23
+ self.force_unique = force_unique
24
+ self.disallow_three = disallow_three
25
+
26
+ self.model = cp_model.CpModel()
27
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
28
+ self.create_vars()
29
+ self.add_all_constraints()
30
+
31
+ def create_vars(self):
32
+ for pos in get_all_pos(self.V, self.H):
33
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
34
+
35
+ def add_all_constraints(self):
36
+ for pos in get_all_pos(self.V, self.H): # force clues
37
+ c = get_char(self.board, pos).strip()
38
+ if c:
39
+ self.model.Add(self.model_vars[pos] == (c == 'B'))
40
+ # 1. Each row and each column must contain an equal number of white and black circles.
41
+ for row in range(self.V):
42
+ row_vars = [self.model_vars[pos] for pos in get_row_pos(row, self.H)]
43
+ self.model.Add(lxp.sum(row_vars) == len(row_vars) // 2)
44
+ for col in range(self.H):
45
+ col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.V)]
46
+ self.model.Add(lxp.sum(col_vars) == len(col_vars) // 2)
47
+ # 2. No three consecutive cells of the same color
48
+ if self.disallow_three:
49
+ for pos in get_all_pos(self.V, self.H):
50
+ self.disallow_three_in_a_row(pos, Direction.RIGHT)
51
+ self.disallow_three_in_a_row(pos, Direction.DOWN)
52
+ # 3. Each row and column is unique.
53
+ if self.force_unique:
54
+ self.force_unique_double_list([[self.model_vars[pos] for pos in get_row_pos(row, self.H)] for row in range(self.V)])
55
+ self.force_unique_double_list([[self.model_vars[pos] for pos in get_col_pos(col, self.V)] for col in range(self.H)])
56
+ # if arithmetic is provided, add constraints for it
57
+ if self.arith_rows is not None:
58
+ self.force_arithmetic(self.arith_rows, Direction.RIGHT, self.V, self.H-1)
59
+ if self.arith_cols is not None:
60
+ self.force_arithmetic(self.arith_cols, Direction.DOWN, self.V-1, self.H)
61
+
62
+ def disallow_three_in_a_row(self, p1: Pos, direction: Direction):
63
+ p2 = get_next_pos(p1, direction)
64
+ p3 = get_next_pos(p2, direction)
65
+ if all(in_bounds(p, self.V, self.H) for p in [p1, p2, p3]):
66
+ self.model.AddBoolOr([self.model_vars[p1], self.model_vars[p2], self.model_vars[p3]])
67
+ self.model.AddBoolOr([self.model_vars[p1].Not(), self.model_vars[p2].Not(), self.model_vars[p3].Not()])
68
+
69
+ def force_unique_double_list(self, model_vars: list[list[cp_model.IntVar]]):
70
+ m = len(model_vars[0])
71
+ assert m <= 61, f'Too many cells for binary encoding in int64: m={m}, model_vars={model_vars}'
72
+ codes = []
73
+ pow2 = [2**k for k in range(m)]
74
+ for i, line in enumerate(model_vars):
75
+ code = self.model.NewIntVar(0, 2**m, f"code_{i}")
76
+ self.model.Add(code == lxp.weighted_sum(line, pow2)) # Sum 2^k * r[k] == code
77
+ codes.append(code)
78
+ self.model.AddAllDifferent(codes)
79
+
80
+ def force_arithmetic(self, arith_board: np.array, direction: Direction, V: int, H: int):
81
+ assert arith_board.shape == (V, H), f'arith_board going {direction} expected shape {V}x{H}, got {arith_board.shape}'
82
+ for pos in get_all_pos(V, H):
83
+ c = get_char(arith_board, pos).strip()
84
+ if c == 'x':
85
+ self.model.Add(self.model_vars[pos] != self.model_vars[get_next_pos(pos, direction)])
86
+ elif c == '=':
87
+ self.model.Add(self.model_vars[pos] == self.model_vars[get_next_pos(pos, direction)])
88
+
89
+ def solve_and_print(self, verbose: bool = True):
90
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
91
+ assignment: dict[Pos, int] = {}
92
+ for pos, var in board.model_vars.items():
93
+ assignment[pos] = solver.Value(var)
94
+ return SingleSolution(assignment=assignment)
95
+ def callback(single_res: SingleSolution):
96
+ print("Solution found")
97
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1))
98
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,7 @@
1
+ import numpy as np
2
+
3
+ from . import binairo
4
+
5
+ class Board(binairo.Board):
6
+ def __init__(self, board: np.array, arith_rows: np.array, arith_cols: np.array):
7
+ super().__init__(board=board, arith_rows=arith_rows, arith_cols=arith_cols, force_unique=False)