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.

Files changed (31) hide show
  1. multi_puzzle_solver-0.1.0.dist-info/METADATA +1897 -0
  2. multi_puzzle_solver-0.1.0.dist-info/RECORD +31 -0
  3. multi_puzzle_solver-0.1.0.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-0.1.0.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +26 -0
  6. puzzle_solver/core/utils.py +127 -0
  7. puzzle_solver/core/utils_ortools.py +78 -0
  8. puzzle_solver/puzzles/bridges/bridges.py +106 -0
  9. puzzle_solver/puzzles/dominosa/dominosa.py +136 -0
  10. puzzle_solver/puzzles/filling/filling.py +192 -0
  11. puzzle_solver/puzzles/guess/guess.py +231 -0
  12. puzzle_solver/puzzles/inertia/inertia.py +122 -0
  13. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +204 -0
  14. puzzle_solver/puzzles/inertia/tsp.py +398 -0
  15. puzzle_solver/puzzles/keen/keen.py +99 -0
  16. puzzle_solver/puzzles/light_up/light_up.py +95 -0
  17. puzzle_solver/puzzles/magnets/magnets.py +117 -0
  18. puzzle_solver/puzzles/map/map.py +56 -0
  19. puzzle_solver/puzzles/minesweeper/minesweeper.py +110 -0
  20. puzzle_solver/puzzles/mosaic/mosaic.py +48 -0
  21. puzzle_solver/puzzles/nonograms/nonograms.py +126 -0
  22. puzzle_solver/puzzles/pearl/pearl.py +151 -0
  23. puzzle_solver/puzzles/range/range.py +154 -0
  24. puzzle_solver/puzzles/signpost/signpost.py +95 -0
  25. puzzle_solver/puzzles/singles/singles.py +116 -0
  26. puzzle_solver/puzzles/sudoku/sudoku.py +90 -0
  27. puzzle_solver/puzzles/tents/tents.py +110 -0
  28. puzzle_solver/puzzles/towers/towers.py +139 -0
  29. puzzle_solver/puzzles/tracks/tracks.py +170 -0
  30. puzzle_solver/puzzles/undead/undead.py +168 -0
  31. 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)