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,232 @@
1
+ from collections import Counter, defaultdict
2
+ from itertools import product
3
+
4
+ import numpy as np
5
+
6
+
7
+ class Board:
8
+ def __init__(self, num_pegs: int = 4, all_colors: tuple[str] = ('R', 'Y', 'G', 'B', 'O', 'P'), show_warnings: bool = True, show_progress: bool = False):
9
+ assert num_pegs >= 1, 'num_pegs must be at least 1'
10
+ assert len(all_colors) == len(set(all_colors)), 'all_colors must contain only unique colors'
11
+ self.previous_guesses = []
12
+ self.num_pegs = num_pegs
13
+ self.all_colors = all_colors
14
+ self.show_progress = show_progress
15
+ self.tqdm = None
16
+ if self.show_progress:
17
+ try:
18
+ from tqdm import tqdm
19
+ self.tqdm = tqdm
20
+ except ImportError:
21
+ print('tqdm is not installed, so progress bar will not be shown')
22
+ self.tqdm = None
23
+ self.possible_triplets = set((i, j, num_pegs-i-j) for i in range(num_pegs+1) for j in range(num_pegs+1-i))
24
+ int_to_color = {i: c for i, c in enumerate(self.all_colors)}
25
+ c = len(self.all_colors)**num_pegs
26
+ if c > 10**5 and show_warnings:
27
+ print(f'Warning: len(all_colors)**num_pegs is too large (= {c:,}). The solver may take infinitely long to run.')
28
+ self.all_possible_pegs = tuple({(i, int_to_color[int_]) for i, int_ in enumerate(ints)} for ints in product(range(len(self.all_colors)), repeat=num_pegs))
29
+
30
+ def add_guess(self, guess: tuple[tuple[int, str]], guess_result: tuple[int, int, int]):
31
+ assert len(guess) == self.num_pegs, 'previous guess must have the same number of pegs as the game'
32
+ assert not set(guess) - set(self.all_colors), f'previous guess must contain only colors in all_colors; invalid colors: {set(guess) - set(self.all_colors)}'
33
+ assert sum(guess_result) == self.num_pegs, 'guess result must sum to num_pegs'
34
+ self.previous_guesses.append((guess, guess_result))
35
+
36
+ def get_possible_ground_truths(self):
37
+ """
38
+ Returns the possible ground truths based on the previous guesses.
39
+ """
40
+ previous_guesses = self.previous_guesses
41
+ all_possible_pegs = self.all_possible_pegs
42
+ possible_triplets = self.possible_triplets
43
+ if self.tqdm is not None:
44
+ previous_guesses = self.tqdm(previous_guesses, desc='Step 1/2: Filtering possible ground truths')
45
+ # filter possible ground truths based on previous guesses
46
+ pair_mask = np.full((len(all_possible_pegs), ), True, dtype=bool)
47
+ for previous_guess, guess_result in previous_guesses:
48
+ previous_guess = tuple(tuple((i, c) for i, c in enumerate(previous_guess)))
49
+ pairs = np_information_gain(guess=previous_guess, possible_ground_truths=all_possible_pegs, possible_triplets=possible_triplets, return_pairs=True)
50
+ mask = np.all(pairs == guess_result, axis=1)
51
+ pair_mask &= mask
52
+ possible_ground_truths = tuple(all_possible_pegs[i] for i in range(len(all_possible_pegs)) if pair_mask[i])
53
+ return possible_ground_truths
54
+
55
+ def best_next_guess(
56
+ self,
57
+ return_guess_entropy: bool = False,
58
+ verbose: bool = True,
59
+ ):
60
+ """
61
+ Returns the best next guess that would maximize the Shannon entropy of the next guess.
62
+ """
63
+ possible_triplets = self.possible_triplets
64
+ all_possible_pegs = self.all_possible_pegs
65
+ possible_ground_truths = self.get_possible_ground_truths()
66
+ ng = len(possible_ground_truths) # number of possible ground truths
67
+ if ng == 0:
68
+ print('No possible ground truths found. This should not happen in a real game, please check your inputted guesses.')
69
+ return ng, None
70
+ elif ng == 1:
71
+ answer = [c for i, c in sorted(possible_ground_truths[0], key=lambda x: x[0])]
72
+ if verbose:
73
+ print(f'Solution found! The solution is: {answer}')
74
+ return ng, answer
75
+ if verbose:
76
+ print(f'out of {len(all_possible_pegs)} possible ground truths, only {ng} are still possible.')
77
+
78
+ if self.tqdm is not None:
79
+ all_possible_pegs = self.tqdm(all_possible_pegs, desc='Step 2/2: Calculating entropy for each guess')
80
+ guess_entropy = []
81
+ possible_ground_truths_set = set(tuple((i, c) for i, c in guess) for guess in possible_ground_truths)
82
+ for guess in all_possible_pegs:
83
+ entropy = np_information_gain(guess=guess, possible_ground_truths=possible_ground_truths, possible_triplets=possible_triplets)
84
+ is_possible = tuple(guess) in possible_ground_truths_set
85
+ guess_entropy.append((guess, entropy, is_possible))
86
+ guess_entropy = sorted(guess_entropy, key=lambda x: (x[1], x[2]), reverse=True)
87
+ max_entropy_guess = guess_entropy[0]
88
+ if verbose:
89
+ answer = [c for i, c in sorted(max_entropy_guess[0], key=lambda x: x[0])]
90
+ print(f'max entropy guess is: {answer} with entropy {max_entropy_guess[1]:.4f}')
91
+ if return_guess_entropy:
92
+ return ng, max_entropy_guess, guess_entropy
93
+ else:
94
+ return ng, max_entropy_guess
95
+
96
+
97
+
98
+
99
+ def get_triplets(guess, ground_truth, verbose=False):
100
+ """
101
+ Returns
102
+ 1. Number of guesses that match the color and location
103
+ 2. Number of guesses that match the color but not the location
104
+ 3. Number of guesses that do not match the color or the location
105
+ e.g.
106
+ if guess is ((0, 'Y'), (1, 'R'), (2, 'R'), (3, 'R')) and ground_truth is ((0, 'Y'), (1, 'Y'), (2, 'R'), (3, 'R')), then the triplets are (3, 0, 1)
107
+ if guess is ((0, 'R'), (1, 'Y'), (2, 'Y'), (3, 'Y')) and ground_truth is ((0, 'Y'), (1, 'Y'), (2, 'R'), (3, 'R')), then the triplets are (1, 2, 1)
108
+ """
109
+ color_count = defaultdict(int)
110
+ for _, color in ground_truth:
111
+ color_count[color] += 1
112
+ matching_color_and_location = 0
113
+ matching_color_but_not_location = 0
114
+ not_matching = 0
115
+ done_locs = set()
116
+ for (loc, color) in guess:
117
+ if (loc, color) in ground_truth:
118
+ if verbose:
119
+ print(f'loc {loc} color {color} matched perfectly')
120
+ matching_color_and_location += 1
121
+ color_count[color] -= 1
122
+ done_locs.add(loc)
123
+ for (loc, color) in guess:
124
+ if loc in done_locs:
125
+ continue
126
+ if color_count.get(color, 0) > 0:
127
+ if verbose:
128
+ print(f'loc {loc} color {color} matched but not in the right location')
129
+ matching_color_but_not_location += 1
130
+ color_count[color] -= 1
131
+ else:
132
+ not_matching += 1
133
+ return matching_color_and_location, matching_color_but_not_location, not_matching
134
+
135
+ def slow_information_gain(guess: set[tuple[int, str]], possible_ground_truths: set[set[tuple[int, str]]], possible_triplets: set[tuple[int, int, int]]):
136
+ # safe but slow solution used as a reference
137
+ counts = {triplet: 0 for triplet in possible_triplets}
138
+ for ground_truth in possible_ground_truths:
139
+ counts[tuple(get_triplets(guess, ground_truth))] += 1
140
+ px = {triplet: count / len(possible_ground_truths) for triplet, count in counts.items()}
141
+ entropy = -sum(px[triplet] * np.log2(px[triplet]) for triplet in possible_triplets if px[triplet] > 0)
142
+ # print(counts)
143
+ return entropy
144
+
145
+
146
+ def np_information_gain(guess: tuple[tuple[int, str]], possible_ground_truths: tuple[set[tuple[int, str]]], possible_triplets: set[tuple[int, int, int]], return_pairs: bool = False):
147
+ # my attempt of a vectorized np solution
148
+ n = len(guess)
149
+ all_colors = set()
150
+ for _, color in guess:
151
+ all_colors.add(color)
152
+ for gt in possible_ground_truths:
153
+ for _, color in gt:
154
+ all_colors.add(color)
155
+ guess_mask = {c: np.full((n, 1), 0, dtype=np.int8) for c in all_colors}
156
+ for loc, color in guess:
157
+ guess_mask[color][loc] = 1
158
+ guess_mask_repeated = {c: np.repeat(guess_mask[c].T, len(possible_ground_truths), axis=0) for c in all_colors}
159
+
160
+ color_matrices = {c: np.full((len(possible_ground_truths), n), 0, dtype=np.int8) for c in all_colors}
161
+ for i, gt in enumerate(possible_ground_truths):
162
+ for loc, color in gt:
163
+ color_matrices[color][i, loc] = 1
164
+
165
+ pair_1 = sum(color_matrices[c] @ guess_mask[c] for c in all_colors)
166
+
167
+ pair_2_diff = {c: guess_mask_repeated[c] - color_matrices[c] for c in all_colors}
168
+ pos_mask = {c: pair_2_diff[c] > 0 for c in all_colors}
169
+ pair_2_extra_guess = {c: pair_2_diff[c].copy() for c in all_colors}
170
+ pair_2_extra_ground = {c: pair_2_diff[c].copy() for c in all_colors}
171
+ pair_2 = {}
172
+ for c in all_colors:
173
+ pair_2_extra_guess[c][~pos_mask[c]] = 0
174
+ pair_2_extra_guess[c] = np.sum(pair_2_extra_guess[c], axis=1)
175
+ pair_2_extra_ground[c][pos_mask[c]] = 0
176
+ pair_2_extra_ground[c] = np.abs(np.sum(pair_2_extra_ground[c], axis=1))
177
+ pair_2[c] = np.minimum(pair_2_extra_guess[c], pair_2_extra_ground[c])
178
+
179
+ pair_2 = sum(pair_2[c] for c in all_colors)
180
+ pair_2 = pair_2[:, None]
181
+
182
+ pair_3 = n - pair_1 - pair_2
183
+
184
+ pair = np.concatenate([pair_1, pair_2, pair_3], axis=1)
185
+ pair_counter = Counter(tuple(t) for t in pair)
186
+ counts = {triplet: pair_counter[triplet] for triplet in possible_triplets}
187
+ px = {triplet: count / len(possible_ground_truths) for triplet, count in counts.items()}
188
+ entropy = -sum(px[triplet] * np.log2(px[triplet]) for triplet in possible_triplets if px[triplet] > 0)
189
+ # print(counts)
190
+ if return_pairs:
191
+ return pair
192
+ else:
193
+ return entropy
194
+
195
+
196
+
197
+
198
+
199
+
200
+ def fast_information_gain(guess: set[tuple[int, str]],
201
+ possible_ground_truths: set[set[tuple[int, str]]],
202
+ possible_triplets: set[tuple[int, int, int]]):
203
+ # chatgpt fast solution + many modifications by me
204
+ counts = {t: 0 for t in possible_triplets}
205
+
206
+ for gt in possible_ground_truths:
207
+ color_count = {}
208
+ for _, c in gt:
209
+ color_count[c] = color_count.get(c, 0) + 1
210
+
211
+ H = 0
212
+ for loc, c in guess:
213
+ if (loc, c) in gt:
214
+ H += 1
215
+ color_count[c] -= 1 # safe: gt contributes this occurrence
216
+
217
+ color_only = 0
218
+ for loc, c in guess:
219
+ if (loc, c) in gt:
220
+ continue
221
+ remain = color_count.get(c, 0)
222
+ if remain > 0:
223
+ color_only += 1
224
+ color_count[c] = remain - 1
225
+
226
+ triplet = (H, color_only, len(guess) - H - color_only)
227
+ counts[triplet] += 1
228
+
229
+ px = {triplet: count / len(possible_ground_truths) for triplet, count in counts.items()}
230
+ entropy = -sum(px[triplet] * np.log2(px[triplet]) for triplet in possible_triplets if px[triplet] > 0)
231
+ # print(counts)
232
+ return entropy
@@ -0,0 +1,152 @@
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_col_pos, get_neighbors4, get_pos, get_char, get_row_pos
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
6
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
7
+
8
+
9
+ def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
10
+ """Given a list of integers (mostly with duplicates), return every consecutive sequence of 3 integer changes.
11
+ i.e. return a list of (begin_idx, end_idx) tuples where for each r=int_list[begin_idx:end_idx] we have r[0]!=r[1] and r[-2]!=r[-1] and len(r)>=3"""
12
+ out = []
13
+ change_indices = [i for i in range(len(int_list) - 1) if int_list[i] != int_list[i+1]]
14
+ # notice how for every subsequence r, the subsequence beginning index is in change_indices and the ending index - 1 is in change_indices
15
+ for i in range(len(change_indices) - 1):
16
+ begin_idx = change_indices[i]
17
+ end_idx = change_indices[i+1] + 1 # we want to include the first number in the third sequence
18
+ if end_idx > len(int_list):
19
+ continue
20
+ out.append((begin_idx, end_idx))
21
+ return out
22
+
23
+
24
+ def get_diagonal(pos1: Pos, pos2: Pos) -> list[Pos]:
25
+ assert pos1 != pos2, 'positions must be different'
26
+ dx = pos1.x - pos2.x
27
+ dy = pos1.y - pos2.y
28
+ assert abs(dx) == abs(dy), 'positions must be on a diagonal'
29
+ sdx = 1 if dx > 0 else -1
30
+ sdy = 1 if dy > 0 else -1
31
+ return [get_pos(x=pos2.x + i*sdx, y=pos2.y + i*sdy) for i in range(abs(dx) + 1)]
32
+
33
+
34
+ class Board:
35
+ def __init__(self, board: np.array, region_to_clue: dict[str, int]):
36
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
37
+ assert all(str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
38
+ self.board = board
39
+ self.V, self.H = board.shape
40
+ self.all_regions: set[int] = {int(c.item()) for c in np.nditer(board)}
41
+ self.region_to_clue = {int(k): v for k, v in region_to_clue.items()}
42
+ assert set(self.region_to_clue.keys()).issubset(self.all_regions), f'extra regions in region_to_clue: {set(self.region_to_clue.keys()) - self.all_regions}'
43
+ self.region_to_pos: dict[int, set[Pos]] = {r: set() for r in self.all_regions}
44
+ for pos in get_all_pos(self.V, self.H):
45
+ rid = int(get_char(self.board, pos))
46
+ self.region_to_pos[rid].add(pos)
47
+
48
+ self.model = cp_model.CpModel()
49
+ self.B: dict[Pos, cp_model.IntVar] = {}
50
+ self.W: dict[Pos, cp_model.IntVar] = {}
51
+
52
+ self.create_vars()
53
+ self.add_all_constraints()
54
+
55
+ def create_vars(self):
56
+ for pos in get_all_pos(self.V, self.H):
57
+ self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
58
+ self.W[pos] = self.model.NewBoolVar(f'W:{pos}')
59
+ self.model.AddExactlyOne([self.B[pos], self.W[pos]])
60
+
61
+ def add_all_constraints(self):
62
+ # Regions with a number should contain black cells matching the number.
63
+ for rid, clue in self.region_to_clue.items():
64
+ self.model.Add(sum([self.B[p] for p in self.region_to_pos[rid]]) == clue)
65
+ # 2 black cells cannot be adjacent horizontally or vertically.
66
+ for pos in get_all_pos(self.V, self.H):
67
+ for neighbor in get_neighbors4(pos, self.V, self.H):
68
+ self.model.AddBoolOr([self.W[pos], self.W[neighbor]])
69
+ # All white cells should be connected in a single group.
70
+ force_connected_component(self.model, self.W)
71
+ # A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
72
+ self.disallow_white_lines_spanning_3_regions()
73
+ # straight diagonal black lines from side wall to horizontal wall are not allowed; because they would disconnect the white cells
74
+ self.disallow_full_black_diagonal()
75
+ # disallow a diagonal black line coming out of a wall of length N then coming back in on the same wall; because it would disconnect the white cells
76
+ self.disallow_zigzag_on_wall()
77
+
78
+ def disallow_white_lines_spanning_3_regions(self):
79
+ # A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
80
+ row_to_region: dict[int, list[int]] = {row: [] for row in range(self.V)}
81
+ col_to_region: dict[int, list[int]] = {col: [] for col in range(self.H)}
82
+ for pos in get_all_pos(self.V, self.H): # must traverse from least to most (both row and col)
83
+ rid = int(get_char(self.board, pos))
84
+ row_to_region[pos.y].append(rid)
85
+ col_to_region[pos.x].append(rid)
86
+ for row_num, row in row_to_region.items():
87
+ for begin_idx, end_idx in return_3_consecutives(row):
88
+ pos_list = [get_pos(x=x, y=row_num) for x in range(begin_idx, end_idx+1)]
89
+ self.model.AddBoolOr([self.B[p] for p in pos_list])
90
+ for col_num, col in col_to_region.items():
91
+ for begin_idx, end_idx in return_3_consecutives(col):
92
+ pos_list = [get_pos(x=col_num, y=y) for y in range(begin_idx, end_idx+1)]
93
+ self.model.AddBoolOr([self.B[p] for p in pos_list])
94
+
95
+ def disallow_full_black_diagonal(self):
96
+ corners_dx_dy = [
97
+ ((0, 0), 1, 1),
98
+ ((self.H-1, 0), -1, 1),
99
+ ((0, self.V-1), 1, -1),
100
+ ((self.H-1, self.V-1), -1, -1),
101
+ ]
102
+ for (corner_x, corner_y), dx, dy in corners_dx_dy:
103
+ for delta in range(1, min(self.V, self.H)):
104
+ pos1 = get_pos(x=corner_x, y=corner_y + delta*dy)
105
+ pos2 = get_pos(x=corner_x + delta*dx, y=corner_y)
106
+ diagonal_line = get_diagonal(pos1, pos2)
107
+ self.model.AddBoolOr([self.W[p] for p in diagonal_line])
108
+
109
+ def disallow_zigzag_on_wall(self):
110
+ for pos in get_row_pos(0, self.H): # top line
111
+ for end_x in range(pos.x + 2, self.H, 2): # end pos is even distance away from start pos
112
+ end_pos = get_pos(x=end_x, y=pos.y)
113
+ dx = end_x - pos.x
114
+ mid_pos = get_pos(x=pos.x + dx//2, y=pos.y + dx//2)
115
+ diag_1 = get_diagonal(pos, mid_pos) # from top wall to bottom triangle tip "\"
116
+ diag_2 = get_diagonal(end_pos, mid_pos) # from bottom triangle tip to top wall "/"
117
+ self.model.AddBoolOr([self.W[p] for p in diag_1 + diag_2])
118
+ for pos in get_row_pos(self.V-1, self.H): # bottom line
119
+ for end_x in range(pos.x + 2, self.H, 2): # end pos is even distance away from start pos
120
+ end_pos = get_pos(x=end_x, y=pos.y)
121
+ dx = end_x - pos.x
122
+ mid_pos = get_pos(x=pos.x + dx//2, y=pos.y - dx//2)
123
+ diag_1 = get_diagonal(pos, mid_pos) # from bottom wall to top triangle tip "/"
124
+ diag_2 = get_diagonal(end_pos, mid_pos) # from top triangle tip to bottom wall "\"
125
+ self.model.AddBoolOr([self.W[p] for p in diag_1 + diag_2])
126
+ for pos in get_col_pos(0, self.V): # left line
127
+ for end_y in range(pos.y + 2, self.V, 2): # end pos is even distance away from start pos
128
+ end_pos = get_pos(x=pos.x, y=end_y)
129
+ dy = end_y - pos.y
130
+ mid_pos = get_pos(x=pos.x + dy//2, y=pos.y + dy//2)
131
+ diag_1 = get_diagonal(pos, mid_pos) # from left wall to right triangle tip "\"
132
+ diag_2 = get_diagonal(end_pos, mid_pos) # from right triangle tip to left wall "/"
133
+ self.model.AddBoolOr([self.W[p] for p in diag_1 + diag_2])
134
+ for pos in get_col_pos(self.H-1, self.V): # right line
135
+ for end_y in range(pos.y + 2, self.V, 2): # end pos is even distance away from start pos
136
+ end_pos = get_pos(x=pos.x, y=end_y)
137
+ dy = end_y - pos.y
138
+ mid_pos = get_pos(x=pos.x - dy//2, y=pos.y + dy//2)
139
+ diag_1 = get_diagonal(pos, mid_pos) # from right wall to left triangle tip "/"
140
+
141
+ def solve_and_print(self, verbose: bool = True, max_solutions: int = 20):
142
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
143
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.B.items()})
144
+ def callback(single_res: SingleSolution):
145
+ print("Solution found")
146
+ print(combined_function(self.V, self.H,
147
+ cell_flags=id_board_to_wall_fn(self.board),
148
+ is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
149
+ center_char=lambda r, c: self.region_to_clue.get(int(self.board[r, c]), ''),
150
+ text_on_shaded_cells=False
151
+ ))
152
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=max_solutions)
@@ -0,0 +1,52 @@
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, get_row_pos, get_col_pos, Direction8, get_ray
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
+ class Board:
11
+ def __init__(self, board: np.array, side: np.array, top: np.array):
12
+ assert (len(side), len(top)) == board.shape, 'side and top must be the same shape as board'
13
+ assert all(c.item().strip() in ['', 'Q', 'W', 'E', 'A', 'D', 'Z', 'X', 'C'] for c in np.nditer(board)), 'board must contain only space or Q, W, E, A, D, Z, X, C'
14
+ self.board = board
15
+ self.side = side
16
+ self.top = top
17
+ self.V, self.H = board.shape
18
+ self.model = cp_model.CpModel()
19
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
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
+ if not get_char(self.board, pos).strip():
26
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
27
+
28
+ def add_all_constraints(self):
29
+ for row in range(self.V): # force side counts
30
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in get_row_pos(row, self.H) if pos in self.model_vars]) == self.side[row])
31
+ for col in range(self.H): # force top counts
32
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in get_col_pos(col, self.V) if pos in self.model_vars]) == self.top[col])
33
+ pos_hit_by_ray = set()
34
+ for pos in get_all_pos(self.V, self.H): # every arrow must point to a star
35
+ c = get_char(self.board, pos).strip()
36
+ if not c:
37
+ continue
38
+ d = {'Q': Direction8.UP_LEFT, 'W': Direction8.UP, 'E': Direction8.UP_RIGHT, 'A': Direction8.LEFT, 'D': Direction8.RIGHT, 'Z': Direction8.DOWN_LEFT, 'X': Direction8.DOWN, 'C': Direction8.DOWN_RIGHT}[c]
39
+ ray = get_ray(pos, d, self.V, self.H)
40
+ pos_hit_by_ray.update(ray)
41
+ self.model.AddAtLeastOne([self.model_vars[pos] for pos in ray if pos in self.model_vars])
42
+ for pos in get_all_pos(self.V, self.H): # every star must have an arrow pointing to it
43
+ if pos not in pos_hit_by_ray and pos in self.model_vars:
44
+ self.model.Add(self.model_vars[pos] == 0)
45
+
46
+ def solve_and_print(self, verbose: bool = True):
47
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
48
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
49
+ def callback(single_res: SingleSolution):
50
+ print("Solution found")
51
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), 0) == 1, center_char=lambda r, c: str(self.board[r, c]), text_on_shaded_cells=False))
52
+ 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 Direction8, Pos, get_all_pos, get_next_pos, get_pos, Direction, in_bounds, get_char
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
6
+ from puzzle_solver.core.utils_visualizer import combined_function
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert all((c.item().strip() == '') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
13
+ self.board = board
14
+ self.V, self.H = board.shape
15
+ self.N = self.V * self.H
16
+
17
+ self.model = cp_model.CpModel()
18
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
19
+ self.step_up: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
20
+ self.is_max: dict[Pos, cp_model.IntVar] = {}
21
+ self.create_vars()
22
+ self.add_all_constraints()
23
+
24
+ def create_vars(self):
25
+ for pos in get_all_pos(self.V, self.H):
26
+ self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}:num')
27
+ self.is_max[pos] = self.model.NewBoolVar(f'{pos}:is_max')
28
+ for direction in Direction8:
29
+ next_pos = get_next_pos(pos, direction)
30
+ if not in_bounds(next_pos, self.V, self.H):
31
+ continue
32
+ self.step_up[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}:step_up')
33
+
34
+ def add_all_constraints(self):
35
+ self.model.AddAllDifferent(list(self.model_vars.values())) # all numbers are unique
36
+ for pos in get_all_pos(self.V, self.H):
37
+ c = get_char(self.board, pos).strip()
38
+ if c:
39
+ self.model.Add(self.model_vars[pos] == int(c))
40
+ self.model.Add(self.model_vars[pos] == self.N).OnlyEnforceIf(self.is_max[pos])
41
+ self.model.Add(self.model_vars[pos] != self.N).OnlyEnforceIf(self.is_max[pos].Not())
42
+ for direction in Direction8:
43
+ next_pos = get_next_pos(pos, direction)
44
+ if not in_bounds(next_pos, self.V, self.H):
45
+ continue
46
+ self.model.Add(self.model_vars[pos] + 1 == self.model_vars[next_pos]).OnlyEnforceIf(self.step_up[(pos, direction)])
47
+ self.model.Add(self.model_vars[pos] + 1 != self.model_vars[next_pos]).OnlyEnforceIf(self.step_up[(pos, direction)].Not())
48
+ # each position has a direction that is +1 and a direction that is -1
49
+ for pos in get_all_pos(self.V, self.H):
50
+ s_plus_one = [self.step_up[(pos, direction)] for direction in Direction8 if (pos, direction) in self.step_up]
51
+ self.model.AddBoolOr(s_plus_one + [self.is_max[pos]])
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(var) for pos, var in board.model_vars.items()})
56
+ def callback(single_res: SingleSolution):
57
+ print("Solution found")
58
+ print(combined_function(self.V, self.H, center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)])))
59
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,121 @@
1
+ from collections import defaultdict
2
+
3
+ import numpy as np
4
+
5
+ from puzzle_solver.core.utils import Direction8, Pos, get_all_pos, get_char, in_bounds, get_next_pos
6
+ from . import tsp
7
+
8
+
9
+ def _jump(board: np.array, pos: Pos, direction: Direction8) -> tuple[Pos, list[Pos]]:
10
+ # jump from pos in direction, return the next position and the positions of the gems that would be achieved (mostly likely None)
11
+ initial_pos = pos
12
+ out = []
13
+ while True:
14
+ next_pos = get_next_pos(pos, direction)
15
+ if not in_bounds(next_pos, board.shape[0], board.shape[1]):
16
+ break
17
+ ch = get_char(board, next_pos)
18
+ if ch == 'W':
19
+ break
20
+ if ch == 'O':
21
+ pos = next_pos
22
+ break
23
+ if ch == 'B': # Note: the ball always starts ontop of an 'O' cell, thus hitting a 'B' is like hitting a 'O'
24
+ pos = next_pos
25
+ break
26
+ if ch == 'M': # WE HIT A MINE
27
+ return None, None
28
+ if ch == 'G':
29
+ pos = next_pos
30
+ out.append(next_pos)
31
+ continue
32
+ if ch == ' ':
33
+ pos = next_pos
34
+ continue
35
+ if pos == initial_pos: # we did not move
36
+ return None, None
37
+ return pos, out
38
+
39
+ def parse_nodes_and_edges(board: np.array):
40
+ "parses the board into a graph where an edge is 1 move, and each gem lists the edges that would get it"
41
+ assert board.ndim == 2, 'board must be 2d'
42
+ assert all(c.item() in [' ', 'W', 'O', 'B', 'M', 'G'] for c in np.nditer(board)), 'board must contain only spaces, W, O, B, M, or G'
43
+ todo_nodes: set[Pos] = set()
44
+ completed_nodes: set[Pos] = set()
45
+ edges_to_direction: dict[tuple[Pos, Pos], Direction8] = {} # edge (u: Pos, v: Pos) -> direction
46
+ gems_to_edges: dict[Pos, list[tuple[Pos, Pos]]] = defaultdict(list) # gem position -> list of edges that would get this gem
47
+ V, H = board.shape
48
+ start_pos = [p for p in get_all_pos(V, H) if get_char(board, p) == 'B']
49
+ assert len(start_pos) == 1, 'board must have exactly one start position'
50
+ start_pos = start_pos[0]
51
+ todo_nodes.add(start_pos)
52
+ while todo_nodes:
53
+ pos = todo_nodes.pop()
54
+ for direction in Direction8:
55
+ next_pos, gems = _jump(board, pos, direction)
56
+ if next_pos is None:
57
+ continue
58
+ e = (pos, next_pos)
59
+ assert e not in edges_to_direction, 'edge already exists'
60
+ edges_to_direction[e] = direction
61
+ if len(gems) > 0:
62
+ for gem in gems:
63
+ assert e not in gems_to_edges[gem], 'edge already in gems_to_edges'
64
+ gems_to_edges[gem].append(e)
65
+ if next_pos not in completed_nodes:
66
+ todo_nodes.add(next_pos)
67
+ completed_nodes.add(pos)
68
+ assert len(gems_to_edges) == len([p for p in get_all_pos(V, H) if get_char(board, p) == 'G']), 'some gems are not reachable'
69
+ edges = set(edges_to_direction.keys())
70
+ return start_pos, edges, edges_to_direction, gems_to_edges
71
+
72
+ def get_moves_from_walk(walk: list[tuple[Pos, Pos]], edges_to_direction: dict[tuple[Pos, Pos], Direction8], verbose: bool = True) -> list[str]:
73
+ direction_to_str = {Direction8.UP: '↑', Direction8.DOWN: '↓', Direction8.LEFT: '←', Direction8.RIGHT: '→', Direction8.UP_LEFT: '↖', Direction8.UP_RIGHT: '↗', Direction8.DOWN_LEFT: '↙', Direction8.DOWN_RIGHT: '↘'}
74
+ for edge in walk:
75
+ assert edge in edges_to_direction, f'edge {edge} not valid yet was in walk'
76
+ walk_directions = [edges_to_direction[edge] for edge in walk]
77
+ walk_directions_printable = [direction_to_str[x] for x in walk_directions]
78
+ if verbose:
79
+ print("number of moves", len(walk_directions))
80
+ for i, direction in enumerate(walk_directions_printable):
81
+ print(f"{direction}", end=' ')
82
+ if i % 10 == 9:
83
+ print()
84
+ print()
85
+ return walk_directions
86
+
87
+ def simulate_moves(board: np.array, moves: list[str]) -> bool:
88
+ V, H = board.shape
89
+ start_pos = [p for p in get_all_pos(V, H) if get_char(board, p) == 'B']
90
+ assert len(start_pos) == 1, 'board must have exactly one start position'
91
+ gems_collected_so_far = set()
92
+ start_pos = start_pos[0]
93
+ current_pos = start_pos
94
+ for move in moves:
95
+ next_pos, gems = _jump(board, current_pos, move)
96
+ if next_pos is None:
97
+ print(f'invalid move {move} from {current_pos}. Either hit a wall (considered illegal here) or a mine (dead)')
98
+ return set() # Running into a mine is fatal. Even if you picked up the last gem in the same move which then hit a mine, the game will count you as dead rather than victorious.
99
+ current_pos = next_pos
100
+ gems_collected_so_far.update(gems)
101
+ return gems_collected_so_far
102
+
103
+
104
+ def is_board_completed(board: np.array, moves: list[str]) -> bool:
105
+ V, H = board.shape
106
+ all_gems = set(p for p in get_all_pos(V, H) if get_char(board, p) == 'G')
107
+ gems_collected = simulate_moves(board, moves)
108
+ assert gems_collected.issubset(all_gems), f'collected gems that are not on the board??? should not happen, {gems_collected - all_gems}'
109
+ return gems_collected == all_gems
110
+
111
+ def solve_optimal_walk(
112
+ start_pos: Pos,
113
+ edges: set[tuple[Pos, Pos]],
114
+ gems_to_edges: defaultdict[Pos, list[tuple[Pos, Pos]]],
115
+ *,
116
+ restarts: int = 1, # try more for harder instances (e.g., 48–128)
117
+ time_limit_ms: int = 1000, # per restart
118
+ seed: int = 0,
119
+ verbose: bool = False
120
+ ) -> list[tuple[Pos, Pos]]:
121
+ return tsp.solve_optimal_walk(start_pos, edges, gems_to_edges, restarts=restarts, time_limit_ms=time_limit_ms, seed=seed, verbose=verbose)