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,406 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from typing import Union
4
+ from enum import Enum
5
+
6
+ from ortools.sat.python import cp_model
7
+
8
+ from puzzle_solver.core.utils import Pos, get_pos
9
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, or_constraint
10
+
11
+
12
+ class PieceType(Enum):
13
+ KING = 1
14
+ QUEEN = 2
15
+ ROOK = 3
16
+ BISHOP = 4
17
+ KNIGHT = 5
18
+ PAWN = 6
19
+
20
+ @dataclass(frozen=True)
21
+ class SingleSolution:
22
+ assignment: dict[int, tuple[str, Pos, Pos, str]] # every time step a single piece moves from one position to another and eats another piece
23
+ position_occupied: dict[Pos, int]
24
+ # pos_assignment: dict[tuple[int, int, Union[Pos, str]], int]
25
+ # mover: dict[int, tuple[int, PieceType]]
26
+ # victim: dict[int, tuple[int, PieceType]]
27
+
28
+ def get_hashable_solution(self) -> str:
29
+ # only hash assignment
30
+ result = []
31
+ for _, (_, from_pos, to_pos, _) in sorted(self.assignment.items()):
32
+ result.append((from_pos.x, from_pos.y, to_pos.x, to_pos.y))
33
+ # order doesn't matter for uniqueness
34
+ result = sorted(result)
35
+ return json.dumps(result)
36
+
37
+
38
+ def parse_algebraic_notation(algebraic: str) -> tuple[PieceType, Pos]:
39
+ assert isinstance(algebraic, str), f'algebraic notation must be a string, got {type(algebraic)}'
40
+ assert len(algebraic) == 3, 'algebraic notation must be 3 characters'
41
+ p = {'K': PieceType.KING, 'Q': PieceType.QUEEN, 'R': PieceType.ROOK, 'B': PieceType.BISHOP, 'N': PieceType.KNIGHT, 'P': PieceType.PAWN}
42
+ assert algebraic[0] in p, 'invalid piece type'
43
+ assert algebraic[1] in 'abcdefgh', f'invalid file: {algebraic[1]}'
44
+ assert algebraic[2] in '12345678', f'invalid rank: {algebraic[2]}'
45
+ piece_type = p[algebraic[0]]
46
+ file, rank = algebraic[1:]
47
+ file = ord(file) - ord('a')
48
+ rank = int(rank) - 1
49
+ pos = get_pos(x=file, y=rank)
50
+ return (piece_type, pos)
51
+
52
+
53
+ def to_algebraic_notation_single_move(piece_type: str, from_pos: Pos, to_pos: Pos, victim_type: str) -> str:
54
+ letter = {PieceType.KING.name: 'K', PieceType.QUEEN.name: 'Q', PieceType.ROOK.name: 'R', PieceType.BISHOP.name: 'B', PieceType.KNIGHT.name: 'N', PieceType.PAWN.name: 'P'}
55
+ from_file_letter = chr(from_pos.x + ord('a'))
56
+ from_rank_letter = str(from_pos.y + 1)
57
+ to_file_letter = chr(to_pos.x + ord('a'))
58
+ to_rank_letter = str(to_pos.y + 1)
59
+ return f'{letter[piece_type]}{from_file_letter}{from_rank_letter}->{letter[victim_type]}{to_file_letter}{to_rank_letter}'
60
+
61
+
62
+ def to_algebraic_notation(single_solution: SingleSolution) -> list[str]:
63
+ move_sequence = single_solution.assignment
64
+ move_sequence = sorted(move_sequence.items(), key=lambda x: x[0])
65
+ move_sequence = [x[1] for x in move_sequence]
66
+ return [to_algebraic_notation_single_move(piece_type, from_pos, to_pos, victim_type) for piece_type, from_pos, to_pos, victim_type in move_sequence]
67
+
68
+
69
+ def is_same_row_col(from_pos: Pos, to_pos: Pos) -> bool:
70
+ return from_pos.x == to_pos.x or from_pos.y == to_pos.y
71
+
72
+
73
+ def is_diagonal(from_pos: Pos, to_pos: Pos) -> bool:
74
+ return abs(from_pos.x - to_pos.x) == abs(from_pos.y - to_pos.y)
75
+
76
+
77
+ def squares_between_rook(from_pos: Pos, to_pos: Pos) -> list[Pos]:
78
+ if not is_same_row_col(from_pos, to_pos):
79
+ return []
80
+ if abs(from_pos.x - to_pos.x) <= 1 and abs(from_pos.y - to_pos.y) <= 1: # within 2x2 thus no intermediate squares
81
+ return []
82
+ squares: list[Pos] = []
83
+ if from_pos.x == to_pos.x:
84
+ x = from_pos.x
85
+ step = 1 if to_pos.y > from_pos.y else -1
86
+ for y in range(from_pos.y + step, to_pos.y, step):
87
+ squares.append(get_pos(x=x, y=y))
88
+ else:
89
+ y = from_pos.y
90
+ step = 1 if to_pos.x > from_pos.x else -1
91
+ for x in range(from_pos.x + step, to_pos.x, step):
92
+ squares.append(get_pos(x=x, y=y))
93
+ return squares
94
+
95
+
96
+ def squares_between_bishop(from_pos: Pos, to_pos: Pos) -> list[Pos]:
97
+ if not is_diagonal(from_pos, to_pos):
98
+ return []
99
+ if abs(from_pos.x - to_pos.x) <= 1 and abs(from_pos.y - to_pos.y) <= 1: # within 2x2 thus no intermediate squares
100
+ return []
101
+ squares: list[Pos] = []
102
+ step_x = 1 if to_pos.x > from_pos.x else -1
103
+ step_y = 1 if to_pos.y > from_pos.y else -1
104
+ x = from_pos.x + step_x
105
+ y = from_pos.y + step_y
106
+ while x != to_pos.x and y != to_pos.y:
107
+ squares.append(get_pos(x=x, y=y))
108
+ x += step_x
109
+ y += step_y
110
+ return squares
111
+
112
+
113
+
114
+ def is_move_valid(from_pos: Pos, to_pos: Pos, piece_type: PieceType, color=None) -> tuple[bool, list[Pos]]:
115
+ """Returns: (is_valid, list of positions that must be empty for the move to be valid)
116
+ For Kings, Pawns, and Knights, no positions must be empty for the move to be valid.
117
+ A Queen is equivalent to a Rook and a Bishop.
118
+ A Rook needs all positions directly between the from and to position to be empty for the move to be valid.
119
+ Similarly, a Bishop needs all positions diagonally between the from and to position to be empty for the move to be valid.
120
+
121
+ Args:
122
+ from_pos (Pos): from position
123
+ to_pos (Pos): to position
124
+ piece_type (PieceType): piece type
125
+ color (str, optional): color of the piece (default: None, all pieces are assumed white)
126
+
127
+ Returns:
128
+ tuple[bool, list[Pos]]: (is_valid, list of positions that must be empty for the move to be valid)
129
+ """
130
+ if piece_type == PieceType.KING:
131
+ dx = abs(from_pos.x - to_pos.x)
132
+ dy = abs(from_pos.y - to_pos.y)
133
+ is_valid = dx <= 1 and dy <= 1
134
+ return is_valid, []
135
+ elif piece_type == PieceType.QUEEN: # rook + bishop
136
+ rook_valid = is_move_valid(from_pos, to_pos, PieceType.ROOK, color)
137
+ if rook_valid[0]:
138
+ return rook_valid
139
+ return is_move_valid(from_pos, to_pos, PieceType.BISHOP, color)
140
+ elif piece_type == PieceType.ROOK:
141
+ return is_same_row_col(from_pos, to_pos), squares_between_rook(from_pos, to_pos)
142
+ elif piece_type == PieceType.BISHOP:
143
+ return is_diagonal(from_pos, to_pos), squares_between_bishop(from_pos, to_pos)
144
+ elif piece_type == PieceType.KNIGHT:
145
+ dx = abs(from_pos.x - to_pos.x)
146
+ dy = abs(from_pos.y - to_pos.y)
147
+ is_valid = (dx == 2 and dy == 1) or (dx == 1 and dy == 2)
148
+ return is_valid, []
149
+ elif piece_type == PieceType.PAWN: # will always eat because the this is how the puzzle works
150
+ dx = to_pos.x - from_pos.x
151
+ dy = to_pos.y - from_pos.y
152
+ is_valid = abs(dx) == 1 and dy == (1 if color != 'B' else -1)
153
+ return is_valid, []
154
+
155
+
156
+ class Board:
157
+ def __init__(self, pieces: list[str], colors: list[str] = None, max_moves_per_piece: int = None, last_piece_alive: Union[PieceType, str] = None):
158
+ """
159
+ Args:
160
+ pieces: list of algebraic notation of the pieces
161
+ colors: list of colors of the pieces (default: None, all pieces are assumed white (i.e. all pieces are the same except for pawns move only up))
162
+ max_moves_per_piece: maximum number of moves per piece (default: None, no limit)
163
+ last_piece_alive: force the last piece alive to be of this type (default: None, any piece can be last man standing)
164
+ """
165
+ self.pieces: dict[int, tuple[PieceType, Pos]] = {i: parse_algebraic_notation(p) for i, p in enumerate(pieces)}
166
+ assert colors is None or (len(colors) == len(self.pieces) and all(c in ['B', 'W'] for c in colors)), f'if provided, colors must be a list of length {len(self.pieces)} with elements B or W, got {colors}'
167
+ self.colors = colors
168
+ self.N = len(self.pieces) # number of pieces
169
+ self.T = self.N # (N-1) moves + 1 initial state
170
+ self.max_moves_per_piece = max_moves_per_piece
171
+ self.last_piece_alive = last_piece_alive
172
+ self.V = 8 # board size
173
+ self.H = 8 # board size
174
+ # the puzzle rules mean the only legal positions are the starting positions of the pieces
175
+ self.all_legal_positions: set[Pos] = {pos for _, pos in self.pieces.values()}
176
+ assert len(self.all_legal_positions) == len(self.pieces), 'positions are not unique'
177
+
178
+ self.model = cp_model.CpModel()
179
+ # Input numbers: N is number of piece, T is number of time steps (=N here), B is board size (=N here because the only legal positions are the starting positions of the pieces):
180
+ # Number of variables
181
+ # piece_positions: O(NTB)
182
+ # is_dead: O(NT)
183
+ # mover: O(NT)
184
+ # victim: O(NT)
185
+ # position_occupied: O(TB)
186
+ # dies_this_timestep: O(NT)
187
+ # pos_is_p_star: O(NTB)
188
+ # Total: ~ (2*N)N^2 + 6N^2 = 2N^3 + 6N^2
189
+
190
+ # (piece_index, time_step, position) -> boolean variable (possible all false if i'm dead)
191
+ self.piece_positions: dict[tuple[int, int, Pos], cp_model.IntVar] = {}
192
+ self.is_dead: dict[tuple[int, int], cp_model.IntVar] = {} # Am I currently dead
193
+
194
+ # (piece_index, time_step) -> boolean variable indicating if the piece [moved/died]
195
+ self.mover: dict[tuple[int, int], cp_model.IntVar] = {} # did i move this timestep?
196
+ self.victim: dict[tuple[int, int], cp_model.IntVar] = {} # did i die this timestep?
197
+
198
+ # (time_step, position) -> boolean variable indicating if the position is occupied by any piece at this timestep
199
+ self.position_occupied: dict[tuple[int, Pos], cp_model.IntVar] = {}
200
+
201
+ self.create_vars()
202
+ self.add_all_constraints()
203
+ # total_vars = len(self.piece_positions) + len(self.is_dead) + len(self.mover) + len(self.victim) + len(self.position_occupied)
204
+ # print(f'Total number of variables: {total_vars}')
205
+ # print(f'Total number of constraints: {len(self.model.proto.constraints)}')
206
+
207
+ def can_move(self, p: int, t: int) -> bool:
208
+ c = self.colors[p]
209
+ return (c == 'W' and t % 2 == 0) or (c == 'B' and t % 2 == 1)
210
+
211
+ def can_be_victim(self, p: int, t: int) -> bool:
212
+ c = self.colors[p]
213
+ return (c == 'W' and t % 2 == 1) or (c == 'B' and t % 2 == 0)
214
+
215
+ def create_vars(self):
216
+ for p in range(self.N):
217
+ for t in range(self.T):
218
+ for pos in self.all_legal_positions:
219
+ self.piece_positions[(p, t, pos)] = self.model.NewBoolVar(f'piece_positions[{p},{t},{pos}]')
220
+ self.is_dead[(p, t)] = self.model.NewBoolVar(f'is_dead[{p},{t}]')
221
+ for p in range(self.N):
222
+ for t in range(self.T - 1): # final state does not have a mover or victim
223
+ self.mover[(p, t)] = self.model.NewBoolVar(f'mover[{p},{t}]')
224
+ self.victim[(p, t)] = self.model.NewBoolVar(f'victim[{p},{t}]')
225
+
226
+ for t in range(self.T):
227
+ for pos in self.all_legal_positions:
228
+ self.position_occupied[(t, pos)] = self.model.NewBoolVar(f'position_occupied[{t},{pos}]')
229
+
230
+ def add_all_constraints(self):
231
+ self.enforce_initial_state()
232
+ self.enforce_board_state_constraints()
233
+ self.enforce_mover_victim_constraints()
234
+ self.enforce_position_occupied_constraints()
235
+ if self.colors is not None: # t=0 and even timesteps are white, odd timesteps are black
236
+ self.enforce_colors_constraints()
237
+
238
+ def enforce_initial_state(self):
239
+ # initial state
240
+ for p, (_, initial_pos) in self.pieces.items():
241
+ self.model.Add(self.piece_positions[(p, 0, initial_pos)] == 1)
242
+ # can't be initially dead
243
+ self.model.Add(self.is_dead[(p, 0)] == 0)
244
+ # all others are blank
245
+ for pos in self.all_legal_positions:
246
+ if pos == initial_pos:
247
+ continue
248
+ self.model.Add(self.piece_positions[(p, 0, pos)] == 0)
249
+
250
+ def enforce_board_state_constraints(self):
251
+ # at each timestep and each piece, it can only be at exactly one position or dead
252
+ for p in range(self.N):
253
+ for t in range(self.T):
254
+ pos_vars = [self.piece_positions[(p, t, pos)] for pos in self.all_legal_positions]
255
+ pos_vars.append(self.is_dead[(p, t)])
256
+ self.model.AddExactlyOne(pos_vars)
257
+ # if im dead this timestep then im also dead next timestep
258
+ for p in range(self.N):
259
+ for t in range(self.T - 1):
260
+ self.model.Add(self.is_dead[(p, t + 1)] == 1).OnlyEnforceIf(self.is_dead[(p, t)])
261
+ # every move must be legal chess move
262
+ for p in range(self.N):
263
+ color = self.colors[p] if self.colors is not None else None
264
+ for t in range(self.T - 1):
265
+ for from_pos in self.all_legal_positions:
266
+ for to_pos in self.all_legal_positions:
267
+ if from_pos == to_pos:
268
+ continue
269
+ is_valid, need_to_be_empty = is_move_valid(from_pos, to_pos, self.pieces[p][0], color=color)
270
+ # remove non legal moves
271
+ need_to_be_empty = set(need_to_be_empty) & self.all_legal_positions
272
+ if not is_valid:
273
+ self.model.Add(self.piece_positions[(p, t + 1, to_pos)] == 0).OnlyEnforceIf([self.piece_positions[(p, t, from_pos)]])
274
+ elif len(need_to_be_empty) > 0:
275
+ occupied_between = self.model.NewBoolVar(f'occupied_between[{from_pos},{to_pos},{t},{p}]')
276
+ or_constraint(self.model, occupied_between, [self.position_occupied[(t, pos)] for pos in need_to_be_empty])
277
+ self.model.Add(self.piece_positions[(p, t + 1, to_pos)] == 0).OnlyEnforceIf([self.piece_positions[(p, t, from_pos)], occupied_between])
278
+
279
+ # if mover is i and victim is j then i HAS to be at the position of j at the next timestep
280
+ for p_mover in range(self.N):
281
+ for p_victim in range(self.N):
282
+ if p_mover == p_victim:
283
+ continue
284
+ for t in range(self.T - 1):
285
+ for pos in self.all_legal_positions:
286
+ self.model.Add(self.piece_positions[(p_mover, t + 1, pos)] == self.piece_positions[(p_victim, t, pos)]).OnlyEnforceIf([self.mover[(p_mover, t)], self.victim[(p_victim, t)]])
287
+
288
+ # optional parameter to force last piece alive
289
+ if self.last_piece_alive is not None:
290
+ target_ps = [p for p in range(self.N) if self.pieces[p][0] == self.last_piece_alive]
291
+ assert len(target_ps) == 1, f'multiple pieces of type {self.last_piece_alive} found'
292
+ target_p = target_ps[0]
293
+ # target piece is force to be last man standing
294
+ self.model.Add(self.is_dead[(target_p, self.T - 1)] == 0)
295
+ for p in range(self.N):
296
+ if p == target_p:
297
+ continue
298
+ self.model.Add(self.is_dead[(p, self.T - 1)] == 1)
299
+
300
+ def enforce_mover_victim_constraints(self):
301
+ for p in range(self.N):
302
+ for t in range(self.T - 1):
303
+ # if i'm dead at time step t then I did not move nor victimized
304
+ self.model.Add(self.mover[(p, t)] == 0).OnlyEnforceIf(self.is_dead[(p, t)])
305
+ self.model.Add(self.victim[(p, t)] == 0).OnlyEnforceIf(self.is_dead[(p, t)])
306
+ # if I was the mover or victim at time step t then I was not dead
307
+ self.model.Add(self.is_dead[(p, t)] == 0).OnlyEnforceIf(self.mover[(p, t)])
308
+ self.model.Add(self.is_dead[(p, t)] == 0).OnlyEnforceIf(self.victim[(p, t)])
309
+ # a victim cannot be the mover and vice versa
310
+ self.model.Add(self.mover[(p, t)] == 0).OnlyEnforceIf(self.victim[(p, t)])
311
+ self.model.Add(self.victim[(p, t)] == 0).OnlyEnforceIf(self.mover[(p, t)])
312
+
313
+ # if im dead next timestep and i was alive this timestep then im the victim
314
+ # can't rely on victim var here because the goal it to constrain it
315
+ dies_this_timestep = self.model.NewBoolVar(f'dies_this_timestep[{p},{t}]')
316
+ and_constraint(self.model, dies_this_timestep, [self.is_dead[(p, t + 1)], self.is_dead[(p, t)].Not()])
317
+ self.model.Add(self.victim[(p, t)] == dies_this_timestep)
318
+
319
+ # if next timestep im somewhere else then i was the mover
320
+ # i.e. there exists a position p* s.t. (piece_positions[p, t + 1, p*] AND NOT piece_positions[p, t, p*])
321
+ pos_is_p_star = []
322
+ for pos in self.all_legal_positions:
323
+ v = self.model.NewBoolVar(f'pos_is_p_star[{p},{t},{pos}]')
324
+ self.model.Add(v == 1).OnlyEnforceIf([self.piece_positions[(p, t + 1, pos)], self.piece_positions[(p, t, pos)].Not()])
325
+ self.model.Add(v == 0).OnlyEnforceIf([self.piece_positions[(p, t + 1, pos)].Not()])
326
+ self.model.Add(v == 0).OnlyEnforceIf([self.piece_positions[(p, t, pos)]])
327
+ pos_is_p_star.append(v)
328
+ or_constraint(self.model, self.mover[(p, t)], pos_is_p_star)
329
+
330
+ # at each timestep only one piece can be the mover
331
+ for t in range(self.T - 1):
332
+ self.model.AddExactlyOne([self.mover[(p, t)] for p in range(self.N)])
333
+ # at each timestep only one piece can be victimized
334
+ for t in range(self.T - 1):
335
+ self.model.AddExactlyOne([self.victim[(p, t)] for p in range(self.N)])
336
+
337
+ # optional parameter to force
338
+ if self.max_moves_per_piece is not None:
339
+ for p in range(self.N):
340
+ self.model.Add(sum([self.mover[(p, t)] for t in range(self.T - 1)]) <= self.max_moves_per_piece)
341
+
342
+ def enforce_position_occupied_constraints(self):
343
+ for t in range(self.T):
344
+ for pos in self.all_legal_positions:
345
+ self.model.Add(self.position_occupied[(t, pos)] == sum([self.piece_positions[(p, t, pos)] for p in range(self.N)]))
346
+
347
+ def enforce_colors_constraints(self):
348
+ # t=0 and even timesteps are white, odd timesteps are black
349
+ for p in range(self.N):
350
+ for t in range(self.T - 1):
351
+ if self.can_move(p, t):
352
+ continue
353
+ self.model.Add(self.mover[(p, t)] == 0)
354
+ # t=0 and even timesteps only black victims, odd timesteps only white victims
355
+ for p in range(self.N):
356
+ for t in range(self.T - 1):
357
+ if not self.can_be_victim(p, t):
358
+ self.model.Add(self.victim[(p, t)] == 0)
359
+
360
+
361
+ def solve_and_print(self, verbose: bool = True, max_solutions: int = None):
362
+ def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
363
+ pos_assignment: dict[tuple[int, int, Union[Pos, str]], int] = {}
364
+ for t in range(board.T):
365
+ for i in range(board.N):
366
+ for pos in board.all_legal_positions:
367
+ pos_assignment[(i, t, pos)] = solver.Value(board.piece_positions[(i, t, pos)])
368
+ pos_assignment[(i, t, 'DEAD')] = solver.Value(board.is_dead[(i, t)])
369
+ mover = {}
370
+ for t in range(board.T - 1):
371
+ for i in range(board.N):
372
+ if solver.Value(board.mover[(i, t)]):
373
+ mover[t] = (i, board.pieces[i][0].name)
374
+ victim = {}
375
+ for t in range(board.T - 1):
376
+ for i in range(board.N):
377
+ if solver.Value(board.victim[(i, t)]):
378
+ victim[t] = (i, board.pieces[i][0].name)
379
+
380
+ assignment: dict[int, tuple[int, Pos, Pos]] = {} # final result
381
+ for t in range(board.T - 1):
382
+ mover_i = mover[t][0]
383
+ victim_i = victim[t][0]
384
+ from_pos = next(pos for pos in board.all_legal_positions if pos_assignment[(mover_i, t, pos)])
385
+ to_pos = next(pos for pos in board.all_legal_positions if pos_assignment[(mover_i, t + 1, pos)])
386
+ assignment[t] = (board.pieces[mover_i][0].name, from_pos, to_pos, board.pieces[victim_i][0].name)
387
+ # return SingleSolution(assignment=assignment, pos_assignment=pos_assignment, mover=mover, victim=victim)
388
+ position_occupied = {(t, pos): int(solver.Value(board.position_occupied[(t, pos)])) for t in range(board.T) for pos in board.all_legal_positions}
389
+ return SingleSolution(assignment=assignment, position_occupied=position_occupied)
390
+
391
+ def callback(single_res: SingleSolution):
392
+ print("Solution found")
393
+ # pieces = sorted(set(i for (i, _, _) in single_res.assignment.keys()))
394
+ # for piece in pieces:
395
+ # print(f"Piece {piece} type: {single_res.piece_types[piece]}")
396
+ # # at each timestep a piece can only be in one position
397
+ # t_to_pos = {t: pos for (i, t, pos), v in single_res.assignment.items() if i == piece and v == 1}
398
+ # print(t_to_pos)
399
+ # print('victims:', single_res.victim)
400
+ # print('movers:', single_res.mover)
401
+ # print()
402
+ # for t in range(self.T):
403
+ # print('at timestep', t, 'the following positions are occupied', [pos for pos in self.all_legal_positions if single_res.position_occupied[(t, pos)] == 1])
404
+ move_sequence = to_algebraic_notation(single_res)
405
+ print(move_sequence)
406
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=max_solutions)
@@ -0,0 +1,9 @@
1
+ from .chess_range import Board as RangeBoard
2
+ from .chess_range import PieceType
3
+
4
+ class Board(RangeBoard):
5
+ def __init__(self, pieces: list[str]):
6
+ king_pieces = [p for p in range(len(pieces)) if pieces[p][0] == 'K']
7
+ assert len(king_pieces) == 1, 'exactly one king piece is required'
8
+ super().__init__(pieces, max_moves_per_piece=2, last_piece_alive=PieceType.KING)
9
+
@@ -0,0 +1,262 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ from typing import Union
4
+ from enum import Enum
5
+
6
+ import numpy as np
7
+ from ortools.sat.python import cp_model
8
+
9
+ from puzzle_solver.core.utils import Pos, get_pos, get_all_pos, get_char, set_char, get_row_pos, get_col_pos, Direction, get_next_pos, in_bounds
10
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, or_constraint
11
+
12
+
13
+
14
+ class PieceType(Enum):
15
+ KING = 1
16
+ QUEEN = 2
17
+ ROOK = 3
18
+ BISHOP = 4
19
+ KNIGHT = 5
20
+ PAWN = 6
21
+
22
+ @dataclass(frozen=True)
23
+ class SingleSolution:
24
+ assignment: dict[int, tuple[str, Pos, Pos, str]] # every time step a single piece moves from one position to another and eats another piece
25
+ # pos_assignment: dict[tuple[int, int, Union[Pos, str]], int]
26
+ # mover: dict[int, tuple[int, PieceType]]
27
+ # victim: dict[int, tuple[int, PieceType]]
28
+
29
+ def get_hashable_solution(self) -> str:
30
+ # only hash assignment
31
+ result = []
32
+ for _, (_, from_pos, to_pos, _) in sorted(self.assignment.items()):
33
+ result.append((from_pos.x, from_pos.y, to_pos.x, to_pos.y))
34
+ # order doesnt matter for uniqueness
35
+ result = sorted(result)
36
+ return json.dumps(result)
37
+
38
+
39
+ def parse_algebraic_notation(algebraic: str) -> tuple[PieceType, Pos]:
40
+ assert len(algebraic) == 3, 'algebraic notation must be 3 characters'
41
+ p = {'K': PieceType.KING, 'Q': PieceType.QUEEN, 'R': PieceType.ROOK, 'B': PieceType.BISHOP, 'N': PieceType.KNIGHT, 'P': PieceType.PAWN}
42
+ assert algebraic[0] in p, 'invalid piece type'
43
+ assert algebraic[1] in 'abcdefgh', f'invalid file: {algebraic[1]}'
44
+ assert algebraic[2] in '12345678', f'invalid rank: {algebraic[2]}'
45
+ piece_type = p[algebraic[0]]
46
+ file, rank = algebraic[1:]
47
+ file = ord(file) - ord('a')
48
+ rank = int(rank) - 1
49
+ pos = get_pos(x=file, y=rank)
50
+ return (piece_type, pos)
51
+
52
+ def to_algebraic_notation_single_move(piece_type: str, from_pos: Pos, to_pos: Pos, victim_type: str) -> str:
53
+ letter = {PieceType.KING.name: 'K', PieceType.QUEEN.name: 'Q', PieceType.ROOK.name: 'R', PieceType.BISHOP.name: 'B', PieceType.KNIGHT.name: 'N', PieceType.PAWN.name: 'P'}
54
+ from_file_letter = chr(from_pos.x + ord('a'))
55
+ from_rank_letter = str(from_pos.y + 1)
56
+ to_file_letter = chr(to_pos.x + ord('a'))
57
+ to_rank_letter = str(to_pos.y + 1)
58
+ return f'{letter[piece_type]}{from_file_letter}{from_rank_letter}->{letter[victim_type]}{to_file_letter}{to_rank_letter}'
59
+
60
+ def to_algebraic_notation(move_sequence: dict[int, tuple[str, Pos, Pos, str]]) -> list[str]:
61
+ move_sequence = sorted(move_sequence.items(), key=lambda x: x[0])
62
+ move_sequence = [x[1] for x in move_sequence]
63
+ return [to_algebraic_notation_single_move(piece_type, from_pos, to_pos, victim_type) for piece_type, from_pos, to_pos, victim_type in move_sequence]
64
+
65
+
66
+ def is_same_row_col(from_pos: Pos, to_pos: Pos) -> bool:
67
+ return from_pos.x == to_pos.x or from_pos.y == to_pos.y
68
+
69
+ def is_diagonal(from_pos: Pos, to_pos: Pos) -> bool:
70
+ return abs(from_pos.x - to_pos.x) == abs(from_pos.y - to_pos.y)
71
+
72
+ def is_move_valid(from_pos: Pos, to_pos: Pos, piece_type: PieceType) -> bool:
73
+ if piece_type == PieceType.KING:
74
+ dx = abs(from_pos.x - to_pos.x)
75
+ dy = abs(from_pos.y - to_pos.y)
76
+ return dx <= 1 and dy <= 1
77
+ elif piece_type == PieceType.QUEEN:
78
+ return is_same_row_col(from_pos, to_pos) or is_diagonal(from_pos, to_pos)
79
+ elif piece_type == PieceType.ROOK:
80
+ return is_same_row_col(from_pos, to_pos)
81
+ elif piece_type == PieceType.BISHOP:
82
+ return is_diagonal(from_pos, to_pos)
83
+ elif piece_type == PieceType.KNIGHT:
84
+ dx = abs(from_pos.x - to_pos.x)
85
+ dy = abs(from_pos.y - to_pos.y)
86
+ return (dx == 2 and dy == 1) or (dx == 1 and dy == 2)
87
+ elif piece_type == PieceType.PAWN: # will always eat because the this is how the puzzle works
88
+ dx = to_pos.x - from_pos.x
89
+ dy = to_pos.y - from_pos.y
90
+ return abs(dx) == 1 and dy == 1
91
+
92
+
93
+ class Board:
94
+ def __init__(self, pieces: list[str]):
95
+ self.pieces: dict[int, tuple[PieceType, Pos]] = {i: parse_algebraic_notation(p) for i, p in enumerate(pieces)}
96
+ self.N = len(self.pieces) # number of pieces
97
+ self.T = self.N # (N-1) moves + 1 initial state
98
+
99
+ self.V = 8 # board size
100
+ self.H = 8 # board size
101
+ self.num_positions = self.V * self.H # 8x8 board
102
+
103
+ self.model = cp_model.CpModel()
104
+ # Input numbers: N is number of piece, T is number of time steps (=N here), B is board size (=64 here):
105
+ # Number of variables
106
+ # piece_positions: O(NTB)
107
+ # is_dead: O(NT)
108
+ # mover: O(NT)
109
+ # victim: O(NT)
110
+ # dies_this_timestep: O(NT)
111
+ # pos_is_p_star: O(NTB)
112
+ # Total: ~ (2*64)N^2 + 5N^2 = 132N^2
113
+
114
+ # (piece_index, time_step, position) -> boolean variable (possible all false if i'm dead)
115
+ self.piece_positions: dict[tuple[int, int, Pos], cp_model.IntVar] = {}
116
+ self.is_dead: dict[tuple[int, int], cp_model.IntVar] = {} # Am I currently dead
117
+
118
+ # (piece_index, time_step) -> boolean variable indicating if the piece [moved/died]
119
+ self.mover: dict[tuple[int, int], cp_model.IntVar] = {} # did i move this timestep?
120
+ self.victim: dict[tuple[int, int], cp_model.IntVar] = {} # did i die this timestep?
121
+
122
+ self.create_vars()
123
+ self.add_all_constraints()
124
+
125
+ def create_vars(self):
126
+ for p in range(self.N):
127
+ for t in range(self.T):
128
+ for pos in get_all_pos(self.V, self.H):
129
+ self.piece_positions[(p, t, pos)] = self.model.NewBoolVar(f'piece_positions[{p},{t},{pos}]')
130
+ self.is_dead[(p, t)] = self.model.NewBoolVar(f'is_dead[{p},{t}]')
131
+ for p in range(self.N):
132
+ for t in range(self.T - 1): # final state does not have a mover or victim
133
+ self.mover[(p, t)] = self.model.NewIntVar(0, 1, f'mover[{p},{t}]')
134
+ self.victim[(p, t)] = self.model.NewIntVar(0, 1, f'victim[{p},{t}]')
135
+
136
+ def add_all_constraints(self):
137
+ self.enforce_initial_state()
138
+ self.enforce_board_state_constraints()
139
+ self.enforce_mover_victim_constraints()
140
+
141
+ def enforce_initial_state(self):
142
+ # initial state
143
+ for p, (_, initial_pos) in self.pieces.items():
144
+ self.model.Add(self.piece_positions[(p, 0, initial_pos)] == 1)
145
+ # cant be initially dead
146
+ self.model.Add(self.is_dead[(p, 0)] == 0)
147
+ # all others are blank
148
+ for pos in get_all_pos(self.V, self.H):
149
+ if pos == initial_pos:
150
+ continue
151
+ self.model.Add(self.piece_positions[(p, 0, pos)] == 0)
152
+
153
+ def enforce_board_state_constraints(self):
154
+ # at each timestep and each piece, it can only be at exactly one position or dead
155
+ for p in range(self.N):
156
+ for t in range(self.T):
157
+ pos_vars = [self.piece_positions[(p, t, pos)] for pos in get_all_pos(self.V, self.H)]
158
+ pos_vars.append(self.is_dead[(p, t)])
159
+ self.model.AddExactlyOne(pos_vars)
160
+ # if im dead this timestep then im also dead next timestep
161
+ for p in range(self.N):
162
+ for t in range(self.T - 1):
163
+ self.model.Add(self.is_dead[(p, t + 1)] == 1).OnlyEnforceIf(self.is_dead[(p, t)])
164
+ # every move must be legal chess move
165
+ for p in range(self.N):
166
+ for t in range(self.T - 1):
167
+ for from_pos in get_all_pos(self.V, self.H):
168
+ for to_pos in get_all_pos(self.V, self.H):
169
+ if from_pos == to_pos:
170
+ continue
171
+ if not is_move_valid(from_pos, to_pos, self.pieces[p][0]):
172
+ self.model.Add(self.piece_positions[(p, t + 1, to_pos)] == 0).OnlyEnforceIf([self.piece_positions[(p, t, from_pos)]])
173
+ # if mover is i and victim is j then i HAS to be at the position of j at the next timestep
174
+ for p_mover in range(self.N):
175
+ for p_victim in range(self.N):
176
+ if p_mover == p_victim:
177
+ continue
178
+ for t in range(self.T - 1):
179
+ for pos in get_all_pos(self.V, self.H):
180
+ self.model.Add(self.piece_positions[(p_mover, t + 1, pos)] == self.piece_positions[(p_victim, t, pos)]).OnlyEnforceIf([self.mover[(p_mover, t)], self.victim[(p_victim, t)]])
181
+
182
+ def enforce_mover_victim_constraints(self):
183
+ for p in range(self.N):
184
+ for t in range(self.T - 1):
185
+ # if i'm dead at time step t then I did not move nor victimized
186
+ self.model.Add(self.mover[(p, t)] == 0).OnlyEnforceIf(self.is_dead[(p, t)])
187
+ self.model.Add(self.victim[(p, t)] == 0).OnlyEnforceIf(self.is_dead[(p, t)])
188
+ # if I was the mover or victim at time step t then I was not dead
189
+ self.model.Add(self.is_dead[(p, t)] == 0).OnlyEnforceIf(self.mover[(p, t)])
190
+ self.model.Add(self.is_dead[(p, t)] == 0).OnlyEnforceIf(self.victim[(p, t)])
191
+ # a victim cannot be the mover and vice versa
192
+ self.model.Add(self.mover[(p, t)] == 0).OnlyEnforceIf(self.victim[(p, t)])
193
+ self.model.Add(self.victim[(p, t)] == 0).OnlyEnforceIf(self.mover[(p, t)])
194
+
195
+ # if im dead next timestep and i was alive this timestep then im the victim
196
+ # cant rely on victim var here because the goal it to constrain it
197
+ dies_this_timestep = self.model.NewBoolVar(f'dies_this_timestep[{p},{t}]')
198
+ and_constraint(self.model, dies_this_timestep, [self.is_dead[(p, t + 1)], self.is_dead[(p, t)].Not()])
199
+ self.model.Add(self.victim[(p, t)] == dies_this_timestep)
200
+
201
+ # if next timestep im somewhere else then i was the mover
202
+ # i.e. there exists a position p* s.t. (piece_positions[p, t + 1, p*] AND NOT piece_positions[p, t, p*])
203
+ pos_is_p_star = []
204
+ for pos in get_all_pos(self.V, self.H):
205
+ v = self.model.NewBoolVar(f'pos_is_p_star[{p},{t},{pos}]')
206
+ self.model.Add(v == 1).OnlyEnforceIf([self.piece_positions[(p, t + 1, pos)], self.piece_positions[(p, t, pos)].Not()])
207
+ self.model.Add(v == 0).OnlyEnforceIf([self.piece_positions[(p, t + 1, pos)].Not()])
208
+ self.model.Add(v == 0).OnlyEnforceIf([self.piece_positions[(p, t, pos)]])
209
+ pos_is_p_star.append(v)
210
+ or_constraint(self.model, self.mover[(p, t)], pos_is_p_star)
211
+
212
+ # at each timestep only one piece can be the mover
213
+ for t in range(self.T - 1):
214
+ self.model.AddExactlyOne([self.mover[(p, t)] for p in range(self.N)])
215
+ # at each timestep only one piece can be victimized
216
+ for t in range(self.T - 1):
217
+ self.model.AddExactlyOne([self.victim[(p, t)] for p in range(self.N)])
218
+
219
+
220
+ def solve_and_print(self, verbose: bool = True, max_solutions: int = None):
221
+ def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
222
+ pos_assignment: dict[tuple[int, int, Union[Pos, str]], int] = {}
223
+ for t in range(board.T):
224
+ for i in range(board.N):
225
+ for pos in get_all_pos(board.V, board.H):
226
+ pos_assignment[(i, t, pos)] = solver.Value(board.piece_positions[(i, t, pos)])
227
+ pos_assignment[(i, t, 'DEAD')] = solver.Value(board.is_dead[(i, t)])
228
+ mover = {}
229
+ for t in range(board.T - 1):
230
+ for i in range(board.N):
231
+ if solver.Value(board.mover[(i, t)]):
232
+ mover[t] = (i, board.pieces[i][0].name)
233
+ victim = {}
234
+ for t in range(board.T - 1):
235
+ for i in range(board.N):
236
+ if solver.Value(board.victim[(i, t)]):
237
+ victim[t] = (i, board.pieces[i][0].name)
238
+
239
+ assignment: dict[int, tuple[int, Pos, Pos]] = {} # final result
240
+ for t in range(board.T - 1):
241
+ mover_i = mover[t][0]
242
+ victim_i = victim[t][0]
243
+ from_pos = next(pos for pos in get_all_pos(board.V, board.H) if pos_assignment[(mover_i, t, pos)])
244
+ to_pos = next(pos for pos in get_all_pos(board.V, board.H) if pos_assignment[(mover_i, t + 1, pos)])
245
+ assignment[t] = (board.pieces[mover_i][0].name, from_pos, to_pos, board.pieces[victim_i][0].name)
246
+ # return SingleSolution(assignment=assignment, pos_assignment=pos_assignment, mover=mover, victim=victim)
247
+ return SingleSolution(assignment=assignment)
248
+
249
+ def callback(single_res: SingleSolution):
250
+ print("Solution found")
251
+ # pieces = sorted(set(i for (i, _, _) in single_res.assignment.keys()))
252
+ # for piece in pieces:
253
+ # print(f"Piece {piece} type: {single_res.piece_types[piece]}")
254
+ # # at each timestep a piece can only be in one position
255
+ # t_to_pos = {t: pos for (i, t, pos), v in single_res.assignment.items() if i == piece and v == 1}
256
+ # print(t_to_pos)
257
+ # print('victims:', single_res.victim)
258
+ # print('movers:', single_res.mover)
259
+ # print()
260
+ move_sequence = to_algebraic_notation(single_res.assignment)
261
+ print(move_sequence)
262
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=max_solutions)