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.
- multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
- multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
- multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
- multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
- puzzle_solver/__init__.py +184 -0
- puzzle_solver/core/utils.py +298 -0
- puzzle_solver/core/utils_ortools.py +333 -0
- puzzle_solver/core/utils_visualizer.py +575 -0
- puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
- puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
- puzzle_solver/puzzles/area_51/area_51.py +159 -0
- puzzle_solver/puzzles/battleships/battleships.py +139 -0
- puzzle_solver/puzzles/binairo/binairo.py +98 -0
- puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
- puzzle_solver/puzzles/black_box/black_box.py +243 -0
- puzzle_solver/puzzles/branches/branches.py +64 -0
- puzzle_solver/puzzles/bridges/bridges.py +104 -0
- puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
- puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
- puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
- puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
- puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
- puzzle_solver/puzzles/clouds/clouds.py +81 -0
- puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
- puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
- puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
- puzzle_solver/puzzles/filling/filling.py +94 -0
- puzzle_solver/puzzles/flip/flip.py +64 -0
- puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
- puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
- puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
- puzzle_solver/puzzles/guess/guess.py +232 -0
- puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
- puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
- puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
- puzzle_solver/puzzles/inertia/inertia.py +121 -0
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
- puzzle_solver/puzzles/inertia/tsp.py +400 -0
- puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
- puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
- puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
- puzzle_solver/puzzles/keen/keen.py +76 -0
- puzzle_solver/puzzles/kropki/kropki.py +94 -0
- puzzle_solver/puzzles/light_up/light_up.py +58 -0
- puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
- puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
- puzzle_solver/puzzles/lits/lits.py +138 -0
- puzzle_solver/puzzles/magnets/magnets.py +96 -0
- puzzle_solver/puzzles/map/map.py +56 -0
- puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
- puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
- puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
- puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
- puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
- puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
- puzzle_solver/puzzles/norinori/norinori.py +96 -0
- puzzle_solver/puzzles/number_path/number_path.py +76 -0
- puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
- puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
- puzzle_solver/puzzles/palisade/palisade.py +91 -0
- puzzle_solver/puzzles/pearl/pearl.py +107 -0
- puzzle_solver/puzzles/pipes/pipes.py +82 -0
- puzzle_solver/puzzles/range/range.py +59 -0
- puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
- puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
- puzzle_solver/puzzles/rooms/rooms.py +75 -0
- puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
- puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
- puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
- puzzle_solver/puzzles/signpost/signpost.py +93 -0
- puzzle_solver/puzzles/singles/singles.py +53 -0
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
- puzzle_solver/puzzles/slant/slant.py +111 -0
- puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
- puzzle_solver/puzzles/snail/snail.py +97 -0
- puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
- puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
- puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
- puzzle_solver/puzzles/stitches/stitches.py +96 -0
- puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
- puzzle_solver/puzzles/suguru/suguru.py +55 -0
- puzzle_solver/puzzles/suko/suko.py +54 -0
- puzzle_solver/puzzles/tapa/tapa.py +97 -0
- puzzle_solver/puzzles/tatami/tatami.py +64 -0
- puzzle_solver/puzzles/tents/tents.py +80 -0
- puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
- puzzle_solver/puzzles/towers/towers.py +89 -0
- puzzle_solver/puzzles/tracks/tracks.py +88 -0
- puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
- puzzle_solver/puzzles/troix/dumplings.py +7 -0
- puzzle_solver/puzzles/troix/troix.py +75 -0
- puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
- puzzle_solver/puzzles/undead/undead.py +130 -0
- puzzle_solver/puzzles/unequal/unequal.py +128 -0
- puzzle_solver/puzzles/unruly/unruly.py +54 -0
- puzzle_solver/puzzles/vectors/vectors.py +94 -0
- puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
- puzzle_solver/puzzles/walls/walls.py +52 -0
- puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
- puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
- puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
- puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
- puzzle_solver/utils/visualizer.py +155 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from ortools.sat.python import cp_model
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_row_pos, get_col_pos, in_bounds, get_opposite_direction, get_next_pos, Direction
|
|
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, top: list, right: list, bottom: list, left: list, ball_count: Optional[tuple[int, int]] = None, max_travel_steps: Optional[int] = None):
|
|
12
|
+
assert len(top) == len(bottom), 'top and bottom must be the same length'
|
|
13
|
+
assert len(left) == len(right), 'left and right must be the same length'
|
|
14
|
+
self.K = len(top) + len(right) + len(bottom) + len(left) # side count
|
|
15
|
+
self.H = len(top)
|
|
16
|
+
self.V = len(left)
|
|
17
|
+
if max_travel_steps is None:
|
|
18
|
+
self.T = self.V * self.H # maximum travel steps for a beam that bounces an undefined number of times
|
|
19
|
+
else:
|
|
20
|
+
self.T = max_travel_steps
|
|
21
|
+
self.ball_count = ball_count
|
|
22
|
+
# top and bottom entry cells are at -1 and V
|
|
23
|
+
self.top_cells = set(get_row_pos(row_idx=-1, H=self.H))
|
|
24
|
+
self.bottom_cells = set(get_row_pos(row_idx=self.V, H=self.H))
|
|
25
|
+
# left and right entry cells are at -1 and H
|
|
26
|
+
self.left_cells = set(get_col_pos(col_idx=-1, V=self.V))
|
|
27
|
+
self.right_cells = set(get_col_pos(col_idx=self.H, V=self.V))
|
|
28
|
+
|
|
29
|
+
self.top_values = top
|
|
30
|
+
self.right_values = right
|
|
31
|
+
self.bottom_values = bottom
|
|
32
|
+
self.left_values = left
|
|
33
|
+
|
|
34
|
+
self.model = cp_model.CpModel()
|
|
35
|
+
self.ball_states: dict[Pos, cp_model.IntVar] = {}
|
|
36
|
+
# (entry_pos, T, cell_pos, direction) -> True if the beam that entered from the board at "entry_pos" is present in "cell_pos" and is going in the direction "direction" at time T
|
|
37
|
+
self.beam_states: dict[tuple[Pos, int, Pos, Direction], cp_model.IntVar] = {}
|
|
38
|
+
# self.beam_states_ending_at: dict[Pos, cp_model.IntVar] = {}
|
|
39
|
+
self.beam_states_at_t: dict[int, dict[Pos, dict[tuple[Pos, Direction], cp_model.IntVar]]] = {}
|
|
40
|
+
self.create_vars()
|
|
41
|
+
print('Total number of variables:', len(self.ball_states), len(self.beam_states))
|
|
42
|
+
self.add_all_constraints()
|
|
43
|
+
print('Solving...')
|
|
44
|
+
|
|
45
|
+
def get_outside_border(self):
|
|
46
|
+
top_border = tuple(get_row_pos(row_idx=-1, H=self.H))
|
|
47
|
+
bottom_border = tuple(get_row_pos(row_idx=self.V, H=self.H))
|
|
48
|
+
left_border = tuple(get_col_pos(col_idx=-1, V=self.V))
|
|
49
|
+
right_border = tuple(get_col_pos(col_idx=self.H, V=self.V))
|
|
50
|
+
return (*top_border, *bottom_border, *left_border, *right_border)
|
|
51
|
+
|
|
52
|
+
def get_all_pos_extended(self):
|
|
53
|
+
return (*self.get_outside_border(), *get_all_pos(self.V, self.H))
|
|
54
|
+
|
|
55
|
+
def create_vars(self):
|
|
56
|
+
for pos in get_all_pos(self.V, self.H):
|
|
57
|
+
self.ball_states[pos] = self.model.NewBoolVar(f'ball_at:{pos}')
|
|
58
|
+
for pos in self.get_all_pos_extended(): # NxN board + 4 edges
|
|
59
|
+
if pos not in self.ball_states: # pos is not in the board -> its on the edge
|
|
60
|
+
self.ball_states[pos] = None # balls can't be on the edge
|
|
61
|
+
|
|
62
|
+
for entry_pos in (self.top_cells | self.right_cells | self.bottom_cells | self.left_cells):
|
|
63
|
+
|
|
64
|
+
for t in range(self.T):
|
|
65
|
+
self.beam_states[(entry_pos, t, 'HIT', 'HIT')] = self.model.NewBoolVar(f'beam:{entry_pos}:{t}:HIT:HIT')
|
|
66
|
+
for cell in self.get_all_pos_extended():
|
|
67
|
+
for direction in Direction:
|
|
68
|
+
self.beam_states[(entry_pos, t, cell, direction)] = self.model.NewBoolVar(f'beam:{entry_pos}:{t}:{cell}:{direction}')
|
|
69
|
+
|
|
70
|
+
for (entry_pos, t, cell, direction) in self.beam_states.keys():
|
|
71
|
+
if t not in self.beam_states_at_t:
|
|
72
|
+
self.beam_states_at_t[t] = {}
|
|
73
|
+
if entry_pos not in self.beam_states_at_t[t]:
|
|
74
|
+
self.beam_states_at_t[t][entry_pos] = {}
|
|
75
|
+
self.beam_states_at_t[t][entry_pos][(cell, direction)] = self.beam_states[(entry_pos, t, cell, direction)]
|
|
76
|
+
|
|
77
|
+
def add_all_constraints(self):
|
|
78
|
+
self.init_beams()
|
|
79
|
+
self.constrain_beam_movement()
|
|
80
|
+
self.constrain_final_beam_states()
|
|
81
|
+
if self.ball_count is not None:
|
|
82
|
+
s = sum([b for b in self.ball_states.values() if b is not None])
|
|
83
|
+
b_min, b_max = self.ball_count
|
|
84
|
+
self.model.Add(s >= b_min)
|
|
85
|
+
self.model.Add(s <= b_max)
|
|
86
|
+
|
|
87
|
+
def init_beams(self):
|
|
88
|
+
beam_ids = []
|
|
89
|
+
beam_ids.extend((beam_id, Direction.DOWN) for beam_id in self.top_cells)
|
|
90
|
+
beam_ids.extend((beam_id, Direction.LEFT) for beam_id in self.right_cells)
|
|
91
|
+
beam_ids.extend((beam_id, Direction.UP) for beam_id in self.bottom_cells)
|
|
92
|
+
beam_ids.extend((beam_id, Direction.RIGHT) for beam_id in self.left_cells)
|
|
93
|
+
|
|
94
|
+
for (beam_id, direction) in beam_ids:
|
|
95
|
+
# beam at t=0 is present at beam_id and facing direction
|
|
96
|
+
self.model.Add(self.beam_states[(beam_id, 0, beam_id, direction)] == 1)
|
|
97
|
+
for p in self.get_all_pos_extended():
|
|
98
|
+
for direction in Direction:
|
|
99
|
+
if (p, direction) != (beam_id, direction):
|
|
100
|
+
self.model.Add(self.beam_states[(beam_id, 0, p, direction)] == 0)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def constrain_beam_movement(self):
|
|
104
|
+
for t in range(self.T):
|
|
105
|
+
for entry_pos in self.beam_states_at_t[t].keys():
|
|
106
|
+
next_state_dict = self.beam_states_at_t[t][entry_pos]
|
|
107
|
+
self.model.AddExactlyOne(list(next_state_dict.values()))
|
|
108
|
+
if t == self.T - 1:
|
|
109
|
+
continue
|
|
110
|
+
for (cell, direction), prev_state in next_state_dict.items():
|
|
111
|
+
self.constrain_next_beam_state(entry_pos, t+1, cell, direction, prev_state)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def constrain_next_beam_state(self, entry_pos: Pos, t: int, cur_pos: Pos, direction: Direction, prev_state: cp_model.IntVar):
|
|
115
|
+
if cur_pos == "HIT": # a beam that was "HIT" stays "HIT"
|
|
116
|
+
self.model.Add(self.beam_states[(entry_pos, t, "HIT", "HIT")] == 1).OnlyEnforceIf(prev_state)
|
|
117
|
+
return
|
|
118
|
+
pos_ahead = get_next_pos(cur_pos, direction)
|
|
119
|
+
if not in_bounds(pos_ahead, self.V, self.H) and not in_bounds(cur_pos, self.V, self.H):
|
|
120
|
+
self.model.Add(self.beam_states[(entry_pos, t, cur_pos, direction)] == 1).OnlyEnforceIf(prev_state)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# look at the 3 balls ahead of the beam: thus 8 possible scenarios
|
|
124
|
+
# A beam with no balls ahead of it -> moves forward in the same direction (1 scenario)
|
|
125
|
+
# A beam that hits a ball head-on -> beam is "HIT" (4 scenarios)
|
|
126
|
+
# A beam with a ball in its front-left square and no ball ahead of it -> gets deflected 90 degrees to the right (1 scenario)
|
|
127
|
+
# A beam with a ball in its front-right square and no ball ahead of it -> gets similarly deflected to the left (1 scenario)
|
|
128
|
+
# A beam that would in its front-left AND front-right squares -> is reflected (1 scenarios)
|
|
129
|
+
|
|
130
|
+
direction_left = {
|
|
131
|
+
Direction.UP: Direction.LEFT,
|
|
132
|
+
Direction.LEFT: Direction.DOWN,
|
|
133
|
+
Direction.DOWN: Direction.RIGHT,
|
|
134
|
+
Direction.RIGHT: Direction.UP,
|
|
135
|
+
}[direction]
|
|
136
|
+
direction_right = {
|
|
137
|
+
Direction.UP: Direction.RIGHT,
|
|
138
|
+
Direction.RIGHT: Direction.DOWN,
|
|
139
|
+
Direction.DOWN: Direction.LEFT,
|
|
140
|
+
Direction.LEFT: Direction.UP,
|
|
141
|
+
}[direction]
|
|
142
|
+
reflected = get_opposite_direction(direction)
|
|
143
|
+
ball_left_pos = get_next_pos(pos_ahead, direction_left)
|
|
144
|
+
ball_right_pos = get_next_pos(pos_ahead, direction_right)
|
|
145
|
+
if in_bounds(pos_ahead, self.V, self.H):
|
|
146
|
+
ball_ahead = self.ball_states[pos_ahead]
|
|
147
|
+
ball_ahead_not = ball_ahead.Not()
|
|
148
|
+
else:
|
|
149
|
+
ball_ahead = False
|
|
150
|
+
ball_ahead_not = True
|
|
151
|
+
if in_bounds(ball_left_pos, self.V, self.H):
|
|
152
|
+
ball_left = self.ball_states[ball_left_pos]
|
|
153
|
+
ball_left_not = ball_left.Not()
|
|
154
|
+
else:
|
|
155
|
+
ball_left = False
|
|
156
|
+
ball_left_not = True
|
|
157
|
+
if in_bounds(ball_right_pos, self.V, self.H):
|
|
158
|
+
ball_right = self.ball_states[ball_right_pos]
|
|
159
|
+
ball_right_not = ball_right.Not()
|
|
160
|
+
else:
|
|
161
|
+
ball_right = False
|
|
162
|
+
ball_right_not = True
|
|
163
|
+
|
|
164
|
+
pos_left = get_next_pos(cur_pos, direction_left)
|
|
165
|
+
pos_right = get_next_pos(cur_pos, direction_right)
|
|
166
|
+
pos_reflected = get_next_pos(cur_pos, reflected)
|
|
167
|
+
if not in_bounds(pos_left, self.V, self.H):
|
|
168
|
+
pos_left = cur_pos
|
|
169
|
+
if not in_bounds(pos_right, self.V, self.H):
|
|
170
|
+
pos_right = cur_pos
|
|
171
|
+
if not in_bounds(pos_reflected, self.V, self.H):
|
|
172
|
+
pos_reflected = cur_pos
|
|
173
|
+
|
|
174
|
+
# ball head-on -> beam is "HIT"
|
|
175
|
+
self.model.Add(self.beam_states[(entry_pos, t, "HIT", "HIT")] == 1).OnlyEnforceIf([
|
|
176
|
+
ball_ahead,
|
|
177
|
+
prev_state,
|
|
178
|
+
])
|
|
179
|
+
# ball in front-left -> beam is deflected right
|
|
180
|
+
self.model.Add(self.beam_states[(entry_pos, t, pos_right, direction_right)] == 1).OnlyEnforceIf([
|
|
181
|
+
ball_ahead_not,
|
|
182
|
+
ball_left,
|
|
183
|
+
ball_right_not,
|
|
184
|
+
prev_state,
|
|
185
|
+
])
|
|
186
|
+
# ball in front-right -> beam is deflected left
|
|
187
|
+
self.model.Add(self.beam_states[(entry_pos, t, pos_left, direction_left)] == 1).OnlyEnforceIf([
|
|
188
|
+
ball_ahead_not,
|
|
189
|
+
ball_left_not,
|
|
190
|
+
ball_right,
|
|
191
|
+
prev_state,
|
|
192
|
+
])
|
|
193
|
+
# ball in front-left and front-right -> beam is reflected
|
|
194
|
+
self.model.Add(self.beam_states[(entry_pos, t, pos_reflected, reflected)] == 1).OnlyEnforceIf([
|
|
195
|
+
ball_ahead_not,
|
|
196
|
+
ball_left,
|
|
197
|
+
ball_right,
|
|
198
|
+
prev_state,
|
|
199
|
+
])
|
|
200
|
+
# no ball ahead -> beam moves forward in the same direction
|
|
201
|
+
self.model.Add(self.beam_states[(entry_pos, t, pos_ahead, direction)] == 1).OnlyEnforceIf([
|
|
202
|
+
ball_ahead_not,
|
|
203
|
+
ball_left_not,
|
|
204
|
+
ball_right_not,
|
|
205
|
+
prev_state,
|
|
206
|
+
])
|
|
207
|
+
|
|
208
|
+
def constrain_final_beam_states(self):
|
|
209
|
+
all_values = []
|
|
210
|
+
all_values.extend([(Pos(x=c, y=-1), top_value) for c, top_value in enumerate(self.top_values)])
|
|
211
|
+
all_values.extend([(Pos(x=self.H, y=c), right_value) for c, right_value in enumerate(self.right_values)])
|
|
212
|
+
all_values.extend([(Pos(x=c, y=self.V), bottom_value) for c, bottom_value in enumerate(self.bottom_values)])
|
|
213
|
+
all_values.extend([(Pos(x=-1, y=c), left_value) for c, left_value in enumerate(self.left_values)])
|
|
214
|
+
digits = {}
|
|
215
|
+
hits = []
|
|
216
|
+
reflects = []
|
|
217
|
+
for pos, value in all_values:
|
|
218
|
+
value = str(value)
|
|
219
|
+
if value.isdecimal():
|
|
220
|
+
digits.setdefault(value, []).append(pos)
|
|
221
|
+
elif value == 'H':
|
|
222
|
+
hits.append(pos)
|
|
223
|
+
elif value == 'R':
|
|
224
|
+
reflects.append(pos)
|
|
225
|
+
else:
|
|
226
|
+
raise ValueError(f'Invalid value: {value}')
|
|
227
|
+
for digit, pos_list in digits.items():
|
|
228
|
+
assert len(pos_list) == 2, f'digit {digit} has {len(pos_list)} positions: {pos_list}'
|
|
229
|
+
p1, p2 = pos_list
|
|
230
|
+
self.model.AddExactlyOne([self.beam_states[(p1, self.T-1, p2, direction)] for direction in Direction])
|
|
231
|
+
self.model.AddExactlyOne([self.beam_states[(p2, self.T-1, p1, direction)] for direction in Direction])
|
|
232
|
+
for hit in hits:
|
|
233
|
+
self.model.AddExactlyOne([self.beam_states[(hit, self.T-1, 'HIT', 'HIT')]])
|
|
234
|
+
for reflect in reflects:
|
|
235
|
+
self.model.AddExactlyOne([self.beam_states[(reflect, self.T-1, reflect, direction)] for direction in Direction])
|
|
236
|
+
|
|
237
|
+
def solve_and_print(self, verbose: bool = True):
|
|
238
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
239
|
+
return SingleSolution(assignment={pos: solver.Value(self.ball_states[pos]) for pos in get_all_pos(self.V, self.H)})
|
|
240
|
+
def callback(single_res: SingleSolution):
|
|
241
|
+
print("Solution found")
|
|
242
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: 'O' if single_res.assignment[get_pos(x=c, y=r)] else ''))
|
|
243
|
+
generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,64 @@
|
|
|
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, Direction, get_next_pos, get_opposite_direction, set_char, get_ray
|
|
6
|
+
from puzzle_solver.core.utils_ortools import and_constraint, 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):
|
|
12
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
13
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
14
|
+
self.board = board
|
|
15
|
+
self.V, self.H = board.shape
|
|
16
|
+
self.model = cp_model.CpModel()
|
|
17
|
+
self.model_vars: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
|
|
18
|
+
self.create_vars()
|
|
19
|
+
self.add_all_constraints()
|
|
20
|
+
|
|
21
|
+
def create_vars(self):
|
|
22
|
+
for pos in get_all_pos(self.V, self.H):
|
|
23
|
+
for direction in Direction:
|
|
24
|
+
self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
|
|
25
|
+
|
|
26
|
+
def add_all_constraints(self):
|
|
27
|
+
for pos in get_all_pos(self.V, self.H):
|
|
28
|
+
dir_vars = [self.model_vars[(pos, direction)] for direction in Direction]
|
|
29
|
+
c = get_char(self.board, pos)
|
|
30
|
+
if not str(c).isdecimal():
|
|
31
|
+
self.model.AddAtMostOne(dir_vars)
|
|
32
|
+
continue
|
|
33
|
+
self.model.Add(lxp.Sum(dir_vars) == 0) # spot with number has to be blank
|
|
34
|
+
self.range_clue(pos, int(c))
|
|
35
|
+
|
|
36
|
+
def range_clue(self, pos: Pos, k: int):
|
|
37
|
+
vis_vars: list[cp_model.IntVar] = []
|
|
38
|
+
for direction in Direction: # Build visibility chains in four direction
|
|
39
|
+
branch_direction = get_opposite_direction(direction)
|
|
40
|
+
ray = get_ray(pos, direction, self.V, self.H) # cells outward
|
|
41
|
+
for idx in range(len(ray)):
|
|
42
|
+
v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
|
|
43
|
+
and_constraint(self.model, target=v, cs=[self.model_vars[(p, branch_direction)] for p in ray[:idx+1]])
|
|
44
|
+
vis_vars.append(v)
|
|
45
|
+
self.model.Add(sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
|
|
46
|
+
|
|
47
|
+
def solve_and_print(self, verbose: bool = True):
|
|
48
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
49
|
+
return SingleSolution(assignment={pos: direction.name[0] for (pos, direction), var in board.model_vars.items() if solver.Value(var) == 1})
|
|
50
|
+
def callback(single_res: SingleSolution):
|
|
51
|
+
print("Solution found")
|
|
52
|
+
d = {'U': Direction.UP, 'D': Direction.DOWN, 'L': Direction.LEFT, 'R': Direction.RIGHT}
|
|
53
|
+
opp_d_char = {'U': 'D', 'D': 'U', 'L': 'R', 'R': 'L'}
|
|
54
|
+
arr = np.full((self.V, self.H), '', dtype=object)
|
|
55
|
+
center_char = np.full((self.V, self.H), '', dtype=object)
|
|
56
|
+
for pos, direction in single_res.assignment.items():
|
|
57
|
+
opp_direction = get_opposite_direction(d[direction])
|
|
58
|
+
if single_res.assignment.get(get_next_pos(pos, opp_direction), '') == direction:
|
|
59
|
+
set_char(arr, pos, direction + opp_d_char[direction])
|
|
60
|
+
else:
|
|
61
|
+
set_char(arr, pos, direction)
|
|
62
|
+
set_char(center_char, pos, '*')
|
|
63
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: str(self.board[r, c]).strip() or center_char[r, c].strip(), special_content=lambda r, c: arr[r, c]))
|
|
64
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_row_pos, get_col_pos
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Board:
|
|
12
|
+
def __init__(self, board: np.array, max_bridges_per_direction: int = 2):
|
|
13
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
14
|
+
assert all(c.item() == ' ' or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only spaces or digits'
|
|
15
|
+
self.board = board
|
|
16
|
+
self.V = board.shape[0]
|
|
17
|
+
self.H = board.shape[1]
|
|
18
|
+
self.max_bridges_per_direction = max_bridges_per_direction
|
|
19
|
+
self.horiz_bridges: set[tuple[Pos, Pos]] = set()
|
|
20
|
+
self.vert_bridges: set[tuple[Pos, Pos]] = set()
|
|
21
|
+
|
|
22
|
+
self.model = cp_model.CpModel()
|
|
23
|
+
self.model_vars: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
|
|
24
|
+
self.is_bridge_active: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
|
|
25
|
+
|
|
26
|
+
self.init_bridges()
|
|
27
|
+
self.create_vars()
|
|
28
|
+
self.add_all_constraints()
|
|
29
|
+
|
|
30
|
+
def init_bridges(self):
|
|
31
|
+
for row_i in range(self.V):
|
|
32
|
+
cells_in_row = [i for i in get_row_pos(row_i, self.H) if get_char(self.board, i) != ' ']
|
|
33
|
+
for cell_i in range(len(cells_in_row) - 1):
|
|
34
|
+
self.horiz_bridges.add((cells_in_row[cell_i], cells_in_row[cell_i + 1]))
|
|
35
|
+
for col_i in range(self.H):
|
|
36
|
+
cells_in_col = [i for i in get_col_pos(col_i, self.V) if get_char(self.board, i) != ' ']
|
|
37
|
+
for cell_i in range(len(cells_in_col) - 1):
|
|
38
|
+
self.vert_bridges.add((cells_in_col[cell_i], cells_in_col[cell_i + 1]))
|
|
39
|
+
|
|
40
|
+
def create_vars(self):
|
|
41
|
+
for bridge in self.horiz_bridges | self.vert_bridges:
|
|
42
|
+
self.model_vars[bridge] = self.model.NewIntVar(0, self.max_bridges_per_direction, f'{bridge}')
|
|
43
|
+
self.is_bridge_active[bridge] = self.model.NewBoolVar(f'{bridge}:is_active')
|
|
44
|
+
self.model.Add(self.model_vars[bridge] == 0).OnlyEnforceIf(self.is_bridge_active[bridge].Not())
|
|
45
|
+
self.model.Add(self.model_vars[bridge] > 0).OnlyEnforceIf(self.is_bridge_active[bridge])
|
|
46
|
+
|
|
47
|
+
def add_all_constraints(self):
|
|
48
|
+
self.constrain_sums()
|
|
49
|
+
self.constrain_no_overlapping_bridges()
|
|
50
|
+
|
|
51
|
+
def constrain_sums(self):
|
|
52
|
+
for pos in get_all_pos(self.V, self.H):
|
|
53
|
+
c = get_char(self.board, pos)
|
|
54
|
+
if c == ' ':
|
|
55
|
+
continue
|
|
56
|
+
all_pos_bridges = [bridge for bridge in self.horiz_bridges if pos in bridge] + [bridge for bridge in self.vert_bridges if pos in bridge]
|
|
57
|
+
self.model.Add(lxp.sum([self.model_vars[bridge] for bridge in all_pos_bridges]) == int(c))
|
|
58
|
+
|
|
59
|
+
def constrain_no_overlapping_bridges(self):
|
|
60
|
+
for horiz_bridge in self.horiz_bridges:
|
|
61
|
+
for vert_bridge in self.vert_bridges:
|
|
62
|
+
if self.is_overlapping(horiz_bridge, vert_bridge):
|
|
63
|
+
self.model.AddImplication(self.is_bridge_active[horiz_bridge], self.is_bridge_active[vert_bridge].Not())
|
|
64
|
+
self.model.AddImplication(self.is_bridge_active[vert_bridge], self.is_bridge_active[horiz_bridge].Not())
|
|
65
|
+
|
|
66
|
+
def is_overlapping(self, horiz_bridge: tuple[Pos, Pos], vert_bridge: tuple[Pos, Pos]) -> bool:
|
|
67
|
+
assert vert_bridge[0].x == vert_bridge[1].x, 'vertical bridge must have constant x'
|
|
68
|
+
assert horiz_bridge[0].y == horiz_bridge[1].y, 'horizontal bridge must have constant y'
|
|
69
|
+
xvert = vert_bridge[0].x
|
|
70
|
+
yvert_min = min(vert_bridge[0].y, vert_bridge[1].y)
|
|
71
|
+
yvert_max = max(vert_bridge[0].y, vert_bridge[1].y)
|
|
72
|
+
|
|
73
|
+
xhoriz_min = min(horiz_bridge[0].x, horiz_bridge[1].x)
|
|
74
|
+
xhoriz_max = max(horiz_bridge[0].x, horiz_bridge[1].x)
|
|
75
|
+
yhoriz = horiz_bridge[0].y
|
|
76
|
+
|
|
77
|
+
# no equals because that's what the puzzle says
|
|
78
|
+
x_contained = xhoriz_min < xvert < xhoriz_max
|
|
79
|
+
y_contained = yvert_min < yhoriz < yvert_max
|
|
80
|
+
return x_contained and y_contained
|
|
81
|
+
|
|
82
|
+
def solve_and_print(self, verbose: bool = True):
|
|
83
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
84
|
+
assignment = defaultdict(lambda: [0, 0, 0, 0])
|
|
85
|
+
for bridge in board.horiz_bridges:
|
|
86
|
+
v = solver.Value(board.model_vars[bridge])
|
|
87
|
+
assignment[bridge[0]][0] += v
|
|
88
|
+
assignment[bridge[1]][1] += v
|
|
89
|
+
for bridge in board.vert_bridges:
|
|
90
|
+
v = solver.Value(board.model_vars[bridge])
|
|
91
|
+
assignment[bridge[0]][2] += v
|
|
92
|
+
assignment[bridge[1]][3] += v
|
|
93
|
+
# convert to tuples
|
|
94
|
+
assignment = {pos: tuple(assignment[pos]) for pos in assignment.keys()}
|
|
95
|
+
return SingleSolution(assignment=assignment)
|
|
96
|
+
def callback(single_res: SingleSolution):
|
|
97
|
+
print("Solution found")
|
|
98
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
99
|
+
for pos, (h1, h2, v1, v2) in single_res.assignment.items():
|
|
100
|
+
c = str(h1) + str(h2) + str(v1) + str(v2)
|
|
101
|
+
set_char(res, pos, c)
|
|
102
|
+
for row in res:
|
|
103
|
+
print('|' + '|'.join(row) + '|\n')
|
|
104
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=20)
|