From 1f661428a89730b2ffb5625be9fa1023cef55d85 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Tue, 10 Feb 2026 01:23:37 +0800 Subject: [PATCH 1/4] add new solvers: nurikabe (wip), cojun, shugaku, update heyawake, hitori solver --- src/puzzlekit/core/solver.py | 1 - src/puzzlekit/parsers/registry.py | 3 + src/puzzlekit/solvers/__init__.py | 6 + src/puzzlekit/solvers/cojun.py | 111 +++++++++ src/puzzlekit/solvers/heyawake.py | 335 ++++++++++++++++++++++++++-- src/puzzlekit/solvers/hitori.py | 228 +------------------ src/puzzlekit/solvers/nurikabe.py | 334 +++++++++++++++++++++++++++ src/puzzlekit/solvers/shugaku.py | 283 +++++++++++++++++++++++ src/puzzlekit/verifiers/__init__.py | 3 + src/puzzlekit/viz/__init__.py | 3 + 10 files changed, 1064 insertions(+), 243 deletions(-) create mode 100644 src/puzzlekit/solvers/cojun.py create mode 100644 src/puzzlekit/solvers/nurikabe.py create mode 100644 src/puzzlekit/solvers/shugaku.py diff --git a/src/puzzlekit/core/solver.py b/src/puzzlekit/core/solver.py index 0c699ee3..324a7b3d 100644 --- a/src/puzzlekit/core/solver.py +++ b/src/puzzlekit/core/solver.py @@ -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" diff --git a/src/puzzlekit/parsers/registry.py b/src/puzzlekit/parsers/registry.py index d19e8c8f..d675b7b7 100644 --- a/src/puzzlekit/parsers/registry.py +++ b/src/puzzlekit/parsers/registry.py @@ -118,6 +118,9 @@ "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 } diff --git a/src/puzzlekit/solvers/__init__.py b/src/puzzlekit/solvers/__init__.py index 0f74b31a..6f6ecf12 100644 --- a/src/puzzlekit/solvers/__init__.py +++ b/src/puzzlekit/solvers/__init__.py @@ -97,6 +97,9 @@ 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 # ========================================== # Core: Mapping of puzzle type to solver class # ========================================== @@ -202,6 +205,9 @@ "nurimisaki": ("nurimisaki", "NurimisakiSolver"), "aqre": ("aqre", "AqreSolver"), "canal_view": ("canal_view", "CanalViewSolver"), + "nurikabe": ("nurikabe", "NurikabeSolver"), + "cojun": ("cojun", "CojunSolver"), + "shugaku": ("shugaku", "ShugakuSolver"), } # ========================================== diff --git a/src/puzzlekit/solvers/cojun.py b/src/puzzlekit/solvers/cojun.py new file mode 100644 index 00000000..72eb8397 --- /dev/null +++ b/src/puzzlekit/solvers/cojun.py @@ -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": [""], + "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) \ No newline at end of file diff --git a/src/puzzlekit/solvers/heyawake.py b/src/puzzlekit/solvers/heyawake.py index 460fc8f1..537ea77f 100644 --- a/src/puzzlekit/solvers/heyawake.py +++ b/src/puzzlekit/solvers/heyawake.py @@ -1,10 +1,11 @@ -from typing import Any, List, Dict, Set, Tuple +from typing import Any, List, Dict, Set, Tuple, FrozenSet 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 puzzlekit.core.result import PuzzleResult from puzzlekit.utils.ortools_utils import ortools_cpsat_analytics +from puzzlekit.utils.ortools_utils import add_connected_subgraph_constraint from ortools.sat.python import cp_model as cp from typeguard import typechecked import copy @@ -84,11 +85,32 @@ def _add_constr(self): # 1 = Shaded (Black), 0 = Unshaded (White) self.x[i, j] = self.model.NewBoolVar(name = f"x[{i}, {j}]") + self.boundary_cells = self._get_boundary_cells() self._add_region_num_constr() self._add_stripe_constr() self._add_adjacent_constr() + self._add_connected_at_least_constr() # REMOVED: self._add_connectivity_constr() - + + def _add_connected_at_least_constr(self): + """ + Basic connectivity hint: + + Each white cell must have at least one white neighbor + (unless it's the only white cell, which is rare). + This is a weak but useful constraint from the Gurobi code. + """ + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + neighbors = list(self.grid.get_neighbors(pos, "orthogonal")) + if neighbors: + # If pos is white, at least one neighbor must be white + # is_white[pos] <= sum(is_white[n] for n in neighbors) + self.model.Add( + 1 - self.x[pos.r, pos.c] <= sum(1 - self.x[n.r, n.c] for n in neighbors) + ) + def _add_region_num_constr(self): for region_id, cells in self.region_grid.regions.items(): curr_val = None @@ -155,11 +177,21 @@ def _add_adjacent_constr(self): if j < self.num_cols - 1: self.model.Add(self.x[i, j] + self.x[i, j + 1] <= 1) + def _temp_display(self): + for i in range(self.num_rows): + for j in range(self.num_cols): + if self.solver.Value(self.x[i, j]) == 1: + print("x", end=" ") + else: + print("-", end=" ") + print() + def _check_connectivity(self) -> List[Set[Tuple[int, int]]]: """ BFS to find connected components of WHITE cells (x=0). Returns a list of components, where each component is a Set of (r,c). """ + white_cells = set() for r in range(self.num_rows): for c in range(self.num_cols): @@ -194,7 +226,237 @@ def _check_connectivity(self) -> List[Set[Tuple[int, int]]]: components.append(curr_comp) return components + + def _find_loop_from_component(self, comp: FrozenSet[Position]) -> Set[Position]: + + if len(comp) < 4: # 少于4个格子不可能形成对角线环 + return set() + + adjacency = {pos: [] for pos in comp} + + for pos in comp: + for neighbor in self.grid.get_neighbors(pos, "diagonal_only"): + neighbor_pos = Position(neighbor[0], neighbor[1]) + if neighbor_pos in comp: + adjacency[pos].append(neighbor_pos) + + visited = set() + parent = {} + cycle_found = False + cycle_start = None + cycle_end = None + + def dfs(current: Position, prev: Position) -> bool: + nonlocal cycle_found, cycle_start, cycle_end + + visited.add(current) + + for neighbor in adjacency[current]: + if neighbor not in visited: + parent[neighbor] = current + if dfs(neighbor, current): + return True + elif neighbor != prev: + # 找到环:邻居已被访问但不是父节点 + cycle_found = True + cycle_start = neighbor + cycle_end = current + return True + return False + + # 3. 从每个未访问的节点开始DFS检测环 + for pos in comp: + if pos not in visited and not cycle_found: + parent[pos] = None + if dfs(pos, None): + break + + if not cycle_found: + return set() + + # 4. 提取环上的节点 + cycle_nodes = set() + + # 从cycle_end回溯到cycle_start + current = cycle_end + while current != cycle_start: + cycle_nodes.add(current) + current = parent.get(current) + if current is None: + # 回溯失败,返回空集 + return set() + + cycle_nodes.add(cycle_start) + + # 5. 确保环是闭合的 + # 检查cycle_start和cycle_end是否通过边直接连接 + if cycle_start not in adjacency.get(cycle_end, []) and cycle_end not in adjacency.get(cycle_start, []): + # 如果不是直接连接,需要找到连接路径 + # 通过BFS找到连接cycle_start和cycle_end的最短路径 + queue = deque([(cycle_start, [cycle_start])]) + visited_path = {cycle_start} + found = False + + while queue and not found: + current, path = queue.popleft() + + for neighbor in adjacency.get(current, []): + if neighbor == cycle_end: + # 找到连接路径 + for node in path + [neighbor]: + cycle_nodes.add(node) + found = True + break + elif neighbor not in visited_path: + visited_path.add(neighbor) + queue.append((neighbor, path + [neighbor])) + + if not found: + return set() + + valid_cycle = True + for node in cycle_nodes: + neighbors_in_cycle = sum(1 for n in adjacency.get(node, []) if n in cycle_nodes) + if neighbors_in_cycle < 2: + valid_cycle = False + break + + if not valid_cycle or len(cycle_nodes) < 4: + return set() + + final_cycle = self._extract_core_cycle(cycle_nodes, adjacency) + return final_cycle + + def _extract_core_cycle(self, nodes: Set[Position], adjacency: dict) -> Set[Position]: + + from collections import defaultdict + degree = defaultdict(int) + for node in nodes: + degree[node] = 0 + for node in nodes: + for neighbor in adjacency.get(node, []): + if neighbor in nodes: + degree[node] += 1 + + nodes_set = set(nodes) + changed = True + + while changed: + changed = False + to_remove = set() + + for node in nodes_set: + node_degree = 0 + for neighbor in adjacency.get(node, []): + if neighbor in nodes_set: + node_degree += 1 + + if node_degree <= 1: + to_remove.add(node) + changed = True + + nodes_set -= to_remove + + # 如果节点数变得太少,返回空 + if len(nodes_set) < 4: + return set() + + return nodes_set + + def _is_on_boundary(self, pos: Position) -> bool: + return (pos.r == 0 or pos.r == self.num_rows - 1 or pos.c == 0 or pos.c == self.num_cols - 1) + + def _get_boundary_cells(self) -> FrozenSet[Position]: + boundary_cells = set() + for i in range(self.num_rows): + boundary_cells.add(Position(i, 0)) + boundary_cells.add(Position(i, self.num_cols - 1)) + for j in range(self.num_cols): + boundary_cells.add(Position(0, j)) + boundary_cells.add(Position(self.num_rows - 1, j)) + return frozenset[Position](boundary_cells) + + + def _get_black_diagonal_components(self) -> Set[FrozenSet[Position]]: + all_components = set() + visited = set() + + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + if (self.solver.Value(self.x[pos.r, pos.c]) == 1 and + pos not in visited): + + queue = [pos] + component = set() + visited.add(pos) + + while queue: + current = queue.pop(0) + component.add(current) + + for neighbor in self.grid.get_neighbors(current, "diagonal_only"): + if (neighbor is not None and + neighbor not in visited and + self.solver.Value(self.x[neighbor.r, neighbor.c]) == 1): + visited.add(neighbor) + queue.append(neighbor) + if component: + all_components.add(frozenset(component)) + # for comp in all_components: + # print(comp) + # print("--------------------------------") + return all_components + + + def _find_all_cycles_in_component(self, comp: FrozenSet[Position]) -> List[FrozenSet[Position]]: + if len(comp) < 4: # 少于4个格子不可能形成对角线环 + return [] + + + + def _get_cycle_edges(self, comp: FrozenSet[Position]) -> Set[Tuple[Position, Position]]: + """获取构成环的边(用于调试和可视化)""" + if len(comp) < 4: + return set() + + # 构建邻接表 + adjacency = {pos: [] for pos in comp} + for pos in comp: + for neighbor in self.grid.get_neighbors(pos, "diagonal_only"): + if neighbor in comp: + adjacency[pos].append(neighbor) + + visited = set() + parent = {} + cycle_edges = set() + + def dfs(current, prev): + visited.add(current) + + for neighbor in adjacency[current]: + if neighbor not in visited: + parent[neighbor] = current + if dfs(neighbor, current): + return True + elif neighbor != prev: + # 找到环,回溯记录边 + cur = current + while cur != neighbor: + next_pos = parent[cur] + cycle_edges.add((min(cur, next_pos), max(cur, next_pos))) + cur = next_pos + cycle_edges.add((min(current, neighbor), max(current, neighbor))) + return True + return False + + for pos in comp: + if pos not in visited: + dfs(pos, None) + + return cycle_edges + # Override the solve method to implement Iterative Constraint Generation def solve(self) -> PuzzleResult: tic = time.perf_counter() @@ -210,13 +472,31 @@ def solve(self) -> PuzzleResult: iteration = 0 solution_dict = {} - + add_new = False while True: iteration += 1 # print(f"Iteration {iteration}...") - + # print(f"Iteration {iteration}...") status = self.solver.Solve(self.model) - + if iteration > 50 and not add_new: + print("DUMPING NEW CONSTRAINT!") + add_new = True + adjacency_map = {} + self.is_white = {} + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + self.is_white[i, j] = self.model.NewBoolVar(name = f"x[{i}, {j}]") + self.model.Add(self.x[i, j] + self.is_white[i, j] == 1) + neighbors = self.grid.get_neighbors(pos, "orthogonal") + adjacency_map[i, j] = set((nbr.r, nbr.c) for nbr in neighbors) + + add_connected_subgraph_constraint( + self.model, + self.is_white, + adjacency_map + ) + # Check feasibility if status not in [cp.OPTIMAL, cp.FEASIBLE]: # Infeasible or Unknown @@ -225,8 +505,36 @@ def solve(self) -> PuzzleResult: break # 2. Check Connectivity - components = self._check_connectivity() + black_components = self._get_black_diagonal_components() + for comp in black_components: + # Rule1. check border overlap + if len(comp) <= 2: + continue + if len(comp & self.boundary_cells) >= 2: + # have 2 boundary cells in the component + # at least one cell in the component must be white + # print(comp) + # print(f"ADDed! {iteration}") + self.model.Add(sum(self.x[pos.r, pos.c] for pos in comp) <= len(comp) - 1) + + # Rule2. Check loop in the component + # if len(comp) < 4: + # continue + # for comp in black_components: + loop = self._find_loop_from_component(comp) + if loop: +# cuts_added = True +# # print(f"ADD!2") +# # print([[pos.r, pos.c] for pos in loop]) + self.model.Add(sum(self.x[pos.r, pos.c] for pos in loop) <= len(loop) - 1) + # break + + + # Case B: Disconnected -> Add Constraints (Cuts) + # Strategy: For each isolated component (except maybe the largest one), + # find its boundary (Black cells). At least one of those Black cells MUST be White. + components = self._check_connectivity() # Case A: Simply connected (1 component) -> Success if len(components) <= 1: # Done! @@ -236,18 +544,14 @@ def solve(self) -> PuzzleResult: solution_dict['status'] = "Optimal" solution_dict['iterations'] = iteration break - # Case B: Disconnected -> Add Constraints (Cuts) - # Strategy: For each isolated component (except maybe the largest one), - # find its boundary (Black cells). At least one of those Black cells MUST be White. - # Sort components by size (process smallest first usually better) components.sort(key=len) - # We cut ALL components except the largest one (assuming the largest is the "main" body) - # Actually, cutting all of them is fine too, but usually one is the 'ocean'. - # Let's iterate through all components that are NOT touching the 'main' body. - # But we don't know which is main. Simple heuristic: cut the smaller ones. - # Adding cuts for components[0 ... -2] (all except biggest) + # # We cut ALL components except the largest one (assuming the largest is the "main" body) + # # Actually, cutting all of them is fine too, but usually one is the 'ocean'. + # # Let's iterate through all components that are NOT touching the 'main' body. + # # But we don't know which is main. Simple heuristic: cut the smaller ones. + # # Adding cuts for components[0 ... -2] (all except biggest) for comp in components[:-1]: # Find "Boundary Wall": Black cells adjacent to this component @@ -296,3 +600,4 @@ def get_solution(self): sol_grid[i][j] = "-" # Unshaded return Grid(sol_grid) + diff --git a/src/puzzlekit/solvers/hitori.py b/src/puzzlekit/solvers/hitori.py index a553ae49..48ba01ac 100644 --- a/src/puzzlekit/solvers/hitori.py +++ b/src/puzzlekit/solvers/hitori.py @@ -1,234 +1,8 @@ -# from typing import Any, List, Dict -# from puzzlekit.core.solver import PuzzleSolver, IterativePuzzleSolver -# from puzzlekit.core.grid import Grid -# from puzzlekit.core.position import Position -# from ortools.linear_solver import pywraplp -# from collections import deque -# from itertools import chain -# from typeguard import typechecked -# from puzzlekit.utils.ortools_utils import ortools_mip_analytics - -# class HitoriSolver(PuzzleSolver): - -# @typechecked -# def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): -# self.num_rows: int = num_rows -# self.num_cols: int = num_cols -# self.grid: Grid[str] = Grid(grid) -# self.validate_input() -# self.solver = None -# self.is_white = {} - -# def validate_input(self): -# self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) -# self._check_allowed_chars( -# self.grid.matrix, -# {'-'}, -# validator=lambda x: x.isdigit() and int(x) >= 0 -# ) - -# def _add_constr(self): -# self.solver = pywraplp.Solver.CreateSolver('SCIP') -# if not self.solver: -# raise RuntimeError("Unable to create solver.") - -# for i in range(self.num_rows): -# for j in range(self.num_cols): -# pos = Position(i, j) -# var_name = f"white_{pos}" -# self.is_white[pos] = self.solver.BoolVar(var_name) - -# for i in range(self.num_rows): -# for j in range(self.num_cols): -# curr = Position(i, j) -# for neighbor in self.grid.get_neighbors(curr, "orthogonal"): -# self.solver.Add( -# self.is_white[curr] + self.is_white[neighbor] >= 1 -# ) - -# for r in range(self.num_rows): -# val_map = {} -# for c in range(self.num_cols): -# val = self.grid.value(r, c) -# val_map.setdefault(val, []).append(Position(r, c)) - -# for val, positions in val_map.items(): -# if len(positions) > 1: -# self.solver.Add( -# sum(self.is_white[pos] for pos in positions) <= 1 -# ) - -# for c in range(self.num_cols): -# val_map = {} -# for r in range(self.num_rows): -# val = self.grid.value(r, c) -# val_map.setdefault(val, []).append(Position(r, c)) - -# for val, positions in val_map.items(): -# if len(positions) > 1: -# self.solver.Add( -# sum(self.is_white[pos] for pos in positions) <= 1 -# ) - -# def _check_connectivity_and_add_cuts(self, solution_values): -# rows, cols = self.num_rows, self.num_cols -# grid_state = [[0] * cols for _ in range(rows)] -# for i in range(rows): -# for j in range(cols): -# pos = Position(i, j) -# grid_state[i][j] = solution_values[pos] - -# visited = set() -# directions = [(-1, 0), (1, 0), (0, -1), (0, 1)] -# white_cells = [] - -# for i in range(rows): -# for j in range(cols): -# if grid_state[i][j] == 1 and (i, j) not in visited: -# queue = deque([(i, j)]) -# component = [] -# boundary_black_cells = set() - -# while queue: -# x, y = queue.popleft() -# if (x, y) in visited: -# continue - -# visited.add((x, y)) -# component.append((x, y)) - -# # 检查相邻单元格 -# for dx, dy in directions: -# nx, ny = x + dx, y + dy -# if 0 <= nx < rows and 0 <= ny < cols: -# if grid_state[nx][ny] == 1 and (nx, ny) not in visited: -# queue.append((nx, ny)) -# elif grid_state[nx][ny] == 0: -# boundary_black_cells.add((nx, ny)) - -# white_cells.append({ -# 'component': component, -# 'boundary_black_cells': list(boundary_black_cells) -# }) - -# if len(white_cells) <= 1: -# return True - -# for comp_data in white_cells: -# boundary = comp_data['boundary_black_cells'] -# if not boundary: -# continue -# constraint = self.solver.Constraint(1, len(boundary)) -# for (x, y) in boundary: -# pos = Position(x, y) -# constraint.SetCoefficient(self.is_white[pos], 1) - -# return False - -# def get_solution(self): -# sol_grid = [["" for _ in range(self.num_cols)] for _ in range(self.num_rows)] -# for i in range(self.num_rows): -# for j in range(self.num_cols): -# pos = Position(i, j) -# if self.is_white[pos].solution_value() == 1: -# sol_grid[i][j] = "-" -# else: -# sol_grid[i][j] = "x" -# return Grid(sol_grid) - -# def solve(self) -> dict: -# """求解Hitori问题""" -# from puzzlekit.core.result import PuzzleResult -# import time - -# solution_dict = {} - -# # 1. 构建模型 -# tic = time.perf_counter() -# self._add_constr() -# toc = time.perf_counter() -# build_time = toc - tic - -# # 2. 设置目标函数:最小化黑色单元格数量 -# objective = self.solver.Objective() -# for i in range(self.num_rows): -# for j in range(self.num_cols): -# pos = Position(i, j) -# # 最小化黑色单元格数量 = 最大化白色单元格数量的负数 -# # 等价于最小化 (1 - is_white) -# objective.SetCoefficient(self.is_white[pos], -1) -# objective.SetMinimization() - -# # 3. 迭代求解,添加连通性割平面 -# max_iterations = 10000 -# iteration = 0 -# is_connected = False -# status = "Not Solved" -# start_time = time.perf_counter() - -# while iteration < max_iterations and not is_connected: -# # 求解当前模型 -# status = self.solver.Solve() - -# if status != pywraplp.Solver.OPTIMAL and status != pywraplp.Solver.FEASIBLE: -# # 无可行解 -# break - -# # 获取当前解 -# solution_values = {} -# for i in range(self.num_rows): -# for j in range(self.num_cols): -# pos = Position(i, j) -# solution_values[pos] = 1 if self.is_white[pos].solution_value() >= 0.5 else 0 - -# # 检查连通性并添加割平面 -# is_connected = self._check_connectivity_and_add_cuts(solution_values) - -# if is_connected: -# break - -# iteration += 1 - -# end_time = time.perf_counter() -# solve_time = end_time - start_time - -# # 4. 收集统计信息 -# solution_dict = ortools_mip_analytics(self.solver, self.is_white) -# solution_dict['build_time'] = build_time -# solution_dict['solve_time'] = solve_time -# solution_dict['num_cuts_added'] = iteration - -# solution_status = { -# pywraplp.Solver.OPTIMAL: "Optimal", -# pywraplp.Solver.FEASIBLE: "Feasible", -# pywraplp.Solver.INFEASIBLE: "Infeasible", -# pywraplp.Solver.ABNORMAL: "Abnormal", -# pywraplp.Solver.NOT_SOLVED: "Not Solved", -# pywraplp.Solver.MODEL_INVALID: "Invalid Model", -# } - -# status = solution_status.get(status, "Unknown") - -# if status in ["Optimal", "Feasible"]: -# solution_grid = self.get_solution() -# else: -# solution_grid = Grid.empty() - -# solution_dict['solution_grid'] = solution_grid - -# return PuzzleResult( -# puzzle_type=self.puzzle_type, -# puzzle_data=vars(self).copy(), -# solution_data=solution_dict -# ) - -# src/puzzlekit/solvers/hitori.py - from typing import List, Dict, Any from puzzlekit.core.solver import IterativePuzzleSolver from puzzlekit.core.grid import Grid from puzzlekit.core.position import Position -from puzzlekit.utils.ortools_utils import add_connectivity_cut_node_based, ortools_mip_analytics +from puzzlekit.utils.ortools_utils import add_connectivity_cut_node_based from typeguard import typechecked class HitoriSolver(IterativePuzzleSolver): diff --git a/src/puzzlekit/solvers/nurikabe.py b/src/puzzlekit/solvers/nurikabe.py new file mode 100644 index 00000000..1c57db7c --- /dev/null +++ b/src/puzzlekit/solvers/nurikabe.py @@ -0,0 +1,334 @@ +from typing import Any, List, Dict, Set, Tuple +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_connected_subgraph_constraint +from ortools.sat.python import cp_model as cp +from typeguard import typechecked +import time + +class NurikabeSolver(PuzzleSolver): + metadata: Dict[str, Any] = { + "name": "Nurikabe", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://www.janko.at/Raetsel/Nurikabe/index.htm", + "external_links": [ + {"Play at puzz.link": "https://puzz.link/p?nurikabe"}, + {"Play at Janko": "https://www.janko.at/Raetsel/Nurikabe/0001.a.htm"}, + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ +10 10 +- - - - 2 - 2 - - - +2 - - - - - - - 2 - +- - - 2 - - - - - - +- 2 - - - - 2 - - - +- - - - - 2 - 2 - - +- - 2 - - - - - - - +- - - - - 2 - 2 - - +- - 2 - - - - - - 2 +2 - - - - 2 - - - - +- - - - - - - - 2 - + """, + "output_example": """ +10 10 +x x x - - x - - x x +- - x x x x x x - x +x x x - - x - x - x +x - - x x x - x x x +x x x x - - x - - x +x - - x x x x x x x +x x x x - - x - x - +- x - x x x x - x - +- x - x - - x x x x +x x x x x x x - - x + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.validate_input() + + # Collect all hint positions and their values + self.hints: Dict[Position, int] = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + val = self.grid.value(r, c) + if val.isdigit(): + self.hints[Position(r, c)] = int(val) + + # Total black cells = total cells - sum of all island sizes + self.total_black = num_rows * num_cols - sum(self.hints.values()) + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + self._check_allowed_chars(self.grid.matrix, {'-'}, validator=lambda x: x.isdigit() and int(x) >= 1) + + def _lightweight_preprocessing(self) -> Dict[Position, int]: + """ + Run lightweight deterministic inference BEFORE CP-SAT to fix cells. + Returns dict of {position: color} where color=1 for black, 0 for white. + """ + fixed = {} + changed = True + grid_state = {} # -1=unknown, 0=white, 1=black + + # Initialize state + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + val = self.grid.value(r, c) + if val.isdigit(): + grid_state[pos] = 0 # Numbered cells are white + fixed[pos] = 0 + else: + grid_state[pos] = -1 # Unknown + + # Helper: get region size including unknowns that MUST be white + def get_must_white_region(start: Position) -> Tuple[Set[Position], Set[Position]]: + """Returns (white_cells, unknown_neighbors) for region containing start.""" + if grid_state[start] != 0: + return set(), set() + + white_cells = {start} + unknown_neighbors = set() + queue = [start] + visited = {start} + + while queue: + pos = queue.pop(0) + for nb in self.grid.get_neighbors(pos, "orthogonal"): + if nb in visited: + continue + visited.add(nb) + if grid_state.get(nb, -1) == 0: + white_cells.add(nb) + queue.append(nb) + elif grid_state.get(nb, -1) == -1: + unknown_neighbors.add(nb) + return white_cells, unknown_neighbors + + # Iterative inference + while changed: + changed = False + + # Rule 1: Complete islands → surround with black + for hint_pos, size in self.hints.items(): + if grid_state[hint_pos] != 0: + continue + white_cells, unknown_neighbors = get_must_white_region(hint_pos) + if len(white_cells) == size and unknown_neighbors: + for nb in unknown_neighbors: + if grid_state.get(nb, -1) == -1: + grid_state[nb] = 1 + fixed[nb] = 1 + changed = True + + # Rule 2: Single liberty → must be white + for hint_pos, size in self.hints.items(): + if grid_state[hint_pos] != 0: + continue + white_cells, unknown_neighbors = get_must_white_region(hint_pos) + if len(white_cells) < size and len(unknown_neighbors) == 1: + nb = next(iter(unknown_neighbors)) + if grid_state.get(nb, -1) == -1: + grid_state[nb] = 0 + fixed[nb] = 0 + changed = True + + # Rule 3: 2x2 pool prevention (3 black + 1 unknown → unknown must be white) + for r in range(self.num_rows - 1): + for c in range(self.num_cols - 1): + cells = [ + Position(r, c), Position(r, c+1), + Position(r+1, c), Position(r+1, c+1) + ] + blacks = sum(1 for p in cells if grid_state.get(p, -1) == 1) + unknowns = [p for p in cells if grid_state.get(p, -1) == -1] + if blacks == 3 and len(unknowns) == 1: + p = unknowns[0] + grid_state[p] = 0 + fixed[p] = 0 + changed = True + + return fixed + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + # self.solver.parameters.max_time_in_seconds = 30.0 + # self.solver.parameters.num_search_workers = 8 + + # ========================================== + # 0. Preprocessing + # ========================================== + fixed_cells = self._lightweight_preprocessing() + + # ========================================== + # 1. Variables + # ========================================== + self.is_black: Dict[Position, cp.IntVar] = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + if pos in fixed_cells: + self.is_black[pos] = self.model.NewConstant(fixed_cells[pos]) + else: + self.is_black[pos] = self.model.NewBoolVar(f"black_{r}_{c}") + + # ========================================== + # 2. Hint cells must be white + # ========================================== + for hint_pos in self.hints: + self.model.Add(self.is_black[hint_pos] == 0) + + # ========================================== + # 3. No 2x2 black squares + # ========================================== + for r in range(self.num_rows - 1): + for c in range(self.num_cols - 1): + p1 = Position(r, c) + p2 = Position(r, c + 1) + p3 = Position(r + 1, c) + p4 = Position(r + 1, c + 1) + self.model.Add( + self.is_black[p1] + self.is_black[p2] + + self.is_black[p3] + self.is_black[p4] <= 3 + ) + + # ========================================== + # 4. Island constraints with membership tracking + # ========================================== + island_membership: Dict[Position, List[cp.IntVar]] = { + Position(r, c): [] + for r in range(self.num_rows) + for c in range(self.num_cols) + } + + for hint_pos, size in self.hints.items(): + if size == 1: + # Size 1: neighbors must be black + for nb in self.grid.get_neighbors(hint_pos, "orthogonal"): + self.model.Add(self.is_black[nb] == 1) + membership_const = self.model.NewConstant(1) + island_membership[hint_pos].append(membership_const) + continue + + # Flood-fill variables + flood = {} + for t in range(size + 1): + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + flood[(t, pos)] = self.model.NewBoolVar( + f"flood_{hint_pos.r}_{hint_pos.c}_{t}_{r}_{c}" + ) + + # Initial state (t=0) + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + if pos == hint_pos: + self.model.Add(flood[(0, pos)] == 1) + else: + self.model.Add(flood[(0, pos)] == 0) + + # Iterative expansion + for t in range(size): + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + option1 = flood[(t, pos)] + neighbors = self.grid.get_neighbors(pos, 'orthogonal') + neighbor_reached = [flood[(t, nb)] for nb in neighbors] + + if neighbor_reached: + has_reachable_neighbor = self.model.NewBoolVar( + f"has_reachable_{hint_pos.r}_{hint_pos.c}_{t}_{r}_{c}" + ) + self.model.AddBoolOr(neighbor_reached).OnlyEnforceIf(has_reachable_neighbor) + self.model.AddBoolAnd([v.Not() for v in neighbor_reached]).OnlyEnforceIf(has_reachable_neighbor.Not()) + + is_white = self.is_black[pos].Not() + + option2 = self.model.NewBoolVar( + f"option2_{hint_pos.r}_{hint_pos.c}_{t}_{r}_{c}" + ) + self.model.AddBoolAnd([has_reachable_neighbor, is_white]).OnlyEnforceIf(option2) + self.model.AddBoolOr([has_reachable_neighbor.Not(), is_white.Not()]).OnlyEnforceIf(option2.Not()) + + self.model.AddBoolOr([option1, option2]).OnlyEnforceIf(flood[(t+1, pos)]) + self.model.AddImplication(flood[(t+1, pos)].Not(), option1.Not()) + self.model.AddImplication(flood[(t+1, pos)].Not(), option2.Not()) + else: + self.model.Add(flood[(t+1, pos)] == option1) + + # Size constraint + all_positions = [Position(r, c) for r in range(self.num_rows) for c in range(self.num_cols)] + flood_sum = [flood[(size, pos)] for pos in all_positions] + self.model.Add(cp.LinearExpr.Sum(flood_sum) == size) + + # Reachable cells must be white + for pos in all_positions: + self.model.AddImplication(flood[(size, pos)], self.is_black[pos].Not()) + + for pos in all_positions: + island_membership[pos].append(flood[(size, pos)]) + + # ========================================== + # 4.5 ★ Each cell can only belong to one island ★ + # ========================================== + for pos, membership_vars in island_membership.items(): + if len(membership_vars) > 1: + self.model.Add(cp.LinearExpr.Sum(membership_vars) <= 1) + + # ========================================== + # 5. Total black cells constraint + # ========================================== + if self.total_black > 0: + black_sum = cp.LinearExpr.Sum([self.is_black[Position(r, c)] for r in range(self.num_rows) for c in range(self.num_cols)]) + self.model.Add(black_sum == self.total_black) + + # ========================================== + # 6. Black connectivity + # ========================================== + self._add_black_connectivity_constraint() + + + def _add_black_connectivity_constraint(self): + """Add constraint that all black cells form a single connected region.""" + # Build adjacency map for black connectivity + adjacency_map = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + neighbors = self.grid.get_neighbors(pos, 'orthogonal') + adjacency_map[pos] = neighbors + + # Use existing utility with optimization: require at least one black cell if total_black > 0 + if self.total_black > 0: + add_connected_subgraph_constraint( + self.model, + self.is_black, + adjacency_map, + prefix="nurikabe_black_conn" + ) + # If total_black == 0, no black cells exist (trivially connected) + + def get_solution(self): + + sol_grid = [['-' 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): + pos = Position(r, c) + if self.solver.Value(self.is_black[pos]) == 1: + sol_grid[r][c] = 'x' + + return Grid(sol_grid) diff --git a/src/puzzlekit/solvers/shugaku.py b/src/puzzlekit/solvers/shugaku.py new file mode 100644 index 00000000..3e7e6a13 --- /dev/null +++ b/src/puzzlekit/solvers/shugaku.py @@ -0,0 +1,283 @@ +from typing import Any, List, Dict, Tuple +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_connected_subgraph_constraint +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + +class ShugakuSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "Shugaku", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?shugaku", + "external_links": [ + {"Janko": "https://www.janko.at/Raetsel/Shugaku/003.a.htm"}, + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 6 6\n- - - - - -\n1 - - - - 2\n- - 2 - - -\n0 - - - - -\n5 - - - 3 -\n- - - - - 2 + """, + "output_example": """ + 6 6\n# o x x o #\n- o # x x -\no # - x o #\n- x x x x o\n- x o # - #\nx x x o # - + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.validate_input() + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + # Allow '-', or non-negative integers (0-5 typically for adjacent square count) + self._check_allowed_chars( + self.grid.matrix, + {'-'}, + validator=lambda x: x.isdigit() and 0 <= int(x) <= 5 + ) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + + # Identify clue cells (cells with numbers) + self.clue_cells: Dict[Position, int] = {} + self.free_cells: List[Position] = [] + + for r in range(self.num_rows): + for c in range(self.num_cols): + val = self.grid.value(r, c) + pos = Position(r, c) + if val.isdigit(): + self.clue_cells[pos] = int(val) + else: + self.free_cells.append(pos) + + # Variables for each free cell: + # is_black[pos]: True if cell is black + # is_circle[pos]: True if cell is a circle (part of domino) + # is_square[pos]: True if cell is a square (part of domino) + self.is_black: Dict[Position, cp.IntVar] = {} + self.is_circle: Dict[Position, cp.IntVar] = {} + self.is_square: Dict[Position, cp.IntVar] = {} + + for pos in self.free_cells: + self.is_black[pos] = self.model.NewBoolVar(f"black_{pos.r}_{pos.c}") + self.is_circle[pos] = self.model.NewBoolVar(f"circle_{pos.r}_{pos.c}") + self.is_square[pos] = self.model.NewBoolVar(f"square_{pos.r}_{pos.c}") + + # Each free cell is exactly one of: black, circle, or square + self.model.AddExactlyOne([ + self.is_black[pos], + self.is_circle[pos], + self.is_square[pos] + ]) + + # Domino variables: for each pair of adjacent free cells, create a domino variable + # domino[(pos1, pos2)]: True if there's a domino covering pos1 and pos2 + self.domino: Dict[Tuple[Position, Position], cp.IntVar] = {} + self._create_domino_variables() + + # Add constraints + self._add_domino_formation_constr() + self._add_domino_orientation_constr() + self._add_number_clue_constr() + self._add_black_connectivity_constr() + self._add_no_2x2_black_constr() + self._add_domino_adjacent_black_constr() + + def _create_domino_variables(self): + """Create domino variables for each pair of orthogonally adjacent free cells.""" + free_set = set(self.free_cells) + + for pos in self.free_cells: + # Only check right and down to avoid duplicates + for neighbor in [pos.right, pos.down]: + if neighbor in free_set: + # Use canonical ordering (smaller position first) + key = (pos, neighbor) if (pos.r, pos.c) < (neighbor.r, neighbor.c) else (neighbor, pos) + if key not in self.domino: + self.domino[key] = self.model.NewBoolVar(f"domino_{key[0].r}_{key[0].c}_{key[1].r}_{key[1].c}") + + def _add_domino_formation_constr(self): + """ + Each circle or square cell must be part of exactly one domino. + A domino consists of exactly one circle and one square. + """ + # For each free cell, collect all dominoes it could be part of + cell_dominoes: Dict[Position, List[Tuple[Position, Position]]] = {pos: [] for pos in self.free_cells} + + for key in self.domino: + pos1, pos2 = key + cell_dominoes[pos1].append(key) + cell_dominoes[pos2].append(key) + + # If a cell is not black, it must be in exactly one domino + for pos in self.free_cells: + dominoes_for_cell = cell_dominoes[pos] + + if dominoes_for_cell: + # sum of domino vars = 1 if not black, 0 if black + # Equivalent to: sum(domino vars) == (1 - is_black) + # Or: sum(domino vars) + is_black == 1 + domino_sum = sum(self.domino[key] for key in dominoes_for_cell) + self.model.Add(domino_sum == 1).OnlyEnforceIf(self.is_black[pos].Not()) + self.model.Add(domino_sum == 0).OnlyEnforceIf(self.is_black[pos]) + else: + # No possible dominoes for this cell, must be black + self.model.Add(self.is_black[pos] == 1) + + # Each domino has exactly one circle and one square + for key, domino_var in self.domino.items(): + pos1, pos2 = key + + # If domino is placed, one cell is circle and one is square + # pos1 is circle XOR pos2 is circle (given domino is active) + self.model.Add( + self.is_circle[pos1] + self.is_circle[pos2] == 1 + ).OnlyEnforceIf(domino_var) + + self.model.Add( + self.is_square[pos1] + self.is_square[pos2] == 1 + ).OnlyEnforceIf(domino_var) + + def _add_domino_orientation_constr(self): + """ + For vertical dominoes, the square must be the lower half. + Vertical domino: two cells with same column, different rows. + If pos1 is above pos2 (pos1.r < pos2.r), then pos2 must be square. + """ + for key, domino_var in self.domino.items(): + pos1, pos2 = key # pos1 < pos2 in our ordering + + # Check if this is a vertical domino + if pos1.c == pos2.c: + # Vertical domino: pos1 is upper, pos2 is lower (since pos1.r < pos2.r) + # Square must be in lower position (pos2) + self.model.Add(self.is_square[pos2] == 1).OnlyEnforceIf(domino_var) + self.model.Add(self.is_circle[pos1] == 1).OnlyEnforceIf(domino_var) + + def _add_number_clue_constr(self): + """ + A number indicates how many square cells are orthogonally adjacent to the number cell. + """ + free_set = set(self.free_cells) + + for pos, number in self.clue_cells.items(): + if number == 5: + continue + neighbors = self.grid.get_neighbors(pos, "orthogonal") + adjacent_squares = [] + + for nbr in neighbors: + if nbr in free_set: + adjacent_squares.append(self.is_square[nbr]) + + # Sum of adjacent squares must equal the clue number + self.model.Add(sum(adjacent_squares) == number) + + def _add_black_connectivity_constr(self): + """ + All black cells must form a single orthogonally contiguous area. + """ + # Build adjacency map for free cells only + adjacent_map: Dict[Position, List[Position]] = {} + free_set = set(self.free_cells) + + for pos in self.free_cells: + neighbors = self.grid.get_neighbors(pos, "orthogonal") + adjacent_map[pos] = [nbr for nbr in neighbors if nbr in free_set] + + # Use the connectivity constraint from the utility + add_connected_subgraph_constraint( + self.model, + self.is_black, + adjacent_map, + prefix='black' + ) + + def _add_no_2x2_black_constr(self): + """ + The black cells do not cover an area of 2x2 cells or larger. + """ + free_set = set(self.free_cells) + + for r in range(self.num_rows - 1): + for c in range(self.num_cols - 1): + # Check 2x2 area starting at (r, c) + positions = [ + Position(r, c), + Position(r, c + 1), + Position(r + 1, c), + Position(r + 1, c + 1) + ] + + # Collect black variables for free cells in this 2x2 + black_vars = [] + non_free_count = 0 + + for pos in positions: + if pos in free_set: + black_vars.append(self.is_black[pos]) + else: + # Clue cells are never black + non_free_count += 1 + + # If all 4 positions are free cells, at most 3 can be black + if len(black_vars) == 4: + self.model.Add(sum(black_vars) <= 3) + + def _add_domino_adjacent_black_constr(self): + """ + Each domino is orthogonally adjacent to at least one black cell. + """ + free_set = set(self.free_cells) + + for key, domino_var in self.domino.items(): + pos1, pos2 = key + + # Collect all neighbors of the domino (both cells) + neighbors1 = self.grid.get_neighbors(pos1, "orthogonal") + neighbors2 = self.grid.get_neighbors(pos2, "orthogonal") + + # Domino neighbors are neighbors of either cell, excluding the domino cells themselves + domino_neighbors = (neighbors1 | neighbors2) - {pos1, pos2} + + # Collect black variables for free cell neighbors + adjacent_black_vars = [] + for nbr in domino_neighbors: + if nbr in free_set: + adjacent_black_vars.append(self.is_black[nbr]) + + # If domino is placed, at least one adjacent cell must be black + if adjacent_black_vars: + self.model.Add(sum(adjacent_black_vars) >= 1).OnlyEnforceIf(domino_var) + else: + # No free neighbors means domino cannot satisfy this constraint + # This can only happen if all neighbors are clue cells + # In this case, the domino cannot be placed (handled implicitly) + self.model.Add(domino_var == 0) + + def get_solution(self): + sol_grid = [['-' 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): + pos = Position(r, c) + if pos in self.clue_cells: + sol_grid[r][c] = "-" + elif pos in self.is_black: + if self.solver.Value(self.is_black[pos]): + sol_grid[r][c] = 'x' + elif self.solver.Value(self.is_circle[pos]): + sol_grid[r][c] = 'o' + elif self.solver.Value(self.is_square[pos]): + sol_grid[r][c] = '#' + + return Grid(sol_grid) \ No newline at end of file diff --git a/src/puzzlekit/verifiers/__init__.py b/src/puzzlekit/verifiers/__init__.py index 77c4c361..30adf088 100644 --- a/src/puzzlekit/verifiers/__init__.py +++ b/src/puzzlekit/verifiers/__init__.py @@ -101,6 +101,9 @@ "nurimisaki": lambda a, b: verify_target_content(a, b, "x"), "aqre": lambda a, b: verify_target_content(a, b, "x"), "canal_view": verify_exact, + "nurikabe": lambda a, b: verify_target_content(a, b, 'x'), + "cojun": verify_exact, + "shugaku": verify_exact, } def grid_verifier(puzzle_type: str, a: Grid, b: Grid) -> bool: diff --git a/src/puzzlekit/viz/__init__.py b/src/puzzlekit/viz/__init__.py index 007e12da..9ce1be3c 100644 --- a/src/puzzlekit/viz/__init__.py +++ b/src/puzzlekit/viz/__init__.py @@ -105,6 +105,9 @@ "nurimisaki": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), "aqre": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), "canal_view": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), + "nurikabe": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), + "cojun": lambda g, d, p: draw_general_puzzle(g, d, p, style='text'), + "shugaku": lambda g, d, p: draw_general_puzzle(g, d, p, style='text'), # ... } From e11b2dc6e9b6a226406e39156a11ab12e4c6db4d Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Wed, 11 Feb 2026 17:38:47 +0800 Subject: [PATCH 2/4] update solver: nanro, pipes, shimaguni, shirokuro, tatamibari etc. --- src/puzzlekit/parsers/registry.py | 9 +- src/puzzlekit/solvers/__init__.py | 16 ++ src/puzzlekit/solvers/cojun.py | 2 +- src/puzzlekit/solvers/geradeweg.py | 311 ++++++++++++++++++++++++++++ src/puzzlekit/solvers/nanro.py | 191 +++++++++++++++++ src/puzzlekit/solvers/pipes.py | 200 ++++++++++++++++++ src/puzzlekit/solvers/shimaguni.py | 130 ++++++++++++ src/puzzlekit/solvers/shirokuro.py | 194 +++++++++++++++++ src/puzzlekit/solvers/tatamibari.py | 211 +++++++++++++++++++ src/puzzlekit/solvers/usoone.py | 167 +++++++++++++++ src/puzzlekit/verifiers/__init__.py | 7 + src/puzzlekit/viz/__init__.py | 7 + src/puzzlekit/viz/drawers.py | 2 +- 13 files changed, 1444 insertions(+), 3 deletions(-) create mode 100644 src/puzzlekit/solvers/geradeweg.py create mode 100644 src/puzzlekit/solvers/nanro.py create mode 100644 src/puzzlekit/solvers/pipes.py create mode 100644 src/puzzlekit/solvers/shimaguni.py create mode 100644 src/puzzlekit/solvers/shirokuro.py create mode 100644 src/puzzlekit/solvers/tatamibari.py create mode 100644 src/puzzlekit/solvers/usoone.py diff --git a/src/puzzlekit/parsers/registry.py b/src/puzzlekit/parsers/registry.py index d675b7b7..9bbf811e 100644 --- a/src/puzzlekit/parsers/registry.py +++ b/src/puzzlekit/parsers/registry.py @@ -120,7 +120,14 @@ "canal_view": standard_grid_parser, "nurikabe": standard_grid_parser, "cojun": standard_region_grid_parser, - "shugaku": standard_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, } diff --git a/src/puzzlekit/solvers/__init__.py b/src/puzzlekit/solvers/__init__.py index 6f6ecf12..1575ffdd 100644 --- a/src/puzzlekit/solvers/__init__.py +++ b/src/puzzlekit/solvers/__init__.py @@ -100,6 +100,13 @@ 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 # ========================================== @@ -208,6 +215,15 @@ "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"), + + } # ========================================== diff --git a/src/puzzlekit/solvers/cojun.py b/src/puzzlekit/solvers/cojun.py index 72eb8397..ab86b116 100644 --- a/src/puzzlekit/solvers/cojun.py +++ b/src/puzzlekit/solvers/cojun.py @@ -11,7 +11,7 @@ class CojunSolver(PuzzleSolver): metadata : Dict[str, Any] = { "name": "Cojun", - "aliases": [""], + "aliases": ["Kojun"], "difficulty": "", "tags": [], "rule_url": "https://pzplus.tck.mn/rules.html?cojun", diff --git a/src/puzzlekit/solvers/geradeweg.py b/src/puzzlekit/solvers/geradeweg.py new file mode 100644 index 00000000..2c4d23ef --- /dev/null +++ b/src/puzzlekit/solvers/geradeweg.py @@ -0,0 +1,311 @@ +from typing import Any, List, Dict, Tuple, Optional +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.ortools_utils import add_circuit_constraint_from_undirected +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + + +class GeradewegSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "Geradeweg", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?geradeweg", + "external_links": [ + {"Janko": "https://www.janko.at/Raetsel/Geradeweg/003.a.htm"}, + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 5 5\n2 - - - -\n- - 3 - -\n- - - - -\n- - - - -\n- - - - 4 + """, + "output_example": """ + 5 5\nse ew sw se sw\nns - ns ns ns\nne sw ns ns ns\nse nw ne nw ns\nne ew ew ew nw + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.validate_input() + + def validate_input(self): + def is_valid_clue(cell: str) -> bool: + if cell == '-' or cell == '?': + return True + return cell.isdigit() and int(cell) > 0 + + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + self._check_allowed_chars( + self.grid.matrix, + {'-', '?'}, + validator=is_valid_clue + ) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + self.arc_vars: Dict[Tuple[Position, Position], cp.IntVar] = {} + + # Step 1: Create edge variables for all possible orthogonal connections + self._add_edge_variables() + + # Step 2: Enforce single-loop constraint using circuit formulation + all_nodes = [Position(r, c) for r in range(self.num_rows) for c in range(self.num_cols)] + self.node_active = add_circuit_constraint_from_undirected(self.model, all_nodes, self.arc_vars) + + # Step 3: Add clue constraints (must be on loop + edge-count length requirements) + self._add_clue_constraints() + + def _add_edge_variables(self): + """Create boolean variables for all possible horizontal and vertical edges.""" + for r in range(self.num_rows): + for c in range(self.num_cols): + curr = Position(r, c) + + # Horizontal edge to the right + if c < self.num_cols - 1: + right = Position(r, c + 1) + self.arc_vars[(curr, right)] = self.model.NewBoolVar(f"edge_h_{r}_{c}") + + # Vertical edge downward + if r < self.num_rows - 1: + down = Position(r + 1, c) + self.arc_vars[(curr, down)] = self.model.NewBoolVar(f"edge_v_{r}_{c}") + + def _get_edge_var(self, p1: Position, p2: Position) -> Optional[cp.IntVar]: + """Helper to retrieve edge variable between two adjacent positions.""" + if (p1, p2) in self.arc_vars: + return self.arc_vars[(p1, p2)] + if (p2, p1) in self.arc_vars: + return self.arc_vars[(p2, p1)] + return None + + def _edge_exists(self, p1: Position, p2: Position) -> cp.IntVar: + """Return edge variable if exists, otherwise constant 0.""" + edge_var = self._get_edge_var(p1, p2) + return edge_var if edge_var is not None else self.model.NewConstant(0) + + def _create_arm_length_var(self, r: int, c: int, dr: int, dc: int) -> cp.IntVar: + """ + Compute the length (in EDGES) of a straight segment extending from (r,c) in direction (dr,dc). + + Critical rules for straight segments: + - Length = number of consecutive edges in the direction + - For arm length >= 2: intermediate cells MUST be straight (exactly two opposite edges) + * East/West direction: cell must have west+east edges, NO north/south edges + * North/South direction: cell must have north+south edges, NO west/east edges + + Example: From (0,0) going east with arm length 2 requires: + 1. Edge (0,0)-(0,1) exists + 2. Cell (0,1) has west+east edges AND no north/south edges + 3. Edge (0,1)-(0,2) exists + """ + steps: List[cp.IntVar] = [] + curr_r, curr_c = r, c + prev_step_active = None # For k>=2, requires previous step continued + + # Maximum possible steps in this direction + max_steps = self.num_rows if dr != 0 else self.num_cols + + for step in range(1, max_steps + 1): + next_r, next_c = curr_r + dr, curr_c + dc + + # Check grid boundaries + if not (0 <= next_r < self.num_rows and 0 <= next_c < self.num_cols): + break + + # Get edge between current and next cell + edge_var = self._edge_exists(Position(curr_r, curr_c), Position(next_r, next_c)) + + if step == 1: + # First edge: arm length >=1 iff edge exists + step_active = edge_var + else: + # For step >=2: requires + # (a) previous step continued + # (b) current cell is STRAIGHT in this direction (no turns) + # (c) current edge exists + + # Determine straightness condition for intermediate cell (curr_r, curr_c) + pos = Position(curr_r, curr_c) + if dr == 0: # East/West direction + # Must have west+east edges, NO north/south edges + has_w = self._edge_exists(pos, pos.left) + has_e = self._edge_exists(pos, pos.right) + has_n = self._edge_exists(pos, pos.up) + has_s = self._edge_exists(pos, pos.down) + + straight_cond = self.model.NewBoolVar(f"straight_{r}_{c}_{dr}_{dc}_{step}") + # Straight: has_w AND has_e AND NOT has_n AND NOT has_s + self.model.AddBoolAnd([has_w, has_e, has_n.Not(), has_s.Not()]).OnlyEnforceIf(straight_cond) + self.model.AddBoolOr([has_w.Not(), has_e.Not(), has_n, has_s]).OnlyEnforceIf(straight_cond.Not()) + else: # North/South direction + # Must have north+south edges, NO west/east edges + has_n = self._edge_exists(pos, pos.up) + has_s = self._edge_exists(pos, pos.down) + has_w = self._edge_exists(pos, pos.left) + has_e = self._edge_exists(pos, pos.right) + + straight_cond = self.model.NewBoolVar(f"straight_{r}_{c}_{dr}_{dc}_{step}") + # Straight: has_n AND has_s AND NOT has_w AND NOT has_e + self.model.AddBoolAnd([has_n, has_s, has_w.Not(), has_e.Not()]).OnlyEnforceIf(straight_cond) + self.model.AddBoolOr([has_n.Not(), has_s.Not(), has_w, has_e]).OnlyEnforceIf(straight_cond.Not()) + + # Step continues iff: previous continued AND straight AND edge exists + step_active = self.model.NewBoolVar(f"step_{r}_{c}_{dr}_{dc}_{step}") + self.model.AddBoolAnd([prev_step_active, straight_cond, edge_var]).OnlyEnforceIf(step_active) + self.model.AddBoolOr([ + prev_step_active.Not(), + straight_cond.Not(), + edge_var.Not() + ]).OnlyEnforceIf(step_active.Not()) + + steps.append(step_active) + prev_step_active = step_active + curr_r, curr_c = next_r, next_c + + # Arm length = sum of active steps (each step = 1 edge) + if not steps: + return self.model.NewConstant(0) + else: + arm_len = self.model.NewIntVar(0, len(steps), f"arm_len_{r}_{c}_{dr}_{dc}") + self.model.Add(arm_len == sum(steps)) + return arm_len + + def _add_clue_constraints(self): + """Enforce constraints for all clue cells (numbers and question marks).""" + for r in range(self.num_rows): + for c in range(self.num_cols): + cell_val = self.grid.value(r, c) + if cell_val == '-': + continue # Skip empty cells + + pos = Position(r, c) + + # Constraint 1: All clue cells must be on the loop + self.model.Add(self.node_active[pos] == 1) + + # Skip length constraints for question marks + # if cell_val == '?': + # continue + + # clue_value = int(cell_val) if cell_val.isdigit() else -1 + clue_value = int(cell_val) if cell_val != "?" else -1 + + # Compute arm lengths in all 4 directions (in EDGES) + len_n = self._create_arm_length_var(r, c, -1, 0) # North + len_s = self._create_arm_length_var(r, c, 1, 0) # South + len_w = self._create_arm_length_var(r, c, 0, -1) # West + len_e = self._create_arm_length_var(r, c, 0, 1) # East + + # Determine which edges exist at this cell + has_n = self._edge_exists(pos, pos.up) + has_s = self._edge_exists(pos, pos.down) + has_w = self._edge_exists(pos, pos.left) + has_e = self._edge_exists(pos, pos.right) + + # Case analysis based on edge configuration (exactly 2 edges per clue cell) + # Case 1: Vertical straight (north-south) + is_vert_straight = self.model.NewBoolVar(f"vert_str_{r}_{c}") + self.model.AddBoolAnd([has_n, has_s, has_w.Not(), has_e.Not()]).OnlyEnforceIf(is_vert_straight) + self.model.AddBoolOr([has_n.Not(), has_s.Not(), has_w, has_e]).OnlyEnforceIf(is_vert_straight.Not()) + total_vert = self.model.NewIntVar(0, 2 * (self.num_rows + self.num_cols), f"total_v_{r}_{c}") + self.model.Add(total_vert == len_n + len_s) + if clue_value != -1: + self.model.Add(total_vert == clue_value).OnlyEnforceIf(is_vert_straight) + + # Case 2: Horizontal straight (west-east) + is_horiz_straight = self.model.NewBoolVar(f"horiz_str_{r}_{c}") + self.model.AddBoolAnd([has_w, has_e, has_n.Not(), has_s.Not()]).OnlyEnforceIf(is_horiz_straight) + self.model.AddBoolOr([has_w.Not(), has_e.Not(), has_n, has_s]).OnlyEnforceIf(is_horiz_straight.Not()) + total_horiz = self.model.NewIntVar(0, 2 * (self.num_rows + self.num_cols), f"total_h_{r}_{c}") + self.model.Add(total_horiz == len_w + len_e) + if clue_value != -1: + self.model.Add(total_horiz == clue_value).OnlyEnforceIf(is_horiz_straight) + + # Case 3: Turning configurations (4 possibilities) + # NE turn: north + east + is_ne_turn = self.model.NewBoolVar(f"turn_ne_{r}_{c}") + self.model.AddBoolAnd([has_n, has_e, has_s.Not(), has_w.Not()]).OnlyEnforceIf(is_ne_turn) + self.model.AddBoolOr([has_n.Not(), has_e.Not(), has_s, has_w]).OnlyEnforceIf(is_ne_turn.Not()) + if clue_value != -1: + self.model.Add(len_n == clue_value).OnlyEnforceIf(is_ne_turn) + self.model.Add(len_e == clue_value).OnlyEnforceIf(is_ne_turn) + else: + self.model.Add(len_n == len_w).OnlyEnforceIf(is_ne_turn) + # NW turn: north + west + is_nw_turn = self.model.NewBoolVar(f"turn_nw_{r}_{c}") + self.model.AddBoolAnd([has_n, has_w, has_s.Not(), has_e.Not()]).OnlyEnforceIf(is_nw_turn) + self.model.AddBoolOr([has_n.Not(), has_w.Not(), has_s, has_e]).OnlyEnforceIf(is_nw_turn.Not()) + if clue_value != -1: + self.model.Add(len_n == clue_value).OnlyEnforceIf(is_nw_turn) + self.model.Add(len_w == clue_value).OnlyEnforceIf(is_nw_turn) + else: + self.model.Add(len_w == len_n).OnlyEnforceIf(is_nw_turn) + + # SE turn: south + east + is_se_turn = self.model.NewBoolVar(f"turn_se_{r}_{c}") + self.model.AddBoolAnd([has_s, has_e, has_n.Not(), has_w.Not()]).OnlyEnforceIf(is_se_turn) + self.model.AddBoolOr([has_s.Not(), has_e.Not(), has_n, has_w]).OnlyEnforceIf(is_se_turn.Not()) + if clue_value != -1: + self.model.Add(len_s == clue_value).OnlyEnforceIf(is_se_turn) + self.model.Add(len_e == clue_value).OnlyEnforceIf(is_se_turn) + else: + self.model.Add(len_e == len_s).OnlyEnforceIf(is_se_turn) + + # SW turn: south + west + is_sw_turn = self.model.NewBoolVar(f"turn_sw_{r}_{c}") + self.model.AddBoolAnd([has_s, has_w, has_n.Not(), has_e.Not()]).OnlyEnforceIf(is_sw_turn) + self.model.AddBoolOr([has_s.Not(), has_w.Not(), has_n, has_e]).OnlyEnforceIf(is_sw_turn.Not()) + if clue_value != -1: + self.model.Add(len_s == clue_value).OnlyEnforceIf(is_sw_turn) + self.model.Add(len_w == clue_value).OnlyEnforceIf(is_sw_turn) + else: + self.model.Add(len_w == len_s).OnlyEnforceIf(is_sw_turn) + + # Ensure exactly one configuration is active (2 edges total) + self.model.Add( + is_vert_straight + is_horiz_straight + + is_ne_turn + is_nw_turn + is_se_turn + is_sw_turn == 1 + ) + + def get_solution(self) -> Grid: + """ + Extract solution from solver and format as directional strings (n/s/e/w). + Format: space-separated directions sorted alphabetically (e.g., "ne" for north+east). + Non-loop cells remain as '-'. + """ + output_matrix = [['-' 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): + pos = Position(r, c) + + # Skip cells not on the loop + if self.solver.Value(self.node_active[pos]) == 0: + continue + + # Collect active directions + directions = [] + for (dir_char, neighbor) in [ + ('n', pos.up), + ('s', pos.down), + ('w', pos.left), + ('e', pos.right) + ]: + if neighbor is not None and 0 <= neighbor.r < self.num_rows and 0 <= neighbor.c < self.num_cols: + edge_var = self._get_edge_var(pos, neighbor) + if edge_var is not None and self.solver.Value(edge_var) == 1: + directions.append(dir_char) + + # Format directions alphabetically (e.g., "ne" not "en") + if directions: + output_matrix[r][c] = ''.join(sorted(directions)) + + return Grid(output_matrix) \ No newline at end of file diff --git a/src/puzzlekit/solvers/nanro.py b/src/puzzlekit/solvers/nanro.py new file mode 100644 index 00000000..b229e668 --- /dev/null +++ b/src/puzzlekit/solvers/nanro.py @@ -0,0 +1,191 @@ +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 puzzlekit.utils.ortools_utils import add_connected_subgraph_constraint +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + +class NanroSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "Nanro", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?nanro", + "external_links": [ + + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 8 8\n4 - 5 - - - - 2\n4 4 - - - 3 - -\n- - - - - - - 4\n- - 2 - 2 - 4 -\n- - - 4 - - 3 -\n4 - - - - 2 - -\n- - 3 - - - - -\n- 3 - 4 - - 2 3\n1 1 6 6 6 9 9 14\n1 1 4 6 9 9 14 14\n2 1 4 6 10 10 10 10\n2 4 4 7 7 7 10 15\n2 2 5 8 11 11 11 15\n2 5 5 8 11 13 13 15\n3 3 5 8 8 13 12 15\n3 3 3 8 12 12 12 15 + """, + "output_example": """ + 8 8\n4 - 5 5 5 3 3 2\n4 4 - 5 - 3 - 2\n- 4 2 5 4 - 4 4\n4 - 2 - 2 2 4 -\n4 4 3 4 3 - 3 -\n4 - 3 - 3 2 2 3\n3 - 3 4 4 - - 3\n3 3 - 4 - 2 2 3 + """ + } + + @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 + self.grid: Grid[str] = Grid(grid) + 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 (positive integers) + 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() + + # 1. Decision Variables + self.is_filled = {} # BoolVar: True if cell contains a number + self.region_value = {} # IntVar: the number filled in each region (also equals count of filled cells) + + # Determine max possible value per region (region size) + region_sizes = { + rid: len(cells) + for rid, cells in self.region_grid.regions.items() + } + + # Create region value variables (1 to region_size) + for rid, size in region_sizes.items(): + self.region_value[rid] = self.model.NewIntVar(1, size, f"region_val_{rid}") + + # Create cell fill variables + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + self.is_filled[pos] = self.model.NewBoolVar(f"filled_{pos}") + + # 2. Region Clue Constraints (from grid input) + region_clues = {} # region_id -> required value + for i in range(self.num_rows): + for j in range(self.num_cols): + val = self.grid.value(i, j) + if val.isdigit(): + d = int(val) + rid = self.region_grid.value(i, j) + pos = Position(i, j) + + # This cell must be filled + self.model.Add(self.is_filled[pos] == 1) + + # Region must have this value (all clues in same region must agree) + if rid in region_clues: + if region_clues[rid] != d: + raise ValueError( + f"Inconsistent clues in region {rid}: found both {region_clues[rid]} and {d}" + ) + else: + region_clues[rid] = d + self.model.Add(self.region_value[rid] == d) + + # 3. Region Count Constraints + # For each region: sum(is_filled in region) == region_value + for rid, cells in self.region_grid.regions.items(): + filled_vars = [self.is_filled[pos] for pos in cells] + self.model.Add(sum(filled_vars) == self.region_value[rid]) + + # 4. Cross-Region Adjacency Constraint + # Same numbers must not be orthogonally adjacent across region boundaries + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + rid1 = self.region_grid.value(i, j) + + # Check right neighbor + if j + 1 < self.num_cols: + nbr = Position(i, j + 1) + rid2 = self.region_grid.value(i, j + 1) + if rid1 != rid2: # Different regions + # If both filled, their region values must differ + # Use indicator variable to enforce: is_filled[pos] + is_filled[nbr] <= 1 OR values differ + indicator = self.model.NewBoolVar(f"diff_{pos}_{nbr}") + + # Case 1: region_value[rid1] > region_value[rid2] + self.model.Add( + self.region_value[rid1] - self.region_value[rid2] >= 1 + ).OnlyEnforceIf([self.is_filled[pos], self.is_filled[nbr], indicator]) + + # Case 2: region_value[rid2] > region_value[rid1] + self.model.Add( + self.region_value[rid2] - self.region_value[rid1] >= 1 + ).OnlyEnforceIf([self.is_filled[pos], self.is_filled[nbr], indicator.Not()]) + + # Check down neighbor + if i + 1 < self.num_rows: + nbr = Position(i + 1, j) + rid2 = self.region_grid.value(i + 1, j) + if rid1 != rid2: # Different regions + indicator = self.model.NewBoolVar(f"diff_{pos}_{nbr}") + + self.model.Add( + self.region_value[rid1] - self.region_value[rid2] >= 1 + ).OnlyEnforceIf([self.is_filled[pos], self.is_filled[nbr], indicator]) + + self.model.Add( + self.region_value[rid2] - self.region_value[rid1] >= 1 + ).OnlyEnforceIf([self.is_filled[pos], self.is_filled[nbr], indicator.Not()]) + + # 5. 2x2 Area Constraint + # Numbered cells must not cover a 2x2 area + for i in range(self.num_rows - 1): + for j in range(self.num_cols - 1): + positions = [ + Position(i, j), + Position(i, j + 1), + Position(i + 1, j), + Position(i + 1, j + 1) + ] + self.model.Add(sum(self.is_filled[p] for p in positions) <= 3) + + # 6. Connectivity Constraint + # All filled cells must form a single orthogonally contiguous area + adjacency_map = {} + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + neighbors = self.grid.get_neighbors(pos, "orthogonal") + adjacency_map[pos] = list(neighbors) + + # Note: Connectivity constraint assumes at least one filled cell. + # Nanro rules guarantee this (each region has at least 1 filled cell). + add_connected_subgraph_constraint( + self.model, + self.is_filled, + adjacency_map, + prefix="nanro_conn" + ) + + def get_solution(self): + sol_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + + # For each region, determine its value from the solution + region_solution_values = {} + for rid in self.region_grid.regions: + if rid in self.region_value: + region_solution_values[rid] = self.solver.Value(self.region_value[rid]) + + # Fill cells that are marked as filled + for i in range(self.num_rows): + for j in range(self.num_cols): + pos = Position(i, j) + if self.solver.Value(self.is_filled[pos]) == 1: + rid = self.region_grid.value(i, j) + sol_grid[i][j] = str(region_solution_values[rid]) + # else remains '-' + + return Grid(sol_grid) \ No newline at end of file diff --git a/src/puzzlekit/solvers/pipes.py b/src/puzzlekit/solvers/pipes.py new file mode 100644 index 00000000..d46d7458 --- /dev/null +++ b/src/puzzlekit/solvers/pipes.py @@ -0,0 +1,200 @@ +from typing import Any, List, Dict, Tuple, Optional, Set +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from ortools.sat.python import cp_model as cp +from typeguard import typechecked +from puzzlekit.utils.ortools_utils import add_connected_subgraph_by_height + +class PipesSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "Pipes", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://www.puzzle-pipes.com/", + "external_links": [ + {"Play at pipes": "https://www.puzzle-pipes.com/"} + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 10 10\n3 3 1 1 3 3 1 3 5 1\n1 7 7 7 7 1 7 3 3 1\n1 3 1 1 7 1 5 1 7 1\n3 3 5 3 7 7 5 1 5 5\n1 7 7 1 1 7 3 5 3 7\n1 3 3 3 5 7 5 3 3 3\n1 7 3 7 7 7 7 7 7 3\n1 1 7 7 1 5 7 1 1 7\n7 7 3 1 1 7 7 7 3 1\n1 1 1 5 5 7 1 1 3 1 + """, + "output_example": """ + 10 10\nse sw s s se sw s se we w\nn nse nwe nwe nsw n nse nw se w\ne nw s e nsw s ns e nsw s\nse sw ns se nwe nsw ns s ns ns\nn nse nsw n s nse nw ns ne nsw\ne nw ne sw ns nse we nw se nw\ne swe sw nse nwe nwe swe swe nwe sw\ns n nse nsw e we nsw n e nsw\nnse swe nw n e swe nwe swe sw n\nn n e we we nwe w n ne w + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.validate_input() + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + allowed_vals = {'1', '3', '5', '7', '15'} + self._check_allowed_chars( + self.grid.matrix, + allowed=set(), + ignore=allowed_vals, + validator=lambda x: x.isdigit() and x in allowed_vals + ) + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + + # 1. Define direction variables for each cell + # n[i,j], s[i,j], w[i,j], e[i,j] ∈ {0,1} + self.n = {} + self.s = {} + self.w = {} + self.e = {} + for i in range(self.num_rows): + for j in range(self.num_cols): + self.n[i, j] = self.model.NewBoolVar(f"n_{i}_{j}") + self.s[i, j] = self.model.NewBoolVar(f"s_{i}_{j}") + self.w[i, j] = self.model.NewBoolVar(f"w_{i}_{j}") + self.e[i, j] = self.model.NewBoolVar(f"e_{i}_{j}") + + # 2. Precompute degree per type + deg_map = {1: 1, 3: 2, 5: 2, 7: 3, 15: 4} + + # 3. For each cell, enforce type-specific allowed patterns + for i in range(self.num_rows): + for j in range(self.num_cols): + v = int(self.grid.value(i, j)) + deg = deg_map[v] + + # Constraint: total degree = deg + self.model.Add(self.n[i, j] + self.s[i, j] + self.w[i, j] + self.e[i, j] == deg) + + # Enumerate all valid direction sets for this type + valid_dirs = [] + + if v == 1: + valid_dirs = [ + (1, 0, 0, 0), # N + (0, 1, 0, 0), # S + (0, 0, 1, 0), # W + (0, 0, 0, 1), # E + ] + elif v == 3: # L: two orthogonal + valid_dirs = [ + (1, 0, 1, 0), # NW + (1, 0, 0, 1), # NE + (0, 1, 1, 0), # SW + (0, 1, 0, 1), # SE + ] + elif v == 5: # Straight: opposite + valid_dirs = [ + (1, 1, 0, 0), # NS + (0, 0, 1, 1), # WE + ] + elif v == 7: # T: three directions + valid_dirs = [ + (1, 1, 1, 0), # NSW + (1, 1, 0, 1), # NSE + (1, 0, 1, 1), # NWE + (0, 1, 1, 1), # SWE + ] + elif v == 15: # Cross: all four + valid_dirs = [(1, 1, 1, 1)] + + # Add one-hot constraint over valid patterns + # We create a selector var for each pattern, then link to directions + pattern_vars = [] + for idx, (nn, ss, ww, ee) in enumerate(valid_dirs): + p = self.model.NewBoolVar(f"pat_{i}_{j}_{idx}") + pattern_vars.append(p) + # Link: if p=1, then directions must match + self.model.Add(self.n[i, j] == nn).OnlyEnforceIf(p) + self.model.Add(self.s[i, j] == ss).OnlyEnforceIf(p) + self.model.Add(self.w[i, j] == ww).OnlyEnforceIf(p) + self.model.Add(self.e[i, j] == ee).OnlyEnforceIf(p) + + # Exactly one pattern chosen + self.model.Add(sum(pattern_vars) == 1) + + # 4. Symmetry constraints: edges must match between adjacent cells + # Horizontal: e[i,j] == w[i,j+1] + for i in range(self.num_rows): + for j in range(self.num_cols - 1): + self.model.Add(self.e[i, j] == self.w[i, j + 1]) + + # Vertical: s[i,j] == n[i+1,j] + for i in range(self.num_rows - 1): + for j in range(self.num_cols): + self.model.Add(self.s[i, j] == self.n[i + 1, j]) + + # 5. Edge count = cells - 1 (to ensure tree, no cycles) + total_edges = 0 + # Horizontal edges + for i in range(self.num_rows): + for j in range(self.num_cols - 1): + total_edges += self.e[i, j] # or w[i,j+1], same + # Vertical edges + for i in range(self.num_rows - 1): + for j in range(self.num_cols): + total_edges += self.s[i, j] # or n[i+1,j], same + self.model.Add(total_edges == self.num_rows * self.num_cols - 1) + + # 6. Connectivity: use add_connected_subgraph_by_height on all cells + # We treat every cell as active (must be part of the graph) + active_nodes = {} + for i in range(self.num_rows): + for j in range(self.num_cols): + # Use a dummy BoolVar = 1 (always active) + act = self.model.NewBoolVar(f"act_{i}_{j}") + self.model.Add(act == 1) + active_nodes[(i, j)] = act + + # Build adjacency map: two cells are adjacent if they share an edge AND that edge is used + # But we cannot use edge variables directly in adjacency_map (it's static). + # Instead, we build adjacency based on *possible* neighbors, and rely on symmetry + edge count to enforce connectivity. + # However, `add_connected_subgraph_by_height` expects a fixed adjacency map — so we provide full 4-neighbor grid. + adjacency_map: Dict[Tuple[int, int], List[Tuple[int, int]]] = {} + for i in range(self.num_rows): + for j in range(self.num_cols): + neighbors = [] + for di, dj in [(0, 1), (0, -1), (1, 0), (-1, 0)]: + ni, nj = i + di, j + dj + if 0 <= ni < self.num_rows and 0 <= nj < self.num_cols: + neighbors.append((ni, nj)) + adjacency_map[(i, j)] = neighbors + + # Add connectivity constraint + # _, _ = add_connected_subgraph_constraint( + # self.model, active_nodes, adjacency_map, prefix="pipes_conn" + # ) + _, _ = add_connected_subgraph_by_height( + self.model, active_nodes, adjacency_map, prefix="pipes_conn" + ) + + # Optional: improve performance by setting search strategy + # We prioritize direction vars (but OR-Tools auto-heuristics are good) + # self.solver.SearchForAllSolutions(self.model, cp.SatParameters(max_time_in_seconds=30)) + + def get_solution(self) -> Grid: + sol_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + + for i in range(self.num_rows): + for j in range(self.num_cols): + n_val = self.solver.Value(self.n[i, j]) + s_val = self.solver.Value(self.s[i, j]) + w_val = self.solver.Value(self.w[i, j]) + e_val = self.solver.Value(self.e[i, j]) + + dirs = [] + if n_val: dirs.append('n') + if s_val: dirs.append('s') + if w_val: dirs.append('w') + if e_val: dirs.append('e') + # Sort lexicographically: n < s < w < e + dirs.sort(key=lambda x: {'n':0, 's':1, 'w':2, 'e':3}[x]) + sol_grid[i][j] = ''.join(dirs) if dirs else '-' + + return Grid(sol_grid) \ No newline at end of file diff --git a/src/puzzlekit/solvers/shimaguni.py b/src/puzzlekit/solvers/shimaguni.py new file mode 100644 index 00000000..3dad6955 --- /dev/null +++ b/src/puzzlekit/solvers/shimaguni.py @@ -0,0 +1,130 @@ +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 puzzlekit.utils.ortools_utils import add_connected_subgraph_by_height, add_connected_subgraph_constraint +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + +class ShimaguniSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "Shimaguni", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?shimaguni", + "external_links": [ + + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 6 6\n- 2 3 - - -\n- - - - - -\n- - - 4 - -\n- - - - - -\n- 1 - - - -\n- - - - - -\n1 2 4 4 4 4\n1 2 1 1 1 1\n1 2 1 5 5 1\n1 2 1 3 5 1\n1 3 3 3 5 1\n1 1 1 1 1 1 + """, + "output_example": """ + 6 6\n- x - x x x\n- x - - - -\nx - - x x -\nx - - - x -\nx - x - x -\nx x - - - - + """ + } + + @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 + self.grid: Grid[str] = Grid(grid) + 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 (positive integers) + 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.black = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + self.black[r, c] = self.model.NewBoolVar(f"black_{r}_{c}") + + region_cells = defaultdict(list) + pos_to_region = {} + region_clues = {} + + for r in range(self.num_rows): + for c in range(self.num_cols): + region_id = self.region_grid.value(r, c) + pos = Position(r, c) + region_cells[region_id].append(pos) + pos_to_region[pos] = region_id + + clue_val = self.grid.value(r, c) + if clue_val != '-' and region_id not in region_clues: + region_clues[region_id] = int(clue_val) + + region_black_count = {} + + for region_id, cells in region_cells.items(): + # print(region_id, cells) + count_var = self.model.NewIntVar(0, len(cells) + 2, f"count_{region_id}") + region_black_count[region_id] = count_var + if region_id in region_clues: + self.model.Add(count_var == region_clues[region_id]) + else: + self.model.Add(count_var >= 1) + black_vars_in_region = [self.black[pos.r, pos.c] for pos in cells] + self.model.Add(count_var == sum(black_vars_in_region)) + adjacency_map = {} + for pos in cells: + neighbors = [] + for nbr in self.grid.get_neighbors(pos, "orthogonal"): + if nbr in cells: + neighbors.append(nbr) + adjacency_map[pos] = neighbors + + active_nodes = {pos: self.black[pos.r, pos.c] for pos in cells} + + if len(cells) > 1: + add_connected_subgraph_by_height( + self.model, + active_nodes, + adjacency_map, + prefix=f"conn_{region_id}" + ) + + # (region1, region2) -> [(pos_in_r1, pos_in_r2), ...] + adjacent_region_pairs = defaultdict(list) + + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + region1 = pos_to_region[pos] + for nbr in self.grid.get_neighbors(pos, "orthogonal"): + region2 = pos_to_region[nbr] + if region1 != region2: + pair = tuple(sorted([str(region1), str(region2)])) + adjacent_region_pairs[pair].append((pos, nbr)) + + for (region1, region2), boundary_pairs in adjacent_region_pairs.items(): + self.model.Add(region_black_count[region1] != region_black_count[region2]) + + for pos1, pos2 in boundary_pairs: + self.model.AddBoolOr([self.black[pos1.r, pos1.c].Not(),self.black[pos2.r, pos2.c].Not()]) + + def get_solution(self): + sol_grid = [['-' 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): + if self.solver.Value(self.black[r, c]) == 1: + sol_grid[r][c] = 'x' + + return Grid(sol_grid) \ No newline at end of file diff --git a/src/puzzlekit/solvers/shirokuro.py b/src/puzzlekit/solvers/shirokuro.py new file mode 100644 index 00000000..a8521648 --- /dev/null +++ b/src/puzzlekit/solvers/shirokuro.py @@ -0,0 +1,194 @@ +from typing import Any, List, Dict, Tuple, Optional +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + +class ShirokuroSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "Shirokuro", + "aliases": ["Shirokuro-link", "white-black-link"], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?wblink", + "external_links": [ + {"Janko": "https://www.janko.at/Raetsel/Shirokuro/003.a.htm"}, + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 10 10\nb - w b w - b - b -\nb - w w - w b w - -\nw b w w - b b b - b\nw - b - - - w - w -\n- w b w w b b b - w\nb b - b - - - w w b\nb - b b - b w b - w\n- - - w - - - - b w\nw w w b w - w - - b\nw - - - b w - w - b + """, + "output_example": """ + 10 10\ne ew w s e ew w - s -\ne ew w n - e w s ns -\ne w s e ew w s n ns s\ns - n - - - n - n ns\nns e w s e w s s - n\nn s - n - - ns n e w\ns ns s s - s n e ew w\nns ns ns n - ns - - e w\nn n n e w ns e ew ew w\ne ew ew ew w n - e ew w + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.validate_input() + # Will be populated during constraint building + self.connection_vars: Dict[Tuple[Position, Position], cp.IntVar] = {} + self.connection_paths: Dict[Tuple[Position, Position], List[Position]] = {} + + def validate_input(self): + """Validate input grid contains only allowed characters: 'b', 'w', '-'""" + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + self._check_allowed_chars( + self.grid.matrix, + allowed={'-', 'b', 'w'}, + ignore=set(), + validator=None + ) + + def _add_constr(self): + """ + Build CP-SAT model for Shirokuro puzzle. + Key constraints: + 1. Each black circle connects to exactly one white circle via straight line + 2. Each white circle connects to exactly one black circle + 3. Lines must not pass through other circles (only empty cells allowed between endpoints) + 4. Lines must not share any cell (including endpoints and intermediate cells) + """ + # Initialize CP-SAT model and solver + self.model = cp.CpModel() + self.solver = cp.CpSolver() + + # Collect positions of black and white circles + black_positions: List[Position] = [] + white_positions: List[Position] = [] + for r in range(self.num_rows): + for c in range(self.num_cols): + cell = self.grid[r][c] + if cell == 'b': + black_positions.append(Position(r, c)) + elif cell == 'w': + white_positions.append(Position(r, c)) + + # Helper function to check if a straight-line connection is valid (no obstacles) + def is_valid_connection(b: Position, w: Position) -> bool: + if b.r == w.r: # Same row - horizontal connection + step = 1 if w.c > b.c else -1 + for c in range(b.c + step, w.c, step): + if self.grid[b.r][c] in ('b', 'w'): + return False + return True + elif b.c == w.c: # Same column - vertical connection + step = 1 if w.r > b.r else -1 + for r in range(b.r + step, w.r, step): + if self.grid[r][b.c] in ('b', 'w'): + return False + return True + return False # Not aligned - invalid connection + + # Build possible connections between black and white circles + possible_connections: List[Tuple[Position, Position]] = [] + possible_whites_for_black: Dict[Position, List[Position]] = {b: [] for b in black_positions} + possible_blacks_for_white: Dict[Position, List[Position]] = {w: [] for w in white_positions} + + for b in black_positions: + for w in white_positions: + if is_valid_connection(b, w): + possible_connections.append((b, w)) + possible_whites_for_black[b].append(w) + possible_blacks_for_white[w].append(b) + + # Create boolean variables for each possible connection + for b, w in possible_connections: + var = self.model.NewBoolVar(f"conn_{b.r}_{b.c}_{w.r}_{w.c}") + self.connection_vars[(b, w)] = var + + # Precompute path (all cells including endpoints) for this connection + if b.r == w.r: # Horizontal + step = 1 if w.c > b.c else -1 + path = [Position(b.r, c) for c in range(b.c, w.c + step, step)] + else: # Vertical (b.c == w.c) + step = 1 if w.r > b.r else -1 + path = [Position(r, b.c) for r in range(b.r, w.r + step, step)] + self.connection_paths[(b, w)] = path + + # Constraint 1: Each black circle must have exactly one connection + for b in black_positions: + if possible_whites_for_black[b]: + self.model.Add( + sum(self.connection_vars[(b, w)] for w in possible_whites_for_black[b]) == 1 + ) + else: + # No valid connections for this black circle -> infeasible + self.model.Add(0 == 1) + + # Constraint 2: Each white circle must have exactly one connection + for w in white_positions: + if possible_blacks_for_white[w]: + self.model.Add( + sum(self.connection_vars[(b, w)] for b in possible_blacks_for_white[w]) == 1 + ) + else: + # No valid connections for this white circle -> infeasible + self.model.Add(0 == 1) + + # Constraint 3: No cell may be used by more than one connection + # Build mapping from each cell to connections that pass through it + cell_to_connections: Dict[Position, List[Tuple[Position, Position]]] = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + cell_to_connections[Position(r, c)] = [] + + for (b, w), var in self.connection_vars.items(): + path = self.connection_paths[(b, w)] + for pos in path: + cell_to_connections[pos].append((b, w)) + + # Add constraint: at most one connection per cell + for pos, connections in cell_to_connections.items(): + if connections: + self.model.Add( + sum(self.connection_vars[conn] for conn in connections) <= 1 + ) + + def get_solution(self) -> Grid: + """ + Construct output grid from solved connections. + Output format: + - Empty cells (no line): '-' + - Endpoints (b/w circles): single direction letter ('n','s','e','w') + - Intermediate cells: two direction letters in lexicographical order ('ew' for horizontal, 'ns' for vertical) + """ + output_matrix = [['-' for _ in range(self.num_cols)] for _ in range(self.num_rows)] + + # Process each active connection + for (b, w), var in self.connection_vars.items(): + if self.solver.Value(var) == 1: + path = self.connection_paths[(b, w)] + + if b.r == w.r: # Horizontal connection + if w.c > b.c: # Black on left, white on right + output_matrix[b.r][b.c] = 'e' # Black points right + output_matrix[w.r][w.c] = 'w' # White points left + else: # Black on right, white on left + output_matrix[b.r][b.c] = 'w' # Black points left + output_matrix[w.r][w.c] = 'e' # White points right + + # Mark intermediate cells with 'ew' (lexicographical order: e < w) + for pos in path: + if pos != b and pos != w: + output_matrix[pos.r][pos.c] = 'ew' + + else: # Vertical connection (b.c == w.c) + if w.r > b.r: # Black on top, white on bottom + output_matrix[b.r][b.c] = 's' # Black points down + output_matrix[w.r][w.c] = 'n' # White points up + else: # Black on bottom, white on top + output_matrix[b.r][b.c] = 'n' # Black points up + output_matrix[w.r][w.c] = 's' # White points down + + # Mark intermediate cells with 'ns' (lexicographical order: n < s) + for pos in path: + if pos != b and pos != w: + output_matrix[pos.r][pos.c] = 'ns' + + return Grid(output_matrix) \ No newline at end of file diff --git a/src/puzzlekit/solvers/tatamibari.py b/src/puzzlekit/solvers/tatamibari.py new file mode 100644 index 00000000..eeb3fd65 --- /dev/null +++ b/src/puzzlekit/solvers/tatamibari.py @@ -0,0 +1,211 @@ +from typing import Any, List, Dict, Tuple, Optional +from puzzlekit.core.solver import PuzzleSolver +from puzzlekit.core.grid import Grid +from puzzlekit.core.position import Position +from puzzlekit.utils.puzzle_math import get_factor_pairs +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + + +class TatamibariSolver(PuzzleSolver): + metadata : Dict[str, Any] = { + "name": "tatamibari", + "aliases": [""], + "difficulty": "", + "tags": [], + "rule_url": "https://pzplus.tck.mn/rules.html?tatamibari", + "external_links": [ + {"Janko": "https://www.janko.at/Raetsel/tatamibari/003.a.htm"}, + ], + "input_desc": "TBD", + "output_desc": "TBD", + "input_example": """ + 8 8\ns - - - - - - -\n- - s - - v - v\n- - - - - - - s\n- v - s - - v -\n- h - - h - - -\n- - - - v - s -\n- - - h - - h v\nv - h - - s - h + """, + "output_example": """ + 8 8\n1 3 7 7 10 10 15 17\n2 3 7 7 10 10 15 17\n2 3 8 8 10 10 15 18\n2 3 8 8 10 10 15 19\n2 4 4 9 9 12 12 19\n2 5 5 5 11 12 12 19\n2 5 5 5 11 13 13 19\n2 6 6 6 11 14 16 16 + """ + } + + @typechecked + def __init__(self, num_rows: int, num_cols: int, grid: List[List[str]]): + self.num_rows: int = num_rows + self.num_cols: int = num_cols + self.grid: Grid[str] = Grid(grid) + self.validate_input() + + def validate_input(self): + self._check_grid_dims(self.num_rows, self.num_cols, self.grid.matrix) + self._check_allowed_chars(self.grid.matrix, {'-'}, validator=lambda x: x in ['s', 'h', 'v']) + + def _build_clue_prefix_sum(self): + """Build prefix sum matrix for clue cells to quickly count clues in any rectangle.""" + self._clue_prefix = [[0] * (self.num_cols + 1) for _ in range(self.num_rows + 1)] + for i in range(self.num_rows): + for j in range(self.num_cols): + is_clue = 1 if self.grid.value(i, j) in ['s', 'h', 'v'] else 0 + self._clue_prefix[i + 1][j + 1] = ( + self._clue_prefix[i][j + 1] + + self._clue_prefix[i + 1][j] - + self._clue_prefix[i][j] + + is_clue + ) + + def _count_clues_in_rect(self, r1: int, c1: int, r2: int, c2: int) -> int: + """Count number of clues in rectangle [r1, r2] x [c1, c2] using prefix sum.""" + return ( + self._clue_prefix[r2 + 1][c2 + 1] - + self._clue_prefix[r1][c2 + 1] - + self._clue_prefix[r2 + 1][c1] + + self._clue_prefix[r1][c1] + ) + + def _is_valid_rectangle(self, r1: int, c1: int, r2: int, c2: int, clue_type: str) -> bool: + """Check if rectangle satisfies shape constraints and contains exactly one clue.""" + height = r2 - r1 + 1 + width = c2 - c1 + 1 + + # Shape constraints + if clue_type == 's' and height != width: + return False + if clue_type == 'h' and width <= height: + return False + if clue_type == 'v' and height <= width: + return False + + # Must contain exactly one clue (which must be the current clue) + return self._count_clues_in_rect(r1, c1, r2, c2) == 1 + + def _generate_candidate_rectangles(self) -> Dict[Tuple[int, int, int, int], Tuple[int, int, str]]: + """ + Generate all valid rectangles for each clue. + Returns dict: {(r1, c1, r2, c2): (clue_r, clue_c, clue_type)} + """ + candidates = {} + clues = [] + + # Collect all clue positions and types + for r in range(self.num_rows): + for c in range(self.num_cols): + cell = self.grid.value(r, c) + if cell in ['s', 'h', 'v']: + clues.append((r, c, cell)) + + # For each clue, generate valid rectangles containing it + for clue_r, clue_c, clue_type in clues: + # Enumerate possible rectangle dimensions based on clue type + for r1 in range(clue_r + 1): + for r2 in range(clue_r, self.num_rows): + height = r2 - r1 + 1 + for c1 in range(clue_c + 1): + for c2 in range(clue_c, self.num_cols): + width = c2 - c1 + 1 + + # Skip if shape constraints violated + if clue_type == 's' and height != width: + continue + if clue_type == 'h' and width <= height: + continue + if clue_type == 'v' and height <= width: + continue + + # Check if rectangle contains exactly one clue + if self._count_clues_in_rect(r1, c1, r2, c2) == 1: + key = (r1, c1, r2, c2) + # Store which clue this rectangle belongs to + candidates[key] = (clue_r, clue_c, clue_type) + + return candidates + + def _add_constr(self): + self.model = cp.CpModel() + self.solver = cp.CpSolver() + + # Build prefix sum for clue counting + self._build_clue_prefix_sum() + + # Generate all candidate rectangles + self.candidates = self._generate_candidate_rectangles() + + # Create boolean variables for each candidate rectangle + self.rect_vars = {} + for rect in self.candidates.keys(): + r1, c1, r2, c2 = rect + var = self.model.NewBoolVar(f"rect_{r1}_{c1}_{r2}_{c2}") + self.rect_vars[rect] = var + + # Build mappings: cell -> list of rectangles covering it + self.cell_to_rects = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + self.cell_to_rects[(r, c)] = [] + + for rect, var in self.rect_vars.items(): + r1, c1, r2, c2 = rect + for r in range(r1, r2 + 1): + for c in range(c1, c2 + 1): + self.cell_to_rects[(r, c)].append(var) + + # Build mappings: clue -> list of rectangles containing it + self.clue_to_rects = {} + for rect, (clue_r, clue_c, _) in self.candidates.items(): + clue_pos = (clue_r, clue_c) + if clue_pos not in self.clue_to_rects: + self.clue_to_rects[clue_pos] = [] + self.clue_to_rects[clue_pos].append(self.rect_vars[rect]) + + # Constraint 1: Each clue must be covered by exactly one rectangle + for clue_pos, rect_vars in self.clue_to_rects.items(): + self.model.Add(sum(rect_vars) == 1) + + # Constraint 2: Each cell must be covered by exactly one rectangle + for r in range(self.num_rows): + for c in range(self.num_cols): + self.model.Add(sum(self.cell_to_rects[(r, c)]) == 1) + + # Constraint 3: Avoid 4-way intersections (no 2x2 area covered by 4 different rectangles) + # For each 2x2 subgrid, ensure at least one rectangle covers >=2 cells in it + for r in range(self.num_rows - 1): + for c in range(self.num_cols - 1): + # The four cells in this 2x2 subgrid + cells = [(r, c), (r, c + 1), (r + 1, c), (r + 1, c + 1)] + + # Find rectangles covering at least 2 cells in this 2x2 area + covering_rects = [] + for rect, var in self.rect_vars.items(): + r1, c1, r2, c2 = rect + + # Compute intersection between rectangle and 2x2 subgrid + inter_r1 = max(r1, r) + inter_r2 = min(r2, r + 1) + inter_c1 = max(c1, c) + inter_c2 = min(c2, c + 1) + + # Count cells in intersection + if inter_r1 <= inter_r2 and inter_c1 <= inter_c2: + inter_area = (inter_r2 - inter_r1 + 1) * (inter_c2 - inter_c1 + 1) + if inter_area >= 2: + covering_rects.append(var) + + # At least one rectangle must cover >=2 cells in this 2x2 area + if covering_rects: + self.model.Add(sum(covering_rects) >= 1) + else: + # No valid rectangle covers >=2 cells - problem is unsatisfiable + self.model.AddBoolAnd([]) # Add false constraint to make model infeasible + + def get_solution(self) -> Grid: + # Create output grid initialized with placeholder values + output_matrix = [[0] * self.num_cols for _ in range(self.num_rows)] + + # Assign region IDs to selected rectangles + region_id = 1 + for rect, var in self.rect_vars.items(): + if self.solver.Value(var) > 0.5: # Rectangle is selected + r1, c1, r2, c2 = rect + for r in range(r1, r2 + 1): + for c in range(c1, c2 + 1): + output_matrix[r][c] = str(region_id) + region_id += 1 + + return Grid(output_matrix) \ No newline at end of file diff --git a/src/puzzlekit/solvers/usoone.py b/src/puzzlekit/solvers/usoone.py new file mode 100644 index 00000000..87d1c4ac --- /dev/null +++ b/src/puzzlekit/solvers/usoone.py @@ -0,0 +1,167 @@ +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 puzzlekit.utils.ortools_utils import add_connected_subgraph_by_height +from ortools.sat.python import cp_model as cp +from typeguard import typechecked + + +class UsooneSolver(PuzzleSolver): + metadata: Dict[str, Any] = { + "name": "Usoone", + "aliases": [], + "difficulty": "Medium", + "tags": ["connectivity", "regional", "shading"], + "rule_url": "https://pzplus.tck.mn/rules.html?usoone", + "external_links": [], + "input_desc": "First line: rows cols\nNext rows lines: puzzle grid ('-' for empty, digits for clues)\nNext rows lines: region grid (region identifiers)", + "output_desc": "Grid with 'x' for shaded cells and '-' for unshaded cells", + "input_example": """8 8 +1 2 - - - - 1 - +- - 1 1 - 3 2 - +- - - 0 - 2 - 1 +0 2 - - - - 2 0 +2 1 - - - 1 0 - +0 - - 0 - 0 0 - +- 2 0 - 3 1 - 1 +- - 0 2 - 1 1 - +1 1 1 6 6 6 6 14 +1 1 1 6 6 6 6 14 +1 1 1 6 6 6 6 14 +2 2 2 2 2 2 10 10 +2 2 2 2 2 2 11 11 +3 3 4 7 8 8 12 12 +3 3 4 7 9 9 9 9 +3 3 5 5 5 5 13 13""", + "output_example": """8 8 +- - x - - x - - +x - - - x - - x +- x - - - - x - +- - x - - x - - +- - - - - - - x +- x - - x - - - +- - - x - - x - +- x - - x - - x""" + } + + @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 + self.grid: Grid[str] = Grid(grid) + 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 (non-negative integers) + 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() + + # 1. Define variables: x[r,c] = 1 means shaded, 0 means unshaded + self.x = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + self.x[r, c] = self.model.NewBoolVar(f"x_{r}_{c}") + + # 2. Constraint: Numbers cannot be shaded (Rule 2) + for r in range(self.num_rows): + for c in range(self.num_cols): + if self.grid.value(r, c) != '-': + self.model.Add(self.x[r, c] == 0) + + # 3. Constraint: Shaded cells cannot be orthogonally adjacent (Rule 1) + # Only check right and down to avoid duplicate constraints + for r in range(self.num_rows): + for c in range(self.num_cols): + # Right neighbor + if c + 1 < self.num_cols: + self.model.Add(self.x[r, c] + self.x[r, c + 1] <= 1) + # Down neighbor + if r + 1 < self.num_rows: + self.model.Add(self.x[r, c] + self.x[r + 1, c] <= 1) + + # 4. Process number clues and region constraints (Rule 3) + # For each region, collect clue cells and their correctness variables + region_clues: Dict[str, List[Tuple[int, int, int, cp.IntVar]]] = defaultdict(list) + + for r in range(self.num_rows): + for c in range(self.num_cols): + cell_val = self.grid.value(r, c) + if cell_val != '-': + d = int(cell_val) + region_id = self.region_grid.value(r, c) + + # Calculate sum of shaded neighbors (s) + neighbor_vars = [] + for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + nr, nc = r + dr, c + dc + if 0 <= nr < self.num_rows and 0 <= nc < self.num_cols: + neighbor_vars.append(self.x[nr, nc]) + + s = sum(neighbor_vars) # Linear expression for neighbor sum + + # Create correctness variable: eq_var = 1 iff s == d + eq_var = self.model.NewBoolVar(f"eq_{r}_{c}_{region_id}") + + # Handle cases where d is outside valid range [0, 4] + if 0 <= d <= 4: + # eq_var => (s == d) + self.model.Add(s == d).OnlyEnforceIf(eq_var) + # !eq_var => (s != d) + self.model.Add(s != d).OnlyEnforceIf(eq_var.Not()) + else: + # d is invalid (e.g., >4), so this clue must be incorrect + self.model.Add(eq_var == 0) + + region_clues[region_id].append((r, c, d, eq_var)) + + # For each region: exactly one clue must be incorrect (i.e., sum(correct) = num_clues - 1) + for region_id, clues in region_clues.items(): + num_clues = len(clues) + if num_clues > 0: + correct_vars = [eq_var for _, _, _, eq_var in clues] + self.model.Add(sum(correct_vars) == num_clues - 1) + + # 5. Constraint: All unshaded cells form a single orthogonally connected component (Rule 4) + # Build adjacency map for the entire grid + adjacency_map: Dict[Position, List[Position]] = {} + for r in range(self.num_rows): + for c in range(self.num_cols): + pos = Position(r, c) + neighbors = [] + for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + nr, nc = r + dr, c + dc + if 0 <= nr < self.num_rows and 0 <= nc < self.num_cols: + neighbors.append(Position(nr, nc)) + adjacency_map[pos] = neighbors + + # Active nodes = unshaded cells (x[r,c] = 0) + active_nodes = {Position(r, c): self.x[r, c].Not() for r in range(self.num_rows) for c in range(self.num_cols)} + + # Add connectivity constraint using efficient height-based method + add_connected_subgraph_by_height( + self.model, + active_nodes, + adjacency_map, + prefix="unshaded_conn" + ) + + def get_solution(self): + sol_grid = [['-' 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): + if self.solver.Value(self.x[r, c]) == 1: + sol_grid[r][c] = 'x' + else: + sol_grid[r][c] = '-' + + return Grid(sol_grid) \ No newline at end of file diff --git a/src/puzzlekit/verifiers/__init__.py b/src/puzzlekit/verifiers/__init__.py index 30adf088..1d9e6bfe 100644 --- a/src/puzzlekit/verifiers/__init__.py +++ b/src/puzzlekit/verifiers/__init__.py @@ -104,6 +104,13 @@ "nurikabe": lambda a, b: verify_target_content(a, b, 'x'), "cojun": verify_exact, "shugaku": verify_exact, + "geradeweg": verify_lines, + "nanro": verify_exact, + "shimaguni": verify_exact, + "usoone": lambda a, b: verify_target_content(a, b, 'x'), + "tatamibari": verify_bijective, + "shirokuro": verify_lines, + "pipes": verify_lines, } def grid_verifier(puzzle_type: str, a: Grid, b: Grid) -> bool: diff --git a/src/puzzlekit/viz/__init__.py b/src/puzzlekit/viz/__init__.py index 9ce1be3c..91df5928 100644 --- a/src/puzzlekit/viz/__init__.py +++ b/src/puzzlekit/viz/__init__.py @@ -108,6 +108,13 @@ "nurikabe": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), "cojun": lambda g, d, p: draw_general_puzzle(g, d, p, style='text'), "shugaku": lambda g, d, p: draw_general_puzzle(g, d, p, style='text'), + "geradeweg": lambda g, d, p: draw_general_puzzle(g, d, p, style='line'), + "nanro": lambda g, d, p: draw_general_puzzle(g, d, p, style='text'), + "shimaguni": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), + "usoone": lambda g, d, p: draw_general_puzzle(g, d, p, style='shade'), + "tatamibari": lambda g, d, p: draw_general_puzzle(g, d, p, style='region'), + "shirokuro": lambda g, d, p: draw_general_puzzle(g, d, p, style='line'), + "pipes": lambda g, d, p: draw_general_puzzle(g, d, p, style='line'), # ... } diff --git a/src/puzzlekit/viz/drawers.py b/src/puzzlekit/viz/drawers.py index a59d0e41..54a781b4 100644 --- a/src/puzzlekit/viz/drawers.py +++ b/src/puzzlekit/viz/drawers.py @@ -151,7 +151,7 @@ def _render_line(): is_given = _is_given_cell(pos) if is_given: plotter.draw_cell_text(pos.r, pos.c, initial_grid.value(pos), color='black', weight='bold') - if val_str not in set(["ns", 'sn', "ne", "en", "nw", "wn", "sw", "ws", "se", "es", "we", "ew", "n", "s", "w", "e"]): + if not all(c in 'nswe' for c in val_str): continue plotter.draw_connectors(pos.r, pos.c, val_str, color='blue', linewidth=3) From 268f1911f82d55e2cfc7e4df564426f572f4239b Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sun, 15 Feb 2026 19:29:19 +0800 Subject: [PATCH 3/4] update heyawake and readme --- README.md | 340 ++++++++++++++++-------------- scripts/benchmark.py | 19 +- scripts/quick_start.py | 16 +- src/puzzlekit/solvers/heyawake.py | 4 +- 4 files changed, 221 insertions(+), 158 deletions(-) diff --git a/README.md b/README.md index 643d1c2a..8efe9eee 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Puzzle Kit -This repository provides **90+ useful, efficient and problem‑specific solvers** for a variety of **logic puzzles**. The underlying solving engines is open‑source Google [ortools](https://developers.google.cn/optimization). You can refer to [docs of puzzlekit](https://smilingwayne.github.io/PuzzleSolver/) for details of puzzles and their input format. +This repository provides **100+ useful, efficient and problem‑specific solvers** for a variety of **logic puzzles**. The underlying solving engine is the open‑source Google [OR-Tools](https://developers.google.cn/optimization) CP-SAT solver. For more details of puzzles and their input format, you can refer to [docs of puzzlekit](https://smilingwayne.github.io/PuzzleSolver/). + +For simplicity, the dataset is removed to [puzzlekit-dataset](https://github.com/SmilingWayne/puzzlekit-dataset) repo. The structured dataset contains 41k+ instances covering 130+ specific and popular puzzle types (e.g. Nonogram, Slitherlink, Akari, Fillomino, Hitori, Kakuro, Kakuro), mostly from [Raetsel's Janko](https://www.janko.at/Raetsel/index.htm) and [puzz.link](https://puzz.link). The details are listed in the table below. -For simplicity, the dataset is removed to [puzzlekit-dataset](https://github.com/SmilingWayne/puzzlekit-dataset) repo. The structured dataset contains 38k+ instances covering 120+ specific and popular puzzle types (e.g. Nonogram, Slitherlink, Akari, Fillomino, Hitori, Kakuro, Kakuro), mostly from [Raetsel's Janko](https://www.janko.at/Raetsel/index.htm) and [puzz.link](https://puzz.link). The details are listed in the table below. More data, along with related analytics, will be added over time. -Most of solvers implemented in this repo are both effective and efficient. They have been tested in around 30k+ instances, most of which can be easily solved 0.2 s, even grids with a scale of 30x30 in 1s. The detailed table of puzzles, datasets and solver performance are shown below.
Table of puzzles, datasets and solvers. @@ -20,136 +20,146 @@ Most of solvers implemented in this repo are both effective and efficient. They > `#.V` shows the number of verified solutions compared with the expected solutions. Note that some of solutions failed this mainly because of additional yet unpopular constraints (like diagnonal-ABCEndView, which is a bit rare), or different variants of puzzles(like different shapes of 6x6 Jigsaw Sudoku and Bricks puzzle). -| No. | Puzzle Name | #.P | #.S | Max Size | Sol? | Avg T(s) | Max T(s) | #.V | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 1 | [ABCEndView](./assets/data/ABCEndView) | 607 | 607 | 8x8 | ✅ | 0.014 | 0.042 | 591 | -| 2 | [Akari](./assets/data/Akari) | 970 | 970 | 100x100 | ✅ | 0.013 | 0.453 | 970 | -| 3 | [Aqre](./assets/data/Aqre) | 90 | 90 | 17x17 | ❌ | - | - | - | -| 4 | [Araf](./assets/data/Araf) | 120 | 120 | 10x18 | ❌ | - | - | - | -| 5 | [BalanceLoop](./assets/data/BalanceLoop) | 70 | 70 | 17x17 | ✅ | 0.060 | 0.217 | 70 | -| 6 | [Battleship](./assets/data/Battleship) | 861 | 860 | 30x30 | ✅ | 0.101 | 1.646 | 860 | -| 7 | [Binairo](./assets/data/Binairo) | 380 | 380 | 14x14 | ✅ | 0.007 | 0.018 | 380 | -| 8 | [Bosanowa](./assets/data/Bosanowa) | 38 | 38 | 11x16 | ✅ | 0.016 | 0.186 | 38 | -| 9 | [Bricks](./assets/data/Bricks) | 210 | 210 | 8x8 | ✅ | 0.003 | 0.013 | 190 | -| 10 | [Buraitoraito](./assets/data/Buraitoraito) | 101 | 100 | 15x15 | ✅ | 0.009 | 0.223 | 100 | -| 11 | [Burokku](./assets/data/Burokku) | 270 | 270 | 10x10 | ❌ | - | - | - | -| 12 | [ButterflySudoku](./assets/data/ButterflySudoku) | 77 | 77 | 12x12 | ✅ | 0.008 | 0.011 | 77 | -| 13 | [CanalView](./assets/data/CanalView) | 110 | 110 | 17x17 | ❌ | - | - | - | -| 14 | [CastleWall](./assets/data/CastleWall) | 110 | 110 | 50x50 | ❌ | - | - | - | -| 15 | [Cave](./assets/data/Cave) | 419 | 419 | 25x25 | ✅ | 0.198 | 9.749 | 419 | -| 16 | [Clueless1Sudoku](./assets/data/Clueless1Sudoku) | 29 | 29 | 27x27 | ✅ | 0.030 | 0.049 | 29 | -| 17 | [Clueless2Sudoku](./assets/data/Clueless2Sudoku) | 40 | 40 | 27x27 | ✅ | 0.033 | 0.082 | 40 | -| 18 | [CocktailLamp](./assets/data/CocktailLamp) | 50 | 50 | 17x17 | ❌ | - | - | - | -| 19 | [ConsecutiveSudoku](./assets/data/ConsecutiveSudoku) | 211 | 211 | 9x9 | ❌ | - | - | - | -| 20 | [CountryRoad](./assets/data/CountryRoad) | 270 | 270 | 15x15 | ✅ | 0.027 | 0.089 | 270 | -| 21 | [Creek](./assets/data/Creek) | 440 | 440 | 40x50 | ✅ | 0.343 | 11.257 | 440 | -| 22 | [CurvingRoad](./assets/data/CurvingRoad) | 190 | 190 | 14x14 | ❌ | - | - | - | -| 23 | [Detour](./assets/data/Detour) | 80 | 80 | 13x12 | ✅ | 0.025 | 0.372 | 80 | -| 24 | [DiffNeighbors](./assets/data/DiffNeighbors) | 140 | 140 | 15x15 | ✅ | 0.014 | 0.026 | 140 | -| 25 | [DigitalBattleship](./assets/data/DigitalBattleship) | 80 | 80 | 12x12 | ❌ | - | - | - | -| 26 | [Dominos](./assets/data/Dominos) | 582 | 581 | 41x42 | ✅ | 0.006 | 1.107 | 580 | -| 27 | [Doors](./assets/data/Doors) | 270 | 270 | 12x12 | ❌ | - | - | - | -| 28 | [DotchiLoop](./assets/data/DotchiLoop) | 60 | 60 | 17x17 | ✅ | 0.036 | 0.083 | 60 | -| 29 | [DoubleBack](./assets/data/DoubleBack) | 100 | 100 | 26x26 | ✅ | 0.025 | 0.235 | 100 | -| 30 | [EntryExit](./assets/data/EntryExit) | 170 | 170 | 16x16 | ✅ | 0.038 | 0.081 | 170 | -| 31 | [Eulero](./assets/data/Eulero) | 290 | 290 | 5x5 | ✅ | 0.004 | 0.007 | 290 | -| 32 | [EvenOddSudoku](./assets/data/EvenOddSudoku) | 129 | 129 | 9x9 | ✅ | 0.004 | 0.006 | 129 | -| 33 | [Factors](./assets/data/Factors) | 150 | 150 | 11x11 | ❌ | - | - | - | -| 34 | [Fillomino](./assets/data/Fillomino) | 840 | 840 | 50x64 | ❌ | - | - | - | -| 35 | [Fobidoshi](./assets/data/Fobidoshi) | 250 | 250 | 12x12 | ✅ | 0.055 | 0.136 | 250 | -| 36 | [Foseruzu](./assets/data/Foseruzu) | 310 | 310 | 30x45 | ❌ | - | - | - | -| 37 | [Fuzuli](./assets/data/Fuzuli) | 160 | 160 | 8x8 | ✅ | 0.010 | 0.030 | 160 | -| 38 | [Galaxies](./assets/data/Galaxies) | 580 | 580 | 20x36 | ❌ | - | - | - | -| 39 | [Gappy](./assets/data/Gappy) | 429 | 427 | 18x18 | ✅ | 0.018 | 0.055 | 427 | -| 40 | [Gattai8Sudoku](./assets/data/Gattai8Sudoku) | 120 | 120 | 21x33 | ✅ | 0.020 | 0.028 | 120 | -| 41 | [GokigenNaname](./assets/data/GokigenNaname) | 780 | 780 | 24x36 | ❌ | - | - | - | -| 42 | [GrandTour](./assets/data/GrandTour) | 350 | 350 | 15x15 | ✅ | 0.019 | 0.067 | 350 | -| 43 | [Hakoiri](./assets/data/Hakoiri) | 140 | 140 | 12x12 | ✅ | 0.094 | 0.244 | 140 | -| 44 | [Hakyuu](./assets/data/Hakyuu) | 480 | 480 | 30x45 | ✅ | 0.041 | 0.780 | 480 | -| 45 | [Hanare](./assets/data/Hanare) | 107 | 107 | 16x16 | ❌ | - | - | - | -| 46 | [Heyawake](./assets/data/Heyawake) | 787 | 787 | 31x45 | ✅ | 0.259 | 32.105 | 786 | -| 47 | [Hidoku](./assets/data/Hidoku) | 510 | 510 | 10x10 | ✅ | 0.025 | 0.142 | 510 | -| 48 | [Hitori](./assets/data/Hitori) | 941 | 941 | 25x25 | ✅ | 0.208 | 1.907 | 940 | -| 49 | [JigsawSudoku](./assets/data/JigsawSudoku) | 680 | 680 | 9x9 | ✅ | 0.004 | 0.008 | 665 | -| 50 | [Juosan](./assets/data/Juosan) | 80 | 80 | 30x45 | ✅ | 0.011 | 0.068 | 80 | -| 51 | [Kakkuru](./assets/data/Kakkuru) | 400 | 400 | 9x9 | ✅ | 0.004 | 0.017 | 389 | -| 52 | [Kakurasu](./assets/data/Kakurasu) | 280 | 280 | 11x11 | ✅ | 0.003 | 0.005 | 280 | -| 53 | [Kakuro](./assets/data/Kakuro) | 999 | 999 | 31x46 | ✅ | 0.011 | 0.191 | 999 | -| 54 | [KenKen](./assets/data/KenKen) | 430 | 430 | 9x9 | ✅ | 0.005 | 0.072 | 430 | -| 55 | [KillerSudoku](./assets/data/KillerSudoku) | 810 | 810 | 9x9 | ✅ | 0.007 | 0.078 | 584 | -| 56 | [Koburin](./assets/data/Koburin) | 150 | 150 | 12x12 | ✅ | 0.021 | 0.043 | 150 | -| 57 | [Kuromasu](./assets/data/Kuromasu) | 560 | 560 | 31x45 | ✅ | 0.069 | 4.007 | 560 | -| 58 | [Kuroshuto](./assets/data/Kuroshuto) | 210 | 210 | 14x14 | ✅ | 0.146 | 0.848 | 210 | -| 59 | [Kurotto](./assets/data/Kurotto) | 230 | 230 | 19x27 | ❌ | - | - | - | -| 60 | [LITS](./assets/data/LITS) | 419 | 419 | 40x57 | ✅ | 0.563 | 19.842 | 410 | -| 61 | [Linesweeper](./assets/data/Linesweeper) | 310 | 310 | 16x16 | ✅ | 0.018 | 0.055 | 310 | -| 62 | [Magnetic](./assets/data/Magnetic) | 439 | 439 | 12x12 | ✅ | 0.010 | 0.025 | 439 | -| 63 | [Makaro](./assets/data/Makaro) | 190 | 190 | 15x15 | ✅ | 0.007 | 0.011 | 190 | -| 64 | [MarginSudoku](./assets/data/MarginSudoku) | 149 | 149 | 9x9 | ❌ | - | - | - | -| 65 | [Masyu](./assets/data/Masyu) | 830 | 828 | 40x58 | ✅ | 0.067 | 0.774 | 828 | -| 66 | [Mathrax](./assets/data/Mathrax) | 175 | 175 | 9x9 | ✅ | 0.004 | 0.015 | 175 | -| 67 | [Maze-a-pix](./assets/data/Maze-a-pix) | 0 | 0 | - | ❌ | - | - | - | -| 68 | [Minesweeper](./assets/data/Minesweeper) | 360 | 360 | 14x24 | ✅ | 0.005 | 0.010 | 360 | -| 69 | [MoonSun](./assets/data/MoonSun) | 200 | 200 | 30x45 | ✅ | 0.041 | 0.326 | 200 | -| 70 | [Mosaic](./assets/data/Mosaic) | 165 | 104 | 118x100 | ✅ | 0.016 | 0.123 | 104 | -| 71 | [Munraito](./assets/data/Munraito) | 360 | 360 | 12x12 | ✅ | 0.010 | 0.025 | 360 | -| 72 | [Nanbaboru](./assets/data/Nanbaboru) | 270 | 270 | 9x9 | ❌ | - | - | - | -| 73 | [Nawabari](./assets/data/Nawabari) | 160 | 160 | 14x14 | ✅ | 0.020 | 0.038 | 160 | -| 74 | [Nondango](./assets/data/Nondango) | 110 | 110 | 14x14 | ✅ | 0.005 | 0.009 | 110 | -| 75 | [Nonogram](./assets/data/Nonogram) | 2338 | 2337 | 30x40 | ✅ | 0.301 | 1.217 | 2337 | -| 76 | [Norinori](./assets/data/Norinori) | 289 | 289 | 36x54 | ✅ | 0.008 | 0.081 | 288 | -| 77 | [NumberCross](./assets/data/NumberCross) | 170 | 170 | 8x8 | ✅ | 0.003 | 0.005 | 170 | -| 78 | [NumberLink](./assets/data/NumberLink) | 580 | 580 | 35x48 | ❌ | - | - | - | -| 79 | [NumberSnake](./assets/data/NumberSnake) | 70 | 70 | 10x10 | ❌ | - | - | - | -| 80 | [Nurikabe](./assets/data/Nurikabe) | 1130 | 1130 | 50x50 | ❌ | - | - | - | -| 81 | [Nurimisaki](./assets/data/Nurimisaki) | 100 | 100 | 10x10 | ❌ | - | - | - | -| 82 | [OneToX](./assets/data/OneToX) | 58 | 58 | 10x10 | ✅ | 0.011 | 0.113 | 58 | -| 83 | [PaintArea](./assets/data/PaintArea) | 226 | 226 | 12x12 | ✅ | 0.063 | 2.815 | 226 | -| 84 | [Patchwork](./assets/data/Patchwork) | 211 | 211 | 12x12 | ✅ | 0.020 | 0.033 | 211 | -| 85 | [Pfeilzahlen](./assets/data/Pfeilzahlen) | 360 | 360 | 10x10 | ✅ | 0.012 | 0.035 | 358 | -| 86 | [Pills](./assets/data/Pills) | 164 | 163 | 10x10 | ✅ | 0.007 | 0.008 | 163 | -| 87 | [Polyiamond](./assets/data/Polyiamond) | 0 | 0 | - | ❌ | - | - | - | -| 88 | [Polyminoes](./assets/data/Polyminoes) | 0 | 0 | - | ❌ | - | - | - | -| 89 | [Putteria](./assets/data/Putteria) | 60 | 60 | 16x16 | ✅ | 0.025 | 0.055 | 60 | -| 90 | [RegionalYajilin](./assets/data/RegionalYajilin) | 70 | 70 | 10x18 | ✅ | 0.021 | 0.044 | 70 | -| 91 | [Rekuto](./assets/data/Rekuto) | 220 | 220 | 14x14 | ❌ | - | - | - | -| 92 | [Renban](./assets/data/Renban) | 150 | 150 | 9x9 | ✅ | 0.005 | 0.066 | 150 | -| 93 | [SamuraiSudoku](./assets/data/SamuraiSudoku) | 272 | 272 | 21x21 | ✅ | 0.011 | 0.021 | 272 | -| 94 | [Shikaku](./assets/data/Shikaku) | 501 | 501 | 50x40 | ✅ | 0.009 | 0.077 | 498 | -| 95 | [Shimaguni](./assets/data/Shimaguni) | 266 | 266 | 30x45 | ❌ | - | - | - | -| 96 | [Shingoki](./assets/data/Shingoki) | 103 | 103 | 41x41 | ✅ | 0.083 | 1.220 | 103 | -| 97 | [Shirokuro](./assets/data/Shirokuro) | 110 | 110 | 17x17 | ❌ | - | - | - | -| 98 | [ShogunSudoku](./assets/data/ShogunSudoku) | 90 | 90 | 21x45 | ✅ | 0.030 | 0.059 | 90 | -| 99 | [Shugaku](./assets/data/Shugaku) | 130 | 130 | 30x45 | ❌ | - | - | - | -| 100 | [SimpleLoop](./assets/data/SimpleLoop) | 70 | 70 | 17x18 | ✅ | 0.020 | 0.054 | 70 | -| 101 | [Skyscraper](./assets/data/Skyscraper) | 470 | 470 | 8x8 | ✅ | 0.014 | 0.060 | 470 | -| 102 | [SkyscraperSudoku](./assets/data/SkyscraperSudoku) | 50 | 50 | 9x9 | ❌ | - | - | - | -| 103 | [Slitherlink](./assets/data/Slitherlink) | 1176 | 1153 | 60x60 | ✅ | 0.067 | 1.630 | 1149 | -| 104 | [Snake](./assets/data/Snake) | 230 | 230 | 12x12 | ✅ | 0.062 | 0.209 | 230 | -| 105 | [SoheiSudoku](./assets/data/SoheiSudoku) | 120 | 120 | 21x21 | ✅ | 0.010 | 0.014 | 120 | -| 106 | [SquareO](./assets/data/SquareO) | 120 | 80 | 15x15 | ✅ | 0.004 | 0.016 | 80 | -| 107 | [Starbattle](./assets/data/Starbattle) | 309 | 308 | 25x25 | ✅ | 0.009 | 0.047 | 307 | -| 108 | [Sternenhimmel](./assets/data/Sternenhimmel) | 188 | 188 | 17x17 | ❌ | - | - | - | -| 109 | [Stitches](./assets/data/Stitches) | 110 | 110 | 15x15 | ✅ | 0.005 | 0.022 | 110 | -| 110 | [Str8t](./assets/data/Str8t) | 560 | 560 | 9x9 | ✅ | 0.005 | 0.008 | 560 | -| 111 | [Sudoku](./assets/data/Sudoku) | 125 | 125 | 16x16 | ✅ | 0.010 | 0.018 | 125 | -| 112 | [Suguru](./assets/data/Suguru) | 200 | 200 | 10x10 | ✅ | 0.008 | 0.014 | 200 | -| 113 | [SumoSudoku](./assets/data/SumoSudoku) | 110 | 110 | 33x33 | ✅ | 0.032 | 0.047 | 110 | -| 114 | [Tatamibari](./assets/data/Tatamibari) | 150 | 150 | 14x14 | ❌ | - | - | - | -| 115 | [TennerGrid](./assets/data/TennerGrid) | 375 | 374 | 6x10 | ✅ | 0.007 | 0.011 | 374 | -| 116 | [Tent](./assets/data/Tent) | 706 | 706 | 30x30 | ✅ | 0.006 | 0.026 | 706 | -| 117 | [TerraX](./assets/data/TerraX) | 80 | 80 | 17x17 | ✅ | 0.009 | 0.019 | 80 | -| 118 | [Thermometer](./assets/data/Thermometer) | 250 | 250 | 10x10 | ✅ | 0.004 | 0.007 | 250 | -| 119 | [TilePaint](./assets/data/TilePaint) | 377 | 377 | 16x16 | ✅ | 0.004 | 0.081 | 377 | -| 120 | [Trinairo](./assets/data/Trinairo) | 60 | 60 | 12x12 | ✅ | 0.016 | 0.037 | 60 | -| 121 | [Tripletts](./assets/data/Tripletts) | 190 | 190 | 10x12 | ❌ | - | - | - | -| 122 | [Usoone](./assets/data/Usoone) | 130 | 130 | 30x45 | ❌ | - | - | - | -| 123 | [WindmillSudoku](./assets/data/WindmillSudoku) | 150 | 150 | 21x21 | ✅ | 0.012 | 0.019 | 150 | -| 124 | [Yajikabe](./assets/data/Yajikabe) | 100 | 100 | 17x17 | ✅ | 0.116 | 0.499 | 100 | -| 125 | [Yajilin](./assets/data/Yajilin) | 610 | 610 | 39x57 | ✅ | 0.052 | 0.520 | 610 | -| 126 | [YinYang](./assets/data/YinYang) | 170 | 170 | 14x14 | ✅ | 0.286 | 2.016 | 170 | -| 127 | [Yonmasu](./assets/data/Yonmasu) | 120 | 120 | 10x10 | ❌ | - | - | - | -| | **Total** | **38438** | **38303** | - | - | - | - | - | +| No. | Puzzle Name | #.P | #.S | Max Size | Sol? | Avg T(s) | Max T(s) | #.V | +| --- | ---------------------------------------------------- | --------- | --------- | -------- | ---- | -------- | -------- | ---- | +| 1 | [ABCEndView](./assets/data/ABCEndView) | 607 | 607 | 8x8 | ✅ | 0.014 | 0.042 | 591 | +| 2 | [Akari](./assets/data/Akari) | 970 | 970 | 100x100 | ✅ | 0.014 | 0.479 | 970 | +| 3 | [Aqre](./assets/data/Aqre) | 90 | 90 | 17x17 | ✅ | 0.122 | 2.387 | 90 | +| 4 | [Araf](./assets/data/Araf) | 120 | 120 | 10x18 | ❌ | - | - | - | +| 5 | [BalanceLoop](./assets/data/BalanceLoop) | 70 | 70 | 17x17 | ✅ | 0.062 | 0.218 | 70 | +| 6 | [Battleship](./assets/data/Battleship) | 861 | 860 | 30x30 | ✅ | 0.103 | 1.748 | 860 | +| 7 | [Binairo](./assets/data/Binairo) | 380 | 380 | 14x14 | ✅ | 0.007 | 0.015 | 380 | +| 8 | [Bosanowa](./assets/data/Bosanowa) | 38 | 38 | 11x16 | ✅ | 0.015 | 0.178 | 38 | +| 9 | [Bricks](./assets/data/Bricks) | 210 | 210 | 8x8 | ✅ | 0.004 | 0.013 | 190 | +| 10 | [Buraitoraito](./assets/data/Buraitoraito) | 101 | 100 | 15x15 | ✅ | 0.007 | 0.159 | 100 | +| 11 | [Burokku](./assets/data/Burokku) | 270 | 270 | 10x10 | ❌ | - | - | - | +| 12 | [ButterflySudoku](./assets/data/ButterflySudoku) | 77 | 77 | 12x12 | ✅ | 0.008 | 0.011 | 77 | +| 13 | [CanalView](./assets/data/CanalView) | 110 | 110 | 17x17 | ✅ | 0.120 | 1.157 | 110 | +| 14 | [CastleWall](./assets/data/CastleWall) | 110 | 110 | 50x50 | ✅ | 0.058 | 1.184 | 110 | +| 15 | [Cave](./assets/data/Cave) | 419 | 419 | 25x25 | ✅ | 0.211 | 11.318 | 419 | +| 16 | [Chocona](./assets/data/Chocona) | 250 | 250 | 17x17 | ❌ | - | - | - | +| 17 | [Clueless1Sudoku](./assets/data/Clueless1Sudoku) | 29 | 29 | 27x27 | ✅ | 0.029 | 0.042 | 29 | +| 18 | [Clueless2Sudoku](./assets/data/Clueless2Sudoku) | 40 | 40 | 27x27 | ✅ | 0.035 | 0.083 | 40 | +| 19 | [CocktailLamp](./assets/data/CocktailLamp) | 50 | 50 | 17x17 | ❌ | - | - | - | +| 20 | [Cojun](./assets/data/Cojun) | 120 | 120 | 17x17 | ✅ | 0.007 | 0.073 | 120 | +| 21 | [ConsecutiveSudoku](./assets/data/ConsecutiveSudoku) | 211 | 211 | 9x9 | ❌ | - | - | - | +| 22 | [CountryRoad](./assets/data/CountryRoad) | 270 | 270 | 15x15 | ✅ | 0.029 | 0.128 | 270 | +| 23 | [Creek](./assets/data/Creek) | 440 | 440 | 40x50 | ✅ | 0.389 | 13.115 | 440 | +| 24 | [CurvingRoad](./assets/data/CurvingRoad) | 190 | 190 | 14x14 | ❌ | - | - | - | +| 25 | [Detour](./assets/data/Detour) | 80 | 80 | 13x12 | ✅ | 0.023 | 0.184 | 80 | +| 26 | [DiffNeighbors](./assets/data/DiffNeighbors) | 140 | 140 | 15x15 | ✅ | 0.015 | 0.027 | 140 | +| 27 | [DigitalBattleship](./assets/data/DigitalBattleship) | 80 | 80 | 12x12 | ❌ | - | - | - | +| 28 | [Dominos](./assets/data/Dominos) | 582 | 581 | 41x42 | ✅ | 0.006 | 1.224 | 580 | +| 29 | [Doors](./assets/data/Doors) | 270 | 270 | 12x12 | ❌ | - | - | - | +| 30 | [DoppelBlock](./assets/data/DoppelBlock) | 240 | 240 | 8x8 | ❌ | - | - | - | +| 31 | [DotchiLoop](./assets/data/DotchiLoop) | 60 | 60 | 17x17 | ✅ | 0.038 | 0.089 | 60 | +| 32 | [DoubleBack](./assets/data/DoubleBack) | 100 | 100 | 26x26 | ✅ | 0.026 | 0.241 | 100 | +| 33 | [EntryExit](./assets/data/EntryExit) | 170 | 170 | 16x16 | ✅ | 0.039 | 0.095 | 170 | +| 34 | [Eulero](./assets/data/Eulero) | 290 | 290 | 5x5 | ✅ | 0.004 | 0.007 | 290 | +| 35 | [EvenOddSudoku](./assets/data/EvenOddSudoku) | 129 | 129 | 9x9 | ✅ | 0.004 | 0.005 | 129 | +| 36 | [Factors](./assets/data/Factors) | 150 | 150 | 11x11 | ❌ | - | - | - | +| 37 | [Fillomino](./assets/data/Fillomino) | 840 | 840 | 50x64 | ❌ | - | - | - | +| 38 | [Fobidoshi](./assets/data/Fobidoshi) | 250 | 250 | 12x12 | ✅ | 0.056 | 0.150 | 250 | +| 39 | [Foseruzu](./assets/data/Foseruzu) | 310 | 310 | 30x45 | ❌ | - | - | - | +| 40 | [Fuzuli](./assets/data/Fuzuli) | 160 | 160 | 8x8 | ✅ | 0.010 | 0.018 | 160 | +| 41 | [Galaxies](./assets/data/Galaxies) | 580 | 580 | 20x36 | ❌ | - | - | - | +| 42 | [Gappy](./assets/data/Gappy) | 429 | 427 | 18x18 | ✅ | 0.019 | 0.062 | 427 | +| 43 | [Gattai8Sudoku](./assets/data/Gattai8Sudoku) | 120 | 120 | 21x33 | ✅ | 0.021 | 0.032 | 120 | +| 44 | [Geradeweg](./assets/data/Geradeweg) | 100 | 100 | 14x14 | ✅ | 0.050 | 0.157 | 100 | +| 45 | [GokigenNaname](./assets/data/GokigenNaname) | 780 | 780 | 24x36 | ❌ | - | - | - | +| 46 | [GrandTour](./assets/data/GrandTour) | 350 | 350 | 15x15 | ✅ | 0.020 | 0.070 | 350 | +| 47 | [Hakoiri](./assets/data/Hakoiri) | 140 | 140 | 12x12 | ✅ | 0.098 | 0.263 | 140 | +| 48 | [Hakyuu](./assets/data/Hakyuu) | 480 | 480 | 30x45 | ✅ | 0.042 | 0.801 | 480 | +| 49 | [Hanare](./assets/data/Hanare) | 107 | 107 | 16x16 | ❌ | - | - | - | +| 50 | [Hashi](./assets/data/Hashi) | 910 | 910 | 40x60 | ❌ | - | - | - | +| 51 | [Heyawake](./assets/data/Heyawake) | 787 | 787 | 31x45 | ✅ | 0.139 | 4.756 | 786 | +| 52 | [Hidoku](./assets/data/Hidoku) | 510 | 510 | 10x10 | ✅ | 0.026 | 0.140 | 510 | +| 53 | [Hitori](./assets/data/Hitori) | 941 | 941 | 25x25 | ✅ | 0.010 | 1.017 | 941 | +| 54 | [JigsawSudoku](./assets/data/JigsawSudoku) | 680 | 680 | 9x9 | ✅ | 0.003 | 0.007 | 665 | +| 55 | [Juosan](./assets/data/Juosan) | 80 | 80 | 30x45 | ✅ | 0.011 | 0.075 | 80 | +| 56 | [Kakkuru](./assets/data/Kakkuru) | 400 | 400 | 9x9 | ✅ | 0.003 | 0.016 | 389 | +| 57 | [Kakurasu](./assets/data/Kakurasu) | 280 | 280 | 11x11 | ✅ | 0.003 | 0.005 | 280 | +| 58 | [Kakuro](./assets/data/Kakuro) | 999 | 999 | 31x46 | ✅ | 0.011 | 0.200 | 999 | +| 59 | [KenKen](./assets/data/KenKen) | 430 | 430 | 9x9 | ✅ | 0.005 | 0.135 | 430 | +| 60 | [KillerSudoku](./assets/data/KillerSudoku) | 810 | 810 | 9x9 | ✅ | 0.006 | 0.119 | 584 | +| 61 | [Koburin](./assets/data/Koburin) | 150 | 150 | 12x12 | ✅ | 0.020 | 0.041 | 150 | +| 62 | [Kuromasu](./assets/data/Kuromasu) | 560 | 560 | 31x45 | ✅ | 0.070 | 4.359 | 560 | +| 63 | [Kuroshuto](./assets/data/Kuroshuto) | 210 | 210 | 14x14 | ✅ | 0.145 | 0.806 | 210 | +| 64 | [Kurotto](./assets/data/Kurotto) | 230 | 230 | 19x27 | ✅ | 0.853 | 16.754 | 227 | +| 65 | [LITS](./assets/data/LITS) | 419 | 410 | 40x57 | ✅ | 0.619 | 40.630 | 410 | +| 66 | [Linesweeper](./assets/data/Linesweeper) | 310 | 310 | 16x16 | ✅ | 0.017 | 0.045 | 310 | +| 67 | [Magnetic](./assets/data/Magnetic) | 439 | 439 | 12x12 | ✅ | 0.010 | 0.025 | 439 | +| 68 | [Makaro](./assets/data/Makaro) | 190 | 190 | 15x15 | ✅ | 0.007 | 0.010 | 190 | +| 69 | [MarginSudoku](./assets/data/MarginSudoku) | 149 | 149 | 9x9 | ❌ | - | - | - | +| 70 | [Masyu](./assets/data/Masyu) | 830 | 828 | 40x58 | ✅ | 0.066 | 0.790 | 828 | +| 71 | [Mathrax](./assets/data/Mathrax) | 175 | 175 | 9x9 | ✅ | 0.004 | 0.014 | 175 | +| 73 | [Mejilink](./assets/data/Mejilink) | 1 | 0 | 8x8 | ✅ | 0.012 | 0.012 | 0 | +| 74 | [MidLoop](./assets/data/MidLoop) | 2 | 2 | 10x10 | ✅ | 0.014 | 0.025 | 2 | +| 75 | [Minesweeper](./assets/data/Minesweeper) | 360 | 360 | 14x24 | ✅ | 0.005 | 0.023 | 360 | +| 76 | [MoonSun](./assets/data/MoonSun) | 200 | 200 | 30x45 | ✅ | 0.041 | 0.304 | 200 | +| 77 | [Mosaic](./assets/data/Mosaic) | 165 | 104 | 118x100 | ✅ | 0.015 | 0.133 | 104 | +| 78 | [Munraito](./assets/data/Munraito) | 360 | 360 | 12x12 | ✅ | 0.010 | 0.025 | 360 | +| 79 | [Nanbaboru](./assets/data/Nanbaboru) | 270 | 270 | 9x9 | ❌ | - | - | - | +| 80 | [Nanro](./assets/data/Nanro) | 159 | 159 | 14x14 | ✅ | 0.138 | 0.406 | 159 | +| 81 | [Nawabari](./assets/data/Nawabari) | 160 | 160 | 14x14 | ✅ | 0.020 | 0.039 | 160 | +| 82 | [Nondango](./assets/data/Nondango) | 110 | 110 | 14x14 | ✅ | 0.004 | 0.009 | 110 | +| 83 | [Nonogram](./assets/data/Nonogram) | 2338 | 2337 | 30x40 | ✅ | 0.300 | 1.494 | 2337 | +| 84 | [Norinori](./assets/data/Norinori) | 289 | 289 | 36x54 | ✅ | 0.008 | 0.082 | 288 | +| 85 | [NumberCross](./assets/data/NumberCross) | 170 | 170 | 8x8 | ✅ | 0.003 | 0.006 | 170 | +| 86 | [NumberLink](./assets/data/NumberLink) | 580 | 580 | 35x48 | ❌ | - | - | - | +| 87 | [NumberSnake](./assets/data/NumberSnake) | 70 | 70 | 10x10 | ❌ | - | - | - | +| 88 | [Nurikabe](./assets/data/Nurikabe) | 1130 | 1130 | 50x50 | ❌ | - | - | - | +| 89 | [Nurimisaki](./assets/data/Nurimisaki) | 100 | 100 | 10x10 | ✅ | 0.059 | 0.114 | 100 | +| 90 | [OneToX](./assets/data/OneToX) | 58 | 58 | 10x10 | ✅ | 0.011 | 0.075 | 58 | +| 91 | [PaintArea](./assets/data/PaintArea) | 226 | 226 | 12x12 | ✅ | 0.064 | 2.336 | 226 | +| 92 | [Patchwork](./assets/data/Patchwork) | 211 | 211 | 12x12 | ✅ | 0.020 | 0.033 | 211 | +| 93 | [Pfeilzahlen](./assets/data/Pfeilzahlen) | 360 | 359 | 10x10 | ✅ | 0.011 | 0.021 | 358 | +| 94 | [Pills](./assets/data/Pills) | 164 | 163 | 10x10 | ✅ | 0.007 | 0.008 | 163 | +| 95 | [Pipeline](./assets/data/Pipeline) | 349 | 349 | 20x20 | ❌ | - | - | - | +| 96 | [Pipelink](./assets/data/Pipelink) | 190 | 190 | 30x45 | ❌ | - | - | - | +| 97 | [Pipes](./assets/data/Pipes) | 2 | 2 | 60x40 | ✅ | 0.384 | 0.959 | 2 | +| 100 | [Putteria](./assets/data/Putteria) | 60 | 60 | 16x16 | ✅ | 0.025 | 0.056 | 60 | +| 101 | [RegionalYajilin](./assets/data/RegionalYajilin) | 70 | 70 | 10x18 | ✅ | 0.021 | 0.058 | 70 | +| 102 | [Rekuto](./assets/data/Rekuto) | 220 | 220 | 14x14 | ❌ | - | - | - | +| 103 | [Renban](./assets/data/Renban) | 150 | 150 | 9x9 | ✅ | 0.005 | 0.059 | 150 | +| 104 | [SamuraiSudoku](./assets/data/SamuraiSudoku) | 272 | 272 | 21x21 | ✅ | 0.011 | 0.022 | 272 | +| 105 | [Shakashaka](./assets/data/Shakashaka) | 369 | 369 | 22x30 | ❌ | - | - | - | +| 106 | [Shikaku](./assets/data/Shikaku) | 501 | 501 | 50x40 | ✅ | 0.010 | 0.078 | 498 | +| 107 | [Shimaguni](./assets/data/Shimaguni) | 266 | 266 | 30x45 | ✅ | 0.032 | 0.537 | 266 | +| 108 | [Shingoki](./assets/data/Shingoki) | 103 | 103 | 41x41 | ✅ | 0.082 | 1.204 | 103 | +| 109 | [Shirokuro](./assets/data/Shirokuro) | 110 | 110 | 17x17 | ✅ | 0.006 | 0.008 | 110 | +| 110 | [ShogunSudoku](./assets/data/ShogunSudoku) | 90 | 90 | 21x45 | ✅ | 0.030 | 0.048 | 90 | +| 111 | [Shugaku](./assets/data/Shugaku) | 130 | 130 | 30x45 | ✅ | 0.665 | 9.848 | 130 | +| 112 | [SimpleLoop](./assets/data/SimpleLoop) | 70 | 70 | 17x18 | ✅ | 0.020 | 0.056 | 70 | +| 113 | [Skyscraper](./assets/data/Skyscraper) | 470 | 470 | 8x8 | ✅ | 0.015 | 0.077 | 470 | +| 114 | [SkyscraperSudoku](./assets/data/SkyscraperSudoku) | 50 | 50 | 9x9 | ❌ | - | - | - | +| 115 | [Slitherlink](./assets/data/Slitherlink) | 1176 | 1152 | 60x60 | ✅ | 0.067 | 1.850 | 1149 | +| 116 | [Snake](./assets/data/Snake) | 230 | 230 | 12x12 | ✅ | 0.062 | 0.216 | 230 | +| 117 | [SoheiSudoku](./assets/data/SoheiSudoku) | 120 | 120 | 21x21 | ✅ | 0.010 | 0.014 | 120 | +| 118 | [SquareO](./assets/data/SquareO) | 120 | 80 | 15x15 | ✅ | 0.004 | 0.007 | 80 | +| 119 | [Starbattle](./assets/data/Starbattle) | 309 | 308 | 25x25 | ✅ | 0.009 | 0.046 | 308 | +| 120 | [Sternenhimmel](./assets/data/Sternenhimmel) | 188 | 188 | 17x17 | ❌ | - | - | - | +| 121 | [Stitches](./assets/data/Stitches) | 110 | 110 | 15x15 | ✅ | 0.005 | 0.013 | 110 | +| 122 | [Str8t](./assets/data/Str8t) | 560 | 560 | 9x9 | ✅ | 0.004 | 0.008 | 560 | +| 123 | [Sudoku](./assets/data/Sudoku) | 125 | 125 | 16x16 | ✅ | 0.010 | 0.019 | 125 | +| 124 | [Suguru](./assets/data/Suguru) | 200 | 200 | 10x10 | ✅ | 0.008 | 0.013 | 200 | +| 125 | [Sukoro](./assets/data/Sukoro) | 140 | 140 | 12x12 | ❌ | - | - | - | +| 126 | [SumoSudoku](./assets/data/SumoSudoku) | 110 | 110 | 33x33 | ✅ | 0.032 | 0.046 | 110 | +| 127 | [Tatamibari](./assets/data/Tatamibari) | 150 | 150 | 14x14 | ✅ | 0.021 | 0.051 | 150 | +| 128 | [TennerGrid](./assets/data/TennerGrid) | 375 | 374 | 6x10 | ✅ | 0.007 | 0.010 | 374 | +| 129 | [Tent](./assets/data/Tent) | 706 | 706 | 30x30 | ✅ | 0.006 | 0.026 | 706 | +| 130 | [TerraX](./assets/data/TerraX) | 80 | 80 | 17x17 | ✅ | 0.009 | 0.018 | 80 | +| 131 | [Thermometer](./assets/data/Thermometer) | 250 | 250 | 10x10 | ✅ | 0.003 | 0.006 | 250 | +| 132 | [TilePaint](./assets/data/TilePaint) | 377 | 377 | 16x16 | ✅ | 0.004 | 0.086 | 377 | +| 133 | [Trinairo](./assets/data/Trinairo) | 60 | 60 | 12x12 | ✅ | 0.018 | 0.046 | 60 | +| 134 | [Tripletts](./assets/data/Tripletts) | 190 | 190 | 10x12 | ❌ | - | - | - | +| 135 | [Usoone](./assets/data/Usoone) | 130 | 130 | 30x45 | ✅ | 0.031 | 0.430 | 130 | +| 136 | [WindmillSudoku](./assets/data/WindmillSudoku) | 150 | 150 | 21x21 | ✅ | 0.012 | 0.034 | 150 | +| 137 | [Yajikabe](./assets/data/Yajikabe) | 100 | 100 | 17x17 | ✅ | 0.115 | 0.492 | 100 | +| 138 | [Yajilin](./assets/data/Yajilin) | 610 | 610 | 39x57 | ✅ | 0.052 | 0.512 | 610 | +| 139 | [YinYang](./assets/data/YinYang) | 170 | 170 | 14x14 | ✅ | 0.316 | 1.897 | 170 | +| 140 | [Yonmasu](./assets/data/Yonmasu) | 120 | 120 | 10x10 | ❌ | - | - | - | +| | **Total** | **41270** | **41123** | - | - | - | - | - |
@@ -164,9 +174,17 @@ Most of solvers implemented in this repo are both effective and efficient. They -Unlike other solvers that rely on logical/deductive methods, the solvers here are primarily based on **C**onstraint **P**rogramming. While I greatly admire those who can spot purely logical solutions, this project is **not** intended to replace human reasoning with automated solving: **it’s just for fun**. +## Related Projects & Online Play + +If you are looking for **online playing** or a **browser-based solver** with excellent performance, here are some fantastic alternatives: + +- **[Noqx](https://github.com/T0nyX1ang/Noqx)** 🎉, with interactive penpa-edit style [web interface](http://t0nyx1ang.github.io/noqx/penpa-edit/): An enhanced and more-efficient logic puzzle solver based on Clingo (Answer Set Program (ASP) solver) and WASM. It supports 170+ puzzle types, covering most of this repo with same or even better efficiency compared with this repo. It's highly recommend to give it a try. +- **[nikoli-puzzle-solver (Z3)](https://util.in:8102)**: Another SAT-based solver with interactive penpa-style web page via z3 solver for 100+ puzzle types. It supports more grid format like hex, triangle and more. +- **[puzzle_solver](https://github.com/Ar-Kareem/puzzle_solver)** for 90+puzzles by Ar-Kareem, also in OR-Tools. +- **[Puzzles-Solver](https://github.com/newtomsoft/Puzzles-Solver)** in action by newtomsoft, with browser plugins provided. -Some details are greatly inspired by similar yet more sophisticated repositories like [puzzle_solver](https://github.com/Ar-Kareem/puzzle_solver) for 90+puzzles by Ar-Kareem and [Puzzles-Solver](https://github.com/newtomsoft/Puzzles-Solver) in action by newtomsoft. +**How Puzzlekit differs:** +> Puzzlekit is designed as a **Python library** (WIP... though) for developers and researchers, offering programmatic access, a unified API, and a massive structured **dataset** for benchmarking. ## Usage @@ -188,11 +206,22 @@ Then a quick tour: import puzzlekit problem_str = """ -20 20\n6\n7 1\n11 2\n3 7\n3 2 5\n3 3 3\n3 2 3 2\n2 2 3 2\n2 1 1 1\n2 2 1 1\n2 2 2\n2 2 2\n2 2 3 3\n2 2 3 3 1\n2 2 1\n3 2 2\n4 9\n17\n9 2\n6 4\n12\n14\n5 4\n3 4\n3 1 3\n2 2 3 1 3\n2 6 5 3\n2 3 3 3\n2 3\n3 2 2 2\n3 2 2 4\n1 2 2 2 2 1\n1 2 2 1\n1 1 4\n5 2 3\n3 1 2\n2 1 2\n3 2 2\n4 3 2\n11 +10 10 +- - - - - b - - - - +- b - - w - - b - - +w - - - - - - - - - +- - - - - - - - w - +- - - - - b b - b - +- w - w w - - - - - +- w - - - - - - - - +- - - - - - - - - b +- - b - - b - - w - +- - - - w - - - - - """ -res = puzzlekit.solve(problem_str, "nonogram") +res = puzzlekit.solve(problem_str, "masyu") # res.show() # If you want to visualize it. ``` + The detailed usage of specific logic puzzles can be found in the [docs of puzzlekit](https://smilingwayne.github.io/PuzzleSolver/). If you want a batch-run, clone the dataset you need via [puzzlekit-dataset](https://github.com/SmilingWayne/puzzlekit-dataset) to `./assets` folder in the root. Then run the `scripts/benchmark.py` like: @@ -211,26 +240,31 @@ python scripts/benchmark.py -a Currently it will take ~30 min to solve all 30k+ instances available. -## Table of contents - -1. [Solvers for Logic Puzzles by CS-SAT](./Puzzles/). INTERESTING and brain-burning logic puzzles (at least it's hard for impatient guys like me). +## Roadmap -2. [Dataset of 100+ puzzles (another repo)](https://github.com/SmilingWayne/puzzlekit-dataset), One of the key features that distinguishes this repository from related works. +- [x] 130+ Puzzle Solvers & 40k+ Dataset. +- [x] Unified Python API (pip install puzzlekit). +- [ ] Unified Converter: Batch converting internal formats to penpa-edit or puzz.link URLs. +- [ ] Dataset update, remove duplicates. +- [ ] Docs update. -3. Easy to use batch verification and unified API. -4. `WIP` Support of puzz.link-style url decode-solve, with direct public API. +---- -- **Motivation**: Many puzzles available online are stored in PDF or image formats, which are not readily usable for automated solving. This repository provides easy-to-use web [crawlers](./Crawlers/) that extract puzzle data and convert it into a structured, machine-readable format. -- **Usage**: The datasets can serve as benchmarks for evaluating and testing the performance of computer-aided solvers. +## Reference +Aside from links mentioned above, please refer to the following: ----- +**Solving Tools:** +- [OR-Tools CP-SAT solver](https://developers.google.cn/optimization?hl=zh-cn) by Google. +- [Z3 SMT Solver](https://www.microsoft.com/en-us/research/project/z3-3/?msockid=31abdfd0975e6ec50eb0c8d196dd6f6f) by microsoft. +- [Clingo](https://potassco.org/clingo/) and [Clasp](https://potassco.org/clasp/) by the University of Potsdam, an answer set solving collection. +- [Hakank's OR-Tools tutorials](http://www.hakank.org/google_or_tools/) +- More... -## Reference +**Dataset and online play:** +- [Raetsel's Janko](https://www.janko.at/Raetsel/index.htm); +- [Puzzle-xx.com](https://www.puzzle-loop.com). +- [puzz.link](https://puzz.link), and variants like [pzplus](https://pzplus.tck.mn/list.html), [pvz.jp](http://pzv.jp/) +- [pzprjs](https://github.com/robx/pzprjs) format for puzzle encoding. +- More... -- [OR-tools Official](https://developers.google.cn/optimization?hl=zh-cn). -- [Hakank's ORtools tutorials](http://www.hakank.org/google_or_tools/). -- Puzzle data source: [Raetsel's Janko](https://www.janko.at/Raetsel/index.htm), [Puzzle](https://www.puzzle-loop.com). -- Related repos like [puzzle_solver](https://github.com/Ar-Kareem/puzzle_solver), [Puzzles-Solver](https://github.com/newtomsoft/Puzzles-Solver) and [Nikoli puzzle solver](https://github.com/kevinychen/nikoli-puzzle-solver). -- [puzz.link](https://puzz.link) and [pzprjs](https://github.com/robx/pzprjs). -- [Nonogram solver](https://rosettacode.org/wiki/Nonogram_solver#Python). \ No newline at end of file diff --git a/scripts/benchmark.py b/scripts/benchmark.py index 93b0a5fc..c54011dd 100644 --- a/scripts/benchmark.py +++ b/scripts/benchmark.py @@ -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 @@ -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 diff --git a/scripts/quick_start.py b/scripts/quick_start.py index ec1209f3..1125352d 100644 --- a/scripts/quick_start.py +++ b/scripts/quick_start.py @@ -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', [])) @@ -17,4 +27,4 @@ end_time = time.time() print(f"Time taken: {end_time - start_time} seconds") # Visualize (optional) -res.show() \ No newline at end of file +res.show() diff --git a/src/puzzlekit/solvers/heyawake.py b/src/puzzlekit/solvers/heyawake.py index 537ea77f..5079fd1d 100644 --- a/src/puzzlekit/solvers/heyawake.py +++ b/src/puzzlekit/solvers/heyawake.py @@ -7,10 +7,13 @@ from puzzlekit.utils.ortools_utils import ortools_cpsat_analytics from puzzlekit.utils.ortools_utils import add_connected_subgraph_constraint from ortools.sat.python import cp_model as cp +from collections import deque from typeguard import typechecked import copy import time +# A tailored solver for heyawake puzzle. 25% faster than original. + class HeyawakeSolver(PuzzleSolver): metadata : Dict[str, Any] = { "name": "heyawake", @@ -257,7 +260,6 @@ def dfs(current: Position, prev: Position) -> bool: if dfs(neighbor, current): return True elif neighbor != prev: - # 找到环:邻居已被访问但不是父节点 cycle_found = True cycle_start = neighbor cycle_end = current From 0016acd40f31764e5b23580ec187273d017de8ff Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sun, 15 Feb 2026 19:33:48 +0800 Subject: [PATCH 4/4] update for v0.3.2 --- README.CN.md | 12 ------------ mkdocs.yml | 14 +++++++++++--- pyproject.toml | 4 ++-- src/puzzlekit/__init__.py | 2 +- 4 files changed, 14 insertions(+), 18 deletions(-) delete mode 100644 README.CN.md diff --git a/README.CN.md b/README.CN.md deleted file mode 100644 index 9db92e72..00000000 --- a/README.CN.md +++ /dev/null @@ -1,12 +0,0 @@ -# 逻辑谜题求解器 - -本仓库为多种**逻辑谜题**提供了**可靠、高效且定制化的求解器**。底层求解引擎主要基于成熟的开源工具,如 ortools 和 z3. - -与其他依赖逻辑/演绎方法的求解器不同,本项目的求解器主要基于**约束编程**。虽然我非常钦佩那些能够找出纯逻辑解的人,但本项目**并非**旨在用自动求解取代人类推理:**我这么做纯粹是因为好玩**。 - -该仓库还包含一个结构化的数据集(超过 28,000 个实例),覆盖 80 多种特定且流行的谜题类型(如 Nonogram、Slitherlink、Akari、Fillomino、Hitori、Kakuro 等), - -更多数据及相关分析将随时间推移逐步添加。 - -近期(约 2025 年 11 月起),仓库进行了重构,采用了统一的 [网格](./Puzzles/Common/Board/Grid.py) 数据结构和解析-求解-验证流程。部分实现细节深受类似但更复杂的仓库启发,如:为90+种逻辑谜题提供求解接口的 [puzzle_solver](https://github.com/Ar-Kareem/puzzle_solver) 以及 一款能够实时操作解谜的 [Puzzles-Solver](https://github.com/newtomsoft/Puzzles-Solver)。 - diff --git a/mkdocs.yml b/mkdocs.yml index 3268a4d8..53b5b821 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 @@ -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 @@ -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 @@ -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 @@ -134,7 +144,6 @@ theme: - navigation.top - navigation.indexes - markdown_extensions: - admonition - meta @@ -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 diff --git a/pyproject.toml b/pyproject.toml index f3a87614..4f4a548e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"}] diff --git a/src/puzzlekit/__init__.py b/src/puzzlekit/__init__.py index 9535eba3..397e1d58 100644 --- a/src/puzzlekit/__init__.py +++ b/src/puzzlekit/__init__.py @@ -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' \ No newline at end of file +__version__ = '0.3.2' \ No newline at end of file