multi-puzzle-solver 0.9.27__py3-none-any.whl → 0.9.30__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.

@@ -1,10 +1,11 @@
1
- puzzle_solver/__init__.py,sha256=FzOcK5T5beTloncBQ-qo9hpqtxu4sbmR7m3fPUnnqec,3272
1
+ puzzle_solver/__init__.py,sha256=f79JI0EQhfQi12yO6gvuzzLtxXQgojVrq-v8HZrzjS0,3693
2
2
  puzzle_solver/core/utils.py,sha256=XBW5j-IwtJMPMP-ycmY6SqRCM1NOVl5O6UeoGqNj618,8153
3
3
  puzzle_solver/core/utils_ortools.py,sha256=_i8cixHOB5XGqqcr-493bOiZgYJidnvxQMEfj--Trns,10278
4
- puzzle_solver/core/utils_visualizer.py,sha256=D1eNTZ3eJ76pBoznKRMwvlxXxQsz89K2fBfXP_dASwo,12868
4
+ puzzle_solver/core/utils_visualizer.py,sha256=2jBnS2PeI4keFf-rneScSxX669zXu5F1lkClZ5EkMhE,21152
5
5
  puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
6
6
  puzzle_solver/puzzles/battleships/battleships.py,sha256=RuYCrs4j0vUjlU139NRYYP-uNPAgO0V7hAzbsHrRwD8,7446
7
- puzzle_solver/puzzles/binairo/binairo.py,sha256=sRtflnlGrN8xQ64beRZBGr74R8KptzxYDdFgXuW27pM,4595
7
+ puzzle_solver/puzzles/binairo/binairo.py,sha256=4xgYd1ewYIQCqEzsHdgp6hWzyW_TF_2rt6PO8QLFKWU,6838
8
+ puzzle_solver/puzzles/binairo/binairo_plus.py,sha256=TvLG3olwANtft3LuCF-y4OofpU9PNa4IXDqgZqsD-g0,267
8
9
  puzzle_solver/puzzles/black_box/black_box.py,sha256=ZnHDVt6PFS_r1kMNSsbz9hav1hxIrNDUvPyERGPjLjM,15635
9
10
  puzzle_solver/puzzles/bridges/bridges.py,sha256=15A9uV4xjoqPRo_9CTnoKeGRxS3z2aMF619T1n0dTOQ,5402
10
11
  puzzle_solver/puzzles/chess_range/chess_melee.py,sha256=D-_Oi8OyxsVe1j3dIKYwRlxgeb3NWLmDWGcv-oclY0c,195
@@ -17,10 +18,12 @@ puzzle_solver/puzzles/flip/flip.py,sha256=ZngJLUhRNc7qqo2wtNLdMPx4u9w9JTUge27Pmd
17
18
  puzzle_solver/puzzles/galaxies/galaxies.py,sha256=p10lpmW0FjtneFCMEjG1FSiEpQuvD8zZG9FG8zYGoes,5582
18
19
  puzzle_solver/puzzles/galaxies/parse_map/parse_map.py,sha256=v5TCrdREeOB69s9_QFgPHKA7flG69Im1HVzIdxH0qQc,9355
19
20
  puzzle_solver/puzzles/guess/guess.py,sha256=sH-NlYhxM3DNbhk4eGde09kgM0KaDvSbLrpHQiwcFGo,10791
21
+ puzzle_solver/puzzles/heyawake/heyawake.py,sha256=qMnc_CuHn8K5Rw40tefjueI1pycpHQ7eN1R9Xg5WEuw,5601
20
22
  puzzle_solver/puzzles/inertia/inertia.py,sha256=gJBahkh69CrSWNscalKEoP1j4X-Q3XpbIBMiG9PUpU0,5657
21
23
  puzzle_solver/puzzles/inertia/tsp.py,sha256=gobiISHtARA4Elq0jr90p6Yhq11ULjGoqsS-rLFhYcc,15389
22
24
  puzzle_solver/puzzles/inertia/parse_map/parse_map.py,sha256=A9JQTNqamUdzlwqks0XQp3Hge3mzyTIVK6YtDJvqpL4,8422
23
25
  puzzle_solver/puzzles/kakurasu/kakurasu.py,sha256=VNGMJnBHDi6WkghLObRLhUvkmrPaGphTTUDMC0TkQvQ,2064
26
+ puzzle_solver/puzzles/kakuro/kakuro.py,sha256=Jf0Iilv32EPcaWikX92_vgBOVRp5MAE27aFRmnLotGQ,4374
24
27
  puzzle_solver/puzzles/keen/keen.py,sha256=tDb6C5S3Q0JAKPsdw-84WQ6PxRADELZHr_BK8FDH-NA,5039
25
28
  puzzle_solver/puzzles/light_up/light_up.py,sha256=iSA1rjZMFsnI0V0Nxivxox4qZkB7PvUrROSHXcoUXds,4541
26
29
  puzzle_solver/puzzles/lits/lits.py,sha256=3fPIkhAIUz8JokcfaE_ZM3b0AFEnf5xPzGJ2qnm8SWY,7099
@@ -35,16 +38,19 @@ puzzle_solver/puzzles/palisade/palisade.py,sha256=T-LXlaLU5OwUQ24QWJWhBUFUktg0qD
35
38
  puzzle_solver/puzzles/pearl/pearl.py,sha256=OhzpMYpxqvR3GCd5NH4ETT0NO4X753kRi6p5omYLChM,6798
36
39
  puzzle_solver/puzzles/range/range.py,sha256=rruvD5ZSaOgvQuX6uGV_Dkr82nSiWZ5kDz03_j7Tt24,4425
37
40
  puzzle_solver/puzzles/rectangles/rectangles.py,sha256=zaPg3qI9TNxr2iXmNi2kOL8R2RsS9DyQPUTY3ukgYIA,7033
41
+ puzzle_solver/puzzles/shakashaka/shakashaka.py,sha256=PRpg_qI7XA3ysAo_g1TRJsT3VwB5Vial2UcFyBOMwKQ,9571
42
+ puzzle_solver/puzzles/shingoki/shingoki.py,sha256=uwX1ZIGGDlshMtsZedlgGYE8hDB1ou3h6aBnZEr_l8I,7425
38
43
  puzzle_solver/puzzles/signpost/signpost.py,sha256=-0_S6ycwzwlUf9-ZhP127Rgo5gMBOHiTM6t08dLLDac,3869
39
- puzzle_solver/puzzles/singles/singles.py,sha256=3wACiUa1Vmh2ce6szQ2hPjyBuH7aHiQ888p4R2jFkW4,3342
44
+ puzzle_solver/puzzles/singles/singles.py,sha256=KKn_Yl-eW874Bl1UmmcqoQ5vhNiO1JbM7fxKczOV5M4,2847
40
45
  puzzle_solver/puzzles/slant/slant.py,sha256=xF-N4PuXYfx638NP1f1mi6YncIZB4mLtXtdS79XyPbg,6122
41
46
  puzzle_solver/puzzles/slant/parse_map/parse_map.py,sha256=dxnALSDXe9wU0uSD0QEXnzoh1q801mj1ePTNLtG0n60,4796
42
47
  puzzle_solver/puzzles/slitherlink/slitherlink.py,sha256=e1A_f_3J-QXN9fmt_Nf3FsYnp-TmE9TRKN06Wn4NnAU,7056
43
48
  puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0Nl_xlXXSHvJKw8K1EuE,3718
44
49
  puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
45
50
  puzzle_solver/puzzles/stitches/stitches.py,sha256=iK8t02q43gH3FPbuIDn4dK0sbaOgZOnw8yHNRNvNuIU,6534
46
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=1LNJkIqpcz1LvY0H0uRedABQWm44dgNf9XeQuKm36WM,10275
51
+ puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=f49ZGVBPXjAGgqZnqPab6PcO_DsFDFZnG3uA8b-1d7k,10441
47
52
  puzzle_solver/puzzles/sudoku/sudoku.py,sha256=SE4TM_gic6Jj0fkDR_NzUJdX2XKyQ8eeOnVAQ011Xbo,8870
53
+ puzzle_solver/puzzles/tapa/tapa.py,sha256=TsOQhnEvlC1JxaWiEjQg2KxRXJR49GrN71DsMvPpia8,5337
48
54
  puzzle_solver/puzzles/tents/tents.py,sha256=iyVK2WXfIT5j_9qqlQg0WmwvixwXlZSsHGK3XA-KpII,6283
49
55
  puzzle_solver/puzzles/thermometers/thermometers.py,sha256=nsvJZkm7G8FALT27bpaB0lv5E_AWawqmvapQI8QcYXw,4015
50
56
  puzzle_solver/puzzles/towers/towers.py,sha256=QvL0Pp-Z2ewCeq9ZkNrh8MShKOh-Y52sFBSudve68wk,6496
@@ -55,7 +61,7 @@ puzzle_solver/puzzles/unruly/unruly.py,sha256=sDF0oKT50G-NshyW2DYrvAgD9q9Ku9ANUy
55
61
  puzzle_solver/puzzles/yin_yang/yin_yang.py,sha256=WrRdNhmKhIARdGOt_36gpRxRzrfLGv3wl7igBpPFM64,5259
56
62
  puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py,sha256=drjfoHqmFf6U-ZQUwrBbfGINRxDQpgbvy4U3D9QyMhM,6617
57
63
  puzzle_solver/utils/visualizer.py,sha256=tsX1yEKwmwXBYuBJpx_oZGe2UUt1g5yV73G3UbtmvtE,6817
58
- multi_puzzle_solver-0.9.27.dist-info/METADATA,sha256=ZZDovGlLzsfNSjwsqHiYi91vCPJCA2-Na3jiaQ8JiRU,224813
59
- multi_puzzle_solver-0.9.27.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
60
- multi_puzzle_solver-0.9.27.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
61
- multi_puzzle_solver-0.9.27.dist-info/RECORD,,
64
+ multi_puzzle_solver-0.9.30.dist-info/METADATA,sha256=yxPV6ZvkvGPOs1O2HpIob3e94uFQXjpm5JJKdCXyc2s,335384
65
+ multi_puzzle_solver-0.9.30.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
66
+ multi_puzzle_solver-0.9.30.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
67
+ multi_puzzle_solver-0.9.30.dist-info/RECORD,,
puzzle_solver/__init__.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from puzzle_solver.puzzles.aquarium import aquarium as aquarium_solver
2
2
  from puzzle_solver.puzzles.battleships import battleships as battleships_solver
3
3
  from puzzle_solver.puzzles.binairo import binairo as binairo_solver
4
+ from puzzle_solver.puzzles.binairo import binairo_plus as binairo_plus_solver
4
5
  from puzzle_solver.puzzles.black_box import black_box as black_box_solver
5
6
  from puzzle_solver.puzzles.bridges import bridges as bridges_solver
6
7
  from puzzle_solver.puzzles.chess_range import chess_range as chess_range_solver
@@ -11,8 +12,10 @@ from puzzle_solver.puzzles.filling import filling as filling_solver
11
12
  from puzzle_solver.puzzles.flip import flip as flip_solver
12
13
  from puzzle_solver.puzzles.galaxies import galaxies as galaxies_solver
13
14
  from puzzle_solver.puzzles.guess import guess as guess_solver
15
+ from puzzle_solver.puzzles.heyawake import heyawake as heyawake_solver
14
16
  from puzzle_solver.puzzles.inertia import inertia as inertia_solver
15
17
  from puzzle_solver.puzzles.kakurasu import kakurasu as kakurasu_solver
18
+ from puzzle_solver.puzzles.kakuro import kakuro as kakuro_solver
16
19
  from puzzle_solver.puzzles.keen import keen as keen_solver
17
20
  from puzzle_solver.puzzles.light_up import light_up as light_up_solver
18
21
  from puzzle_solver.puzzles.magnets import magnets as magnets_solver
@@ -27,6 +30,8 @@ from puzzle_solver.puzzles.lits import lits as lits_solver
27
30
  from puzzle_solver.puzzles.pearl import pearl as pearl_solver
28
31
  from puzzle_solver.puzzles.range import range as range_solver
29
32
  from puzzle_solver.puzzles.rectangles import rectangles as rectangles_solver
33
+ from puzzle_solver.puzzles.shakashaka import shakashaka as shakashaka_solver
34
+ from puzzle_solver.puzzles.shingoki import shingoki as shingoki_solver
30
35
  from puzzle_solver.puzzles.signpost import signpost as signpost_solver
31
36
  from puzzle_solver.puzzles.singles import singles as singles_solver
32
37
  from puzzle_solver.puzzles.slant import slant as slant_solver
@@ -35,6 +40,7 @@ from puzzle_solver.puzzles.star_battle import star_battle as star_battle_solver
35
40
  from puzzle_solver.puzzles.star_battle import star_battle_shapeless as star_battle_shapeless_solver
36
41
  from puzzle_solver.puzzles.stitches import stitches as stitches_solver
37
42
  from puzzle_solver.puzzles.sudoku import sudoku as sudoku_solver
43
+ from puzzle_solver.puzzles.tapa import tapa as tapa_solver
38
44
  from puzzle_solver.puzzles.tents import tents as tents_solver
39
45
  from puzzle_solver.puzzles.thermometers import thermometers as thermometers_solver
40
46
  from puzzle_solver.puzzles.towers import towers as towers_solver
@@ -46,4 +52,4 @@ from puzzle_solver.puzzles.yin_yang import yin_yang as yin_yang_solver
46
52
 
47
53
  from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
48
54
 
49
- __version__ = '0.9.27'
55
+ __version__ = '0.9.30'
@@ -1,5 +1,5 @@
1
1
  import numpy as np
2
- from typing import Union, Callable, Optional
2
+ from typing import Union, Callable, Optional, List, Sequence, Literal
3
3
  from puzzle_solver.core.utils import Pos, get_all_pos, get_next_pos, in_bounds, set_char, get_char, Direction
4
4
 
5
5
 
@@ -308,3 +308,216 @@ def render_shaded_grid(V: int,
308
308
  labeled.append(label + line)
309
309
 
310
310
  return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
311
+
312
+ CellVal = Literal["B", "W", "TL", "TR", "BL", "BR"]
313
+ GridLike = Sequence[Sequence[CellVal]]
314
+
315
+ def render_bw_tiles_split(
316
+ grid: GridLike,
317
+ cell_w: int = 6,
318
+ cell_h: int = 3,
319
+ borders: bool = False,
320
+ mode: Literal["ansi", "text"] = "ansi",
321
+ text_palette: Literal["solid", "hatch"] = "solid",
322
+ cell_text: Optional[Callable[[int, int], str]] = None) -> str:
323
+ """
324
+ Render a VxH grid with '/' or '\\' splits and optional per-cell centered text.
325
+
326
+ `cell_text(r, c) -> str`: if returns non-empty, its first character is drawn
327
+ near the geometric center of cell (r,c), nudged to the black side for halves.
328
+ """
329
+
330
+ V = len(grid)
331
+ if V == 0:
332
+ return ""
333
+ H = len(grid[0])
334
+ if any(len(row) != H for row in grid):
335
+ raise ValueError("All rows must have the same length")
336
+ if cell_w < 1 or cell_h < 1:
337
+ raise ValueError("cell_w and cell_h must be >= 1")
338
+
339
+ allowed = {"B","W","TL","TR","BL","BR"}
340
+ for r in range(V):
341
+ for c in range(H):
342
+ if grid[r][c] not in allowed:
343
+ raise ValueError(f"Invalid cell value at ({r},{c}): {grid[r][c]}")
344
+
345
+ # ── Mode setup ─────────────────────────────────────────────────────────
346
+ use_color = (mode == "ansi")
347
+
348
+ def sgr(bg: int | None = None, fg: int | None = None) -> str:
349
+ if not use_color:
350
+ return ""
351
+ parts = []
352
+ if fg is not None: parts.append(str(fg))
353
+ if bg is not None: parts.append(str(bg))
354
+ return ("\x1b[" + ";".join(parts) + "m") if parts else ""
355
+
356
+ RESET = "\x1b[0m" if use_color else ""
357
+
358
+ BG_BLACK, BG_WHITE = 40, 47
359
+ FG_BLACK, FG_WHITE = 30, 37
360
+
361
+ if text_palette == "solid":
362
+ TXT_BLACK, TXT_WHITE = " ", "█"
363
+ elif text_palette == "hatch":
364
+ TXT_BLACK, TXT_WHITE = "░", "▓"
365
+ else:
366
+ raise ValueError("text_palette must be 'solid' or 'hatch'")
367
+
368
+ def diag_kind_and_slash(val: CellVal):
369
+ if val in ("TR", "BL"):
370
+ return "main", "\\"
371
+ elif val in ("TL", "BR"):
372
+ return "anti", "/"
373
+ return None, "?"
374
+
375
+ def is_black(val: CellVal, fx: float, fy: float) -> bool:
376
+ if val == "B":
377
+ return True
378
+ if val == "W":
379
+ return False
380
+ kind, _ = diag_kind_and_slash(val)
381
+ if kind == "main": # y = x
382
+ return (fy < fx) if val == "TR" else (fy > fx)
383
+ else: # y = 1 - x
384
+ return (fy < 1 - fx) if val == "TL" else (fy > 1 - fx)
385
+
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
+ # Build one tile as a matrix of 1-char tokens (already colorized if ANSI)
397
+ def make_tile(val: CellVal) -> List[List[str]]:
398
+ rows: List[List[str]] = []
399
+ kind, slash_ch = diag_kind_and_slash(val)
400
+
401
+ for y in range(cell_h):
402
+ fy = (y + 0.5) / cell_h
403
+ line: List[str] = []
404
+ for x in range(cell_w):
405
+ fx = (x + 0.5) / cell_w
406
+
407
+ if val == "B":
408
+ line.append(sgr(bg=BG_BLACK) + " " + RESET if use_color else TXT_BLACK)
409
+ continue
410
+ if val == "W":
411
+ line.append(sgr(bg=BG_WHITE) + " " + RESET if use_color else TXT_WHITE)
412
+ continue
413
+
414
+ black_side = is_black(val, fx, fy)
415
+ boundary = on_boundary(val, fx, fy)
416
+
417
+ if use_color:
418
+ bg = BG_BLACK if black_side else BG_WHITE
419
+ if boundary:
420
+ fg = FG_WHITE if bg == BG_BLACK else FG_BLACK
421
+ line.append(sgr(bg=bg, fg=fg) + slash_ch + RESET)
422
+ else:
423
+ line.append(sgr(bg=bg) + " " + RESET)
424
+ else:
425
+ if boundary:
426
+ line.append(slash_ch)
427
+ else:
428
+ line.append(TXT_BLACK if black_side else TXT_WHITE)
429
+ rows.append(line)
430
+ return rows
431
+
432
+ # Overlay a single character centered (nudged into black side if needed)
433
+ def overlay_center_char(tile: List[List[str]], val: CellVal, ch: str):
434
+ if not ch:
435
+ return
436
+ ch = ch[0] # keep one character (user said single number)
437
+ 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
455
+
456
+ # Compose the glyph for that spot
457
+ if use_color:
458
+ # Force black bg + white fg so it pops
459
+ token = sgr(bg=BG_BLACK, fg=FG_WHITE) + ch + RESET
460
+ else:
461
+ # In text mode, just put the raw character
462
+ token = ch
463
+ tile[cy][cx] = token
464
+
465
+ # Optional borders
466
+ if borders:
467
+ horiz = "─" * cell_w
468
+ top = "┌" + "┬".join(horiz for _ in range(H)) + "┐"
469
+ mid = "├" + "┼".join(horiz for _ in range(H)) + "┤"
470
+ bot = "└" + "┴".join(horiz for _ in range(H)) + "┘"
471
+
472
+ out_lines: List[str] = []
473
+ if borders:
474
+ out_lines.append(top)
475
+
476
+ for r in range(V):
477
+ # Build tiles for this row (so we can overlay per-cell text)
478
+ row_tiles: List[List[List[str]]] = []
479
+ for c in range(H):
480
+ t = make_tile(grid[r][c])
481
+ if cell_text is not None:
482
+ label = cell_text(r, c)
483
+ if label:
484
+ overlay_center_char(t, grid[r][c], label)
485
+ row_tiles.append(t)
486
+
487
+ # Emit tile rows
488
+ for y in range(cell_h):
489
+ if borders:
490
+ parts = ["│"]
491
+ for c in range(H):
492
+ parts.append("".join(row_tiles[c][y]))
493
+ parts.append("│")
494
+ out_lines.append("".join(parts))
495
+ else:
496
+ out_lines.append("".join("".join(row_tiles[c][y]) for c in range(H)))
497
+
498
+ if borders and r < V - 1:
499
+ out_lines.append(mid)
500
+
501
+ if borders:
502
+ out_lines.append(bot)
503
+
504
+ return "\n".join(out_lines) + (RESET if use_color else "")
505
+
506
+
507
+
508
+
509
+ # demo = [
510
+ # ["TL","TR","BL","BR","B","W","BL","BR","B","W","TL","TR","BL","BR","B","W","BL","BR","B","W","W","BL","BR","B","W"],
511
+ # ["W","BL","TR","BL","TL","BR","BL","BR","W","W","W","B","TR","BL","TL","BR","BL","BR","B","W","BR","BL","BR","B","W"],
512
+ # ["BR","BL","TR","TL","W","B","BL","BR","B","W","BR","BL","TR","TL","W","B","BL","BR","B","W","B","BL","BR","B","W"],
513
+ # ]
514
+ # print(render_bw_tiles_split(demo, cell_w=8, cell_h=4, borders=True, mode="ansi"))
515
+ # art = render_bw_tiles_split(
516
+ # demo,
517
+ # cell_w=8,
518
+ # cell_h=4,
519
+ # borders=True,
520
+ # mode="text", # ← key change
521
+ # text_palette="solid" # try "solid" for stark black/white
522
+ # )
523
+ # print("```text\n" + art + "\n```")
@@ -1,17 +1,32 @@
1
+ from typing import Optional
2
+
1
3
  import numpy as np
2
4
  from ortools.sat.python import cp_model
3
5
  from ortools.sat.python.cp_model import LinearExpr as lxp
4
6
 
5
- from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, in_bounds, set_char, get_char, get_neighbors8, get_row_pos, get_col_pos
7
+ from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, get_char, get_row_pos, get_col_pos
6
8
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+ from puzzle_solver.core.utils_visualizer import render_shaded_grid
7
10
 
8
11
 
9
12
  class Board:
10
- def __init__(self, board: np.array):
13
+ def __init__(self, board: np.array, arith_rows: Optional[np.array] = None, arith_cols: Optional[np.array] = None, force_unique: bool = True):
11
14
  assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
15
  assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space or B'
13
16
  self.board = board
14
17
  self.V, self.H = board.shape
18
+ if arith_rows is not None:
19
+ assert arith_rows.ndim == 2, f'arith_rows must be 2d, got {arith_rows.ndim}'
20
+ assert arith_rows.shape == (self.V, self.H-1), f'arith_rows must be one column less than board, got {arith_rows.shape} for {board.shape}'
21
+ assert all(isinstance(c.item(), str) and c.item() in [' ', 'x', '='] for c in np.nditer(arith_rows)), 'arith_rows must contain only space, x, or ='
22
+ if arith_cols is not None:
23
+ assert arith_cols.ndim == 2, f'arith_cols must be 2d, got {arith_cols.ndim}'
24
+ assert arith_cols.shape == (self.V-1, self.H), f'arith_cols must be one column and row less than board, got {arith_cols.shape} for {board.shape}'
25
+ assert all(isinstance(c.item(), str) and c.item() in [' ', 'x', '='] for c in np.nditer(arith_cols)), 'arith_cols must contain only space, x, or ='
26
+ self.arith_rows = arith_rows
27
+ self.arith_cols = arith_cols
28
+ self.force_unique = force_unique
29
+
15
30
  self.model = cp_model.CpModel()
16
31
  self.model_vars: dict[Pos, cp_model.IntVar] = {}
17
32
 
@@ -40,11 +55,32 @@ class Board:
40
55
  for pos in get_all_pos(self.V, self.H):
41
56
  self.disallow_three_in_a_row(pos, Direction.RIGHT)
42
57
  self.disallow_three_in_a_row(pos, Direction.DOWN)
58
+
43
59
  # 3. Each row and column is unique.
44
- # a list per row
45
- self.force_unique([[self.model_vars[pos] for pos in get_row_pos(row, self.H)] for row in range(self.V)])
46
- # a list per column
47
- self.force_unique([[self.model_vars[pos] for pos in get_col_pos(col, self.V)] for col in range(self.H)])
60
+ if self.force_unique:
61
+ # a list per row
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
+ # a list per column
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
+
66
+ # if arithmetic is provided, add constraints for it
67
+ if self.arith_rows is not None:
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}'
69
+ for pos in get_all_pos(self.V, self.H-1):
70
+ c = get_char(self.arith_rows, pos)
71
+ if c == 'x':
72
+ self.model.Add(self.model_vars[pos] != self.model_vars[get_next_pos(pos, Direction.RIGHT)])
73
+ elif c == '=':
74
+ self.model.Add(self.model_vars[pos] == self.model_vars[get_next_pos(pos, Direction.RIGHT)])
75
+ if self.arith_cols is not None:
76
+ assert self.arith_cols.shape == (self.V-1, self.H), f'arith_cols must be one row less than board, got {self.arith_cols.shape} for {self.board.shape}'
77
+ for pos in get_all_pos(self.V-1, self.H):
78
+ c = get_char(self.arith_cols, pos)
79
+ if c == 'x':
80
+ self.model.Add(self.model_vars[pos] != self.model_vars[get_next_pos(pos, Direction.DOWN)])
81
+ elif c == '=':
82
+ self.model.Add(self.model_vars[pos] == self.model_vars[get_next_pos(pos, Direction.DOWN)])
83
+
48
84
 
49
85
  def disallow_three_in_a_row(self, p1: Pos, direction: Direction):
50
86
  p2 = get_next_pos(p1, direction)
@@ -62,7 +98,7 @@ class Board:
62
98
  self.model_vars[p3].Not(),
63
99
  ])
64
100
 
65
- def force_unique(self, model_vars: list[list[cp_model.IntVar]]):
101
+ def force_unique_double_list(self, model_vars: list[list[cp_model.IntVar]]):
66
102
  if not model_vars or len(model_vars) < 2:
67
103
  return
68
104
  m = len(model_vars[0])
@@ -86,13 +122,5 @@ class Board:
86
122
  return SingleSolution(assignment=assignment)
87
123
  def callback(single_res: SingleSolution):
88
124
  print("Solution found")
89
- res = np.full((self.V, self.H), ' ', dtype=object)
90
- for pos in get_all_pos(self.V, self.H):
91
- c = get_char(self.board, pos)
92
- c = 'B' if single_res.assignment[pos] == 1 else 'W'
93
- set_char(res, pos, c)
94
- print('[')
95
- for row in res:
96
- print(" [ '" + "', '".join(row.tolist()) + "' ],")
97
- print(']')
125
+ print(render_shaded_grid(self.V, self.H, lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1))
98
126
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,7 @@
1
+ import numpy as np
2
+
3
+ from . import binairo
4
+
5
+ class Board(binairo.Board):
6
+ def __init__(self, board: np.array, arith_rows: np.array, arith_cols: np.array):
7
+ super().__init__(board=board, arith_rows=arith_rows, arith_cols=arith_cols, force_unique=False)
@@ -0,0 +1,94 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos, set_char, get_char
5
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
6
+ from puzzle_solver.core.utils_visualizer import render_shaded_grid
7
+
8
+ def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
9
+ """Given a list of integers (mostly with duplicates), return every consecutive sequence of 3 integer changes.
10
+ i.e. return a list of (begin_idx, end_idx) tuples where for each r=int_list[begin_idx:end_idx] we have r[0]!=r[1] and r[-2]!=r[-1] and len(r)>=3"""
11
+ out = []
12
+ change_indices = [i for i in range(len(int_list) - 1) if int_list[i] != int_list[i+1]]
13
+ # notice how for every subsequence r, the subsequence begining index is in change_indices and the ending index - 1 is in change_indices
14
+ for i in range(len(change_indices) - 1):
15
+ begin_idx = change_indices[i]
16
+ end_idx = change_indices[i+1] + 1 # we want to include the first number in the third sequence
17
+ if end_idx > len(int_list):
18
+ continue
19
+ out.append((begin_idx, end_idx))
20
+ return out
21
+
22
+
23
+ class Board:
24
+ def __init__(self, board: np.array, region_to_clue: dict[str, int]):
25
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
26
+ assert all(str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
27
+ self.board = board
28
+ self.V, self.H = board.shape
29
+ self.all_regions: set[int] = {int(c.item()) for c in np.nditer(board)}
30
+ self.region_to_clue = {int(k): v for k, v in region_to_clue.items()}
31
+ assert set(self.region_to_clue.keys()).issubset(self.all_regions), f'extra regions in region_to_clue: {set(self.region_to_clue.keys()) - self.all_regions}'
32
+ self.region_to_pos: dict[int, set[Pos]] = {r: set() for r in self.all_regions}
33
+ for pos in get_all_pos(self.V, self.H):
34
+ rid = int(get_char(self.board, pos))
35
+ self.region_to_pos[rid].add(pos)
36
+
37
+ self.model = cp_model.CpModel()
38
+ self.B: dict[Pos, cp_model.IntVar] = {}
39
+ self.W: dict[Pos, cp_model.IntVar] = {}
40
+
41
+ self.create_vars()
42
+ self.add_all_constraints()
43
+
44
+ def create_vars(self):
45
+ for pos in get_all_pos(self.V, self.H):
46
+ self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
47
+ self.W[pos] = self.model.NewBoolVar(f'W:{pos}')
48
+ self.model.AddExactlyOne([self.B[pos], self.W[pos]])
49
+
50
+ def add_all_constraints(self):
51
+ # Regions with a number should contain black cells matching the number.
52
+ for rid, clue in self.region_to_clue.items():
53
+ self.model.Add(sum([self.B[p] for p in self.region_to_pos[rid]]) == clue)
54
+ # 2 black cells cannot be adjacent horizontally or vertically.
55
+ for pos in get_all_pos(self.V, self.H):
56
+ for neighbor in get_neighbors4(pos, self.V, self.H):
57
+ self.model.AddBoolOr([self.W[pos], self.W[neighbor]])
58
+ # All white cells should be connected in a single group.
59
+ force_connected_component(self.model, self.W)
60
+ # A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
61
+ self.disallow_white_lines_spanning_3_regions()
62
+
63
+ def disallow_white_lines_spanning_3_regions(self):
64
+ # A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
65
+ row_to_region: dict[int, list[int]] = {row: [] for row in range(self.V)}
66
+ col_to_region: dict[int, list[int]] = {col: [] for col in range(self.H)}
67
+ for pos in get_all_pos(self.V, self.H): # must traverse from least to most (both row and col)
68
+ rid = int(get_char(self.board, pos))
69
+ row_to_region[pos.y].append(rid)
70
+ col_to_region[pos.x].append(rid)
71
+ for row_num, row in row_to_region.items():
72
+ for begin_idx, end_idx in return_3_consecutives(row):
73
+ pos_list = [get_pos(x=x, y=row_num) for x in range(begin_idx, end_idx+1)]
74
+ self.model.AddBoolOr([self.B[p] for p in pos_list])
75
+ for col_num, col in col_to_region.items():
76
+ for begin_idx, end_idx in return_3_consecutives(col):
77
+ pos_list = [get_pos(x=col_num, y=y) for y in range(begin_idx, end_idx+1)]
78
+ self.model.AddBoolOr([self.B[p] for p in pos_list])
79
+
80
+ def solve_and_print(self, verbose: bool = True):
81
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
82
+ assignment: dict[Pos, int] = {}
83
+ for pos, var in board.B.items():
84
+ assignment[pos] = 1 if solver.Value(var) == 1 else 0
85
+ return SingleSolution(assignment=assignment)
86
+ def callback(single_res: SingleSolution):
87
+ print("Solution found")
88
+ # res = np.full((self.V, self.H), ' ', dtype=object)
89
+ # for pos in get_all_pos(self.V, self.H):
90
+ # c = 'B' if single_res.assignment[pos] == 1 else ' '
91
+ # set_char(res, pos, c)
92
+ # print(res)
93
+ print(render_shaded_grid(self.V, self.H, lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, empty_text=lambda r, c: self.region_to_clue.get(int(self.board[r, c]), ' ')))
94
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=1)
@@ -0,0 +1,77 @@
1
+ from typing import Iterator
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 Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, set_char, get_char, get_neighbors8
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+ from puzzle_solver.core.utils_visualizer import render_shaded_grid
10
+
11
+
12
+ class Board:
13
+ def __init__(self, board: np.array, row_sums: list[list[int]], col_sums: list[list[int]], N: int = 9):
14
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
15
+ assert all((c.item() in ['#', ' ', '1', '2', '3', '4', '5', '6', '7', '8', '9']) for c in np.nditer(board)), 'board must contain only #, space, or digits'
16
+ assert len(row_sums) == board.shape[0] and all(isinstance(i, list) and all(isinstance(j, int) or j == '#' for j in i) for i in row_sums), 'row_sums must be a list of lists of integers or #'
17
+ assert len(col_sums) == board.shape[1] and all(isinstance(i, list) and all(isinstance(j, int) or j == '#' for j in i) for i in col_sums), 'col_sums must be a list of lists of integers or #'
18
+ self.board = board
19
+ self.row_sums = row_sums
20
+ self.col_sums = col_sums
21
+ self.V, self.H = board.shape
22
+ self.N = N
23
+ self.model = cp_model.CpModel()
24
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
25
+
26
+ self.create_vars()
27
+ self.add_all_constraints()
28
+
29
+ def create_vars(self):
30
+ for pos in get_all_pos(self.V, self.H):
31
+ if get_char(self.board, pos) == '#':
32
+ continue
33
+ self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
34
+
35
+ def get_consecutives(self, pos: Pos, direction: Direction) -> Iterator[list[Pos]]:
36
+ consecutive = []
37
+ while in_bounds(pos, self.V, self.H):
38
+ if get_char(self.board, pos) == '#':
39
+ if len(consecutive) > 0:
40
+ yield consecutive
41
+ consecutive = []
42
+ else:
43
+ consecutive.append(pos)
44
+ pos = get_next_pos(pos, direction)
45
+ if len(consecutive) > 0:
46
+ yield consecutive
47
+
48
+ def add_all_constraints(self):
49
+ for row in range(self.V):
50
+ row_consecutives = self.get_consecutives(get_pos(x=0, y=row), Direction.RIGHT)
51
+ for i, consecutive in enumerate(row_consecutives):
52
+ # print('row', row, 'i', i, 'consecutive', consecutive)
53
+ self.model.AddAllDifferent([self.model_vars[p] for p in consecutive])
54
+ clue = self.row_sums[row][i]
55
+ if clue != '#':
56
+ self.model.Add(lxp.sum([self.model_vars[p] for p in consecutive]) == clue)
57
+ assert len(self.row_sums[row]) == i + 1, f'row_sums[{row}] has {len(self.row_sums[row])} clues, but {i + 1} consecutive cells'
58
+ for col in range(self.H):
59
+ col_consecutives = self.get_consecutives(get_pos(x=col, y=0), Direction.DOWN)
60
+ for i, consecutive in enumerate(col_consecutives):
61
+ # print('col', col, 'i', i, 'consecutive', consecutive)
62
+ self.model.AddAllDifferent([self.model_vars[p] for p in consecutive])
63
+ clue = self.col_sums[col][i]
64
+ if clue != '#':
65
+ self.model.Add(lxp.sum([self.model_vars[p] for p in consecutive]) == clue)
66
+ assert len(self.col_sums[col]) == i + 1, f'col_sums[{col}] has {len(self.col_sums[col])} clues, but {i + 1} consecutive cells'
67
+
68
+ def solve_and_print(self, verbose: bool = True):
69
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
70
+ assignment: dict[Pos, int] = {}
71
+ for pos, var in board.model_vars.items():
72
+ assignment[pos] = solver.Value(var)
73
+ return SingleSolution(assignment=assignment)
74
+ def callback(single_res: SingleSolution):
75
+ print("Solution found")
76
+ print(render_shaded_grid(self.V, self.H, is_shaded=lambda r, c: self.board[r, c] == '#', empty_text=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)])))
77
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)