multi-puzzle-solver 1.1.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
  2. multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
  3. multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +184 -0
  6. puzzle_solver/core/utils.py +298 -0
  7. puzzle_solver/core/utils_ortools.py +333 -0
  8. puzzle_solver/core/utils_visualizer.py +575 -0
  9. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  10. puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
  11. puzzle_solver/puzzles/area_51/area_51.py +159 -0
  12. puzzle_solver/puzzles/battleships/battleships.py +139 -0
  13. puzzle_solver/puzzles/binairo/binairo.py +98 -0
  14. puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
  15. puzzle_solver/puzzles/black_box/black_box.py +243 -0
  16. puzzle_solver/puzzles/branches/branches.py +64 -0
  17. puzzle_solver/puzzles/bridges/bridges.py +104 -0
  18. puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
  19. puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
  20. puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
  21. puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
  22. puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
  23. puzzle_solver/puzzles/clouds/clouds.py +81 -0
  24. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
  25. puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
  26. puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
  27. puzzle_solver/puzzles/filling/filling.py +94 -0
  28. puzzle_solver/puzzles/flip/flip.py +64 -0
  29. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  30. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
  31. puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
  32. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
  33. puzzle_solver/puzzles/guess/guess.py +232 -0
  34. puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
  35. puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
  36. puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
  37. puzzle_solver/puzzles/inertia/inertia.py +121 -0
  38. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
  39. puzzle_solver/puzzles/inertia/tsp.py +400 -0
  40. puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
  41. puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
  42. puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
  43. puzzle_solver/puzzles/keen/keen.py +76 -0
  44. puzzle_solver/puzzles/kropki/kropki.py +94 -0
  45. puzzle_solver/puzzles/light_up/light_up.py +58 -0
  46. puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
  47. puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
  48. puzzle_solver/puzzles/lits/lits.py +138 -0
  49. puzzle_solver/puzzles/magnets/magnets.py +96 -0
  50. puzzle_solver/puzzles/map/map.py +56 -0
  51. puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
  52. puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
  53. puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
  54. puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
  55. puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
  56. puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
  57. puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
  58. puzzle_solver/puzzles/norinori/norinori.py +96 -0
  59. puzzle_solver/puzzles/number_path/number_path.py +76 -0
  60. puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
  61. puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
  62. puzzle_solver/puzzles/palisade/palisade.py +91 -0
  63. puzzle_solver/puzzles/pearl/pearl.py +107 -0
  64. puzzle_solver/puzzles/pipes/pipes.py +82 -0
  65. puzzle_solver/puzzles/range/range.py +59 -0
  66. puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
  67. puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
  68. puzzle_solver/puzzles/rooms/rooms.py +75 -0
  69. puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
  70. puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
  71. puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
  72. puzzle_solver/puzzles/signpost/signpost.py +93 -0
  73. puzzle_solver/puzzles/singles/singles.py +53 -0
  74. puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
  75. puzzle_solver/puzzles/slant/slant.py +111 -0
  76. puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
  77. puzzle_solver/puzzles/snail/snail.py +97 -0
  78. puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
  79. puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
  80. puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
  81. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
  82. puzzle_solver/puzzles/stitches/stitches.py +96 -0
  83. puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
  84. puzzle_solver/puzzles/suguru/suguru.py +55 -0
  85. puzzle_solver/puzzles/suko/suko.py +54 -0
  86. puzzle_solver/puzzles/tapa/tapa.py +97 -0
  87. puzzle_solver/puzzles/tatami/tatami.py +64 -0
  88. puzzle_solver/puzzles/tents/tents.py +80 -0
  89. puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
  90. puzzle_solver/puzzles/towers/towers.py +89 -0
  91. puzzle_solver/puzzles/tracks/tracks.py +88 -0
  92. puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
  93. puzzle_solver/puzzles/troix/dumplings.py +7 -0
  94. puzzle_solver/puzzles/troix/troix.py +75 -0
  95. puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
  96. puzzle_solver/puzzles/undead/undead.py +130 -0
  97. puzzle_solver/puzzles/unequal/unequal.py +128 -0
  98. puzzle_solver/puzzles/unruly/unruly.py +54 -0
  99. puzzle_solver/puzzles/vectors/vectors.py +94 -0
  100. puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
  101. puzzle_solver/puzzles/walls/walls.py +52 -0
  102. puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
  103. puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
  104. puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
  105. puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
  106. puzzle_solver/utils/visualizer.py +155 -0
@@ -0,0 +1,44 @@
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_pos, get_row_pos, get_col_pos
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
6
+ from puzzle_solver.core.utils_visualizer import combined_function
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
13
+ self.board = board
14
+ self.V, self.H = board.shape
15
+ self.N = max(self.V, self.H)
16
+
17
+ self.model = cp_model.CpModel()
18
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
19
+ self.create_vars()
20
+ self.add_all_constraints()
21
+
22
+ def create_vars(self):
23
+ for pos in get_all_pos(self.V, self.H):
24
+ if get_char(self.board, pos).strip():
25
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
26
+
27
+ def add_all_constraints(self):
28
+ for v in range(1, self.N + 1): # each digit is circled once
29
+ self.model.AddExactlyOne([self.model_vars[pos] for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos) == str(v)])
30
+ for row in range(self.V): # each row contains 1 circle
31
+ self.model.AddExactlyOne([self.model_vars[pos] for pos in get_row_pos(row, self.H) if pos in self.model_vars])
32
+ for col in range(self.H): # each column contains 1 circle
33
+ self.model.AddExactlyOne([self.model_vars[pos] for pos in get_col_pos(col, self.V) if pos in self.model_vars])
34
+
35
+ def solve_and_print(self, verbose: bool = True):
36
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
37
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
38
+ def callback(single_res: SingleSolution):
39
+ print("Solution found")
40
+ print(combined_function(self.V, self.H,
41
+ cell_flags=lambda r, c: 'ULRD' if single_res.assignment.get(get_pos(x=c, y=r), 0) == 1 else '',
42
+ center_char=lambda r, c: self.board[r, c].strip() if self.board[r, c].strip() else '.',
43
+ ))
44
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,81 @@
1
+ from dataclasses import dataclass
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_pos, 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
+ @dataclass(frozen=True)
13
+ class ShapeOnBoard:
14
+ body: frozenset[Pos]
15
+ is_active: cp_model.IntVar
16
+ disallow: frozenset[Pos]
17
+
18
+
19
+ class Board:
20
+ def __init__(self, side: np.array, top: np.array):
21
+ self.V = side.shape[0]
22
+ self.H = top.shape[0]
23
+ self.side = side
24
+ self.top = top
25
+
26
+ self.model = cp_model.CpModel()
27
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
28
+ self.shapes_on_board: set[ShapeOnBoard] = set()
29
+ self.create_vars()
30
+ self.add_all_constraints()
31
+
32
+ def create_vars(self):
33
+ for x in range(0, self.H):
34
+ for end_x in range(x, self.H + 1):
35
+ if (end_x - x) < 2:
36
+ continue
37
+ max_allowed_height = np.min(self.top[x:end_x])
38
+ for y in range(0, self.V):
39
+ for end_y in range(y, self.V + 1):
40
+ if (end_y - y) < 2 or (end_y - y) > max_allowed_height:
41
+ continue
42
+ max_allowed_width = np.min(self.side[y:end_y])
43
+ if (end_x - x) > max_allowed_width:
44
+ continue
45
+ body = frozenset(get_pos(x=i, y=j) for i in range(x, end_x) for j in range(y, end_y))
46
+ disallow = frozenset(get_pos(x=i, y=j) for i in range(x-1, end_x+1) for j in range(y-1, end_y+1)) - body
47
+ self.shapes_on_board.add(ShapeOnBoard(
48
+ body=body,
49
+ is_active=self.model.NewBoolVar(f'{x}-{y}-{end_x}-{end_y}-is_active'),
50
+ disallow=disallow
51
+ ))
52
+ for pos in get_all_pos(self.V, self.H):
53
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
54
+
55
+ def add_all_constraints(self):
56
+ # if a piece is active then all its body is active and the disallow is inactive
57
+ for shape in self.shapes_on_board:
58
+ for pos in shape.body:
59
+ self.model.Add(self.model_vars[pos] == 1).OnlyEnforceIf(shape.is_active)
60
+ for pos in shape.disallow:
61
+ if pos not in self.model_vars:
62
+ continue
63
+ self.model.Add(self.model_vars[pos] == 0).OnlyEnforceIf(shape.is_active)
64
+ # if a spot is active then exactly one piece (with a body there) is active
65
+ for pos in get_all_pos(self.V, self.H):
66
+ pieces_on_pos = [shape for shape in self.shapes_on_board if pos in shape.body]
67
+ # if pos is on then exactly one shape is active. if pos is off then 0 shapes are active.
68
+ self.model.Add(lxp.Sum([shape.is_active for shape in pieces_on_pos]) == self.model_vars[pos])
69
+ for row in range(self.V): # force side counts
70
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in get_row_pos(row, self.H)]) == self.side[row])
71
+ for col in range(self.H): # force top counts
72
+ self.model.Add(lxp.Sum([self.model_vars[pos] for pos in get_col_pos(col, self.V)]) == self.top[col])
73
+
74
+
75
+ def solve_and_print(self, verbose: bool = True):
76
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
77
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
78
+ def callback(single_res: SingleSolution):
79
+ print("Solution found")
80
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1))
81
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,50 @@
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_pos, get_neighbors4
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
6
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ self.board = board
13
+ self.V, self.H = board.shape
14
+ self.unique_colors = set([str(c.item()).strip() for c in np.nditer(board) if str(c.item()).strip() not in ['', '#']])
15
+ assert all(np.count_nonzero(board == color) == 2 for color in self.unique_colors), f'each color must appear == 2 times, got {self.unique_colors}'
16
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
18
+ self.create_vars()
19
+ self.add_all_constraints()
20
+
21
+ def create_vars(self):
22
+ for pos in get_all_pos(self.V, self.H):
23
+ for color in self.unique_colors:
24
+ self.model_vars[(pos, color)] = self.model.NewBoolVar(f'{pos}:{color}')
25
+
26
+ def add_all_constraints(self):
27
+ for pos in get_all_pos(self.V, self.H):
28
+ c = get_char(self.board, pos).strip()
29
+ if c == '#': # a wall, thus no color
30
+ self.model.Add(sum([self.model_vars[(pos, color)] for color in self.unique_colors]) == 0)
31
+ continue
32
+ self.model.AddExactlyOne([self.model_vars[(pos, color)] for color in self.unique_colors])
33
+ if c != '': # an endpoint, thus must be the color
34
+ self.model.Add(self.model_vars[(pos, c)] == 1)
35
+ self.model.Add(sum([self.model_vars[(n, c)] for n in get_neighbors4(pos, self.V, self.H)]) == 1) # endpoints must have exactly 1 neighbor
36
+ else: # not an endpoint, thus must have exactly 2 neighbors
37
+ for color in self.unique_colors:
38
+ self.model.Add(sum([self.model_vars[(n, color)] for n in get_neighbors4(pos, self.V, self.H)]) == 2).OnlyEnforceIf(self.model_vars[(pos, color)])
39
+ for color in self.unique_colors:
40
+ force_connected_component(self.model, {pos: self.model_vars[(pos, color)] for pos in get_all_pos(self.V, self.H)})
41
+
42
+ def solve_and_print(self, verbose: bool = True):
43
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
44
+ return SingleSolution(assignment={pos: color for (pos, color), var in board.model_vars.items() if solver.Value(var) == 1})
45
+ def callback(single_res: SingleSolution):
46
+ print("Solution found")
47
+ print(combined_function(self.V, self.H,
48
+ cell_flags=id_board_to_wall_fn(np.array([[single_res.assignment.get(get_pos(x=c, y=r), '') for c in range(self.H)] for r in range(self.V)])),
49
+ center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), self.board[r, c])))
50
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,66 @@
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_pos, Direction, get_char, get_ray
5
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
6
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.ndarray):
11
+ assert board.ndim == 2 and board.shape[0] > 0 and board.shape[1] > 0, f'board must be 2d, got {board.ndim}'
12
+ assert all(str(i.item()).strip() in ['', 'W', 'P'] or str(i.item()).strip().isdecimal() for i in np.nditer(board)), f'board must be empty or a W or a P or a number, got {list(np.nditer(board))}'
13
+ self.V, self.H = board.shape
14
+ self.board = board
15
+
16
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
18
+ self.outside_fence: dict[Pos, cp_model.IntVar] = {}
19
+ self.create_vars()
20
+ self.add_all_constraints()
21
+
22
+ def create_vars(self):
23
+ for pos in get_all_pos(self.V, self.H):
24
+ self.model_vars[pos] = self.model.NewBoolVar(f"{pos}")
25
+ self.outside_fence[pos] = self.model_vars[pos].Not()
26
+
27
+ def add_all_constraints(self):
28
+ for pos in get_all_pos(self.V, self.H):
29
+ c = str(get_char(self.board, pos)).strip()
30
+ if c == '':
31
+ continue
32
+ elif c in ['W', 'P']: # cow or cactus
33
+ self.model.Add(self.model_vars[pos] == (c == 'W'))
34
+ else:
35
+ self.range_clue(pos, int(c))
36
+ force_connected_component(self.model, self.model_vars)
37
+ def is_outside_neighbor(p1: Pos, p2: Pos) -> bool:
38
+ if abs(p1.x - p2.x) + abs(p1.y - p2.y) == 1: # manhattan distance is 1
39
+ return True
40
+ # both are on the border
41
+ p1_on_border = p1.x == 0 or p1.x == self.H- 1 or p1.y == 0 or p1.y == self.V - 1
42
+ p2_on_border = p2.x == 0 or p2.x == self.H- 1 or p2.y == 0 or p2.y == self.V - 1
43
+ return p1_on_border and p2_on_border
44
+ force_connected_component(self.model, self.outside_fence, is_neighbor=is_outside_neighbor)
45
+
46
+ def range_clue(self, pos: Pos, c: int):
47
+ self.model.Add(self.model_vars[pos] == 1) # Force it white
48
+ vis_vars: list[cp_model.IntVar] = []
49
+ for direction in Direction: # Build visibility chains in four direction
50
+ ray = get_ray(pos, direction, self.V, self.H) # cells outward
51
+ for idx in range(len(ray)):
52
+ v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
53
+ and_constraint(self.model, target=v, cs=[self.model_vars[p] for p in ray[:idx+1]])
54
+ vis_vars.append(v)
55
+ self.model.Add(1 + sum(vis_vars) == int(c)) # Sum of visible whites = 1 (itself) + sum(chains) == k
56
+
57
+ def solve_and_print(self, verbose: bool = True):
58
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
59
+ return SingleSolution(assignment={pos: solver.Value(board.model_vars[pos]) for pos in get_all_pos(board.V, board.H)})
60
+ def callback(single_res: SingleSolution):
61
+ print("Solution:")
62
+ print(combined_function(self.V, self.H,
63
+ 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)])),
64
+ center_char=lambda r, c: self.board[r, c].strip(),
65
+ ))
66
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,67 @@
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
+
8
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, Direction, get_next_pos, in_bounds, get_pos
9
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
10
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ShapeOnBoard:
15
+ uid: str
16
+ is_active: cp_model.IntVar
17
+
18
+
19
+ class Board:
20
+ def __init__(self, board: np.array, target_pairs: Optional[list[tuple[int, int]]] = None):
21
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
22
+ assert all(str(i.item()).isdecimal() for i in np.nditer(board)), 'board must contain only digits'
23
+ self.board = board
24
+ self.V, self.H = board.shape
25
+ self.target_pairs = target_pairs
26
+ if target_pairs is None:
27
+ nums = [int(i.item()) for i in np.nditer(board)]
28
+ assert min(nums) == 0, 'expected board to start from 0'
29
+ self.target_pairs = [(i, j) for i in range(max(nums) + 1) for j in range(i, max(nums) + 1)]
30
+
31
+ self.model = cp_model.CpModel()
32
+ self.pair_to_shapes: dict[tuple[int, int], set[ShapeOnBoard]] = defaultdict(set)
33
+ self.pos_to_shapes: dict[Pos, set[ShapeOnBoard]] = defaultdict(set)
34
+ self.create_vars()
35
+ self.add_all_constraints()
36
+
37
+ def create_vars(self):
38
+ for pos in get_all_pos(self.V, self.H):
39
+ for direction in [Direction.RIGHT, Direction.DOWN]:
40
+ next_pos = get_next_pos(pos, direction)
41
+ if not in_bounds(next_pos, self.V, self.H):
42
+ continue
43
+ c1 = int(get_char(self.board, pos))
44
+ c2 = int(get_char(self.board, next_pos))
45
+ pair = tuple(sorted((c1, c2)))
46
+ uid = f'{pos.x}-{pos.y}-{direction.name[0]}'
47
+ s = ShapeOnBoard(uid=uid, is_active=self.model.NewBoolVar(uid))
48
+ self.pair_to_shapes[pair].add(s)
49
+ self.pos_to_shapes[pos].add(s)
50
+ self.pos_to_shapes[next_pos].add(s)
51
+
52
+ def add_all_constraints(self):
53
+ for pair in self.target_pairs: # exactly one shape active for each pair
54
+ self.model.AddExactlyOne(s.is_active for s in self.pair_to_shapes[pair])
55
+ for pos in get_all_pos(self.V, self.H): # at most one shape active at each position
56
+ self.model.AddAtMostOne(s.is_active for s in self.pos_to_shapes[pos])
57
+
58
+ def solve_and_print(self, verbose: bool = True):
59
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
60
+ return SingleSolution(assignment={pos: s.uid for pos in get_all_pos(self.V, self.H) for s in self.pos_to_shapes[pos] if solver.Value(s.is_active) == 1})
61
+ def callback(single_res: SingleSolution):
62
+ print("Solution found")
63
+ print(combined_function(self.V, self.H,
64
+ 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)])),
65
+ center_char=lambda r, c: str(self.board[r, c])
66
+ ))
67
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,94 @@
1
+ from dataclasses import dataclass
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_pos, polyominoes, in_bounds, get_next_pos, Direction
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
9
+
10
+
11
+ @dataclass
12
+ class ShapeOnBoard:
13
+ is_active: cp_model.IntVar
14
+ N: int
15
+ body: set[Pos]
16
+ disallow_same_shape: set[Pos]
17
+
18
+
19
+ class Board:
20
+ def __init__(self, board: np.ndarray, digits = (1, 2, 3, 4, 5, 6, 7, 8, 9)):
21
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
22
+ self.board = board
23
+ self.V, self.H = board.shape
24
+ assert all((c == ' ') or (str(c).isdecimal() and 0 <= int(c) <= 9) for c in np.nditer(board)), "board must contain space or digits 0..9"
25
+ self.digits = digits
26
+ self.polyominoes = {d: polyominoes(d) for d in self.digits}
27
+
28
+ self.model = cp_model.CpModel()
29
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
30
+ self.digit_to_shapes = {d: [] for d in self.digits}
31
+ self.body_loc_to_shape = {(d,p): [] for d in self.digits for p in get_all_pos(self.V, self.H)}
32
+ self.forced_pos: dict[Pos, int] = {}
33
+
34
+ self.create_vars()
35
+ self.force_hints()
36
+ self.init_polyominoes_on_board()
37
+ self.add_all_constraints()
38
+
39
+ def create_vars(self):
40
+ for pos in get_all_pos(self.V, self.H):
41
+ for d in self.digits:
42
+ self.model_vars[(d,pos)] = self.model.NewBoolVar(f'{d}:{pos}')
43
+
44
+ def force_hints(self):
45
+ for pos in get_all_pos(self.V, self.H):
46
+ c = get_char(self.board, pos)
47
+ if c.isdecimal():
48
+ self.model.Add(self.model_vars[(int(c),pos)] == 1)
49
+ self.forced_pos[pos] = int(c)
50
+
51
+ def init_polyominoes_on_board(self):
52
+ for d in self.digits: # all digits
53
+ digit_count = 0
54
+ for pos in get_all_pos(self.V, self.H): # translate by shape
55
+ for shape in self.polyominoes[d]: # all shapes of d digits
56
+ body = {pos + p for p in shape}
57
+ if any(not in_bounds(p, self.V, self.H) for p in body):
58
+ continue
59
+ if any(p in self.forced_pos and self.forced_pos[p] != d for p in body): # part of this shape's body is already forced to a different digit, skip
60
+ continue
61
+ disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
62
+ disallow_same_shape = {p for p in disallow_same_shape if p not in body and in_bounds(p, self.V, self.H)}
63
+ shape_on_board = ShapeOnBoard(
64
+ is_active=self.model.NewBoolVar(f'd{d}:{digit_count}:{pos}:is_active'),
65
+ N=d,
66
+ body=body,
67
+ disallow_same_shape=disallow_same_shape,
68
+ )
69
+ self.digit_to_shapes[d].append(shape_on_board)
70
+ for p in body:
71
+ self.body_loc_to_shape[(d,p)].append(shape_on_board)
72
+ digit_count += 1
73
+
74
+ def add_all_constraints(self):
75
+ for pos in get_all_pos(self.V, self.H):
76
+ self.model.AddExactlyOne(self.model_vars[(d,pos)] for d in self.digits) # exactly one digit is active at every position
77
+ self.model.AddExactlyOne(s.is_active for d in self.digits for s in self.body_loc_to_shape[(d,pos)]) # exactly one shape is active at that position
78
+ for s_list in self.body_loc_to_shape.values(): # if a shape is active then all its body is active
79
+ for s in s_list:
80
+ for p in s.body:
81
+ self.model.Add(self.model_vars[(s.N,p)] == 1).OnlyEnforceIf(s.is_active)
82
+ for d, s_list in self.digit_to_shapes.items(): # same shape cannot touch each other
83
+ for s in s_list:
84
+ for disallow_pos in s.disallow_same_shape:
85
+ self.model.Add(self.model_vars[(d,disallow_pos)] == 0).OnlyEnforceIf(s.is_active)
86
+
87
+ def solve_and_print(self, verbose: bool = True):
88
+ def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
89
+ return SingleSolution(assignment={pos: d for pos in get_all_pos(self.V, self.H) for d in self.digits if solver.Value(self.model_vars[(d,pos)]) == 1})
90
+ def callback(single_res: SingleSolution):
91
+ print("Solution found")
92
+ res_arr = np.array([[single_res.assignment[get_pos(x=c, y=r)] for c in range(self.H)] for r in range(self.V)])
93
+ print(combined_function(self.V, self.H, cell_flags=id_board_to_wall_fn(res_arr), center_char=lambda r, c: res_arr[r, c]))
94
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,64 @@
1
+ from typing import Any, Optional
2
+
3
+ import numpy as np
4
+ from ortools.sat.python import cp_model
5
+
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_char, Direction, get_next_pos, get_pos
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
+ from puzzle_solver.core.utils_visualizer import combined_function
9
+
10
+
11
+ def _to_pos(pos: Pos, s: str) -> Pos:
12
+ d = {'L': Direction.LEFT, 'R': Direction.RIGHT, 'U': Direction.UP, 'D': Direction.DOWN}[s[0]]
13
+ r = get_next_pos(pos, d)
14
+ if len(s) == 1:
15
+ return r
16
+ else:
17
+ return _to_pos(r, s[1:])
18
+
19
+
20
+ class Board:
21
+ def __init__(self, board: np.array, random_mapping: Optional[dict[Pos, Any]] = None):
22
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
23
+ assert all((c.item() in ['B', 'W']) for c in np.nditer(board)), 'board must contain only B or W'
24
+ self.board = board
25
+ self.V, self.H = board.shape
26
+ if random_mapping is None:
27
+ self.tap_mapping: dict[Pos, set[Pos]] = {pos: list(get_neighbors4(pos, self.V, self.H, include_self=True)) for pos in get_all_pos(self.V, self.H)}
28
+ else:
29
+ mapping_value = list(random_mapping.values())[0]
30
+ if isinstance(mapping_value, (set, list, tuple)) and isinstance(list(mapping_value)[0], Pos):
31
+ self.tap_mapping: dict[Pos, set[Pos]] = {pos: set(random_mapping[pos]) for pos in get_all_pos(self.V, self.H)}
32
+ elif isinstance(mapping_value, (set, list, tuple)) and isinstance(list(mapping_value)[0], str): # strings like "L", "UR", etc.
33
+ self.tap_mapping: dict[Pos, set[Pos]] = {pos: set(_to_pos(pos, s) for s in random_mapping[pos]) for pos in get_all_pos(self.V, self.H)}
34
+ else:
35
+ raise ValueError(f'invalid random_mapping: {random_mapping}')
36
+ for k, v in self.tap_mapping.items():
37
+ if k not in v:
38
+ v.add(k)
39
+
40
+ self.model = cp_model.CpModel()
41
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
42
+ self.create_vars()
43
+ self.add_all_constraints()
44
+
45
+ def create_vars(self):
46
+ for pos in get_all_pos(self.V, self.H):
47
+ self.model_vars[pos] = self.model.NewBoolVar(f'tap:{pos}')
48
+
49
+ def add_all_constraints(self):
50
+ for pos in get_all_pos(self.V, self.H):
51
+ # the state of a position is its starting state + if it is tapped + if any pos pointing to it is tapped
52
+ pos_that_will_turn_me = [k for k,v in self.tap_mapping.items() if pos in v]
53
+ literals = [self.model_vars[p] for p in pos_that_will_turn_me]
54
+ if get_char(self.board, pos) == 'W': # if started as white then needs an even number of taps while xor checks for odd number
55
+ literals.append(self.model.NewConstant(True))
56
+ self.model.AddBoolXOr(literals)
57
+
58
+ def solve_and_print(self, verbose: bool = True):
59
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
60
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
61
+ def callback(single_res: SingleSolution):
62
+ print("Solution found")
63
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1))
64
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,174 @@
1
+ import sys
2
+ import time
3
+ from collections import defaultdict
4
+ from typing import Optional
5
+
6
+ import numpy as np
7
+ from ortools.sat.python import cp_model
8
+ from ortools.sat.python.cp_model import LinearExpr as lxp
9
+
10
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_char
11
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
12
+
13
+
14
+ class Board:
15
+ def __init__(self, nodes: dict[int, int], edges: dict[int, set[int]], horizon: int, start_node_id: int):
16
+ self.T = horizon
17
+ self.nodes = nodes
18
+ self.edges = edges
19
+ self.start_node_id = start_node_id
20
+ self.K = len(set(nodes.values()))
21
+
22
+ self.model = cp_model.CpModel()
23
+ self.decision: dict[tuple[int, int], cp_model.IntVar] = {} # (t, k)
24
+ self.connected: dict[tuple[int, int], cp_model.IntVar] = {} # (t, cluster_id)
25
+
26
+ self.create_vars()
27
+ self.add_all_constraints()
28
+
29
+ def create_vars(self):
30
+ for t in range(self.T - 1): # (N-1) actions (we dont need to decide at time N)
31
+ for k in range(self.K):
32
+ self.decision[t, k] = self.model.NewBoolVar(f'decision:{t}:{k}')
33
+ for t in range(self.T):
34
+ for cluster_id in self.nodes:
35
+ self.connected[t, cluster_id] = self.model.NewBoolVar(f'connected:{t}:{cluster_id}')
36
+
37
+ def add_all_constraints(self):
38
+ # init time t=0, all clusters are not connected except start_node
39
+ for cluster_id in self.nodes:
40
+ if cluster_id == self.start_node_id:
41
+ self.model.Add(self.connected[0, cluster_id] == 1)
42
+ else:
43
+ self.model.Add(self.connected[0, cluster_id] == 0)
44
+ # each timestep I will pick either one or zero colors
45
+ for t in range(self.T - 1):
46
+ # print('fixing decision at time t=', t, 'to single action with colors', self.K)
47
+ self.model.Add(lxp.sum([self.decision[t, k] for k in range(self.K)]) <= 1)
48
+ # at the end of the game, all clusters must be connected
49
+ for cluster_id in self.nodes:
50
+ self.model.Add(self.connected[self.T-1, cluster_id] == 1)
51
+
52
+ for t in range(1, self.T):
53
+ for cluster_id in self.nodes:
54
+ # connected[t, i] must be 0 if all connencted clusters at t-1 are 0 (thus connected[t, i] <= sum(connected[t-1, j] for j in touching)
55
+ sum_neighbors = lxp.sum([self.connected[t-1, j] for j in self.edges[cluster_id]]) + self.connected[t-1, cluster_id]
56
+ self.model.Add(self.connected[t, cluster_id] <= sum_neighbors)
57
+ # connected[t, i] must be 0 if color chosen at time t does not match color of cluster i and not connected at t-1
58
+ cluster_color = self.nodes[cluster_id]
59
+ self.model.Add(self.connected[t, cluster_id] == 0).OnlyEnforceIf([self.decision[t-1, cluster_color].Not(), self.connected[t-1, cluster_id].Not()])
60
+ self.model.Add(self.connected[t, cluster_id] == 1).OnlyEnforceIf([self.connected[t-1, cluster_id]])
61
+
62
+ pairs = [(self.decision[t, k], t+1) for t in range(self.T - 1) for k in range(self.K)]
63
+ self.model.Minimize(lxp.weighted_sum([p[0] for p in pairs], [p[1] for p in pairs]))
64
+
65
+ def solve(self) -> list[SingleSolution]:
66
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
67
+ assignment: list[str] = [None for _ in range(self.T - 1)]
68
+ for t in range(self.T - 1):
69
+ for k in range(self.K):
70
+ if solver.Value(self.decision[t, k]) == 1:
71
+ assignment[t] = k
72
+ break
73
+ return SingleSolution(assignment=assignment)
74
+ return generic_solve_all(self, board_to_solution, verbose=False, max_solutions=1)
75
+
76
+
77
+ def solve_minimum_steps(board: np.array, start_pos: Optional[Pos] = None, verbose: bool = True) -> int:
78
+ tic = time.time()
79
+ all_colors: set[str] = {c.item().strip() for c in np.nditer(board) if c.item().strip()}
80
+ color_to_int: dict[str, int] = {c: i for i, c in enumerate(sorted(all_colors))} # colors string to color id
81
+ int_to_color: dict[int, str] = {i: c for c, i in color_to_int.items()}
82
+
83
+ graph: dict[Pos, int] = _board_to_graph(board) # position to cluster id
84
+ nodes: dict[int, int] = {cluster_id: color_to_int[get_char(board, pos)] for pos, cluster_id in graph.items()}
85
+ edges = _graph_to_edges(board, graph) # cluster id to touching cluster ids
86
+ if start_pos is None:
87
+ start_pos = Pos(0,0)
88
+
89
+ def solution_int_to_str(solution: SingleSolution):
90
+ return [int_to_color.get(color_id, '?') for color_id in solution.assignment]
91
+
92
+ def print_solution(solution: SingleSolution):
93
+ solution = solution_int_to_str(solution)
94
+ print("Solution:", solution)
95
+ solution = _binary_search_solution(nodes, edges, graph[start_pos], callback=print_solution if verbose else None, verbose=verbose)
96
+ if verbose:
97
+ if solution is None:
98
+ print("No solution found")
99
+ else:
100
+ solution = solution_int_to_str(solution)
101
+ print(f"Best Horizon is: T={len(solution)}")
102
+ print("Best solution is:", solution)
103
+ toc = time.time()
104
+ print(f"Time taken: {toc - tic:.2f} seconds")
105
+ return solution
106
+
107
+
108
+ def _board_to_graph(board: np.array) -> dict[int, set[int]]:
109
+ def dfs_flood(board: np.array, pos: Pos, cluster_id: int, graph: dict[Pos, int]):
110
+ if pos in graph:
111
+ return
112
+ graph[pos] = cluster_id
113
+ for neighbor in get_neighbors4(pos, board.shape[0], board.shape[1]):
114
+ if get_char(board, neighbor) == get_char(board, pos):
115
+ dfs_flood(board, neighbor, cluster_id, graph)
116
+ graph: dict[Pos, int] = {}
117
+ cluster_id = 0
118
+ V, H = board.shape
119
+ for pos in get_all_pos(V, H):
120
+ if pos in graph:
121
+ continue
122
+ dfs_flood(board, pos, cluster_id, graph)
123
+ cluster_id += 1
124
+ return graph
125
+
126
+
127
+ def _graph_to_edges(board: np.array, graph: dict[Pos, int]) -> dict[int, set[int]]:
128
+ cluster_edges: dict[int, set[int]] = defaultdict(set)
129
+ V, H = board.shape
130
+ for pos in get_all_pos(V, H):
131
+ for neighbor in get_neighbors4(pos, V, H):
132
+ n1, n2 = graph[pos], graph[neighbor]
133
+ if n1 != n2:
134
+ cluster_edges[n1].add(n2)
135
+ cluster_edges[n2].add(n1)
136
+ return cluster_edges
137
+
138
+
139
+ def _binary_search_solution(nodes, edges, start_node_id, callback, verbose: bool = True):
140
+ if len(nodes) <= 1:
141
+ return SingleSolution(assignment=[])
142
+ min_T = 2
143
+ max_T = len(nodes)
144
+ hist = {} # record historical T and best solution
145
+ while min_T <= max_T:
146
+ if max_T - min_T <= 20: # small gap, just take the middle
147
+ T = min_T + (max_T - min_T) // 2
148
+ else: # large gap, just +5 the min to not go too far
149
+ T = min_T + 15
150
+ # main check for binary search
151
+ if T in hist: # already done and found solution
152
+ solutions = hist[T]
153
+ else:
154
+ if verbose:
155
+ print(f"Trying with exactly {T-1} moves...", end='')
156
+ sys.stdout.flush()
157
+ binst = Board(nodes=nodes, edges=edges, horizon=T, start_node_id=start_node_id)
158
+ solutions = binst.solve()
159
+ if verbose:
160
+ print(' Possible!' if len(solutions) > 0 else ' Not possible!')
161
+ if len(solutions) > 0:
162
+ callback(solutions[0])
163
+ if min_T == max_T:
164
+ hist[T] = solutions
165
+ break
166
+ if len(solutions) > 0:
167
+ hist[T] = solutions
168
+ max_T = T
169
+ else:
170
+ min_T = T + 1
171
+ best_solution = min(hist.items(), key=lambda x: x[0])[1][0]
172
+ return best_solution
173
+
174
+