Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions README.CN.md

This file was deleted.

340 changes: 187 additions & 153 deletions README.md

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ nav:
- cave: puzzles/cave.md
- clueless_1_sudoku: puzzles/clueless_1_sudoku.md
- clueless_2_sudoku: puzzles/clueless_2_sudoku.md
- cojun: puzzles/cojun.md
- country_road: puzzles/country_road.md
- creek: puzzles/creek.md
- detour: puzzles/detour.md
Expand All @@ -37,6 +38,7 @@ nav:
- fuzuli: puzzles/fuzuli.md
- gappy: puzzles/gappy.md
- gattai_8_sudoku: puzzles/gattai_8_sudoku.md
- geradeweg: puzzles/geradeweg.md
- grand_tour: puzzles/grand_tour.md
- hakoiri: puzzles/hakoiri.md
- hakyuu: puzzles/hakyuu.md
Expand Down Expand Up @@ -66,24 +68,30 @@ nav:
- moon_sun: puzzles/moon_sun.md
- mosaic: puzzles/mosaic.md
- munraito: puzzles/munraito.md
- nanro: puzzles/nanro.md
- nawabari: puzzles/nawabari.md
- nondango: puzzles/nondango.md
- nonogram: puzzles/nonogram.md
- norinori: puzzles/norinori.md
- number_cross: puzzles/number_cross.md
- nurikabe: puzzles/nurikabe.md
- nurimisaki: puzzles/nurimisaki.md
- one_to_x: puzzles/one_to_x.md
- paint_area: puzzles/paint_area.md
- patchwork: puzzles/patchwork.md
- pfeilzahlen: puzzles/pfeilzahlen.md
- pills: puzzles/pills.md
- pipes: puzzles/pipes.md
- putteria: puzzles/putteria.md
- regional_yajilin: puzzles/regional_yajilin.md
- renban: puzzles/renban.md
- samurai_sudoku: puzzles/samurai_sudoku.md
- shikaku: puzzles/shikaku.md
- shimaguni: puzzles/shimaguni.md
- shingoki: puzzles/shingoki.md
- shirokuro: puzzles/shirokuro.md
- shogun_sudoku: puzzles/shogun_sudoku.md
- shugaku: puzzles/shugaku.md
- simple_loop: puzzles/simple_loop.md
- skyscraper: puzzles/skyscraper.md
- slitherlink: puzzles/slitherlink.md
Expand All @@ -97,12 +105,14 @@ nav:
- sudoku: puzzles/sudoku.md
- suguru: puzzles/suguru.md
- sumo_sudoku: puzzles/sumo_sudoku.md
- tatamibari: puzzles/tatamibari.md
- tenner_grid: puzzles/tenner_grid.md
- tent: puzzles/tent.md
- terra_x: puzzles/terra_x.md
- thermometer: puzzles/thermometer.md
- tile_paint: puzzles/tile_paint.md
- trinairo: puzzles/trinairo.md
- usoone: puzzles/usoone.md
- windmill_sudoku: puzzles/windmill_sudoku.md
- yajikabe: puzzles/yajikabe.md
- yajilin: puzzles/yajilin.md
Expand Down Expand Up @@ -134,7 +144,6 @@ theme:
- navigation.top
- navigation.indexes


markdown_extensions:
- admonition
- meta
Expand Down Expand Up @@ -166,5 +175,4 @@ markdown_extensions:
- pymdownx.snippets
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator:
!!python/name:material.extensions.emoji.to_svg
emoji_generator: !!python/name:material.extensions.emoji.to_svg
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"

[project]
name = "puzzlekit"
version = "0.3.1"
description = "A comprehensive logic puzzle solver (90+) based on Google OR-Tools. e.g., solvers for Nonogram, Slitherlink, Akari, Yajilin, Hitori and Sudoku-variants."
version = "0.3.2"
description = "A comprehensive logic puzzle solver (100+) based on Google OR-Tools. e.g., solvers for Nonogram, Slitherlink, Akari, Yajilin, Hitori and Sudoku-variants."
readme = "README.md"
requires-python = ">=3.10"
authors = [{name = "SmilingWayne", email = "xiaoxiaowayne@gmail.com"}]
Expand Down
19 changes: 18 additions & 1 deletion scripts/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,19 @@ def parse_args():
group.add_argument("-p", "--puzzle", type=str,
help="Specific puzzle name to benchmark (e.g. 'Akari', 'slitherlink'). Case-insensitive.")

parser.add_argument("--skip", type=str, default="",
help="Comma-separated list of puzzle names to skip (e.g., 'Nurikabe,Fillomino').")
return parser.parse_args()

def main():
import time
tic = time.perf_counter()
args = parse_args()
# Support skip func
skip_set = set()
if args.skip:
skip_set = {name.strip() for name in args.skip.split(',') if name.strip()}
print(f"NOTE: Will skip the following puzzles: {', '.join(skip_set)}")

# Default behavior: run all if no arguments specified
target_puzzle = args.puzzle
Expand Down Expand Up @@ -216,7 +223,17 @@ def main():
# Check solver availability
solver_status = "❌"
has_solver_impl = False
if puzzle_type:
# if puzzle_type:
# try:
# get_solver_class(puzzle_type)
# has_solver_impl = True
# solver_status = "✅"
# except (ValueError, AttributeError):
# pass
if folder_name in skip_set:
print(f"[{idx}/{len(sorted_assets)}] Skipping {folder_name} (Requested by user --skip)")

elif puzzle_type:
try:
get_solver_class(puzzle_type)
has_solver_impl = True
Expand Down
16 changes: 13 additions & 3 deletions scripts/quick_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,21 @@
# Raw input data
start_time = time.time()
problem_str = """
10 10\n- - - - - 3sx - - - -\n- - - o - - - 3so - -\n- x - - - - - - - -\n- - - - 2eo - - - o -\n1nx - - - - - 1wo - - -\n- - - 1so - - - - - 2nx\n- x - - - 1sx - - - -\n- - - - - - - - o -\n- - 3nx - - - x - - -\n- - - - 2ex - - - - -
10 10
- - - - - b - - - -
- b - - w - - b - -
w - - - - - - - - -
- - - - - - - - w -
- - - - - b b - b -
- w - w w - - - - -
- w - - - - - - - -
- - - - - - - - - b
- - b - - b - - w -
- - - - w - - - - -
"""

# Solve
res = puzzlekit.solve(problem_str, puzzle_type="castle_wall")
res = puzzlekit.solve(problem_str, puzzle_type="masyu")

# Print solution grid
print(res.solution_data.get('solution_grid', []))
Expand All @@ -17,4 +27,4 @@
end_time = time.time()
print(f"Time taken: {end_time - start_time} seconds")
# Visualize (optional)
res.show()
res.show()
2 changes: 1 addition & 1 deletion src/puzzlekit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,4 @@ def solver(puzzle_type: str, data: Dict[str, Any] = None, **kwargs) -> Any:
return SolverClass(**init_params)

__all__ = ["solve", "solver"]
__version__ = '0.3.1'
__version__ = '0.3.2'
1 change: 0 additions & 1 deletion src/puzzlekit/core/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ def solve(self) -> dict:
# If _check_and_add_cuts returns True, it means the current solution does not satisfy the connectivity constraint,
# and new constraints have been added. We need to continue solving.
cuts_added = self._check_and_add_cuts()

if not cuts_added:
# No new cuts added -> All constraints satisfied -> Found final solution.
final_status_str = "Optimal" if status == pywraplp.Solver.OPTIMAL else "Feasible"
Expand Down
10 changes: 10 additions & 0 deletions src/puzzlekit/parsers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@
"nurimisaki": standard_grid_parser,
"aqre": standard_region_grid_parser,
"canal_view": standard_grid_parser,
"nurikabe": standard_grid_parser,
"cojun": standard_region_grid_parser,
"shugaku": standard_grid_parser,
"geradeweg": standard_grid_parser,
"nanro": standard_region_grid_parser,
"shimaguni": standard_region_grid_parser,
"usoone": standard_region_grid_parser,
"tatamibari": standard_grid_parser,
"shirokuro": standard_grid_parser,
"pipes": standard_grid_parser,
}


Expand Down
22 changes: 22 additions & 0 deletions src/puzzlekit/solvers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@
from .nurimisaki import NurimisakiSolver
from .aqre import AqreSolver
from .canal_view import CanalViewSolver
from .nurikabe import NurikabeSolver
from .cojun import CojunSolver
from .shugaku import ShugakuSolver
from .geradeweg import GeradewegSolver
from .nanro import NanroSolver
from .shimaguni import ShimaguniSolver
from .usoone import UsooneSolver
from .tatamibari import TatamibariSolver
from .shirokuro import ShirokuroSolver
from .pipes import PipesSolver
# ==========================================
# Core: Mapping of puzzle type to solver class
# ==========================================
Expand Down Expand Up @@ -202,6 +212,18 @@
"nurimisaki": ("nurimisaki", "NurimisakiSolver"),
"aqre": ("aqre", "AqreSolver"),
"canal_view": ("canal_view", "CanalViewSolver"),
"nurikabe": ("nurikabe", "NurikabeSolver"),
"cojun": ("cojun", "CojunSolver"),
"shugaku": ("shugaku", "ShugakuSolver"),
"geradeweg": ("geradeweg", "GeradewegSolver"),
"nanro": ("nanro", "NanroSolver"),
"shimaguni": ("shimaguni", "ShimaguniSolver"),
"usoone": ("usoone", "UsooneSolver"),
"tatamibari": ("tatamibari", "TatamibariSolver"),
"shirokuro": ("shirokuro", "ShirokuroSolver"),
"pipes": ("pipes", "PipesSolver"),


}
# ==========================================

Expand Down
111 changes: 111 additions & 0 deletions src/puzzlekit/solvers/cojun.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from typing import Any, List, Dict, Set, Tuple
from collections import defaultdict
from puzzlekit.core.solver import PuzzleSolver
from puzzlekit.core.grid import Grid
from puzzlekit.core.regionsgrid import RegionsGrid
from puzzlekit.core.position import Position
from ortools.sat.python import cp_model as cp
from typeguard import typechecked


class CojunSolver(PuzzleSolver):
metadata : Dict[str, Any] = {
"name": "Cojun",
"aliases": ["Kojun"],
"difficulty": "",
"tags": [],
"rule_url": "https://pzplus.tck.mn/rules.html?cojun",
"external_links": [

],
"input_desc": "TBD",
"output_desc": "TBD",
"input_example": """
6 6\n2 - - - 1 -\n- - - 3 - -\n- 3 - - 5 3\n- - - - - -\n- - 3 - 4 2\n- - - - - -\n1 1 7 7 7 11\n2 2 2 2 2 11\n3 6 6 6 2 10\n3 3 3 6 10 10\n4 4 8 9 9 9\n5 5 8 8 9 9
""",
"output_example": """
6 6\n2 1 3 2 1 2\n1 4 2 3 6 1\n4 3 4 2 5 3\n3 1 2 1 2 1\n1 2 3 5 4 2\n2 1 2 1 3 1
"""
}


@typechecked
def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]], region_grid: List[List[str]]):
self.num_rows: int = num_rows
self.num_cols: int = num_cols
# Clue grid: '-' for empty, digits for given numbers
self.grid: Grid[str] = Grid(grid)
# Region grid: defines region membership
self.region_grid: RegionsGrid[str] = RegionsGrid(region_grid)
self.validate_input()

def validate_input(self):
self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix)
self._check_grid_dims(self.num_rows, self.num_cols, self.region_grid.matrix)
# Allowed chars: '-' for empty cells, digits for clues
self._check_allowed_chars(
self.grid.matrix,
{'-'},
validator=lambda x: x.isdigit() and int(x) > 0
)

def _add_constr(self):
self.model = cp.CpModel()
self.solver = cp.CpSolver()
self.x = {} # Decision variables: x[(r, c)] = value at position (r, c)

# Precompute region information
region_cells = {rid: list(cells) for rid, cells in self.region_grid.regions.items()}
region_sizes = {rid: len(cells) for rid, cells in region_cells.items()}

# 1. Create variables and apply domain constraints
for r in range(self.num_rows):
for c in range(self.num_cols):
char = self.grid.value(r, c)
rid = self.region_grid.value(r, c)
region_size = region_sizes[rid]

var_name = f"x[{r},{c}]"
# Domain: 1 to region_size (inclusive)
self.x[r, c] = self.model.NewIntVar(1, region_size, var_name)

# Pre-filled number constraint
if char.isdigit():
self.model.Add(self.x[r, c] == int(char))

# 2. Region uniqueness constraints (AllDifferent within each region)
for rid, cells in region_cells.items():
region_vars = [self.x[pos.r, pos.c] for pos in cells]
self.model.AddAllDifferent(region_vars)

# 3. Orthogonal adjacency constraint: same numbers cannot be orthogonally adjacent
for r in range(self.num_rows):
for c in range(self.num_cols):
# Check right neighbor
if c + 1 < self.num_cols:
self.model.Add(self.x[r, c] != self.x[r, c + 1])

# Check down neighbor
if r + 1 < self.num_rows:
self.model.Add(self.x[r, c] != self.x[r + 1, c])

# 4. Vertical stacking constraint: within same region, top number must be larger than bottom
for r in range(self.num_rows - 1): # Only check down direction to avoid duplication
for c in range(self.num_cols):
# Check if current cell and cell below belong to same region
current_rid = self.region_grid.value(r, c)
below_rid = self.region_grid.value(r + 1, c)

if current_rid == below_rid:
# Enforce strict inequality: top > bottom
self.model.Add(self.x[r, c] > self.x[r + 1, c])

def get_solution(self):
sol_grid = [[None for _ in range(self.num_cols)] for _ in range(self.num_rows)]

for r in range(self.num_rows):
for c in range(self.num_cols):
val = self.solver.Value(self.x[r, c])
sol_grid[r][c] = str(val)

return Grid(sol_grid)
Loading