multi-puzzle-solver 0.9.30__py3-none-any.whl → 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of multi-puzzle-solver might be problematic. Click here for more details.

Files changed (45) hide show
  1. {multi_puzzle_solver-0.9.30.dist-info → multi_puzzle_solver-1.0.2.dist-info}/METADATA +331 -76
  2. multi_puzzle_solver-1.0.2.dist-info/RECORD +69 -0
  3. puzzle_solver/__init__.py +58 -1
  4. puzzle_solver/core/utils_ortools.py +8 -6
  5. puzzle_solver/core/utils_visualizer.py +23 -41
  6. puzzle_solver/puzzles/binairo/binairo.py +4 -4
  7. puzzle_solver/puzzles/black_box/black_box.py +5 -11
  8. puzzle_solver/puzzles/bridges/bridges.py +1 -1
  9. puzzle_solver/puzzles/chess_range/chess_range.py +3 -3
  10. puzzle_solver/puzzles/chess_range/chess_solo.py +1 -1
  11. puzzle_solver/puzzles/filling/filling.py +3 -3
  12. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  13. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +198 -0
  14. puzzle_solver/puzzles/galaxies/galaxies.py +1 -1
  15. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +3 -3
  16. puzzle_solver/puzzles/guess/guess.py +1 -1
  17. puzzle_solver/puzzles/heyawake/heyawake.py +3 -3
  18. puzzle_solver/puzzles/inertia/inertia.py +1 -1
  19. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +13 -10
  20. puzzle_solver/puzzles/inertia/tsp.py +5 -7
  21. puzzle_solver/puzzles/kakuro/kakuro.py +1 -1
  22. puzzle_solver/puzzles/keen/keen.py +2 -2
  23. puzzle_solver/puzzles/minesweeper/minesweeper.py +2 -3
  24. puzzle_solver/puzzles/nonograms/nonograms.py +3 -3
  25. puzzle_solver/puzzles/norinori/norinori.py +2 -2
  26. puzzle_solver/puzzles/nurikabe/nurikabe.py +2 -2
  27. puzzle_solver/puzzles/range/range.py +1 -1
  28. puzzle_solver/puzzles/rectangles/rectangles.py +2 -6
  29. puzzle_solver/puzzles/shingoki/shingoki.py +1 -1
  30. puzzle_solver/puzzles/signpost/signpost.py +2 -2
  31. puzzle_solver/puzzles/slant/parse_map/parse_map.py +7 -5
  32. puzzle_solver/puzzles/slitherlink/slitherlink.py +1 -1
  33. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +6 -5
  34. puzzle_solver/puzzles/stitches/stitches.py +1 -1
  35. puzzle_solver/puzzles/sudoku/sudoku.py +91 -20
  36. puzzle_solver/puzzles/tents/tents.py +2 -2
  37. puzzle_solver/puzzles/thermometers/thermometers.py +1 -1
  38. puzzle_solver/puzzles/towers/towers.py +1 -1
  39. puzzle_solver/puzzles/undead/undead.py +1 -1
  40. puzzle_solver/puzzles/unruly/unruly.py +1 -1
  41. puzzle_solver/puzzles/yin_yang/yin_yang.py +1 -1
  42. puzzle_solver/utils/visualizer.py +1 -1
  43. multi_puzzle_solver-0.9.30.dist-info/RECORD +0 -67
  44. {multi_puzzle_solver-0.9.30.dist-info → multi_puzzle_solver-1.0.2.dist-info}/WHEEL +0 -0
  45. {multi_puzzle_solver-0.9.30.dist-info → multi_puzzle_solver-1.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,69 @@
1
+ puzzle_solver/__init__.py,sha256=Ll-qN1ElCTTILeun1u4t5dU0CdI3DkCX0ZNf0Q2UJtE,4886
2
+ puzzle_solver/core/utils.py,sha256=XBW5j-IwtJMPMP-ycmY6SqRCM1NOVl5O6UeoGqNj618,8153
3
+ puzzle_solver/core/utils_ortools.py,sha256=ACV3HgKWpEUTt1lpqsPryK1DeZpu7kdWQKEWTLJ2tfs,10384
4
+ puzzle_solver/core/utils_visualizer.py,sha256=ymuhF75uwJbNhN8XVDYEPqw6sPKoqRaaxlhGeHtXpLs,20201
5
+ puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
6
+ puzzle_solver/puzzles/battleships/battleships.py,sha256=RuYCrs4j0vUjlU139NRYYP-uNPAgO0V7hAzbsHrRwD8,7446
7
+ puzzle_solver/puzzles/binairo/binairo.py,sha256=NmVPIoyVCoMLaSFhsN0TcJQYvav9hi4hSwoAVirYhDU,6835
8
+ puzzle_solver/puzzles/binairo/binairo_plus.py,sha256=TvLG3olwANtft3LuCF-y4OofpU9PNa4IXDqgZqsD-g0,267
9
+ puzzle_solver/puzzles/black_box/black_box.py,sha256=EiCVkbhUP0x94otvQirv7MrggTu0ok8MIUPbxv6jkIU,15544
10
+ puzzle_solver/puzzles/bridges/bridges.py,sha256=QwOhZyO5urbatkNyPmQxZ_lGM01ZejndMr_eoiBkr7g,5394
11
+ puzzle_solver/puzzles/chess_range/chess_melee.py,sha256=D-_Oi8OyxsVe1j3dIKYwRlxgeb3NWLmDWGcv-oclY0c,195
12
+ puzzle_solver/puzzles/chess_range/chess_range.py,sha256=_VHlpUPnqeBstvSIt9RtTV-w2etSK7UrEHg6sErNqtU,21068
13
+ puzzle_solver/puzzles/chess_range/chess_solo.py,sha256=ByDfcRsk5FVmFicpU_DpLoLTJ99Kr___vX4y8ln8_EQ,400
14
+ puzzle_solver/puzzles/chess_sequence/chess_sequence.py,sha256=6ap3Wouf2PxHV4P56B9ol1QT98Ym6VHaxorQZWl6LnY,13692
15
+ puzzle_solver/puzzles/dominosa/dominosa.py,sha256=Nmb7pn8U27QJwGy9F3wo8ylqo2_U51OAo3GN2soaNpc,7195
16
+ puzzle_solver/puzzles/filling/filling.py,sha256=R8UIbztk3zNCeNbVClBJoKZHKeHwK_pesjGmMaEEQO0,5536
17
+ puzzle_solver/puzzles/flip/flip.py,sha256=ZngJLUhRNc7qqo2wtNLdMPx4u9w9JTUge27PmdXyDCw,3985
18
+ puzzle_solver/puzzles/flood_it/flood_it.py,sha256=jnCtH1sZIt6K4hbQDSsiM1Cd8FjQNP7cfw2ObUW5fEQ,7948
19
+ puzzle_solver/puzzles/flood_it/parse_map/parse_map.py,sha256=0aw1TbiyxknY2hUAXaP3nXqT6I6mT9BIiERJSCj57xw,8245
20
+ puzzle_solver/puzzles/galaxies/galaxies.py,sha256=36X9jaQfvLIWFkBY1VZH6I59eCDkc77U06NDtKRUECY,5571
21
+ puzzle_solver/puzzles/galaxies/parse_map/parse_map.py,sha256=XmFqVN_oRfq9AZFWy5ViUJ2Szjgx-srrRkFPJXEEyFo,9358
22
+ puzzle_solver/puzzles/guess/guess.py,sha256=MpyrF6YVu0S1fzX-BllwxGKRGacWJpeLbNn5GetuEyo,10792
23
+ puzzle_solver/puzzles/heyawake/heyawake.py,sha256=L_y44dHArOvO_tDyO35dwkvqdk9eEGItO7n4FDfzNDc,5586
24
+ puzzle_solver/puzzles/inertia/inertia.py,sha256=-Y5fr7aK20zwmGHsZql7pYCq1kyMZglvkVZ6uIDf1HA,5658
25
+ puzzle_solver/puzzles/inertia/tsp.py,sha256=mAhlSjCWespASeN8uLZ0JkYDw-ZqFEpal6NM-ubpCXw,15313
26
+ puzzle_solver/puzzles/inertia/parse_map/parse_map.py,sha256=x0d64gTBd0HC2lO5uOpX2VKWfwj8rRiz0mQM_lqNmWs,8457
27
+ puzzle_solver/puzzles/kakurasu/kakurasu.py,sha256=VNGMJnBHDi6WkghLObRLhUvkmrPaGphTTUDMC0TkQvQ,2064
28
+ puzzle_solver/puzzles/kakuro/kakuro.py,sha256=m22Ju-V2BdQl2Ng_pjVUSrxPCtIfqezdpebutURlhvg,4348
29
+ puzzle_solver/puzzles/keen/keen.py,sha256=adSA_pc1m6F6jV7a-PpQxdci1bv4psCNRNt9hMIQdSY,5034
30
+ puzzle_solver/puzzles/light_up/light_up.py,sha256=iSA1rjZMFsnI0V0Nxivxox4qZkB7PvUrROSHXcoUXds,4541
31
+ puzzle_solver/puzzles/lits/lits.py,sha256=3fPIkhAIUz8JokcfaE_ZM3b0AFEnf5xPzGJ2qnm8SWY,7099
32
+ puzzle_solver/puzzles/magnets/magnets.py,sha256=-Wl49JD_PKeq735zQVMQ3XSQX6gdHiY-7PKw-Sh16jw,6474
33
+ puzzle_solver/puzzles/map/map.py,sha256=sxc57tapB8Tsgam-yoDitln1o-EB_SbIYvO6WEYy3us,2582
34
+ puzzle_solver/puzzles/minesweeper/minesweeper.py,sha256=gSdFsuZ-KrwVxgI1HF2q_pYleZ6vBm9jjRTFlboVnLY,5871
35
+ puzzle_solver/puzzles/mosaic/mosaic.py,sha256=QX_nVpVKQg8OfaUcqFk9tKqsDyVqvZc6-XWvfI3YcSw,2175
36
+ puzzle_solver/puzzles/nonograms/nonograms.py,sha256=dTKfMwBL49hW3bNd34ETXW7lBRPuQeSPNSCHqHmfybg,6066
37
+ puzzle_solver/puzzles/norinori/norinori.py,sha256=qR7V7NbZRN_ME90R2jL47AkGik1CY6JlAPhLBMXP2Gw,4714
38
+ puzzle_solver/puzzles/nurikabe/nurikabe.py,sha256=3cbW7X4kAMQK8PkH_t65fzT5cI0O6tWWOqpQUVyuGT4,6501
39
+ puzzle_solver/puzzles/palisade/palisade.py,sha256=T-LXlaLU5OwUQ24QWJWhBUFUktg0qDODTilNmBaXs4I,5014
40
+ puzzle_solver/puzzles/pearl/pearl.py,sha256=OhzpMYpxqvR3GCd5NH4ETT0NO4X753kRi6p5omYLChM,6798
41
+ puzzle_solver/puzzles/range/range.py,sha256=q0J3crlGfjYZSA6Dh4iMCwP_gRMWid-_8KPgggOrFKk,4410
42
+ puzzle_solver/puzzles/rectangles/rectangles.py,sha256=MgOhZJGr9DVHb9bB8EAuwus0_8frBqRWqMwrOvMezHQ,6918
43
+ puzzle_solver/puzzles/shakashaka/shakashaka.py,sha256=PRpg_qI7XA3ysAo_g1TRJsT3VwB5Vial2UcFyBOMwKQ,9571
44
+ puzzle_solver/puzzles/shingoki/shingoki.py,sha256=heMuL9sm3jBegItjnqX05ttmDNiHSLB77BRljpeLLWk,7417
45
+ puzzle_solver/puzzles/signpost/signpost.py,sha256=38LlMvP5Fx4qrTXmw4aNCt3yUbG3fhdSk6-YXmhAHFg,3861
46
+ puzzle_solver/puzzles/singles/singles.py,sha256=KKn_Yl-eW874Bl1UmmcqoQ5vhNiO1JbM7fxKczOV5M4,2847
47
+ puzzle_solver/puzzles/slant/slant.py,sha256=xF-N4PuXYfx638NP1f1mi6YncIZB4mLtXtdS79XyPbg,6122
48
+ puzzle_solver/puzzles/slant/parse_map/parse_map.py,sha256=8thQxWbq0qjehKb2VzgUP22PGj-9n9djwbt3LGMVLJw,4811
49
+ puzzle_solver/puzzles/slitherlink/slitherlink.py,sha256=JpyNQk8K4nUziwWKxSvWEkF1RRBGLnCppCWK1Yf5bt0,7052
50
+ puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0Nl_xlXXSHvJKw8K1EuE,3718
51
+ puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
52
+ puzzle_solver/puzzles/stitches/stitches.py,sha256=bb5JXyclkbKq350MQ9d8AuGteQwSF8knaJ0DU9M92Uw,6515
53
+ puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=b21SQvlnDM6wOl_1iUhZ7X6akpBZoOnj3kEzImBCh8Q,10497
54
+ puzzle_solver/puzzles/sudoku/sudoku.py,sha256=rLq0N34v3Hb10CiptXtKxX-37OlQIyjIle9Es1FAtpM,13378
55
+ puzzle_solver/puzzles/tapa/tapa.py,sha256=TsOQhnEvlC1JxaWiEjQg2KxRXJR49GrN71DsMvPpia8,5337
56
+ puzzle_solver/puzzles/tents/tents.py,sha256=jccUXWA7KWAtPKpVJJYNI6masTYWQgx0eitcQw0-6Fc,6281
57
+ puzzle_solver/puzzles/thermometers/thermometers.py,sha256=bGcVmpPeqL5AJtj8jkK8gYThzv9aGCd_QrWEiYBCA2s,4011
58
+ puzzle_solver/puzzles/towers/towers.py,sha256=OLyTf9nTFR5L32-S_fhVyBmpz4i5YUNJotwOwbw_Fjg,6500
59
+ puzzle_solver/puzzles/tracks/tracks.py,sha256=98xds9SKNqtOLFTRUX_KSMC7XYmZo567LOFeqotVQaM,7237
60
+ puzzle_solver/puzzles/undead/undead.py,sha256=IGFQysgoaKZH8rKjqlrkoHsH28ve4_hKor2f0QOsWY0,6596
61
+ puzzle_solver/puzzles/unequal/unequal.py,sha256=ExY2XDCrqROCDpRLfHo8uVr1zuli1QvbCdNCiDhlCac,6978
62
+ puzzle_solver/puzzles/unruly/unruly.py,sha256=xwOUpC12uHbmlDj2guN60VaaHpLr1Y-WmMD5TKeHbZE,3826
63
+ puzzle_solver/puzzles/yin_yang/yin_yang.py,sha256=5WixT_7K1HwfQ_dWbuBlQfpU8p69zB2KvOg32XJ8vno,5255
64
+ puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py,sha256=drjfoHqmFf6U-ZQUwrBbfGINRxDQpgbvy4U3D9QyMhM,6617
65
+ puzzle_solver/utils/visualizer.py,sha256=T2g5We9J3tkhyXWoN2OrIDIJDjt6w5sDd2ksOub0ZI8,6819
66
+ multi_puzzle_solver-1.0.2.dist-info/METADATA,sha256=LCKeSEhi50eG0kd-PUEbBBrpY7ZPuWau5Kz4csMTN84,347154
67
+ multi_puzzle_solver-1.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
68
+ multi_puzzle_solver-1.0.2.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
69
+ multi_puzzle_solver-1.0.2.dist-info/RECORD,,
puzzle_solver/__init__.py CHANGED
@@ -9,6 +9,7 @@ from puzzle_solver.puzzles.chess_range import chess_solo as chess_solo_solver
9
9
  from puzzle_solver.puzzles.chess_range import chess_melee as chess_melee_solver
10
10
  from puzzle_solver.puzzles.dominosa import dominosa as dominosa_solver
11
11
  from puzzle_solver.puzzles.filling import filling as filling_solver
12
+ from puzzle_solver.puzzles.flood_it import flood_it as flood_it_solver
12
13
  from puzzle_solver.puzzles.flip import flip as flip_solver
13
14
  from puzzle_solver.puzzles.galaxies import galaxies as galaxies_solver
14
15
  from puzzle_solver.puzzles.guess import guess as guess_solver
@@ -52,4 +53,60 @@ from puzzle_solver.puzzles.yin_yang import yin_yang as yin_yang_solver
52
53
 
53
54
  from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
54
55
 
55
- __version__ = '0.9.30'
56
+ __all__ = [
57
+ aquarium_solver,
58
+ battleships_solver,
59
+ binairo_solver,
60
+ binairo_plus_solver,
61
+ black_box_solver,
62
+ bridges_solver,
63
+ chess_range_solver,
64
+ chess_solo_solver,
65
+ chess_melee_solver,
66
+ dominosa_solver,
67
+ filling_solver,
68
+ flood_it_solver,
69
+ flip_solver,
70
+ galaxies_solver,
71
+ guess_solver,
72
+ heyawake_solver,
73
+ inertia_solver,
74
+ kakurasu_solver,
75
+ kakuro_solver,
76
+ keen_solver,
77
+ light_up_solver,
78
+ magnets_solver,
79
+ map_solver,
80
+ minesweeper_solver,
81
+ mosaic_solver,
82
+ nonograms_solver,
83
+ norinori_solver,
84
+ nurikabe_solver,
85
+ palisade_solver,
86
+ lits_solver,
87
+ pearl_solver,
88
+ range_solver,
89
+ rectangles_solver,
90
+ shakashaka_solver,
91
+ shingoki_solver,
92
+ signpost_solver,
93
+ singles_solver,
94
+ slant_solver,
95
+ slitherlink_solver,
96
+ star_battle_solver,
97
+ star_battle_shapeless_solver,
98
+ stitches_solver,
99
+ sudoku_solver,
100
+ tapa_solver,
101
+ tents_solver,
102
+ thermometers_solver,
103
+ towers_solver,
104
+ tracks_solver,
105
+ undead_solver,
106
+ unequal_solver,
107
+ unruly_solver,
108
+ yin_yang_solver,
109
+ inertia_image_parser,
110
+ ]
111
+
112
+ __version__ = '1.0.2'
@@ -14,6 +14,8 @@ class SingleSolution:
14
14
  assignment: dict[Pos, Union[str, int]]
15
15
 
16
16
  def get_hashable_solution(self) -> str:
17
+ if isinstance(self.assignment, list):
18
+ return json.dumps(self.assignment)
17
19
  result = []
18
20
  for pos, v in self.assignment.items():
19
21
  result.append((pos.x, pos.y, v))
@@ -93,14 +95,14 @@ def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any,
93
95
  Total new variables: =4V [for N by M 2D grid total is 4NM]
94
96
  """
95
97
  if is_neighbor is None:
96
- is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1
98
+ is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1 # noqa: E731
97
99
 
98
100
  vs = vars_to_force
99
101
  v_count = len(vs)
100
102
  if v_count <= 2: # graph must have at least 3 nodes to possibly be disconnected
101
103
  return {}
102
104
  # =V model variables, one for each variable
103
- is_root: dict[Pos, cp_model.IntVar] = {} # =V, defines the unique root
105
+ is_root: dict[Pos, cp_model.IntVar] = {} # =V, defines the unique
104
106
  prefix_zero: dict[Pos, cp_model.IntVar] = {} # =V, used for picking the unique root
105
107
  node_height: dict[Pos, cp_model.IntVar] = {} # =V, trickles down from the root
106
108
  max_neighbor_height: dict[Pos, cp_model.IntVar] = {} # =V, the height of the tallest neighbor
@@ -141,7 +143,7 @@ def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any,
141
143
  model.Add(node_height[pi] == max_neighbor_height[pi] - 1).OnlyEnforceIf([vs[pi], is_root[pi].Not()])
142
144
  model.Add(node_height[pi] == v_count).OnlyEnforceIf(is_root[pi])
143
145
  model.Add(node_height[pi] == 0).OnlyEnforceIf(vs[pi].Not())
144
-
146
+
145
147
  # final check: all active nodes have height > 0
146
148
  for p in keys_in_order:
147
149
  model.Add(node_height[p] > 0).OnlyEnforceIf(vs[p])
@@ -161,7 +163,7 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
161
163
  Returns a dictionary of new variables that can be used to enforce the no component constraint.
162
164
  """
163
165
  if is_neighbor is None:
164
- is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1
166
+ is_neighbor = lambda p1, p2: manhattan_distance(p1, p2) <= 1 # noqa: E731
165
167
 
166
168
  vs = vars_to_force
167
169
  v_count = len(vs)
@@ -220,8 +222,8 @@ def force_no_loops(model: cp_model.CpModel, vars_to_force: dict[Any, cp_model.In
220
222
  model.Add(tree_edge[(p_parent, p)] == 0).OnlyEnforceIf([is_root[p]])
221
223
  # every active node has exactly 1 parent except root has none
222
224
  model.AddExactlyOne([tree_edge[(p_parent, p)] for p_parent in parent_of[p]] + [vs[p].Not(), is_root[p]])
223
-
224
- # now each subgraph has directions where each non-root points to a single parent (and its value is parent+1).
225
+
226
+ # now each subgraph has directions where each non-root points to a single parent (and its value is parent+1).
225
227
  # to break cycles, every non-root active node must be > all neighbors that arent children
226
228
 
227
229
  all_new_vars: dict[str, cp_model.IntVar] = {}
@@ -94,7 +94,7 @@ def render_grid(cell_flags: np.ndarray,
94
94
  for r in range(R):
95
95
  rr = 2*r + 1
96
96
  for c in range(C):
97
- val = center_char if isinstance(center_char, str) else center_char[r, c]
97
+ val = center_char if isinstance(center_char, str) else (center_char(r, c) if callable(center_char) else center_char[r, c])
98
98
  put_center_text(rr, c, '' if val is None else str(val))
99
99
 
100
100
  # rows -> strings
@@ -159,13 +159,7 @@ def id_board_to_wall_board(id_board: np.array, border_is_wall = True) -> np.arra
159
159
  def render_shaded_grid(V: int,
160
160
  H: int,
161
161
  is_shaded: Callable[[int, int], bool],
162
- *,
163
- scale_x: int = 2,
164
- scale_y: int = 1,
165
- fill_char: str = '▒',
166
- empty_char: str = ' ',
167
- empty_text: Optional[Union[str, Callable[[int, int], Optional[str]]]] = None,
168
- show_axes: bool = True) -> str:
162
+ empty_text: Optional[Union[str, Callable[[int, int], Optional[str]]]] = None,) -> str:
169
163
  """
170
164
  Most of this function was AI generated then modified by me, I don't currently care about the details of rendering to the terminal this looked good enough during my testing.
171
165
  Visualize a V x H grid where each cell is shaded if is_shaded(r, c) is True.
@@ -179,6 +173,11 @@ def render_shaded_grid(V: int,
179
173
  cells. If a callable (r, c) -> str|None, used per cell. Text is
180
174
  centered within the interior row and truncated to fit.
181
175
  """
176
+ scale_x: int = 2
177
+ scale_y: int = 1
178
+ fill_char: str = '▒'
179
+ empty_char: str = ' '
180
+ show_axes: bool = True
182
181
  assert V >= 1 and H >= 1
183
182
  assert scale_x >= 1 and scale_y >= 1
184
183
  assert len(fill_char) == 1 and len(empty_char) == 1
@@ -349,8 +348,10 @@ def render_bw_tiles_split(
349
348
  if not use_color:
350
349
  return ""
351
350
  parts = []
352
- if fg is not None: parts.append(str(fg))
353
- if bg is not None: parts.append(str(bg))
351
+ if fg is not None:
352
+ parts.append(str(fg))
353
+ if bg is not None:
354
+ parts.append(str(bg))
354
355
  return ("\x1b[" + ";".join(parts) + "m") if parts else ""
355
356
 
356
357
  RESET = "\x1b[0m" if use_color else ""
@@ -383,26 +384,17 @@ def render_bw_tiles_split(
383
384
  else: # y = 1 - x
384
385
  return (fy < 1 - fx) if val == "TL" else (fy > 1 - fx)
385
386
 
386
- def on_boundary(val: CellVal, fx: float, fy: float) -> bool:
387
- if val in ("B","W"):
388
- return False
389
- kind, _ = diag_kind_and_slash(val)
390
- eps = 0.5 / max(cell_w, cell_h) # thin boundary
391
- if kind == "main":
392
- return abs(fy - fx) <= eps
393
- else:
394
- return abs(fy - (1 - fx)) <= eps
395
-
396
387
  # Build one tile as a matrix of 1-char tokens (already colorized if ANSI)
397
388
  def make_tile(val: CellVal) -> List[List[str]]:
398
389
  rows: List[List[str]] = []
399
- kind, slash_ch = diag_kind_and_slash(val)
400
-
390
+ _, slash_ch = diag_kind_and_slash(val)
401
391
  for y in range(cell_h):
402
392
  fy = (y + 0.5) / cell_h
403
393
  line: List[str] = []
394
+ prev = None
404
395
  for x in range(cell_w):
405
396
  fx = (x + 0.5) / cell_w
397
+ fx_next = (x + 1.5) / cell_w
406
398
 
407
399
  if val == "B":
408
400
  line.append(sgr(bg=BG_BLACK) + " " + RESET if use_color else TXT_BLACK)
@@ -412,7 +404,12 @@ def render_bw_tiles_split(
412
404
  continue
413
405
 
414
406
  black_side = is_black(val, fx, fy)
415
- boundary = on_boundary(val, fx, fy)
407
+ next_black_side = is_black(val, fx_next, fy)
408
+ boundary = False # if true places a "/" or "\" at the current position
409
+ if prev is not None and not prev and black_side: # prev white and cur black => boundary now
410
+ boundary = True
411
+ if black_side and not next_black_side: # cur black and next white => boundary now
412
+ boundary = True
416
413
 
417
414
  if use_color:
418
415
  bg = BG_BLACK if black_side else BG_WHITE
@@ -426,6 +423,7 @@ def render_bw_tiles_split(
426
423
  line.append(slash_ch)
427
424
  else:
428
425
  line.append(TXT_BLACK if black_side else TXT_WHITE)
426
+ prev = black_side
429
427
  rows.append(line)
430
428
  return rows
431
429
 
@@ -435,23 +433,7 @@ def render_bw_tiles_split(
435
433
  return
436
434
  ch = ch[0] # keep one character (user said single number)
437
435
  cx, cy = cell_w // 2, cell_h // 2
438
- fx = (cx + 0.5) / cell_w
439
- fy = (cy + 0.5) / cell_h
440
-
441
- # If center is boundary or not black, nudge horizontally toward black side
442
- if val in ("TL","TR","BL","BR"):
443
- kind, _ = diag_kind_and_slash(val)
444
- # Determine which side is black relative to x at this y
445
- if kind == "main": # boundary y=x → compare fx vs fy
446
- want_right = (val == "TR") # black is to the right of boundary
447
- if on_boundary(val, fx, fy) or (is_black(val, fx, fy) is False):
448
- if want_right and cx + 1 < cell_w: cx += 1
449
- elif not want_right and cx - 1 >= 0: cx -= 1
450
- else: # boundary y=1-x → compare fx vs 1-fy
451
- want_left = (val == "TL") # black is to the left of boundary
452
- if on_boundary(val, fx, fy) or (is_black(val, fx, fy) is False):
453
- if want_left and cx - 1 >= 0: cx -= 1
454
- elif not want_left and cx + 1 < cell_w: cx += 1
436
+ cx -= 1
455
437
 
456
438
  # Compose the glyph for that spot
457
439
  if use_color:
@@ -520,4 +502,4 @@ def render_bw_tiles_split(
520
502
  # mode="text", # ← key change
521
503
  # text_palette="solid" # try "solid" for stark black/white
522
504
  # )
523
- # print("```text\n" + art + "\n```")
505
+ # print("```text\n" + art + "\n```")
@@ -56,13 +56,13 @@ class Board:
56
56
  self.disallow_three_in_a_row(pos, Direction.RIGHT)
57
57
  self.disallow_three_in_a_row(pos, Direction.DOWN)
58
58
 
59
- # 3. Each row and column is unique.
59
+ # 3. Each row and column is unique.
60
60
  if self.force_unique:
61
61
  # a list per row
62
62
  self.force_unique_double_list([[self.model_vars[pos] for pos in get_row_pos(row, self.H)] for row in range(self.V)])
63
63
  # a list per column
64
64
  self.force_unique_double_list([[self.model_vars[pos] for pos in get_col_pos(col, self.V)] for col in range(self.H)])
65
-
65
+
66
66
  # if arithmetic is provided, add constraints for it
67
67
  if self.arith_rows is not None:
68
68
  assert self.arith_rows.shape == (self.V, self.H-1), f'arith_rows must be one column less than board, got {self.arith_rows.shape} for {self.board.shape}'
@@ -106,10 +106,10 @@ class Board:
106
106
 
107
107
  codes = []
108
108
  pow2 = [1 << k for k in range(m)] # weights for bit positions (LSB at index 0)
109
- for i, l in enumerate(model_vars):
109
+ for i, line in enumerate(model_vars):
110
110
  code = self.model.NewIntVar(0, (1 << m) - 1, f"code_{i}")
111
111
  # Sum 2^k * r[k] == code
112
- self.model.Add(code == sum(pow2[k] * l[k] for k in range(m)))
112
+ self.model.Add(code == sum(pow2[k] * line[k] for k in range(m)))
113
113
  codes.append(code)
114
114
 
115
115
  self.model.AddAllDifferent(codes)
@@ -50,7 +50,7 @@ class Board:
50
50
  self.right_values = right
51
51
  self.bottom_values = bottom
52
52
  self.left_values = left
53
-
53
+
54
54
  self.model = cp_model.CpModel()
55
55
  self.ball_states: dict[Pos, cp_model.IntVar] = {}
56
56
  # (entry_pos, T, cell_pos, direction) -> True if the beam that entered from the board at "entry_pos" is present in "cell_pos" and is going in the direction "direction" at time T
@@ -86,7 +86,7 @@ class Board:
86
86
  for cell in self.get_all_pos_extended():
87
87
  for direction in Direction:
88
88
  self.beam_states[(entry_pos, t, cell, direction)] = self.model.NewBoolVar(f'beam:{entry_pos}:{t}:{cell}:{direction}')
89
-
89
+
90
90
  for (entry_pos, t, cell, direction) in self.beam_states.keys():
91
91
  if t not in self.beam_states_at_t:
92
92
  self.beam_states_at_t[t] = {}
@@ -110,7 +110,7 @@ class Board:
110
110
  beam_ids.extend((beam_id, Direction.LEFT) for beam_id in self.right_cells)
111
111
  beam_ids.extend((beam_id, Direction.UP) for beam_id in self.bottom_cells)
112
112
  beam_ids.extend((beam_id, Direction.RIGHT) for beam_id in self.left_cells)
113
-
113
+
114
114
  for (beam_id, direction) in beam_ids:
115
115
  # beam at t=0 is present at beam_id and facing direction
116
116
  self.model.Add(self.beam_states[(beam_id, 0, beam_id, direction)] == 1)
@@ -189,7 +189,7 @@ class Board:
189
189
  else:
190
190
  ball_right = False
191
191
  ball_right_not = True
192
-
192
+
193
193
  pos_left = get_next_pos(cur_pos, direction_left)
194
194
  pos_right = get_next_pos(cur_pos, direction_right)
195
195
  pos_reflected = get_next_pos(cur_pos, reflected)
@@ -304,10 +304,4 @@ class Board:
304
304
  ball_state = 'O' if single_res.assignment[pos] else ' '
305
305
  res[pos.y][pos.x] = ball_state
306
306
  print(res)
307
- r = generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
308
- # print('non unique count:', count)
309
-
310
-
311
-
312
-
313
-
307
+ generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -73,7 +73,7 @@ class Board:
73
73
  xhoriz_min = min(horiz_bridge[0].x, horiz_bridge[1].x)
74
74
  xhoriz_max = max(horiz_bridge[0].x, horiz_bridge[1].x)
75
75
  yhoriz = horiz_bridge[0].y
76
-
76
+
77
77
  # no equals because thats what the puzzle says
78
78
  x_contained = xhoriz_min < xvert < xhoriz_max
79
79
  y_contained = yvert_min < yhoriz < yvert_max
@@ -173,11 +173,11 @@ class Board:
173
173
  self.H = 8 # board size
174
174
  # the puzzle rules mean the only legal positions are the starting positions of the pieces
175
175
  self.all_legal_positions: set[Pos] = {pos for _, pos in self.pieces.values()}
176
- assert len(self.all_legal_positions) == len(self.pieces), f'positions are not unique'
176
+ assert len(self.all_legal_positions) == len(self.pieces), 'positions are not unique'
177
177
 
178
178
  self.model = cp_model.CpModel()
179
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
180
+ # Number of variables
181
181
  # piece_positions: O(NTB)
182
182
  # is_dead: O(NT)
183
183
  # mover: O(NT)
@@ -341,7 +341,7 @@ class Board:
341
341
  for t in range(self.T - 1):
342
342
  self.model.AddExactlyOne([self.victim[(p, t)] for p in range(self.N)])
343
343
 
344
- # optional parameter to force
344
+ # optional parameter to force
345
345
  if self.max_moves_per_piece is not None:
346
346
  for p in range(self.N):
347
347
  self.model.Add(sum([self.mover[(p, t)] for t in range(self.T - 1)]) <= self.max_moves_per_piece)
@@ -4,6 +4,6 @@ from .chess_range import PieceType
4
4
  class Board(RangeBoard):
5
5
  def __init__(self, pieces: list[str]):
6
6
  king_pieces = [p for p in range(len(pieces)) if pieces[p][0] == 'K']
7
- assert len(king_pieces) == 1, f'exactly one king piece is required'
7
+ assert len(king_pieces) == 1, 'exactly one king piece is required'
8
8
  super().__init__(pieces, max_moves_per_piece=2, last_piece_alive=PieceType.KING)
9
9
 
@@ -3,8 +3,8 @@ from dataclasses import dataclass
3
3
  import numpy as np
4
4
  from ortools.sat.python import cp_model
5
5
 
6
- from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, polyominoes, in_bounds, get_next_pos, Direction
7
- from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
6
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, polyominoes, in_bounds, get_next_pos, Direction
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
8
 
9
9
 
10
10
  @dataclass
@@ -85,7 +85,7 @@ class Board:
85
85
  # exactly one shape is active at that position
86
86
  self.model.AddExactlyOne(s.is_active for d in self.digits for s in self.body_loc_to_shape[(d,pos)])
87
87
  # if a shape is active then all its body is active
88
-
88
+
89
89
  for s_list in self.body_loc_to_shape.values():
90
90
  for s in s_list:
91
91
  for p in s.body:
@@ -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
+