multi-puzzle-solver 0.1.0__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.
Potentially problematic release.
This version of multi-puzzle-solver might be problematic. Click here for more details.
- multi_puzzle_solver-0.1.0.dist-info/METADATA +1897 -0
- multi_puzzle_solver-0.1.0.dist-info/RECORD +31 -0
- multi_puzzle_solver-0.1.0.dist-info/WHEEL +5 -0
- multi_puzzle_solver-0.1.0.dist-info/top_level.txt +1 -0
- puzzle_solver/__init__.py +26 -0
- puzzle_solver/core/utils.py +127 -0
- puzzle_solver/core/utils_ortools.py +78 -0
- puzzle_solver/puzzles/bridges/bridges.py +106 -0
- puzzle_solver/puzzles/dominosa/dominosa.py +136 -0
- puzzle_solver/puzzles/filling/filling.py +192 -0
- puzzle_solver/puzzles/guess/guess.py +231 -0
- puzzle_solver/puzzles/inertia/inertia.py +122 -0
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +204 -0
- puzzle_solver/puzzles/inertia/tsp.py +398 -0
- puzzle_solver/puzzles/keen/keen.py +99 -0
- puzzle_solver/puzzles/light_up/light_up.py +95 -0
- puzzle_solver/puzzles/magnets/magnets.py +117 -0
- puzzle_solver/puzzles/map/map.py +56 -0
- puzzle_solver/puzzles/minesweeper/minesweeper.py +110 -0
- puzzle_solver/puzzles/mosaic/mosaic.py +48 -0
- puzzle_solver/puzzles/nonograms/nonograms.py +126 -0
- puzzle_solver/puzzles/pearl/pearl.py +151 -0
- puzzle_solver/puzzles/range/range.py +154 -0
- puzzle_solver/puzzles/signpost/signpost.py +95 -0
- puzzle_solver/puzzles/singles/singles.py +116 -0
- puzzle_solver/puzzles/sudoku/sudoku.py +90 -0
- puzzle_solver/puzzles/tents/tents.py +110 -0
- puzzle_solver/puzzles/towers/towers.py +139 -0
- puzzle_solver/puzzles/tracks/tracks.py +170 -0
- puzzle_solver/puzzles/undead/undead.py +168 -0
- puzzle_solver/puzzles/unruly/unruly.py +86 -0
|
@@ -0,0 +1,110 @@
|
|
|
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_neighbors8, get_next_pos, Direction, 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, sides: dict[str, np.array]):
|
|
13
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
14
|
+
assert board.shape[0] == board.shape[1], 'board must be square'
|
|
15
|
+
assert len(sides) == 2, '2 sides must be provided'
|
|
16
|
+
assert set(sides.keys()) == set(['top', 'side'])
|
|
17
|
+
assert all(s.ndim == 1 and s.shape[0] == board.shape[0] for s in sides.values()), 'all sides must be equal to board size'
|
|
18
|
+
assert all(c.item() in ['*', 'T'] for c in np.nditer(board)), 'board must contain only * or T'
|
|
19
|
+
self.board = board
|
|
20
|
+
self.N = board.shape[0]
|
|
21
|
+
self.star_positions: set[Pos] = {pos for pos in get_all_pos(self.N) if get_char(self.board, pos) == '*'}
|
|
22
|
+
self.tree_positions: set[Pos] = {pos for pos in get_all_pos(self.N) if get_char(self.board, pos) == 'T'}
|
|
23
|
+
self.model = cp_model.CpModel()
|
|
24
|
+
self.is_tent = defaultdict(int)
|
|
25
|
+
self.tent_direction = defaultdict(int)
|
|
26
|
+
self.sides = sides
|
|
27
|
+
self.create_vars()
|
|
28
|
+
self.add_all_constraints()
|
|
29
|
+
|
|
30
|
+
def create_vars(self):
|
|
31
|
+
for pos in self.star_positions:
|
|
32
|
+
is_tent = self.model.NewBoolVar(f'{pos}:is_tent')
|
|
33
|
+
tent_direction = self.model.NewIntVar(0, 4, f'{pos}:tent_direction')
|
|
34
|
+
self.model.Add(tent_direction == 0).OnlyEnforceIf(is_tent.Not())
|
|
35
|
+
self.model.Add(tent_direction > 0).OnlyEnforceIf(is_tent)
|
|
36
|
+
self.is_tent[pos] = is_tent
|
|
37
|
+
self.tent_direction[pos] = tent_direction
|
|
38
|
+
|
|
39
|
+
def add_all_constraints(self):
|
|
40
|
+
# - There are exactly as many tents as trees.
|
|
41
|
+
self.model.Add(lxp.sum([self.is_tent[pos] for pos in self.star_positions]) == len(self.tree_positions))
|
|
42
|
+
# - no two tents are adjacent horizontally, vertically or diagonally
|
|
43
|
+
for pos in self.star_positions:
|
|
44
|
+
for neighbour in get_neighbors8(pos, V=self.N, H=self.N, include_self=False):
|
|
45
|
+
if get_char(self.board, neighbour) != '*':
|
|
46
|
+
continue
|
|
47
|
+
self.model.Add(self.is_tent[neighbour] == 0).OnlyEnforceIf(self.is_tent[pos])
|
|
48
|
+
# - the number of tents in each row and column matches the numbers around the edge of the grid
|
|
49
|
+
for row in range(self.N):
|
|
50
|
+
row_vars = [self.is_tent[pos] for pos in get_row_pos(row, self.N)]
|
|
51
|
+
self.model.Add(lxp.sum(row_vars) == self.sides['side'][row])
|
|
52
|
+
for col in range(self.N):
|
|
53
|
+
col_vars = [self.is_tent[pos] for pos in get_col_pos(col, self.N)]
|
|
54
|
+
self.model.Add(lxp.sum(col_vars) == self.sides['top'][col])
|
|
55
|
+
# - it is possible to match tents to trees so that each tree is orthogonally adjacent to its own tent (but may also be adjacent to other tents).
|
|
56
|
+
# for each tree, one of the following must be true:
|
|
57
|
+
# a tent on its left has direction RIGHT
|
|
58
|
+
# a tent on its right has direction LEFT
|
|
59
|
+
# a tent on its top has direction DOWN
|
|
60
|
+
# a tent on its bottom has direction UP
|
|
61
|
+
for tree in self.tree_positions:
|
|
62
|
+
self.add_tree_constraints(tree)
|
|
63
|
+
|
|
64
|
+
def add_tree_constraints(self, tree_pos: Pos):
|
|
65
|
+
left_pos = get_next_pos(tree_pos, Direction.LEFT)
|
|
66
|
+
right_pos = get_next_pos(tree_pos, Direction.RIGHT)
|
|
67
|
+
top_pos = get_next_pos(tree_pos, Direction.UP)
|
|
68
|
+
bottom_pos = get_next_pos(tree_pos, Direction.DOWN)
|
|
69
|
+
var_list = []
|
|
70
|
+
if left_pos in self.star_positions:
|
|
71
|
+
aux = self.model.NewBoolVar(f'{tree_pos}:left')
|
|
72
|
+
self.model.Add(self.tent_direction[left_pos] == Direction.RIGHT.value).OnlyEnforceIf(aux)
|
|
73
|
+
self.model.Add(self.tent_direction[left_pos] != Direction.RIGHT.value).OnlyEnforceIf(aux.Not())
|
|
74
|
+
var_list.append(aux)
|
|
75
|
+
if right_pos in self.star_positions:
|
|
76
|
+
aux = self.model.NewBoolVar(f'{tree_pos}:right')
|
|
77
|
+
self.model.Add(self.tent_direction[right_pos] == Direction.LEFT.value).OnlyEnforceIf(aux)
|
|
78
|
+
self.model.Add(self.tent_direction[right_pos] != Direction.LEFT.value).OnlyEnforceIf(aux.Not())
|
|
79
|
+
var_list.append(aux)
|
|
80
|
+
if top_pos in self.star_positions:
|
|
81
|
+
aux = self.model.NewBoolVar(f'{tree_pos}:top')
|
|
82
|
+
self.model.Add(self.tent_direction[top_pos] == Direction.DOWN.value).OnlyEnforceIf(aux)
|
|
83
|
+
self.model.Add(self.tent_direction[top_pos] != Direction.DOWN.value).OnlyEnforceIf(aux.Not())
|
|
84
|
+
var_list.append(aux)
|
|
85
|
+
if bottom_pos in self.star_positions:
|
|
86
|
+
aux = self.model.NewBoolVar(f'{tree_pos}:bottom')
|
|
87
|
+
self.model.Add(self.tent_direction[bottom_pos] == Direction.UP.value).OnlyEnforceIf(aux)
|
|
88
|
+
self.model.Add(self.tent_direction[bottom_pos] != Direction.UP.value).OnlyEnforceIf(aux.Not())
|
|
89
|
+
var_list.append(aux)
|
|
90
|
+
self.model.AddBoolOr(var_list)
|
|
91
|
+
|
|
92
|
+
def solve_and_print(self):
|
|
93
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
94
|
+
assignment: dict[Pos, int] = {}
|
|
95
|
+
for pos, var in board.is_tent.items():
|
|
96
|
+
if isinstance(var, int):
|
|
97
|
+
continue
|
|
98
|
+
assignment[pos] = solver.value(var)
|
|
99
|
+
return SingleSolution(assignment=assignment)
|
|
100
|
+
def callback(single_res: SingleSolution):
|
|
101
|
+
print("Solution found")
|
|
102
|
+
res = np.full((self.N, self.N), ' ', dtype=object)
|
|
103
|
+
for pos in get_all_pos(self.N):
|
|
104
|
+
c = get_char(self.board, pos)
|
|
105
|
+
if c == '*':
|
|
106
|
+
c = single_res.assignment[pos]
|
|
107
|
+
c = 'E' if c == 1 else ' '
|
|
108
|
+
set_char(res, pos, c)
|
|
109
|
+
print(res)
|
|
110
|
+
return generic_solve_all(self, board_to_solution, callback=callback)
|
|
@@ -0,0 +1,139 @@
|
|
|
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, set_char, get_pos, get_row_pos, get_col_pos
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def bool_from_greater_than(model, a, b, name):
|
|
10
|
+
res = model.NewBoolVar(name)
|
|
11
|
+
model.add(a > b).OnlyEnforceIf(res)
|
|
12
|
+
model.add(a <= b).OnlyEnforceIf(res.Not())
|
|
13
|
+
return res
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Board:
|
|
17
|
+
def __init__(self, board: np.array, sides: dict[str, np.array]):
|
|
18
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
19
|
+
assert board.shape[0] == board.shape[1], 'board must be square'
|
|
20
|
+
assert len(sides) == 4, '4 sides must be provided'
|
|
21
|
+
assert all(s.ndim == 1 and s.shape[0] == board.shape[0] for s in sides.values()), 'all sides must be equal to board size'
|
|
22
|
+
assert set(sides.keys()) == set(['right', 'left', 'top', 'bottom'])
|
|
23
|
+
self.board = board
|
|
24
|
+
self.sides = sides
|
|
25
|
+
self.N = board.shape[0]
|
|
26
|
+
self.model = cp_model.CpModel()
|
|
27
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
28
|
+
|
|
29
|
+
self.create_vars()
|
|
30
|
+
self.add_all_constraints()
|
|
31
|
+
|
|
32
|
+
def create_vars(self):
|
|
33
|
+
for pos in get_all_pos(self.N):
|
|
34
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
|
|
35
|
+
|
|
36
|
+
def add_all_constraints(self):
|
|
37
|
+
# if board has value then force intvar to be that value
|
|
38
|
+
for pos in get_all_pos(self.N):
|
|
39
|
+
v = get_char(self.board, pos)
|
|
40
|
+
if str(v).isdecimal():
|
|
41
|
+
self.model.Add(self.model_vars[pos] == int(v))
|
|
42
|
+
# all different for rows
|
|
43
|
+
for row_i in range(self.N):
|
|
44
|
+
row_vars = [self.model_vars[pos] for pos in get_row_pos(row_i, self.N)]
|
|
45
|
+
self.model.AddAllDifferent(row_vars)
|
|
46
|
+
# all different for cols
|
|
47
|
+
for col_i in range(self.N):
|
|
48
|
+
col_vars = [self.model_vars[pos] for pos in get_col_pos(col_i, self.N)]
|
|
49
|
+
self.model.AddAllDifferent(col_vars)
|
|
50
|
+
# constrain number of viewable towers
|
|
51
|
+
# top
|
|
52
|
+
for x in range(self.N):
|
|
53
|
+
real = self.sides['top'][x]
|
|
54
|
+
if real == -1:
|
|
55
|
+
continue
|
|
56
|
+
can_see_variables = []
|
|
57
|
+
previous_towers: list[cp_model.IntVar] = []
|
|
58
|
+
for y in range(self.N):
|
|
59
|
+
current_tower = self.model_vars[get_pos(x=x, y=y)]
|
|
60
|
+
can_see_variables.append(self.can_see_tower(previous_towers, current_tower, f'top:{x}:{y}'))
|
|
61
|
+
previous_towers.append(current_tower)
|
|
62
|
+
self.model.add(lxp.sum(can_see_variables) == real)
|
|
63
|
+
# bottom
|
|
64
|
+
for x in range(self.N):
|
|
65
|
+
real = self.sides['bottom'][x]
|
|
66
|
+
if real == -1:
|
|
67
|
+
continue
|
|
68
|
+
can_see_variables = []
|
|
69
|
+
previous_towers: list[cp_model.IntVar] = []
|
|
70
|
+
for y in range(self.N-1, -1, -1):
|
|
71
|
+
current_tower = self.model_vars[get_pos(x=x, y=y)]
|
|
72
|
+
can_see_variables.append(self.can_see_tower(previous_towers, current_tower, f'bottom:{x}:{y}'))
|
|
73
|
+
previous_towers.append(current_tower)
|
|
74
|
+
self.model.add(lxp.sum(can_see_variables) == real)
|
|
75
|
+
# left
|
|
76
|
+
for y in range(self.N):
|
|
77
|
+
real = self.sides['left'][y]
|
|
78
|
+
if real == -1:
|
|
79
|
+
continue
|
|
80
|
+
can_see_variables = []
|
|
81
|
+
previous_towers: list[cp_model.IntVar] = []
|
|
82
|
+
for x in range(self.N):
|
|
83
|
+
current_tower = self.model_vars[get_pos(x=x, y=y)]
|
|
84
|
+
can_see_variables.append(self.can_see_tower(previous_towers, current_tower, f'left:{x}:{y}'))
|
|
85
|
+
previous_towers.append(current_tower)
|
|
86
|
+
self.model.add(lxp.sum(can_see_variables) == real)
|
|
87
|
+
# right
|
|
88
|
+
for y in range(self.N):
|
|
89
|
+
real = self.sides['right'][y]
|
|
90
|
+
if real == -1:
|
|
91
|
+
continue
|
|
92
|
+
can_see_variables = []
|
|
93
|
+
previous_towers: list[cp_model.IntVar] = []
|
|
94
|
+
for x in range(self.N-1, -1, -1):
|
|
95
|
+
current_tower = self.model_vars[get_pos(x=x, y=y)]
|
|
96
|
+
can_see_variables.append(self.can_see_tower(previous_towers, current_tower, f'right:{x}:{y}'))
|
|
97
|
+
previous_towers.append(current_tower)
|
|
98
|
+
self.model.add(lxp.sum(can_see_variables) == real)
|
|
99
|
+
|
|
100
|
+
def can_see_tower(self, blocks: list[cp_model.IntVar], tower: cp_model.IntVar, name: str) -> cp_model.IntVar:
|
|
101
|
+
"""
|
|
102
|
+
Returns a boolean variable of whether a position BEFORE the blocks can see the "tower" parameter.
|
|
103
|
+
i.e., is the tower taller than all the blocks?
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
blocks (list[cp_model.IntVar]): blocks that possibly block the view of the tower
|
|
107
|
+
tower (cp_model.IntVar): tower to check if can be seen
|
|
108
|
+
name (str): name of the constraint
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
cp_model.IntVar: boolean variable of whether the tower can be seen
|
|
112
|
+
"""
|
|
113
|
+
if len(blocks) == 0:
|
|
114
|
+
return True
|
|
115
|
+
# I can see "tower" if it's larger that all the blocks
|
|
116
|
+
# lits is a list of [(tower > b0), (tower > b1), ..., (tower > bi)]
|
|
117
|
+
lits = [bool_from_greater_than(self.model, tower, block, f'{name}:lits:{i}') for i, block in enumerate(blocks)]
|
|
118
|
+
|
|
119
|
+
# create a single bool which decides if I can see it or not
|
|
120
|
+
res = self.model.NewBoolVar(name)
|
|
121
|
+
self.model.AddBoolAnd(lits).OnlyEnforceIf(res)
|
|
122
|
+
self.model.AddBoolOr([res] + [l.Not() for l in lits])
|
|
123
|
+
return res
|
|
124
|
+
|
|
125
|
+
def solve_and_print(self):
|
|
126
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
127
|
+
assignment: dict[Pos, int] = {}
|
|
128
|
+
for pos, var in board.model_vars.items():
|
|
129
|
+
assignment[pos] = solver.value(var)
|
|
130
|
+
return SingleSolution(assignment=assignment)
|
|
131
|
+
def callback(single_res: SingleSolution):
|
|
132
|
+
print("Solution found")
|
|
133
|
+
res = np.full((self.N, self.N), ' ', dtype=object)
|
|
134
|
+
for pos in get_all_pos(self.N):
|
|
135
|
+
c = get_char(self.board, pos)
|
|
136
|
+
c = single_res.assignment[pos]
|
|
137
|
+
set_char(res, pos, c)
|
|
138
|
+
print(res)
|
|
139
|
+
return generic_solve_all(self, board_to_solution, callback=callback)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
import numpy as np
|
|
3
|
+
from ortools.sat.python import cp_model
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, get_neighbors4, Direction, in_bounds, get_next_pos, get_row_pos, get_col_pos, get_opposite_direction
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint, or_constraint
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Board:
|
|
10
|
+
def __init__(self, board: np.array, side: np.array, top: np.array):
|
|
11
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
12
|
+
assert all((len(c.item()) == 2) and all(ch in [' ', 'U', 'L', 'D', 'R'] for ch in c.item()) for c in np.nditer(board)), 'board must contain only * or digits'
|
|
13
|
+
self.board = board
|
|
14
|
+
self.V = board.shape[0]
|
|
15
|
+
self.H = board.shape[1]
|
|
16
|
+
self.side = side
|
|
17
|
+
self.top = top
|
|
18
|
+
self.first_col_start_pos = [p for p in get_col_pos(0, self.V) if 'L' in get_char(self.board, p)]
|
|
19
|
+
assert len(self.first_col_start_pos) == 1, 'first column must have exactly one start position'
|
|
20
|
+
self.first_col_start_pos = self.first_col_start_pos[0]
|
|
21
|
+
self.last_row_end_pos = [p for p in get_row_pos(self.V - 1, self.H) if 'D' in get_char(self.board, p)]
|
|
22
|
+
assert len(self.last_row_end_pos) == 1, 'last row must have exactly one end position'
|
|
23
|
+
self.last_row_end_pos = self.last_row_end_pos[0]
|
|
24
|
+
|
|
25
|
+
self.model = cp_model.CpModel()
|
|
26
|
+
self.cell_active: dict[Pos, cp_model.IntVar] = {}
|
|
27
|
+
self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
|
|
28
|
+
self.reach_layers: list[dict[Pos, cp_model.IntVar]] = [] # R_t[p] booleans, t = 0..T
|
|
29
|
+
|
|
30
|
+
self.create_vars()
|
|
31
|
+
self.add_all_constraints()
|
|
32
|
+
|
|
33
|
+
def create_vars(self):
|
|
34
|
+
for pos in get_all_pos(self.V, self.H):
|
|
35
|
+
self.cell_active[pos] = self.model.NewBoolVar(f'{pos}')
|
|
36
|
+
for direction in Direction:
|
|
37
|
+
self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
|
|
38
|
+
# Percolation layers R_t (monotone flood fill)
|
|
39
|
+
for t in range(self.V * self.H + 1):
|
|
40
|
+
Rt: dict[Pos, cp_model.IntVar] = {}
|
|
41
|
+
for pos in get_all_pos(self.V, self.H):
|
|
42
|
+
Rt[pos] = self.model.NewBoolVar(f"R[{t}][{pos}]")
|
|
43
|
+
self.reach_layers.append(Rt)
|
|
44
|
+
|
|
45
|
+
def add_all_constraints(self):
|
|
46
|
+
self.force_hints()
|
|
47
|
+
self.force_sides()
|
|
48
|
+
self.force_0_or_2_active()
|
|
49
|
+
self.force_direction_constraints()
|
|
50
|
+
self.force_percolation()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def force_hints(self):
|
|
54
|
+
# force the already given hints
|
|
55
|
+
for pos in get_all_pos(self.V, self.H):
|
|
56
|
+
c = get_char(self.board, pos)
|
|
57
|
+
if 'U' in c:
|
|
58
|
+
self.model.Add(self.cell_direction[(pos, Direction.UP)] == 1)
|
|
59
|
+
if 'L' in c:
|
|
60
|
+
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == 1)
|
|
61
|
+
if 'D' in c:
|
|
62
|
+
self.model.Add(self.cell_direction[(pos, Direction.DOWN)] == 1)
|
|
63
|
+
if 'R' in c:
|
|
64
|
+
self.model.Add(self.cell_direction[(pos, Direction.RIGHT)] == 1)
|
|
65
|
+
|
|
66
|
+
def force_sides(self):
|
|
67
|
+
# force the already given sides
|
|
68
|
+
for i in range(self.V):
|
|
69
|
+
self.model.Add(sum([self.cell_active[pos] for pos in get_row_pos(i, self.H)]) == self.side[i])
|
|
70
|
+
for i in range(self.H):
|
|
71
|
+
self.model.Add(sum([self.cell_active[pos] for pos in get_col_pos(i, self.V)]) == self.top[i])
|
|
72
|
+
|
|
73
|
+
def force_0_or_2_active(self):
|
|
74
|
+
# cell active means exactly 2 directions are active, cell not active means no directions are active
|
|
75
|
+
for pos in get_all_pos(self.V, self.H):
|
|
76
|
+
s = sum([self.cell_direction[(pos, direction)] for direction in Direction])
|
|
77
|
+
self.model.Add(s == 2).OnlyEnforceIf(self.cell_active[pos])
|
|
78
|
+
self.model.Add(s == 0).OnlyEnforceIf(self.cell_active[pos].Not())
|
|
79
|
+
|
|
80
|
+
def force_direction_constraints(self):
|
|
81
|
+
# X having right means the cell to its right has left and so on for all directions
|
|
82
|
+
for pos in get_all_pos(self.V, self.H):
|
|
83
|
+
right_pos = get_next_pos(pos, Direction.RIGHT)
|
|
84
|
+
if in_bounds(right_pos, self.V, self.H):
|
|
85
|
+
self.model.Add(self.cell_direction[(pos, Direction.RIGHT)] == self.cell_direction[(right_pos, Direction.LEFT)])
|
|
86
|
+
down_pos = get_next_pos(pos, Direction.DOWN)
|
|
87
|
+
if in_bounds(down_pos, self.V, self.H):
|
|
88
|
+
self.model.Add(self.cell_direction[(pos, Direction.DOWN)] == self.cell_direction[(down_pos, Direction.UP)])
|
|
89
|
+
left_pos = get_next_pos(pos, Direction.LEFT)
|
|
90
|
+
if in_bounds(left_pos, self.V, self.H):
|
|
91
|
+
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == self.cell_direction[(left_pos, Direction.RIGHT)])
|
|
92
|
+
top_pos = get_next_pos(pos, Direction.UP)
|
|
93
|
+
if in_bounds(top_pos, self.V, self.H):
|
|
94
|
+
self.model.Add(self.cell_direction[(pos, Direction.UP)] == self.cell_direction[(top_pos, Direction.DOWN)])
|
|
95
|
+
|
|
96
|
+
# first column cant have L unless it is the start position
|
|
97
|
+
for pos in get_col_pos(0, self.V):
|
|
98
|
+
if pos != self.first_col_start_pos:
|
|
99
|
+
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == 0)
|
|
100
|
+
# last column cant have R
|
|
101
|
+
for pos in get_col_pos(self.H - 1, self.V):
|
|
102
|
+
self.model.Add(self.cell_direction[(pos, Direction.RIGHT)] == 0)
|
|
103
|
+
# last row cant have D unless it is the end position
|
|
104
|
+
for pos in get_row_pos(self.V - 1, self.H):
|
|
105
|
+
if pos != self.last_row_end_pos:
|
|
106
|
+
self.model.Add(self.cell_direction[(pos, Direction.DOWN)] == 0)
|
|
107
|
+
# first row cant have U
|
|
108
|
+
for pos in get_row_pos(0, self.H):
|
|
109
|
+
self.model.Add(self.cell_direction[(pos, Direction.UP)] == 0)
|
|
110
|
+
|
|
111
|
+
def force_percolation(self):
|
|
112
|
+
"""
|
|
113
|
+
Layered percolation:
|
|
114
|
+
- root is exactly the first cell in the first column
|
|
115
|
+
- R_t is monotone nondecreasing in t (R_t+1 >= R_t)
|
|
116
|
+
- A cell can 'turn on' at layer t+1 iff it's active and has a neighbor on AND pointing to it at layer t
|
|
117
|
+
- Final layer is equal to the active mask: R_T[p] == active[p] => all active cells are connected to the unique root
|
|
118
|
+
"""
|
|
119
|
+
# only the start position is a root
|
|
120
|
+
self.model.Add(self.reach_layers[0][self.first_col_start_pos] == 1)
|
|
121
|
+
for pos in get_all_pos(self.V, self.H):
|
|
122
|
+
if pos != self.first_col_start_pos:
|
|
123
|
+
self.model.Add(self.reach_layers[0][pos] == 0)
|
|
124
|
+
|
|
125
|
+
for t in range(1, len(self.reach_layers)):
|
|
126
|
+
Rt_prev = self.reach_layers[t - 1]
|
|
127
|
+
Rt = self.reach_layers[t]
|
|
128
|
+
for p in get_all_pos(self.V, self.H):
|
|
129
|
+
# Rt[p] = Rt_prev[p] | (active[p] & Rt_prev[neighbour #1]) | (active[p] & Rt_prev[neighbour #2]) | ...
|
|
130
|
+
# Create helper (active[p] & Rt_prev[neighbour #X]) for each neighbor q
|
|
131
|
+
neigh_helpers: list[cp_model.IntVar] = []
|
|
132
|
+
for direction in Direction:
|
|
133
|
+
q = get_next_pos(p, direction)
|
|
134
|
+
if not in_bounds(q, self.V, self.H):
|
|
135
|
+
continue
|
|
136
|
+
a = self.model.NewBoolVar(f"A[{t}][{p}]<-({q})")
|
|
137
|
+
and_constraint(self.model, target=a, cs=[self.cell_active[p], Rt_prev[q], self.cell_direction[(q, get_opposite_direction(direction))]])
|
|
138
|
+
neigh_helpers.append(a)
|
|
139
|
+
or_constraint(self.model, target=Rt[p], cs=[Rt_prev[p]] + neigh_helpers)
|
|
140
|
+
# every avtive track must be reachible -> single connected component
|
|
141
|
+
for pos in get_all_pos(self.V, self.H):
|
|
142
|
+
self.model.Add(self.reach_layers[-1][pos] == 1).OnlyEnforceIf(self.cell_active[pos])
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def solve_and_print(self):
|
|
149
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
150
|
+
assignment: dict[Pos, str] = defaultdict(str)
|
|
151
|
+
for (pos, direction), var in board.cell_direction.items():
|
|
152
|
+
assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
|
|
153
|
+
for pos in get_all_pos(self.V, self.H):
|
|
154
|
+
if len(assignment[pos]) == 0:
|
|
155
|
+
assignment[pos] = ' '
|
|
156
|
+
else:
|
|
157
|
+
assignment[pos] = ''.join(sorted(assignment[pos]))
|
|
158
|
+
return SingleSolution(assignment=assignment)
|
|
159
|
+
def callback(single_res: SingleSolution):
|
|
160
|
+
print("Solution found")
|
|
161
|
+
print(single_res.assignment)
|
|
162
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
163
|
+
pretty_dict = {'DU': '┃ ', 'LR': '━━', 'DL': '━┒', 'DR': '┏━', 'RU': '┗━', 'LU': '━┛', ' ': ' '}
|
|
164
|
+
for pos in get_all_pos(self.V, self.H):
|
|
165
|
+
c = get_char(self.board, pos)
|
|
166
|
+
c = single_res.assignment[pos]
|
|
167
|
+
c = pretty_dict[c]
|
|
168
|
+
set_char(res, pos, c)
|
|
169
|
+
print(res)
|
|
170
|
+
return generic_solve_all(self, board_to_solution, callback=callback, max_solutions=20)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from typing import Iterable, Optional
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from dataclasses import dataclass
|
|
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, set_char, get_pos, get_next_pos, in_bounds, get_char, Direction, get_row_pos, get_col_pos
|
|
10
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Monster(Enum):
|
|
14
|
+
VAMPIRE = "VA"
|
|
15
|
+
ZOMBIE = "ZO"
|
|
16
|
+
GHOST = "GH"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SingleBeamResult:
|
|
21
|
+
position: Pos
|
|
22
|
+
reflect_count: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_all_monster_types() -> Iterable[tuple[str, str]]:
|
|
26
|
+
for monster in Monster:
|
|
27
|
+
yield monster, monster.value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def can_see(reflect_count: int, monster: Monster) -> bool:
|
|
31
|
+
if monster == Monster.ZOMBIE:
|
|
32
|
+
return True
|
|
33
|
+
elif monster == Monster.VAMPIRE:
|
|
34
|
+
return reflect_count == 0
|
|
35
|
+
elif monster == Monster.GHOST:
|
|
36
|
+
return reflect_count > 0
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def beam(board, start_pos: Pos, direction: Direction) -> list[SingleBeamResult]:
|
|
42
|
+
N = board.shape[0]
|
|
43
|
+
cur_result: list[SingleBeamResult] = []
|
|
44
|
+
reflect_count = 0
|
|
45
|
+
cur_pos = start_pos
|
|
46
|
+
while True:
|
|
47
|
+
if not in_bounds(cur_pos, N):
|
|
48
|
+
break
|
|
49
|
+
cur_pos_char = get_char(board, cur_pos)
|
|
50
|
+
if cur_pos_char == '//':
|
|
51
|
+
direction = {
|
|
52
|
+
Direction.RIGHT: Direction.UP,
|
|
53
|
+
Direction.UP: Direction.RIGHT,
|
|
54
|
+
Direction.DOWN: Direction.LEFT,
|
|
55
|
+
Direction.LEFT: Direction.DOWN
|
|
56
|
+
}[direction]
|
|
57
|
+
reflect_count += 1
|
|
58
|
+
elif cur_pos_char == '\\':
|
|
59
|
+
direction = {
|
|
60
|
+
Direction.RIGHT: Direction.DOWN,
|
|
61
|
+
Direction.DOWN: Direction.RIGHT,
|
|
62
|
+
Direction.UP: Direction.LEFT,
|
|
63
|
+
Direction.LEFT: Direction.UP
|
|
64
|
+
}[direction]
|
|
65
|
+
reflect_count += 1
|
|
66
|
+
else:
|
|
67
|
+
# not a mirror
|
|
68
|
+
cur_result.append(SingleBeamResult(cur_pos, reflect_count))
|
|
69
|
+
cur_pos = get_next_pos(cur_pos, direction)
|
|
70
|
+
return cur_result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Board:
|
|
74
|
+
def __init__(self, board: np.array, sides: dict[str, np.array], monster_count: Optional[dict[Monster, int]] = None):
|
|
75
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
76
|
+
assert board.shape[0] == board.shape[1], 'board must be square'
|
|
77
|
+
assert len(sides) == 4, '4 sides must be provided'
|
|
78
|
+
assert all(s.ndim == 1 and s.shape[0] == board.shape[0] for s in sides.values()), 'all sides must be equal to board size'
|
|
79
|
+
assert set(sides.keys()) == set(['right', 'left', 'top', 'bottom'])
|
|
80
|
+
self.board = board
|
|
81
|
+
self.sides = sides
|
|
82
|
+
self.N = board.shape[0]
|
|
83
|
+
self.model = cp_model.CpModel()
|
|
84
|
+
self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
|
|
85
|
+
self.star_positions: set[Pos] = {pos for pos in get_all_pos(self.N) if get_char(self.board, pos) == '**'}
|
|
86
|
+
self.monster_count = monster_count
|
|
87
|
+
|
|
88
|
+
self.create_vars()
|
|
89
|
+
self.add_all_constraints()
|
|
90
|
+
|
|
91
|
+
def create_vars(self):
|
|
92
|
+
for pos in self.star_positions:
|
|
93
|
+
c = get_char(self.board, pos)
|
|
94
|
+
assert c == '**', f'star position {pos} has character {c}'
|
|
95
|
+
monster_vars = []
|
|
96
|
+
for _, monster_name in get_all_monster_types():
|
|
97
|
+
v = self.model.NewBoolVar(f"{pos}_is_{monster_name}")
|
|
98
|
+
self.model_vars[(pos, monster_name)] = v
|
|
99
|
+
monster_vars.append(v)
|
|
100
|
+
self.model.add_exactly_one(*monster_vars)
|
|
101
|
+
|
|
102
|
+
def add_all_constraints(self):
|
|
103
|
+
# top edge
|
|
104
|
+
for i, ground in zip(range(self.N), self.sides['top']):
|
|
105
|
+
if ground == -1:
|
|
106
|
+
continue
|
|
107
|
+
pos = get_pos(x=i, y=0)
|
|
108
|
+
beam_result = beam(self.board, pos, Direction.DOWN)
|
|
109
|
+
self.model.add(self.get_var(beam_result) == ground)
|
|
110
|
+
|
|
111
|
+
# left edge
|
|
112
|
+
for i, ground in zip(range(self.N), self.sides['left']):
|
|
113
|
+
if ground == -1:
|
|
114
|
+
continue
|
|
115
|
+
pos = get_pos(x=0, y=i)
|
|
116
|
+
beam_result = beam(self.board, pos, Direction.RIGHT)
|
|
117
|
+
self.model.add(self.get_var(beam_result) == ground)
|
|
118
|
+
|
|
119
|
+
# right edge
|
|
120
|
+
for i, ground in zip(range(self.N), self.sides['right']):
|
|
121
|
+
if ground == -1:
|
|
122
|
+
continue
|
|
123
|
+
pos = get_pos(x=self.N-1, y=i)
|
|
124
|
+
beam_result = beam(self.board, pos, Direction.LEFT)
|
|
125
|
+
self.model.add(self.get_var(beam_result) == ground)
|
|
126
|
+
|
|
127
|
+
# bottom edge
|
|
128
|
+
for i, ground in zip(range(self.N), self.sides['bottom']):
|
|
129
|
+
if ground == -1:
|
|
130
|
+
continue
|
|
131
|
+
pos = get_pos(x=i, y=self.N-1)
|
|
132
|
+
beam_result = beam(self.board, pos, Direction.UP)
|
|
133
|
+
self.model.add(self.get_var(beam_result) == ground)
|
|
134
|
+
|
|
135
|
+
if self.monster_count is not None:
|
|
136
|
+
for monster, count in self.monster_count.items():
|
|
137
|
+
if count == -1:
|
|
138
|
+
continue
|
|
139
|
+
monster_name = monster.value
|
|
140
|
+
monster_vars = [self.model_vars[(pos, monster_name)] for pos in self.star_positions]
|
|
141
|
+
self.model.add(lxp.Sum(monster_vars) == count)
|
|
142
|
+
|
|
143
|
+
def get_var(self, path: list[SingleBeamResult]) -> lxp:
|
|
144
|
+
path_vars = []
|
|
145
|
+
for square in path:
|
|
146
|
+
assert square.position in self.star_positions, f'square {square.position} is not a star position'
|
|
147
|
+
for monster, monster_name in get_all_monster_types():
|
|
148
|
+
if can_see(square.reflect_count, monster):
|
|
149
|
+
path_vars.append(self.model_vars[(square.position, monster_name)])
|
|
150
|
+
return lxp.Sum(path_vars) if path_vars else 0
|
|
151
|
+
|
|
152
|
+
def solve_and_print(self):
|
|
153
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
154
|
+
assignment: dict[Pos, str] = {}
|
|
155
|
+
for (pos, monster_name), var in board.model_vars.items():
|
|
156
|
+
if solver.BooleanValue(var):
|
|
157
|
+
assignment[pos] = monster_name
|
|
158
|
+
return SingleSolution(assignment=assignment)
|
|
159
|
+
def callback(single_res: SingleSolution):
|
|
160
|
+
print("Solution found")
|
|
161
|
+
res = np.full((self.N, self.N), ' ', dtype=object)
|
|
162
|
+
for pos in get_all_pos(self.N):
|
|
163
|
+
c = get_char(self.board, pos)
|
|
164
|
+
if c == '**':
|
|
165
|
+
c = single_res.assignment[pos]
|
|
166
|
+
set_char(res, pos, c)
|
|
167
|
+
print(res)
|
|
168
|
+
return generic_solve_all(self, board_to_solution, callback=callback)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, get_row_pos, get_col_pos, in_bounds, Direction, get_next_pos
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_3_consecutive_horiz_and_vert(pos: Pos, V: int, H: int) -> tuple[list[Pos], list[Pos]]:
|
|
10
|
+
"""Get 3 consecutive squares, horizontally and vertically, from the given position."""
|
|
11
|
+
horiz = []
|
|
12
|
+
vert = []
|
|
13
|
+
cur_pos = pos
|
|
14
|
+
for _ in range(3):
|
|
15
|
+
if in_bounds(cur_pos, V, H):
|
|
16
|
+
horiz.append(cur_pos)
|
|
17
|
+
cur_pos = get_next_pos(cur_pos, Direction.RIGHT)
|
|
18
|
+
cur_pos = pos
|
|
19
|
+
for _ in range(3):
|
|
20
|
+
if in_bounds(cur_pos, V, H):
|
|
21
|
+
vert.append(cur_pos)
|
|
22
|
+
cur_pos = get_next_pos(cur_pos, Direction.DOWN)
|
|
23
|
+
return horiz, vert
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Board:
|
|
27
|
+
def __init__(self, board: np.array):
|
|
28
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
29
|
+
assert board.shape[0] % 2 == 0, 'board must have even number of rows'
|
|
30
|
+
assert board.shape[1] % 2 == 0, 'board must have even number of columns'
|
|
31
|
+
self.board = board
|
|
32
|
+
self.V = board.shape[0]
|
|
33
|
+
self.H = board.shape[1]
|
|
34
|
+
self.model = cp_model.CpModel()
|
|
35
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
36
|
+
|
|
37
|
+
self.create_vars()
|
|
38
|
+
self.add_all_constraints()
|
|
39
|
+
|
|
40
|
+
def create_vars(self):
|
|
41
|
+
for pos in get_all_pos(self.V, self.H):
|
|
42
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
43
|
+
|
|
44
|
+
def add_all_constraints(self):
|
|
45
|
+
# some cells are already filled
|
|
46
|
+
for pos in get_all_pos(self.V, self.H):
|
|
47
|
+
c = get_char(self.board, pos)
|
|
48
|
+
if c == '*':
|
|
49
|
+
continue
|
|
50
|
+
v = 1 if c == 'B' else 0
|
|
51
|
+
self.model.Add(self.model_vars[pos] == v)
|
|
52
|
+
# no three consecutive squares, horizontally or vertically, are the same colour
|
|
53
|
+
for pos in get_all_pos(self.V, self.H):
|
|
54
|
+
horiz, vert = get_3_consecutive_horiz_and_vert(pos, self.V, self.H)
|
|
55
|
+
if len(horiz) == 3:
|
|
56
|
+
horiz = [self.model_vars[h] for h in horiz]
|
|
57
|
+
self.model.Add(lxp.Sum(horiz) != 0)
|
|
58
|
+
self.model.Add(lxp.Sum(horiz) != 3)
|
|
59
|
+
if len(vert) == 3:
|
|
60
|
+
vert = [self.model_vars[v] for v in vert]
|
|
61
|
+
self.model.Add(lxp.Sum(vert) != 0)
|
|
62
|
+
self.model.Add(lxp.Sum(vert) != 3)
|
|
63
|
+
# each row and column contains the same number of black and white squares.
|
|
64
|
+
for col in range(self.H):
|
|
65
|
+
var_list = [self.model_vars[pos] for pos in get_col_pos(col, self.V)]
|
|
66
|
+
self.model.Add(lxp.Sum(var_list) == self.V // 2)
|
|
67
|
+
for row in range(self.V):
|
|
68
|
+
var_list = [self.model_vars[pos] for pos in get_row_pos(row, self.H)]
|
|
69
|
+
self.model.Add(lxp.Sum(var_list) == self.H // 2)
|
|
70
|
+
|
|
71
|
+
def solve_and_print(self):
|
|
72
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
73
|
+
assignment: dict[Pos, int] = {}
|
|
74
|
+
for pos, var in board.model_vars.items():
|
|
75
|
+
assignment[pos] = solver.Value(var)
|
|
76
|
+
return SingleSolution(assignment=assignment)
|
|
77
|
+
def callback(single_res: SingleSolution):
|
|
78
|
+
print("Solution found")
|
|
79
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
80
|
+
for pos in get_all_pos(self.V, self.H):
|
|
81
|
+
c = get_char(self.board, pos)
|
|
82
|
+
if c == '*':
|
|
83
|
+
c = 'B' if single_res.assignment[pos] == 1 else 'W'
|
|
84
|
+
set_char(res, pos, c)
|
|
85
|
+
print(res)
|
|
86
|
+
return generic_solve_all(self, board_to_solution, callback=callback)
|