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,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)
|