multi-puzzle-solver 1.1.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
- multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
- multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
- multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
- puzzle_solver/__init__.py +184 -0
- puzzle_solver/core/utils.py +298 -0
- puzzle_solver/core/utils_ortools.py +333 -0
- puzzle_solver/core/utils_visualizer.py +575 -0
- puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
- puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
- puzzle_solver/puzzles/area_51/area_51.py +159 -0
- puzzle_solver/puzzles/battleships/battleships.py +139 -0
- puzzle_solver/puzzles/binairo/binairo.py +98 -0
- puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
- puzzle_solver/puzzles/black_box/black_box.py +243 -0
- puzzle_solver/puzzles/branches/branches.py +64 -0
- puzzle_solver/puzzles/bridges/bridges.py +104 -0
- puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
- puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
- puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
- puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
- puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
- puzzle_solver/puzzles/clouds/clouds.py +81 -0
- puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
- puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
- puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
- puzzle_solver/puzzles/filling/filling.py +94 -0
- puzzle_solver/puzzles/flip/flip.py +64 -0
- puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
- puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
- puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
- puzzle_solver/puzzles/guess/guess.py +232 -0
- puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
- puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
- puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
- puzzle_solver/puzzles/inertia/inertia.py +121 -0
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
- puzzle_solver/puzzles/inertia/tsp.py +400 -0
- puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
- puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
- puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
- puzzle_solver/puzzles/keen/keen.py +76 -0
- puzzle_solver/puzzles/kropki/kropki.py +94 -0
- puzzle_solver/puzzles/light_up/light_up.py +58 -0
- puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
- puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
- puzzle_solver/puzzles/lits/lits.py +138 -0
- puzzle_solver/puzzles/magnets/magnets.py +96 -0
- puzzle_solver/puzzles/map/map.py +56 -0
- puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
- puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
- puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
- puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
- puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
- puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
- puzzle_solver/puzzles/norinori/norinori.py +96 -0
- puzzle_solver/puzzles/number_path/number_path.py +76 -0
- puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
- puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
- puzzle_solver/puzzles/palisade/palisade.py +91 -0
- puzzle_solver/puzzles/pearl/pearl.py +107 -0
- puzzle_solver/puzzles/pipes/pipes.py +82 -0
- puzzle_solver/puzzles/range/range.py +59 -0
- puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
- puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
- puzzle_solver/puzzles/rooms/rooms.py +75 -0
- puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
- puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
- puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
- puzzle_solver/puzzles/signpost/signpost.py +93 -0
- puzzle_solver/puzzles/singles/singles.py +53 -0
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
- puzzle_solver/puzzles/slant/slant.py +111 -0
- puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
- puzzle_solver/puzzles/snail/snail.py +97 -0
- puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
- puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
- puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
- puzzle_solver/puzzles/stitches/stitches.py +96 -0
- puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
- puzzle_solver/puzzles/suguru/suguru.py +55 -0
- puzzle_solver/puzzles/suko/suko.py +54 -0
- puzzle_solver/puzzles/tapa/tapa.py +97 -0
- puzzle_solver/puzzles/tatami/tatami.py +64 -0
- puzzle_solver/puzzles/tents/tents.py +80 -0
- puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
- puzzle_solver/puzzles/towers/towers.py +89 -0
- puzzle_solver/puzzles/tracks/tracks.py +88 -0
- puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
- puzzle_solver/puzzles/troix/dumplings.py +7 -0
- puzzle_solver/puzzles/troix/troix.py +75 -0
- puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
- puzzle_solver/puzzles/undead/undead.py +130 -0
- puzzle_solver/puzzles/unequal/unequal.py +128 -0
- puzzle_solver/puzzles/unruly/unruly.py +54 -0
- puzzle_solver/puzzles/vectors/vectors.py +94 -0
- puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
- puzzle_solver/puzzles/walls/walls.py +52 -0
- puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
- puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
- puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
- puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
- puzzle_solver/utils/visualizer.py +155 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, in_bounds, Direction, get_next_pos, polyominoes_with_shape_id
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# a shape on the 2d board is just a set of positions
|
|
13
|
+
Shape = frozenset[Pos]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ShapeOnBoard:
|
|
18
|
+
is_active: cp_model.IntVar
|
|
19
|
+
shape: Shape
|
|
20
|
+
shape_id: int
|
|
21
|
+
body: set[Pos]
|
|
22
|
+
disallow_same_shape: set[Pos]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Board:
|
|
26
|
+
def __init__(self, board: np.array, polyomino_degrees: int = 4):
|
|
27
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
28
|
+
self.V = board.shape[0]
|
|
29
|
+
self.H = board.shape[1]
|
|
30
|
+
assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
|
|
31
|
+
self.board = board
|
|
32
|
+
self.polyomino_degrees = polyomino_degrees
|
|
33
|
+
self.polyominoes = polyominoes_with_shape_id(self.polyomino_degrees)
|
|
34
|
+
|
|
35
|
+
self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
|
|
36
|
+
self.blocks = {i: set() for i in self.block_numbers}
|
|
37
|
+
for cell in get_all_pos(self.V, self.H):
|
|
38
|
+
self.blocks[int(get_char(self.board, cell))].add(cell)
|
|
39
|
+
|
|
40
|
+
self.model = cp_model.CpModel()
|
|
41
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
42
|
+
self.connected_components: dict[Pos, cp_model.IntVar] = {}
|
|
43
|
+
self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
|
|
44
|
+
|
|
45
|
+
self.create_vars()
|
|
46
|
+
self.init_shapes_on_board()
|
|
47
|
+
self.add_all_constraints()
|
|
48
|
+
|
|
49
|
+
def create_vars(self):
|
|
50
|
+
for pos in get_all_pos(self.V, self.H):
|
|
51
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
52
|
+
|
|
53
|
+
def init_shapes_on_board(self):
|
|
54
|
+
for idx, (shape, shape_id) in enumerate(self.polyominoes):
|
|
55
|
+
for translate in get_all_pos(self.V, self.H): # body of shape is translated to be at pos
|
|
56
|
+
body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape}
|
|
57
|
+
if any(not in_bounds(p, self.V, self.H) for p in body):
|
|
58
|
+
continue
|
|
59
|
+
# shape must be fully contained in one block
|
|
60
|
+
if len(set(get_char(self.board, p) for p in body)) > 1:
|
|
61
|
+
continue
|
|
62
|
+
# 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
|
|
63
|
+
disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
|
|
64
|
+
disallow_same_shape -= body
|
|
65
|
+
self.shapes_on_board.append(ShapeOnBoard(
|
|
66
|
+
is_active=self.model.NewBoolVar(f'{idx}:{translate}:is_active'),
|
|
67
|
+
shape=shape,
|
|
68
|
+
shape_id=shape_id,
|
|
69
|
+
body=body,
|
|
70
|
+
disallow_same_shape=disallow_same_shape,
|
|
71
|
+
))
|
|
72
|
+
|
|
73
|
+
def add_all_constraints(self):
|
|
74
|
+
# RULES:
|
|
75
|
+
# 1- You have to place one tetromino in each region in such a way that:
|
|
76
|
+
# 2- 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
|
|
77
|
+
# 3- The shaded cells should form a single connected area.
|
|
78
|
+
# 4- 2x2 shaded areas are not allowed
|
|
79
|
+
|
|
80
|
+
# each cell must be part of a shape, every shape must be fully on the board. Core constraint, otherwise shapes on the board make no sense.
|
|
81
|
+
self.only_allow_shapes_on_board()
|
|
82
|
+
|
|
83
|
+
self.force_one_shape_per_block() # Rule #1
|
|
84
|
+
self.disallow_same_shape_touching() # Rule #2
|
|
85
|
+
self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
|
|
86
|
+
shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
|
|
87
|
+
self.disallow_shape(shape_2_by_2) # Rule #4
|
|
88
|
+
|
|
89
|
+
def only_allow_shapes_on_board(self):
|
|
90
|
+
for shape_on_board in self.shapes_on_board:
|
|
91
|
+
# if shape is active then all its body cells must be active
|
|
92
|
+
self.model.Add(sum(self.model_vars[p] for p in shape_on_board.body) == len(shape_on_board.body)).OnlyEnforceIf(shape_on_board.is_active)
|
|
93
|
+
# each cell must be part of a shape
|
|
94
|
+
for p in get_all_pos(self.V, self.H):
|
|
95
|
+
shapes_on_p = [s for s in self.shapes_on_board if p in s.body]
|
|
96
|
+
self.model.Add(sum(s.is_active for s in shapes_on_p) == 1).OnlyEnforceIf(self.model_vars[p])
|
|
97
|
+
|
|
98
|
+
def force_one_shape_per_block(self):
|
|
99
|
+
# You have to place exactly one tetromino in each region
|
|
100
|
+
for block_i in self.block_numbers:
|
|
101
|
+
shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
|
|
102
|
+
assert all(s.body.issubset(self.blocks[block_i]) for s in shapes_on_block), 'expected all shapes on block to be fully contained in the block'
|
|
103
|
+
self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
|
|
104
|
+
|
|
105
|
+
def disallow_same_shape_touching(self):
|
|
106
|
+
# if shape is active then it must not touch any other shape of the same type
|
|
107
|
+
for shape_on_board in self.shapes_on_board:
|
|
108
|
+
similar_shapes = [s for s in self.shapes_on_board if s.shape_id == shape_on_board.shape_id]
|
|
109
|
+
for s in similar_shapes:
|
|
110
|
+
if shape_on_board.disallow_same_shape & s.body: # this shape disallows having s be on the board
|
|
111
|
+
self.model.Add(s.is_active == 0).OnlyEnforceIf(shape_on_board.is_active)
|
|
112
|
+
|
|
113
|
+
def disallow_shape(self, shape_to_disallow: Shape):
|
|
114
|
+
# for every position in the board, force sum of body < len(body)
|
|
115
|
+
for translate in get_all_pos(self.V, self.H):
|
|
116
|
+
cur_body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape_to_disallow}
|
|
117
|
+
if any(not in_bounds(p, self.V, self.H) for p in cur_body):
|
|
118
|
+
continue
|
|
119
|
+
self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
|
|
123
|
+
if verbose_callback is None:
|
|
124
|
+
verbose_callback = verbose
|
|
125
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
126
|
+
assignment: dict[Pos, int] = {}
|
|
127
|
+
for pos, var in board.model_vars.items():
|
|
128
|
+
assignment[pos] = solver.Value(var)
|
|
129
|
+
return SingleSolution(assignment=assignment)
|
|
130
|
+
def callback(single_res: SingleSolution):
|
|
131
|
+
print("Solution found")
|
|
132
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
133
|
+
for pos, val in single_res.assignment.items():
|
|
134
|
+
set_char(res, pos, '▒▒▒' if val == 1 else ' ')
|
|
135
|
+
print(combined_function(self.V, self.H,
|
|
136
|
+
cell_flags=id_board_to_wall_fn(self.board),
|
|
137
|
+
center_char=lambda r, c: res[r][c]))
|
|
138
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
|
|
@@ -0,0 +1,96 @@
|
|
|
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_char, get_pos, get_next_pos, Direction, 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, id_board_to_wall_fn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class MagnetOnBoard:
|
|
14
|
+
uid: str
|
|
15
|
+
is_active: cp_model.IntVar
|
|
16
|
+
str_rep: tuple[tuple[Pos, str], ...]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Board:
|
|
20
|
+
def __init__(self, board: np.array, top_pos: np.array, top_neg: np.array, side_pos: np.array, side_neg: np.array):
|
|
21
|
+
assert (len(side_pos), len(top_pos)) == board.shape, 'side_pos and top_pos must be the same shape as the board'
|
|
22
|
+
assert (len(side_neg), len(top_neg)) == board.shape, 'side_neg and top_neg must be the same shape as the board'
|
|
23
|
+
self.board = board
|
|
24
|
+
self.top_pos, self.top_neg = top_pos, top_neg
|
|
25
|
+
self.side_pos, self.side_neg = side_pos, side_neg
|
|
26
|
+
|
|
27
|
+
self.V, self.H = board.shape
|
|
28
|
+
self.model = cp_model.CpModel()
|
|
29
|
+
self.magnets: set[MagnetOnBoard] = set()
|
|
30
|
+
self.pos_vars: dict[tuple[Pos, str], MagnetOnBoard] = {}
|
|
31
|
+
self.create_vars()
|
|
32
|
+
self.add_all_constraints()
|
|
33
|
+
|
|
34
|
+
def create_vars(self):
|
|
35
|
+
for col_i in range(self.H): # vertical magnets
|
|
36
|
+
row_i = 0
|
|
37
|
+
while row_i < self.V - 1:
|
|
38
|
+
pos_1 = get_pos(x=col_i, y=row_i)
|
|
39
|
+
if get_char(self.board, pos_1) != 'V':
|
|
40
|
+
row_i += 1
|
|
41
|
+
continue
|
|
42
|
+
pos_2 = get_next_pos(pos_1, Direction.DOWN)
|
|
43
|
+
self.add_magnet(pos_1, pos_2)
|
|
44
|
+
row_i += 2 # skip next cell since it's already covered by this magnet
|
|
45
|
+
for row_i in range(self.V): # horizontal magnets
|
|
46
|
+
col_i = 0
|
|
47
|
+
while col_i < self.H - 1:
|
|
48
|
+
pos_1 = get_pos(x=col_i, y=row_i)
|
|
49
|
+
if get_char(self.board, pos_1) != 'H':
|
|
50
|
+
col_i += 1
|
|
51
|
+
continue
|
|
52
|
+
pos_2 = get_next_pos(pos_1, Direction.RIGHT)
|
|
53
|
+
self.add_magnet(pos_1, pos_2)
|
|
54
|
+
col_i += 2 # skip next cell since it's already covered by this magnet
|
|
55
|
+
|
|
56
|
+
def add_magnet(self, pos1: Pos, pos2: Pos):
|
|
57
|
+
for v1, v2 in [('+', '-'), ('-', '+'), ('x', 'x')]:
|
|
58
|
+
magnet = MagnetOnBoard(uid=f'{pos1}:{pos2}:{v1}{v2}', is_active=self.model.NewBoolVar(f'{pos1}:{pos2}:{v1}{v2}'), str_rep=((pos1, v1), (pos2, v2)))
|
|
59
|
+
self.pos_vars[(pos1, v1)] = magnet
|
|
60
|
+
self.pos_vars[(pos2, v2)] = magnet
|
|
61
|
+
self.magnets.add(magnet)
|
|
62
|
+
|
|
63
|
+
def add_all_constraints(self):
|
|
64
|
+
for pos in get_all_pos(self.V, self.H): # each position has exactly one magnet
|
|
65
|
+
self.model.AddExactlyOne([self.pos_vars[(pos, v)].is_active for v in ['+', '-', 'x']])
|
|
66
|
+
for pos in get_all_pos(self.V, self.H): # orthogonal positions can't both be + or -
|
|
67
|
+
for v in ['+', '-']:
|
|
68
|
+
magnet = self.pos_vars.get((pos, v))
|
|
69
|
+
if magnet is None:
|
|
70
|
+
continue
|
|
71
|
+
for direction in [Direction.DOWN, Direction.RIGHT]:
|
|
72
|
+
next_magnet = self.pos_vars.get((get_next_pos(pos, direction), v))
|
|
73
|
+
if next_magnet is None:
|
|
74
|
+
continue
|
|
75
|
+
self.model.AddBoolOr([magnet.is_active.Not(), next_magnet.is_active.Not()]) # ~magnet ∨ ~next_magnet
|
|
76
|
+
for row in range(self.V): # force side counts
|
|
77
|
+
if self.side_pos[row] != -1:
|
|
78
|
+
self.model.Add(lxp.Sum([self.pos_vars[(pos, '+')].is_active for pos in get_row_pos(row, self.H) if (pos, '+') in self.pos_vars]) == self.side_pos[row])
|
|
79
|
+
if self.side_neg[row] != -1:
|
|
80
|
+
self.model.Add(lxp.Sum([self.pos_vars[(pos, '-')].is_active for pos in get_row_pos(row, self.H) if (pos, '-') in self.pos_vars]) == self.side_neg[row])
|
|
81
|
+
for col in range(self.H): # force top counts
|
|
82
|
+
if self.top_pos[col] != -1:
|
|
83
|
+
self.model.Add(lxp.Sum([self.pos_vars[(pos, '+')].is_active for pos in get_col_pos(col, self.V) if (pos, '+') in self.pos_vars]) == self.top_pos[col])
|
|
84
|
+
if self.top_neg[col] != -1:
|
|
85
|
+
self.model.Add(lxp.Sum([self.pos_vars[(pos, '-')].is_active for pos in get_col_pos(col, self.V) if (pos, '-') in self.pos_vars]) == self.top_neg[col])
|
|
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: (magnet.uid, s) for magnet in board.magnets for (pos, s) in magnet.str_rep if solver.BooleanValue(magnet.is_active)})
|
|
90
|
+
def callback(single_res: SingleSolution):
|
|
91
|
+
print("Solution found")
|
|
92
|
+
print(combined_function(V=self.V, H=self.H,
|
|
93
|
+
center_char=lambda r, c: single_res.assignment[get_pos(x=c, y=r)][1],
|
|
94
|
+
cell_flags=id_board_to_wall_fn(np.array([[single_res.assignment.get(get_pos(x=c, y=r), (None, ' '))[0] for c in range(self.H)] for r in range(self.V)])),
|
|
95
|
+
))
|
|
96
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class SingleSolution:
|
|
11
|
+
assignment: dict[int, int]
|
|
12
|
+
|
|
13
|
+
def get_hashable_solution(self) -> str:
|
|
14
|
+
return json.dumps(self.assignment, sort_keys=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Board:
|
|
18
|
+
def __init__(self, regions: dict[int, set[int]], fixed_colors: dict[int, str]):
|
|
19
|
+
self.regions = regions
|
|
20
|
+
self.fixed_colors = fixed_colors
|
|
21
|
+
self.N = len(regions)
|
|
22
|
+
assert max(max(region) for region in regions.values() if region) == self.N - 1, 'region indices must be 0..N-1'
|
|
23
|
+
assert set(fixed_colors.keys()).issubset(set(range(self.N))), 'fixed colors must be a subset of region indices'
|
|
24
|
+
assert all(color in ['Y', 'R', 'G', 'B'] for color in fixed_colors.values()), 'fixed colors must be Y, R, G, or B'
|
|
25
|
+
self.color_to_int = {c: i for i, c in enumerate(set(fixed_colors.values()))}
|
|
26
|
+
self.int_to_color = {i: c for c, i in self.color_to_int.items()}
|
|
27
|
+
|
|
28
|
+
self.model = cp_model.CpModel()
|
|
29
|
+
self.model_vars: dict[int, cp_model.IntVar] = {}
|
|
30
|
+
|
|
31
|
+
self.create_vars()
|
|
32
|
+
self.add_all_constraints()
|
|
33
|
+
|
|
34
|
+
def create_vars(self):
|
|
35
|
+
for region_idx in self.regions.keys():
|
|
36
|
+
self.model_vars[region_idx] = self.model.NewIntVar(0, 3, f'{region_idx}')
|
|
37
|
+
|
|
38
|
+
def add_all_constraints(self):
|
|
39
|
+
# fix given colors
|
|
40
|
+
for region_idx, color in self.fixed_colors.items():
|
|
41
|
+
self.model.Add(self.model_vars[region_idx] == self.color_to_int[color])
|
|
42
|
+
# neighboring regions must have different colors
|
|
43
|
+
for region_idx, region_connections in self.regions.items():
|
|
44
|
+
for other_region_idx in region_connections: # neighboring regions must have different colors
|
|
45
|
+
self.model.Add(self.model_vars[region_idx] != self.model_vars[other_region_idx])
|
|
46
|
+
|
|
47
|
+
def solve_and_print(self, verbose: bool = True):
|
|
48
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
49
|
+
assignment: dict[int, int] = {}
|
|
50
|
+
for region_idx, var in board.model_vars.items():
|
|
51
|
+
assignment[region_idx] = solver.Value(var)
|
|
52
|
+
return SingleSolution(assignment=assignment)
|
|
53
|
+
def callback(single_res: SingleSolution):
|
|
54
|
+
print("Solution found")
|
|
55
|
+
print({k: self.int_to_color[v] for k, v in single_res.assignment.items()})
|
|
56
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import numpy as np
|
|
3
|
+
from ortools.sat.python import cp_model
|
|
4
|
+
from ortools.util.python import sorted_interval_list
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils import Direction, Pos, get_char, get_next_pos, get_row_pos, get_col_pos, in_bounds, set_char
|
|
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
|
+
@dataclass
|
|
12
|
+
class var_with_bounds:
|
|
13
|
+
var: cp_model.IntVar
|
|
14
|
+
min_value: int
|
|
15
|
+
max_value: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _div_bounds(a_min: int, a_max: int, b_min: int, b_max: int) -> tuple[int, int]:
|
|
19
|
+
assert not (b_min == 0 and b_max == 0), "Denominator interval cannot be [0, 0]."
|
|
20
|
+
denoms = [b_min, b_max]
|
|
21
|
+
if 0 in denoms:
|
|
22
|
+
denoms.remove(0)
|
|
23
|
+
if b_min <= -1:
|
|
24
|
+
denoms += [-1]
|
|
25
|
+
if b_max >= 1:
|
|
26
|
+
denoms += [1]
|
|
27
|
+
candidates = [a_min // d for d in denoms] + [a_max // d for d in denoms]
|
|
28
|
+
return min(candidates), max(candidates)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Board:
|
|
32
|
+
def __init__(self, board: np.array, digits: list[int]):
|
|
33
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
34
|
+
self.board = board
|
|
35
|
+
self.V, self.H = board.shape
|
|
36
|
+
assert self.V >= 3 and self.V % 2 == 1, f'board must have at least 3 rows and an odd number of rows. Got {self.V} rows.'
|
|
37
|
+
assert self.H >= 3 and self.H % 2 == 1, f'board must have at least 3 columns and an odd number of columns. Got {self.H} columns.'
|
|
38
|
+
self.digits = digits
|
|
39
|
+
self.domain_values = sorted_interval_list.Domain.FromValues(self.digits)
|
|
40
|
+
self.domain_values_no_zero = sorted_interval_list.Domain.FromValues([d for d in self.digits if d != 0])
|
|
41
|
+
|
|
42
|
+
self.model = cp_model.CpModel()
|
|
43
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
44
|
+
self.create_vars()
|
|
45
|
+
assert len(self.model_vars) == len(self.digits), f'len(model_vars) != len(digits), {len(self.model_vars)} != {len(self.digits)}'
|
|
46
|
+
self.model.AddAllDifferent(list(self.model_vars.values()))
|
|
47
|
+
|
|
48
|
+
def create_vars(self):
|
|
49
|
+
for row in range(0, self.V-2, 2):
|
|
50
|
+
line_pos = [pos for pos in get_row_pos(row, self.H)]
|
|
51
|
+
self.parse_line(line_pos)
|
|
52
|
+
for col in range(0, self.H-2, 2):
|
|
53
|
+
line_pos = [pos for pos in get_col_pos(col, self.V)]
|
|
54
|
+
self.parse_line(line_pos)
|
|
55
|
+
|
|
56
|
+
def parse_line(self, line_pos: list[Pos]) -> list[int]:
|
|
57
|
+
last_num = get_char(self.board, line_pos[-1])
|
|
58
|
+
equal_sign = get_char(self.board, line_pos[-2])
|
|
59
|
+
assert equal_sign == '=', f'last element of line must be =, got {equal_sign}'
|
|
60
|
+
line_pos = line_pos[:-2]
|
|
61
|
+
operators = [get_char(self.board, pos) for pos in line_pos[1::2]]
|
|
62
|
+
assert all(c.strip() in ['+', '-', '*', '/'] for c in operators), f'even indices of line must be operators, got {operators}'
|
|
63
|
+
digits_pos = line_pos[::2]
|
|
64
|
+
running_var = self.get_var(digits_pos[0], fixed=get_char(self.board, digits_pos[0]))
|
|
65
|
+
for pos, operator in zip(digits_pos[1:], operators):
|
|
66
|
+
running_var = self.apply_operator(operator, running_var, self.get_var(pos, fixed=get_char(self.board, pos)))
|
|
67
|
+
self.model.Add(running_var.var == int(last_num))
|
|
68
|
+
return running_var
|
|
69
|
+
|
|
70
|
+
def get_var(self, pos: Pos, fixed: str) -> var_with_bounds:
|
|
71
|
+
if pos not in self.model_vars:
|
|
72
|
+
domain = self.domain_values_no_zero if self.might_be_denominator(pos) else self.domain_values
|
|
73
|
+
self.model_vars[pos] = self.model.NewIntVarFromDomain(domain, f'{pos}')
|
|
74
|
+
if fixed.strip():
|
|
75
|
+
self.model.Add(self.model_vars[pos] == int(fixed))
|
|
76
|
+
return var_with_bounds(var=self.model_vars[pos], min_value=min(self.digits), max_value=max(self.digits))
|
|
77
|
+
|
|
78
|
+
def might_be_denominator(self, pos: Pos) -> bool:
|
|
79
|
+
"Important since if the variable might be a denominator and the domain includes 0 then ortools immediately sets the model as INVALID"
|
|
80
|
+
above_pos = get_next_pos(pos, Direction.UP)
|
|
81
|
+
left_pos = get_next_pos(pos, Direction.LEFT)
|
|
82
|
+
above_operator = get_char(self.board, above_pos) if in_bounds(above_pos, self.V, self.H) else None
|
|
83
|
+
left_operator = get_char(self.board, left_pos) if in_bounds(left_pos, self.V, self.H) else None
|
|
84
|
+
return above_operator == '/' or left_operator == '/'
|
|
85
|
+
|
|
86
|
+
def apply_operator(self, operator: str, a: var_with_bounds, b: var_with_bounds) -> var_with_bounds:
|
|
87
|
+
assert operator in ['+', '-', '*', '/'], f'invalid operator: {operator}'
|
|
88
|
+
if operator == "+":
|
|
89
|
+
lo = a.min_value + b.min_value
|
|
90
|
+
hi = a.max_value + b.max_value
|
|
91
|
+
res = self.model.NewIntVar(lo, hi, "sum")
|
|
92
|
+
self.model.Add(res == a.var + b.var)
|
|
93
|
+
elif operator == "-":
|
|
94
|
+
lo = a.min_value - b.max_value
|
|
95
|
+
hi = a.max_value - b.min_value
|
|
96
|
+
res = self.model.NewIntVar(lo, hi, "diff")
|
|
97
|
+
self.model.Add(res == a.var - b.var)
|
|
98
|
+
elif operator == "*":
|
|
99
|
+
cands = [a.min_value*b.min_value, a.min_value*b.max_value, a.max_value*b.min_value, a.max_value*b.max_value]
|
|
100
|
+
lo, hi = min(cands), max(cands)
|
|
101
|
+
res = self.model.NewIntVar(lo, hi, "prod")
|
|
102
|
+
self.model.AddMultiplicationEquality(res, [a.var, b.var])
|
|
103
|
+
elif operator == "/":
|
|
104
|
+
self.model.Add(b.var != 0)
|
|
105
|
+
lo, hi = _div_bounds(a.min_value, a.max_value, b.min_value, b.max_value)
|
|
106
|
+
res = self.model.NewIntVar(lo, hi, "quot")
|
|
107
|
+
self.model.AddDivisionEquality(res, a.var, b.var)
|
|
108
|
+
return var_with_bounds(res, lo, hi)
|
|
109
|
+
|
|
110
|
+
def solve_and_print(self, verbose: bool = True):
|
|
111
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
112
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
|
|
113
|
+
def callback(single_res: SingleSolution):
|
|
114
|
+
print("Solution found")
|
|
115
|
+
output_board = self.board.copy()
|
|
116
|
+
for pos, var in single_res.assignment.items():
|
|
117
|
+
set_char(output_board, pos, str(var))
|
|
118
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: str(output_board[r, c])))
|
|
119
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
from ortools.util.python import sorted_interval_list
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Direction8, Pos, get_all_pos, get_char, Direction, get_col_pos, get_next_pos, get_pos, get_row_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
|
+
def add_opcode_constraint(model: cp_model.CpModel, vlist: list[cp_model.IntVar], opcode: str, result: int):
|
|
13
|
+
assert opcode in ['+', '-', '*', '/'], "Invalid opcode"
|
|
14
|
+
assert opcode not in ['-', '/'] or len(vlist) == 2, f"Opcode '{opcode}' requires exactly 2 variables"
|
|
15
|
+
if opcode == '+':
|
|
16
|
+
model.Add(sum(vlist) == result)
|
|
17
|
+
elif opcode == '*':
|
|
18
|
+
model.AddMultiplicationEquality(result, vlist)
|
|
19
|
+
elif opcode == '-':
|
|
20
|
+
# either vlist[0] - vlist[1] == result OR vlist[1] - vlist[0] == result
|
|
21
|
+
b = model.NewBoolVar('sub_gate')
|
|
22
|
+
model.Add(vlist[0] - vlist[1] == result).OnlyEnforceIf(b)
|
|
23
|
+
model.Add(vlist[1] - vlist[0] == result).OnlyEnforceIf(b.Not())
|
|
24
|
+
elif opcode == '/':
|
|
25
|
+
# either v0 / v1 == result or v1 / v0 == result
|
|
26
|
+
b = model.NewBoolVar('div_gate')
|
|
27
|
+
# Ensure no division by zero
|
|
28
|
+
model.Add(vlist[0] != 0)
|
|
29
|
+
model.Add(vlist[1] != 0)
|
|
30
|
+
# case 1: v0 / v1 == result → v0 == v1 * result
|
|
31
|
+
model.Add(vlist[0] == vlist[1] * result).OnlyEnforceIf(b)
|
|
32
|
+
# case 2: v1 / v0 == result → v1 == v0 * result
|
|
33
|
+
model.Add(vlist[1] == vlist[0] * result).OnlyEnforceIf(b.Not())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Board:
|
|
37
|
+
def __init__(self, circle_board: np.array, board: Optional[np.array] = None):
|
|
38
|
+
assert circle_board.ndim == 2, f'circle_board must be 2d, got {circle_board.ndim}'
|
|
39
|
+
self.circle_board = circle_board
|
|
40
|
+
self.board = board
|
|
41
|
+
self.V, self.H = circle_board.shape
|
|
42
|
+
self.V += 1
|
|
43
|
+
self.H += 1
|
|
44
|
+
assert board is None or board.shape == (self.V, self.H), f'board must be {(self.V, self.H)}, got {board.shape}'
|
|
45
|
+
self.N = max(self.V, self.H)
|
|
46
|
+
|
|
47
|
+
self.model = cp_model.CpModel()
|
|
48
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
49
|
+
self.create_vars()
|
|
50
|
+
self.add_all_constraints()
|
|
51
|
+
|
|
52
|
+
def create_vars(self):
|
|
53
|
+
for pos in get_all_pos(self.V, self.H):
|
|
54
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
|
|
55
|
+
|
|
56
|
+
def add_all_constraints(self):
|
|
57
|
+
for pos in get_all_pos(self.V-1, self.H-1): # enforce circles
|
|
58
|
+
tl = self.model_vars[pos]
|
|
59
|
+
tr = self.model_vars[get_next_pos(pos, Direction.RIGHT)]
|
|
60
|
+
bl = self.model_vars[get_next_pos(pos, Direction.DOWN)]
|
|
61
|
+
br = self.model_vars[get_next_pos(pos, Direction8.DOWN_RIGHT)]
|
|
62
|
+
c = get_char(self.circle_board, pos).strip()
|
|
63
|
+
if c == 'E': # all are even
|
|
64
|
+
domain = sorted_interval_list.Domain.FromValues(list(range(2, self.N+1, 2)))
|
|
65
|
+
for v in [tl, tr, bl, br]:
|
|
66
|
+
self.model.AddLinearExpressionInDomain(v, domain)
|
|
67
|
+
elif c == 'O': # all are odd
|
|
68
|
+
domain = sorted_interval_list.Domain.FromValues(list(range(1, self.N+1, 2)))
|
|
69
|
+
for v in [tl, tr, bl, br]:
|
|
70
|
+
self.model.AddLinearExpressionInDomain(v, domain)
|
|
71
|
+
elif c:
|
|
72
|
+
result, opcode = c[:-1], c[-1]
|
|
73
|
+
opcode = opcode.replace('x', '*')
|
|
74
|
+
add_opcode_constraint(self.model, [tl, br], opcode, int(result))
|
|
75
|
+
add_opcode_constraint(self.model, [tr, bl], opcode, int(result))
|
|
76
|
+
for row in range(self.V): # every row is unique
|
|
77
|
+
self.model.AddAllDifferent([self.model_vars[p] for p in get_row_pos(row, self.H)])
|
|
78
|
+
for col in range(self.H): # every column is unique
|
|
79
|
+
self.model.AddAllDifferent([self.model_vars[p] for p in get_col_pos(col, self.V)])
|
|
80
|
+
if self.board is not None:
|
|
81
|
+
for pos in get_all_pos(self.V, self.H):
|
|
82
|
+
c = get_char(self.board, pos).strip()
|
|
83
|
+
if c:
|
|
84
|
+
self.model.Add(self.model_vars[pos] == int(c))
|
|
85
|
+
|
|
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: solver.Value(board.model_vars[pos]) for pos in get_all_pos(board.V, board.H)})
|
|
90
|
+
def callback(single_res: SingleSolution):
|
|
91
|
+
print("Solution found")
|
|
92
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)]).strip()))
|
|
93
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Union, Optional
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
7
|
+
|
|
8
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_neighbors8
|
|
9
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Board:
|
|
13
|
+
def __init__(self, board: np.array, mine_count: Optional[int] = None):
|
|
14
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
15
|
+
assert all(isinstance(i.item(), str) and (str(i.item()) in [' ', 'F', 'S', 'M', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9']) for i in np.nditer(board)), 'board must be either F, S, M, 0-9 or space'
|
|
16
|
+
self.board = board
|
|
17
|
+
self.V = board.shape[0]
|
|
18
|
+
self.H = board.shape[1]
|
|
19
|
+
self.mine_count = mine_count
|
|
20
|
+
self.model = cp_model.CpModel()
|
|
21
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
22
|
+
|
|
23
|
+
self.create_vars()
|
|
24
|
+
self.add_all_constraints()
|
|
25
|
+
|
|
26
|
+
def create_vars(self):
|
|
27
|
+
for pos in get_all_pos(self.V, self.H):
|
|
28
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
29
|
+
|
|
30
|
+
def add_all_constraints(self):
|
|
31
|
+
if self.mine_count is not None:
|
|
32
|
+
self.model.Add(lxp.Sum(list(self.model_vars.values())) == self.mine_count)
|
|
33
|
+
for pos in get_all_pos(self.V, self.H):
|
|
34
|
+
c = get_char(self.board, pos)
|
|
35
|
+
if c in ['F', ' ']:
|
|
36
|
+
continue
|
|
37
|
+
if c == 'S': # safe position but neighbors are unknown
|
|
38
|
+
self.model.Add(self.model_vars[pos] == 0)
|
|
39
|
+
continue
|
|
40
|
+
if c == 'M': # mine position but neighbors are unknown
|
|
41
|
+
self.model.Add(self.model_vars[pos] == 1)
|
|
42
|
+
continue
|
|
43
|
+
# clue indicates safe position AND neighbors are known
|
|
44
|
+
c = int(c)
|
|
45
|
+
self.model.Add(lxp.Sum([self.model_vars[n] for n in get_neighbors8(pos, self.V, self.H, include_self=False)]) == c)
|
|
46
|
+
self.model.Add(self.model_vars[pos] == 0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_feasible(board: np.array, pos: Pos = None, value: str = None, mine_count: int = None) -> bool:
|
|
50
|
+
"""Returns True if the board is feasible after setting the value at the position"""
|
|
51
|
+
board = board.copy()
|
|
52
|
+
if pos is not None and value is not None:
|
|
53
|
+
set_char(board, pos, str(value))
|
|
54
|
+
board = Board(board, mine_count=mine_count)
|
|
55
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
56
|
+
return SingleSolution(assignment={pos: solver.value(var) for pos, var in board.model_vars.items()})
|
|
57
|
+
return len(generic_solve_all(board, board_to_solution, max_solutions=1, verbose=False)) >= 1
|
|
58
|
+
|
|
59
|
+
def _is_safe(board: np.array, pos: Pos, mine_count: Optional[int] = None) -> Union[bool, None]:
|
|
60
|
+
"""Returns a True if the position is safe, False if it is a mine, otherwise None"""
|
|
61
|
+
safe_feasible = _is_feasible(board, pos, 'S', mine_count=mine_count)
|
|
62
|
+
mine_feasible = _is_feasible(board, pos, 'M', mine_count=mine_count)
|
|
63
|
+
if safe_feasible and mine_feasible:
|
|
64
|
+
return None
|
|
65
|
+
if safe_feasible:
|
|
66
|
+
return True
|
|
67
|
+
if mine_feasible:
|
|
68
|
+
return False
|
|
69
|
+
raise ValueError(f"Position {pos} has both safe and mine infeasible")
|
|
70
|
+
|
|
71
|
+
def give_next_guess(board: np.array, mine_count: Optional[int] = None, verbose: bool = True):
|
|
72
|
+
tic = time.time()
|
|
73
|
+
is_feasible = _is_feasible(board, mine_count=mine_count)
|
|
74
|
+
if not is_feasible:
|
|
75
|
+
raise ValueError("Board is not feasible")
|
|
76
|
+
V = board.shape[0]
|
|
77
|
+
H = board.shape[1]
|
|
78
|
+
check_positions = set() # any position that is unknown and has a neighbor with a clue or flag
|
|
79
|
+
flag_positions = set()
|
|
80
|
+
for pos in get_all_pos(V, H):
|
|
81
|
+
neighbors8 = get_neighbors8(pos, V, H, include_self=False)
|
|
82
|
+
if get_char(board, pos) not in [' ', 'F']:
|
|
83
|
+
continue
|
|
84
|
+
if get_char(board, pos) == 'F' or any(get_char(board, n) != ' ' for n in neighbors8):
|
|
85
|
+
check_positions.add(pos)
|
|
86
|
+
if get_char(board, pos) == 'F':
|
|
87
|
+
flag_positions.add(pos)
|
|
88
|
+
pos_dict = {pos: _is_safe(board, pos, mine_count) for pos in check_positions}
|
|
89
|
+
safe_positions = {pos for pos, is_safe in pos_dict.items() if is_safe is True}
|
|
90
|
+
mine_positions = {pos for pos, is_safe in pos_dict.items() if is_safe is False}
|
|
91
|
+
new_garuneed_mine_positions = mine_positions - flag_positions
|
|
92
|
+
wrong_flag_positions = flag_positions - mine_positions
|
|
93
|
+
if verbose:
|
|
94
|
+
if len(safe_positions) > 0:
|
|
95
|
+
print(f"Found {len(safe_positions)} new guaranteed safe positions")
|
|
96
|
+
print(safe_positions)
|
|
97
|
+
print('#'*10)
|
|
98
|
+
if len(mine_positions) == 0:
|
|
99
|
+
print("No guaranteed mine positions")
|
|
100
|
+
print('#'*10)
|
|
101
|
+
if len(new_garuneed_mine_positions) > 0:
|
|
102
|
+
print(f"Found {len(new_garuneed_mine_positions)} new guaranteed mine positions")
|
|
103
|
+
print(new_garuneed_mine_positions)
|
|
104
|
+
print('#'*10)
|
|
105
|
+
if len(wrong_flag_positions) > 0:
|
|
106
|
+
print("WARNING | "*4 + "WARNING")
|
|
107
|
+
print(f"Found {len(wrong_flag_positions)} wrong flag positions")
|
|
108
|
+
print(wrong_flag_positions)
|
|
109
|
+
print('#'*10)
|
|
110
|
+
toc = time.time()
|
|
111
|
+
print(f"Time taken: {toc - tic:.2f} seconds")
|
|
112
|
+
return safe_positions, new_garuneed_mine_positions, wrong_flag_positions
|
|
113
|
+
|
|
114
|
+
def print_board(board: np.array, safe_positions: set[Pos], new_garuneed_mine_positions: set[Pos], wrong_flag_positions: set[Pos]):
|
|
115
|
+
res = np.full((board.shape[0], board.shape[1]), ' ', dtype=object)
|
|
116
|
+
for pos in get_all_pos(board.shape[0], board.shape[1]):
|
|
117
|
+
if pos in safe_positions:
|
|
118
|
+
set_char(res, pos, 'S')
|
|
119
|
+
elif pos in new_garuneed_mine_positions:
|
|
120
|
+
set_char(res, pos, 'M')
|
|
121
|
+
elif get_char(board, pos) == 'F' and pos not in wrong_flag_positions:
|
|
122
|
+
set_char(res, pos, 'F')
|
|
123
|
+
print(res)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors8, get_pos
|
|
6
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
7
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Board:
|
|
11
|
+
def __init__(self, board: np.array):
|
|
12
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
13
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
14
|
+
self.board = board
|
|
15
|
+
self.V, self.H = board.shape
|
|
16
|
+
self.model = cp_model.CpModel()
|
|
17
|
+
self.model_vars: dict[Pos, 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
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
24
|
+
|
|
25
|
+
def add_all_constraints(self):
|
|
26
|
+
for pos in get_all_pos(self.V, self.H):
|
|
27
|
+
c = get_char(self.board, pos)
|
|
28
|
+
if not str(c).isdecimal():
|
|
29
|
+
continue
|
|
30
|
+
self.model.Add(lxp.Sum([self.model_vars[n] for n in get_neighbors8(pos, self.V, self.H, include_self=True)]) == int(c))
|
|
31
|
+
|
|
32
|
+
def solve_and_print(self, verbose: bool = True):
|
|
33
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
34
|
+
return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.model_vars.items()})
|
|
35
|
+
def callback(single_res: SingleSolution):
|
|
36
|
+
print("Solution found")
|
|
37
|
+
print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, center_char=lambda r, c: str(self.board[r, c]), text_on_shaded_cells=False))
|
|
38
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|