From a54a34784f39790b36ca768e8525536fa32b1564 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Fri, 27 Feb 2026 17:17:01 +0800 Subject: [PATCH 01/17] sync file --- src/puzzlekit/core/penpa.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/puzzlekit/core/penpa.py diff --git a/src/puzzlekit/core/penpa.py b/src/puzzlekit/core/penpa.py new file mode 100644 index 00000000..e69de29b From 6020420a381f7e7e6fe71071e99e8f3c182923d8 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Wed, 4 Mar 2026 22:02:12 +0800 Subject: [PATCH 02/17] update format --- .gitignore | 3 +- src/puzzlekit/formats/__init__.py | 0 src/puzzlekit/formats/base.py | 261 ++++++++++++++++++++ src/puzzlekit/formats/heyawake.py | 388 ++++++++++++++++++++++++++++++ 4 files changed, 651 insertions(+), 1 deletion(-) create mode 100644 src/puzzlekit/formats/__init__.py create mode 100644 src/puzzlekit/formats/base.py create mode 100644 src/puzzlekit/formats/heyawake.py diff --git a/.gitignore b/.gitignore index bae167aa..1feaec7e 100644 --- a/.gitignore +++ b/.gitignore @@ -154,4 +154,5 @@ cython_debug/ # Ignore dataset directory assets/ benchmark_results/ -docs/puzzles/*.md \ No newline at end of file +docs/puzzles/*.md +penpa_edit/ \ No newline at end of file diff --git a/src/puzzlekit/formats/__init__.py b/src/puzzlekit/formats/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py new file mode 100644 index 00000000..73504f29 --- /dev/null +++ b/src/puzzlekit/formats/base.py @@ -0,0 +1,261 @@ +from dataclasses import dataclass, field +from typing import Dict, Optional, Any, Tuple, List + +PENPA_MODE = { + # correspond to "mode" in penpa.js + "z9": "zA", + "zG": ["1", "2", "1"], + "zQ": { + "zM": "combi", + "zS": ["", 1], + "multicolor":["",1], + "zL": ["1", 2], + "zE": ["1", 2], + "zW": ["", 2], + "zC": ["1", 10], + "zN": ["1", 1], + "zY": ["circle_L", 1], + "zP": ["zT", ""], + "zB": ["", ""], + "move": ["1" , ""], + "combi": ["battleship" , 3], + "sudoku": ["1" , 1] + }, + "zA":{ + "zM" : "combi", + "zS" : ["",1], + "multicolor": ["",1], + "zL" : ["1" , 3], + "zE" : ["1" , 3], + "zW" : ["",3] , + "zC" : ["1" , 10], + "zN" : ["1" , 2], + "zY" : ["circle_L" , 1], + "zP" : ["zT" , ""], + "zB" : ["", ""], + "move" : ["1",""], + "combi":["blpo",3], + "sudoku":["1",9] + } +} + +# element 5: this.pu_{x}, e.g., this.pu_a, this.pu_q_col, this.a_col +PENPA_PU_X_DEFAULF = { + "zR": {"z_": []}, + "zU": {"z_": []}, + "z8": {"z_": []}, + "zS": {}, + "zN": {}, + "z1": {}, + "zY": {}, + "zF": {}, + "z2": {}, + "zT": [], + "z3": [], + "zD": [], + "z0": [], + "z5": [], + "zL": {}, + "zE": {}, + "zW": {}, + "zC": {}, + "z4": {}, + "z6": [], + "z7": [] +} + +# element 8: __export_solcheck_shared +PENPA_SOL_CHECK_DICT_DEFAULT = { + "sol_surface_exact": False, + "sol_surface": False, + "sol_number": False, + "sol_loopline_exact": False, + "sol_loopline": False, + "sol_ignoreloopline": False, + "sol_loopedge_exact": False, + "sol_loopedge": False, + "sol_ignoreborder": False, + "sol_wall": False, + "sol_square": False, + "sol_circle": False, + "sol_tri": False, + "sol_arrow": False, + "sol_math": False, + "sol_battleship": False, + "sol_tent": False, + "sol_star": False, + "sol_akari": False, + "sol_mine": False +} + + +# element 18: __export_checker_shared +PENPA_SOL_CHECK_OR_DICT_DEFAULT = { + "sol_or_surface_exact": False, + "sol_or_surface": False, + "sol_or_number": False, + "sol_or_loopline_exact": False, + "sol_or_loopline": False, + "sol_or_loopedge_exact": False, + "sol_or_loopedge": False, + "sol_or_wall": False, + "sol_or_square": False, + "sol_or_circle": False, + "sol_or_tri": False, + "sol_or_arrow": False, + "sol_or_math": False, + "sol_or_battleship": False, + "sol_or_tent": False, + "sol_or_star": False, + "sol_or_akari": False, + "sol_or_mine": False +} + +PENPA_BG_IMAGE_ENCRYPTED = "JYjBDkAwEAX/5Z33UNf+jDQUjdWV1Q0i/r0Nl8nMPDBl+GzZMhAveEe6PsochleadazZWJxlnF8ghf1CJhC8fan0sq8T9vBQ==" + + +@dataclass +class PenpaMetadata: + + # ========== Line 1: header ========== + grid_type: str = "square" + nx: int = 5 + ny: int = 5 + size: int = 35 # size of each cell on penpa + theta: int = 0 # for rotate + reflect: List[int] = field(default_factory=lambda: [1, 1]) + canvasx: int = 0 # canvas size x + canvasy: int = 0 # canvas size y + center_n: int = 0 # center cell + center_n0: int = 0 # center cell (?) + sudoku: List[int] = field(default_factory=lambda: [0, 0, 0, 0]) + title: str = "" # name of the puzzle e.g., heyawake, nonogram + author: str = "" # author of the puzzle, optional + source: str = "" # (source) url of the puzzle + rules: str = "" # rules of the puzzle + border_status: str = "OFF" # unknown + multisolution: bool = False # is multi solution ? + bg_image_encrypted: str = "" # (placeholder) for background picture + + # ========== Line 2: space ========== + space: List[int] = field(default_factory=lambda: [0, 0, 0, 0]) # [top, bottom, left, right] + + # ========== Line 3: mode ========== + mode: Dict[str, Any] = field(default_factory=dict) # complete mode + + # ========== Line 5: pu_a ========== + pu_a: Dict[str, Any] = field(default_factory=dict) + + # ========== Line 6-7: __export_list_tab_shared ========== + centerlist_diff: List[int] = field(default_factory=list) # diff encoding centerlist + tab_settings: List[str] = field(default_factory=lambda: ["Surface", "Composite"]) + + # ========== Line 8: sol_check ========== + sol_check: Dict[str, bool] = field(default_factory=dict) + + # ========== Line 9-14: version shared ========== + timer_placeholder: str = "x" # default 'x' + comp_mode: str = "x" # default 'x' + version: List[int] = field(default_factory=lambda: [3, 2, 1]) # v3.2.1, aha~ + mode_snapshot: Dict[str, Any] = field(default_factory=dict) # another snapshot of mode (sub mode?) + theme_placeholder: str = "x" # default 'x' + custom_colors_on: int = 0 # either 1 or 0 + + # ========== Line 15-16: pu_q_col / pu_a_col ========== + pu_q_col: Dict[str, Any] = field(default_factory=dict) + pu_a_col: Dict[str, Any] = field(default_factory=dict) + + # ========== Line 17: sol_check (OR) ========== + sol_check_or: Dict[str, bool] = field(default_factory=dict) + + # ========== Line 18: genre_tags ========== + genre_tags: List[str] = field(default_factory=list) + + # ========== Line 19: custom_message ========== + custom_message: str = "" + + def __post_init__(self): + """ + Auto fill default after init + """ + if not self.mode: self.mode = PENPA_MODE.copy() + + if not self.pu_a: self.pu_a = PENPA_PU_X_DEFAULF.copy() + + if not self.mode_snapshot: self.mode_snapshot = PENPA_MODE.copy() + + if not self.sol_check: self.sol_check = PENPA_SOL_CHECK_DICT_DEFAULT.copy() + + if not self.sol_check_or: self.sol_check_or = PENPA_SOL_CHECK_OR_DICT_DEFAULT.copy() + + if not self.bg_image_encrypted: self.bg_image_encrypted = PENPA_BG_IMAGE_ENCRYPTED + + if not self.pu_q_col: self.pu_q_col = PENPA_PU_X_DEFAULF.copy() + + if not self.pu_a_col: self.pu_a_col = PENPA_PU_X_DEFAULF.copy() + + +@dataclass +class CellState: + """ + Cell status + """ + value: Optional[str] = None # Number clue + shaded: bool = False # black? + num_color: int = 1 # number color + + +@dataclass +class EdgeState: + """Edge Status + + - edge_type: + 2: black border line + 13: dot line + ... + """ + connected: bool = True # thin line + edge_type: int = 2 # default = 2 + # blacked: bool = False # black border + # deleted: bool = False # delete mark + + +@dataclass +class PuzzleInstance: + """ + Puzzle Intermediate Representation (IR) + + Start from Heyawake! + """ + grid_type: str = "square" + puzzle_type: str = "heyawake" + title: str = "" + author: str = "" + source: str = "" + rows: int = 0 # for rectangle with margins + cols: int = 0 # for rectangle with margins + hex_len: int = 0 # placeholder + margins: List[int] = field(default_factory = list) # for margins, top, bottom, left, right + boxes: List[Any] = field(default_factory=list) # same as 'box' of penpa + edges: Dict[tuple[Any], EdgeState] = field(default_factory=dict) # edge status + cells: Dict[tuple[Any], CellState] = field(default_factory=dict) + regions: list[list[int]] = field(default_factory=list) # Heyawake 的房间区域 ID + metadata: Dict[str, Any] = field(default_factory=dict) + + + def __repr__(self): + """Custom format. + """ + return f""" + Puzzle Instance for {self.puzzle_type}. + + grid_type: {self.grid_type} + title: {self.title} + shape: {self.rows} x {self.cols} + margins: {', '.join(map(str, self.margins))} + author: {self.author} + source: {self.source} + edges (len): {len(self.edges.keys())} + boxes (len): {len(self.boxes)} + cells (len): {len(self.cells.keys())} + """ diff --git a/src/puzzlekit/formats/heyawake.py b/src/puzzlekit/formats/heyawake.py new file mode 100644 index 00000000..8e7fead0 --- /dev/null +++ b/src/puzzlekit/formats/heyawake.py @@ -0,0 +1,388 @@ +from puzzlekit.formats.base import ( + PuzzleInstance, CellState, EdgeState, + PenpaMetadata +) +from typing import Any, Dict, List, Optional, Tuple, Union +import json +from base64 import b64decode, b64encode +from functools import reduce +from zlib import compress, decompress + + +PENPA_URLPREFIX = "https://swaroopg92.github.io/penpa-edit/#" +PENPA_PREFIX = "m=edit&p=" +PENPA_ABBREVIATIONS = [ + ('"qa"', "z9"), + ('"pu_q"', "zQ"), + ('"pu_a"', "zA"), + ('"grid"', "zG"), + ('"edit_mode"', "zM"), + ('"surface"', "zS"), + ('"line"', "zL"), + ('"edge"', "zE"), + ('"wall"', "zW"), + ('"cage"', "zC"), + ('"number"', "zN"), + ('"sudoku"', "z1"), + ('"symbol"', "zY"), + ('"special"', "zP"), + ('"board"', "zB"), + ('"command_redo"', "zR"), + ('"command_undo"', "zU"), + ('"command_replay"', "z8"), + ('"freeline"', "zF"), + ('"freeedge"', "z2"), + ('"thermo"', "zT"), + ('"arrows"', "z3"), + ('"d"', "zD"), + ('"squareframe"', "z0"), + ('"polygon"', "z5"), + ('"deleteedge"', "z4"), + ('"killercages"', "z6"), + ('"nobulbthermo"', "z7"), + ('"__a"', "z_"), + ("null", "zO"), +] + +def generate_centerlist_diff(rows: int, cols: int, margins: List[int] = [0, 0, 0, 0]): + """ + Auto pack centerlist (in default all cells are filled) + + for part of `__export_list_tab_shared` in penpa+ + """ + prev, nx0 = 0, cols + 4 + centerlist = [] + top_m, bottom_m, left_m, right_m = margins + for row in range(2 + top_m, rows + 2 - bottom_m): + for col in range(2 + left_m, cols + 2 - right_m): + idx = col + row * nx0 + centerlist.append(idx - prev) + prev = idx + return centerlist + +def calculate_center_n(nx: int, ny: int, size: int = 38) -> int: + """ + Simulate search_center() logic of penpa+ + return center_n (point index) + """ + nx0, ny0 = nx + 4, ny + 4 # internal grid size + + # 1. centerlist (visible cell centers, type=0) + centerlist = [i + j * nx0 for j in range(2, ny0 - 2) for i in range(2, nx0 - 2)] + + # 2. Geometry center(based on cell center pixel coords) + coords = [((idx % nx0 + 0.5) * size, (idx // nx0 + 0.5) * size) for idx in centerlist] + xmin, xmax = min(c[0] for c in coords), max(c[0] for c in coords) + ymin, ymax = min(c[1] for c in coords), max(c[1] for c in coords) + geo_center = ((xmin + xmax) / 2, (ymin + ymax) / 2) + + # 3. search all point for nearest + min_dist = float('inf') + closest_idx = 0 + base = nx0 * ny0 # points per type + + # Type 0: Cell Centers + for j in range(ny0): + for i in range(nx0): + k = i + j * nx0 + x, y = (i + 0.5) * size, (j + 0.5) * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # Type 1: Vertices + for j in range(ny0): + for i in range(nx0): + k = base + i + j * nx0 + x = (i + 0.5) * size + 0.5 * size + y = (j + 0.5) * size + 0.5 * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # Type 2: H-Edge Mids (y direction offset) + for j in range(ny0): + for i in range(nx0): + k = 2*base + i + j * nx0 + x = (i + 0.5) * size + y = (j + 0.5) * size + 0.5 * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # Type 3: V-Edge Mids (x direction offset) + for j in range(ny0): + for i in range(nx0): + k = 3*base + i + j * nx0 + x = (i + 0.5) * size + 0.5 * size + y = (j + 0.5) * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # Type 4,5 omit + offsets_4 = [(-0.25, -0.25), (0.25, -0.25), (-0.25, 0.25), (0.25, 0.25)] + for j in range(ny0): + for i in range(nx0): + base_k = 4*base + 4*(i + j * nx0) + cx = (i + 0.5) * size + cy = (j + 0.5) * size + for subidx, (ox, oy) in enumerate(offsets_4): + k = base_k + subidx + x = cx + ox * size + y = cy + oy * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # ========== Type 5: Compass Points (r=0.3, 4 per cell) ========== + # Order: N, E, W, S (up, right, left, down) + offsets_5 = [(0, -0.3), (0.3, 0), (-0.3, 0), (0, 0.3)] + for j in range(ny0): + for i in range(nx0): + base_k = 8*base + 4*(i + j * nx0) + cx = (i + 0.5) * size + cy = (j + 0.5) * size + for subidx, (ox, oy) in enumerate(offsets_5): + k = base_k + subidx + x = cx + ox * size + y = cy + oy * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + return closest_idx + +class HeyawakePenpaConverter: + """Convert Penpa to PuzzleInstance + """ + def __init__(self, url: str): + self.url = url + self.ir_puzzle = PuzzleInstance( + metadata={ + "source": "penpa", + "original_url": self.url, + } + ) + + + + def index_to_coord(self, index: int, type_: str = 'edge') -> Tuple[Tuple[int, int], int]: + """Convert the [Penpa+](https://swaroopg92.github.io/penpa-edit/) index to coordinate. + + * In [Penpa+](https://swaroopg92.github.io/penpa-edit/), the coordination (with margins) and category of a cell is encoded as a single integer index. This function helps to convert the index back to the ((`row`, `col`), `category`) format. + + Args: + index: The [Penpa+](https://swaroopg92.github.io/penpa-edit/) index to be converted. + offset: To be compatiable with edge / cell index: + - edge := 0 + - cell := 1 + """ + assert type_ in ("edge", "cell"), f"Wrong index type for index_to_coord, expected 'cell', 'edge', get {type_}" + category, index = divmod(index, self.real_rows * self.real_cols) + if type_ == "edge": + return (index // self.real_cols - 1, index % self.real_cols - 1), category + else: + return (index // self.real_cols - 2, index % self.real_cols - 2), category + + def coord_to_index(self, coord: Tuple[int, int], type_: str) -> Tuple[int, int]: + assert type_ in ("edge", "cell"), f"Wrong index type for index_to_coord, expected 'cell', 'edge', get {type}" + r_, c_ = coord + if type_ == "edge": + return r_ * self.real_cols + c_ + self.real_cols * self.real_rows + else: + return r_ * self.real_cols + c_ + self.real_cols * 2 + 2 + + # def coord_to_index(self, coord: Tuple[int, int], category: int = 0) -> Tuple[int, int]: + # return (coord[0] + self.top_margin, coord[1] + self.left_margin) + + # def coord_to_index(self, coord: Tuple[int, int], category: int = 0) -> int: + # """Convert the coordinate to [Penpa+](https://swaroopg92.github.io/penpa-edit/) index. + # * In [Penpa+](https://swaroopg92.github.io/penpa-edit/), the coordination (with margins) and category of a cell is encoded as a single integer index. This function helps to convert the ((`row`, `col`), `category`) format back to the index. + # Args: + # coord: The coordination to be converted. + # category: The category code of the direction (default is 0). + # """ + # return (category * self.real_rows * self.real_cols) + (coord[0] + 1 + self.top_margin) * self.real_cols + coord[1] + 1 + self.left_margin + + def decode(self) -> PuzzleInstance: + self.parts = decompress(b64decode(self.url[len(PENPA_PREFIX) :]), -15).decode().split("\n") + header = self.parts[0].split(",") + print(self.parts[6]) + assert header[0] in ("square", "sudoku", "kakuro"), "Penpa puzzle must be in square, sudoku, kakuro" + + # info collect + self.ir_puzzle.grid_type = "square" + self.ir_puzzle.title = header[15][len("Title: "):] + self.ir_puzzle.author = header[16][len("Author: "):] + self.ir_puzzle.source = header[17] + + self.margin = json.loads(self.parts[1]) + self.top_margin, self.bottom_margin, self.left_margin, self.right_margin = self.margin + self.rows = int(header[2]) - self.top_margin - self.bottom_margin # net penpa size, no margin + self.cols = int(header[1]) - self.left_margin - self.right_margin + + + self.real_rows = self.rows + self.top_margin + self.bottom_margin + 4 # penpa size after padding + self.real_cols = self.cols + self.left_margin + self.right_margin + 4 + self.new_rows = self.rows + self.top_margin + self.bottom_margin + self.new_cols = self.cols + self.left_margin + self.right_margin # PuzzleInstance new size, no padding, with margin + + self.ir_puzzle.rows, self.ir_puzzle.cols = self.new_rows, self.new_cols + + print(f"Puzzle shape (r, c) = {(self.new_rows, self.new_cols)}", ) + + for p in range(len(self.parts)): + if p == 1: + # decode margins + margins = json.loads(self.parts[p]) + self.ir_puzzle.margins = margins + elif p == 3: + # decode from pu_q + self.board = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), PENPA_ABBREVIATIONS, self.parts[p])) + for k, v in self.board.items(): + if k == "edge": + # decode edge + print(k, end = " ") + self._decode_edge(edge_dict = v) + print(self.ir_puzzle.edges) + print([(x, y) for x,y in v.items()]) + elif k == "number": + # decode number + print(k, end = " ") + self._decode_number(number_dict = v) + print(v) + print(self.ir_puzzle.cells) + else: + # print(k, v) + pass + elif p == 5: + # decode box + boxes = json.loads(self.parts[p]) + self.ir_puzzle.boxes = boxes + + # for k, v in self.ir_puzzle.cells.items(): + # print(k, v) + # print(self.ir_puzzle.boxes, type(self.ir_puzzle.boxes[0])) + # print(self.ir_puzzle.margins, type(self.ir_puzzle.margins[0])) + print(self.ir_puzzle) + + check_diff = json.loads(self.parts[5]) + new_diff = generate_centerlist_diff( + self.ir_puzzle.rows, + self.ir_puzzle.cols, + json.loads(self.parts[1]) + ) + + assert ",".join(map(str, check_diff)) == ",".join(map(str, new_diff)), "Diff list not matched!!" + + return self.ir_puzzle + + # def _pad_margin_edge(self): + # # temporal, to fix border + # for r in range(self.new_rows): + # self.ir_puzzle.edges[((r, 0), (r + 1, 0))] = EdgeState(connected = True, edge_type = 2) + # self.ir_puzzle.edges[((r, self.new_cols), (r + 1, self.new_cols))] = EdgeState(connected = True, edge_type = 2) + # for c in range(self.new_cols): + # self.ir_puzzle.edges[((0, c), (0, c + 1))] = EdgeState(connected = True, edge_type = 2) + # self.ir_puzzle.edges[((self.new_rows, c), (self.new_rows, c + 1))] = EdgeState(connected = True, edge_type = 2) + + def _decode_number(self, number_dict: Dict[str, int]): + # ['4', 1, '1']: number, color, submode + # lots to do here. for diff number format + new_number_dict = dict() + for index, num_data in number_dict.items(): + if num_data[2] == "1": + # only one scenario! + (r, c), _ = self.index_to_coord(int(index), 'cell') + new_number_dict[(r, c)] = CellState(value = num_data[0], num_color = num_data[1]) + self.ir_puzzle.cells = new_number_dict + + def _decode_edge(self, edge_dict: Dict[str, int]): + new_edge_dict = {} + for index, v_ in edge_dict.items(): + if "," in index: + index_1, index_2 = map(int, index.split(",")) + coord_1, _ = self.index_to_coord(index_1, 'edge') + coord_2, _ = self.index_to_coord(index_2, 'edge') + new_edge_dict[(coord_1, coord_2)] = EdgeState(connected = True, edge_type = 2) + self.ir_puzzle.edges = new_edge_dict + + def _encode_number(self, number_dict: Dict[str, CellState]): + new_number_dict = dict() + for coords, v_ in number_dict.items(): + index = f"{self.coord_to_index(coords, 'cell')}" + print(index, coords) + pass + pass + + def _encode_edge(self): + pass + + def encode(self, inst: PuzzleInstance) -> str: + """Forge the Penpa+ format url. + + Args: + instance (PuzzleInstance): Input PuzzleInstance + + Returns: + str: url (penpa+ format) + """ + # Totally 19 lines ... + text = "" # to merge into a long plain text + mtd = PenpaMetadata() + center_n = calculate_center_n(inst.rows, inst.cols, mtd.size) + center_list = generate_centerlist_diff(inst.rows, inst.cols) + self._encode_number(inst.cells) + # NOTE: currently the theta, reflect (1,1), sudoku (0,0,0,0) param are hard-coded. + # NOTE: currently center_n and center_n0 are treated as same + + text_list = [ + [inst.grid_type, inst.rows, inst.cols, mtd.size, 0, 1, 1, (inst.rows + 1) * mtd.size, (inst.cols + 1) * mtd.size, center_n, center_n, 0, 0, 0, 0], + inst.margins, + mtd.mode, + + mtd.pu_a, + center_list, + [], # todo here, better set according to puzzle type, limiting user input + mtd.sol_check, + "x", + "x", + [3, 2, 1], # default version + mtd.mode_snapshot, + "x", + "0", + mtd.pu_q_col, + mtd.pu_a_col, + mtd.sol_check_or, + [], # selected genre tags, default empty + "" # msg, default empty + ] + + + pass + +class HeyawakePuzzlinkConverter: + def decode(self, url: str) -> PuzzleInstance: + pass + def encode(self, instance: PuzzleInstance) -> str: + pass + + +if __name__ == "__main__": + + for test_url in [ + # "m=edit&p=7ZbPbhs3HITveoqAZx52Se7fS+Gmdi+u09YugkAQDFnZ1EZsKJWtpl3D756P5LAC2gBpUTS9BCtRI2o4/HE4S+39L/v1brK1iy/f28rWXGEI6e27Jr0rXRc3D7fT+Mwe7R+utzuAtS9OTuyb9e39tFiKtVo8zsM4H9n523FpamON412blZ1/GB/n78b52M7n/GRsT99pJjng8QG+TL9H9Dx31hX4TBj4Cri52W1up8vT3PP9uJwvrInzfJ1GR2jutr9ORnXE75vt3dVN7LhaP7CY++ubd/rlfv96+3Yvbr16svNRLvf8I+X6Q7kR5nIj+ki5cRX/cbnD6ukJ23+k4MtxGWv/6QD7AzwfH2nPxkcTmjj0K2pJe0NvnX57ldqT1LrUXjDUzj6136S2Sm2T2tPEOUbRdcG6oTKjY8cJjRtq4Q7shXtwyLivwK1wDe6EPXgQJoSVNIcKLP5Qg8UfPFj8GNq68FuwE+7AqmEYwCwfjDY4a6Jtvct8tMGZjzZY/Bq+Ez/eMa4XpgaXa0Dbep/XjjZYmg6+F9/B9+I7+EF8Bz8Ufg/OXqENVg2etYe8drTB0uT29Y34Hn4jfoDfiB/gN+IHvGqzV2iDVUNg7a3WHtBspdnEA0H8Jh4M4jfwO/Eb+J34LV518qqlhk41tKy909pbNHtpdvB78Tv4vfgd/F58MuaVsXQ4KWOeXHnlCm2w1k7GvDKGtg2VvO07sGruB7D4ZCwoY2iDVUM8EJUrtMGal4wFZQxtcPYWbXCuGW0bXOajDS58alDGQtWDc/3MA841MA8418A8YOmTn6D8MM6GkGtjHFj6Dv0gfbIUlCXG2aBsMA6suchGUDYYB5Y+OQnKCeNs0L4zDqy52PegfWccWPpkIJQMxL1Tf/5jKf1kvmSDs4K9POypcpK8qoq3zKX7nc8/9iXUcIr/NZzifw3HFX+it6rfRW+LV9FbeeWjt8Ur1u61Fs/avbzyrN1rXzzz6r7m87AvIfpcfIs+F9+Ytym+Rc81bxM9Lx4yb9ojDteX6Yh9ntqQ2jYdvV080//mqc+dbMbemlRD/gv490f+J2tbYl98nvjr1Xzpj9dqsTTn+92b9Wbib/349c/Ts7Pt7m59y7ez/d3VtCvfeap6WpjfTHovfXxI+/Kg9T89aMUtqP7R49ZnuNc+Uc4Sd7kb5xfWvNtfri83WzKGd7GfA+nP/Z+9eg4Lcz39vn6/fjuZ1eID", + # "m=edit&p=7VbvT+M4EP3evwLl61q3cZw0P6T9UErZEwKOHnA9qCpk2pQG0oZNWuCC+N/3je20TVv27nQ6iZNObezJ83jmzdgep/i2kHnMuE1/ETD0+Lk8UI8TNNVjm99FMk/jaO/n+A/5LB9i1lrMJ1ke7b0kMqOHsXYqiyIZLlX28kUa78nHxzSJi5/YL4eHbCzTImZHV/f7Bw+t507r98/etRCXp+NP9wfdy/tR7zfetZPPuX2aBrOTs4P99NPX8vpk0nqKO3HzrMiGkzSWI1le945e0tlhcDcZ8/bRpB2M5cwuvgUX4dN+98uXRt/QHjReyzAqu6z8GvUtbjHLwcOtASu70Wt5EpUdVp5jyGJ8wKzpIp0nwyzNcqvCymM90YHYWYk9NU5SW4PchnxqZIhXEIdJPkzjm2ONnEX98oJZ5HtfzSbRmmZPMTkjbvQ+zKa3CQG3co6MF5Pk0WICA8VilD0sjCofvLGypSM4/4sRwEgVAYk6ApJ2RECB/bsRhIO3NyzOr4jhJupTOJcrMViJ59Er2tPo1RI2TXVBRa+g5TkENFeAr4A1Dc7dKmFLxNtCfEKwL5aIpzwtdeCfKxZXqj1UraPaC5BkpVDtgWpt1XqqPVY6HXB3PMEcD2Qc7EDPhQwaSvYgN42ME+eBDMku6RvZCRnetcyh74RG32eOz7Xsc5xWYzOATmh0wpAJrueiZ8LRNtEz4eq56Jmo+IQ01/AJwSc0HEL4CgMjozos7TdhH+lS9lFGHB0jeiZEpQ9f3Pji8OVoX+iho+2gBwcTI+wLjqVU+g70NR/04Gx8ufBV5ZODmzB5EMiDa2JxKbdVrpBD39j3kdvAxBWAm2242eBG20P5BTeTZ/Twa3KIdRHVunDYFMamgE23WjvwaRo+TfDxDR8ffALDJ4BN29i0YZM2ofILPiYW9PBr+CAWUcVCvoSJXWAvCaND+sLkTSBvhhvWAXMJx2bsqS3ZVq2r2qbaqj6dtr91Hv/5qfhTOn2h76T6z/vvYYNGHzeRVWTpTbHIx3IY38Qvcji3In0jro/UsNliehujlK9BaZbhRp3tslAN1cDkbpbl8c4hAuPR3XumaGiHqdssH21wepZpWo9FfVvUIH2V1KB5jnti7V3mefZcQ6ZyPqkBa3dKzVI820jmXNYpyge54W26Ssdbw3qx1NPH2aL1+v+z4QN/NtBC2R+tWH00OmqPZ/kPCs5qcBPeUXaA/qDyrI3uwt8pMmujm/hWRSGy20UF6I66AnSztADari4AtwoMsHdqDFndLDPEarPSkKutYkOu1utNf9D4Dg==", + # "m=edit&p=7Vjfb9u2E3/PX0EIKLABaiKRlC15T1nafL8PXdY1HYoiCApaVm0tsuTqR704yP/ez1E6Wk5S9KEYtofCtnx3JI+fu/uQJt186kyd+aGktw78wA/ximRgP2oa2Q/Z6fU2b4tsJv6f3ZqtucnET967vCyzWuSNeF913s/+adeuqnomXtemMWVpxGXWrMyizv1V226a2cnJdrs9Xq433W5XZM1xWq1P5kW1PJGBlCeBPFkNvp/Pb59vBifPm8HJiX/ZmnJh6sUeQ93Bz0xcokMmmmqdiTQrikbMC5PewCDalWmFKQpRZ2uTl3m5FNtV3nI/hC/SClGkbbYQphEbU7ei+iiMaNC3GLcu66rb/CIIDLR+fAqEVUs2ODKlyBbL7FhcVKIr53V1k5WiyT51WZlm5HQ8c16i/62oq62oasxSdOuS3Im0rppGtFvCniOIedVR0DnyJU5F2a3nlHKMRkjLvCohL/LUtBkGrTLugNkOgGKATUU/5li8sd+N2ObtSpQVD1ubW7EynymW26+4OhZn1mM/0naxeRBzVKDvV33O6uNn8gXe5wjOdG21Nm2eoiBF1xLmdJWlN0jwM3lmUR9gXXdNS97Wpr6BEdD7cpKrvidhWtbm9lj4v5+f+x9N0WRHVwNRr4/udslsd+rv/je78kLP9yQ+oXft7/6Y3e1+m+0u/N0lmjw/hO1V30lCfDmIZH5nO5D1rLeGAeSLQYb4HmKa12mRfXjVd3w9u9q99T2a6Fc7mkRvjWx4AxDSQfp5Toa5abGgmlW+GVqablHddEPf8Pre3532eC8Zb7zHq/Z4SezhkvQEXAL33XBvzV95kZdPYU2u7++R9DdA+2F2RcD/3IuXszs8L2Z3ntLUH8UI+3J4SUwGtTeEgSTLZGyZPOwjFVmCkWViR0Vjix2lR5Zp8NASWz+UKmsByNBCfW+f5/Yp7fMtIvF3yj5f2Gdgn5F9vrJ9XiJAOVW+nCJKCZcQoESsRFAAqlcmUKasTKEgEb0SQ0lYSXwZA7ZV4gBKyEoIBUH3ioSCWHoFCGJGEANBzAhi7ObB4ACCr8IBAQRfqcEBBF/pAQEEX00GBxB8xQgg+CphB8nU1+HgAIKv5eAAgq/14ACCryeDAwi+5hxoJGSkoBunSiOJMLAC15xePcVP1pQnnZK3ISF6GkBx8wAB1b5XYiBgbBOgngyJ1xMgmDACRAoDo4YDzoFGdmDgSOGA86aRURg4O3Dgcg0EMAxKhPQyAqWQ+IgTjwcMXB/kWrpqw0HgiEQFHjtQQwgQoLgWjNFD2BCgMByNefSQKiUxj2sJMEbyPAmABo6J4GjCQMlBxEC1hAPXQtwZ6gMBCocNHuwVMAQGngchMHcsrx2rQBelhypAgOJaMEZz2Bph7wmLMUwxCDi/8BiEoFwICE5zcBBQOWZICO6EzJ2QasqsoiOSI3kAIu0dgAcBEykAkQImEsoIAyugMpdRB4SAqRzQ6WtMJNoGeyIBG49RKD0MTCTE4+hP8zBdIICWjAAM0UwxCCP6w8F+/aCmMLADeGNS2OzsSQEicTwQUEZHJPCANua+BdhYkQmqwAmBFS3MECTRKTKBA06iTMAdLgkpMnEIsI8mvI8mQMD1oRbFlYMA125SAuoUwsbcCTGPcvMATujgYCNOHEfB3tCxF/MonkfRouUchJjHKQFSFbpUUd54DLYnFXNLBAS8PVklcjsFnb7dGMwTu90FIcQcArbBvRIBAW+DKqLN27lGcJHjDhTeRyFgYXA3KnDCk1LlEvZG3Xgj7m8GHAJ+s1TC3iaAw79mVpkwKSa087lu8MY/bQq/cyOFsDFQauHSQ4DCiwk11byLQQB7eQEiOM2RQgDjeW1jR9orEt14r9KgpWYmalAZhpHCOywEuOZuKJbmYkGA4n4xsIc4RaIbb8Q6hAM6BfXe0KLcDws54DVHinIO4K1XcMp4Z88aZ/ap7XNizyBT+4z51PX105jrMjqYff/J5xvI7o+uECzdLR+/oh92el0fXXmXXf3RpBlO2GfVelM1uCx6uMx4uDV9aIa2WVt3GY7fMPV3L29mbz+9qaiqDc7o6DYy5suyqrMnm8hIV9Yn+s+revHA+xb36AND///Bgam/YhyY2hr3h5Fualx6Dyy4HK4ODKOr0YGnrGwPAbTmEKK5wU350Pc+5vsj72/Pfvqr4o+L4r9yUaQCBN++Lv7ju9J/eb/sF31V79f9iNIwP7H2YX1yjQ/2R8sc9kcLmiZ8vKZhfWJZw/pwZcP0eHHD+Gh9w/aVJU5eH65yQvVwodNUj9Y6TTVe7lfXR1b6Ag==", + # "m=edit&p=7Vbvbts2EP+epyAEFGgBxbZkO070LUvrYkCXbU22ojCMgpZpi7BEeiQVJwoC9B32dXu5Pkl/R8m1FWfDMGBoPwyyT3c/Hu8vj7b9reRGhCM8J8OwF0Z4hnHPf0/8Z/tcS5eLhF2t7mxq+FoYy56PeW7Fi/C8dJk2CXttuHJsLFfChplza5t0u5vNprMs1mVV5cJ2Ul10Z7leduNePOhGva7dmTtekLXj2d3xkuwcL8hON/yVG8md1IrpRcv72xIWE/a9ssI4xtlcLqVjC6MLFjGn2SWTCi/B04ylIs8hMpcJ4LM7kKWRc2Y1IO6Y0s12I9aCO0u6XN0xozdMG5bqvCxUh53nVj+LL2qbqixmwmytenPYbYQVCgYIy4RcZo7i5mxWynwu1RJm534xzUthGdIiQZfOyjnei50xqeYy5Q5mEERB0WxtWJZyxWaCffr4hxVCffr4J9tkQrFc6xX58EXwic2lESlVr8OueZ4j3p0RNCJdeX83UiDPBbPFI50OGyN9ccuLdS4oc0m5UFVSrRyXStTJ1LWwLBr2BzHpAURmG73nDueMUbTb6ATLxcIhh9/RL6rK0PN1fYwQe1u/7DC+oKTmvQyI7LZupMvqcoIcZIpQ0Xj3uAroH8zj3Na1LzrP4pf4nOPAiFsnjKT++15RAv6Mepc202UOk4IJuIVJrdAqxEdsLeV6AwF9qJt8w2GGLeWNUI0TKi4vnS5wwFMcxrz0Bz3NREp9bAr5pbrPpUIk/hSl2lBjkVM7yBesKK3zUSngWEd+S1RTkbEZVOgNbSx1wh/H49BndDShwcczPbqvzpLqPKxeJ5MgCsIgxjcKpmH1c3Jf/ZBUl2F1haUAumH1plbqg31VszHB77wCoRe1ag/sJdizGn0PNpUmzcWHN9gC5KdkUl2HAfn5zm8hNij0jQiaOEjG9TGTBMy4w21kM7luVmw516uy0Y2mD2F17sPdLvx90MR+5ZjPpg8PqP1bRP0hmVACv+zY0x17ldyDXnoaJfdB1Ds5JRNDHyXEUUxifyueDvZXozOvPGjEOI5IRIu92O8Pm2hqcTDaN9Uf7pTh/L0PYexp7Ok1IgyrvqcvPe15OvT0jdd55ek7Ty88HXh64nVGlOO/qMJ/Gs6kf1LPBp7RP+OmR5PgyneXXWqDSxUdb+QLbZQwezLm1IgAcxdg/j/Y0ix4igPkxxJnBFg9/kHiTNkguOfXOS7flppcKm3Ek0sEivnyKf2ZNnMyvrewwRXZAuo/CS2onoQW5AyO+Z7MDX4nWgiuuawF7I1EyxJq0g7A8XaIfIV/BW3bu5wfjoLbwH8nffxxGf1/p33NO4360PvWZvpbC8cfYW2eHH/A2xugjT456g1+MO3AD+aaHB6ONtAnphvo4wEHdDjjAA/GHNhfTDpZfTzsFNXjeSdXByNPrvanfjI98txn", + "m=edit&p=7VffT+NGEH7nr6j29VaNd/3rh3SqQoCTEEehQCmJIuQkThxw7JztADLif79vdh3shHBVe33goXK8+81MPPPN7O44Kb6twjziNhcmt21ucIHLNA3uurgd+hj1dTkvkyj4hXdXZZzlAHFZLoug01muqn7V/zWZp/ed5W9plmazPFx0rI4wOkKasWU7sev5cTgap2ImZ+bMwi1nYsb570dHfBomRcSPb+72D+67j4fdvzp23zSvTqef7g7Or+4m13+Kc2PeyY3TxEu/nh3sJ5++VP2vcfchOoycsyIbx0kUTsKqf338lKRH3iyeit5x3POmYWoU37xL/2H//PPnvYGpEjSGe8+VH1RdXn0JBkwwziRuwYa8Og+eq68BG2eL0Zzx6gJ2xsWQs8UqKefjLMlyttZVJ/ppCXjYwGtlJ9TTSmEAn9YY8AZwPM/HSXR7ojVnwaC65IwI7KunCbJF9hBRMCJIsiYFxSgssRRFPF8ybsJQrCbZ/ar+qhi+8Kr7L9KAp3UaBHUahHakQdn9fBrJMtuRgD98ecEC/YEUboMBZXPVQK+BF8EzxtPgmUmBR02srFpDJiVEpxFNiH4jWhCFfJVNA3JLJF84DmuRfHmNSL4EnZFa9kl+FS3yZTUi+XIbkXwJ2oFadhWTV9Ej3w0Rn77dJCUMctb4FoJiteyCqDSPC+m1vKNQQpXrRo1HapRqvEQ1eWWq8UCNhhptNZ6o7xyiyMJBcBccJTy6COyBIGEPQX2QI+wLLg0QA8YMDFIK+1wKECIsPC6lq7F0uTQdjU2HS8vW2LK5tFEewrbFpaPjYubS1XExc+nVcR0skat9YgY37RMzuGmfmMGn9mnAp6h9ovNJ2jKKD3zSDlB8kAstqOKDXKw6Fwu52HUuNnJx6lwc5OLWuaBtSm8dFyveqo+ghVWY6lbX00c9fc0N8ytnaYIz1QccdYFMBNMCVQ4stAAatBW1AH7EWwkeiNdFwgxcJ4pFk7ThFEYBvLowHgqjiGPVr9Xa99RoqdFRe8Kl8/ePTujPb7+/pTOw6JTSRSf/P5iHewN2scqn4ThC4+pli2VWzMuI4eXBiiy5LbTtNnoKxyUL9EusbdnQpavFKELPbamSLFvinbnLw9q0oZzP0iyPdppIGU1m77ki0w5XoyyfbHF6DJNkMxf1+2BDpXv+hqrM0dBbcpjn2eOGZhGW8Yai9Q7b8BSlW8Usw02K4X24FW3RlONljz0xdaM14nz9/6b/8G96Wizjo3WTj0ZH7fMs/0HTaYzb6h2tB9ofdJ+WdZf+nUbTsm7r33QVIvu2sUC7o7dAu91eoHrbYaB802Sge6fPkNftVkOstrsNhXrTcChUu+cM2PpPDxvufQc=", + "m=edit&p=7VZtT+M4EP7eX4Hyda27OM67dB9Kl+7LQbcsIJZWFQolQCAlXF4KG8R/32dsp23asnen00mcdEpjTx+PZ54Z2+MUf1RRHjNu0k/4DD0em/vytXxXvqZ+jpMyjcOdj/H36DG6i1m3Km+yPNx5SqKMXsZ6aVQUyXShspNXabwTPTykSVz8wr70++wqSouYfT672e9l3cf33W9zvxyN+Aez+mSe3vZv332d/f4pETnvD/zhwfAgsa67H3u7h+7eO3dYFSdlPD+c8d3bk9Hx1fD0OrC+7w1Gdj36YjqfR1e/zrsnv3XGmvGk81wHYX3I6g/h2OAGMyy83Jiw+jB8rg/CesDqIwwZjE+YMavSMplmaZYbDVbvq4kWxL2leCrHSeopkJuQB1qGeAZxmuTTND7fV8gwHNfHzCDfu3I2icYsm8fkjLjR/2k2u0gIuIhKJLu4SR4MJjBQVJfZXaVV+eSF1V0VwdFfjABGmghIVBGQtCUCCuzfjSCYvLxgcb4ihvNwTOGcLEV/KR6Fz2gH4bMhTJpqg4paQcOxCHCXgCeBFQ3O7SZhC8TZQDxCsC8WiCM9LXTgn0sWZ7Lty9aS7TFIslrI9r1sTdk6st2XOnvgbjmCWQ7IWNiBjg0ZNKTsQHa1jMPmgAzJNulr2QoY/iuZQ98KtL7HLI8r2eM4qNqmD51A6wQBE1zNRc+EpWyiZ8JWc9Ez0fAJaK7mE4BPoDkE8BX4WkZhWNh3YR/pkvZRQSwVI3omhNJHD/tYGpId8qVksim45sCBW4oDesxV9tFDX8cOv4JrO9yCvuKJHrFoDjY4NHnm4Cx0fgTyY+sYbcp5k0Pk1tP2PeTc1/H64GZqbia40baRfsFN5x89/OrcYr1Es14cNoW2KWDTbtYUfFzNxwUfT/PxwMfXfHzYNLVNEzZpc0q/4KNjQQ+/mg9iEU0s5Evo2AX2mNA6pC903gTyJrlhY57K7dmTrS1bV25bj07e3zqb//yE/CmdsVBXU/tx/nvYpDPGrWQUWXpeVPlVNI3P46doWhqhuhhXR1rYfTW7iFHWV6A0y3Cx3m+z0Ay1wOT6PsvjrUMExpfXr5mioS2mLrL8co3TY5Sm7VjkJ0YLUtdKCypz3Bkr/6M8zx5byCwqb1rAyv3SshTfryWzjNoUo7tozdtsmY6XjvFkyHeM80Tr9f8nxBv+hKCFMt9asXprdOQez/KfFJzl4Dq8pewA/UnlWRndhr9SZFZG1/GNikJkN4sK0C11Beh6aQG0WV0AbhQYYK/UGLK6XmaI1XqlIVcbxYZcrdab8aTzAw==" + ]: + hpc = HeyawakePenpaConverter(url = test_url) + tmp = hpc.decode() + hpc.encode(tmp) + + + + # print(calculate_center_n(7, 7, 65)) \ No newline at end of file From 01131c6369d0bec095307b1c69e318b8d3649a95 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Fri, 6 Mar 2026 01:23:56 +0800 Subject: [PATCH 03/17] update converter --- src/puzzlekit/core/penpa.py | 0 src/puzzlekit/formats/base.py | 134 +++++----- src/puzzlekit/formats/heyawake.py | 260 +++++++++----------- src/puzzlekit/formats/puzzlink_converter.py | 100 ++++++++ 4 files changed, 279 insertions(+), 215 deletions(-) delete mode 100644 src/puzzlekit/core/penpa.py create mode 100644 src/puzzlekit/formats/puzzlink_converter.py diff --git a/src/puzzlekit/core/penpa.py b/src/puzzlekit/core/penpa.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py index 73504f29..c49344c5 100644 --- a/src/puzzlekit/formats/base.py +++ b/src/puzzlekit/formats/base.py @@ -1,70 +1,46 @@ from dataclasses import dataclass, field from typing import Dict, Optional, Any, Tuple, List +from functools import reduce +import json -PENPA_MODE = { - # correspond to "mode" in penpa.js - "z9": "zA", - "zG": ["1", "2", "1"], - "zQ": { - "zM": "combi", - "zS": ["", 1], - "multicolor":["",1], - "zL": ["1", 2], - "zE": ["1", 2], - "zW": ["", 2], - "zC": ["1", 10], - "zN": ["1", 1], - "zY": ["circle_L", 1], - "zP": ["zT", ""], - "zB": ["", ""], - "move": ["1" , ""], - "combi": ["battleship" , 3], - "sudoku": ["1" , 1] - }, - "zA":{ - "zM" : "combi", - "zS" : ["",1], - "multicolor": ["",1], - "zL" : ["1" , 3], - "zE" : ["1" , 3], - "zW" : ["",3] , - "zC" : ["1" , 10], - "zN" : ["1" , 2], - "zY" : ["circle_L" , 1], - "zP" : ["zT" , ""], - "zB" : ["", ""], - "move" : ["1",""], - "combi":["blpo",3], - "sudoku":["1",9] - } -} - -# element 5: this.pu_{x}, e.g., this.pu_a, this.pu_q_col, this.a_col -PENPA_PU_X_DEFAULF = { - "zR": {"z_": []}, - "zU": {"z_": []}, - "z8": {"z_": []}, - "zS": {}, - "zN": {}, - "z1": {}, - "zY": {}, - "zF": {}, - "z2": {}, - "zT": [], - "z3": [], - "zD": [], - "z0": [], - "z5": [], - "zL": {}, - "zE": {}, - "zW": {}, - "zC": {}, - "z4": {}, - "z6": [], - "z7": [] -} +PENPA_MODE = '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}' +PENPA_PU_X_STR = '{zR:{z_:[]},zU:{z_:[]},z8:{z_:[]},zS:{},zN:{},z1:{},zY:{},zF:{},z2:{},zT:[],z3:[],zD:[],z0:[],z5:[],zL:{},zE:{},zW:{},zC:{},z4:{},z6:[],z7:[]}' +COMPRESS_SUB = [ + ('z', 'zZ'), + ('"qa"', 'z9'), + ('"pu_q"', 'zQ'), + ('"pu_a"', 'zA'), + ('"grid"', 'zG'), + ('"edit_mode"', 'zM'), + ('"surface"', 'zS'), + ('"line"', 'zL'), + ('"lineE"', 'zE'), + ('"wall"', 'zW'), + ('"cage"', 'zC'), + ('"number"', 'zN'), + ('"symbol"', 'zY'), + ('"special"', 'zP'), + ('"board"', 'zB'), + ('"command_redo"', 'zR'), + ('"command_undo"', 'zU'), + ('"command_replay"', 'z8'), + ('"numberS"', 'z1'), + ('"freeline"', 'zF'), + ('"freelineE"', 'z2'), + ('"thermo"', 'zT'), + ('"arrows"', 'z3'), + ('"direction"', 'zD'), + ('"squareframe"', 'z0'), + ('"polygon"', 'z5'), + ('"deletelineE"', 'z4'), + ('"killercages"', 'z6'), + ('"nobulbthermo"', 'z7'), + ('"_a"', 'z_'), + ('null', 'zO'), +] # element 8: __export_solcheck_shared + PENPA_SOL_CHECK_DICT_DEFAULT = { "sol_surface_exact": False, "sol_surface": False, @@ -88,6 +64,8 @@ "sol_mine": False } +PENPA_PU_X_DEFAULT = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, PENPA_PU_X_STR)) +PENPA_MODE_DEFAULT = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, PENPA_MODE)) # element 18: __export_checker_shared PENPA_SOL_CHECK_OR_DICT_DEFAULT = { @@ -121,7 +99,7 @@ class PenpaMetadata: grid_type: str = "square" nx: int = 5 ny: int = 5 - size: int = 35 # size of each cell on penpa + size: int = 38 # size of each cell on penpa theta: int = 0 # for rotate reflect: List[int] = field(default_factory=lambda: [1, 1]) canvasx: int = 0 # canvas size x @@ -141,25 +119,28 @@ class PenpaMetadata: space: List[int] = field(default_factory=lambda: [0, 0, 0, 0]) # [top, bottom, left, right] # ========== Line 3: mode ========== - mode: Dict[str, Any] = field(default_factory=dict) # complete mode + mode: Dict[str, Any] = field(default_factory=dict) + + # ========== Line 4: pu_q ========== + pu_q: Dict[str, Any] = field(default_factory=dict) # ========== Line 5: pu_a ========== pu_a: Dict[str, Any] = field(default_factory=dict) # ========== Line 6-7: __export_list_tab_shared ========== centerlist_diff: List[int] = field(default_factory=list) # diff encoding centerlist - tab_settings: List[str] = field(default_factory=lambda: ["Surface", "Composite"]) + tab_settings: List[str] = field(default_factory=lambda: []) # ========== Line 8: sol_check ========== sol_check: Dict[str, bool] = field(default_factory=dict) # ========== Line 9-14: version shared ========== - timer_placeholder: str = "x" # default 'x' - comp_mode: str = "x" # default 'x' + timer_placeholder: str = '"x"' # default 'x' + comp_mode: str = '"x"' # default 'x' version: List[int] = field(default_factory=lambda: [3, 2, 1]) # v3.2.1, aha~ - mode_snapshot: Dict[str, Any] = field(default_factory=dict) # another snapshot of mode (sub mode?) - theme_placeholder: str = "x" # default 'x' - custom_colors_on: int = 0 # either 1 or 0 + mode_snapshot: Dict[str, Any] = field(default_factory=dict) + theme_placeholder: str = '"x"' # default 'x' + custom_colors_on: str = '0' # either 1 or 0 # ========== Line 15-16: pu_q_col / pu_a_col ========== pu_q_col: Dict[str, Any] = field(default_factory=dict) @@ -178,11 +159,13 @@ def __post_init__(self): """ Auto fill default after init """ - if not self.mode: self.mode = PENPA_MODE.copy() + if not self.mode: self.mode = PENPA_MODE_DEFAULT.copy() + + if not self.pu_q: self.pu_q = PENPA_PU_X_DEFAULT.copy() - if not self.pu_a: self.pu_a = PENPA_PU_X_DEFAULF.copy() + if not self.pu_a: self.pu_a = PENPA_PU_X_DEFAULT.copy() - if not self.mode_snapshot: self.mode_snapshot = PENPA_MODE.copy() + if not self.mode_snapshot: self.mode_snapshot = PENPA_MODE_DEFAULT.copy() if not self.sol_check: self.sol_check = PENPA_SOL_CHECK_DICT_DEFAULT.copy() @@ -190,9 +173,9 @@ def __post_init__(self): if not self.bg_image_encrypted: self.bg_image_encrypted = PENPA_BG_IMAGE_ENCRYPTED - if not self.pu_q_col: self.pu_q_col = PENPA_PU_X_DEFAULF.copy() + if not self.pu_q_col: self.pu_q_col = PENPA_PU_X_DEFAULT.copy() - if not self.pu_a_col: self.pu_a_col = PENPA_PU_X_DEFAULF.copy() + if not self.pu_a_col: self.pu_a_col = PENPA_PU_X_DEFAULT.copy() @dataclass @@ -203,6 +186,7 @@ class CellState: value: Optional[str] = None # Number clue shaded: bool = False # black? num_color: int = 1 # number color + num_style: str = "1" # number style @dataclass diff --git a/src/puzzlekit/formats/heyawake.py b/src/puzzlekit/formats/heyawake.py index 8e7fead0..6c80feb3 100644 --- a/src/puzzlekit/formats/heyawake.py +++ b/src/puzzlekit/formats/heyawake.py @@ -1,6 +1,6 @@ from puzzlekit.formats.base import ( PuzzleInstance, CellState, EdgeState, - PenpaMetadata + PenpaMetadata, COMPRESS_SUB ) from typing import Any, Dict, List, Optional, Tuple, Union import json @@ -11,43 +11,37 @@ PENPA_URLPREFIX = "https://swaroopg92.github.io/penpa-edit/#" PENPA_PREFIX = "m=edit&p=" -PENPA_ABBREVIATIONS = [ - ('"qa"', "z9"), - ('"pu_q"', "zQ"), - ('"pu_a"', "zA"), - ('"grid"', "zG"), - ('"edit_mode"', "zM"), - ('"surface"', "zS"), - ('"line"', "zL"), - ('"edge"', "zE"), - ('"wall"', "zW"), - ('"cage"', "zC"), - ('"number"', "zN"), - ('"sudoku"', "z1"), - ('"symbol"', "zY"), - ('"special"', "zP"), - ('"board"', "zB"), - ('"command_redo"', "zR"), - ('"command_undo"', "zU"), - ('"command_replay"', "z8"), - ('"freeline"', "zF"), - ('"freeedge"', "z2"), - ('"thermo"', "zT"), - ('"arrows"', "z3"), - ('"d"', "zD"), - ('"squareframe"', "z0"), - ('"polygon"', "z5"), - ('"deleteedge"', "z4"), - ('"killercages"', "z6"), - ('"nobulbthermo"', "z7"), - ('"__a"', "z_"), - ("null", "zO"), -] + +def to_penpa_str(pu_x: Optional[Dict | List], apply_compression : bool = True): + + assert isinstance(pu_x, list) or isinstance(pu_x, dict), f"pu_x must be dict or list, get {type(pu_x)}" + + if isinstance(pu_x, list): + return json.dumps(pu_x, separators=(',', ':'), ensure_ascii=False) + elif apply_compression: + pu_q_str = json.dumps(pu_x, separators=(',', ':'), ensure_ascii=False) + # 3. apply COMPRESS_SUB subsitution (in order, list sequence) + for orig, abbr in COMPRESS_SUB: + pu_q_str = pu_q_str.replace(orig, abbr) + return pu_q_str + else: + return json.dumps(pu_x, separators=(',', ':'), ensure_ascii=False) + + +def penpa_encrypt(text: str) -> str: + """Raw Deflate + base64 match encrypt_data of JS""" + # UTF-8 + data = text.encode('utf-8') + # Raw Deflate: wbits = -15: no header/trailer + compressed = compress(data, level=9)[2:-4] # remove zlib header(2) and adler32(4) + # base64 encode(URL safe: Optional) + return b64encode(compressed).decode('ascii') def generate_centerlist_diff(rows: int, cols: int, margins: List[int] = [0, 0, 0, 0]): """ Auto pack centerlist (in default all cells are filled) + FIX: must consider the margin part. for part of `__export_list_tab_shared` in penpa+ """ prev, nx0 = 0, cols + 4 @@ -153,6 +147,7 @@ def calculate_center_n(nx: int, ny: int, size: int = 38) -> int: return closest_idx + class HeyawakePenpaConverter: """Convert Penpa to PuzzleInstance """ @@ -164,8 +159,12 @@ def __init__(self, url: str): "original_url": self.url, } ) - + + def decode(self): + pass + def encode(self): + pass def index_to_coord(self, index: int, type_: str = 'edge') -> Tuple[Tuple[int, int], int]: """Convert the [Penpa+](https://swaroopg92.github.io/penpa-edit/) index to coordinate. @@ -175,8 +174,6 @@ def index_to_coord(self, index: int, type_: str = 'edge') -> Tuple[Tuple[int, in Args: index: The [Penpa+](https://swaroopg92.github.io/penpa-edit/) index to be converted. offset: To be compatiable with edge / cell index: - - edge := 0 - - cell := 1 """ assert type_ in ("edge", "cell"), f"Wrong index type for index_to_coord, expected 'cell', 'edge', get {type_}" category, index = divmod(index, self.real_rows * self.real_cols) @@ -189,30 +186,18 @@ def coord_to_index(self, coord: Tuple[int, int], type_: str) -> Tuple[int, int]: assert type_ in ("edge", "cell"), f"Wrong index type for index_to_coord, expected 'cell', 'edge', get {type}" r_, c_ = coord if type_ == "edge": - return r_ * self.real_cols + c_ + self.real_cols * self.real_rows + return (r_ + 1) * self.real_cols + (c_ + 1) + self.real_cols * self.real_rows else: return r_ * self.real_cols + c_ + self.real_cols * 2 + 2 - - # def coord_to_index(self, coord: Tuple[int, int], category: int = 0) -> Tuple[int, int]: - # return (coord[0] + self.top_margin, coord[1] + self.left_margin) - - # def coord_to_index(self, coord: Tuple[int, int], category: int = 0) -> int: - # """Convert the coordinate to [Penpa+](https://swaroopg92.github.io/penpa-edit/) index. - # * In [Penpa+](https://swaroopg92.github.io/penpa-edit/), the coordination (with margins) and category of a cell is encoded as a single integer index. This function helps to convert the ((`row`, `col`), `category`) format back to the index. - # Args: - # coord: The coordination to be converted. - # category: The category code of the direction (default is 0). - # """ - # return (category * self.real_rows * self.real_cols) + (coord[0] + 1 + self.top_margin) * self.real_cols + coord[1] + 1 + self.left_margin def decode(self) -> PuzzleInstance: self.parts = decompress(b64decode(self.url[len(PENPA_PREFIX) :]), -15).decode().split("\n") header = self.parts[0].split(",") - print(self.parts[6]) assert header[0] in ("square", "sudoku", "kakuro"), "Penpa puzzle must be in square, sudoku, kakuro" # info collect self.ir_puzzle.grid_type = "square" + self.ir_puzzle.size = header[3] self.ir_puzzle.title = header[15][len("Title: "):] self.ir_puzzle.author = header[16][len("Author: "):] self.ir_puzzle.source = header[17] @@ -239,33 +224,18 @@ def decode(self) -> PuzzleInstance: self.ir_puzzle.margins = margins elif p == 3: # decode from pu_q - self.board = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), PENPA_ABBREVIATIONS, self.parts[p])) + self.board = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, self.parts[p])) for k, v in self.board.items(): - if k == "edge": - # decode edge - print(k, end = " ") - self._decode_edge(edge_dict = v) - print(self.ir_puzzle.edges) - print([(x, y) for x,y in v.items()]) + if k == "lineE": + self.ir_puzzle.edges = self._decode_edge(edge_dict = v) elif k == "number": - # decode number - print(k, end = " ") - self._decode_number(number_dict = v) - print(v) - print(self.ir_puzzle.cells) + self.ir_puzzle.cells = self._decode_number(number_dict = v) else: - # print(k, v) pass elif p == 5: # decode box boxes = json.loads(self.parts[p]) self.ir_puzzle.boxes = boxes - - # for k, v in self.ir_puzzle.cells.items(): - # print(k, v) - # print(self.ir_puzzle.boxes, type(self.ir_puzzle.boxes[0])) - # print(self.ir_puzzle.margins, type(self.ir_puzzle.margins[0])) - print(self.ir_puzzle) check_diff = json.loads(self.parts[5]) new_diff = generate_centerlist_diff( @@ -277,26 +247,15 @@ def decode(self) -> PuzzleInstance: assert ",".join(map(str, check_diff)) == ",".join(map(str, new_diff)), "Diff list not matched!!" return self.ir_puzzle - - # def _pad_margin_edge(self): - # # temporal, to fix border - # for r in range(self.new_rows): - # self.ir_puzzle.edges[((r, 0), (r + 1, 0))] = EdgeState(connected = True, edge_type = 2) - # self.ir_puzzle.edges[((r, self.new_cols), (r + 1, self.new_cols))] = EdgeState(connected = True, edge_type = 2) - # for c in range(self.new_cols): - # self.ir_puzzle.edges[((0, c), (0, c + 1))] = EdgeState(connected = True, edge_type = 2) - # self.ir_puzzle.edges[((self.new_rows, c), (self.new_rows, c + 1))] = EdgeState(connected = True, edge_type = 2) def _decode_number(self, number_dict: Dict[str, int]): # ['4', 1, '1']: number, color, submode # lots to do here. for diff number format new_number_dict = dict() for index, num_data in number_dict.items(): - if num_data[2] == "1": - # only one scenario! - (r, c), _ = self.index_to_coord(int(index), 'cell') - new_number_dict[(r, c)] = CellState(value = num_data[0], num_color = num_data[1]) - self.ir_puzzle.cells = new_number_dict + (r, c), _ = self.index_to_coord(int(index), 'cell') + new_number_dict[(r, c)] = CellState(value = num_data[0], num_color = num_data[1], num_style = num_data[2]) + return new_number_dict def _decode_edge(self, edge_dict: Dict[str, int]): new_edge_dict = {} @@ -305,69 +264,90 @@ def _decode_edge(self, edge_dict: Dict[str, int]): index_1, index_2 = map(int, index.split(",")) coord_1, _ = self.index_to_coord(index_1, 'edge') coord_2, _ = self.index_to_coord(index_2, 'edge') - new_edge_dict[(coord_1, coord_2)] = EdgeState(connected = True, edge_type = 2) - self.ir_puzzle.edges = new_edge_dict + new_edge_dict[(coord_1, coord_2)] = EdgeState(connected = True, edge_type = v_) + return new_edge_dict def _encode_number(self, number_dict: Dict[str, CellState]): new_number_dict = dict() for coords, v_ in number_dict.items(): index = f"{self.coord_to_index(coords, 'cell')}" - print(index, coords) - pass - pass - - def _encode_edge(self): - pass + new_number_dict[str(index)] = [v_.value, v_.num_color, v_.num_style] + + return new_number_dict + + def _encode_edge(self, edge_dict: Dict[str, EdgeState]): + new_edge_dict = dict() + for coords, v_ in edge_dict.items(): + coord_1, coord_2 = coords + edge_str = f"{self.coord_to_index(coord_1, 'edge')},{self.coord_to_index(coord_2, 'edge')}" + new_edge_dict[edge_str] = v_.edge_type + return new_edge_dict def encode(self, inst: PuzzleInstance) -> str: - """Forge the Penpa+ format url. - - Args: - instance (PuzzleInstance): Input PuzzleInstance - - Returns: - str: url (penpa+ format) - """ - # Totally 19 lines ... - text = "" # to merge into a long plain text + """Forge the Penpa+ format url.""" mtd = PenpaMetadata() center_n = calculate_center_n(inst.rows, inst.cols, mtd.size) - center_list = generate_centerlist_diff(inst.rows, inst.cols) - self._encode_number(inst.cells) - # NOTE: currently the theta, reflect (1,1), sudoku (0,0,0,0) param are hard-coded. - # NOTE: currently center_n and center_n0 are treated as same + center_list = generate_centerlist_diff(inst.rows, inst.cols, inst.margins) + + # 1. form pu_q dict, then update + original_pu_q = mtd.pu_q + print(self.parts[3], "\n") + + # augmented update(number/edge only: for now) + original_pu_q["number"] = self._encode_number(inst.cells) + original_pu_q["lineE"] = self._encode_edge(inst.edges) + + # 2. standard JSON serialization (compact mode) - text_list = [ - [inst.grid_type, inst.rows, inst.cols, mtd.size, 0, 1, 1, (inst.rows + 1) * mtd.size, (inst.cols + 1) * mtd.size, center_n, center_n, 0, 0, 0, 0], - inst.margins, - mtd.mode, - - mtd.pu_a, - center_list, - [], # todo here, better set according to puzzle type, limiting user input - mtd.sol_check, - "x", - "x", - [3, 2, 1], # default version - mtd.mode_snapshot, - "x", - "0", - mtd.pu_q_col, - mtd.pu_a_col, - mtd.sol_check_or, - [], # selected genre tags, default empty - "" # msg, default empty + # 3. construct text_lines + text_lines = [] + + # print(original_pu_q) + + to_pack_elem = [ + ",".join(map(str, [ + inst.grid_type, inst.cols, inst.rows, mtd.size, mtd.theta, mtd.reflect[0], mtd.reflect[1], + (inst.cols + 1) * mtd.size, (inst.rows + 1) * mtd.size, + center_n, center_n, mtd.sudoku[0], mtd.sudoku[1], mtd.sudoku[2], mtd.sudoku[3], + "Title: " + inst.title.replace(',', '%2C'), # comma update + "Author: " + inst.author.replace(',', '%2C'), # comma update + inst.source.replace(',', '%2C'), + mtd.rules.replace(',', '%2C'), + mtd.border_status, mtd.multisolution, + mtd.bg_image_encrypted + ])), # Line 0: header + to_penpa_str(inst.margins), + to_penpa_str(mtd.mode), + to_penpa_str(original_pu_q), + to_penpa_str(mtd.pu_a), + to_penpa_str(inst.boxes), + to_penpa_str(mtd.tab_settings), + to_penpa_str(mtd.sol_check, apply_compression = False), + mtd.timer_placeholder, + mtd.comp_mode, + to_penpa_str(mtd.version), + to_penpa_str(mtd.mode_snapshot), + mtd.theme_placeholder, + mtd.custom_colors_on, + to_penpa_str(mtd.pu_q_col), + to_penpa_str(mtd.pu_a_col), + to_penpa_str(mtd.sol_check_or, apply_compression = False), + to_penpa_str(mtd.genre_tags), + mtd.custom_message ] + for i in range(19): + if i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]: + text_lines.append(to_pack_elem[i]) + else: + text_lines.append(self.parts[i]) + - pass - -class HeyawakePuzzlinkConverter: - def decode(self, url: str) -> PuzzleInstance: - pass - def encode(self, instance: PuzzleInstance) -> str: - pass - + # 5. concatenate + compress + base64 + plain_text = "\n".join(text_lines) + compressed = compress(plain_text.encode())[2:-4] + + return PENPA_URLPREFIX + PENPA_PREFIX + b64encode(compressed).decode('ascii') if __name__ == "__main__": @@ -376,13 +356,13 @@ def encode(self, instance: PuzzleInstance) -> str: # "m=edit&p=7VbvT+M4EP3evwLl61q3cZw0P6T9UErZEwKOHnA9qCpk2pQG0oZNWuCC+N/3je20TVv27nQ6iZNObezJ83jmzdgep/i2kHnMuE1/ETD0+Lk8UI8TNNVjm99FMk/jaO/n+A/5LB9i1lrMJ1ke7b0kMqOHsXYqiyIZLlX28kUa78nHxzSJi5/YL4eHbCzTImZHV/f7Bw+t507r98/etRCXp+NP9wfdy/tR7zfetZPPuX2aBrOTs4P99NPX8vpk0nqKO3HzrMiGkzSWI1le945e0tlhcDcZ8/bRpB2M5cwuvgUX4dN+98uXRt/QHjReyzAqu6z8GvUtbjHLwcOtASu70Wt5EpUdVp5jyGJ8wKzpIp0nwyzNcqvCymM90YHYWYk9NU5SW4PchnxqZIhXEIdJPkzjm2ONnEX98oJZ5HtfzSbRmmZPMTkjbvQ+zKa3CQG3co6MF5Pk0WICA8VilD0sjCofvLGypSM4/4sRwEgVAYk6ApJ2RECB/bsRhIO3NyzOr4jhJupTOJcrMViJ59Er2tPo1RI2TXVBRa+g5TkENFeAr4A1Dc7dKmFLxNtCfEKwL5aIpzwtdeCfKxZXqj1UraPaC5BkpVDtgWpt1XqqPVY6HXB3PMEcD2Qc7EDPhQwaSvYgN42ME+eBDMku6RvZCRnetcyh74RG32eOz7Xsc5xWYzOATmh0wpAJrueiZ8LRNtEz4eq56Jmo+IQ01/AJwSc0HEL4CgMjozos7TdhH+lS9lFGHB0jeiZEpQ9f3Pji8OVoX+iho+2gBwcTI+wLjqVU+g70NR/04Gx8ufBV5ZODmzB5EMiDa2JxKbdVrpBD39j3kdvAxBWAm2242eBG20P5BTeTZ/Twa3KIdRHVunDYFMamgE23WjvwaRo+TfDxDR8ffALDJ4BN29i0YZM2ofILPiYW9PBr+CAWUcVCvoSJXWAvCaND+sLkTSBvhhvWAXMJx2bsqS3ZVq2r2qbaqj6dtr91Hv/5qfhTOn2h76T6z/vvYYNGHzeRVWTpTbHIx3IY38Qvcji3In0jro/UsNliehujlK9BaZbhRp3tslAN1cDkbpbl8c4hAuPR3XumaGiHqdssH21wepZpWo9FfVvUIH2V1KB5jnti7V3mefZcQ6ZyPqkBa3dKzVI820jmXNYpyge54W26Ssdbw3qx1NPH2aL1+v+z4QN/NtBC2R+tWH00OmqPZ/kPCs5qcBPeUXaA/qDyrI3uwt8pMmujm/hWRSGy20UF6I66AnSztADari4AtwoMsHdqDFndLDPEarPSkKutYkOu1utNf9D4Dg==", # "m=edit&p=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", # "m=edit&p=7Vbvbts2EP+epyAEFGgBxbZkO070LUvrYkCXbU22ojCMgpZpi7BEeiQVJwoC9B32dXu5Pkl/R8m1FWfDMGBoPwyyT3c/Hu8vj7b9reRGhCM8J8OwF0Z4hnHPf0/8Z/tcS5eLhF2t7mxq+FoYy56PeW7Fi/C8dJk2CXttuHJsLFfChplza5t0u5vNprMs1mVV5cJ2Ul10Z7leduNePOhGva7dmTtekLXj2d3xkuwcL8hON/yVG8md1IrpRcv72xIWE/a9ssI4xtlcLqVjC6MLFjGn2SWTCi/B04ylIs8hMpcJ4LM7kKWRc2Y1IO6Y0s12I9aCO0u6XN0xozdMG5bqvCxUh53nVj+LL2qbqixmwmytenPYbYQVCgYIy4RcZo7i5mxWynwu1RJm534xzUthGdIiQZfOyjnei50xqeYy5Q5mEERB0WxtWJZyxWaCffr4hxVCffr4J9tkQrFc6xX58EXwic2lESlVr8OueZ4j3p0RNCJdeX83UiDPBbPFI50OGyN9ccuLdS4oc0m5UFVSrRyXStTJ1LWwLBr2BzHpAURmG73nDueMUbTb6ATLxcIhh9/RL6rK0PN1fYwQe1u/7DC+oKTmvQyI7LZupMvqcoIcZIpQ0Xj3uAroH8zj3Na1LzrP4pf4nOPAiFsnjKT++15RAv6Mepc202UOk4IJuIVJrdAqxEdsLeV6AwF9qJt8w2GGLeWNUI0TKi4vnS5wwFMcxrz0Bz3NREp9bAr5pbrPpUIk/hSl2lBjkVM7yBesKK3zUSngWEd+S1RTkbEZVOgNbSx1wh/H49BndDShwcczPbqvzpLqPKxeJ5MgCsIgxjcKpmH1c3Jf/ZBUl2F1haUAumH1plbqg31VszHB77wCoRe1ag/sJdizGn0PNpUmzcWHN9gC5KdkUl2HAfn5zm8hNij0jQiaOEjG9TGTBMy4w21kM7luVmw516uy0Y2mD2F17sPdLvx90MR+5ZjPpg8PqP1bRP0hmVACv+zY0x17ldyDXnoaJfdB1Ds5JRNDHyXEUUxifyueDvZXozOvPGjEOI5IRIu92O8Pm2hqcTDaN9Uf7pTh/L0PYexp7Ok1IgyrvqcvPe15OvT0jdd55ek7Ty88HXh64nVGlOO/qMJ/Gs6kf1LPBp7RP+OmR5PgyneXXWqDSxUdb+QLbZQwezLm1IgAcxdg/j/Y0ix4igPkxxJnBFg9/kHiTNkguOfXOS7flppcKm3Ek0sEivnyKf2ZNnMyvrewwRXZAuo/CS2onoQW5AyO+Z7MDX4nWgiuuawF7I1EyxJq0g7A8XaIfIV/BW3bu5wfjoLbwH8nffxxGf1/p33NO4360PvWZvpbC8cfYW2eHH/A2xugjT456g1+MO3AD+aaHB6ONtAnphvo4wEHdDjjAA/GHNhfTDpZfTzsFNXjeSdXByNPrvanfjI98txn", - "m=edit&p=7VffT+NGEH7nr6j29VaNd/3rh3SqQoCTEEehQCmJIuQkThxw7JztADLif79vdh3shHBVe33goXK8+81MPPPN7O44Kb6twjziNhcmt21ucIHLNA3uurgd+hj1dTkvkyj4hXdXZZzlAHFZLoug01muqn7V/zWZp/ed5W9plmazPFx0rI4wOkKasWU7sev5cTgap2ImZ+bMwi1nYsb570dHfBomRcSPb+72D+67j4fdvzp23zSvTqef7g7Or+4m13+Kc2PeyY3TxEu/nh3sJ5++VP2vcfchOoycsyIbx0kUTsKqf338lKRH3iyeit5x3POmYWoU37xL/2H//PPnvYGpEjSGe8+VH1RdXn0JBkwwziRuwYa8Og+eq68BG2eL0Zzx6gJ2xsWQs8UqKefjLMlyttZVJ/ppCXjYwGtlJ9TTSmEAn9YY8AZwPM/HSXR7ojVnwaC65IwI7KunCbJF9hBRMCJIsiYFxSgssRRFPF8ybsJQrCbZ/ar+qhi+8Kr7L9KAp3UaBHUahHakQdn9fBrJMtuRgD98ecEC/YEUboMBZXPVQK+BF8EzxtPgmUmBR02srFpDJiVEpxFNiH4jWhCFfJVNA3JLJF84DmuRfHmNSL4EnZFa9kl+FS3yZTUi+XIbkXwJ2oFadhWTV9Ej3w0Rn77dJCUMctb4FoJiteyCqDSPC+m1vKNQQpXrRo1HapRqvEQ1eWWq8UCNhhptNZ6o7xyiyMJBcBccJTy6COyBIGEPQX2QI+wLLg0QA8YMDFIK+1wKECIsPC6lq7F0uTQdjU2HS8vW2LK5tFEewrbFpaPjYubS1XExc+nVcR0skat9YgY37RMzuGmfmMGn9mnAp6h9ovNJ2jKKD3zSDlB8kAstqOKDXKw6Fwu52HUuNnJx6lwc5OLWuaBtSm8dFyveqo+ghVWY6lbX00c9fc0N8ytnaYIz1QccdYFMBNMCVQ4stAAatBW1AH7EWwkeiNdFwgxcJ4pFk7ThFEYBvLowHgqjiGPVr9Xa99RoqdFRe8Kl8/ePTujPb7+/pTOw6JTSRSf/P5iHewN2scqn4ThC4+pli2VWzMuI4eXBiiy5LbTtNnoKxyUL9EusbdnQpavFKELPbamSLFvinbnLw9q0oZzP0iyPdppIGU1m77ki0w5XoyyfbHF6DJNkMxf1+2BDpXv+hqrM0dBbcpjn2eOGZhGW8Yai9Q7b8BSlW8Usw02K4X24FW3RlONljz0xdaM14nz9/6b/8G96Wizjo3WTj0ZH7fMs/0HTaYzb6h2tB9ofdJ+WdZf+nUbTsm7r33QVIvu2sUC7o7dAu91eoHrbYaB802Sge6fPkNftVkOstrsNhXrTcChUu+cM2PpPDxvufQc=", - "m=edit&p=7VZtT+M4EP7eX4Hyda27OM67dB9Kl+7LQbcsIJZWFQolQCAlXF4KG8R/32dsp23asnen00mcdEpjTx+PZ54Z2+MUf1RRHjNu0k/4DD0em/vytXxXvqZ+jpMyjcOdj/H36DG6i1m3Km+yPNx5SqKMXsZ6aVQUyXShspNXabwTPTykSVz8wr70++wqSouYfT672e9l3cf33W9zvxyN+Aez+mSe3vZv332d/f4pETnvD/zhwfAgsa67H3u7h+7eO3dYFSdlPD+c8d3bk9Hx1fD0OrC+7w1Gdj36YjqfR1e/zrsnv3XGmvGk81wHYX3I6g/h2OAGMyy83Jiw+jB8rg/CesDqIwwZjE+YMavSMplmaZYbDVbvq4kWxL2leCrHSeopkJuQB1qGeAZxmuTTND7fV8gwHNfHzCDfu3I2icYsm8fkjLjR/2k2u0gIuIhKJLu4SR4MJjBQVJfZXaVV+eSF1V0VwdFfjABGmghIVBGQtCUCCuzfjSCYvLxgcb4ihvNwTOGcLEV/KR6Fz2gH4bMhTJpqg4paQcOxCHCXgCeBFQ3O7SZhC8TZQDxCsC8WiCM9LXTgn0sWZ7Lty9aS7TFIslrI9r1sTdk6st2XOnvgbjmCWQ7IWNiBjg0ZNKTsQHa1jMPmgAzJNulr2QoY/iuZQ98KtL7HLI8r2eM4qNqmD51A6wQBE1zNRc+EpWyiZ8JWc9Ez0fAJaK7mE4BPoDkE8BX4WkZhWNh3YR/pkvZRQSwVI3omhNJHD/tYGpId8qVksim45sCBW4oDesxV9tFDX8cOv4JrO9yCvuKJHrFoDjY4NHnm4Cx0fgTyY+sYbcp5k0Pk1tP2PeTc1/H64GZqbia40baRfsFN5x89/OrcYr1Es14cNoW2KWDTbtYUfFzNxwUfT/PxwMfXfHzYNLVNEzZpc0q/4KNjQQ+/mg9iEU0s5Evo2AX2mNA6pC903gTyJrlhY57K7dmTrS1bV25bj07e3zqb//yE/CmdsVBXU/tx/nvYpDPGrWQUWXpeVPlVNI3P46doWhqhuhhXR1rYfTW7iFHWV6A0y3Cx3m+z0Ay1wOT6PsvjrUMExpfXr5mioS2mLrL8co3TY5Sm7VjkJ0YLUtdKCypz3Bkr/6M8zx5byCwqb1rAyv3SshTfryWzjNoUo7tozdtsmY6XjvFkyHeM80Tr9f8nxBv+hKCFMt9asXprdOQez/KfFJzl4Dq8pewA/UnlWRndhr9SZFZG1/GNikJkN4sK0C11Beh6aQG0WV0AbhQYYK/UGLK6XmaI1XqlIVcbxYZcrdab8aTzAw==" + # "m=edit&p=7VffT+NGEH7nr6j29VaNd/3rh3SqQoCTEEehQCmJIuQkThxw7JztADLif79vdh3shHBVe33goXK8+81MPPPN7O44Kb6twjziNhcmt21ucIHLNA3uurgd+hj1dTkvkyj4hXdXZZzlAHFZLoug01muqn7V/zWZp/ed5W9plmazPFx0rI4wOkKasWU7sev5cTgap2ImZ+bMwi1nYsb570dHfBomRcSPb+72D+67j4fdvzp23zSvTqef7g7Or+4m13+Kc2PeyY3TxEu/nh3sJ5++VP2vcfchOoycsyIbx0kUTsKqf338lKRH3iyeit5x3POmYWoU37xL/2H//PPnvYGpEjSGe8+VH1RdXn0JBkwwziRuwYa8Og+eq68BG2eL0Zzx6gJ2xsWQs8UqKefjLMlyttZVJ/ppCXjYwGtlJ9TTSmEAn9YY8AZwPM/HSXR7ojVnwaC65IwI7KunCbJF9hBRMCJIsiYFxSgssRRFPF8ybsJQrCbZ/ar+qhi+8Kr7L9KAp3UaBHUahHakQdn9fBrJMtuRgD98ecEC/YEUboMBZXPVQK+BF8EzxtPgmUmBR02srFpDJiVEpxFNiH4jWhCFfJVNA3JLJF84DmuRfHmNSL4EnZFa9kl+FS3yZTUi+XIbkXwJ2oFadhWTV9Ej3w0Rn77dJCUMctb4FoJiteyCqDSPC+m1vKNQQpXrRo1HapRqvEQ1eWWq8UCNhhptNZ6o7xyiyMJBcBccJTy6COyBIGEPQX2QI+wLLg0QA8YMDFIK+1wKECIsPC6lq7F0uTQdjU2HS8vW2LK5tFEewrbFpaPjYubS1XExc+nVcR0skat9YgY37RMzuGmfmMGn9mnAp6h9ovNJ2jKKD3zSDlB8kAstqOKDXKw6Fwu52HUuNnJx6lwc5OLWuaBtSm8dFyveqo+ghVWY6lbX00c9fc0N8ytnaYIz1QccdYFMBNMCVQ4stAAatBW1AH7EWwkeiNdFwgxcJ4pFk7ThFEYBvLowHgqjiGPVr9Xa99RoqdFRe8Kl8/ePTujPb7+/pTOw6JTSRSf/P5iHewN2scqn4ThC4+pli2VWzMuI4eXBiiy5LbTtNnoKxyUL9EusbdnQpavFKELPbamSLFvinbnLw9q0oZzP0iyPdppIGU1m77ki0w5XoyyfbHF6DJNkMxf1+2BDpXv+hqrM0dBbcpjn2eOGZhGW8Yai9Q7b8BSlW8Usw02K4X24FW3RlONljz0xdaM14nz9/6b/8G96Wizjo3WTj0ZH7fMs/0HTaYzb6h2tB9ofdJ+WdZf+nUbTsm7r33QVIvu2sUC7o7dAu91eoHrbYaB802Sge6fPkNftVkOstrsNhXrTcChUu+cM2PpPDxvufQc=", + "m=edit&p=7VZtT+M4EP7eX4Hyda27OM67dB9Kl+7LQbcsIJZWFQolQCAlXF4KG8R/32dsp23asnen00mcdEpjTx+PZ54Z2+MUf1RRHjNu0k/4DD0em/vytXxXvqZ+jpMyjcOdj/H36DG6i1m3Km+yPNx5SqKMXsZ6aVQUyXShspNXabwTPTykSVz8wr70++wqSouYfT672e9l3cf33W9zvxyN+Aez+mSe3vZv332d/f4pETnvD/zhwfAgsa67H3u7h+7eO3dYFSdlPD+c8d3bk9Hx1fD0OrC+7w1Gdj36YjqfR1e/zrsnv3XGmvGk81wHYX3I6g/h2OAGMyy83Jiw+jB8rg/CesDqIwwZjE+YMavSMplmaZYbDVbvq4kWxL2leCrHSeopkJuQB1qGeAZxmuTTND7fV8gwHNfHzCDfu3I2icYsm8fkjLjR/2k2u0gIuIhKJLu4SR4MJjBQVJfZXaVV+eSF1V0VwdFfjABGmghIVBGQtCUCCuzfjSCYvLxgcb4ihvNwTOGcLEV/KR6Fz2gH4bMhTJpqg4paQcOxCHCXgCeBFQ3O7SZhC8TZQDxCsC8WiCM9LXTgn0sWZ7Lty9aS7TFIslrI9r1sTdk6st2XOnvgbjmCWQ7IWNiBjg0ZNKTsQHa1jMPmgAzJNulr2QoY/iuZQ98KtL7HLI8r2eM4qNqmD51A6wQBE1zNRc+EpWyiZ8JWc9Ez0fAJaK7mE4BPoDkE8BX4WkZhWNh3YR/pkvZRQSwVI3omhNJHD/tYGpId8qVksim45sCBW4oDesxV9tFDX8cOv4JrO9yCvuKJHrFoDjY4NHnm4Cx0fgTyY+sYbcp5k0Pk1tP2PeTc1/H64GZqbia40baRfsFN5x89/OrcYr1Es14cNoW2KWDTbtYUfFzNxwUfT/PxwMfXfHzYNLVNEzZpc0q/4KNjQQ+/mg9iEU0s5Evo2AX2mNA6pC903gTyJrlhY57K7dmTrS1bV25bj07e3zqb//yE/CmdsVBXU/tx/nvYpDPGrWQUWXpeVPlVNI3P46doWhqhuhhXR1rYfTW7iFHWV6A0y3Cx3m+z0Ay1wOT6PsvjrUMExpfXr5mioS2mLrL8co3TY5Sm7VjkJ0YLUtdKCypz3Bkr/6M8zx5byCwqb1rAyv3SshTfryWzjNoUo7tozdtsmY6XjvFkyHeM80Tr9f8nxBv+hKCFMt9asXprdOQez/KfFJzl4Dq8pewA/UnlWRndhr9SZFZG1/GNikJkN4sK0C11Beh6aQG0WV0AbhQYYK/UGLK6XmaI1XqlIVcbxYZcrdab8aTzAw==", + "m=edit&p=1VVfb9s2EH/3pyAIFEgAxbbkP7H1lqXNXtqsq70VhWAEtMRYhCXSo8g4VpB+jX2gfbHekU4txV6BPWzAIOt8+ul497szf3T1h2WaB2EfP8NRAN9wDacjd0eTsbv7+2suTMFjcmu1WLMlJ2cfmdDVOTmjITGKTOl5cGVNrnRMPljNDLlmksyVNCzIjdlUca+33W67q3Jj67rgVTdVZW9ZqFUv6kdRr3/Zk/vUFxvMfLHcXZSY6CJl8sJgol7wO9OCGaEkUfcHKtpCvpjMcpZxUqmSE15uzI6kvCgqAIjJgY/JOVlpkRFRkUw8iIyDK4G6lRWuzAhMg1VvomvCWZqTFEsKKeSK8EeWmmJHzFYRacsl1xU5E7IynGXIREl+TpjMSM4eMJ7BRAwrSCVq4AJjLnBESKCyJS5At5GsS+bw0OZBSraDxFhVWaCTCbZSkhXFrkuuCsjtY32Ppa0MgUkAZ8lTA/hWmNz3oaCYxq6W1hCpSPTXnxEMQtkNMvHrYcS4nEsjNIeSPnn3TfQWPjdKE2aNKmHyKYyzsO4XSHOerqFbTO1aO0WoZHrt5kyWBUvXBFP5SCy+0mzXDX65uQnuWVHxTrLfbItOQkMa0AjukC6+1rOvCaVBuOg81Z/ip/ouThbPQf3bwZ0c3Fn8BPY2fqKDiMYJHcBClyagoxAByPodmCIApV6A8QCBSQO4RGB0ACYTBKYHIOy7kEbWMHIxlw3EUxk3kfGr0uFwiMiwgXg232OgrdA198XZG2cjZ+fQe1APnH3rbN/ZkbPvXcw7Zz87e+3s0Nmxi7nE6XU6ycAfA+1r9P/DcA/NrL5nKaewbyjs27tq/xwbbXngIK9AGrsN6KFCqU0hJIQ1QLGSCqRx6hWCPFudil8qnb3KvgUJtwB/DLegVOi0aENGi9Yz01ptWwjIM28BS2bgyK5ysWlnApW3CRjWpsjWcMy2cx96fu7QR+ruJAqicRBOUJXTuL4K6p/9bn3RbVD/CrL8ENe3qEqvYNyMLmgA7jvvRuB+du8RvPaRfXBv97sf3C/g+rHcvfcrPsZJPQ8olvnJLUGXluoBmHoa+Az/MkvoJaGNafg3lc3U2r4IDMV15dnOfswW3R+xRW7/Mtvp4tn/DP1/dCD+B+fH415pSh/E1thHAJ8QHKAnhbXHj7QF+JGKsOCxkAA9oSVAX8sJoGNFAXgkKsD+RleY9bW0kNVrdWGpI4FhqabGkkXnGw==" + ]: hpc = HeyawakePenpaConverter(url = test_url) tmp = hpc.decode() - hpc.encode(tmp) - - - - # print(calculate_center_n(7, 7, 65)) \ No newline at end of file + enc = hpc.encode(tmp) + print(enc) + \ No newline at end of file diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py new file mode 100644 index 00000000..ab749891 --- /dev/null +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -0,0 +1,100 @@ +from typing import Dict, List, Union + +class PuzzlinkConverter: + """_summary_ + + Returns: + _type_: _description_ + """ + def __init__(self, url: str): + pass + + def decode(self): + pass + + def _decode_border(self) -> Dict[int, int]: + """To get the region walls of grid. e.g., heyawake, jigsaw sudoku. + + Returns: + Dict[int, int]: _description_ + """ + # TODO: + border_list = {} + id_counter = 0 + twi = [16, 8, 4, 2, 1] # 5 bits mask + + # 1. Calculate the string length (JS: pos1, pos2 calculations) + # Vertical borders total: each row has cols-1 borders, total rows rows + num_vert_borders = (self.num_cols - 1) * self.num_rows + # Length = ceil(total / 5) + pos1 = (num_vert_borders + 4) // 5 + + # Horizontal borders total: each column has rows-1 borders, total cols columns + num_horiz_borders = self.num_cols * (self.num_rows - 1) + pos2 = pos1 + (num_horiz_borders + 4) // 5 + + # Extract the corresponding length of body string + border_str = self.body[:pos2] + # Update self.body, remove the read part (JS: this.gridurl.substr(pos2)) + self.body = self.body[pos2:] + + # 2. Parse vertical borders (Vertical Borders) + # ID range: 0 to (cols-1)*rows - 1 + for i in range(pos1): + if i >= len(border_str): break + # JS: parseInt(char, 32) + val = int(border_str[i], 32) + + for w in range(5): + if id_counter < num_vert_borders: + # Check bit: if (val & mask) + if val & twi[w]: + border_list[id_counter] = 1 + id_counter += 1 + + # 3. Parse horizontal borders (Horizontal Borders) + # ID range: after vertical borders + # Note: id_counter should now be num_vert_borders (if there is padding, it may be larger, but logically continue from here) + + + start_horiz_id = num_vert_borders + id_counter = start_horiz_id + + for i in range(pos1, pos2): + if i >= len(border_str): break + val = int(border_str[i], 32) + + for w in range(5): + if id_counter < start_horiz_id + num_horiz_borders: + if val & twi[w]: + border_list[id_counter] = 1 + id_counter += 1 + + return border_list + + def _read_number16(self, body_str: str, i: int): + if i >= len(body_str): + return -1, 0 + + char = body_str[i] + + if ('0' <= char <= '9') or ('a' <= char <= 'f'): + return int(char, 16), 1 + + elif char == '-': + return int(body_str[i+1 : i+3], 16), 3 + elif char == '+': + return int(body_str[i+1 : i+4], 16), 4 + elif char == '=': + return int(body_str[i+1 : i+4], 16) + 4096, 4 + elif char == '%': + return int(body_str[i+1 : i+4], 16) + 8192, 4 + elif char == '*': + return int(body_str[i+1 : i+5], 16) + 12240, 5 + elif char == '$': + return int(body_str[i+1 : i+6], 16) + 77776, 6 + + elif char == '.': + return '?', 1 + + return -1, 0 \ No newline at end of file From 5672fcadb6a163cacef05b2d04d7029ebbc272b2 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sun, 8 Mar 2026 19:39:33 +0800 Subject: [PATCH 04/17] update puzzlink encoder --- src/puzzlekit/core/puzzlink.py | 536 ------------------ src/puzzlekit/formats/base.py | 7 +- src/puzzlekit/formats/heyawake.py | 49 +- src/puzzlekit/formats/puzzlink_converter.py | 585 ++++++++++++++++++-- src/puzzlekit/formats/utils.py | 18 + 5 files changed, 586 insertions(+), 609 deletions(-) delete mode 100644 src/puzzlekit/core/puzzlink.py create mode 100644 src/puzzlekit/formats/utils.py diff --git a/src/puzzlekit/core/puzzlink.py b/src/puzzlekit/core/puzzlink.py deleted file mode 100644 index 6886d0e8..00000000 --- a/src/puzzlekit/core/puzzlink.py +++ /dev/null @@ -1,536 +0,0 @@ -from typing import Dict, Any, List, Optional, Union - -from puzzlekit.core import grid - -# Yajilin, Masyu, Slitherlink, heyawake, shikaku, norinori, hitori -class PuzzlinkParser: - def __init__(self, raw_url: str): - self.raw_url: str = raw_url - self.body: str = "" - self.num_rows: int = 0 - self.num_cols: int = 0 - self.skip_shading: bool = True - self.puzzle_type: str = "" - - def parse(self) -> Dict[str, Any]: - # If wanna add more puzzle types, just add the puzzle type to the list and implement the corresponding logic - if self.puzzle_type in ["yajilin", "yajirin", "snakes", "hebi", "castle"]: - return self._parse_yajilin_variant() - elif self.puzzle_type in ["moonsun","mashu", "masyu", "pearl"]: - return self._parse_masyu_variant() - elif self.puzzle_type in ["slither", "slitherlink", "vslither"]: - info_number = self._decode_number4() - grid_matrix = self._convert_number_map_to_grid(info_number) - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - "grid": grid_matrix - } - - elif self.puzzle_type in ["heyawake", "shikaku", "norinori"]: - border_list = self._decode_border() - region_grid = self._convert_border_to_region_grid(border_list) - number_map = self._decode_number16() - grid_matrix = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] - self._move_numbers_to_top_left_corner(grid_matrix, region_grid, number_map) - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - "grid": grid_matrix, - "region_grid": region_grid - } - - elif self.puzzle_type in ["country", "detour", "juosan", "yajilin-regions", "yajirin-regions"]: - # toichika2, nagenawa, maxi, factors are neglected. - pass - elif self.puzzle_type in ["hitori"]: - info_number = self._decode_number36(self.num_cols * self.num_rows) - # print(self.num_rows, self.num_cols, len(info_number)) - # print(info_number) - grid_matrix = [["-" 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): - grid_matrix[i][j] = str(info_number[i * self.num_cols + j]) - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - "grid": grid_matrix - } - # pu = new Puzzle_square(cols, rows, size); - # setupProblem(pu, "surface"); - - # info_number = puzzlink_pu.decodeNumber36(cols * rows); - # puzzlink_pu.drawNumbers(pu, info_number, 1, "1", false); - - else: - raise NotImplementedError - - def _parse_header(self): - """Parse the header of the puzzle, such as: slither/10/10/body_str""" - parts = self.raw_url.split("?") - urldata = parts[1].split("/") - if len(urldata) > 1 and urldata[1] == 'v:': - urldata.pop(1) - - self.puzzle_type = urldata[0] - self.skip_shading = (self.puzzle_type != "castle") and (self.puzzle_type != "hebi") - if urldata[1] == "b": - self.skip_shading = False - self.num_cols = int(urldata[2]) - self.num_rows = int(urldata[3]) - self.body = urldata[4] - return (self.puzzle_type, self.num_cols, self.num_rows, self.body) - else: - self.num_cols = int(urldata[1]) - self.num_rows = int(urldata[2]) - - # if cols > 65 or rows > 65: - # print("Penpa+ does not support grid size greater than 65 rows or columns") - # return None - - bstr = urldata[3] - self.body = bstr - - return (self.puzzle_type, self.num_cols, self.num_rows, self.body) - - def _parse_yajilin_variant(self): - if self.puzzle_type == "yajirin": - self.puzzle_type = "yajilin" - elif self.puzzle_type == "snakes": - self.puzzle_type = "hebi" - # TODO: this pzl is not paid enough attention to, because neither data nor solver is implemented. - - parsing_castle = (self.puzzle_type == "castle") - arrows = self._decode_yajilin_arrows(parsing_castle) - - number_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] - # shading_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] - - for cell_index, arrow_data in arrows.items(): - direction, number_str, shading_type = arrow_data - row = cell_index // self.num_cols - col = cell_index % self.num_cols - - number = number_str - if self.skip_shading and not number_str: - number = "?" - - if direction != 0 and number_str: - direction_map = {1: "n", 2: "s", 3: "w", 4: "e"} # 上、下、左、右 - number = f"{number_str}{direction_map[direction]}" - - number_grid[row][col] = number - - # ===== For debug start ===== - # if not self.skip_shading: - # if shading_type == 0: - # shading_grid[row][col] = "L" # Light gray - # elif shading_type == 2: - # shading_grid[row][col] = "B" # Black - # elif shading_type == 1: - # shading_grid[row][col] = "N" # No shading - # else: - # shading_grid[row][col] = "-" - # ===== For debug end ===== - - if self.puzzle_type == "yajilin": - if shading_type == 2 and number == "-": - number_grid[row][col] = "x" - elif self.puzzle_type == "castle": - if shading_type == 2: - number_grid[row][col] = "x" if number == "-" else f"{number}x" - elif shading_type == 1: - number_grid[row][col] = "o" if number == "-" else f"{number}o" - else: - # snake puzzle, temporary not implemented. - pass - - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - # "puzzle_type": self.puzzle_type, - "grid": number_grid, # grid + arrow matrix - # "shading": shading_grid, # bg grid - # "arrows": arrows # raw arrow data (ignored for now, available for debug) - } - - def _parse_masyu_variant(self): - if self.puzzle_type in ["moonsun"]: - - border_list = self._decode_border() - region_grid = self._convert_border_to_region_grid(border_list) - info_number = self._decode_number3() - grid = self._convert_one_two_2_white_black_grid(info_number, category = "moonsun") - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - "grid": grid, - "region_grid": region_grid - } - else: - info_number = self._decode_number3() - grid = self._convert_one_two_2_white_black_grid(info_number) - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - "grid": grid - } - - def _move_numbers_to_top_left_corner(self, - grid_matrix: List[List[str]], - region_grid: List[List[str]], - number_map: Dict[int, int]): - """ - Python implementation of moveNumbersToRegionCorners in - - https://github.com/marktekfan/sudokupad-penpa-import/src/penpa-loader/puzzlink.js - - Parse the {RegionID: Number} and fill it into the grid_matrix at the top left corner of the region. - """ - - # 1. Find the top left corner of each region - # region_start_points: {region_id: (r, c)} - region_start_points = {} - - for r in range(self.num_rows): - for c in range(self.num_cols): - r_id = region_grid[r][c] - if r_id not in region_start_points: - # because the iteration is from top to bottom, and left to right - region_start_points[r_id] = (r, c) - - for r_id_raw, val in number_map.items(): - r_id = str(r_id_raw) - if r_id in region_start_points: - r, c = region_start_points[r_id] - grid_matrix[r][c] = str(val) - else: - print(f"Warning: Number for Region {r_id} found, but region not does not exist in grid.") - return grid_matrix - - def _read_number16(self, body_str: str, i: int): - if i >= len(body_str): - return -1, 0 - - char = body_str[i] - - if ('0' <= char <= '9') or ('a' <= char <= 'f'): - return int(char, 16), 1 - - elif char == '-': - return int(body_str[i+1 : i+3], 16), 3 - elif char == '+': - return int(body_str[i+1 : i+4], 16), 4 - elif char == '=': - return int(body_str[i+1 : i+4], 16) + 4096, 4 - elif char == '%': - return int(body_str[i+1 : i+4], 16) + 8192, 4 - elif char == '*': - return int(body_str[i+1 : i+5], 16) + 12240, 5 - elif char == '$': - return int(body_str[i+1 : i+6], 16) + 77776, 6 - - elif char == '.': - return '?', 1 - - return -1, 0 - - - def _decode_number36(self, max_iter: int = -1) -> List[Union[int, str]]: - - number_list = [] - index = 0 - - while index < len(self.body) and max_iter != 0: - char = self.body[index] - - if char == '-': - number_list.append(int(self.body[index+1:index+3], 36)) - index += 3 # 应该是3,不是2! - elif char == '%': - number_list.append('?') - index += 1 - elif char == '.': - number_list.append(' ') - index += 1 - else: - number_list.append(int(char, 36)) - index += 1 - - max_iter -= 1 - if max_iter == 0: - break - - self.body = self.body[index:] - return number_list - - def _decode_number16(self) -> Dict[int, int]: - - number_map = {} - i = 0 # char cursor - c = 0 # counter (for Heyawake, this is Region Counter) - current_body = self.body - - while i < len(current_body): - char = current_body[i] - - val, length = self._read_number16(current_body, i) - if val != -1: - number_map[c] = val - i += length - c += 1 - elif 'g' <= char <= 'z': - skip_count = int(char, 36) - 15 - c += skip_count - i += 1 - else: - - i += 1 - - self.body = current_body[i:] - return number_map - - def _decode_number4(self) -> Dict[int, Union[int, str]]: - """Decode number4 format (0-4 with skip encoding)""" - number_map = {} - i = 0 - pos = 0 - - for char in self.body: - if char == '.': - number_map[pos] = '?' - elif '0' <= char <= '4': - number_map[pos] = int(char) - elif '5' <= char <= '9': - number_map[pos] = int(char) - 5 - pos += 1 - elif 'a' <= char <= 'e': - number_map[pos] = int(char, 16) - 10 - pos += 2 - elif 'g' <= char <= 'z': - pos += int(char, 36) - 16 - - pos += 1 - i += 1 - - return number_map - - def _decode_number3(self, max_iter: int = -1) -> List[int]: - """Decode number3 format (3 numbers per character)""" - number_list = [] - - for char in self.body: - if max_iter == 0: - break - - num = int(char, 36) - number_list.extend([ - (num // 9) % 3, - (num // 3) % 3, - (num // 1) % 3 - ]) - - max_iter -= 1 - - self.body = self.body[len(number_list) // 3:] - return number_list - - def _decode_yajilin_arrows(self, parsing_castle: bool = False) -> Dict[int, List[Any]]: - """Decode Yajilin arrows (or Castle arrows)""" - arrows = {} - i = 0 - c = 0 - shading = 0 - - while i < len(self.body): - ca = self.body[i] - if 'a' <= ca <= 'z': - c += int(ca, 36) - 9 - i += 1 - continue - - if parsing_castle: - shading = int(ca) - i += 1 - ca = self.body[i] - - number_length = 3 if ca == '-' else 1 - if ca == '-': - i += 1 - ca = self.body[i] - - direc = int(ca) - number_length += direc // 5 - - cell_value = self.body[i + 1:i + 1 + number_length] - if cell_value == '.': - cell_value = "" - else: - cell_value = str(int(cell_value, 16)) - - arrows[c] = [direc % 5, cell_value, shading] - c += 1 - i += number_length + 1 - - return arrows - - def _decode_border(self) -> Dict[int, int]: - """To get the region walls of grid. e.g., heyawake, jigsaw sudoku. - - Returns: - Dict[int, int]: _description_ - """ - border_list = {} - id_counter = 0 - twi = [16, 8, 4, 2, 1] # 5 bits mask - - # 1. Calculate the string length (JS: pos1, pos2 calculations) - # Vertical borders total: each row has cols-1 borders, total rows rows - num_vert_borders = (self.num_cols - 1) * self.num_rows - # Length = ceil(total / 5) - pos1 = (num_vert_borders + 4) // 5 - - # Horizontal borders total: each column has rows-1 borders, total cols columns - num_horiz_borders = self.num_cols * (self.num_rows - 1) - pos2 = pos1 + (num_horiz_borders + 4) // 5 - - # Extract the corresponding length of body string - border_str = self.body[:pos2] - # Update self.body, remove the read part (JS: this.gridurl.substr(pos2)) - self.body = self.body[pos2:] - - # 2. Parse vertical borders (Vertical Borders) - # ID range: 0 to (cols-1)*rows - 1 - for i in range(pos1): - if i >= len(border_str): break - # JS: parseInt(char, 32) - val = int(border_str[i], 32) - - for w in range(5): - if id_counter < num_vert_borders: - # Check bit: if (val & mask) - if val & twi[w]: - border_list[id_counter] = 1 - id_counter += 1 - - # 3. Parse horizontal borders (Horizontal Borders) - # ID range: after vertical borders - # Note: id_counter should now be num_vert_borders (if there is padding, it may be larger, but logically continue from here) - - - start_horiz_id = num_vert_borders - id_counter = start_horiz_id - - for i in range(pos1, pos2): - if i >= len(border_str): break - val = int(border_str[i], 32) - - for w in range(5): - if id_counter < start_horiz_id + num_horiz_borders: - if val & twi[w]: - border_list[id_counter] = 1 - id_counter += 1 - - return border_list - - def _convert_number_map_to_grid(self, number_map: Dict[int, Union[int, str]]) -> List[List[str]]: - grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] - for pos, val in number_map.items(): - r_, c_ = pos // self.num_cols, pos % self.num_cols - grid[r_][c_] = str(val) - return grid - - def _convert_one_two_2_white_black_grid(self, number_list: List[int], category: str = "default") -> List[List[str]]: - grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] - for i in range(len(number_list)): - if number_list[i] == 0: - continue - row_ind = i // self.num_cols - col_ind = i % self.num_cols - if category == "default": - if number_list[i] == 1: grid[row_ind][col_ind] = "w" - elif number_list[i] == 2: grid[row_ind][col_ind] = "b" - elif category == "moonsun": - if number_list[i] == 1: grid[row_ind][col_ind] = "o" - elif number_list[i] == 2: grid[row_ind][col_ind] = "x" - return grid - - def _convert_border_to_region_grid(self, border_list: Dict[int, int]) -> List[List[int]]: - - - rows, cols = self.num_rows, self.num_cols - num_vert = (cols - 1) * rows - - region_grid = [["x" for _ in range(cols)] for _ in range(rows)] - current_region_id = 0 - - for r in range(rows): - for c in range(cols): - if region_grid[r][c] == "x": - self._bfs_flood_fill(r, c, f"{current_region_id}", region_grid, border_list, num_vert) - current_region_id += 1 - - return region_grid - - def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, num_vert_borders): - """BFS helper function""" - queue = [(start_r, start_c)] - region_grid[start_r][start_c] = region_id - - while queue: - r, c = queue.pop(0) - - # --- Check four directions --- - - # 1. Left - if c > 0: - border_id = r * (self.num_cols - 1) + (c - 1) - if border_id not in border_list: - if region_grid[r][c-1] == "x": - region_grid[r][c-1] = region_id - queue.append((r, c-1)) - - # 2. Right - if c < self.num_cols - 1: - border_id = r * (self.num_cols - 1) + c - if border_id not in border_list: - if region_grid[r][c+1] == "x": - region_grid[r][c+1] = region_id - queue.append((r, c+1)) - - # 3. Up - if r > 0: - border_id = num_vert_borders + (r - 1) * self.num_cols + c - if border_id not in border_list: - if region_grid[r-1][c] == "x": - region_grid[r-1][c] = region_id - queue.append((r-1, c)) - - # 4. Down - if r < self.num_rows - 1: - border_id = num_vert_borders + r * self.num_cols + c - if border_id not in border_list: - if region_grid[r+1][c] == "x": - region_grid[r+1][c] = region_id - queue.append((r+1, c)) - - -if __name__ == "__main__": - # PzpParser = PuzzlinkParser("https://puzz.link/p?heyawake/24/14/499a0h55854kmgkk9a2ih54aa4kg98ii154a84kh914i544i8kgi92j294kc94ihg00001vg0fs0vvg6000vg0000vo00e0fvv00001vvg03g03vo3g00fovvv00000023g23g23h5h3454j44h643g03g4g3j1222h3") - PzpParser = PuzzlinkParser("https://puzz.link/p?norinori/10/10/90i2c76esik8rapah800evmv37d4fsm9dmte") - PzpParser = PuzzlinkParser("https://puzz.link/p?slither/10/10/ic5137bg7bchbgdccb7dgddg7ddabdgdhc7bg7316dbg") - PzpParser = PuzzlinkParser("https://puzz.link/p?norinori/10/10/90i2c76esik8rapah800evmv37d4fsm9dmte") - PzpParser = PuzzlinkParser("https://puzz.link/p?masyu/15/12/00010c2401000b00i00913j0190040136c3000033b0202090c919i00900c") - PzpParser = PuzzlinkParser("https://puzz.link/p?moonsun/10/10/4g90i152a4k98i142800003vs000vg0f00000632a66fi00i3f9i77000k6b20092f9a00") - PzpParser = PuzzlinkParser("https://puzz.link/p?yajirin/10/10/c23g42d22j41e11a41c31g31f21l33d41a11d11g31f32e") - PzpParser = PuzzlinkParser("https://puzz.link/p?yajilin/b/10/10/22202224zb41zh32zb11131213") - PzpParser = PuzzlinkParser("https://puzz.link/p?yajilin/b/6/6/e20b21r11b12e") - PzpParser = PuzzlinkParser("https://puzz.link/p?castle/12/12/224j234e122125g124f131r222b231e121h131b144h112e241b212r145f114g132142e244j214") - PzpParser = PuzzlinkParser("https://puzz.link/p?yajilin/17/17/21t30b40c22b2310d21b23b31b10g42b23b31b10g21f33a10c33c21b22b31b10c31f42b32b10g41b22b31b10g20c41a21b10g20a3121b31b42g20b21b31b10g41b21b31b10g42b21c12a32g43b21b32b10g20b21b30b10e11f41w3212") - PzpParser = PuzzlinkParser("https://puzz.link/p?vslither/6/6/338833ddg8d3dkd8d") - PzpParser = PuzzlinkParser("https://puzz.link/p?hitori/65/65/-3l-3k-3j-3i-3h-3g-3f-3e-3d-3c-3b-3a-39-38-37-36-35-34-33-32-31-30-2z-2y-2x-2w-2v-2u-2t-2s-2r-2q-2p-2o-2n-2m-2l-2k-2j-2i-2h-2g-2f-2e-2d-2c-2b-2a-29-28-27-26-25-24-23-22-21-20-1z-1y-1x-1w-1v-1u-1t-3k1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r-3j123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q-3i33557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r11-3h32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o-3g557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133-3f56769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q1232-3e7799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r113355-3d7694bad8fehcjil8nmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k-3c99bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r11335577-3b9abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q12325676-3abbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799-39badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694-38ddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bb-37defehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769aba-36ffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbdd-35fehcjil8nmpkrqtgvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1c-1r-1q1-1o325-1k7694bad-1c-34hhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddff-33hijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefe-32jjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhh-31jilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehc-30llnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjj-2zlmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehiji-2ynnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjll-2xnmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8-2wpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnn-2vpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnm-2urrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpp-2trqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpk-2sttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprr-2rtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrq-2qvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrtt-2pvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1pw-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtw-2oxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvv-2nxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvu-2mzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxx-2lzy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxs-2k-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-2j-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-2i-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-2h-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8nmpkrqtovuxszy-11o-2g-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-2f-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-2e-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-2d-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-2c-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-2b-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-2a-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-29-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1c-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtgvuxszy-11o-13-12-15-10-17-16-19g-28-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-27-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-26-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-25-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-24-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-23-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-22-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-21-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8nmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-20-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1z-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1y-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1x-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1w-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1v-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1u-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1t-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtwvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1t") - PzpParser = PuzzlinkParser("https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h") - PzpParser._parse_header() - res = PzpParser.parse() - print(res) - diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py index c49344c5..46d653ef 100644 --- a/src/puzzlekit/formats/base.py +++ b/src/puzzlekit/formats/base.py @@ -166,7 +166,7 @@ def __post_init__(self): if not self.pu_a: self.pu_a = PENPA_PU_X_DEFAULT.copy() if not self.mode_snapshot: self.mode_snapshot = PENPA_MODE_DEFAULT.copy() - + if not self.sol_check: self.sol_check = PENPA_SOL_CHECK_DICT_DEFAULT.copy() if not self.sol_check_or: self.sol_check_or = PENPA_SOL_CHECK_OR_DICT_DEFAULT.copy() @@ -226,6 +226,11 @@ class PuzzleInstance: regions: list[list[int]] = field(default_factory=list) # Heyawake 的房间区域 ID metadata: Dict[str, Any] = field(default_factory=dict) + # =============== Penpa params end =============== + + skip_shading: bool = True + rows_no_margin: int = 0 + cols_no_margin: int = 0 def __repr__(self): """Custom format. diff --git a/src/puzzlekit/formats/heyawake.py b/src/puzzlekit/formats/heyawake.py index 6c80feb3..26d54ae4 100644 --- a/src/puzzlekit/formats/heyawake.py +++ b/src/puzzlekit/formats/heyawake.py @@ -2,6 +2,7 @@ PuzzleInstance, CellState, EdgeState, PenpaMetadata, COMPRESS_SUB ) +from puzzlekit.formats.utils import generate_centerlist_diff from typing import Any, Dict, List, Optional, Tuple, Union import json from base64 import b64decode, b64encode @@ -27,33 +28,6 @@ def to_penpa_str(pu_x: Optional[Dict | List], apply_compression : bool = True): else: return json.dumps(pu_x, separators=(',', ':'), ensure_ascii=False) - -def penpa_encrypt(text: str) -> str: - """Raw Deflate + base64 match encrypt_data of JS""" - # UTF-8 - data = text.encode('utf-8') - # Raw Deflate: wbits = -15: no header/trailer - compressed = compress(data, level=9)[2:-4] # remove zlib header(2) and adler32(4) - # base64 encode(URL safe: Optional) - return b64encode(compressed).decode('ascii') - -def generate_centerlist_diff(rows: int, cols: int, margins: List[int] = [0, 0, 0, 0]): - """ - Auto pack centerlist (in default all cells are filled) - - FIX: must consider the margin part. - for part of `__export_list_tab_shared` in penpa+ - """ - prev, nx0 = 0, cols + 4 - centerlist = [] - top_m, bottom_m, left_m, right_m = margins - for row in range(2 + top_m, rows + 2 - bottom_m): - for col in range(2 + left_m, cols + 2 - right_m): - idx = col + row * nx0 - centerlist.append(idx - prev) - prev = idx - return centerlist - def calculate_center_n(nx: int, ny: int, size: int = 38) -> int: """ Simulate search_center() logic of penpa+ @@ -160,12 +134,6 @@ def __init__(self, url: str): } ) - def decode(self): - pass - - def encode(self): - pass - def index_to_coord(self, index: int, type_: str = 'edge') -> Tuple[Tuple[int, int], int]: """Convert the [Penpa+](https://swaroopg92.github.io/penpa-edit/) index to coordinate. @@ -182,7 +150,7 @@ def index_to_coord(self, index: int, type_: str = 'edge') -> Tuple[Tuple[int, in else: return (index // self.real_cols - 2, index % self.real_cols - 2), category - def coord_to_index(self, coord: Tuple[int, int], type_: str) -> Tuple[int, int]: + def coord_to_index(self, coord: Tuple[int, int] ,type_: str) -> Tuple[int, int]: assert type_ in ("edge", "cell"), f"Wrong index type for index_to_coord, expected 'cell', 'edge', get {type}" r_, c_ = coord if type_ == "edge": @@ -193,6 +161,7 @@ def coord_to_index(self, coord: Tuple[int, int], type_: str) -> Tuple[int, int]: def decode(self) -> PuzzleInstance: self.parts = decompress(b64decode(self.url[len(PENPA_PREFIX) :]), -15).decode().split("\n") header = self.parts[0].split(",") + assert header[0] in ("square", "sudoku", "kakuro"), "Penpa puzzle must be in square, sudoku, kakuro" # info collect @@ -228,6 +197,7 @@ def decode(self) -> PuzzleInstance: for k, v in self.board.items(): if k == "lineE": self.ir_puzzle.edges = self._decode_edge(edge_dict = v) + print(self.ir_puzzle.edges) elif k == "number": self.ir_puzzle.cells = self._decode_number(number_dict = v) else: @@ -289,9 +259,11 @@ def encode(self, inst: PuzzleInstance) -> str: center_n = calculate_center_n(inst.rows, inst.cols, mtd.size) center_list = generate_centerlist_diff(inst.rows, inst.cols, inst.margins) + self.real_rows = inst.rows + inst.margins[0] + inst.margins[1] + 4 # penpa size after padding + self.real_cols = inst.cols + inst.margins[2] + inst.margins[3] + 4 # 1. form pu_q dict, then update original_pu_q = mtd.pu_q - print(self.parts[3], "\n") + # print(self.parts[3], "\n") # augmented update(number/edge only: for now) original_pu_q["number"] = self._encode_number(inst.cells) @@ -337,12 +309,9 @@ def encode(self, inst: PuzzleInstance) -> str: ] for i in range(19): - if i in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]: - text_lines.append(to_pack_elem[i]) - else: - text_lines.append(self.parts[i]) + text_lines.append(to_pack_elem[i]) + # else: text_lines.append(self.parts[i]) - # 5. concatenate + compress + base64 plain_text = "\n".join(text_lines) compressed = compress(plain_text.encode())[2:-4] diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index ab749891..46e35c50 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -1,16 +1,458 @@ -from typing import Dict, List, Union +from typing import Dict, Any, List, Optional, Union, Set +from puzzlekit.formats.base import ( + PuzzleInstance, CellState, EdgeState +) +from puzzlekit.formats.utils import generate_centerlist_diff -class PuzzlinkConverter: - """_summary_ - Returns: - _type_: _description_ - """ +# Yajilin, Masyu, Slitherlink, heyawake, shikaku, norinori, hitori +class PuzzlinkConverter: def __init__(self, url: str): - pass + self.url: str = url + self.ir_puzzle = PuzzleInstance( + metadata={ + "source": "puzz.link", + "original_url": self.url, + } + ) + + self.body: str = "" + self.num_rows: int = 0 + self.num_cols: int = 0 + self.skip_shading: bool = True + self.puzzle_type: str = "" + + def _decode_heyawake_variant(self): + border_list = self._decode_border() + region_grid = self._convert_border_to_region_grid(border_list) + number_map = self._decode_number16() + grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + self._move_numbers_to_top_left_corner(grid, region_grid, number_map) + return (self.num_rows, self.num_cols, grid, region_grid) + + def _reindex_number(self, r: int, c: int, margins: List[int], + grid: List[List[str]], skip: Set[str] = set(), + color: int = 1, style: str = "1"): + + new_number_dict = dict() + top_m, bottom_m, left_m, right_m = margins + for r_ in range(r): + for c_ in range(c): + if grid[r_][c_] not in skip: + # idx = pad_index + r_ * (c + 4 + left_m + right_m) + (c_ + 2 + left_m) + # res_dict[f"{idx}"] = [grid[r_][c_], color, submode] + new_number_dict[(r_ + top_m, c_ + left_m)] = CellState(value = grid[r_][c_], num_color = color, num_style = style) + return new_number_dict + + def _reindex_edge(self, r: int, c: int, margins: List[int], + region_grid: List[List[str]], skip: Set[str] = set()): + new_edge_dict = dict() + top_m, bottom_m, left_m, right_m = margins + for r_ in range(r): + for c_ in range(c): + if r_ > 0: + if region_grid[r_][c_] != region_grid[r_ - 1][c_]: # top + new_edge_dict[((r_ + top_m, c_ + left_m) , (r_ + top_m, c_ + left_m + 1))] = EdgeState(connected = True, edge_type = 2) + if r_ < r - 1: + if region_grid[r_][c_] != region_grid[r_ + 1][c_]: # bottom + new_edge_dict[((r_ + top_m + 1, c_ + left_m) , (r_ + top_m + 1, c_ + left_m + 1))] = EdgeState(connected = True, edge_type = 2) + if c_ > 0: + if region_grid[r_][c_] != region_grid[r_][c_ - 1]: # left + new_edge_dict[((r_ + top_m, c_ + left_m) , (r_ + top_m + 1, c_ + left_m))] = EdgeState(connected = True, edge_type = 2) + if c_ < c - 1: + if region_grid[r_][c_] != region_grid[r_][c_ + 1]: # right + new_edge_dict[((r_ + top_m, c_ + left_m + 1) , (r_ + top_m + 1, c_ + left_m + 1))] = EdgeState(connected = True, edge_type = 2) + return new_edge_dict + + + def decode(self) -> Dict[str, Any]: + # 0. Parse the header url. + + self._parse_header() + + # If wanna add more puzzle types, just add the puzzle type to the list and implement the corresponding logic + if self.puzzle_type in ["yajilin", "yajirin", "snakes", "hebi", "castle"]: + return self._decode_yajilin_variant() + elif self.puzzle_type in ["moonsun","mashu", "masyu", "pearl"]: + return self._decode_masyu_variant() + elif self.puzzle_type in ["slither", "slitherlink", "vslither"]: + info_number = self._decode_number4() + grid_matrix = self._convert_number_map_to_grid(info_number) + return { + "num_rows": self.num_rows, + "num_cols": self.num_cols, + "grid": grid_matrix + } + + elif self.puzzle_type in [ + "heyawake", + "shikaku", + "aqre", + "heyawacky", + "shimaguni", + "stostone" + ]: + _, _, grid, region_grid = self._decode_heyawake_variant() + self.ir_puzzle.title = self.puzzle_type + self.ir_puzzle.rows = self.num_rows + self.ir_puzzle.cols = self.num_cols + self.ir_puzzle.margins = [0, 0, 0, 0] + self.ir_puzzle.source = self.url + self.ir_puzzle.cells = self._reindex_number(self.num_rows, self.num_cols, [0, 0, 0, 0], grid, skip = "-") + self.ir_puzzle.edges = self._reindex_edge(self.num_rows, self.num_cols, [0, 0, 0, 0], region_grid) + + print(region_grid) + print(self.ir_puzzle.cells) + print(self.ir_puzzle.edges) + print(len(self.ir_puzzle.edges.keys())) + print(len(self.ir_puzzle.cells.keys())) + + # puzzlink_pu.drawBorder(pu, info_edge, 2); // 2 is for Black Style + # puzzlink_pu.drawNumbers(pu, info_number, 1, "1") // Black Style, Normal submode is 1 + self.ir_puzzle.boxes = generate_centerlist_diff(self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins) + + # // Change to Solution Tab + # pu.mode_qa("pu_a"); + # pu.mode_set("surface"); //include redraw + # UserSettings.tab_settings = ["Surface"]; + + + elif self.puzzle_type in ["country", "detour", "juosan", "yajilin-regions", "yajirin-regions"]: + # toichika2, nagenawa, maxi, factors are neglected. + pass + elif self.puzzle_type in ["hitori"]: + info_number = self._decode_number36(self.num_cols * self.num_rows) + # print(self.num_rows, self.num_cols, len(info_number)) + # print(info_number) + grid_matrix = [["-" 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): + grid_matrix[i][j] = str(info_number[i * self.num_cols + j]) + return { + "num_rows": self.num_rows, + "num_cols": self.num_cols, + "grid": grid_matrix + } + # pu = new Puzzle_square(cols, rows, size); + # setupProblem(pu, "surface"); - def decode(self): - pass + # info_number = puzzlink_pu.decodeNumber36(cols * rows); + # puzzlink_pu.drawNumbers(pu, info_number, 1, "1", false); + + else: + raise NotImplementedError + + return self.ir_puzzle + + def _parse_header(self): + """Parse the header of the puzzle, such as: slither/10/10/body_str""" + parts = self.url.split("?") + urldata = parts[1].split("/") + if len(urldata) > 1 and urldata[1] == 'v:': + urldata.pop(1) + + self.puzzle_type = urldata[0] + self.skip_shading = (self.puzzle_type != "castle") and (self.puzzle_type != "hebi") + if urldata[1] == "b": + self.skip_shading = False + self.num_cols = int(urldata[2]) + self.num_rows = int(urldata[3]) + self.body = urldata[4] + return (self.puzzle_type, self.num_cols, self.num_rows, self.body) + else: + self.num_cols = int(urldata[1]) + self.num_rows = int(urldata[2]) + + # if cols > 65 or rows > 65: + # print("Penpa+ does not support grid size greater than 65 rows or columns") + # return None + + bstr = urldata[3] + self.body = bstr + + return (self.puzzle_type, self.num_cols, self.num_rows, self.body) + + + def _decode_yajilin_variant(self): + if self.puzzle_type == "yajirin": + self.puzzle_type = "yajilin" + elif self.puzzle_type == "snakes": + self.puzzle_type = "hebi" + # TODO: this pzl is not paid enough attention to, because neither data nor solver is implemented. + + parsing_castle = (self.puzzle_type == "castle") + arrows = self._decode_yajilin_arrows(parsing_castle) + + number_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + # shading_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + + for cell_index, arrow_data in arrows.items(): + direction, number_str, shading_type = arrow_data + row = cell_index // self.num_cols + col = cell_index % self.num_cols + + number = number_str + if self.skip_shading and not number_str: + number = "?" + + if direction != 0 and number_str: + direction_map = {1: "n", 2: "s", 3: "w", 4: "e"} # 上、下、左、右 + number = f"{number_str}{direction_map[direction]}" + + number_grid[row][col] = number + + # ===== For debug start ===== + # if not self.skip_shading: + # if shading_type == 0: + # shading_grid[row][col] = "L" # Light gray + # elif shading_type == 2: + # shading_grid[row][col] = "B" # Black + # elif shading_type == 1: + # shading_grid[row][col] = "N" # No shading + # else: + # shading_grid[row][col] = "-" + # ===== For debug end ===== + + if self.puzzle_type == "yajilin": + if shading_type == 2 and number == "-": + number_grid[row][col] = "x" + elif self.puzzle_type == "castle": + if shading_type == 2: + number_grid[row][col] = "x" if number == "-" else f"{number}x" + elif shading_type == 1: + number_grid[row][col] = "o" if number == "-" else f"{number}o" + else: + # snake puzzle, temporary not implemented. + pass + + return { + "num_rows": self.num_rows, + "num_cols": self.num_cols, + # "puzzle_type": self.puzzle_type, + "grid": number_grid, # grid + arrow matrix + # "shading": shading_grid, # bg grid + # "arrows": arrows # raw arrow data (ignored for now, available for debug) + } + + def _decode_masyu_variant(self): + if self.puzzle_type in ["moonsun"]: + + border_list = self._decode_border() + region_grid = self._convert_border_to_region_grid(border_list) + info_number = self._decode_number3() + grid = self._convert_one_two_2_white_black_grid(info_number, category = "moonsun") + return { + "num_rows": self.num_rows, + "num_cols": self.num_cols, + "grid": grid, + "region_grid": region_grid + } + else: + info_number = self._decode_number3() + grid = self._convert_one_two_2_white_black_grid(info_number) + return { + "num_rows": self.num_rows, + "num_cols": self.num_cols, + "grid": grid + } + + def _move_numbers_to_top_left_corner(self, + grid_matrix: List[List[str]], + region_grid: List[List[str]], + number_map: Dict[int, int]): + """ + Python implementation of moveNumbersToRegionCorners in + + https://github.com/marktekfan/sudokupad-penpa-import/src/penpa-loader/puzzlink.js + + Parse the {RegionID: Number} and fill it into the grid_matrix at the top left corner of the region. + """ + + # 1. Find the top left corner of each region + # region_start_points: {region_id: (r, c)} + region_start_points = {} + + for r in range(self.num_rows): + for c in range(self.num_cols): + r_id = region_grid[r][c] + if r_id not in region_start_points: + # because the iteration is from top to bottom, and left to right + region_start_points[r_id] = (r, c) + + for r_id_raw, val in number_map.items(): + r_id = str(r_id_raw) + if r_id in region_start_points: + r, c = region_start_points[r_id] + grid_matrix[r][c] = str(val) + else: + print(f"Warning: Number for Region {r_id} found, but region not does not exist in grid.") + return grid_matrix + + def _read_number16(self, body_str: str, i: int): + if i >= len(body_str): + return -1, 0 + + char = body_str[i] + + if ('0' <= char <= '9') or ('a' <= char <= 'f'): + return int(char, 16), 1 + + elif char == '-': + return int(body_str[i+1 : i+3], 16), 3 + elif char == '+': + return int(body_str[i+1 : i+4], 16), 4 + elif char == '=': + return int(body_str[i+1 : i+4], 16) + 4096, 4 + elif char == '%': + return int(body_str[i+1 : i+4], 16) + 8192, 4 + elif char == '*': + return int(body_str[i+1 : i+5], 16) + 12240, 5 + elif char == '$': + return int(body_str[i+1 : i+6], 16) + 77776, 6 + + elif char == '.': + return '?', 1 + + return -1, 0 + + + def _decode_number36(self, max_iter: int = -1) -> List[Union[int, str]]: + + number_list = [] + index = 0 + + while index < len(self.body) and max_iter != 0: + char = self.body[index] + + if char == '-': + number_list.append(int(self.body[index+1:index+3], 36)) + index += 3 # 应该是3,不是2! + elif char == '%': + number_list.append('?') + index += 1 + elif char == '.': + number_list.append(' ') + index += 1 + else: + number_list.append(int(char, 36)) + index += 1 + + max_iter -= 1 + if max_iter == 0: + break + + self.body = self.body[index:] + return number_list + + def _decode_number16(self) -> Dict[int, int]: + + number_map = {} + i = 0 # char cursor + c = 0 # counter (for Heyawake, this is Region Counter) + current_body = self.body + + while i < len(current_body): + char = current_body[i] + + val, length = self._read_number16(current_body, i) + if val != -1: + number_map[c] = val + i += length + c += 1 + elif 'g' <= char <= 'z': + skip_count = int(char, 36) - 15 + c += skip_count + i += 1 + else: + + i += 1 + + self.body = current_body[i:] + return number_map + + def _decode_number4(self) -> Dict[int, Union[int, str]]: + """Decode number4 format (0-4 with skip encoding)""" + number_map = {} + i = 0 + pos = 0 + + for char in self.body: + if char == '.': + number_map[pos] = '?' + elif '0' <= char <= '4': + number_map[pos] = int(char) + elif '5' <= char <= '9': + number_map[pos] = int(char) - 5 + pos += 1 + elif 'a' <= char <= 'e': + number_map[pos] = int(char, 16) - 10 + pos += 2 + elif 'g' <= char <= 'z': + pos += int(char, 36) - 16 + + pos += 1 + i += 1 + + return number_map + + def _decode_number3(self, max_iter: int = -1) -> List[int]: + """Decode number3 format (3 numbers per character)""" + number_list = [] + + for char in self.body: + if max_iter == 0: + break + + num = int(char, 36) + number_list.extend([ + (num // 9) % 3, + (num // 3) % 3, + (num // 1) % 3 + ]) + + max_iter -= 1 + + self.body = self.body[len(number_list) // 3:] + return number_list + + def _decode_yajilin_arrows(self, parsing_castle: bool = False) -> Dict[int, List[Any]]: + """Decode Yajilin arrows (or Castle arrows)""" + arrows = {} + i = 0 + c = 0 + shading = 0 + + while i < len(self.body): + ca = self.body[i] + if 'a' <= ca <= 'z': + c += int(ca, 36) - 9 + i += 1 + continue + + if parsing_castle: + shading = int(ca) + i += 1 + ca = self.body[i] + + number_length = 3 if ca == '-' else 1 + if ca == '-': + i += 1 + ca = self.body[i] + + direc = int(ca) + number_length += direc // 5 + + cell_value = self.body[i + 1:i + 1 + number_length] + if cell_value == '.': + cell_value = "" + else: + cell_value = str(int(cell_value, 16)) + + arrows[c] = [direc % 5, cell_value, shading] + c += 1 + i += number_length + 1 + + return arrows def _decode_border(self) -> Dict[int, int]: """To get the region walls of grid. e.g., heyawake, jigsaw sudoku. @@ -18,7 +460,6 @@ def _decode_border(self) -> Dict[int, int]: Returns: Dict[int, int]: _description_ """ - # TODO: border_list = {} id_counter = 0 twi = [16, 8, 4, 2, 1] # 5 bits mask @@ -71,30 +512,110 @@ def _decode_border(self) -> Dict[int, int]: id_counter += 1 return border_list + + def _convert_number_map_to_grid(self, number_map: Dict[int, Union[int, str]]) -> List[List[str]]: + grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + for pos, val in number_map.items(): + r_, c_ = pos // self.num_cols, pos % self.num_cols + grid[r_][c_] = str(val) + return grid - def _read_number16(self, body_str: str, i: int): - if i >= len(body_str): - return -1, 0 - - char = body_str[i] + def _convert_one_two_2_white_black_grid(self, number_list: List[int], category: str = "default") -> List[List[str]]: + grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] + for i in range(len(number_list)): + if number_list[i] == 0: + continue + row_ind = i // self.num_cols + col_ind = i % self.num_cols + if category == "default": + if number_list[i] == 1: grid[row_ind][col_ind] = "w" + elif number_list[i] == 2: grid[row_ind][col_ind] = "b" + elif category == "moonsun": + if number_list[i] == 1: grid[row_ind][col_ind] = "o" + elif number_list[i] == 2: grid[row_ind][col_ind] = "x" + return grid + + def _convert_border_to_region_grid(self, border_list: Dict[int, int]) -> List[List[int]]: - if ('0' <= char <= '9') or ('a' <= char <= 'f'): - return int(char, 16), 1 + + rows, cols = self.num_rows, self.num_cols + num_vert = (cols - 1) * rows + + region_grid = [["x" for _ in range(cols)] for _ in range(rows)] + current_region_id = 0 + + for r in range(rows): + for c in range(cols): + if region_grid[r][c] == "x": + self._bfs_flood_fill(r, c, f"{current_region_id}", region_grid, border_list, num_vert) + current_region_id += 1 + + return region_grid + + def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, num_vert_borders): + """BFS helper function""" + queue = [(start_r, start_c)] + region_grid[start_r][start_c] = region_id + + while queue: + r, c = queue.pop(0) - elif char == '-': - return int(body_str[i+1 : i+3], 16), 3 - elif char == '+': - return int(body_str[i+1 : i+4], 16), 4 - elif char == '=': - return int(body_str[i+1 : i+4], 16) + 4096, 4 - elif char == '%': - return int(body_str[i+1 : i+4], 16) + 8192, 4 - elif char == '*': - return int(body_str[i+1 : i+5], 16) + 12240, 5 - elif char == '$': - return int(body_str[i+1 : i+6], 16) + 77776, 6 + # --- Check four directions --- - elif char == '.': - return '?', 1 + # 1. Left + if c > 0: + border_id = r * (self.num_cols - 1) + (c - 1) + if border_id not in border_list: + if region_grid[r][c-1] == "x": + region_grid[r][c-1] = region_id + queue.append((r, c-1)) + + # 2. Right + if c < self.num_cols - 1: + border_id = r * (self.num_cols - 1) + c + if border_id not in border_list: + if region_grid[r][c+1] == "x": + region_grid[r][c+1] = region_id + queue.append((r, c+1)) - return -1, 0 \ No newline at end of file + # 3. Up + if r > 0: + border_id = num_vert_borders + (r - 1) * self.num_cols + c + if border_id not in border_list: + if region_grid[r-1][c] == "x": + region_grid[r-1][c] = region_id + queue.append((r-1, c)) + + # 4. Down + if r < self.num_rows - 1: + border_id = num_vert_borders + r * self.num_cols + c + if border_id not in border_list: + if region_grid[r+1][c] == "x": + region_grid[r+1][c] = region_id + queue.append((r+1, c)) + + +if __name__ == "__main__": + # PzpParser = PuzzlinkParser("https://puzz.link/p?heyawake/24/14/499a0h55854kmgkk9a2ih54aa4kg98ii154a84kh914i544i8kgi92j294kc94ihg00001vg0fs0vvg6000vg0000vo00e0fvv00001vvg03g03vo3g00fovvv00000023g23g23h5h3454j44h643g03g4g3j1222h3") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?norinori/10/10/90i2c76esik8rapah800evmv37d4fsm9dmte") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?slither/10/10/ic5137bg7bchbgdccb7dgddg7ddabdgdhc7bg7316dbg") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?norinori/10/10/90i2c76esik8rapah800evmv37d4fsm9dmte") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?masyu/15/12/00010c2401000b00i00913j0190040136c3000033b0202090c919i00900c") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?moonsun/10/10/4g90i152a4k98i142800003vs000vg0f00000632a66fi00i3f9i77000k6b20092f9a00") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?yajirin/10/10/c23g42d22j41e11a41c31g31f21l33d41a11d11g31f32e") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?yajilin/b/10/10/22202224zb41zh32zb11131213") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?yajilin/b/6/6/e20b21r11b12e") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?castle/12/12/224j234e122125g124f131r222b231e121h131b144h112e241b212r145f114g132142e244j214") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?yajilin/17/17/21t30b40c22b2310d21b23b31b10g42b23b31b10g21f33a10c33c21b22b31b10c31f42b32b10g41b22b31b10g20c41a21b10g20a3121b31b42g20b21b31b10g41b21b31b10g42b21c12a32g43b21b32b10g20b21b30b10e11f41w3212") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?vslither/6/6/338833ddg8d3dkd8d") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?hitori/65/65/-3l-3k-3j-3i-3h-3g-3f-3e-3d-3c-3b-3a-39-38-37-36-35-34-33-32-31-30-2z-2y-2x-2w-2v-2u-2t-2s-2r-2q-2p-2o-2n-2m-2l-2k-2j-2i-2h-2g-2f-2e-2d-2c-2b-2a-29-28-27-26-25-24-23-22-21-20-1z-1y-1x-1w-1v-1u-1t-3k1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r-3j123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q-3i33557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r11-3h32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o-3g557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133-3f56769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q1232-3e7799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r113355-3d7694bad8fehcjil8nmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k-3c99bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r11335577-3b9abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q12325676-3abbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799-39badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694-38ddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bb-37defehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769aba-36ffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbdd-35fehcjil8nmpkrqtgvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1c-1r-1q1-1o325-1k7694bad-1c-34hhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddff-33hijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefe-32jjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhh-31jilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehc-30llnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjj-2zlmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehiji-2ynnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjll-2xnmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8-2wpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnn-2vpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnm-2urrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpp-2trqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpk-2sttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprr-2rtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrq-2qvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrtt-2pvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1pw-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtw-2oxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvv-2nxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvu-2mzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxx-2lzy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxs-2k-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-2j-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-2i-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-2h-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8nmpkrqtovuxszy-11o-2g-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-2f-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-2e-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-2d-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-2c-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-2b-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-2a-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-29-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1c-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtgvuxszy-11o-13-12-15-10-17-16-19g-28-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-27-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-26-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-25-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-24-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-23-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-22-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-21-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8nmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-20-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1z-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1y-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1x-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1w-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1v-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1u-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1t-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtwvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1t") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h") + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?shimaguni/10/10/884aha5h85jshipgi17vjqfmudt1buhlti1ui2h34g3m") + + + res = PzpCvtr.decode() + from puzzlekit.formats.heyawake import HeyawakePenpaConverter + penpa_url = HeyawakePenpaConverter("") + print(penpa_url.encode(res)) + + diff --git a/src/puzzlekit/formats/utils.py b/src/puzzlekit/formats/utils.py new file mode 100644 index 00000000..61b7ca66 --- /dev/null +++ b/src/puzzlekit/formats/utils.py @@ -0,0 +1,18 @@ +from typing import List + +def generate_centerlist_diff(rows: int, cols: int, margins: List[int] = [0, 0, 0, 0]): + """ + Auto pack centerlist (in default all cells are filled) + + FIX: must consider the margin part. + for part of `__export_list_tab_shared` in penpa+ + """ + prev, nx0 = 0, cols + 4 + centerlist = [] + top_m, bottom_m, left_m, right_m = margins + for row in range(2 + top_m, rows + 2 - bottom_m): + for col in range(2 + left_m, cols + 2 - right_m): + idx = col + row * nx0 + centerlist.append(idx - prev) + prev = idx + return centerlist \ No newline at end of file From 9c4d43c165377bb11b59084733208b70b66a4183 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Mon, 9 Mar 2026 01:25:22 +0800 Subject: [PATCH 05/17] penpa to puzzlink url heyawake --- .../{heyawake.py => penpa_converter.py} | 6 +- src/puzzlekit/formats/puzzlink_converter.py | 285 ++++++++++++++++-- 2 files changed, 263 insertions(+), 28 deletions(-) rename src/puzzlekit/formats/{heyawake.py => penpa_converter.py} (99%) diff --git a/src/puzzlekit/formats/heyawake.py b/src/puzzlekit/formats/penpa_converter.py similarity index 99% rename from src/puzzlekit/formats/heyawake.py rename to src/puzzlekit/formats/penpa_converter.py index 26d54ae4..f3b4b5ab 100644 --- a/src/puzzlekit/formats/heyawake.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -122,7 +122,7 @@ def calculate_center_n(nx: int, ny: int, size: int = 38) -> int: return closest_idx -class HeyawakePenpaConverter: +class PenpaConverter: """Convert Penpa to PuzzleInstance """ def __init__(self, url: str): @@ -274,8 +274,6 @@ def encode(self, inst: PuzzleInstance) -> str: # 3. construct text_lines text_lines = [] - # print(original_pu_q) - to_pack_elem = [ ",".join(map(str, [ inst.grid_type, inst.cols, inst.rows, mtd.size, mtd.theta, mtd.reflect[0], mtd.reflect[1], @@ -330,7 +328,7 @@ def encode(self, inst: PuzzleInstance) -> str: "m=edit&p=1VVfb9s2EH/3pyAIFEgAxbbkP7H1lqXNXtqsq70VhWAEtMRYhCXSo8g4VpB+jX2gfbHekU4txV6BPWzAIOt8+ul497szf3T1h2WaB2EfP8NRAN9wDacjd0eTsbv7+2suTMFjcmu1WLMlJ2cfmdDVOTmjITGKTOl5cGVNrnRMPljNDLlmksyVNCzIjdlUca+33W67q3Jj67rgVTdVZW9ZqFUv6kdRr3/Zk/vUFxvMfLHcXZSY6CJl8sJgol7wO9OCGaEkUfcHKtpCvpjMcpZxUqmSE15uzI6kvCgqAIjJgY/JOVlpkRFRkUw8iIyDK4G6lRWuzAhMg1VvomvCWZqTFEsKKeSK8EeWmmJHzFYRacsl1xU5E7IynGXIREl+TpjMSM4eMJ7BRAwrSCVq4AJjLnBESKCyJS5At5GsS+bw0OZBSraDxFhVWaCTCbZSkhXFrkuuCsjtY32Ppa0MgUkAZ8lTA/hWmNz3oaCYxq6W1hCpSPTXnxEMQtkNMvHrYcS4nEsjNIeSPnn3TfQWPjdKE2aNKmHyKYyzsO4XSHOerqFbTO1aO0WoZHrt5kyWBUvXBFP5SCy+0mzXDX65uQnuWVHxTrLfbItOQkMa0AjukC6+1rOvCaVBuOg81Z/ip/ouThbPQf3bwZ0c3Fn8BPY2fqKDiMYJHcBClyagoxAByPodmCIApV6A8QCBSQO4RGB0ACYTBKYHIOy7kEbWMHIxlw3EUxk3kfGr0uFwiMiwgXg232OgrdA198XZG2cjZ+fQe1APnH3rbN/ZkbPvXcw7Zz87e+3s0Nmxi7nE6XU6ycAfA+1r9P/DcA/NrL5nKaewbyjs27tq/xwbbXngIK9AGrsN6KFCqU0hJIQ1QLGSCqRx6hWCPFudil8qnb3KvgUJtwB/DLegVOi0aENGi9Yz01ptWwjIM28BS2bgyK5ysWlnApW3CRjWpsjWcMy2cx96fu7QR+ruJAqicRBOUJXTuL4K6p/9bn3RbVD/CrL8ENe3qEqvYNyMLmgA7jvvRuB+du8RvPaRfXBv97sf3C/g+rHcvfcrPsZJPQ8olvnJLUGXluoBmHoa+Az/MkvoJaGNafg3lc3U2r4IDMV15dnOfswW3R+xRW7/Mtvp4tn/DP1/dCD+B+fH415pSh/E1thHAJ8QHKAnhbXHj7QF+JGKsOCxkAA9oSVAX8sJoGNFAXgkKsD+RleY9bW0kNVrdWGpI4FhqabGkkXnGw==" ]: - hpc = HeyawakePenpaConverter(url = test_url) + hpc = PenpaConverter(url = test_url) tmp = hpc.decode() enc = hpc.encode(tmp) print(enc) diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index 46e35c50..a8c40ab9 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -1,9 +1,14 @@ from typing import Dict, Any, List, Optional, Union, Set from puzzlekit.formats.base import ( - PuzzleInstance, CellState, EdgeState + PuzzleInstance, CellState, EdgeState, PenpaMetadata ) from puzzlekit.formats.utils import generate_centerlist_diff +ALLOWED_PUZZLE_TYPE = { + "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone" +} + +# allowed puzzle types # Yajilin, Masyu, Slitherlink, heyawake, shikaku, norinori, hitori class PuzzlinkConverter: @@ -26,6 +31,8 @@ def _decode_heyawake_variant(self): border_list = self._decode_border() region_grid = self._convert_border_to_region_grid(border_list) number_map = self._decode_number16() + print(number_map) + print(border_list) grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] self._move_numbers_to_top_left_corner(grid, region_grid, number_map) return (self.num_rows, self.num_cols, grid, region_grid) @@ -67,7 +74,7 @@ def _reindex_edge(self, r: int, c: int, margins: List[int], def decode(self) -> Dict[str, Any]: # 0. Parse the header url. - + print(self.url) self._parse_header() # If wanna add more puzzle types, just add the puzzle type to the list and implement the corresponding logic @@ -102,8 +109,6 @@ def decode(self) -> Dict[str, Any]: self.ir_puzzle.edges = self._reindex_edge(self.num_rows, self.num_cols, [0, 0, 0, 0], region_grid) print(region_grid) - print(self.ir_puzzle.cells) - print(self.ir_puzzle.edges) print(len(self.ir_puzzle.edges.keys())) print(len(self.ir_puzzle.cells.keys())) @@ -116,7 +121,6 @@ def decode(self) -> Dict[str, Any]: # pu.mode_set("surface"); //include redraw # UserSettings.tab_settings = ["Surface"]; - elif self.puzzle_type in ["country", "detour", "juosan", "yajilin-regions", "yajirin-regions"]: # toichika2, nagenawa, maxi, factors are neglected. pass @@ -144,6 +148,96 @@ def decode(self) -> Dict[str, Any]: return self.ir_puzzle + + def encode(self, inst: PuzzleInstance) -> str: + """Encode PuzzleInstance to puzz.link url. + + Args: + inst (PuzzleInstance): Input intermediate representation instance. + + Returns: + str: puzz.link url. + """ + mtd = PenpaMetadata() + + assert inst.grid_type in ["square"], f"Puzzle grid type must be 'square', get {inst.grid_type}." + assert inst.puzzle_type in ALLOWED_PUZZLE_TYPE, f"Puzzle {inst.puzzle_type} has not been implemented yet... " + + self.puzzle_type = inst.puzzle_type + self.num_rows, self.num_cols = inst.rows - inst.margins[0] - inst.margins[1], inst.cols - inst.margins[2] - inst.margins[3] + if self.puzzle_type in ["heyawake", "shikaku", "aqre","heyawacky","shimaguni","stostone"]: + self.body = self._encode_heyawake_variant(inst) + else: + raise NotImplementedError(f"Puzzle type {self.puzzle_type} not supported for encoding") + + # _decode_heyawake_variant + pass + + def _encode_heyawake_variant(self, inst: PuzzleInstance): + border_list = self._region_grid_to_borders(inst.edges) + region_grid = self._convert_border_to_region_grid(border_list) + number_map: Dict[int, Any] = dict() + for k, cell_state in inst.cells.items(): + (r_, c_) = k + val = int(cell_state.value) if cell_state.value.isdigit() else cell_state.value + number_map[int(region_grid[r_][c_])] = val + + + # print(region_grid) + # print(number_map) + border_str = self._encode_border(border_list) + + # 5. 编码 number_map → 16 进制字符串 + number_str = self._encode_number16(number_map) + + # 6. 拼接 body + body = f"https://puzz.link/p?{inst.puzzle_type}/{inst.cols}/{inst.rows}/{border_str + number_str}" + print("FINAL ", body) + # print("EXTRACTED", border_list) + # comp = {1: 1, 6: 1, 12: 1, 16: 1, 18: 1, 20: 1, 24: 1, 26: 1, 28: 1, 32: 1, 34: 1, 35: 1, 39: 1, 41: 1, 47: 1, 49: 1, 50: 1, 53: 1, 54: 1, 55: 1, 56: 1, 57: 1, 60: 1, 64: 1, 65: 1, 68: 1, 70: 1, 71: 1, 74: 1, 75: 1, 80: 1, 83: 1, 89: 1, 92: 1, 93: 1, 94: 1, 95: 1, 96: 1, 97: 1, 98: 1, 99: 1, 100: 1, 103: 1, 104: 1, 105: 1, 106: 1, 108: 1, 111: 1, 112: 1, 113: 1, 114: 1, 115: 1, 117: 1, 118: 1, 120: 1, 121: 1, 122: 1, 123: 1, 126: 1, 127: 1, 129: 1, 130: 1, 131: 1, 132: 1, 134: 1, 139: 1, 141: 1, 143: 1, 144: 1, 145: 1, 146: 1, 147: 1, 148: 1, 150: 1, 154: 1, 155: 1, 157: 1, 159: 1, 160: 1, 161: 1, 162: 1, 164: 1, 165: 1, 168: 1, 174: 1, 175: 1, 176: 1, 177: 1, 178: 1} + # for k_ , v_ in comp.items(): + # if k_ not in border_list: + # print(k_) + # assert len(comp.keys()) == len(border_list.keys()) + + + pass + + + def _region_grid_to_borders(self, edges_dict: Dict[Any, List[EdgeState]]) -> Dict[int, int]: + """ + Reconstruct edge dict from region_grid. + + Reverse operation of _decode_border + + Returns: + {border_id: 1} # 1 + """ + border_list = {} + # calculate vertical cells offset + num_vert_borders = self.num_rows * (self.num_cols - 1) + + for (p1, p2), edge_state in edges_dict.items(): + # only handle "bolder line" edges (edge_type = 2 ==> black border) + if not edge_state.connected or edge_state.edge_type != 2: + continue + + (r1, c1), (r2, c2) = p1, p2 + sorted_v = sorted([(r1, c1), (r2, c2)], key=lambda x: (x[0], x[1])) + (r_a, c_a), (r_b, c_b) = sorted_v + if c_a == c_b and r_b == r_a + 1: + c, r = c_a - 1, r_a + if 0 <= r < self.num_rows and 0 <= c < self.num_cols - 1: + vert_border_id = r * (self.num_cols - 1) + c + border_list[vert_border_id] = 1 + elif r_a == r_b and c_b == c_a + 1: + r, c = r_a - 1, c_a + if 0 <= r < self.num_rows - 1 and 0 <= c < self.num_cols: + horiz_border_id = num_vert_borders + r * self.num_cols + c + border_list[horiz_border_id] = 1 + + return border_list + def _parse_header(self): """Parse the header of the puzzle, such as: slither/10/10/body_str""" parts = self.url.split("?") @@ -201,18 +295,6 @@ def _decode_yajilin_variant(self): number_grid[row][col] = number - # ===== For debug start ===== - # if not self.skip_shading: - # if shading_type == 0: - # shading_grid[row][col] = "L" # Light gray - # elif shading_type == 2: - # shading_grid[row][col] = "B" # Black - # elif shading_type == 1: - # shading_grid[row][col] = "N" # No shading - # else: - # shading_grid[row][col] = "-" - # ===== For debug end ===== - if self.puzzle_type == "yajilin": if shading_type == 2 and number == "-": number_grid[row][col] = "x" @@ -326,7 +408,7 @@ def _decode_number36(self, max_iter: int = -1) -> List[Union[int, str]]: if char == '-': number_list.append(int(self.body[index+1:index+3], 36)) - index += 3 # 应该是3,不是2! + index += 3 # elif char == '%': number_list.append('?') index += 1 @@ -343,6 +425,122 @@ def _decode_number36(self, max_iter: int = -1) -> List[Union[int, str]]: self.body = self.body[index:] return number_list + + def _encode_number16(self, number_map: Dict[int, Any]) -> str: + """ + reverse operation of _decode_number16. + + 参数: + number_map: Dict[int, Optional[int, str]] + key = region_id (int 0 开始的连续/非连续整数) + value = 整数 或 '?' + + 返回: + str: 16 进制压缩字符串,可直接拼接到 body 中 + """ + if not number_map: + return "" + + result = [] + max_region_id = max(number_map.keys()) + current_id = 0 + skip_count = 0 + + while current_id <= max_region_id: + if current_id in number_map: + # 🔹 先输出累积的跳过 + if skip_count > 0: + result.append(self._encode_skip(skip_count)) + skip_count = 0 + + # 🔹 输出当前值 + val = number_map[current_id] + result.append(self._encode_value(val)) + else: + # 🔹 累积跳过计数 + skip_count += 1 + + current_id += 1 + + # 注意:末尾的跳过通常不需要编码,因为解码时字符串结束就停止 + # 但如果需要明确跳过,可以取消下面注释 + # if skip_count > 0: + # result.append(self._encode_skip(skip_count)) + + return ''.join(result) + + + def _encode_skip(self, count: int) -> str: + """ + 编码跳过 count 个区域,使用 g-z 字符 + + 映射关系: + g (36 进制=16) → skip 1 个 + h (17) → skip 2 个 + ... + z (35) → skip 20 个 + + 如果 count > 20,用多个字符拼接 + """ + result = [] + while count > 0: + if count >= 20: + result.append('z') + count -= 20 + else: + # g=1, h=2, ..., z=20 + result.append(chr(ord('g') + count - 1)) + count = 0 + return ''.join(result) + + + def _encode_value(self, val: Any) -> str: + """ + _read_number16 的逆函数,编码单个值 + + 编码格式对照表: + | 值范围 | 前缀 | 后缀长度 | 示例 | + |------------|------|---------|----------| + | 0-15 | 无 | 1 字符 | 'a' | + | 16-255 | - | 2 字符 | '-ff' | + | 256-4095 | + | 3 字符 | '+fff' | + | 4096-8191 | = | 3 字符 | '=000' | + | 8192-12287 | % | 3 字符 | '%000' | + | 12288-77775| * | 4 字符 | '*0000' | + | 77776+ | $ | 5 字符 | '$00000' | + | '?' | . | 1 字符 | '.' | + """ + if val == '?': + return '.' + elif isinstance(val, int): + if 0 <= val <= 15: + return format(val, 'x') # 0-9, a-f + elif 16 <= val <= 255: + return '-' + format(val, '02x') + elif 256 <= val <= 4095: + return '+' + format(val, '03x') + elif 4096 <= val <= 8191: + return '=' + format(val - 4096, '03x') + elif 8192 <= val <= 12287: + return '%' + format(val - 8192, '03x') + elif 12288 <= val <= 77775: + return '*' + format(val - 12240, '04x') # ⚠️ 注意是 12240 + else: # val >= 77776 + return '$' + format(val - 77776, '05x') + else: + # 非法值,默认用 '-' 编码 0 + return '-' + + + + def _int_to_base32(self, val: int) -> str: + """integer -> base32 (0-9, a-v)""" + if 0 <= val <= 9: + return str(val) + elif 10 <= val <= 31: + return chr(ord('a') + val - 10) + else: + raise ValueError(f"Value {val} out of range for base32") def _decode_number16(self) -> Dict[int, int]: @@ -364,9 +562,7 @@ def _decode_number16(self) -> Dict[int, int]: c += skip_count i += 1 else: - i += 1 - self.body = current_body[i:] return number_map @@ -454,6 +650,44 @@ def _decode_yajilin_arrows(self, parsing_castle: bool = False) -> Dict[int, List return arrows + def _encode_border(self, border_list: Dict[int, Optional[str | int]]) -> str: + """ + encode border_list to base32 str + reverse operation of _decode_border + """ + num_vert_borders = (self.num_cols - 1) * self.num_rows + num_horiz_borders = self.num_cols * (self.num_rows - 1) + total_borders = num_vert_borders + num_horiz_borders + + # 2. bitarray (each border 1 bit) + bits = [0] * total_borders + for border_id in border_list: + if 0 <= border_id < total_borders: + bits[border_id] = 1 + + # 3. every 5 bits pack into 1 str of base32 + twi = [16, 8, 4, 2, 1] # 5 bits mask + result_chars = [] + + # vertical border + for i in range(0, num_vert_borders, 5): + val = 0 + for w in range(5): + if i + w < num_vert_borders and bits[i + w]: + val |= twi[w] + result_chars.append(self._int_to_base32(val)) + + # horizontal border + for i in range(num_vert_borders, total_borders, 5): + val = 0 + for w in range(5): + if i + w < total_borders and bits[i + w]: + val |= twi[w] + result_chars.append(self._int_to_base32(val)) + + return ''.join(result_chars) + + def _decode_border(self) -> Dict[int, int]: """To get the region walls of grid. e.g., heyawake, jigsaw sudoku. @@ -537,7 +771,6 @@ def _convert_one_two_2_white_black_grid(self, number_list: List[int], category: def _convert_border_to_region_grid(self, border_list: Dict[int, int]) -> List[List[int]]: - rows, cols = self.num_rows, self.num_cols num_vert = (cols - 1) * rows @@ -611,11 +844,15 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, PzpCvtr = PuzzlinkConverter("https://puzz.link/p?hitori/65/65/-3l-3k-3j-3i-3h-3g-3f-3e-3d-3c-3b-3a-39-38-37-36-35-34-33-32-31-30-2z-2y-2x-2w-2v-2u-2t-2s-2r-2q-2p-2o-2n-2m-2l-2k-2j-2i-2h-2g-2f-2e-2d-2c-2b-2a-29-28-27-26-25-24-23-22-21-20-1z-1y-1x-1w-1v-1u-1t-3k1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r-3j123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q-3i33557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r11-3h32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o-3g557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133-3f56769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q1232-3e7799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r113355-3d7694bad8fehcjil8nmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k-3c99bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r11335577-3b9abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q12325676-3abbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799-39badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694-38ddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bb-37defehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769aba-36ffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbdd-35fehcjil8nmpkrqtgvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1c-1r-1q1-1o325-1k7694bad-1c-34hhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddff-33hijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefe-32jjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhh-31jilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehc-30llnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjj-2zlmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehiji-2ynnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjll-2xnmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8-2wpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnn-2vpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnm-2urrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpp-2trqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpk-2sttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprr-2rtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrq-2qvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrtt-2pvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1pw-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtw-2oxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvv-2nxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvu-2mzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxx-2lzy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxs-2k-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-2j-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-2i-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-2h-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8nmpkrqtovuxszy-11o-2g-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-2f-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-2e-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-2d-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-2c-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-2b-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-2a-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-29-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1c-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtgvuxszy-11o-13-12-15-10-17-16-19g-28-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-27-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-26-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-25-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-24-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-23-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-22-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-21-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8nmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-20-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1z-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1y-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1x-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1w-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1v-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1u-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1t-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtwvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1t") PzpCvtr = PuzzlinkConverter("https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h") PzpCvtr = PuzzlinkConverter("https://puzz.link/p?shimaguni/10/10/884aha5h85jshipgi17vjqfmudt1buhlti1ui2h34g3m") - + PzpCvtr = PuzzlinkConverter("https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g22.i33k2g") res = PzpCvtr.decode() - from puzzlekit.formats.heyawake import HeyawakePenpaConverter + from puzzlekit.formats.penpa_converter import HeyawakePenpaConverter penpa_url = HeyawakePenpaConverter("") - print(penpa_url.encode(res)) + penpa_test = penpa_url.encode(res) + + # PzpCvtr.encode(res) + new_pzp = PuzzlinkConverter("") + new_pzp.encode() From 7758c596b35eadd23f5f5508c0040197c909315e Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Tue, 10 Mar 2026 01:12:57 +0800 Subject: [PATCH 06/17] update converter test case --- src/puzzlekit/formats/base.py | 54 ++++++- src/puzzlekit/formats/penpa_converter.py | 44 +++-- src/puzzlekit/formats/puzzlink_converter.py | 168 +++++++++----------- tests/formats/conftest.py | 22 +++ tests/formats/test_cross_format.py | 25 +++ tests/formats/test_roundtrip.py | 48 ++++++ 6 files changed, 239 insertions(+), 122 deletions(-) create mode 100644 tests/formats/conftest.py create mode 100644 tests/formats/test_cross_format.py create mode 100644 tests/formats/test_roundtrip.py diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py index 46d653ef..ca3a0cbc 100644 --- a/src/puzzlekit/formats/base.py +++ b/src/puzzlekit/formats/base.py @@ -223,7 +223,6 @@ class PuzzleInstance: boxes: List[Any] = field(default_factory=list) # same as 'box' of penpa edges: Dict[tuple[Any], EdgeState] = field(default_factory=dict) # edge status cells: Dict[tuple[Any], CellState] = field(default_factory=dict) - regions: list[list[int]] = field(default_factory=list) # Heyawake 的房间区域 ID metadata: Dict[str, Any] = field(default_factory=dict) # =============== Penpa params end =============== @@ -231,7 +230,7 @@ class PuzzleInstance: skip_shading: bool = True rows_no_margin: int = 0 cols_no_margin: int = 0 - + def __repr__(self): """Custom format. """ @@ -248,3 +247,54 @@ def __repr__(self): boxes (len): {len(self.boxes)} cells (len): {len(self.cells.keys())} """ + + def normalize(self) -> dict: + """Normalize IR to comparable dict. + + Returns: + dict: _description_ + """ + # 1. norm cells - after sort + cells_normalized = {} + for (r, c), state in sorted(self.cells.items()): + cells_normalized[f"{r},{c}"] = { + "value": state.value, + "shaded": state.shaded, + "num_color": state.num_color, + "num_style": state.num_style, + } + + # 2. norm edges - after sort + edges_normalized = {} + for (p1, p2), state in sorted(self.edges.items()): + sorted_coords = tuple(sorted([p1, p2])) + key = f"{sorted_coords[0][0]},{sorted_coords[0][1]}-{sorted_coords[1][0]},{sorted_coords[1][1]}" + edges_normalized[key] = { + "connected": state.connected, + "edge_type": state.edge_type, + } + # print(len(self.cells)) + # 3. core attributes: + return { + "grid_type": self.grid_type, + "puzzle_type": self.puzzle_type, + "rows": self.rows, + "cols": self.cols, + "margins": self.margins, + "cells": cells_normalized, + "edges": edges_normalized, + "boxes": self.boxes, + } + + def semantic_equals(self, other: 'PuzzleInstance') -> bool: + """ + If two IR is semantic equal. + """ + if not isinstance(other, PuzzleInstance): + return False + return self.normalize() == other.normalize() + + def __eq__(self, other: object) -> bool: + if not isinstance(other, PuzzleInstance): + return False + return self.semantic_equals(other) \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index f3b4b5ab..6356f82c 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -125,14 +125,9 @@ def calculate_center_n(nx: int, ny: int, size: int = 38) -> int: class PenpaConverter: """Convert Penpa to PuzzleInstance """ - def __init__(self, url: str): - self.url = url - self.ir_puzzle = PuzzleInstance( - metadata={ - "source": "penpa", - "original_url": self.url, - } - ) + def __init__(self, config: Dict[Any, Any] = dict()): + self.config = config or dict() + def index_to_coord(self, index: int, type_: str = 'edge') -> Tuple[Tuple[int, int], int]: """Convert the [Penpa+](https://swaroopg92.github.io/penpa-edit/) index to coordinate. @@ -158,7 +153,14 @@ def coord_to_index(self, coord: Tuple[int, int] ,type_: str) -> Tuple[int, int]: else: return r_ * self.real_cols + c_ + self.real_cols * 2 + 2 - def decode(self) -> PuzzleInstance: + def decode(self, url: str) -> PuzzleInstance: + self.url = url + self.ir_puzzle = PuzzleInstance( + metadata={ + "source": "penpa", + "original_url": self.url, + } + ) self.parts = decompress(b64decode(self.url[len(PENPA_PREFIX) :]), -15).decode().split("\n") header = self.parts[0].split(",") @@ -184,7 +186,7 @@ def decode(self) -> PuzzleInstance: self.ir_puzzle.rows, self.ir_puzzle.cols = self.new_rows, self.new_cols - print(f"Puzzle shape (r, c) = {(self.new_rows, self.new_cols)}", ) + # print(f"Puzzle shape (r, c) = {(self.new_rows, self.new_cols)}", ) for p in range(len(self.parts)): if p == 1: @@ -197,7 +199,7 @@ def decode(self) -> PuzzleInstance: for k, v in self.board.items(): if k == "lineE": self.ir_puzzle.edges = self._decode_edge(edge_dict = v) - print(self.ir_puzzle.edges) + # print(self.ir_puzzle.edges) elif k == "number": self.ir_puzzle.cells = self._decode_number(number_dict = v) else: @@ -206,16 +208,7 @@ def decode(self) -> PuzzleInstance: # decode box boxes = json.loads(self.parts[p]) self.ir_puzzle.boxes = boxes - - check_diff = json.loads(self.parts[5]) - new_diff = generate_centerlist_diff( - self.ir_puzzle.rows, - self.ir_puzzle.cols, - json.loads(self.parts[1]) - ) - - assert ",".join(map(str, check_diff)) == ",".join(map(str, new_diff)), "Diff list not matched!!" - + return self.ir_puzzle def _decode_number(self, number_dict: Dict[str, int]): @@ -224,7 +217,7 @@ def _decode_number(self, number_dict: Dict[str, int]): new_number_dict = dict() for index, num_data in number_dict.items(): (r, c), _ = self.index_to_coord(int(index), 'cell') - new_number_dict[(r, c)] = CellState(value = num_data[0], num_color = num_data[1], num_style = num_data[2]) + new_number_dict[(r, c)] = CellState(value = f"{num_data[0]}", num_color = num_data[1], num_style = num_data[2]) return new_number_dict def _decode_edge(self, edge_dict: Dict[str, int]): @@ -314,7 +307,8 @@ def encode(self, inst: PuzzleInstance) -> str: plain_text = "\n".join(text_lines) compressed = compress(plain_text.encode())[2:-4] - return PENPA_URLPREFIX + PENPA_PREFIX + b64encode(compressed).decode('ascii') + return PENPA_PREFIX + b64encode(compressed).decode('ascii') + # return PENPA_URLPREFIX + PENPA_PREFIX + b64encode(compressed).decode('ascii') if __name__ == "__main__": @@ -328,8 +322,8 @@ def encode(self, inst: PuzzleInstance) -> str: "m=edit&p=1VVfb9s2EH/3pyAIFEgAxbbkP7H1lqXNXtqsq70VhWAEtMRYhCXSo8g4VpB+jX2gfbHekU4txV6BPWzAIOt8+ul497szf3T1h2WaB2EfP8NRAN9wDacjd0eTsbv7+2suTMFjcmu1WLMlJ2cfmdDVOTmjITGKTOl5cGVNrnRMPljNDLlmksyVNCzIjdlUca+33W67q3Jj67rgVTdVZW9ZqFUv6kdRr3/Zk/vUFxvMfLHcXZSY6CJl8sJgol7wO9OCGaEkUfcHKtpCvpjMcpZxUqmSE15uzI6kvCgqAIjJgY/JOVlpkRFRkUw8iIyDK4G6lRWuzAhMg1VvomvCWZqTFEsKKeSK8EeWmmJHzFYRacsl1xU5E7IynGXIREl+TpjMSM4eMJ7BRAwrSCVq4AJjLnBESKCyJS5At5GsS+bw0OZBSraDxFhVWaCTCbZSkhXFrkuuCsjtY32Ppa0MgUkAZ8lTA/hWmNz3oaCYxq6W1hCpSPTXnxEMQtkNMvHrYcS4nEsjNIeSPnn3TfQWPjdKE2aNKmHyKYyzsO4XSHOerqFbTO1aO0WoZHrt5kyWBUvXBFP5SCy+0mzXDX65uQnuWVHxTrLfbItOQkMa0AjukC6+1rOvCaVBuOg81Z/ip/ouThbPQf3bwZ0c3Fn8BPY2fqKDiMYJHcBClyagoxAByPodmCIApV6A8QCBSQO4RGB0ACYTBKYHIOy7kEbWMHIxlw3EUxk3kfGr0uFwiMiwgXg232OgrdA198XZG2cjZ+fQe1APnH3rbN/ZkbPvXcw7Zz87e+3s0Nmxi7nE6XU6ycAfA+1r9P/DcA/NrL5nKaewbyjs27tq/xwbbXngIK9AGrsN6KFCqU0hJIQ1QLGSCqRx6hWCPFudil8qnb3KvgUJtwB/DLegVOi0aENGi9Yz01ptWwjIM28BS2bgyK5ysWlnApW3CRjWpsjWcMy2cx96fu7QR+ruJAqicRBOUJXTuL4K6p/9bn3RbVD/CrL8ENe3qEqvYNyMLmgA7jvvRuB+du8RvPaRfXBv97sf3C/g+rHcvfcrPsZJPQ8olvnJLUGXluoBmHoa+Az/MkvoJaGNafg3lc3U2r4IDMV15dnOfswW3R+xRW7/Mtvp4tn/DP1/dCD+B+fH415pSh/E1thHAJ8QHKAnhbXHj7QF+JGKsOCxkAA9oSVAX8sJoGNFAXgkKsD+RleY9bW0kNVrdWGpI4FhqabGkkXnGw==" ]: - hpc = PenpaConverter(url = test_url) - tmp = hpc.decode() + hpc = PenpaConverter(dict()) + tmp = hpc.decode(test_url) enc = hpc.encode(tmp) print(enc) \ No newline at end of file diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index a8c40ab9..fdbbdc39 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -1,38 +1,33 @@ from typing import Dict, Any, List, Optional, Union, Set from puzzlekit.formats.base import ( - PuzzleInstance, CellState, EdgeState, PenpaMetadata + PuzzleInstance, CellState, EdgeState ) from puzzlekit.formats.utils import generate_centerlist_diff +import logging ALLOWED_PUZZLE_TYPE = { "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone" } - # allowed puzzle types +logger = logging.getLogger(__name__) +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + # Yajilin, Masyu, Slitherlink, heyawake, shikaku, norinori, hitori class PuzzlinkConverter: - def __init__(self, url: str): - self.url: str = url - self.ir_puzzle = PuzzleInstance( - metadata={ - "source": "puzz.link", - "original_url": self.url, - } - ) - - self.body: str = "" - self.num_rows: int = 0 - self.num_cols: int = 0 - self.skip_shading: bool = True - self.puzzle_type: str = "" + def __init__(self, config: Dict[Any, Any] = dict()): + self.config = config or {} def _decode_heyawake_variant(self): border_list = self._decode_border() - region_grid = self._convert_border_to_region_grid(border_list) + region_grid, _ = self._convert_border_to_region_grid(border_list) number_map = self._decode_number16() - print(number_map) - print(border_list) + # logger.info(f"Number Map: {number_map}", ) + # logger.info(f"Border_list: {border_list}") grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] self._move_numbers_to_top_left_corner(grid, region_grid, number_map) return (self.num_rows, self.num_cols, grid, region_grid) @@ -72,9 +67,22 @@ def _reindex_edge(self, r: int, c: int, margins: List[int], return new_edge_dict - def decode(self) -> Dict[str, Any]: + def decode(self, url: str) -> Dict[str, Any]: + self.url: str = url + self.ir_puzzle = PuzzleInstance( + metadata={ + "source": "puzz.link", + "original_url": self.url, + } + ) + + self.body: str = "" + self.num_rows: int = 0 + self.num_cols: int = 0 + self.skip_shading: bool = True + self.puzzle_type: str = "" # 0. Parse the header url. - print(self.url) + # logger.info(f"URL: {self.url}") self._parse_header() # If wanna add more puzzle types, just add the puzzle type to the list and implement the corresponding logic @@ -107,10 +115,6 @@ def decode(self) -> Dict[str, Any]: self.ir_puzzle.source = self.url self.ir_puzzle.cells = self._reindex_number(self.num_rows, self.num_cols, [0, 0, 0, 0], grid, skip = "-") self.ir_puzzle.edges = self._reindex_edge(self.num_rows, self.num_cols, [0, 0, 0, 0], region_grid) - - print(region_grid) - print(len(self.ir_puzzle.edges.keys())) - print(len(self.ir_puzzle.cells.keys())) # puzzlink_pu.drawBorder(pu, info_edge, 2); // 2 is for Black Style # puzzlink_pu.drawNumbers(pu, info_number, 1, "1") // Black Style, Normal submode is 1 @@ -123,20 +127,20 @@ def decode(self) -> Dict[str, Any]: elif self.puzzle_type in ["country", "detour", "juosan", "yajilin-regions", "yajirin-regions"]: # toichika2, nagenawa, maxi, factors are neglected. - pass + return self.ir_puzzle elif self.puzzle_type in ["hitori"]: - info_number = self._decode_number36(self.num_cols * self.num_rows) - # print(self.num_rows, self.num_cols, len(info_number)) - # print(info_number) - grid_matrix = [["-" 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): - grid_matrix[i][j] = str(info_number[i * self.num_cols + j]) - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - "grid": grid_matrix - } + return self.ir_puzzle + # info_number = self._decode_number36(self.num_cols * self.num_rows) + # grid_matrix = [["-" 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): + # grid_matrix[i][j] = str(info_number[i * self.num_cols + j]) + # return { + # "num_rows": self.num_rows, + # "num_cols": self.num_cols, + # "grid": grid_matrix + # } + # pu = new Puzzle_square(cols, rows, size); # setupProblem(pu, "surface"); @@ -158,7 +162,6 @@ def encode(self, inst: PuzzleInstance) -> str: Returns: str: puzz.link url. """ - mtd = PenpaMetadata() assert inst.grid_type in ["square"], f"Puzzle grid type must be 'square', get {inst.grid_type}." assert inst.puzzle_type in ALLOWED_PUZZLE_TYPE, f"Puzzle {inst.puzzle_type} has not been implemented yet... " @@ -167,41 +170,30 @@ def encode(self, inst: PuzzleInstance) -> str: self.num_rows, self.num_cols = inst.rows - inst.margins[0] - inst.margins[1], inst.cols - inst.margins[2] - inst.margins[3] if self.puzzle_type in ["heyawake", "shikaku", "aqre","heyawacky","shimaguni","stostone"]: self.body = self._encode_heyawake_variant(inst) + return self.body else: raise NotImplementedError(f"Puzzle type {self.puzzle_type} not supported for encoding") # _decode_heyawake_variant - pass def _encode_heyawake_variant(self, inst: PuzzleInstance): border_list = self._region_grid_to_borders(inst.edges) - region_grid = self._convert_border_to_region_grid(border_list) + region_grid, max_region_id = self._convert_border_to_region_grid(border_list) number_map: Dict[int, Any] = dict() for k, cell_state in inst.cells.items(): (r_, c_) = k val = int(cell_state.value) if cell_state.value.isdigit() else cell_state.value number_map[int(region_grid[r_][c_])] = val - - # print(region_grid) - # print(number_map) border_str = self._encode_border(border_list) - # 5. 编码 number_map → 16 进制字符串 - number_str = self._encode_number16(number_map) + # 5. number_map → number16 + number_str = self._encode_number16(number_map, max_region_id) + logger.info(f"{region_grid}") - # 6. 拼接 body + # 6. concat body body = f"https://puzz.link/p?{inst.puzzle_type}/{inst.cols}/{inst.rows}/{border_str + number_str}" - print("FINAL ", body) - # print("EXTRACTED", border_list) - # comp = {1: 1, 6: 1, 12: 1, 16: 1, 18: 1, 20: 1, 24: 1, 26: 1, 28: 1, 32: 1, 34: 1, 35: 1, 39: 1, 41: 1, 47: 1, 49: 1, 50: 1, 53: 1, 54: 1, 55: 1, 56: 1, 57: 1, 60: 1, 64: 1, 65: 1, 68: 1, 70: 1, 71: 1, 74: 1, 75: 1, 80: 1, 83: 1, 89: 1, 92: 1, 93: 1, 94: 1, 95: 1, 96: 1, 97: 1, 98: 1, 99: 1, 100: 1, 103: 1, 104: 1, 105: 1, 106: 1, 108: 1, 111: 1, 112: 1, 113: 1, 114: 1, 115: 1, 117: 1, 118: 1, 120: 1, 121: 1, 122: 1, 123: 1, 126: 1, 127: 1, 129: 1, 130: 1, 131: 1, 132: 1, 134: 1, 139: 1, 141: 1, 143: 1, 144: 1, 145: 1, 146: 1, 147: 1, 148: 1, 150: 1, 154: 1, 155: 1, 157: 1, 159: 1, 160: 1, 161: 1, 162: 1, 164: 1, 165: 1, 168: 1, 174: 1, 175: 1, 176: 1, 177: 1, 178: 1} - # for k_ , v_ in comp.items(): - # if k_ not in border_list: - # print(k_) - # assert len(comp.keys()) == len(border_list.keys()) - - - pass + return body def _region_grid_to_borders(self, edges_dict: Dict[Any, List[EdgeState]]) -> Dict[int, int]: @@ -320,7 +312,7 @@ def _decode_masyu_variant(self): if self.puzzle_type in ["moonsun"]: border_list = self._decode_border() - region_grid = self._convert_border_to_region_grid(border_list) + region_grid, _ = self._convert_border_to_region_grid(border_list) info_number = self._decode_number3() grid = self._convert_one_two_2_white_black_grid(info_number, category = "moonsun") return { @@ -367,7 +359,7 @@ def _move_numbers_to_top_left_corner(self, r, c = region_start_points[r_id] grid_matrix[r][c] = str(val) else: - print(f"Warning: Number for Region {r_id} found, but region not does not exist in grid.") + logger.warning(f"Number for Region {r_id} found, but region not does not exist in grid.") return grid_matrix def _read_number16(self, body_str: str, i: int): @@ -426,7 +418,7 @@ def _decode_number36(self, max_iter: int = -1) -> List[Union[int, str]]: self.body = self.body[index:] return number_list - def _encode_number16(self, number_map: Dict[int, Any]) -> str: + def _encode_number16(self, number_map: Dict[int, Any], max_region_id: int) -> str: """ reverse operation of _decode_number16. @@ -442,10 +434,9 @@ def _encode_number16(self, number_map: Dict[int, Any]) -> str: return "" result = [] - max_region_id = max(number_map.keys()) current_id = 0 skip_count = 0 - + logger.info(f"{number_map}") while current_id <= max_region_id: if current_id in number_map: # 🔹 先输出累积的跳过 @@ -462,11 +453,11 @@ def _encode_number16(self, number_map: Dict[int, Any]) -> str: current_id += 1 - # 注意:末尾的跳过通常不需要编码,因为解码时字符串结束就停止 - # 但如果需要明确跳过,可以取消下面注释 - # if skip_count > 0: - # result.append(self._encode_skip(skip_count)) - + # NOTE: The skip in the end usually do NOT need encoding + # cuz the grid is actually not affected and str ends. Yet the last char is kept here for completeness. + if skip_count > 0: + result.append(self._encode_skip(skip_count)) + logger.info(''.join(result)) return ''.join(result) @@ -783,7 +774,7 @@ def _convert_border_to_region_grid(self, border_list: Dict[int, int]) -> List[Li self._bfs_flood_fill(r, c, f"{current_region_id}", region_grid, border_list, num_vert) current_region_id += 1 - return region_grid + return region_grid, current_region_id - 1 def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, num_vert_borders): """BFS helper function""" @@ -829,30 +820,17 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, if __name__ == "__main__": - # PzpParser = PuzzlinkParser("https://puzz.link/p?heyawake/24/14/499a0h55854kmgkk9a2ih54aa4kg98ii154a84kh914i544i8kgi92j294kc94ihg00001vg0fs0vvg6000vg0000vo00e0fvv00001vvg03g03vo3g00fovvv00000023g23g23h5h3454j44h643g03g4g3j1222h3") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?norinori/10/10/90i2c76esik8rapah800evmv37d4fsm9dmte") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?slither/10/10/ic5137bg7bchbgdccb7dgddg7ddabdgdhc7bg7316dbg") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?norinori/10/10/90i2c76esik8rapah800evmv37d4fsm9dmte") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?masyu/15/12/00010c2401000b00i00913j0190040136c3000033b0202090c919i00900c") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?moonsun/10/10/4g90i152a4k98i142800003vs000vg0f00000632a66fi00i3f9i77000k6b20092f9a00") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?yajirin/10/10/c23g42d22j41e11a41c31g31f21l33d41a11d11g31f32e") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?yajilin/b/10/10/22202224zb41zh32zb11131213") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?yajilin/b/6/6/e20b21r11b12e") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?castle/12/12/224j234e122125g124f131r222b231e121h131b144h112e241b212r145f114g132142e244j214") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?yajilin/17/17/21t30b40c22b2310d21b23b31b10g42b23b31b10g21f33a10c33c21b22b31b10c31f42b32b10g41b22b31b10g20c41a21b10g20a3121b31b42g20b21b31b10g41b21b31b10g42b21c12a32g43b21b32b10g20b21b30b10e11f41w3212") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?vslither/6/6/338833ddg8d3dkd8d") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?hitori/65/65/-3l-3k-3j-3i-3h-3g-3f-3e-3d-3c-3b-3a-39-38-37-36-35-34-33-32-31-30-2z-2y-2x-2w-2v-2u-2t-2s-2r-2q-2p-2o-2n-2m-2l-2k-2j-2i-2h-2g-2f-2e-2d-2c-2b-2a-29-28-27-26-25-24-23-22-21-20-1z-1y-1x-1w-1v-1u-1t-3k1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r-3j123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q-3i33557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r11-3h32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o-3g557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133-3f56769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q1232-3e7799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r113355-3d7694bad8fehcjil8nmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k-3c99bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r11335577-3b9abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q12325676-3abbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799-39badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694-38ddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bb-37defehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769aba-36ffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbdd-35fehcjil8nmpkrqtgvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1c-1r-1q1-1o325-1k7694bad-1c-34hhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddff-33hijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefe-32jjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhh-31jilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehc-30llnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjj-2zlmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehiji-2ynnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjll-2xnmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8-2wpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnn-2vpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnm-2urrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpp-2trqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpk-2sttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprr-2rtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrq-2qvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrtt-2pvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1pw-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtw-2oxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvv-2nxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvu-2mzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxx-2lzy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxs-2k-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-2j-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-2i-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-2h-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8nmpkrqtovuxszy-11o-2g-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-2f-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-2e-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-2d-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-2c-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-2b-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-2a-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-29-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1c-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtgvuxszy-11o-13-12-15-10-17-16-19g-28-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-27-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-26-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-25-1f-1e-1h-1g-1j-1i-1l-1g-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-24-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-23-1h-1i-1j-1i-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-22-1j-1j-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-21-1j-1i-1l-1g-1n-1m-1p-1k-1r-1q1-1o325-1k7694bad8fehcjil8nmpkrqtovuxszy-11o-13-12-15-10-17-16-19-14-1b-1a-1d-18-1f-1e-1h-14-20-1l-1l-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1z-1l-1m-1n-1m-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1y-1n-1n-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1x-1n-1m-1p-1o-1r-1q1-1o32547694badcfehcjilknmpkrqtsvuxszy-11-10-13-12-15-10-17-16-19-18-1b-1a-1d-18-1f-1e-1h-1g-1j-1i-1l-1g-1w-1p-1p-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1v-1p-1q-1r-1q123256769abadefehijilmnmpqrqtuvuxyzy-11-12-13-12-15-16-17-16-19-1a-1b-1a-1d-1e-1f-1e-1h-1i-1j-1i-1l-1m-1n-1m-1u-1r-1r1133557799bbddffhhjjllnnpprrttvvxxzz-11-11-13-13-15-15-17-17-19-19-1b-1b-1d-1d-1f-1f-1h-1h-1j-1j-1l-1l-1n-1n-1p-1p-1t-1r-1q1-1o325-1k7694bad-1cfehcjil8nmpkrqtwvuxszy-11o-13-12-15-10-17-16-19g-1b-1a-1d-18-1f-1e-1h-14-1j-1i-1l-1g-1n-1m-1p-1t") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?shimaguni/10/10/884aha5h85jshipgi17vjqfmudt1buhlti1ui2h34g3m") - PzpCvtr = PuzzlinkConverter("https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g22.i33k2g") - - res = PzpCvtr.decode() - from puzzlekit.formats.penpa_converter import HeyawakePenpaConverter - penpa_url = HeyawakePenpaConverter("") - penpa_test = penpa_url.encode(res) - - # PzpCvtr.encode(res) - new_pzp = PuzzlinkConverter("") - new_pzp.encode() - - + PzpCvtr = PuzzlinkConverter() + url_list = [ + "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g22.i33k2g", + "https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h", + ] + for url in url_list: + p_ir = PzpCvtr.decode(url) + url_new = PzpCvtr.encode(p_ir) + print(url_new) + print(url) + # assert url_new == url + # from puzzlekit.formats.penpa_converter import PenpaConverter + # penpa_url = PenpaConverter("") + # penpa_test = penpa_url.encode(res) diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py new file mode 100644 index 00000000..de4fd6df --- /dev/null +++ b/tests/formats/conftest.py @@ -0,0 +1,22 @@ +import pytest + +@pytest.fixture +def penpa_test_urls(): + """Penpa+ cases""" + return { + "heyawake_1": "m=edit&p=7VdrT+NGFP3Or6j8dUeNx+O3tKrCayUEFAqUQhQhE0xeTpx1HlAj/vueO76TxCFstapWolKVZHzmzMydO/dcXzvTr/OkSIW06atCgSs+rgz1zwl9/bP5c9mfZWn8i2jOZ728AOjNZpNp3GhM5uVteftr1h8PG5PfeunfyVMyTBvSpm9nOLnvF56fdKZDGY0GHV91i0GuVNfuLBZykasg7zpdJQey6ziyr9TQ6Qrx++GheEyyaSqObga7+8Pm00Hzr4Z3q9TV6eOnwf751eDh+k95bvcbhX2aheOTs/3d7NOX8vak11ykB6l/Ns07vSxNHpLy9vroORsfht3eo9w76u2Fj8nYnn4NL6PF7vnnzzstPmJ756WM4rIpyi9xy5KWsBz8pNUW5Xn8Up7E5YUoLzBkCdkW1miezfqdPMsLy3DlcbXQATxYwWs9TmivIqUNfMoY8Aaw0y86WXp3XDFncau8FBbtvatXE7RG+SKlzcg36nfy0X2fiPtkBnWmvf7EEgoD0/lDPpzzVNl+FWXzx04AI+YEBKsTENpyAjrYzz1B1H59hTh/4Ax3cYuOc7WC4QpexC9oT+MXSzlY6iCdtX6W8tFVq26ILiV71fVpdNUN6mtDu96NapOlrJuWkmyv9b315fBOah9vdHuoW0e3lziCKJVu93Vr69bT7bGec4CTOVIJxwms2EF+ShcYG2ocCEdJxhGwqrAD3mXewX3t4niElQT2GMOmyzZdWzieyxhzPJ7jOsA4rMYeMAKhMez7bN+FfZ/te5jj8xwPtSRAIDSGbwH75sN+wPZ98CHzAXwI2YcAvoXsWwCbJADhEHzEfIg4RByHMBDKZn/CEJj9CSNgth9hjuQ5EXhZ8eCEcip/wAFX9pX0hVKV/wpxVhxn5bjA1b5YB1z5hnVCccyxDrg6C9YBs31UXOVVvmEdMPvgwqbHNhFnZeJM2jkGk74cT9hf6g470G+lo9GdtHP57C5pbfRF3EwOkI4ux5B0dI12mG/ywcN8kw+kndHah31/TbuA9w1IX54TYI7RHRpBm6VGS00j6BtxjkXIGaMv9FrqG2F+ZOaTpqyLjTiz7rgudcd1qTuuwKyFjTjba1pL5nFPKWl4xJ9ucJMD0uQD5Q9rhGelkkZ3yhm2Q7lhcol0p5KkMfzhe1PrznrhusoZ3HeK71NcgU1uwL7WDsXgWpeEPd26uvV1qQioFv5Qtfz3Vekf3WkhS6lo1j/ef49r77Ssi3nxmHRSCy8M1jTP7qZV/y59TjozK65eXNZHatx4PrpP8cRdo7I8n+D9aZsFM1Qj+91xXqRbh4hMH7rvmaKhLabu8+Jhw6enJMvqZ9GvizWqeuLXqFmBx/laPymK/KnGjJJZr0asPfprltLxRjBnSd3FZJhs7DZaheN1x3q29A9PYzx//3+7+9BvdySU/dGq1kdzR+d4Xnyn4KwGN+ktZQfsdyrP2ug2/p0isza6yb+pKOTs26ICdktdAbtZWkC9rS4g3xQYcO/UGLK6WWbIq81KQ1u9KTa01Xq9aVnmz6/V3vkG", + "heyawake_3": "m=edit&p=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" + } + +@pytest.fixture +def puzzlink_test_urls(): + """Puzzlink cases""" + return { + # Heyawake + "heyawake_1": "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g221i33k2g", + "heyawake_2_question_mark": "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g22.i33k2g", + "heyawake_3": "https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h", + + # shimaguni + "shimaguni_1": "https://puzz.link/p?shimaguni/10/10/884aha5h85jshipgi17vjqfmudt1buhlti1ui2h34g3m", + } \ No newline at end of file diff --git a/tests/formats/test_cross_format.py b/tests/formats/test_cross_format.py new file mode 100644 index 00000000..16c51e1d --- /dev/null +++ b/tests/formats/test_cross_format.py @@ -0,0 +1,25 @@ +# tests/test_roundtrip.py +import pytest +from puzzlekit.formats.penpa_converter import PenpaConverter +from puzzlekit.formats.puzzlink_converter import PuzzlinkConverter + +class TestCrossFormatPenpaPuzzlink: + """ + TEST Puzzlink Round Trip. + """ + + def test_cross_penpa_puzzlink(self, puzzlink_test_urls, penpa_test_urls): + for pid, url_pzl in puzzlink_test_urls.items(): + if pid in penpa_test_urls: + url_ppa = penpa_test_urls[pid] + # First decode, from url -> IR1 + converter1 = PuzzlinkConverter() + ir1 = converter1.decode(url_pzl) + print(ir1.cells) + # Second decode, from url2 -> IR2 + converter2 = PenpaConverter() + ir2 = converter2.decode(url_ppa) + print(ir2.cells) + + # If IR1 ?== IR2 + assert ir1 == ir2, f"[Puzzlink] Puzzle {pid} wrong. URL: {url_pzl}" diff --git a/tests/formats/test_roundtrip.py b/tests/formats/test_roundtrip.py new file mode 100644 index 00000000..4183d4cf --- /dev/null +++ b/tests/formats/test_roundtrip.py @@ -0,0 +1,48 @@ +# tests/test_roundtrip.py +import pytest +from puzzlekit.formats.penpa_converter import PenpaConverter +from puzzlekit.formats.puzzlink_converter import PuzzlinkConverter + +class TestPuzzlelinkRoundTrip: + """ + TEST Puzzlink Round Trip. + """ + + def test_self_roundtrip(self, puzzlink_test_urls): + for puzzle, url in puzzlink_test_urls.items(): + # First decode, from url -> IR1 + converter1 = PuzzlinkConverter() + ir1 = converter1.decode(url) + + # First Encode, from IR -> url2 + url2 = converter1.encode(ir1) + + # Second decode, from url2 -> IR2 + converter2 = PuzzlinkConverter() + ir2 = converter2.decode(url2) + + # If IR1 ?== IR2 + assert ir1 == ir2, f"[Puzzlink] Puzzle {puzzle} wrong. URL: {url}" + + +class TestPenpaTrip: + """ + TEST Penpa Round Trip. + """ + + def test_self_roundtrip(self, penpa_test_urls): + for puzzle, url in penpa_test_urls.items(): + # First decode, from url -> IR1 + converter1 = PenpaConverter() + ir1 = converter1.decode(url) + + # First Encode, from IR -> url2 + url2 = converter1.encode(ir1) + + # Second decode, from url2 -> IR2 + converter2 = PenpaConverter() + ir2 = converter2.decode(url2) + + # If IR1 ?== IR2 + assert ir1 == ir2, f"[Penpa] Puzzle {puzzle} wrong. URL: {url}" + From fd0d27ede0bfa499a93f1f0e81f623a9b2023f26 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Wed, 18 Mar 2026 00:29:10 +0800 Subject: [PATCH 07/17] sync --- src/puzzlekit/formats/base.py | 44 ++- src/puzzlekit/formats/debug.py | 371 ++++++++++++++++++ src/puzzlekit/formats/penpa_converter.py | 93 +++-- src/puzzlekit/formats/puzzlink_converter.py | 200 ++++++++-- src/puzzlekit/formats/puzzlink_type_config.py | 0 src/puzzlekit/formats/utils.py | 83 +++- tests/formats/conftest.py | 22 +- 7 files changed, 747 insertions(+), 66 deletions(-) create mode 100644 src/puzzlekit/formats/debug.py create mode 100644 src/puzzlekit/formats/puzzlink_type_config.py diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py index ca3a0cbc..c3449b89 100644 --- a/src/puzzlekit/formats/base.py +++ b/src/puzzlekit/formats/base.py @@ -2,6 +2,40 @@ from typing import Dict, Optional, Any, Tuple, List from functools import reduce import json +from enum import Enum + +class NumberColor(Enum): + """_summary_ + + Args: + Enum (_type_): _description_ + + Returns: + _type_: _description_ + """ + BLACK: int = 1 + GREEN: int = 2 + # ... etc + +class SurfaceColor(Enum): + """Enumeration class for **surface color**. + + Args: + Enum (_type_): _description_ + """ + DARK_GREY: int = 1 + GREY: int = 2 + LIGHT_GREY: int = 3 + BLACK: int = 4 + GREEN: int = 5 + BLUE: int = 6 + RED: int = 7 + YELLOW: int = 8 + PINK: int = 9 + ORANGE: int = 10 + PURPLE: int = 11 + BROWN: int = 12 + PENPA_MODE = '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}' PENPA_PU_X_STR = '{zR:{z_:[]},zU:{z_:[]},z8:{z_:[]},zS:{},zN:{},z1:{},zY:{},zF:{},z2:{},zT:[],z3:[],zD:[],z0:[],z5:[],zL:{},zE:{},zW:{},zC:{},z4:{},z6:[],z7:[]}' @@ -185,9 +219,12 @@ class CellState: """ value: Optional[str] = None # Number clue shaded: bool = False # black? - num_color: int = 1 # number color + # num_color: int = 1 # number color + num_color: Optional[NumberColor] = None # number color num_style: str = "1" # number style + surf_color: Optional[SurfaceColor] = None + @dataclass class EdgeState: @@ -260,8 +297,9 @@ def normalize(self) -> dict: cells_normalized[f"{r},{c}"] = { "value": state.value, "shaded": state.shaded, - "num_color": state.num_color, + "num_color": state.num_color.value if state.num_color is not None else None, "num_style": state.num_style, + "surf_color": state.surf_color.value if state.surf_color is not None else None } # 2. norm edges - after sort @@ -277,7 +315,7 @@ def normalize(self) -> dict: # 3. core attributes: return { "grid_type": self.grid_type, - "puzzle_type": self.puzzle_type, + # "puzzle_type": self.puzzle_type, "rows": self.rows, "cols": self.cols, "margins": self.margins, diff --git a/src/puzzlekit/formats/debug.py b/src/puzzlekit/formats/debug.py new file mode 100644 index 00000000..16d315ff --- /dev/null +++ b/src/puzzlekit/formats/debug.py @@ -0,0 +1,371 @@ +# formats/debug.py +from puzzlekit.formats.penpa_converter import PenpaConverter as Ppc +from puzzlekit.formats.puzzlink_converter import PuzzlinkConverter as Plc +from puzzlekit.formats.base import PuzzleInstance +from typing import Dict, Any, List, Tuple + +import json + +def compare_edges(edges1: Dict, edges2: Dict) -> List[str]: + """比较两个 edges dict,返回差异列表""" + diffs = [] + all_keys = set(edges1.keys()) | set(edges2.keys()) + + for key in sorted(all_keys): + e1, e2 = edges1.get(key), edges2.get(key) + if e1 is None: + diffs.append(f"❌ edge [{key}] missing in first, expected: {e2}") + elif e2 is None: + diffs.append(f"❌ edge [{key}] missing in second, expected: {e1}") + else: + # 比较 EdgeState 的各个字段 + if e1.connected != e2.connected: + diffs.append(f"⚠️ edge [{key}].connected: {e1.connected} != {e2.connected}") + if e1.edge_type != e2.edge_type: + diffs.append(f"⚠️ edge [{key}].edge_type: {e1.edge_type} != {e2.edge_type}") + return diffs + + +def debug_edges_summary(edges: Dict, label: str = "Edges"): + """打印 edges 的统计摘要,方便快速定位问题""" + if not edges: + print(f"📋 {label}: empty") + return + + # 按 edge_type 分组统计 + type_count = {} + for edge_state in edges.values(): + t = edge_state.edge_type + type_count[t] = type_count.get(t, 0) + 1 + + print(f"📋 {label}: total={len(edges)}, by edge_type: {type_count}") + + # 可选:打印前 5 个 edge 详情 + # for i, (k, v) in enumerate(list(edges.items())[:5]): + # print(f" [{k}] -> connected={v.connected}, type={v.edge_type}") + +def compare_cells(cells1: Dict, cells2: Dict, tolerance: float = 0) -> List[str]: + """比较两个 cells dict,返回差异列表""" + diffs = [] + all_keys = set(cells1.keys()) | set(cells2.keys()) + + for key in sorted(all_keys): + c1, c2 = cells1.get(key), cells2.get(key) + if c1 is None: + diffs.append(f"❌ [{key}] missing in first: {c2}") + elif c2 is None: + diffs.append(f"❌ [{key}] missing in second: {c1}") + else: + # 比较各个字段 + for field in ['value', 'num_color', 'num_style', 'surf_color', 'shaded']: + v1 = getattr(c1, field, None) + v2 = getattr(c2, field, None) + # 处理 Enum 比较 + if hasattr(v1, 'value'): v1 = v1.value + if hasattr(v2, 'value'): v2 = v2.value + if v1 != v2: + diffs.append(f"⚠️ [{key}].{field}: {v1} != {v2}") + return diffs + +def compare_edges(edges1: Dict, edges2: Dict) -> List[str]: + """比较两个 edges dict""" + diffs = [] + all_keys = set(edges1.keys()) | set(edges2.keys()) + + for key in sorted(all_keys): + e1, e2 = edges1.get(key), edges2.get(key) + if e1 is None: + diffs.append(f"❌ edge [{key}] missing in first") + elif e2 is None: + diffs.append(f"❌ edge [{key}] missing in second") + elif e1.edge_type != e2.edge_type or e1.connected != e2.connected: + diffs.append(f"⚠️ edge [{key}]: {e1} != {e2}") + return diffs + +def debug_roundtrip(converter, url: str, name: str, skip_compare: List[str] = None): + """ + 执行双向转换并打印调试信息 + + Args: + converter: 转换器实例 (PuzzlinkConverter 或 PenpaConverter) + url: 原始 URL + name: 测试名称 + skip_compare: 跳过比较的字段列表,如 ['metadata', 'source'] + """ + skip_compare = skip_compare or [] + print(f"\n{'='*60}") + print(f"🔍 {name} Roundtrip Debug") + print(f"{'='*60}") + + # Step 1: Decode + print(f"\n📥 Decoding: {url[:80]}...") + ir1 = converter.decode(url) + print(f"✅ IR decoded: {ir1.rows}x{ir1.cols}, cells={len(ir1.cells)}, edges={len(ir1.edges)}") + + # Step 2: Encode + print(f"\n📤 Encoding back...") + try: + url2 = converter.encode(ir1) + print(f"✅ Encoded URL: {url2[:80]}...") + except Exception as e: + print(f"❌ Encode failed: {e}") + import traceback + traceback.print_exc() + return ir1, None, None + + # Step 3: Decode again + print(f"\n📥 Re-decoding encoded URL...") + ir2 = converter.decode(url2) + print(f"✅ Re-decoded IR: {ir2.rows}x{ir2.cols}, cells={len(ir2.cells)}, edges={len(ir2.edges)}") + + # Step 4: Compare + print(f"\n🔎 Comparing IRs...") + all_diffs = [] + + # 基础属性比较 + for attr in ['rows', 'cols', 'puzzle_type', 'grid_type']: + if attr in skip_compare: continue + v1, v2 = getattr(ir1, attr), getattr(ir2, attr) + if v1 != v2: + all_diffs.append(f"❌ {attr}: {v1} != {v2}") + else: + print(f"✅ {attr}: {v1}") + + # margins 比较 + if 'margins' not in skip_compare: + if ir1.margins != ir2.margins: + all_diffs.append(f"❌ margins: {ir1.margins} != {ir2.margins}") + else: + print(f"✅ margins: {ir1.margins}") + + # cells 比较 (只显示前 10 个差异) + if 'cells' not in skip_compare: + cell_diffs = compare_cells(ir1.cells, ir2.cells) + if cell_diffs: + print(f"⚠️ Cell differences ({len(cell_diffs)} total, showing first 10):") + for d in cell_diffs[:10]: + print(f" {d}") + if len(cell_diffs) > 10: + print(f" ... and {len(cell_diffs) - 10} more") + all_diffs.extend(cell_diffs) + else: + print(f"✅ cells: all {len(ir1.cells)} cells match") + + # edges 比较 + if 'edges' not in skip_compare: + edge_diffs = compare_edges(ir1.edges, ir2.edges) + if edge_diffs: + print(f"⚠️ Edge differences ({len(edge_diffs)} total):") + # 打印详细差异 + for d in edge_diffs[:20]: # 限制输出数量 + print(f" {d}") + if len(edge_diffs) > 20: + print(f" ... and {len(edge_diffs) - 20} more") + all_diffs.extend(edge_diffs) + else: + print(f"✅ edges: all {len(ir1.edges)} edges match") + + # 打印 edges 摘要(可选,方便调试) + debug_edges_summary(ir1.edges, "IR1 Edges") + debug_edges_summary(ir2.edges, "IR2 Edges") + # Summary + print(f"\n{'-'*60}") + if all_diffs: + print(f"🔴 FAILED: {len(all_diffs)} differences found") + # 可选:保存差异到文件 + # with open(f"debug_{name}_diffs.txt", "w") as f: + # f.write("\n".join(all_diffs)) + else: + print(f"🟢 SUCCESS: Roundtrip perfect! ✅") + print(f"{'-'*60}\n") + + return ir1, ir2, all_diffs + +def debug_nonogram_specific(ir: Any): + """针对 nonogram 的专项调试:打印 margin 区域的数字""" + if ir.puzzle_type != "nonogram": + return + + print(f"\n🧩 Nonogram Specific Debug ({ir.rows}x{ir.cols}, margins={ir.margins})") + rows_offset, cols_offset = ir.margins[0], ir.margins[2] + + # 打印顶部行提示 (row clues) + print(f"\n📋 Top row clues (rows 0~{rows_offset-1}):") + for r in range(rows_offset): + clues = [(c, ir.cells[(r, c)].value) for c in range(ir.cols) + if (r, c) in ir.cells and ir.cells[(r, c)].value] + if clues: + print(f" Row {r}: {clues}") + + # 打印左侧列提示 (col clues) + print(f"\n📋 Left col clues (cols 0~{cols_offset-1}):") + for c in range(cols_offset): + clues = [(r, ir.cells[(r, c)].value) for r in range(ir.rows) + if (r, c) in ir.cells and ir.cells[(r, c)].value] + if clues: + print(f" Col {c}: {clues}") + +def compare_ir(ir1: PuzzleInstance, ir2: PuzzleInstance, + ignore_fields: List[str] = None, + verbose: bool = True) -> Tuple[bool, List[str]]: + """ + Cross-compare two PuzzleInstance objects. + + Args: + ir1, ir2: Two PuzzleInstance to compare + ignore_fields: Fields to skip comparison (e.g., ['source', 'metadata', 'author']) + verbose: Whether to print detailed differences + + Returns: + (is_equal: bool, diffs: List[str]) + """ + ignore_fields = ignore_fields or ['source', 'metadata', 'author', 'title'] + diffs = [] + + # ===== 1. 基础属性对比 ===== + basic_fields = ['grid_type', 'puzzle_type', 'rows', 'cols', 'margins', 'boxes'] + for field in basic_fields: + if field in ignore_fields: + continue + v1, v2 = getattr(ir1, field, None), getattr(ir2, field, None) + if v1 != v2: + diffs.append(f"❌ {field}: {v1} != {v2}") + elif verbose: + print(f"✅ {field}: {v1}") + + # ===== 2. Cells 对比 ===== + if 'cells' not in ignore_fields: + cell_diffs = _compare_cells_detail(ir1.cells, ir2.cells) + if cell_diffs: + diffs.extend(cell_diffs) + if verbose: + print(f"⚠️ Cell differences ({len(cell_diffs)}):") + for d in cell_diffs[:10]: + print(f" {d}") + if len(cell_diffs) > 10: + print(f" ... and {len(cell_diffs) - 10} more") + elif verbose and ir1.cells: + print(f"✅ cells: all {len(ir1.cells)} cells match") + + # ===== 3. Edges 对比 ===== + if 'edges' not in ignore_fields: + edge_diffs = _compare_edges_detail(ir1.edges, ir2.edges) + if edge_diffs: + diffs.extend(edge_diffs) + if verbose: + print(f"⚠️ Edge differences ({len(edge_diffs)}):") + for d in edge_diffs[:10]: + print(f" {d}") + if len(edge_diffs) > 10: + print(f" ... and {len(edge_diffs) - 10} more") + elif verbose and ir1.edges: + print(f"✅ edges: all {len(ir1.edges)} edges match") + + # ===== 4. 可选:CellState/EdgeState 字段级对比配置 ===== + # 如需更细粒度控制,可扩展 _compare_cells_detail 的 compare_fields 参数 + + is_equal = len(diffs) == 0 + if verbose: + print(f"\n{'='*50}") + if is_equal: + print("🟢 IRs are SEMANTICALLY EQUAL ✅") + else: + print(f"🔴 IRs DIFFER: {len(diffs)} issues found") + print(f"{'='*50}\n") + + return is_equal, diffs + + +def _compare_cells_detail(cells1: Dict, cells2: Dict, + compare_fields: List[str] = None) -> List[str]: + """ + 详细对比两个 cells dict,返回差异列表。 + """ + if compare_fields is None: + compare_fields = ['value', 'num_color', 'num_style', 'surf_color', 'shaded'] + + diffs = [] + all_keys = set(cells1.keys()) | set(cells2.keys()) + + # 先检查数量 + if len(cells1) != len(cells2): + diffs.append(f"❌ cells count: {len(cells1)} != {len(cells2)}") + + for key in sorted(all_keys): + c1, c2 = cells1.get(key), cells2.get(key) + + if c1 is None: + diffs.append(f"❌ [{key}] missing in ir1, ir2 has: {c2}") + continue + if c2 is None: + diffs.append(f"❌ [{key}] missing in ir2, ir1 has: {c1}") + continue + + # 字段级对比 + for field in compare_fields: + v1 = getattr(c1, field, None) + v2 = getattr(c2, field, None) + # 处理 Enum 值比较 + if hasattr(v1, 'value'): v1 = v1.value + if hasattr(v2, 'value'): v2 = v2.value + if v1 != v2: + diffs.append(f"⚠️ [{key}].{field}: '{v1}' != '{v2}'") + + return diffs + + +def _compare_edges_detail(edges1: Dict, edges2: Dict) -> List[str]: + """ + 详细对比两个 edges dict,返回差异列表。 + """ + diffs = [] + all_keys = set(edges1.keys()) | set(edges2.keys()) + + if len(edges1) != len(edges2): + diffs.append(f"❌ edges count: {len(edges1)} != {len(edges2)}") + + for key in sorted(all_keys): + # key 是 ((r1,c1), (r2,c2)) 元组,需要标准化顺序 + k_std = tuple(sorted(key)) if isinstance(key, tuple) and len(key) == 2 else key + + e1 = edges1.get(key) or edges1.get(k_std) + e2 = edges2.get(key) or edges2.get(k_std) + + if e1 is None: + diffs.append(f"❌ edge {key} missing in ir1") + continue + if e2 is None: + diffs.append(f"❌ edge {key} missing in ir2") + continue + + if e1.connected != e2.connected: + diffs.append(f"⚠️ edge {key}.connected: {e1.connected} != {e2.connected}") + if e1.edge_type != e2.edge_type: + diffs.append(f"⚠️ edge {key}.edge_type: {e1.edge_type} != {e2.edge_type}") + + return diffs + + +# ============ 主测试入口 ============ + + +if __name__ == "__main__": + # 测试 URL + PUZZLINK_URL = "https://puzz.link/p?nonogram/15/11/55j111i13p55j1k55j1k121i5111h12j5k121i21111g1212h33113i111111h11113i11111i3331r111111h211211h22111i111111h211113h" + PENPA_URL = "m=edit&p=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" + plc = Plc() + ppc = Ppc() + ir1 = plc.decode(PUZZLINK_URL) + ir2 = ppc.decode(PENPA_URL) + # debug_roundtrip(plc, PUZZLINK_URL, "Puzzlink Nonogram", skip_compare=['source', 'metadata']) + + # # 如果解码成功,打印 nonogram 专项信息 + # try: + # ir = plc.decode(PUZZLINK_URL) + # debug_nonogram_specific(ir) + # except Exception as e: + # print(f"⚠️ Could not run nonogram debug: {e}") + + + print("\n🔍 Cross-comparing IR1 vs IR2:") + is_equal, diffs = compare_ir(ir1, ir2, ignore_fields=['source', 'metadata']) \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index 6356f82c..8cf6ec4a 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -1,6 +1,6 @@ from puzzlekit.formats.base import ( PuzzleInstance, CellState, EdgeState, - PenpaMetadata, COMPRESS_SUB + PenpaMetadata, COMPRESS_SUB, NumberColor, SurfaceColor ) from puzzlekit.formats.utils import generate_centerlist_diff from typing import Any, Dict, List, Optional, Tuple, Union @@ -153,6 +153,10 @@ def coord_to_index(self, coord: Tuple[int, int] ,type_: str) -> Tuple[int, int]: else: return r_ * self.real_cols + c_ + self.real_cols * 2 + 2 + def _display_parts(self): + for p in range(len(self.parts)): + print(p, self.parts[p]) + def decode(self, url: str) -> PuzzleInstance: self.url = url self.ir_puzzle = PuzzleInstance( @@ -163,9 +167,9 @@ def decode(self, url: str) -> PuzzleInstance: ) self.parts = decompress(b64decode(self.url[len(PENPA_PREFIX) :]), -15).decode().split("\n") header = self.parts[0].split(",") - + assert header[0] in ("square", "sudoku", "kakuro"), "Penpa puzzle must be in square, sudoku, kakuro" - + # info collect self.ir_puzzle.grid_type = "square" self.ir_puzzle.size = header[3] @@ -187,7 +191,7 @@ def decode(self, url: str) -> PuzzleInstance: self.ir_puzzle.rows, self.ir_puzzle.cols = self.new_rows, self.new_cols # print(f"Puzzle shape (r, c) = {(self.new_rows, self.new_cols)}", ) - + self._display_parts() for p in range(len(self.parts)): if p == 1: # decode margins @@ -201,24 +205,55 @@ def decode(self, url: str) -> PuzzleInstance: self.ir_puzzle.edges = self._decode_edge(edge_dict = v) # print(self.ir_puzzle.edges) elif k == "number": - self.ir_puzzle.cells = self._decode_number(number_dict = v) + self._decode_number(number_dict = v) + elif k == "surface": + self._decode_surface(surface_dict = v) else: pass elif p == 5: # decode box boxes = json.loads(self.parts[p]) self.ir_puzzle.boxes = boxes + # else: + # print(p, self.parts[p]) return self.ir_puzzle + def _decode_surface(self, surface_dict: Dict[str, int]): + for index, num_data in surface_dict.items(): + (r, c), _ = self.index_to_coord(int(index), 'cell') + if not num_data: continue + if (r, c) not in self.ir_puzzle.cells: + self.ir_puzzle.cells[(r, c)] = CellState( + surf_color = SurfaceColor(int(num_data)) + ) + else: + cell = self.ir_puzzle.cells[(r, c)] + cell.surf_color = SurfaceColor(int(num_data)) + self.ir_puzzle.cells[(r, c)] = cell + # Only update the color + def _decode_number(self, number_dict: Dict[str, int]): # ['4', 1, '1']: number, color, submode # lots to do here. for diff number format - new_number_dict = dict() + for index, num_data in number_dict.items(): (r, c), _ = self.index_to_coord(int(index), 'cell') - new_number_dict[(r, c)] = CellState(value = f"{num_data[0]}", num_color = num_data[1], num_style = num_data[2]) - return new_number_dict + if num_data[2] == "1": + # NORMAL + if (r, c) not in self.ir_puzzle.cells: + self.ir_puzzle.cells[(r, c)] = CellState( + value = f"{num_data[0]}", + num_color = NumberColor(num_data[1]), + num_style = num_data[2] + ) + else: + cell = self.ir_puzzle.cells[(r, c)] + cell.value, cell.num_color, cell.num_style = f"{num_data[0]}", NumberColor(num_data[1]), num_data[2] + self.ir_puzzle.cells[(r, c)] = cell + # ELSE? + + def _decode_edge(self, edge_dict: Dict[str, int]): new_edge_dict = {} @@ -230,14 +265,23 @@ def _decode_edge(self, edge_dict: Dict[str, int]): new_edge_dict[(coord_1, coord_2)] = EdgeState(connected = True, edge_type = v_) return new_edge_dict + def _encode_surface(self, cell_dict: Dict[str, CellState]): + new_surface_dict = dict() + for coords, v_ in cell_dict.items(): + if v_.surf_color: + index = f"{self.coord_to_index(coords, 'cell')}" + new_surface_dict[str(index)] = v_.surf_color.value + return new_surface_dict + def _encode_number(self, number_dict: Dict[str, CellState]): new_number_dict = dict() for coords, v_ in number_dict.items(): - index = f"{self.coord_to_index(coords, 'cell')}" - new_number_dict[str(index)] = [v_.value, v_.num_color, v_.num_style] + if v_.value: + index = f"{self.coord_to_index(coords, 'cell')}" + new_number_dict[str(index)] = [v_.value, v_.num_color.value, v_.num_style] return new_number_dict - + def _encode_edge(self, edge_dict: Dict[str, EdgeState]): new_edge_dict = dict() for coords, v_ in edge_dict.items(): @@ -249,19 +293,21 @@ def _encode_edge(self, edge_dict: Dict[str, EdgeState]): def encode(self, inst: PuzzleInstance) -> str: """Forge the Penpa+ format url.""" mtd = PenpaMetadata() - center_n = calculate_center_n(inst.rows, inst.cols, mtd.size) + center_n = calculate_center_n(inst.cols , inst.rows , mtd.size) center_list = generate_centerlist_diff(inst.rows, inst.cols, inst.margins) - self.real_rows = inst.rows + inst.margins[0] + inst.margins[1] + 4 # penpa size after padding - self.real_cols = inst.cols + inst.margins[2] + inst.margins[3] + 4 + self.real_rows = inst.rows + 4 # penpa size after padding + self.real_cols = inst.cols + 4 # 1. form pu_q dict, then update original_pu_q = mtd.pu_q # print(self.parts[3], "\n") # augmented update(number/edge only: for now) + original_pu_q["surface"] = self._encode_surface(inst.cells) original_pu_q["number"] = self._encode_number(inst.cells) original_pu_q["lineE"] = self._encode_edge(inst.edges) + # 2. standard JSON serialization (compact mode) # 3. construct text_lines @@ -301,6 +347,7 @@ def encode(self, inst: PuzzleInstance) -> str: for i in range(19): text_lines.append(to_pack_elem[i]) + print(to_pack_elem[i]) # else: text_lines.append(self.parts[i]) # 5. concatenate + compress + base64 @@ -313,17 +360,19 @@ def encode(self, inst: PuzzleInstance) -> str: if __name__ == "__main__": for test_url in [ - # "m=edit&p=7ZbPbhs3HITveoqAZx52Se7fS+Gmdi+u09YugkAQDFnZ1EZsKJWtpl3D756P5LAC2gBpUTS9BCtRI2o4/HE4S+39L/v1brK1iy/f28rWXGEI6e27Jr0rXRc3D7fT+Mwe7R+utzuAtS9OTuyb9e39tFiKtVo8zsM4H9n523FpamON412blZ1/GB/n78b52M7n/GRsT99pJjng8QG+TL9H9Dx31hX4TBj4Cri52W1up8vT3PP9uJwvrInzfJ1GR2jutr9ORnXE75vt3dVN7LhaP7CY++ubd/rlfv96+3Yvbr16svNRLvf8I+X6Q7kR5nIj+ki5cRX/cbnD6ukJ23+k4MtxGWv/6QD7AzwfH2nPxkcTmjj0K2pJe0NvnX57ldqT1LrUXjDUzj6136S2Sm2T2tPEOUbRdcG6oTKjY8cJjRtq4Q7shXtwyLivwK1wDe6EPXgQJoSVNIcKLP5Qg8UfPFj8GNq68FuwE+7AqmEYwCwfjDY4a6Jtvct8tMGZjzZY/Bq+Ez/eMa4XpgaXa0Dbep/XjjZYmg6+F9/B9+I7+EF8Bz8Ufg/OXqENVg2etYe8drTB0uT29Y34Hn4jfoDfiB/gN+IHvGqzV2iDVUNg7a3WHtBspdnEA0H8Jh4M4jfwO/Eb+J34LV518qqlhk41tKy909pbNHtpdvB78Tv4vfgd/F58MuaVsXQ4KWOeXHnlCm2w1k7GvDKGtg2VvO07sGruB7D4ZCwoY2iDVUM8EJUrtMGal4wFZQxtcPYWbXCuGW0bXOajDS58alDGQtWDc/3MA841MA8418A8YOmTn6D8MM6GkGtjHFj6Dv0gfbIUlCXG2aBsMA6suchGUDYYB5Y+OQnKCeNs0L4zDqy52PegfWccWPpkIJQMxL1Tf/5jKf1kvmSDs4K9POypcpK8qoq3zKX7nc8/9iXUcIr/NZzifw3HFX+it6rfRW+LV9FbeeWjt8Ur1u61Fs/avbzyrN1rXzzz6r7m87AvIfpcfIs+F9+Ytym+Rc81bxM9Lx4yb9ojDteX6Yh9ntqQ2jYdvV080//mqc+dbMbemlRD/gv490f+J2tbYl98nvjr1Xzpj9dqsTTn+92b9Wbib/349c/Ts7Pt7m59y7ez/d3VtCvfeap6WpjfTHovfXxI+/Kg9T89aMUtqP7R49ZnuNc+Uc4Sd7kb5xfWvNtfri83WzKGd7GfA+nP/Z+9eg4Lcz39vn6/fjuZ1eID", - # "m=edit&p=7VbvT+M4EP3evwLl61q3cZw0P6T9UErZEwKOHnA9qCpk2pQG0oZNWuCC+N/3je20TVv27nQ6iZNObezJ83jmzdgep/i2kHnMuE1/ETD0+Lk8UI8TNNVjm99FMk/jaO/n+A/5LB9i1lrMJ1ke7b0kMqOHsXYqiyIZLlX28kUa78nHxzSJi5/YL4eHbCzTImZHV/f7Bw+t507r98/etRCXp+NP9wfdy/tR7zfetZPPuX2aBrOTs4P99NPX8vpk0nqKO3HzrMiGkzSWI1le945e0tlhcDcZ8/bRpB2M5cwuvgUX4dN+98uXRt/QHjReyzAqu6z8GvUtbjHLwcOtASu70Wt5EpUdVp5jyGJ8wKzpIp0nwyzNcqvCymM90YHYWYk9NU5SW4PchnxqZIhXEIdJPkzjm2ONnEX98oJZ5HtfzSbRmmZPMTkjbvQ+zKa3CQG3co6MF5Pk0WICA8VilD0sjCofvLGypSM4/4sRwEgVAYk6ApJ2RECB/bsRhIO3NyzOr4jhJupTOJcrMViJ59Er2tPo1RI2TXVBRa+g5TkENFeAr4A1Dc7dKmFLxNtCfEKwL5aIpzwtdeCfKxZXqj1UraPaC5BkpVDtgWpt1XqqPVY6HXB3PMEcD2Qc7EDPhQwaSvYgN42ME+eBDMku6RvZCRnetcyh74RG32eOz7Xsc5xWYzOATmh0wpAJrueiZ8LRNtEz4eq56Jmo+IQ01/AJwSc0HEL4CgMjozos7TdhH+lS9lFGHB0jeiZEpQ9f3Pji8OVoX+iho+2gBwcTI+wLjqVU+g70NR/04Gx8ufBV5ZODmzB5EMiDa2JxKbdVrpBD39j3kdvAxBWAm2242eBG20P5BTeTZ/Twa3KIdRHVunDYFMamgE23WjvwaRo+TfDxDR8ffALDJ4BN29i0YZM2ofILPiYW9PBr+CAWUcVCvoSJXWAvCaND+sLkTSBvhhvWAXMJx2bsqS3ZVq2r2qbaqj6dtr91Hv/5qfhTOn2h76T6z/vvYYNGHzeRVWTpTbHIx3IY38Qvcji3In0jro/UsNliehujlK9BaZbhRp3tslAN1cDkbpbl8c4hAuPR3XumaGiHqdssH21wepZpWo9FfVvUIH2V1KB5jnti7V3mefZcQ6ZyPqkBa3dKzVI820jmXNYpyge54W26Ssdbw3qx1NPH2aL1+v+z4QN/NtBC2R+tWH00OmqPZ/kPCs5qcBPeUXaA/qDyrI3uwt8pMmujm/hWRSGy20UF6I66AnSztADari4AtwoMsHdqDFndLDPEarPSkKutYkOu1utNf9D4Dg==", - # "m=edit&p=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", - # "m=edit&p=7Vbvbts2EP+epyAEFGgBxbZkO070LUvrYkCXbU22ojCMgpZpi7BEeiQVJwoC9B32dXu5Pkl/R8m1FWfDMGBoPwyyT3c/Hu8vj7b9reRGhCM8J8OwF0Z4hnHPf0/8Z/tcS5eLhF2t7mxq+FoYy56PeW7Fi/C8dJk2CXttuHJsLFfChplza5t0u5vNprMs1mVV5cJ2Ul10Z7leduNePOhGva7dmTtekLXj2d3xkuwcL8hON/yVG8md1IrpRcv72xIWE/a9ssI4xtlcLqVjC6MLFjGn2SWTCi/B04ylIs8hMpcJ4LM7kKWRc2Y1IO6Y0s12I9aCO0u6XN0xozdMG5bqvCxUh53nVj+LL2qbqixmwmytenPYbYQVCgYIy4RcZo7i5mxWynwu1RJm534xzUthGdIiQZfOyjnei50xqeYy5Q5mEERB0WxtWJZyxWaCffr4hxVCffr4J9tkQrFc6xX58EXwic2lESlVr8OueZ4j3p0RNCJdeX83UiDPBbPFI50OGyN9ccuLdS4oc0m5UFVSrRyXStTJ1LWwLBr2BzHpAURmG73nDueMUbTb6ATLxcIhh9/RL6rK0PN1fYwQe1u/7DC+oKTmvQyI7LZupMvqcoIcZIpQ0Xj3uAroH8zj3Na1LzrP4pf4nOPAiFsnjKT++15RAv6Mepc202UOk4IJuIVJrdAqxEdsLeV6AwF9qJt8w2GGLeWNUI0TKi4vnS5wwFMcxrz0Bz3NREp9bAr5pbrPpUIk/hSl2lBjkVM7yBesKK3zUSngWEd+S1RTkbEZVOgNbSx1wh/H49BndDShwcczPbqvzpLqPKxeJ5MgCsIgxjcKpmH1c3Jf/ZBUl2F1haUAumH1plbqg31VszHB77wCoRe1ag/sJdizGn0PNpUmzcWHN9gC5KdkUl2HAfn5zm8hNij0jQiaOEjG9TGTBMy4w21kM7luVmw516uy0Y2mD2F17sPdLvx90MR+5ZjPpg8PqP1bRP0hmVACv+zY0x17ldyDXnoaJfdB1Ds5JRNDHyXEUUxifyueDvZXozOvPGjEOI5IRIu92O8Pm2hqcTDaN9Uf7pTh/L0PYexp7Ok1IgyrvqcvPe15OvT0jdd55ek7Ty88HXh64nVGlOO/qMJ/Gs6kf1LPBp7RP+OmR5PgyneXXWqDSxUdb+QLbZQwezLm1IgAcxdg/j/Y0ix4igPkxxJnBFg9/kHiTNkguOfXOS7flppcKm3Ek0sEivnyKf2ZNnMyvrewwRXZAuo/CS2onoQW5AyO+Z7MDX4nWgiuuawF7I1EyxJq0g7A8XaIfIV/BW3bu5wfjoLbwH8nffxxGf1/p33NO4360PvWZvpbC8cfYW2eHH/A2xugjT456g1+MO3AD+aaHB6ONtAnphvo4wEHdDjjAA/GHNhfTDpZfTzsFNXjeSdXByNPrvanfjI98txn", - # "m=edit&p=7VffT+NGEH7nr6j29VaNd/3rh3SqQoCTEEehQCmJIuQkThxw7JztADLif79vdh3shHBVe33goXK8+81MPPPN7O44Kb6twjziNhcmt21ucIHLNA3uurgd+hj1dTkvkyj4hXdXZZzlAHFZLoug01muqn7V/zWZp/ed5W9plmazPFx0rI4wOkKasWU7sev5cTgap2ImZ+bMwi1nYsb570dHfBomRcSPb+72D+67j4fdvzp23zSvTqef7g7Or+4m13+Kc2PeyY3TxEu/nh3sJ5++VP2vcfchOoycsyIbx0kUTsKqf338lKRH3iyeit5x3POmYWoU37xL/2H//PPnvYGpEjSGe8+VH1RdXn0JBkwwziRuwYa8Og+eq68BG2eL0Zzx6gJ2xsWQs8UqKefjLMlyttZVJ/ppCXjYwGtlJ9TTSmEAn9YY8AZwPM/HSXR7ojVnwaC65IwI7KunCbJF9hBRMCJIsiYFxSgssRRFPF8ybsJQrCbZ/ar+qhi+8Kr7L9KAp3UaBHUahHakQdn9fBrJMtuRgD98ecEC/YEUboMBZXPVQK+BF8EzxtPgmUmBR02srFpDJiVEpxFNiH4jWhCFfJVNA3JLJF84DmuRfHmNSL4EnZFa9kl+FS3yZTUi+XIbkXwJ2oFadhWTV9Ej3w0Rn77dJCUMctb4FoJiteyCqDSPC+m1vKNQQpXrRo1HapRqvEQ1eWWq8UCNhhptNZ6o7xyiyMJBcBccJTy6COyBIGEPQX2QI+wLLg0QA8YMDFIK+1wKECIsPC6lq7F0uTQdjU2HS8vW2LK5tFEewrbFpaPjYubS1XExc+nVcR0skat9YgY37RMzuGmfmMGn9mnAp6h9ovNJ2jKKD3zSDlB8kAstqOKDXKw6Fwu52HUuNnJx6lwc5OLWuaBtSm8dFyveqo+ghVWY6lbX00c9fc0N8ytnaYIz1QccdYFMBNMCVQ4stAAatBW1AH7EWwkeiNdFwgxcJ4pFk7ThFEYBvLowHgqjiGPVr9Xa99RoqdFRe8Kl8/ePTujPb7+/pTOw6JTSRSf/P5iHewN2scqn4ThC4+pli2VWzMuI4eXBiiy5LbTtNnoKxyUL9EusbdnQpavFKELPbamSLFvinbnLw9q0oZzP0iyPdppIGU1m77ki0w5XoyyfbHF6DJNkMxf1+2BDpXv+hqrM0dBbcpjn2eOGZhGW8Yai9Q7b8BSlW8Usw02K4X24FW3RlONljz0xdaM14nz9/6b/8G96Wizjo3WTj0ZH7fMs/0HTaYzb6h2tB9ofdJ+WdZf+nUbTsm7r33QVIvu2sUC7o7dAu91eoHrbYaB802Sge6fPkNftVkOstrsNhXrTcChUu+cM2PpPDxvufQc=", - "m=edit&p=7VZtT+M4EP7eX4Hyda27OM67dB9Kl+7LQbcsIJZWFQolQCAlXF4KG8R/32dsp23asnen00mcdEpjTx+PZ54Z2+MUf1RRHjNu0k/4DD0em/vytXxXvqZ+jpMyjcOdj/H36DG6i1m3Km+yPNx5SqKMXsZ6aVQUyXShspNXabwTPTykSVz8wr70++wqSouYfT672e9l3cf33W9zvxyN+Aez+mSe3vZv332d/f4pETnvD/zhwfAgsa67H3u7h+7eO3dYFSdlPD+c8d3bk9Hx1fD0OrC+7w1Gdj36YjqfR1e/zrsnv3XGmvGk81wHYX3I6g/h2OAGMyy83Jiw+jB8rg/CesDqIwwZjE+YMavSMplmaZYbDVbvq4kWxL2leCrHSeopkJuQB1qGeAZxmuTTND7fV8gwHNfHzCDfu3I2icYsm8fkjLjR/2k2u0gIuIhKJLu4SR4MJjBQVJfZXaVV+eSF1V0VwdFfjABGmghIVBGQtCUCCuzfjSCYvLxgcb4ihvNwTOGcLEV/KR6Fz2gH4bMhTJpqg4paQcOxCHCXgCeBFQ3O7SZhC8TZQDxCsC8WiCM9LXTgn0sWZ7Lty9aS7TFIslrI9r1sTdk6st2XOnvgbjmCWQ7IWNiBjg0ZNKTsQHa1jMPmgAzJNulr2QoY/iuZQ98KtL7HLI8r2eM4qNqmD51A6wQBE1zNRc+EpWyiZ8JWc9Ez0fAJaK7mE4BPoDkE8BX4WkZhWNh3YR/pkvZRQSwVI3omhNJHD/tYGpId8qVksim45sCBW4oDesxV9tFDX8cOv4JrO9yCvuKJHrFoDjY4NHnm4Cx0fgTyY+sYbcp5k0Pk1tP2PeTc1/H64GZqbia40baRfsFN5x89/OrcYr1Es14cNoW2KWDTbtYUfFzNxwUfT/PxwMfXfHzYNLVNEzZpc0q/4KNjQQ+/mg9iEU0s5Evo2AX2mNA6pC903gTyJrlhY57K7dmTrS1bV25bj07e3zqb//yE/CmdsVBXU/tx/nvYpDPGrWQUWXpeVPlVNI3P46doWhqhuhhXR1rYfTW7iFHWV6A0y3Cx3m+z0Ay1wOT6PsvjrUMExpfXr5mioS2mLrL8co3TY5Sm7VjkJ0YLUtdKCypz3Bkr/6M8zx5byCwqb1rAyv3SshTfryWzjNoUo7tozdtsmY6XjvFkyHeM80Tr9f8nxBv+hKCFMt9asXprdOQez/KfFJzl4Dq8pewA/UnlWRndhr9SZFZG1/GNikJkN4sK0C11Beh6aQG0WV0AbhQYYK/UGLK6XmaI1XqlIVcbxYZcrdab8aTzAw==", - "m=edit&p=1VVfb9s2EH/3pyAIFEgAxbbkP7H1lqXNXtqsq70VhWAEtMRYhCXSo8g4VpB+jX2gfbHekU4txV6BPWzAIOt8+ul497szf3T1h2WaB2EfP8NRAN9wDacjd0eTsbv7+2suTMFjcmu1WLMlJ2cfmdDVOTmjITGKTOl5cGVNrnRMPljNDLlmksyVNCzIjdlUca+33W67q3Jj67rgVTdVZW9ZqFUv6kdRr3/Zk/vUFxvMfLHcXZSY6CJl8sJgol7wO9OCGaEkUfcHKtpCvpjMcpZxUqmSE15uzI6kvCgqAIjJgY/JOVlpkRFRkUw8iIyDK4G6lRWuzAhMg1VvomvCWZqTFEsKKeSK8EeWmmJHzFYRacsl1xU5E7IynGXIREl+TpjMSM4eMJ7BRAwrSCVq4AJjLnBESKCyJS5At5GsS+bw0OZBSraDxFhVWaCTCbZSkhXFrkuuCsjtY32Ppa0MgUkAZ8lTA/hWmNz3oaCYxq6W1hCpSPTXnxEMQtkNMvHrYcS4nEsjNIeSPnn3TfQWPjdKE2aNKmHyKYyzsO4XSHOerqFbTO1aO0WoZHrt5kyWBUvXBFP5SCy+0mzXDX65uQnuWVHxTrLfbItOQkMa0AjukC6+1rOvCaVBuOg81Z/ip/ouThbPQf3bwZ0c3Fn8BPY2fqKDiMYJHcBClyagoxAByPodmCIApV6A8QCBSQO4RGB0ACYTBKYHIOy7kEbWMHIxlw3EUxk3kfGr0uFwiMiwgXg232OgrdA198XZG2cjZ+fQe1APnH3rbN/ZkbPvXcw7Zz87e+3s0Nmxi7nE6XU6ycAfA+1r9P/DcA/NrL5nKaewbyjs27tq/xwbbXngIK9AGrsN6KFCqU0hJIQ1QLGSCqRx6hWCPFudil8qnb3KvgUJtwB/DLegVOi0aENGi9Yz01ptWwjIM28BS2bgyK5ysWlnApW3CRjWpsjWcMy2cx96fu7QR+ruJAqicRBOUJXTuL4K6p/9bn3RbVD/CrL8ENe3qEqvYNyMLmgA7jvvRuB+du8RvPaRfXBv97sf3C/g+rHcvfcrPsZJPQ8olvnJLUGXluoBmHoa+Az/MkvoJaGNafg3lc3U2r4IDMV15dnOfswW3R+xRW7/Mtvp4tn/DP1/dCD+B+fH415pSh/E1thHAJ8QHKAnhbXHj7QF+JGKsOCxkAA9oSVAX8sJoGNFAXgkKsD+RleY9bW0kNVrdWGpI4FhqabGkkXnGw==" + "m=edit&p=7Vfrb+I4EP/OX3Hy17WO2CYPIq1OlNKVqrbbXtvrFYRQXkBoIGwSSpWq//vOmCJsQ3uPSqv9sEIZzfxmMi/jsVN+WwVFQplHmUWFRy3K4OdyTluAtWxLPtvfTVplif8b7ayqaV4AM62qZek3m8tV3a/7v2fp4qG5/KPM0mqaFE3mNZnVnITRhMeTOI5dEXMvjtdcWK7DOPNsJniYRrNoEc6CVAjmuYwLzxVCxOvQ4TFz4jAOJyyaRAGlX09O6DjIyoSe3s+Ojh86617n76bdF+L2Yvxpdnx1O4vv/mJXVtosrIvMW5xfHh9ln77U/fNp5zHpJc5lmUfTLAnioO7fnT5lixNvMh2z7um0642DhVV+827aj0dXnz83Bq9FDxvPdduvO7T+4g+IIJQweDgZ0vrKf67PfRLl8zAltL4GPaFsSMl8lVVplGd5QbZYfQYcvMmB7e3YO6lHrrsBmQX8xSsP7D2wUVpEWTI62yCX/qC+oQQTOJJvI0vm+WOCwTA5lDdJARAGFSxaOU2XhApQlKs4f1i9mrLhC607/6MM8LQtA9lNGcgdKAOr+3AZSTxJng5U0B6+vMAK/Qk1jPwBlnO7Y70de+0/A73wn0nLhVfxXw6vgzfbApHvxJYu2iCKndjWRIfroq51Ubtz5aJnRat7dj3dWHflMV3UXTGmR2JMGHq0x629lTG2aq+3hDGMrshc7xFsXUOv94FxIx53DHuMp9rrxTNuxBcoK/oW+lNkx8jHQX9KfNlcJZ7RXeZhfcr7RruZh/1U4nnYP9XeyMcz6vOM+G2jn22jf21j/drGerf1/w7naL/Lhxv95kZ/ueyv8r7A+Iq9MPwJI55Af4q+Zehbht42/Nt6P7mN/VJkB/uzXT/Ytkxu3ntJTyTlkt7A3qa1kPRYUktSW9IzadOT9E7SrqQtSR1p4+J0+E/z4wekM2g58hh+/2f/svmozbAxINerYhxECZw33Xy+zMu0Sgic+aTMs1G50Y2SpyCqiL+5e6gaDVus5mECR6UCZXm+hEvRIQ9blQamk0VeJAdVCOIh+IYrVB1wFeZFbOS0DrJMr0XeATVoc1RrUFXAOazIQVHkaw2ZB9VUA5Srh+YpWRjNrAI9xeAhMKLNd+14aZAnIh8YOTA0fl3Qfv4LGq6W9bON2X9IZ1D3KIFvEirgeKT1V0qWq1EwgnYT+CqgH1H/G4Mf3g25z/LinaG3U5rwgdEH6DvTT9Eewt8YdIrWxPemGia7P9gAPTDbADXHG0D7Ew7AvSEH2BtzDr2aow6zMqcdhtobeBhKnXkD8vpVjd/YZNj4Dg==" ]: hpc = PenpaConverter(dict()) tmp = hpc.decode(test_url) + # print("\n\n", tmp.cells) enc = hpc.encode(tmp) print(enc) - \ No newline at end of file + # b = hpc.decode(enc) + # print(enc) + + # print(a) + # print(b) + # print(enc) + # print(tmp.cells) + \ No newline at end of file diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index fdbbdc39..9245ab28 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -1,12 +1,16 @@ from typing import Dict, Any, List, Optional, Union, Set from puzzlekit.formats.base import ( - PuzzleInstance, CellState, EdgeState + PuzzleInstance, CellState, EdgeState, NumberColor, SurfaceColor ) -from puzzlekit.formats.utils import generate_centerlist_diff +from puzzlekit.formats.utils import ( + generate_centerlist_diff, index_to_coord, coord_to_index, auto_border_split +) +import math import logging ALLOWED_PUZZLE_TYPE = { - "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone" + "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", + "nonogram", } # allowed puzzle types @@ -22,15 +26,76 @@ class PuzzlinkConverter: def __init__(self, config: Dict[Any, Any] = dict()): self.config = config or {} + def _decode_nonogram_variant(self): + self.body = self.url.split("/")[-1] + number_map = self._decode_number16() + # print(number_map) + max_cols_offset = math.ceil(self.num_cols / 2) + max_rows_offset = math.ceil(self.num_rows / 2) + + rows_offset, cols_offset = 0, 0 + for k, v in number_map.items(): + if k < max_rows_offset * self.num_cols: + rows_offset = max(rows_offset, int(k % max_rows_offset) + 1) + else: + cols_offset = max(cols_offset, int((k - max_rows_offset * self.num_cols) % max_cols_offset) + 1) + + cell_dict, edge_dict = dict(), dict() + + for k, v in number_map.items(): + if k < max_rows_offset * self.num_cols: + row_idx = rows_offset - k % max_rows_offset - 1 + col_idx = cols_offset + int(k / max_rows_offset) + cell_dict[(row_idx, col_idx)] = CellState( + value = f"{v}", + num_color = NumberColor.BLACK, + num_style = "1" + ) + else: + row_idx = rows_offset + int((k - max_rows_offset * self.num_cols) / max_cols_offset) + col_idx = cols_offset - (k - max_rows_offset * self.num_cols) % max_cols_offset - 1 + cell_dict[(row_idx, col_idx)] = CellState( + value = f"{v}", + num_color = NumberColor.BLACK, + num_style = "1" + ) + + self.ir_puzzle.puzzle_type = "nonogram" + self.ir_puzzle.title = self.puzzle_type + self.ir_puzzle.rows = self.num_rows + rows_offset + self.ir_puzzle.cols = self.num_cols + cols_offset + self.ir_puzzle.margins = [rows_offset, 0, cols_offset, 0] + self.ir_puzzle.source = self.url + self.ir_puzzle.cells = cell_dict + self.ir_puzzle.edges = auto_border_split(self.num_rows + rows_offset + 4, self.num_cols + cols_offset + 4, [rows_offset, 0, cols_offset, 0]) + self.ir_puzzle.boxes = generate_centerlist_diff(self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins) + + def _decode_heyawake_variant(self): border_list = self._decode_border() region_grid, _ = self._convert_border_to_region_grid(border_list) number_map = self._decode_number16() - # logger.info(f"Number Map: {number_map}", ) - # logger.info(f"Border_list: {border_list}") grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] self._move_numbers_to_top_left_corner(grid, region_grid, number_map) - return (self.num_rows, self.num_cols, grid, region_grid) + + self.ir_puzzle.puzzle_type = "heyawake" + self.ir_puzzle.title = self.puzzle_type + self.ir_puzzle.rows = self.num_rows + self.ir_puzzle.cols = self.num_cols + self.ir_puzzle.margins = [0, 0, 0, 0] + self.ir_puzzle.source = self.url + self.ir_puzzle.cells = self._reindex_number(self.num_rows, self.num_cols, [0, 0, 0, 0], grid, skip = "-") + self.ir_puzzle.edges = self._reindex_edge(self.num_rows, self.num_cols, [0, 0, 0, 0], region_grid) + + # puzzlink_pu.drawBorder(pu, info_edge, 2); // 2 is for Black Style + # puzzlink_pu.drawNumbers(pu, info_number, 1, "1") // Black Style, Normal submode is 1 + self.ir_puzzle.boxes = generate_centerlist_diff(self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins) + # return (self.num_rows, self.num_cols, grid, region_grid) + # NOTE: + # // Change to Solution Tab + # pu.mode_qa("pu_a"); + # pu.mode_set("surface"); //include redraw + # UserSettings.tab_settings = ["Surface"]; def _reindex_number(self, r: int, c: int, margins: List[int], grid: List[List[str]], skip: Set[str] = set(), @@ -43,7 +108,7 @@ def _reindex_number(self, r: int, c: int, margins: List[int], if grid[r_][c_] not in skip: # idx = pad_index + r_ * (c + 4 + left_m + right_m) + (c_ + 2 + left_m) # res_dict[f"{idx}"] = [grid[r_][c_], color, submode] - new_number_dict[(r_ + top_m, c_ + left_m)] = CellState(value = grid[r_][c_], num_color = color, num_style = style) + new_number_dict[(r_ + top_m, c_ + left_m)] = CellState(value = grid[r_][c_], num_color = NumberColor(color), num_style = style) return new_number_dict def _reindex_edge(self, r: int, c: int, margins: List[int], @@ -107,24 +172,10 @@ def decode(self, url: str) -> Dict[str, Any]: "shimaguni", "stostone" ]: - _, _, grid, region_grid = self._decode_heyawake_variant() - self.ir_puzzle.title = self.puzzle_type - self.ir_puzzle.rows = self.num_rows - self.ir_puzzle.cols = self.num_cols - self.ir_puzzle.margins = [0, 0, 0, 0] - self.ir_puzzle.source = self.url - self.ir_puzzle.cells = self._reindex_number(self.num_rows, self.num_cols, [0, 0, 0, 0], grid, skip = "-") - self.ir_puzzle.edges = self._reindex_edge(self.num_rows, self.num_cols, [0, 0, 0, 0], region_grid) - - # puzzlink_pu.drawBorder(pu, info_edge, 2); // 2 is for Black Style - # puzzlink_pu.drawNumbers(pu, info_number, 1, "1") // Black Style, Normal submode is 1 - self.ir_puzzle.boxes = generate_centerlist_diff(self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins) - - # // Change to Solution Tab - # pu.mode_qa("pu_a"); - # pu.mode_set("surface"); //include redraw - # UserSettings.tab_settings = ["Surface"]; - + self._decode_heyawake_variant() + + elif self.puzzle_type in ['nonogram']: + self._decode_nonogram_variant() elif self.puzzle_type in ["country", "detour", "juosan", "yajilin-regions", "yajirin-regions"]: # toichika2, nagenawa, maxi, factors are neglected. return self.ir_puzzle @@ -168,9 +219,19 @@ def encode(self, inst: PuzzleInstance) -> str: self.puzzle_type = inst.puzzle_type self.num_rows, self.num_cols = inst.rows - inst.margins[0] - inst.margins[1], inst.cols - inst.margins[2] - inst.margins[3] - if self.puzzle_type in ["heyawake", "shikaku", "aqre","heyawacky","shimaguni","stostone"]: - self.body = self._encode_heyawake_variant(inst) - return self.body + if self.puzzle_type in [ + "heyawake", + "shikaku", + "aqre", + "heyawacky", + "shimaguni", + "stostone" + ]: + body_str = self._encode_heyawake_variant(inst) + return body_str + elif self.puzzle_type in ['nonogram']: + body_str = self._encode_nonogram_variant(inst) + return body_str else: raise NotImplementedError(f"Puzzle type {self.puzzle_type} not supported for encoding") @@ -186,16 +247,71 @@ def _encode_heyawake_variant(self, inst: PuzzleInstance): number_map[int(region_grid[r_][c_])] = val border_str = self._encode_border(border_list) - # 5. number_map → number16 number_str = self._encode_number16(number_map, max_region_id) - logger.info(f"{region_grid}") # 6. concat body body = f"https://puzz.link/p?{inst.puzzle_type}/{inst.cols}/{inst.rows}/{border_str + number_str}" return body - + def _encode_nonogram_variant(self, inst: PuzzleInstance): + rows_offset = inst.margins[0] # top margin + cols_offset = inst.margins[2] # left margin + + # 2. 计算原始网格大小(不含 margin) + num_rows = inst.rows - rows_offset + num_cols = inst.cols - cols_offset + + # 3. 计算 max offsets(与解码逻辑一致) + max_rows_offset = math.ceil(num_rows / 2) + max_cols_offset = math.ceil(num_cols / 2) + + # 4. 构建 number_map + number_map: Dict[int, Any] = dict() + + for (r, c), cell_state in inst.cells.items(): + if not cell_state.value or cell_state.value.strip() in ['-', '']: + continue + + # 解析数字值 + val = cell_state.value.strip() + if val == '?': + number_val = '?' + else: + # 尝试解析为整数 + try: + number_val = int(val) + except ValueError: + number_val = val + + # 判断是行提示还是列提示 + if r < rows_offset: + # 行提示(顶部 margin) + # 解码公式:row_idx = rows_offset - k % max_rows_offset - 1 + # col_idx = cols_offset + int(k / max_rows_offset) + # 编码反向:k = (col_idx - cols_offset) * max_rows_offset + (rows_offset - row_idx - 1) + k = (c - cols_offset) * max_rows_offset + (rows_offset - r - 1) + number_map[k] = number_val + + elif c < cols_offset: + k_offset = (r - rows_offset) * max_cols_offset + (cols_offset - c - 1) + k = max_rows_offset * num_cols + k_offset + number_map[k] = number_val + else: + # 网格内部,忽略(nonogram 的数字只在 margin 区域) + pass + + # 5. 编码 number_map 为 base16 字符串 + # 需要找到最大的 k 值来确定 max_region_id + max_k = max(number_map.keys()) if number_map else 0 + number_str = self._encode_number16(number_map, max_k) + + # 6. 构建完整的 puzz.link URL + body_str = number_str + url = f"https://puzz.link/p?nonogram/{num_cols}/{num_rows}/{body_str}" + print(url) + return url + def _region_grid_to_borders(self, edges_dict: Dict[Any, List[EdgeState]]) -> Dict[int, int]: """ Reconstruct edge dict from region_grid. @@ -457,7 +573,7 @@ def _encode_number16(self, number_map: Dict[int, Any], max_region_id: int) -> st # cuz the grid is actually not affected and str ends. Yet the last char is kept here for completeness. if skip_count > 0: result.append(self._encode_skip(skip_count)) - logger.info(''.join(result)) + # logger.info(''.join(result)) return ''.join(result) @@ -521,8 +637,6 @@ def _encode_value(self, val: Any) -> str: else: # 非法值,默认用 '-' 编码 0 return '-' - - def _int_to_base32(self, val: int) -> str: """integer -> base32 (0-9, a-v)""" @@ -820,17 +934,27 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, if __name__ == "__main__": + from puzzlekit.formats.penpa_converter import PenpaConverter PzpCvtr = PuzzlinkConverter() url_list = [ - "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g22.i33k2g", - "https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h", + # "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g22.i33k2g", + # "https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h", + # "https://puzz.link/p?nonogram/15/11/55j111i13p55j1k55j1k121i5111h12j5k121i21111g1212h33113i111111h11113i11111i3331r111111h211211h22111i111111h211113h" + "https://puzz.link/p?nonogram/15/11/55j111i13p55j1k55j1k121i5111h12j5k121i21111g1212h33113i111111h11113i11111i3331r111111h211211h22111i111111h211113" ] for url in url_list: p_ir = PzpCvtr.decode(url) url_new = PzpCvtr.encode(p_ir) - print(url_new) - print(url) + + + # penpa_cvter = PenpaConverter() + # penpa_str = penpa_cvter.encode(p_ir) + # print(penpa_str) + + # penpa_ir = PzpCvtr.decode(url_new) + # print(penpa_ir.cells) # assert url_new == url # from puzzlekit.formats.penpa_converter import PenpaConverter # penpa_url = PenpaConverter("") # penpa_test = penpa_url.encode(res) + \ No newline at end of file diff --git a/src/puzzlekit/formats/puzzlink_type_config.py b/src/puzzlekit/formats/puzzlink_type_config.py new file mode 100644 index 00000000..e69de29b diff --git a/src/puzzlekit/formats/utils.py b/src/puzzlekit/formats/utils.py index 61b7ca66..f0d025c7 100644 --- a/src/puzzlekit/formats/utils.py +++ b/src/puzzlekit/formats/utils.py @@ -1,4 +1,85 @@ -from typing import List +from typing import List, Tuple +from puzzlekit.formats.base import EdgeState + +def auto_border_split(rr: int, rc: int, margins: List[int] = [0, 0, 0, 0], intervals = 5): + """ + Automatically split the grid into more readiable style via adding edges. + + Args: + r (int): _description_ + c (int): _description_ + margins (List[int], optional): _description_. Defaults to [0, 0, 0, 0]. + intervals (int, optional): _description_. Defaults to 5. + """ + edge_dict = dict() + # Vertical + pivot = [margins[2], rc - margins[3] - 4] + for c in pivot: + r = 0 + while r < rr - 4 - margins[1]: + coord_1, coord_2 = (r, c), (r + 1, c) + edge_str = f"{coord_to_index(rr, rc, coord_1, 'edge')},{coord_to_index(rr, rc, coord_2, 'edge')}" + # edge_dict[f"{edge_str}"] = 2 + edge_dict[(coord_1, coord_2)] = EdgeState(edge_type = 2) + r += 1 + pivot = [i for i in range(margins[2] + intervals, rc - margins[3] - 4, intervals)] + for c in pivot: + r = 0 + while r < rr - 4 - margins[1]: + coord_1, coord_2 = (r, c), (r + 1, c) + edge_str = f"{coord_to_index(rr, rc, coord_1, 'edge')},{coord_to_index(rr, rc, coord_2, 'edge')}" + # edge_dict[f"{edge_str}"] = 13 # dotted line + edge_dict[(coord_1, coord_2)] = EdgeState(edge_type = 13) + r += 1 + # HORIZONTAL + pivot = [margins[0], rr - 4 - margins[1]] + for r in pivot: + c = 0 + while c < rc - 4 - margins[3]: + coord_1, coord_2 = (r, c), (r, c + 1) + edge_str = f"{coord_to_index(rr, rc, coord_1, 'edge')},{coord_to_index(rr, rc, coord_2, 'edge')}" + # edge_dict[f"{edge_str}"] = 2 + edge_dict[(coord_1, coord_2)] = EdgeState(edge_type = 2) + c += 1 + + pivot = [i for i in range(margins[0] + intervals, rr - margins[1] - 4, intervals)] + for r in pivot: + c = 0 + while c < rc - 4 - margins[3]: + coord_1, coord_2 = (r, c), (r, c + 1) + edge_str = f"{coord_to_index(rr, rc, coord_1, 'edge')},{coord_to_index(rr, rc, coord_2, 'edge')}" + # edge_dict[f"{edge_str}"] = 13 + edge_dict[(coord_1, coord_2)] = EdgeState(edge_type = 13) + c += 1 + return edge_dict + +def index_to_coord(rr: int, rc:int, index: int, type_: str = 'edge') -> Tuple[Tuple[int, int], int]: + """_summary_ + + Args: + rr (int): Real Row (rr) = row + margin_top + margin_bottom + 4 + rc (int): Real Col (rc) = col + margin_left + margin_right + 4 + index (int): The index in penpa format. + type_ (str, optional): The type of this coord. Defaults to 'edge'. + + Returns: + Tuple[Tuple[int, int], int]: _description_ + """ + assert type_ in ("edge", "cell"), f"Wrong index type for index_to_coord, expected 'cell', 'edge', get {type_}" + category, index = divmod(index, rr * rc) + if type_ == "edge": + return (index // rc - 1, index % rc - 1), category + else: + return (index // rc - 2, index % rc - 2), category + +def coord_to_index(rr: int, rc: int, coord: Tuple[int, int] ,type_: str) -> Tuple[int, int]: + assert type_ in ("edge", "cell"), f"Wrong index type for index_to_coord, expected 'cell', 'edge', get {type}" + r_, c_ = coord + if type_ == "edge": + return (r_ + 1) * rc + (c_ + 1) + rc * rr + else: + return r_ * rc + c_ + rc * 2 + 2 + def generate_centerlist_diff(rows: int, cols: int, margins: List[int] = [0, 0, 0, 0]): """ diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py index de4fd6df..f9138a49 100644 --- a/tests/formats/conftest.py +++ b/tests/formats/conftest.py @@ -5,7 +5,20 @@ def penpa_test_urls(): """Penpa+ cases""" return { "heyawake_1": "m=edit&p=7VdrT+NGFP3Or6j8dUeNx+O3tKrCayUEFAqUQhQhE0xeTpx1HlAj/vueO76TxCFstapWolKVZHzmzMydO/dcXzvTr/OkSIW06atCgSs+rgz1zwl9/bP5c9mfZWn8i2jOZ728AOjNZpNp3GhM5uVteftr1h8PG5PfeunfyVMyTBvSpm9nOLnvF56fdKZDGY0GHV91i0GuVNfuLBZykasg7zpdJQey6ziyr9TQ6Qrx++GheEyyaSqObga7+8Pm00Hzr4Z3q9TV6eOnwf751eDh+k95bvcbhX2aheOTs/3d7NOX8vak11ykB6l/Ns07vSxNHpLy9vroORsfht3eo9w76u2Fj8nYnn4NL6PF7vnnzzstPmJ756WM4rIpyi9xy5KWsBz8pNUW5Xn8Up7E5YUoLzBkCdkW1miezfqdPMsLy3DlcbXQATxYwWs9TmivIqUNfMoY8Aaw0y86WXp3XDFncau8FBbtvatXE7RG+SKlzcg36nfy0X2fiPtkBnWmvf7EEgoD0/lDPpzzVNl+FWXzx04AI+YEBKsTENpyAjrYzz1B1H59hTh/4Ax3cYuOc7WC4QpexC9oT+MXSzlY6iCdtX6W8tFVq26ILiV71fVpdNUN6mtDu96NapOlrJuWkmyv9b315fBOah9vdHuoW0e3lziCKJVu93Vr69bT7bGec4CTOVIJxwms2EF+ShcYG2ocCEdJxhGwqrAD3mXewX3t4niElQT2GMOmyzZdWzieyxhzPJ7jOsA4rMYeMAKhMez7bN+FfZ/te5jj8xwPtSRAIDSGbwH75sN+wPZ98CHzAXwI2YcAvoXsWwCbJADhEHzEfIg4RByHMBDKZn/CEJj9CSNgth9hjuQ5EXhZ8eCEcip/wAFX9pX0hVKV/wpxVhxn5bjA1b5YB1z5hnVCccyxDrg6C9YBs31UXOVVvmEdMPvgwqbHNhFnZeJM2jkGk74cT9hf6g470G+lo9GdtHP57C5pbfRF3EwOkI4ux5B0dI12mG/ywcN8kw+kndHah31/TbuA9w1IX54TYI7RHRpBm6VGS00j6BtxjkXIGaMv9FrqG2F+ZOaTpqyLjTiz7rgudcd1qTuuwKyFjTjba1pL5nFPKWl4xJ9ucJMD0uQD5Q9rhGelkkZ3yhm2Q7lhcol0p5KkMfzhe1PrznrhusoZ3HeK71NcgU1uwL7WDsXgWpeEPd26uvV1qQioFv5Qtfz3Vekf3WkhS6lo1j/ef49r77Ssi3nxmHRSCy8M1jTP7qZV/y59TjozK65eXNZHatx4PrpP8cRdo7I8n+D9aZsFM1Qj+91xXqRbh4hMH7rvmaKhLabu8+Jhw6enJMvqZ9GvizWqeuLXqFmBx/laPymK/KnGjJJZr0asPfprltLxRjBnSd3FZJhs7DZaheN1x3q29A9PYzx//3+7+9BvdySU/dGq1kdzR+d4Xnyn4KwGN+ktZQfsdyrP2ug2/p0isza6yb+pKOTs26ICdktdAbtZWkC9rS4g3xQYcO/UGLK6WWbIq81KQ1u9KTa01Xq9aVnmz6/V3vkG", - "heyawake_3": "m=edit&p=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" + "heyawake_2_mark?": "m=edit&p=7VZrT+M4FP3Orxjl60TT2M5bGo3KoyMhYGAHloWqQiGENm3adNIHbFD/+x47N82DMqt9fGClVRv79Pj6+NzEvs3ixyrIIp0Z8itcHT0+JnPVxV1bXQZ9LuNlEvkfRtHvwVMwifTuajlKM/+DPlou5wu/05mv8vxTEs8mnfmXMqrDDPkNJ/P7OLPsIFxMmDcdh7YYZuNUiKERrtdsnQonHfKhYGM25PxTLMSED3X9W6+n94JkEenHN+P9w0n36aj7W8e6FeLq7PHj+PDiavxw/Su7MOJOZpwl7uz0/HA/+fg1vz0dddfRUWSfL9JwlETBQ5DfXh8/J7OeOxw9soPj0YH7GMyMxQ/30lvvX3z+vNenRAd7L7nn5109/+r3NabpGsfFtIGeX/gv+amvhen0Ptb0/DvGNZ0NdG26SpZxmCZpppVcflLM5oBHFbxW4xIdFCQzgM8IA94AhnEWJtHdScGc+/38UtekgX01W0Jtmq4jjQzK34UpEPfBEg9qMYrnmi4wsFg9pJOVVq6w0fPu30hDVGmIbRpidxr8X0kjmac7EvAGmw0e0C9I4c7vy2yuKuhW8Lv/spGGXjTB5VQ8Q1Y8RU3YkhA1wi3vDhG23SKctoZrtAlPEl8qgrH2Moy5bcZqysAxU75vVNtTLVftJdLSc6HaQ9UaqrVUe6JijpAtZ0Ln3NF8jn3LTGC3wBwnmnuEPZ0Lo8DCAGYU79SwjBEUA2xSvIl4k2K4U8PQNznFcGCaa8KPaRKGH9MiTVbDMoY8m1KTPJtSkzxbiLHMykOJLWhapGOyGoYHyyZsAXuVvk2ebcTbvFqrxDbm2uTfwly79ICK6NB9cODBIR0HOg7NdTDXKed6FZZrOeTNBu+KSselXBzk6NJ9cOHTpfvgympMHhyrwh6wRzl68OaVc0WFPeh4pY5ZYQ+aXqnj6cIo8kIPzLYeSowemG/9lBg9MOXieluMHtgkbAJbWz+CsWpdRvHYt4JRPPatYBZhC9gmbAM7hKWOSxh+WJGL4NDhpMOhw0sdVsMyxqk06SwoTdr/6IEpR4Ec6SwoTUHrcngTtC7OiKAzgh6YdHBGthj7U9AZQQ9Mmjgjgs4IvNSwjCfP+H8WFq/Wssq58GORH+xzofb5RlZoWRIOVGuq1lalwpH18S9V0H9elf7UTl8U7x7Nj/Xf4wZ7fbw/aIs0uVussscgjO6i5yBcav6jeo+pjzS42Wp6H2UNKknTOV6ndimUQw0yHs7SLNo5JMnoYfiWlBzaIXWfZg8tT09BkjRzUe+QDar4229Qyyxu/A6yLH1qMNNgOWoQtdeYhlI0a93MZdC0GEyC1mrT6nZs9rRnTV191Ej5vP5/2XvnL3vyYRnvrWC9Nztqn6fZT4pONdimd5QesD+pPrXRXfwbhaY22uZfVRVp9nVhAbujtoBtlxdQrysMyFdFBtwbdUaqtkuNdNWuNnKpVwVHLlWvOfhP+AM=", + "heyawake_3": "m=edit&p=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", + "simpleloop_1": "m=edit&p=7VbtbtowFP3PU1T+W2vEBNoQqZr4rFS1rKx0rEQIGTAkxcE0Hy0y4t177VCRBFpNmzR10gg+Ppyb2PfG9hHhU0wDhomhvqaFoYerTCzdStaZbsbu6nkRZ/YJrsWRKwIgbhStQrtYXMVyIAdfuLdcFFdfQ89fccaFWBWJob5l14CPNVc4JwrLBsbf2m08ozxk+Orhsd5c1F5atZ/FysA07zuz08dm9/5x2v9BuoZXDIwOt5Y3t806P72Ugxu39sxa7Ow2FBOXMzqlctC/WvNl25q7M9K4chvWjC6N8MnqVZ/r3YuLgrOrYVjYyKota1he2g4qIawbQUMsu/ZG3thoIvyxh7C8gzjCZIiRH/PImwguAvSmyWtgBOES0Nae9nVcsUYiEgN4Z8eBPgCdeMGEs9F1otzajuxhpBKo66cVRb54ZmoyeEz/TpICYUwjWIPQ9VYImxAI46lYxLtbyXCLZe03yoCR3spQNClDsSNlqOr+uAzYKWx9pILqcLuFFfoONYxsR5Vzv6fWnt7ZG2SWkF3GyKwkXVV352bSneuOkHLSlxKZVCzo4fmOvQEkGh80tjWWNPZgEixNjU2NhsaKxmt9T0tjX2NDY1njmb7nXKX5i4X8tXQcMzne2avy72nDgoPu4mBGJww2VkP4KxF6EUNwuFEo+ChMYiO2ppMI2YnJpCMZbRn7YwZnIiUp71Jb9MgIb6GM6M2XItCOdxBSIpvO3xtKhY4MNRbBNJfTC+U8W4v27oyUnMmMFAVw4FK/aRCIl4zi08jNCCmPyYzElrmXGdFsinRBc7P5+9exLaA10s0xcUkt4n8n/uxOrFbL+Gw29tnS0RtdBB+4zj6Yl494D6gf2E8qekx/x2lS0bx+YCsq2UNnAfWIuYCa9xeQDi0GxAOXAe0do1Gj5r1GZZW3GzXVgeOoqdKm46Dk7+iJegFoWHgF", + # https://puzz.link/p?simpleloop/10/10/4h00008g0000g1000040 + "simpleloop_2": "m=edit&p=7VZdb+I6EH3nV1R+rbXYBGiIVF3xWalq2bKly5YIoQCGpDiY5qNFRvz3jh0qkkCrq3ulVVdaJT4zOWPGM3F8RPgcOwHDlKjbMDFYuMrU1KNkVvUg+6vvRZxZZ7geR64IwHGjaB1axeI6lkM5/Ma91bK4/if0/DVnXIh1kRJ1E0IWJqE1sCUPgBJSxvh7p4PnDg8Zvn58arSW9dd2/VexMjSMh+78/KnVe3iaDX7SHvGKAelyc3V712rw8ys5vHXrL6zNqnehmLqcOTNHDgfXG77qmAt3TpvXbtOcOysSPpv92kujd3lZsPc9jApbWbNkHcsry0YlhPWgaIRlz9rKWwtNhT/xEJb3EEeYjjDyYx55U8FFgN45eQMeRbgEbvvgDnRcec2EpAT87t4H9xHcqRdMORvfJMydZcs+RqqAhv61cpEvXphaDH6mn5OigJg4EexB6HprhA0IhPFMLOP9VDraYVn/D21Apvc2lJu0obwTbaju/ncb8KWwzYkOaqPdDnboB/QwtmzVzsPBNQ/uvbVF5RqyyhhVEnNRTYypjUm1oSR5pLS8txeJLSezadUAC/m61haQanzU2NFY0tiHRbE0NLY0Eo0VjTd6TlvjQGNTY1ljVc+5UGX/y8Z+Wzm2kRz37FX587hRwUb3cTB3pgw+tKbw1yL0IobgsKNQ8HGYxMZs40wjZCWik45kuFXsTxickRSltEx9sicyvIcypLdYiUAr4FFIkWy2+CiVCp1INRHBLFfTq8N5thet5RkqOaMZKgrgAKaenSAQrxnGdyI3Q6Q0J5OJrXIvM3KyJTpLJ7eaf3gduwLaID1sA5fUJv5V5q+uzGq3yFeTsa9Wjv7QRfCJ6hyCefqE9gD7ifykoqf4D5QmFc3zR7Kiij1WFmBPiAuweX0B6lhigDxSGeA+EBqVNa81qqq83KiljhRHLZUWHRslf0/P1AtAo8Ib", + # https://puzz.link/p?simpleloop/10/10/000g80190002i0001004 + "nurikabe_1": "m=edit&p=7Vdtb+I4EP7Orzj561pHnIQkRFqdKG1Xqtpue22vVxBCBgwJGJzmpa1c9b/v2KElDmHvTiet9qRT4mHmGWc8M46fiOyxoCnDxFK3E2D4hcslgR524Olhba/bOOcs/AX3ijwSKShRnidZ2G4nhRzIwa883qzayW+bIo1XdMLaxFJ31Fn5ucc7iR25ib2wH92Fv7SfnKW9wPjr6SmeU54xfPawPDpe9Z5Pen+2OwPHubucf1oeX98tZ/d/kGsrbqfWJQ82F1fHR/zTFzm4iHpP7IR5V5mYRpzRGZWD+7MXvjkNFtGc9M+ifjCnGyt7DG67T0fXnz+3httCRq1X2Q1lD8sv4RARhJENg6ARltfhq7wI0VSsJzHC8gb8CJMRRuuC5/FUcJGid0yel0/boJ7s1HvtV1q/BIkF+uVWB/UB1GmcTjkbn5fIVTiUtxipBI7000pFa/HE1GIqQWWXSQEwoTlsRBbFCcIOOLJiJlbFdioZvWHZ02XIm79ZAQR5r0CpZQVKa6hAFfbvK+CJaMi9O3p7g735HbIfh0NVyN1ODXbqTfgK8jJ8RY4Nj3bgndXbh5wATP/D9AiY3ofpm5MDH0z7w+xaYLofJrE6hptY5nRiO+Z8u2usTRzXnN9Rfmdne1U/lEN0UQ9anmppa3kLNWPpaHmspaVlR8tzPedEy3st+1q6Wnp6jq+69o/6+gPSGTol2ZiX2p3/GDZqDdFNkc7plMFr3hfrRGRxzhCwDMoEH2elb8xe6DRHYcl2VY+BbYr1hMEJrUBciASotSnCu8sA48VGpKzRpUA2WxwKpVwNoSYindVyeqacm7XoL4kBlQxhQHkKx79i0zQVzwaypnlkABWyMyKxTa2ZOTVTpCtaW229a8dbC70gPeBIwiH8/5PwM38S1D5ZPxuB/UU6Q2i128XyK0ZJMaZjaLPui8I9p4ZDBxXu+zUc2qHxoIa72zhuDe+U+N663oF1/QNxggPrdg/kCW/DD2+/PtIi/Q6/7px1uIFlAf0O0Va8TfgBTq146/gegapk9zkU0AYaBbTOpADtkymAe3wK2AFKVVHrrKqyqhOrWmqPW9VSVXodove/AWjU+gY=", + # https://puzz.link/p?nurikabe/10/10/h5k7t6l5p2h4p2g2q4g7j2v3j2g + "nonogram_1": "m=edit&p=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", + "nonogram_2": "m=edit&p=7Vhdbxo5FH3Pr1j5tdYyHtvzgVStyFelqs0m22SzCULRhEwCYWDoAE01Uf57z7U9ZUxIV7t9yUMF2Odew5zje+9cDIvPq6zKuZBcKC4THnCBh5Yh13HAYx2ZV+Aep+NlkXd/473VclRWAKPlcr7odjrzVX1ZX/5ejGeTzvyPWTkr76ps2hGheU7kRE8EPcR9LO4Nco9wMsSL5raX8z8PD/ltVixy/v7ifnd/0ns46P3T0ZdSnh3dvrnfPzm7vzn/W5wE404VHBXJ7OPx/m7x5l19+XHU+5If5NHxohyOijy7yerL8/dfi9lhcje6FXvvR3vJbTYLFp+T0/TL7snbtzv90Ow7GOw81mm37vH6XbfPBOMsxEuwAa9Puo/1xy4bltPrMeP1J6wzLgacTVfFcjwsi7Jija/+YD8dAh6s4blZJ7RnnSIAPnIY8AJwOK6GRX71wXqOu/36lDMSsGs+TZBNyy85kZFAsq0oOK6zJfKzGI3njEssLFY35WTl3ioGT7zu/Y9t4ErNNgjabRDasg3a3c9vo5iXWzaQDp6ekKC/sIWrbp92c7aGyRp+6j5iPOo+MhXio1TPJodMSc/Uyjc1TNwGjRnB1Gsz9t+c+GbqmVEAM16bwl/1VUW+qshXFZGqtRnTlddmQqrC76YIzIdbjpCoW7b0dQuz6dZ67GsTib+xMKCotOzQFx8qX1/ohQ1ZESY3F2Y8NGNoxlOkjtfSjPtmDMyozfjBvOcAGZWh5pLSGnKGmUuKPGGdchlDHOE44jKFMMKp5EpAFDBmrkIIIhzGXClEi7BSXFGOCEeCqxhBIhwnXKUIEOFUcy0sL2auQ8uLmWtleTFzTdkkXhlAJ7jQWCE05pLyaoyI+izYjJEISAWdMVLwCfCRAcCVBKEx0I6VAqMxVAq5oDRGFHGVgNMYieQ6wGbJAIBipwAAPd0pAOBaOwUAEN0okIiudqQAUO1IAaDakQJwFTSkASIcOlIAqHakAFzRTWYMjSDHjhQAqh0pAFS7bQMgtk4BAFQ7BQBQ7RQA4PvJKZDgkcRj6gDRJRpTBwgusRAGiSISyiU4FHGYOkBkiYIwGBQxEAaBIgJTB9hh6mooRVQDW0OYodXyYkYdWF7MUGp5MUOo5ZXAXr1SmyAcoVZc/WEGdvUdUQJcPUXSlpDB2CP1BYORMSoGg1H3kdWGGdjtPQIvJdJguk8cL77gm1gphT3S/WArDLlSTSEqRIsq3BoIS1PWADCaLCoE2CtR3dSERk005QYAoylrVJjSTR1pKPCqhXqTNaDge+ZDBJRuPmugJmRT8LjltGzKWppDTGNQHTkegI1bQTa1h+rXsuGRqLCmkgFguJ0CbFQlNT1jKCigIjAGstd0A8zArlKQPe2yhxnYdQ9kT7vsYbY3pcGgc9nDTMcyi5E97bKHGdh1p5iObo4XlatjxxuD11QiWum5aah7ZlRmjEyjjekb9D99x/58T/9XOX3sbvsj+uWnx2Cnzz6tqttsmOMotVdO5+VivMwZjrNsURZXC7t2lX/NhkvWtcfq9ornm62m1zlOgS1XUZZzHO23XaFZ8pzju1lZ5VuXyJnf3L10KVracqnrsrrZ0PSQFYW/F/NLxnPZU6jnWlY4YrbsrKrKB88zzZYjz9E6VXtXymcbwVxmvsRskm2wTdfheNphX5l54dCJE9iv3x6v/rcHJSt4bd3xtckxdV5WP2g668VN95bWA+8Puk9rdZv/hUbTWt30P+sqJPZ5Y4F3S2+Bd7O9wPW8w8D5rMnA90KfoatuthpStdltiOpZwyGqds/ps+a/GTbY+QY=", + "nonogram_3": "m=edit&p=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", + + # slitherlink + "slitherlink_1": "m=edit&p=7Vfrb+I4EP/OX3Hy17WO2CYPIq1OlNKVqrbbXtvrFYRQXkBoIGwSSpWq//vOmCJsQ3uPSqv9sEIZzfxmMi/jsVN+WwVFQplHmUWFRy3K4OdyTluAtWxLPtvfTVplif8b7ayqaV4AM62qZek3m8tV3a/7v2fp4qG5/KPM0mqaFE3mNZnVnITRhMeTOI5dEXMvjtdcWK7DOPNsJniYRrNoEc6CVAjmuYwLzxVCxOvQ4TFz4jAOJyyaRAGlX09O6DjIyoSe3s+Ojh86617n76bdF+L2Yvxpdnx1O4vv/mJXVtosrIvMW5xfHh9ln77U/fNp5zHpJc5lmUfTLAnioO7fnT5lixNvMh2z7um0642DhVV+827aj0dXnz83Bq9FDxvPdduvO7T+4g+IIJQweDgZ0vrKf67PfRLl8zAltL4GPaFsSMl8lVVplGd5QbZYfQYcvMmB7e3YO6lHrrsBmQX8xSsP7D2wUVpEWTI62yCX/qC+oQQTOJJvI0vm+WOCwTA5lDdJARAGFSxaOU2XhApQlKs4f1i9mrLhC607/6MM8LQtA9lNGcgdKAOr+3AZSTxJng5U0B6+vMAK/Qk1jPwBlnO7Y70de+0/A73wn0nLhVfxXw6vgzfbApHvxJYu2iCKndjWRIfroq51Ubtz5aJnRat7dj3dWHflMV3UXTGmR2JMGHq0x629lTG2aq+3hDGMrshc7xFsXUOv94FxIx53DHuMp9rrxTNuxBcoK/oW+lNkx8jHQX9KfNlcJZ7RXeZhfcr7RruZh/1U4nnYP9XeyMcz6vOM+G2jn22jf21j/drGerf1/w7naL/Lhxv95kZ/ueyv8r7A+Iq9MPwJI55Af4q+Zehbht42/Nt6P7mN/VJkB/uzXT/Ytkxu3ntJTyTlkt7A3qa1kPRYUktSW9IzadOT9E7SrqQtSR1p4+J0+E/z4wekM2g58hh+/2f/svmozbAxINerYhxECZw33Xy+zMu0Sgic+aTMs1G50Y2SpyCqiL+5e6gaDVus5mECR6UCZXm+hEvRIQ9blQamk0VeJAdVCOIh+IYrVB1wFeZFbOS0DrJMr0XeATVoc1RrUFXAOazIQVHkaw2ZB9VUA5Srh+YpWRjNrAI9xeAhMKLNd+14aZAnIh8YOTA0fl3Qfv4LGq6W9bON2X9IZ1D3KIFvEirgeKT1V0qWq1EwgnYT+CqgH1H/G4Mf3g25z/LinaG3U5rwgdEH6DvTT9Eewt8YdIrWxPemGia7P9gAPTDbADXHG0D7Ew7AvSEH2BtzDr2aow6zMqcdhtobeBhKnXkD8vpVjd/YZNj4Dg==" } @pytest.fixture @@ -14,9 +27,14 @@ def puzzlink_test_urls(): return { # Heyawake "heyawake_1": "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g221i33k2g", - "heyawake_2_question_mark": "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g22.i33k2g", + "heyawake_2_mark?": "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g22.i33k2g", "heyawake_3": "https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h", # shimaguni "shimaguni_1": "https://puzz.link/p?shimaguni/10/10/884aha5h85jshipgi17vjqfmudt1buhlti1ui2h34g3m", + + # nonogram + "nonogram_1": "https://puzz.link/p?nonogram/15/15/22l33l1221j231k251k11l9m311k272k2212j21511i12321i2112j21l22l3m12l222k1211j152k211113h21122i2112j21122i211113h252k1321j222k132k3", + "nonogram_2": "https://puzz.link/p?nonogram/12/12/1k3k5k1k1k11j71j1k1k1k1k1k1k2kck2k1k2k1k1k1k1k1k1k", + "nonogram_3": "https://puzz.link/p?nonogram/15/11/55j111i13p55j1k55j1k121i5111h12j5k121i21111g1212h33113i111111h11113i11111i3331r111111h211211h22111i111111h211113h" } \ No newline at end of file From 67f1925d977cc059f728844766433f068c885267 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Wed, 18 Mar 2026 16:25:50 +0800 Subject: [PATCH 08/17] update converter for ayeheya, aqre, shimaguni --- src/puzzlekit/formats/base.py | 141 +-------------- src/puzzlekit/formats/debug.py | 13 +- src/puzzlekit/formats/penpa_converter.py | 75 ++++---- src/puzzlekit/formats/penpa_template.py | 162 ++++++++++++++++++ src/puzzlekit/formats/puzzlink_converter.py | 14 +- src/puzzlekit/formats/puzzlink_type_config.py | 0 tests/formats/conftest.py | 31 +++- 7 files changed, 245 insertions(+), 191 deletions(-) create mode 100644 src/puzzlekit/formats/penpa_template.py delete mode 100644 src/puzzlekit/formats/puzzlink_type_config.py diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py index c3449b89..1ab72560 100644 --- a/src/puzzlekit/formats/base.py +++ b/src/puzzlekit/formats/base.py @@ -37,8 +37,6 @@ class SurfaceColor(Enum): BROWN: int = 12 -PENPA_MODE = '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}' -PENPA_PU_X_STR = '{zR:{z_:[]},zU:{z_:[]},z8:{z_:[]},zS:{},zN:{},z1:{},zY:{},zF:{},z2:{},zT:[],z3:[],zD:[],z0:[],z5:[],zL:{},zE:{},zW:{},zC:{},z4:{},z6:[],z7:[]}' COMPRESS_SUB = [ ('z', 'zZ'), ('"qa"', 'z9'), @@ -72,146 +70,11 @@ class SurfaceColor(Enum): ('"_a"', 'z_'), ('null', 'zO'), ] - -# element 8: __export_solcheck_shared - -PENPA_SOL_CHECK_DICT_DEFAULT = { - "sol_surface_exact": False, - "sol_surface": False, - "sol_number": False, - "sol_loopline_exact": False, - "sol_loopline": False, - "sol_ignoreloopline": False, - "sol_loopedge_exact": False, - "sol_loopedge": False, - "sol_ignoreborder": False, - "sol_wall": False, - "sol_square": False, - "sol_circle": False, - "sol_tri": False, - "sol_arrow": False, - "sol_math": False, - "sol_battleship": False, - "sol_tent": False, - "sol_star": False, - "sol_akari": False, - "sol_mine": False -} - +PENPA_MODE = '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}' +PENPA_PU_X_STR = '{zR:{z_:[]},zU:{z_:[]},z8:{z_:[]},zS:{},zN:{},z1:{},zY:{},zF:{},z2:{},zT:[],z3:[],zD:[],z0:[],z5:[],zL:{},zE:{},zW:{},zC:{},z4:{},z6:[],z7:[]}' PENPA_PU_X_DEFAULT = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, PENPA_PU_X_STR)) -PENPA_MODE_DEFAULT = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, PENPA_MODE)) - -# element 18: __export_checker_shared -PENPA_SOL_CHECK_OR_DICT_DEFAULT = { - "sol_or_surface_exact": False, - "sol_or_surface": False, - "sol_or_number": False, - "sol_or_loopline_exact": False, - "sol_or_loopline": False, - "sol_or_loopedge_exact": False, - "sol_or_loopedge": False, - "sol_or_wall": False, - "sol_or_square": False, - "sol_or_circle": False, - "sol_or_tri": False, - "sol_or_arrow": False, - "sol_or_math": False, - "sol_or_battleship": False, - "sol_or_tent": False, - "sol_or_star": False, - "sol_or_akari": False, - "sol_or_mine": False -} - -PENPA_BG_IMAGE_ENCRYPTED = "JYjBDkAwEAX/5Z33UNf+jDQUjdWV1Q0i/r0Nl8nMPDBl+GzZMhAveEe6PsochleadazZWJxlnF8ghf1CJhC8fan0sq8T9vBQ==" -@dataclass -class PenpaMetadata: - - # ========== Line 1: header ========== - grid_type: str = "square" - nx: int = 5 - ny: int = 5 - size: int = 38 # size of each cell on penpa - theta: int = 0 # for rotate - reflect: List[int] = field(default_factory=lambda: [1, 1]) - canvasx: int = 0 # canvas size x - canvasy: int = 0 # canvas size y - center_n: int = 0 # center cell - center_n0: int = 0 # center cell (?) - sudoku: List[int] = field(default_factory=lambda: [0, 0, 0, 0]) - title: str = "" # name of the puzzle e.g., heyawake, nonogram - author: str = "" # author of the puzzle, optional - source: str = "" # (source) url of the puzzle - rules: str = "" # rules of the puzzle - border_status: str = "OFF" # unknown - multisolution: bool = False # is multi solution ? - bg_image_encrypted: str = "" # (placeholder) for background picture - - # ========== Line 2: space ========== - space: List[int] = field(default_factory=lambda: [0, 0, 0, 0]) # [top, bottom, left, right] - - # ========== Line 3: mode ========== - mode: Dict[str, Any] = field(default_factory=dict) - - # ========== Line 4: pu_q ========== - pu_q: Dict[str, Any] = field(default_factory=dict) - - # ========== Line 5: pu_a ========== - pu_a: Dict[str, Any] = field(default_factory=dict) - - # ========== Line 6-7: __export_list_tab_shared ========== - centerlist_diff: List[int] = field(default_factory=list) # diff encoding centerlist - tab_settings: List[str] = field(default_factory=lambda: []) - - # ========== Line 8: sol_check ========== - sol_check: Dict[str, bool] = field(default_factory=dict) - - # ========== Line 9-14: version shared ========== - timer_placeholder: str = '"x"' # default 'x' - comp_mode: str = '"x"' # default 'x' - version: List[int] = field(default_factory=lambda: [3, 2, 1]) # v3.2.1, aha~ - mode_snapshot: Dict[str, Any] = field(default_factory=dict) - theme_placeholder: str = '"x"' # default 'x' - custom_colors_on: str = '0' # either 1 or 0 - - # ========== Line 15-16: pu_q_col / pu_a_col ========== - pu_q_col: Dict[str, Any] = field(default_factory=dict) - pu_a_col: Dict[str, Any] = field(default_factory=dict) - - # ========== Line 17: sol_check (OR) ========== - sol_check_or: Dict[str, bool] = field(default_factory=dict) - - # ========== Line 18: genre_tags ========== - genre_tags: List[str] = field(default_factory=list) - - # ========== Line 19: custom_message ========== - custom_message: str = "" - - def __post_init__(self): - """ - Auto fill default after init - """ - if not self.mode: self.mode = PENPA_MODE_DEFAULT.copy() - - if not self.pu_q: self.pu_q = PENPA_PU_X_DEFAULT.copy() - - if not self.pu_a: self.pu_a = PENPA_PU_X_DEFAULT.copy() - - if not self.mode_snapshot: self.mode_snapshot = PENPA_MODE_DEFAULT.copy() - - if not self.sol_check: self.sol_check = PENPA_SOL_CHECK_DICT_DEFAULT.copy() - - if not self.sol_check_or: self.sol_check_or = PENPA_SOL_CHECK_OR_DICT_DEFAULT.copy() - - if not self.bg_image_encrypted: self.bg_image_encrypted = PENPA_BG_IMAGE_ENCRYPTED - - if not self.pu_q_col: self.pu_q_col = PENPA_PU_X_DEFAULT.copy() - - if not self.pu_a_col: self.pu_a_col = PENPA_PU_X_DEFAULT.copy() - - @dataclass class CellState: """ diff --git a/src/puzzlekit/formats/debug.py b/src/puzzlekit/formats/debug.py index 16d315ff..dc6e0fa5 100644 --- a/src/puzzlekit/formats/debug.py +++ b/src/puzzlekit/formats/debug.py @@ -351,21 +351,14 @@ def _compare_edges_detail(edges1: Dict, edges2: Dict) -> List[str]: if __name__ == "__main__": # 测试 URL - PUZZLINK_URL = "https://puzz.link/p?nonogram/15/11/55j111i13p55j1k55j1k121i5111h12j5k121i21111g1212h33113i111111h11113i11111i3331r111111h211211h22111i111111h211113h" - PENPA_URL = "m=edit&p=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" + PUZZLINK_URL = "https://puzz.link/p?stostone/10/14/0001ail18seopri14284g90i10006co37saag11g280000000000g44gch" + PENPA_URL = "m=edit&p=7VdrT+M4FP3Or1j561jbOM7DiTRalddICFhYYFhaVSi0oQ2kTSdJAQXx3+fYudk2bZnVaLUSH6YP9+Tcm2NfX/s6Lb4tojzmwuLC4VJx/OLtCMVd3+KSvs37MinTOPyNdxflJMsBJmU5L8JOZ76oelXv9zSZPXbmfxRlhs8s7girI5yOZVkiSlKhijib54lwbOWMAysRMHjDTPpFFI2FGNsKBL3GjjMeTjj/8/CQ30dpEfOjm4fd/cfu80H3747bk/Lq9P7Tw/751cPo+qs4t5JObp2manZytr+bfvpS9U4m3af4IPbOimw4SeNoFFW966OXdHaoxpN7sXc02VP30cwqvqnL4Gn3/PPnnT7FOdh5rYKw6vLqS9hngnFm4yvYgFfn4Wt1ElYXvLqAiXEx4Gy6SMtkmKVZzhquOq5vtAEPlvDa2DXaq0lhAZ8SBrwBHCb5MI1vj2vmLOxXl5zpvnfN3RqyafYU68702PT1MJveJZq4i0qkqJgkc8YlDMVilD0uyFUM3njV/bkIINJEoGEdgUZbItCB/b8RBIO3NyTnL8RwG/Z1OFdLqJbwInxFexq+Ms/CrQ7WtMkf85zWpbAkroVNBO4R5s4b0x6a1jbtJYR5JU27b1rLtK5pj43PAfqzA59LIVhoY9UEATB6AJaWAHYJS2C/xgK8TbwAbze8CxwQhqasNWHn0mkw9B3Sx+aVrk0YvEu8gy3sImqDscddRRj6Luk7Hpd6ojR2MQaPxuDC3yN/F/o+6bvQ90nfw3gUjceDjyIfDz6KfHzEqChG3wb2CKMvRX0p+ATko3zuWKQZuMD1OMGhNNU+4Lhj1/rggMnHltyRtSY47tD8gOOOW2uCAyYfF5oeabrw8Zvc+dwO6tjxC0xzpfNoUYwWYtEryGCda5pDlNNmDZjcCZpDlFUpSMfW+aV5sDH/Td51fm3yt+HfrAGda0n6EvrNetB5l00ewTdrAzEi38tcO9QX4v1nnXjw95rc6ZySvin7xCudL4pRQcfkDov92iz5PdM6pvXMVvD1DvypPfrfd92/DqePGdMHW/vt/uIGO312scjvo2HMcOyxIktvi/r6Nn6JhiUL6+N31dLiZovpXYxzY4VKs2yOR4FtCo2pRSbjWZbHW02ajEfj96S0aYvUXZaP1sb0HKVpOxbz5NOi6nOrRZU5DqWV6yjPs+cWM43KSYtYOcBaSvFsbTLLqD3E6DFa6226nI63HfbCzLcvOc6rX88oH/oZRSfK+mhV8KMNx6zxLP9BwVka1+ktZQfsDyrPinUb/06RWbGu8xsVRQ92s6iA3VJXwK6XFlCb1QXkRoEB906N0arrZUaPar3S6K42io3uarXe9FnzP44Ndr4D" plc = Plc() ppc = Ppc() ir1 = plc.decode(PUZZLINK_URL) ir2 = ppc.decode(PENPA_URL) # debug_roundtrip(plc, PUZZLINK_URL, "Puzzlink Nonogram", skip_compare=['source', 'metadata']) - # # 如果解码成功,打印 nonogram 专项信息 - # try: - # ir = plc.decode(PUZZLINK_URL) - # debug_nonogram_specific(ir) - # except Exception as e: - # print(f"⚠️ Could not run nonogram debug: {e}") - - + print("\n🔍 Cross-comparing IR1 vs IR2:") is_equal, diffs = compare_ir(ir1, ir2, ignore_fields=['source', 'metadata']) \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index 8cf6ec4a..45811016 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -1,10 +1,17 @@ from puzzlekit.formats.base import ( PuzzleInstance, CellState, EdgeState, - PenpaMetadata, COMPRESS_SUB, NumberColor, SurfaceColor + COMPRESS_SUB, NumberColor, SurfaceColor +) +from puzzlekit.formats.penpa_template import ( + PENPA_FIXED_FIELDS as fixed, + PENPA_PU_X_DEFAULT, + get_penpa_template, + penpa_str_to_dict ) from puzzlekit.formats.utils import generate_centerlist_diff from typing import Any, Dict, List, Optional, Tuple, Union import json +import ast from base64 import b64decode, b64encode from functools import reduce from zlib import compress, decompress @@ -203,7 +210,6 @@ def decode(self, url: str) -> PuzzleInstance: for k, v in self.board.items(): if k == "lineE": self.ir_puzzle.edges = self._decode_edge(edge_dict = v) - # print(self.ir_puzzle.edges) elif k == "number": self._decode_number(number_dict = v) elif k == "surface": @@ -214,6 +220,9 @@ def decode(self, url: str) -> PuzzleInstance: # decode box boxes = json.loads(self.parts[p]) self.ir_puzzle.boxes = boxes + elif p == 17: + genre_tag = ast.literal_eval(self.parts[p]) + self.ir_puzzle.puzzle_type = genre_tag[0] if len(genre_tag) > 0 else "" # else: # print(p, self.parts[p]) @@ -252,8 +261,6 @@ def _decode_number(self, number_dict: Dict[str, int]): cell.value, cell.num_color, cell.num_style = f"{num_data[0]}", NumberColor(num_data[1]), num_data[2] self.ir_puzzle.cells[(r, c)] = cell # ELSE? - - def _decode_edge(self, edge_dict: Dict[str, int]): new_edge_dict = {} @@ -292,57 +299,61 @@ def _encode_edge(self, edge_dict: Dict[str, EdgeState]): def encode(self, inst: PuzzleInstance) -> str: """Forge the Penpa+ format url.""" - mtd = PenpaMetadata() - center_n = calculate_center_n(inst.cols , inst.rows , mtd.size) + + hdr = fixed['header'] + penpa_template = get_penpa_template(inst.puzzle_type) + # mtd = PenpaMetadata() + + center_n = calculate_center_n(inst.cols , inst.rows , hdr.size) center_list = generate_centerlist_diff(inst.rows, inst.cols, inst.margins) self.real_rows = inst.rows + 4 # penpa size after padding self.real_cols = inst.cols + 4 # 1. form pu_q dict, then update - original_pu_q = mtd.pu_q + original_pu_q = PENPA_PU_X_DEFAULT.copy() # print(self.parts[3], "\n") - # augmented update(number/edge only: for now) + # ==== augmented update ====== + # (number/edge/surface only: for now) original_pu_q["surface"] = self._encode_surface(inst.cells) original_pu_q["number"] = self._encode_number(inst.cells) original_pu_q["lineE"] = self._encode_edge(inst.edges) # 2. standard JSON serialization (compact mode) - # 3. construct text_lines text_lines = [] to_pack_elem = [ ",".join(map(str, [ - inst.grid_type, inst.cols, inst.rows, mtd.size, mtd.theta, mtd.reflect[0], mtd.reflect[1], - (inst.cols + 1) * mtd.size, (inst.rows + 1) * mtd.size, - center_n, center_n, mtd.sudoku[0], mtd.sudoku[1], mtd.sudoku[2], mtd.sudoku[3], + inst.grid_type, inst.cols, inst.rows, hdr.size, hdr.theta, hdr.reflect[0], hdr.reflect[1], + (inst.cols + 1) * hdr.size, (inst.rows + 1) * hdr.size, + center_n, center_n, hdr.sudoku[0], hdr.sudoku[1], hdr.sudoku[2], hdr.sudoku[3], "Title: " + inst.title.replace(',', '%2C'), # comma update "Author: " + inst.author.replace(',', '%2C'), # comma update inst.source.replace(',', '%2C'), - mtd.rules.replace(',', '%2C'), - mtd.border_status, mtd.multisolution, - mtd.bg_image_encrypted + hdr.rules.replace(',', '%2C'), + hdr.border_status, hdr.multisolution, + hdr.bg_image_encrypted ])), # Line 0: header to_penpa_str(inst.margins), - to_penpa_str(mtd.mode), + to_penpa_str(penpa_str_to_dict(penpa_template["mode"])), to_penpa_str(original_pu_q), - to_penpa_str(mtd.pu_a), + to_penpa_str(PENPA_PU_X_DEFAULT.copy()), to_penpa_str(inst.boxes), - to_penpa_str(mtd.tab_settings), - to_penpa_str(mtd.sol_check, apply_compression = False), - mtd.timer_placeholder, - mtd.comp_mode, - to_penpa_str(mtd.version), - to_penpa_str(mtd.mode_snapshot), - mtd.theme_placeholder, - mtd.custom_colors_on, - to_penpa_str(mtd.pu_q_col), - to_penpa_str(mtd.pu_a_col), - to_penpa_str(mtd.sol_check_or, apply_compression = False), - to_penpa_str(mtd.genre_tags), - mtd.custom_message + to_penpa_str(penpa_template['user_tab_setting']), + to_penpa_str(fixed['sol_check'], apply_compression = False), + fixed['timer_placeholder'], + fixed['comp_mode'], + to_penpa_str(fixed['version']), + to_penpa_str(penpa_str_to_dict(penpa_template["mode"])), + fixed["theme_placeholder"], + fixed["theme_colors_on"], + to_penpa_str(fixed['pu_q_col']), + to_penpa_str(fixed['pu_a_col']), + to_penpa_str(fixed['sol_check_or'], apply_compression = False), + to_penpa_str(penpa_template['genre_tags']), + fixed["custom_message"] ] for i in range(19): @@ -360,13 +371,13 @@ def encode(self, inst: PuzzleInstance) -> str: if __name__ == "__main__": for test_url in [ - "m=edit&p=7Vfrb+I4EP/OX3Hy17WO2CYPIq1OlNKVqrbbXtvrFYRQXkBoIGwSSpWq//vOmCJsQ3uPSqv9sEIZzfxmMi/jsVN+WwVFQplHmUWFRy3K4OdyTluAtWxLPtvfTVplif8b7ayqaV4AM62qZek3m8tV3a/7v2fp4qG5/KPM0mqaFE3mNZnVnITRhMeTOI5dEXMvjtdcWK7DOPNsJniYRrNoEc6CVAjmuYwLzxVCxOvQ4TFz4jAOJyyaRAGlX09O6DjIyoSe3s+Ojh86617n76bdF+L2Yvxpdnx1O4vv/mJXVtosrIvMW5xfHh9ln77U/fNp5zHpJc5lmUfTLAnioO7fnT5lixNvMh2z7um0642DhVV+827aj0dXnz83Bq9FDxvPdduvO7T+4g+IIJQweDgZ0vrKf67PfRLl8zAltL4GPaFsSMl8lVVplGd5QbZYfQYcvMmB7e3YO6lHrrsBmQX8xSsP7D2wUVpEWTI62yCX/qC+oQQTOJJvI0vm+WOCwTA5lDdJARAGFSxaOU2XhApQlKs4f1i9mrLhC607/6MM8LQtA9lNGcgdKAOr+3AZSTxJng5U0B6+vMAK/Qk1jPwBlnO7Y70de+0/A73wn0nLhVfxXw6vgzfbApHvxJYu2iCKndjWRIfroq51Ubtz5aJnRat7dj3dWHflMV3UXTGmR2JMGHq0x629lTG2aq+3hDGMrshc7xFsXUOv94FxIx53DHuMp9rrxTNuxBcoK/oW+lNkx8jHQX9KfNlcJZ7RXeZhfcr7RruZh/1U4nnYP9XeyMcz6vOM+G2jn22jf21j/drGerf1/w7naL/Lhxv95kZ/ueyv8r7A+Iq9MPwJI55Af4q+Zehbht42/Nt6P7mN/VJkB/uzXT/Ytkxu3ntJTyTlkt7A3qa1kPRYUktSW9IzadOT9E7SrqQtSR1p4+J0+E/z4wekM2g58hh+/2f/svmozbAxINerYhxECZw33Xy+zMu0Sgic+aTMs1G50Y2SpyCqiL+5e6gaDVus5mECR6UCZXm+hEvRIQ9blQamk0VeJAdVCOIh+IYrVB1wFeZFbOS0DrJMr0XeATVoc1RrUFXAOazIQVHkaw2ZB9VUA5Srh+YpWRjNrAI9xeAhMKLNd+14aZAnIh8YOTA0fl3Qfv4LGq6W9bON2X9IZ1D3KIFvEirgeKT1V0qWq1EwgnYT+CqgH1H/G4Mf3g25z/LinaG3U5rwgdEH6DvTT9Eewt8YdIrWxPemGia7P9gAPTDbADXHG0D7Ew7AvSEH2BtzDr2aow6zMqcdhtobeBhKnXkD8vpVjd/YZNj4Dg==" - + "m=edit&p=7VdrT+M4FP3Or1j561jbOM7DiTRalddICFhYYFhaVSi0oQ2kTSdJAQXx3+fYudk2bZnVaLUSH6YP9+Tcm2NfX/s6Lb4tojzmwuLC4VJx/OLtCMVd3+KSvs37MinTOPyNdxflJMsBJmU5L8JOZ76oelXv9zSZPXbmfxRlhs8s7girI5yOZVkiSlKhijib54lwbOWMAysRMHjDTPpFFI2FGNsKBL3GjjMeTjj/8/CQ30dpEfOjm4fd/cfu80H3747bk/Lq9P7Tw/751cPo+qs4t5JObp2manZytr+bfvpS9U4m3af4IPbOimw4SeNoFFW966OXdHaoxpN7sXc02VP30cwqvqnL4Gn3/PPnnT7FOdh5rYKw6vLqS9hngnFm4yvYgFfn4Wt1ElYXvLqAiXEx4Gy6SMtkmKVZzhquOq5vtAEPlvDa2DXaq0lhAZ8SBrwBHCb5MI1vj2vmLOxXl5zpvnfN3RqyafYU68702PT1MJveJZq4i0qkqJgkc8YlDMVilD0uyFUM3njV/bkIINJEoGEdgUZbItCB/b8RBIO3NyTnL8RwG/Z1OFdLqJbwInxFexq+Ms/CrQ7WtMkf85zWpbAkroVNBO4R5s4b0x6a1jbtJYR5JU27b1rLtK5pj43PAfqzA59LIVhoY9UEATB6AJaWAHYJS2C/xgK8TbwAbze8CxwQhqasNWHn0mkw9B3Sx+aVrk0YvEu8gy3sImqDscddRRj6Luk7Hpd6ojR2MQaPxuDC3yN/F/o+6bvQ90nfw3gUjceDjyIfDz6KfHzEqChG3wb2CKMvRX0p+ATko3zuWKQZuMD1OMGhNNU+4Lhj1/rggMnHltyRtSY47tD8gOOOW2uCAyYfF5oeabrw8Zvc+dwO6tjxC0xzpfNoUYwWYtEryGCda5pDlNNmDZjcCZpDlFUpSMfW+aV5sDH/Td51fm3yt+HfrAGda0n6EvrNetB5l00ewTdrAzEi38tcO9QX4v1nnXjw95rc6ZySvin7xCudL4pRQcfkDov92iz5PdM6pvXMVvD1DvypPfrfd92/DqePGdMHW/vt/uIGO312scjvo2HMcOyxIktvi/r6Nn6JhiUL6+N31dLiZovpXYxzY4VKs2yOR4FtCo2pRSbjWZbHW02ajEfj96S0aYvUXZaP1sb0HKVpOxbz5NOi6nOrRZU5DqWV6yjPs+cWM43KSYtYOcBaSvFsbTLLqD3E6DFa6226nI63HfbCzLcvOc6rX88oH/oZRSfK+mhV8KMNx6zxLP9BwVka1+ktZQfsDyrPinUb/06RWbGu8xsVRQ92s6iA3VJXwK6XFlCb1QXkRoEB906N0arrZUaPar3S6K42io3uarXe9FnzP44Ndr4D" ]: hpc = PenpaConverter(dict()) tmp = hpc.decode(test_url) # print("\n\n", tmp.cells) enc = hpc.encode(tmp) + print(tmp) print(enc) # b = hpc.decode(enc) # print(enc) diff --git a/src/puzzlekit/formats/penpa_template.py b/src/puzzlekit/formats/penpa_template.py new file mode 100644 index 00000000..2dc6fe97 --- /dev/null +++ b/src/puzzlekit/formats/penpa_template.py @@ -0,0 +1,162 @@ +from typing import List, TypedDict, Type, Any, Dict +from puzzlekit.formats.base import COMPRESS_SUB +from dataclasses import dataclass, field +import json +from functools import reduce + +PENPA_PU_X_STR = '{zR:{z_:[]},zU:{z_:[]},z8:{z_:[]},zS:{},zN:{},z1:{},zY:{},zF:{},z2:{},zT:[],z3:[],zD:[],z0:[],z5:[],zL:{},zE:{},zW:{},zC:{},z4:{},z6:[],z7:[]}' +# To forge into template pu_q dict. +PENPA_PU_X_DEFAULT = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, PENPA_PU_X_STR)) +# Standard pu_q / pu_a dict +# PENPA_MODE_DEFAULT = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, PENPA_MODE)) + +PENPA_MODE_TEMPLATE = { + "heyawake": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface"], + "genre_tags": ["heyawake"] + }, + "shimaguni": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",9]}}', + "user_tab_setting": ['Surface'], + "genre_tags": ["shimaguni (islands)"] + }, + "aqre": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",9]}}', + "user_tab_setting": ['Surface'], + "genre_tags": ['aqre'] + }, + + "slitherlink" : { + "mode": '{z9:zA,zG:["3","1","2"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["edgex",3],"sudoku":["1",9]}}', + "user_tab_setting": ['Surface', "Composite"], + "genre_tags": ["slitherlink"] + }, + + "ayeheya": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",9]}}', + "user_tab_setting": ['Surface'], + "genre_tags": ["ayeheya (ekawayeh)"] + }, + + "default" : { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface"], + "genre_tags": [] + }, +} + +PUZZLE_TYPE_ALIASES = { + + "slither": "slitherlink", + "slitherlink": "slitherlink", + "vslither": "slitherlink", + + # ==== + "nonogram": "nonogram", + # shimaguni + "shimaguni (islands)": "shimaguni", + + "simpleloop": "simpleloop", + "heyawacky": "heyawake", + "heyawake": "heyawake" +} + +def get_penpa_template(puzzle_type: str) -> dict: + + normalized = PUZZLE_TYPE_ALIASES.get(puzzle_type, puzzle_type) + + template = PENPA_MODE_TEMPLATE.get(normalized, PENPA_MODE_TEMPLATE["default"]) + + return template + +def penpa_str_to_dict(mode_str: str): + return json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, mode_str)) + +@dataclass +class PenpaHeader: + size: int = 38 + theta: int = 0 + reflect: List[int] = field(default_factory=lambda: [1, 1]) + sudoku: List[int] = field(default_factory=lambda: [0, 0, 0, 0]) + rules: str = "" + border_status: str = "OFF" + multisolution: bool = False + bg_image_encrypted: str = "JYjBDkAwEAX/5Z33UNf+jDQUjdWV1Q0i/r0Nl8nMPDBl+GzZMhAveEe6PsochleadazZWJxlnF8ghf1CJhC8fan0sq8T9vBQ==" + +class PenpaFixedFields(TypedDict): + # for auto fill + header: PenpaHeader + sol_check_or: Dict[str, Any] + +PENPA_FIXED_FIELDS: PenpaFixedFields = { + # HEADER (Line 1) + "header": PenpaHeader(), + # Line 5 + "pu_a": PENPA_PU_X_DEFAULT.copy(), + # Line 8 + "sol_check": { + "sol_surface_exact": False, + "sol_surface": False, + "sol_number": False, + "sol_loopline_exact": False, + "sol_loopline": False, + "sol_ignoreloopline": False, + "sol_loopedge_exact": False, + "sol_loopedge": False, + "sol_ignoreborder": False, + "sol_wall": False, + "sol_square": False, + "sol_circle": False, + "sol_tri": False, + "sol_arrow": False, + "sol_math": False, + "sol_battleship": False, + "sol_tent": False, + "sol_star": False, + "sol_akari": False, + "sol_mine": False + }, + # Line 9 + "timer_placeholder": '"x"', + # Line 10 + "comp_mode": '"x"', + # Line 11 + "version": [3, 2, 1], + # Line 13 + "theme_placeholder": '"x"', + # Line 14 + "theme_colors_on": '0', + # Line 15 + "pu_q_col": PENPA_PU_X_DEFAULT.copy(), + # Line 16 + "pu_a_col": PENPA_PU_X_DEFAULT.copy(), + # Line 17 + "sol_check_or": { + "sol_or_surface_exact": False, + "sol_or_surface": False, + "sol_or_number": False, + "sol_or_loopline_exact": False, + "sol_or_loopline": False, + "sol_or_loopedge_exact": False, + "sol_or_loopedge": False, + "sol_or_wall": False, + "sol_or_square": False, + "sol_or_circle": False, + "sol_or_tri": False, + "sol_or_arrow": False, + "sol_or_math": False, + "sol_or_battleship": False, + "sol_or_tent": False, + "sol_or_star": False, + "sol_or_akari": False, + "sol_or_mine": False + }, + # Line 19 + "custom_message" : "" +} + + +# if __name__ == "__main__": +# template = get_penpa_template("heyawake") +# print(template) \ No newline at end of file diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index 9245ab28..02a5751b 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -10,7 +10,7 @@ ALLOWED_PUZZLE_TYPE = { "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", - "nonogram", + "nonogram", "ayeheya" } # allowed puzzle types @@ -77,7 +77,7 @@ def _decode_heyawake_variant(self): number_map = self._decode_number16() grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] self._move_numbers_to_top_left_corner(grid, region_grid, number_map) - + self.ir_puzzle.puzzle_type = "heyawake" self.ir_puzzle.title = self.puzzle_type self.ir_puzzle.rows = self.num_rows @@ -170,10 +170,10 @@ def decode(self, url: str) -> Dict[str, Any]: "aqre", "heyawacky", "shimaguni", - "stostone" + "stostone", + "ayeheya" ]: self._decode_heyawake_variant() - elif self.puzzle_type in ['nonogram']: self._decode_nonogram_variant() elif self.puzzle_type in ["country", "detour", "juosan", "yajilin-regions", "yajirin-regions"]: @@ -225,6 +225,7 @@ def encode(self, inst: PuzzleInstance) -> str: "aqre", "heyawacky", "shimaguni", + "ayeheya", "stostone" ]: body_str = self._encode_heyawake_variant(inst) @@ -937,10 +938,7 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, from puzzlekit.formats.penpa_converter import PenpaConverter PzpCvtr = PuzzlinkConverter() url_list = [ - # "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g22.i33k2g", - # "https://puzz.link/p?heyawake/20/20/00000i805541aaa2kkkdp94riaa74kse99osijh8n72hef32pq43j48464g8890gg4gk0310000007s00ov0300o07o04o0s30v0f7s2000000000vv00000fo1s8fs2007o7g0400003vvo0s3007s00411g53g2j9i844h1j5g2g6g63g5h", - # "https://puzz.link/p?nonogram/15/11/55j111i13p55j1k55j1k121i5111h12j5k121i21111g1212h33113i111111h11113i11111i3331r111111h211211h22111i111111h211113h" - "https://puzz.link/p?nonogram/15/11/55j111i13p55j1k55j1k121i5111h12j5k121i21111g1212h33113i111111h11113i11111i3331r111111h211211h22111i111111h211113" + "https://puzz.link/p?stostone/10/14/0001ail18seopri14284g90i10006co37saag11g280000000000g44gch" ] for url in url_list: p_ir = PzpCvtr.decode(url) diff --git a/src/puzzlekit/formats/puzzlink_type_config.py b/src/puzzlekit/formats/puzzlink_type_config.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py index f9138a49..796b8225 100644 --- a/tests/formats/conftest.py +++ b/tests/formats/conftest.py @@ -7,6 +7,15 @@ def penpa_test_urls(): "heyawake_1": "m=edit&p=7VdrT+NGFP3Or6j8dUeNx+O3tKrCayUEFAqUQhQhE0xeTpx1HlAj/vueO76TxCFstapWolKVZHzmzMydO/dcXzvTr/OkSIW06atCgSs+rgz1zwl9/bP5c9mfZWn8i2jOZ728AOjNZpNp3GhM5uVteftr1h8PG5PfeunfyVMyTBvSpm9nOLnvF56fdKZDGY0GHV91i0GuVNfuLBZykasg7zpdJQey6ziyr9TQ6Qrx++GheEyyaSqObga7+8Pm00Hzr4Z3q9TV6eOnwf751eDh+k95bvcbhX2aheOTs/3d7NOX8vak11ykB6l/Ns07vSxNHpLy9vroORsfht3eo9w76u2Fj8nYnn4NL6PF7vnnzzstPmJ756WM4rIpyi9xy5KWsBz8pNUW5Xn8Up7E5YUoLzBkCdkW1miezfqdPMsLy3DlcbXQATxYwWs9TmivIqUNfMoY8Aaw0y86WXp3XDFncau8FBbtvatXE7RG+SKlzcg36nfy0X2fiPtkBnWmvf7EEgoD0/lDPpzzVNl+FWXzx04AI+YEBKsTENpyAjrYzz1B1H59hTh/4Ax3cYuOc7WC4QpexC9oT+MXSzlY6iCdtX6W8tFVq26ILiV71fVpdNUN6mtDu96NapOlrJuWkmyv9b315fBOah9vdHuoW0e3lziCKJVu93Vr69bT7bGec4CTOVIJxwms2EF+ShcYG2ocCEdJxhGwqrAD3mXewX3t4niElQT2GMOmyzZdWzieyxhzPJ7jOsA4rMYeMAKhMez7bN+FfZ/te5jj8xwPtSRAIDSGbwH75sN+wPZ98CHzAXwI2YcAvoXsWwCbJADhEHzEfIg4RByHMBDKZn/CEJj9CSNgth9hjuQ5EXhZ8eCEcip/wAFX9pX0hVKV/wpxVhxn5bjA1b5YB1z5hnVCccyxDrg6C9YBs31UXOVVvmEdMPvgwqbHNhFnZeJM2jkGk74cT9hf6g470G+lo9GdtHP57C5pbfRF3EwOkI4ux5B0dI12mG/ywcN8kw+kndHah31/TbuA9w1IX54TYI7RHRpBm6VGS00j6BtxjkXIGaMv9FrqG2F+ZOaTpqyLjTiz7rgudcd1qTuuwKyFjTjba1pL5nFPKWl4xJ9ucJMD0uQD5Q9rhGelkkZ3yhm2Q7lhcol0p5KkMfzhe1PrznrhusoZ3HeK71NcgU1uwL7WDsXgWpeEPd26uvV1qQioFv5Qtfz3Vekf3WkhS6lo1j/ef49r77Ssi3nxmHRSCy8M1jTP7qZV/y59TjozK65eXNZHatx4PrpP8cRdo7I8n+D9aZsFM1Qj+91xXqRbh4hMH7rvmaKhLabu8+Jhw6enJMvqZ9GvizWqeuLXqFmBx/laPymK/KnGjJJZr0asPfprltLxRjBnSd3FZJhs7DZaheN1x3q29A9PYzx//3+7+9BvdySU/dGq1kdzR+d4Xnyn4KwGN+ktZQfsdyrP2ug2/p0isza6yb+pKOTs26ICdktdAbtZWkC9rS4g3xQYcO/UGLK6WWbIq81KQ1u9KTa01Xq9aVnmz6/V3vkG", "heyawake_2_mark?": "m=edit&p=7VZrT+M4FP3Orxjl60TT2M5bGo3KoyMhYGAHloWqQiGENm3adNIHbFD/+x47N82DMqt9fGClVRv79Pj6+NzEvs3ixyrIIp0Z8itcHT0+JnPVxV1bXQZ9LuNlEvkfRtHvwVMwifTuajlKM/+DPlou5wu/05mv8vxTEs8mnfmXMqrDDPkNJ/P7OLPsIFxMmDcdh7YYZuNUiKERrtdsnQonHfKhYGM25PxTLMSED3X9W6+n94JkEenHN+P9w0n36aj7W8e6FeLq7PHj+PDiavxw/Su7MOJOZpwl7uz0/HA/+fg1vz0dddfRUWSfL9JwlETBQ5DfXh8/J7OeOxw9soPj0YH7GMyMxQ/30lvvX3z+vNenRAd7L7nn5109/+r3NabpGsfFtIGeX/gv+amvhen0Ptb0/DvGNZ0NdG26SpZxmCZpppVcflLM5oBHFbxW4xIdFCQzgM8IA94AhnEWJtHdScGc+/38UtekgX01W0Jtmq4jjQzK34UpEPfBEg9qMYrnmi4wsFg9pJOVVq6w0fPu30hDVGmIbRpidxr8X0kjmac7EvAGmw0e0C9I4c7vy2yuKuhW8Lv/spGGXjTB5VQ8Q1Y8RU3YkhA1wi3vDhG23SKctoZrtAlPEl8qgrH2Moy5bcZqysAxU75vVNtTLVftJdLSc6HaQ9UaqrVUe6JijpAtZ0Ln3NF8jn3LTGC3wBwnmnuEPZ0Lo8DCAGYU79SwjBEUA2xSvIl4k2K4U8PQNznFcGCaa8KPaRKGH9MiTVbDMoY8m1KTPJtSkzxbiLHMykOJLWhapGOyGoYHyyZsAXuVvk2ebcTbvFqrxDbm2uTfwly79ICK6NB9cODBIR0HOg7NdTDXKed6FZZrOeTNBu+KSselXBzk6NJ9cOHTpfvgympMHhyrwh6wRzl68OaVc0WFPeh4pY5ZYQ+aXqnj6cIo8kIPzLYeSowemG/9lBg9MOXieluMHtgkbAJbWz+CsWpdRvHYt4JRPPatYBZhC9gmbAM7hKWOSxh+WJGL4NDhpMOhw0sdVsMyxqk06SwoTdr/6IEpR4Ec6SwoTUHrcngTtC7OiKAzgh6YdHBGthj7U9AZQQ9Mmjgjgs4IvNSwjCfP+H8WFq/Wssq58GORH+xzofb5RlZoWRIOVGuq1lalwpH18S9V0H9elf7UTl8U7x7Nj/Xf4wZ7fbw/aIs0uVussscgjO6i5yBcav6jeo+pjzS42Wp6H2UNKknTOV6ndimUQw0yHs7SLNo5JMnoYfiWlBzaIXWfZg8tT09BkjRzUe+QDar4229Qyyxu/A6yLH1qMNNgOWoQtdeYhlI0a93MZdC0GEyC1mrT6nZs9rRnTV191Ej5vP5/2XvnL3vyYRnvrWC9Nztqn6fZT4pONdimd5QesD+pPrXRXfwbhaY22uZfVRVp9nVhAbujtoBtlxdQrysMyFdFBtwbdUaqtkuNdNWuNnKpVwVHLlWvOfhP+AM=", "heyawake_3": "m=edit&p=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", + + "ayeheya_1": "m=edit&p=7Vhrb9s2FP2eX1Ho04YSMymRlGSgGJxXgSLJkiVd1hhGoDjyo5GtVH4kVZD/3kPyqrIcJ0MxDMuHwjZ5eO8RH/ceXRCefVkkRcqEZiJgQcQ4E/hoqZkKfCZEFLuG0+dsPM/S9hvWWcxHeQEwms9vZ+1W63ZRXpQXv2Xj6U3r9vfkazpKvyYtoVsiaMUBN1+fvv3hUK5+o9h9Oed9vszDIQAfLJcz04e5ablYLpcWcO4rmamRVEOpgiFjf+zvs0GSzVL24dPn7d2bzt1e5++WugiCj0eDt593Tz5+vj7/S5zwcavgR1k0PTze3c7evi8vDkedZbqX6uNZ3h9laXKdlBfnH+6z6X40HA3EzofRTjRIpnz2JTqLl9sn795tdSkOva2HMm6XHVa+b3c94THPx094PVaetB/Kw3Z5yspTuDwmesybLLL5uJ9neeFVtvLAPegD7tXw3PoN2nFGwYGPCAN+AuyPi36WXh44y3G7W54xz6y9bZ820Jvky9QsZvZmxv18cjU2hqtkjhTORuNbjwVwzBbX+c2CqKL3yMrOj50Ak1QnMNCdwKANJzAH+29PEPceH5GcP3GGy3bXHOdjDaManrYf0B61Hzzp41Fo3eXPkxJDVQ9DDOX3oZCq4RbaPL3ij4OG37f82u/LuOlXZrmAxtiRsPv6ZNt92/q2PcO2WRnYdte23LbKtgeWs4fTBNq8x9iEz4A1sHY45CyIOWEBLByOAiY58SMNTPyYMymIHwtgxweXSd/xwQV2fHCZDBwffiYlcXxwJHECcBRxJDiaOCg3UhNHgRMSB2eRdBapJTCCZXEIjMQYHPpMxkiCwZFkihMnCoGJE/tMCceBnynfceAHdhz4Tclz2AfH6MBicIwIDEZJVIo4EhxNHAmOJo4CJyQO9qxoz0pHwJHDYcCUEYrFKLWxOzu4TNP+wQUmfhwwLYgfa2DHB5dpOgu4wI4PLtOB44MLTLHlyCOnWHHEjfJuc1flV5hcEwcxkbQuemCKrUAuBIRtsQKuNIA8Coq5yTvFFj2w2xt6YLwEFse1ZkyuKZ4215rm15i/0gb0jNzXGtA0J2IrNc2pMWelH2hehnQuaB5aqTUT0rmQCxnSuiHWDWndEOuGtC5yJENaN8I8UaU3zFPp07xHVa6Nlqp8IW6K4oYe2M2PHpjyjrgpipvSAlqq9IN56B1BX+vN6Irigx6Y5kF8vusQ8YHmau1RfNBDny4+6IFdfNDXukV8oNFaqxQf9MC0LuID7dYatvFBETq3pWjHttK22pao0NTdH6rM/74a/uN2utIU/pc+plT/9P9v/t5W1ztdFIOkn3q4dHmzPLucufFlep/0517bXf5WPQ3bdDG5SnFrWTFleX6Li+qmGSpXwzgeTvMi3egyxvR6+NxUxrVhqqu8uF7b012SZc2z2Jt5w+RuTQ3TvMCVaGWcFEV+17BMkvmoYVi5PjVmSqdrwZwnzS0mN8naapM6HI9b3r1nf7jN4KX6eUN+1Tdkkyj+2qrxa9uO1XhevFBwaue6eUPZgfWFyrPi3WR/psiseNftTyqK2ezTogLrhroC63ppgelpdYHxSYGB7ZkaY2ZdLzNmV+uVxiz1pNiYpVbrTdejfxne/JLeJHdm8KvX2/oG", + "ayeheya_2": "m=edit&p=7VdtT+M4F/3Orxj50/NorK0dx3mTRqvyNhICFhZYFqoKhRJIaNp0khSYIP77HDu30xfKrEarldBqW9U9Ob4+1z5xrtvqyzQuEy4ll4KrgAsOxF3tcVcGXEvffgS9T7M6T6IPvDut06IESOt6UkWdzmTaXDaXv+TZeNiZ/Bp/TdLka9yRsiNFR2c6y7N8iFc6TN3UxUuIRAhVJaJ4sPChEuLOGTpjzn/b3eW3cV4lfO/ifnN72H3c6f7Z0ZdKnR3efrzfPj67vzn/Qx6LrFOKwzwYHxxtb+YfPzeXB2n3IdlJvKOqGKR5Et/EzeX53lM+3g3u0lu5tZduBbfxWFRfgtPwYfP406eNHq2sv/HchFHT5c3nqMck48zBR7I+b46j5+Ygak54c4IuxmWfs9E0r7NBkRclm3HNfjvQAdyZw3Pbb9BWS0oBfEgY8AJwkJWDPLnab5mjqNeccmZyb9rRBrJR8ZCYZGZu5npQjK4zQ1zHNW5KlWYTxhU6qulNMZxSqOy/8Kb7cyuAyGwFBrYrMGjNCszC/tkVhP2XF9yc37GGq6hnlnM2h8EcnkTPaA+jZ6Y0hjrYxfb+sUAtXCJE2sAL2+7a1rHtKXR4o2y7bVthW23bfRuzA3nHCbnjuixysEmUBPYIO8A+Yc0dLVrsusAhYY87niTsAzst1gIY87YYvD/jkcunXB5y+ZTLQy6fcnnIFVAuH3xAvI+8AeX1kTekvD70Q9L3oR+SfuBzJYgPQmDiQweYNEMXuNVELFey5RELPOOh47Q6CnVFOe2cEcuVmvHQV60+YoFJx/G4ctt5oh+YNBXGahrrQlOTpgsdj3Q04j2K94D9GUZMQGtBXYO/c591QDiY3yPjj0+8D550rFcznwPcr4D8DHBfgrlvTkgxIWJmnoeIIc+tbwKb0mL13Wfrm2jz4nvBZ8xftpr4BiZ/jLeSNLEPv3uLfagU6Svoz3w2XtGexDcw6WjjJ43VGKtprMZYveCtbvcnvuf+w09l/cTDcW4fkS3burb17KPjmwf0px7hv/+U/uV0enDJnHSrb/1vYPsbPXYyLW/jQcJwsrGqyK+q9voqeYoHNYvaE3axZ4kbT0fXCY6GBSovignO93UKs64lMrsbF2WytsuQyc3dW1Kma43UdVHerMzpMc7z5bXYnzNLVHs0LVF1iXNn4Touy+JxiRnFdbpELJxRS0rJeMXMOl6eYjyMV7KN5na8bLAnZj89xbEh//sZ8q5/hpgbJd5bJXtv07F7vCh/UHDmnav0mrID9geVZ6F3Hf9GkVnoXeVfVRQz2ddFBeyaugJ2tbSAel1dQL4qMODeqDFGdbXMmFmtVhqT6lWxMakW602P0Z+zD/9LhvGjufg/6298Aw==", + + "shimaguni_1": "m=edit&p=7VdrT+NGF/7Or1j5U1/tqPHM+DK2tKrCbSW0UChQXogiZBInNjgx2DEgI/77PjM+zo2w1aqqRKUqyfjxmeNzfXzslA9VVMSM2/orFcMRH4cr8xPKMz+bPmfpLIvDT6xbzZK8AEhms/sy7HTuq/qqvvo1S6d3nfvfyiSdRONqmna4rb9KOVESuYlyb7F1P065/3j7MJpUwxm/qZJslvIqFYl0xnLC2O/7+2wUZWXMDi5vt3fvuk973f933Cspz49Gn293T85vhxd/8hM77RT2Uaamh8e729nnr/XVYdJ9jPdi77jMB0kWR8Oovro4eM6m+2qcjPjOQbKjRtHULh/UWfC4ffLly1aPUutvvdRBWHdZ/TXsWdxilsCPW31Wn4Qv9WFYn7L6FFsW431mTSoEPcizvLBaWf2tuVAA7i3ghdnXaKcRchv4iDDgJeAgLQZZfP2tkRyHvfqMWdr3trlaQ2uSP8bamY5Nnw/yyU2qBTfRDF3RhbWYxEZZDfO7ilR5/5XV3Z/LAEbaDDRsMtBoQwY6sX82g6D/+orm/IEcrsOeTud8AdUCnoYvWI/CF8tRuFSAxqZ/lsdxKuenvoNTZ36qvKVdGODGzKVZ980qzHoGL6yWZt01q21W16zfjM4enAsumRC+FQpQSN9CUjRYBEw4ssESOg7pODYTLmIyWAAjIINx63k2YdjxyI7rAgeEYdMnmx4HdgnDjk92fMgVyX34VeRXOUwEqJTBHpM2+VI+MEqmcYCZYFNsAXQ46QTQ4a2OAqbYggC4iUfaAriJAbpMiiYG6AK3che4yUVy2JSNTYm6SaobdJl0Gr9SQMchHQk7DtlBDSXVEDIm27pBXwjKUaCG5Mv0QpKORP3JL46LfsE++rTol6Q6SNRNUj2l7mnbI+i3/XV0f9ueQt8hfQe9o5jRf2CKzYWOSzq6vy0HXHDApX650HdbfeTiUi4e4tcUbznQ8sSDvkf6nuZPq685QzH7uNanazU39L1hMOJpueQjBr/ljOYS2VfIV1G+SvOKrlWIX99UxB8RtDyBnYDsgDPgyoIzxD0c59zDEZg4oLlkt7ySc05KW/ONrsV9JznJuQPc8k3ztvFreMWJbwL6gvQF9Ft+Cs1J4pXmG/EHxwVXwZM5Px1gp8WakxQnOCCJAzgCk31woOEtBsaFGRs7ZnXM6plx4uuR9lND7+9Prr8Mp4c7Rb8crH7cf5+sv9WzTqtiFA1iC899q8yz67I5v46fo8HMCpv3j+WdFdm0mtzEeHAuibI8v8frzyYL7daKMB1P8yLeuKWF8XD8nim9tcHUTV4M12J6irJsNRfztrciah7cK6JZgafy0nlUFPnTimQSzZIVwdITfMVSPF0r5ixaDTG6i9a8TRbleN2yni3zwxMaD/T/XtI+9EuabpT90abWRwvHcDwvfjBwFpvr4g1jB9IfTJ6l3U3yd4bM0u66/M1E0cG+HSqQbpgrkK6PFojeThcI3wwYyN6ZMdrq+pjRUa1PGu3qzbDRrpbnTc+a/3f99EtaZtF0WP7P6m99Bw==", + + "aqre_1": "m=edit&p=7VZrb+JGFP3Or6jm646KZ8aAbWlVkacUJWnSJE0DQpEDBpwYTPwgkaP89z13bBfbkF2tqqr5UCwP556L72Mex8TPqRt5XEje5criBhe4TNvkstvlUgl9G8V17SeB5/zC+2kyDyOAeZKsYqfdXqXZIBv8GvjLp/bqN/c58tpCtrtt05e2MGe+ZZumb9BHrNcz+u70ekKonuL896MjPnWD2OMnd497B0/9l8P+X+3OQKmb8+mXx4PLm8fJ7Z/i0vDbkXEeWMuzi4O94MtxNjib99feode9iMPxPPDciZsNbk9eg+WRNZtPxf7JfN+auksjfrau7fXe5devrWHRyqj1ltlO1ufZsTNkgnEmcQs24tml85adOdkVz67gYlyMOFukQeKPwyCMWMllp/mDEvBwA2+1n9B+TgoD+LzAgHeAYz8aB979ac5cOMPsmjPKvaefJsgW4dqjZFQb2eNw8eAT8eAmWIV47q8YV3DE6SR8SoufitE7z/o/1wGClB0QzDsgtKMDauzf7cAevb9jcf5AD/fOkNq52UBrA6+cN4znzhtTJh7tYNvq9WOqB7P3t2kadVPBpD2em5asmx2YamNatWdtUfEiudAl3OnxSI9Sj9eokGdKjwd6NPTY0eOp/s0hChcWjpvdZY5ERAuBbbvAOHoGKiNsA4sS28AoERh+YNRHGKdXyhJ36MgW2ALG7BCWwGaB6VibmCaNcdQ7mCPCJh37EveAMVmEOwbJQY67wL0SK+C8Zi0XNJs6F2qQxW8keFnkkohZ1kn1yOJZib5UkVchflm/lp8ipkKPqqhHIW/ZF9WvijlR1HuRV5F8UV5M9q2e8n09mnrs6qXo0Vb6qc32z1f9h+UM0RHtyO2L9vh/wI9aQ3aVRlN37DHoJovD4D7O7Xvv1R0nzMn1u+qpcct08eBBeCpUEIYrvC52RShdNdKfLcPI2+ki0pvMPgpFrh2hHsJo0qjpxQ2Cei/65VijcuGrUUkEVavYbhSFLzVm4SbzGlFRwFokb9mYzMStl+g+uY1si810vLfYK9M3VErSwv3/kvvELzlaKOOzqc9nK0fv8TD6juBsnE16h+yA/Y7yVLy7+A9EpuJt8luKQsVuiwrYHboCtiktoLbVBeSWwID7QGMoalNmqKqm0lCqLbGhVFW9GTL6r89GrW8=", + "aqre_2": "m=edit&p=7VhbT+M4FH7nV6zyOtbWTpxbpdGq3EZCDAsLLAtVhUJJ2wxpA+kFFMR/n+/YJ7QpZVaj1Uo8TC/25+OTc/ep1enDPClToVz6eJGQQuGtY22+Xuibr+T3WTbL0/ZvojOfjYoSYDSb3U/brdb9vLqqrn7Ps8ld6/6P5KFMW8o1n2SYJ4nvu5lUI1lk/VjrzI19nQ2lDBdTKeVgsVgAF1IthoOhRyQPL9cL1NAdqqEKtQq00kL8ub8vBkk+TcXB5bft3bvO417nn5Z/5XnnR4NP33ZPzr/dXvytTmTWKuVRHk2+Hu9u55++VFdfR51FupcGx9OiP8rT5Dapri4OnvLJfjQcDdTOwWgnGiQTOX2IzuLF9snnz1tddrq39VzF7aojqi/trqMc4bj4KqcnqpP2c/W1XZ2K6hRbjlA94Yzn+SzrF3lROjWtOrQPuoB7S3hh9gntWKKSwEeMAS8B+1nZz9PrQ0s5bnerM+GQ7m3zNEFnXCxSUka20bpfjG8yItwkM+RrOsruHeFhYzq/Le7mzKp6L6Lq/JwHEFJ7QNB6QGiDB+TY/+tB3Ht5QXL+gg/X7S65c76E0RKetp8xHrWfHU/jUQ8FbvLneHFjqVVz6TWXPpY4JrwMgsZuEGEZvC5DEkUnyS6VJFnLh5UizSv7Lhm2stYkPVxZk3i9XAdhkz90G+pVRPpW+OOmPlcSf72P4CgToksz7pvRNeMZIigqz4y7ZpRm9M14aHj2EFg3jIQbwwkXwiMJDAcMdoFhvMFoKFJaTM1F1TgAhkEGR8BWDniBrRxPhgIdwWIFust06lqulQ9egbZhsesBw2mDQdc1HfI1y/fwLAXW4Fh4PjJMWIPuM90Hf8j8PnhC5gkgP2T5IXyJrS+mYcZIvsHgj5k/CoWmIiAcS2C2P0azlUimwb7Qyj4LXmArH7xCu9Z+7ANbfuwL7Vn52Ae2NmNfaCpewh5k+iwT7V371hetIcdnORq6fNblg07FZTB+BELrF3iBWSbioOs4SPhb+wI7kadlviTHUCKGkmMlERPOu8kj+4v5tQZMTlWda8S5rgf8BHiK9SrorWuD8sX+Yl7mV4OfTq3B4KdDZTDVA9upYWddA5pqg+30IYdjhXlZGz7k+CzHhxyOIeZmzXA8Tc0E7G8AfwP2N4C/AfsbwP6A7ae6ClhvAL1BXT/QG7HeCHoj1ou68iLWG0FvxHojqj3OHdUP5wUzMNcJ8qI5L5hRb8yPvLzWIdUb5wUzMNcA1RufR8zAXFcuasataxK1x+cU87JuqQ75bGJGrbJeDb3Ug+v65DOLmesZzebCtJwdM2ozBqYVhdTqf+rH4L93vX81p+tRi9309n/R6d3b6jqn83KQ9FMH9yxnWuTXU7u+Tp+S/sxp2/ve6k6DNpmPb1JcVFZIeVHc4yK6SUK91SBmw0lRphu3iJjeDt8TRVsbRN0U5e2aTY9Jnjd9MffuBslelBqkWYlb0Mo6KcvisUEZJ7NRg7ByY2pISidrwZwlTROTu2RN23gZjpct58kxX9x5cI/5dSn+0JdiSpT8aN3wo5ljarwof9Bwlpvr5A1tB9QfdJ6V3U30d5rMyu46/U1HIWPfNhVQN/QVUNdbC0hvuwuIbxoMaO/0GJK63mbIqvVOQ6reNBtStdpvug79i+D0tr4D", + "simpleloop_1": "m=edit&p=7VbtbtowFP3PU1T+W2vEBNoQqZr4rFS1rKx0rEQIGTAkxcE0Hy0y4t177VCRBFpNmzR10gg+Ppyb2PfG9hHhU0wDhomhvqaFoYerTCzdStaZbsbu6nkRZ/YJrsWRKwIgbhStQrtYXMVyIAdfuLdcFFdfQ89fccaFWBWJob5l14CPNVc4JwrLBsbf2m08ozxk+Orhsd5c1F5atZ/FysA07zuz08dm9/5x2v9BuoZXDIwOt5Y3t806P72Ugxu39sxa7Ow2FBOXMzqlctC/WvNl25q7M9K4chvWjC6N8MnqVZ/r3YuLgrOrYVjYyKota1he2g4qIawbQUMsu/ZG3thoIvyxh7C8gzjCZIiRH/PImwguAvSmyWtgBOES0Nae9nVcsUYiEgN4Z8eBPgCdeMGEs9F1otzajuxhpBKo66cVRb54ZmoyeEz/TpICYUwjWIPQ9VYImxAI46lYxLtbyXCLZe03yoCR3spQNClDsSNlqOr+uAzYKWx9pILqcLuFFfoONYxsR5Vzv6fWnt7ZG2SWkF3GyKwkXVV352bSneuOkHLSlxKZVCzo4fmOvQEkGh80tjWWNPZgEixNjU2NhsaKxmt9T0tjX2NDY1njmb7nXKX5i4X8tXQcMzne2avy72nDgoPu4mBGJww2VkP4KxF6EUNwuFEo+ChMYiO2ppMI2YnJpCMZbRn7YwZnIiUp71Jb9MgIb6GM6M2XItCOdxBSIpvO3xtKhY4MNRbBNJfTC+U8W4v27oyUnMmMFAVw4FK/aRCIl4zi08jNCCmPyYzElrmXGdFsinRBc7P5+9exLaA10s0xcUkt4n8n/uxOrFbL+Gw29tnS0RtdBB+4zj6Yl494D6gf2E8qekx/x2lS0bx+YCsq2UNnAfWIuYCa9xeQDi0GxAOXAe0do1Gj5r1GZZW3GzXVgeOoqdKm46Dk7+iJegFoWHgF", # https://puzz.link/p?simpleloop/10/10/4h00008g0000g1000040 "simpleloop_2": "m=edit&p=7VZdb+I6EH3nV1R+rbXYBGiIVF3xWalq2bKly5YIoQCGpDiY5qNFRvz3jh0qkkCrq3ulVVdaJT4zOWPGM3F8RPgcOwHDlKjbMDFYuMrU1KNkVvUg+6vvRZxZZ7geR64IwHGjaB1axeI6lkM5/Ma91bK4/if0/DVnXIh1kRJ1E0IWJqE1sCUPgBJSxvh7p4PnDg8Zvn58arSW9dd2/VexMjSMh+78/KnVe3iaDX7SHvGKAelyc3V712rw8ys5vHXrL6zNqnehmLqcOTNHDgfXG77qmAt3TpvXbtOcOysSPpv92kujd3lZsPc9jApbWbNkHcsry0YlhPWgaIRlz9rKWwtNhT/xEJb3EEeYjjDyYx55U8FFgN45eQMeRbgEbvvgDnRcec2EpAT87t4H9xHcqRdMORvfJMydZcs+RqqAhv61cpEvXphaDH6mn5OigJg4EexB6HprhA0IhPFMLOP9VDraYVn/D21Apvc2lJu0obwTbaju/ncb8KWwzYkOaqPdDnboB/QwtmzVzsPBNQ/uvbVF5RqyyhhVEnNRTYypjUm1oSR5pLS8txeJLSezadUAC/m61haQanzU2NFY0tiHRbE0NLY0Eo0VjTd6TlvjQGNTY1ljVc+5UGX/y8Z+Wzm2kRz37FX587hRwUb3cTB3pgw+tKbw1yL0IobgsKNQ8HGYxMZs40wjZCWik45kuFXsTxickRSltEx9sicyvIcypLdYiUAr4FFIkWy2+CiVCp1INRHBLFfTq8N5thet5RkqOaMZKgrgAKaenSAQrxnGdyI3Q6Q0J5OJrXIvM3KyJTpLJ7eaf3gduwLaID1sA5fUJv5V5q+uzGq3yFeTsa9Wjv7QRfCJ6hyCefqE9gD7ifykoqf4D5QmFc3zR7Kiij1WFmBPiAuweX0B6lhigDxSGeA+EBqVNa81qqq83KiljhRHLZUWHRslf0/P1AtAo8Ib", @@ -16,9 +25,14 @@ def penpa_test_urls(): "nonogram_1": "m=edit&p=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", "nonogram_2": "m=edit&p=7Vhdbxo5FH3Pr1j5tdYyHtvzgVStyFelqs0m22SzCULRhEwCYWDoAE01Uf57z7U9ZUxIV7t9yUMF2Odew5zje+9cDIvPq6zKuZBcKC4THnCBh5Yh13HAYx2ZV+Aep+NlkXd/473VclRWAKPlcr7odjrzVX1ZX/5ejGeTzvyPWTkr76ps2hGheU7kRE8EPcR9LO4Nco9wMsSL5raX8z8PD/ltVixy/v7ifnd/0ns46P3T0ZdSnh3dvrnfPzm7vzn/W5wE404VHBXJ7OPx/m7x5l19+XHU+5If5NHxohyOijy7yerL8/dfi9lhcje6FXvvR3vJbTYLFp+T0/TL7snbtzv90Ow7GOw81mm37vH6XbfPBOMsxEuwAa9Puo/1xy4bltPrMeP1J6wzLgacTVfFcjwsi7Jija/+YD8dAh6s4blZJ7RnnSIAPnIY8AJwOK6GRX71wXqOu/36lDMSsGs+TZBNyy85kZFAsq0oOK6zJfKzGI3njEssLFY35WTl3ioGT7zu/Y9t4ErNNgjabRDasg3a3c9vo5iXWzaQDp6ekKC/sIWrbp92c7aGyRp+6j5iPOo+MhXio1TPJodMSc/Uyjc1TNwGjRnB1Gsz9t+c+GbqmVEAM16bwl/1VUW+qshXFZGqtRnTlddmQqrC76YIzIdbjpCoW7b0dQuz6dZ67GsTib+xMKCotOzQFx8qX1/ohQ1ZESY3F2Y8NGNoxlOkjtfSjPtmDMyozfjBvOcAGZWh5pLSGnKGmUuKPGGdchlDHOE44jKFMMKp5EpAFDBmrkIIIhzGXClEi7BSXFGOCEeCqxhBIhwnXKUIEOFUcy0sL2auQ8uLmWtleTFzTdkkXhlAJ7jQWCE05pLyaoyI+izYjJEISAWdMVLwCfCRAcCVBKEx0I6VAqMxVAq5oDRGFHGVgNMYieQ6wGbJAIBipwAAPd0pAOBaOwUAEN0okIiudqQAUO1IAaDakQJwFTSkASIcOlIAqHakAFzRTWYMjSDHjhQAqh0pAFS7bQMgtk4BAFQ7BQBQ7RQA4PvJKZDgkcRj6gDRJRpTBwgusRAGiSISyiU4FHGYOkBkiYIwGBQxEAaBIgJTB9hh6mooRVQDW0OYodXyYkYdWF7MUGp5MUOo5ZXAXr1SmyAcoVZc/WEGdvUdUQJcPUXSlpDB2CP1BYORMSoGg1H3kdWGGdjtPQIvJdJguk8cL77gm1gphT3S/WArDLlSTSEqRIsq3BoIS1PWADCaLCoE2CtR3dSERk005QYAoylrVJjSTR1pKPCqhXqTNaDge+ZDBJRuPmugJmRT8LjltGzKWppDTGNQHTkegI1bQTa1h+rXsuGRqLCmkgFguJ0CbFQlNT1jKCigIjAGstd0A8zArlKQPe2yhxnYdQ9kT7vsYbY3pcGgc9nDTMcyi5E97bKHGdh1p5iObo4XlatjxxuD11QiWum5aah7ZlRmjEyjjekb9D99x/58T/9XOX3sbvsj+uWnx2Cnzz6tqttsmOMotVdO5+VivMwZjrNsURZXC7t2lX/NhkvWtcfq9ornm62m1zlOgS1XUZZzHO23XaFZ8pzju1lZ5VuXyJnf3L10KVracqnrsrrZ0PSQFYW/F/NLxnPZU6jnWlY4YrbsrKrKB88zzZYjz9E6VXtXymcbwVxmvsRskm2wTdfheNphX5l54dCJE9iv3x6v/rcHJSt4bd3xtckxdV5WP2g668VN95bWA+8Puk9rdZv/hUbTWt30P+sqJPZ5Y4F3S2+Bd7O9wPW8w8D5rMnA90KfoatuthpStdltiOpZwyGqds/ps+a/GTbY+QY=", "nonogram_3": "m=edit&p=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", + "nonogram_4": "m=edit&p=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", # slitherlink - "slitherlink_1": "m=edit&p=7Vfrb+I4EP/OX3Hy17WO2CYPIq1OlNKVqrbbXtvrFYRQXkBoIGwSSpWq//vOmCJsQ3uPSqv9sEIZzfxmMi/jsVN+WwVFQplHmUWFRy3K4OdyTluAtWxLPtvfTVplif8b7ayqaV4AM62qZek3m8tV3a/7v2fp4qG5/KPM0mqaFE3mNZnVnITRhMeTOI5dEXMvjtdcWK7DOPNsJniYRrNoEc6CVAjmuYwLzxVCxOvQ4TFz4jAOJyyaRAGlX09O6DjIyoSe3s+Ojh86617n76bdF+L2Yvxpdnx1O4vv/mJXVtosrIvMW5xfHh9ln77U/fNp5zHpJc5lmUfTLAnioO7fnT5lixNvMh2z7um0642DhVV+827aj0dXnz83Bq9FDxvPdduvO7T+4g+IIJQweDgZ0vrKf67PfRLl8zAltL4GPaFsSMl8lVVplGd5QbZYfQYcvMmB7e3YO6lHrrsBmQX8xSsP7D2wUVpEWTI62yCX/qC+oQQTOJJvI0vm+WOCwTA5lDdJARAGFSxaOU2XhApQlKs4f1i9mrLhC607/6MM8LQtA9lNGcgdKAOr+3AZSTxJng5U0B6+vMAK/Qk1jPwBlnO7Y70de+0/A73wn0nLhVfxXw6vgzfbApHvxJYu2iCKndjWRIfroq51Ubtz5aJnRat7dj3dWHflMV3UXTGmR2JMGHq0x629lTG2aq+3hDGMrshc7xFsXUOv94FxIx53DHuMp9rrxTNuxBcoK/oW+lNkx8jHQX9KfNlcJZ7RXeZhfcr7RruZh/1U4nnYP9XeyMcz6vOM+G2jn22jf21j/drGerf1/w7naL/Lhxv95kZ/ueyv8r7A+Iq9MPwJI55Af4q+Zehbht42/Nt6P7mN/VJkB/uzXT/Ytkxu3ntJTyTlkt7A3qa1kPRYUktSW9IzadOT9E7SrqQtSR1p4+J0+E/z4wekM2g58hh+/2f/svmozbAxINerYhxECZw33Xy+zMu0Sgic+aTMs1G50Y2SpyCqiL+5e6gaDVus5mECR6UCZXm+hEvRIQ9blQamk0VeJAdVCOIh+IYrVB1wFeZFbOS0DrJMr0XeATVoc1RrUFXAOazIQVHkaw2ZB9VUA5Srh+YpWRjNrAI9xeAhMKLNd+14aZAnIh8YOTA0fl3Qfv4LGq6W9bON2X9IZ1D3KIFvEirgeKT1V0qWq1EwgnYT+CqgH1H/G4Mf3g25z/LinaG3U5rwgdEH6DvTT9Eewt8YdIrWxPemGia7P9gAPTDbADXHG0D7Ew7AvSEH2BtzDr2aow6zMqcdhtobeBhKnXkD8vpVjd/YZNj4Dg==" + "slitherlink_1": "m=edit&p=7Vfrb+I4EP/OX3Hy17WO2CYPIq1OlNKVqrbbXtvrFYRQXkBoIGwSSpWq//vOmCJsQ3uPSqv9sEIZzfxmMi/jsVN+WwVFQplHmUWFRy3K4OdyTluAtWxLPtvfTVplif8b7ayqaV4AM62qZek3m8tV3a/7v2fp4qG5/KPM0mqaFE3mNZnVnITRhMeTOI5dEXMvjtdcWK7DOPNsJniYRrNoEc6CVAjmuYwLzxVCxOvQ4TFz4jAOJyyaRAGlX09O6DjIyoSe3s+Ojh86617n76bdF+L2Yvxpdnx1O4vv/mJXVtosrIvMW5xfHh9ln77U/fNp5zHpJc5lmUfTLAnioO7fnT5lixNvMh2z7um0642DhVV+827aj0dXnz83Bq9FDxvPdduvO7T+4g+IIJQweDgZ0vrKf67PfRLl8zAltL4GPaFsSMl8lVVplGd5QbZYfQYcvMmB7e3YO6lHrrsBmQX8xSsP7D2wUVpEWTI62yCX/qC+oQQTOJJvI0vm+WOCwTA5lDdJARAGFSxaOU2XhApQlKs4f1i9mrLhC607/6MM8LQtA9lNGcgdKAOr+3AZSTxJng5U0B6+vMAK/Qk1jPwBlnO7Y70de+0/A73wn0nLhVfxXw6vgzfbApHvxJYu2iCKndjWRIfroq51Ubtz5aJnRat7dj3dWHflMV3UXTGmR2JMGHq0x629lTG2aq+3hDGMrshc7xFsXUOv94FxIx53DHuMp9rrxTNuxBcoK/oW+lNkx8jHQX9KfNlcJZ7RXeZhfcr7RruZh/1U4nnYP9XeyMcz6vOM+G2jn22jf21j/drGerf1/w7naL/Lhxv95kZ/ueyv8r7A+Iq9MPwJI55Af4q+Zehbht42/Nt6P7mN/VJkB/uzXT/Ytkxu3ntJTyTlkt7A3qa1kPRYUktSW9IzadOT9E7SrqQtSR1p4+J0+E/z4wekM2g58hh+/2f/svmozbAxINerYhxECZw33Xy+zMu0Sgic+aTMs1G50Y2SpyCqiL+5e6gaDVus5mECR6UCZXm+hEvRIQ9blQamk0VeJAdVCOIh+IYrVB1wFeZFbOS0DrJMr0XeATVoc1RrUFXAOazIQVHkaw2ZB9VUA5Srh+YpWRjNrAI9xeAhMKLNd+14aZAnIh8YOTA0fl3Qfv4LGq6W9bON2X9IZ1D3KIFvEirgeKT1V0qWq1EwgnYT+CqgH1H/G4Mf3g25z/LinaG3U5rwgdEH6DvTT9Eewt8YdIrWxPemGia7P9gAPTDbADXHG0D7Ew7AvSEH2BtzDr2aow6zMqcdhtobeBhKnXkD8vpVjd/YZNj4Dg==", + + # stostone + "stostone_1": "m=edit&p=7VZtb6pIFP7ur9jM106uMAPUkjQbX5s0rbeudt1qjEEchYpgebENxv/eMwMooO1Nd3OTftggJ888Z+a8zMCDwUtk+AxrcNEalrAMF9E0ccuKIm4pvQZ26DD9D1yPQsvzAVhhuAn0anUTxaN49MOx3VV182cQevBzWVWDaynZtkm2rkSZuaREVZcY/+x08MJwAoZvn54brVX9tV3/p6qOKH3sLi6eW73H5/nwb7kn2VVf6jo19/6h1XAubuLRvVXfsjbTHgLPtBxmzI14NLx9c9xObWkt5Oat1awtDFcKXmqDq22jd31dGafVTyq7+EqP6zi+0cdIRhgRuGU0wXFP38X3etzHcR9cCMsTjNaRE9qm53g+yrj4LllIALaPcCj8HDUTUpYAd1MM8Amgafumw6Z3CfOgj+MBRjx3Q6zmEK29LePJeG18bHrrmc2JmRHCxgeWvUGYgiOI5t4qSqfKkz2O61/rAIJkHXCYdMDRmQ54Y7+3g6vJfg+H8xf0MNXHvJ3HI6wdYV/fge3qO0QoLKXwpIrzQ5TAkByGCveqh6HGvdkQAsgizJOwHWGJsAPIgmMqbEtYSVhV2Dsxpw3JZRlSEQhKICIFrGQYkihqghXg1ZRXgFdTXoWqNahPYHi7NCXBGsy5TOcQeAez+ITnSucTWEvS+QTWkmw+rCVaiuGtJZfH2mi6lsJamq6lsJZm9UCurE6V15yr7VAzrz+Nr0J8NY2v8V6y+nkvfD5s0lBsVVNYRVhNbOElP8YvHfR/P61fljPmO3y4+GPyL/GkMkb9yF8YJkOgNSjwnGmQjKfszTBDpCeal/cUODdazxi8rDnK8bwNqOq5CJmrQNpL1/PZWRcn2Xz5USjuOhNq5vnzUk2vhuMUexGfkAKViEWBCn1QgtzY8H3vtcCsjdAqEDnVKERibmkzQ6NYorEyStnWx+3YV9AbEjdICBz+/x+Gb/1h4AclfTfV+G7liGfc8z8RnKOzTJ+RHWA/UZ6c9xz/gcjkvGX+RFF4saeiAuwZXQG2LC1AnaoLkCcCA9wHGsOjlmWGV1VWGp7qRGx4qrzejFH2lxhNKu8=", + "stostone_2": "m=edit&p=7Zdtb+I4FIW/91es/HWsJbZJcCKNVvRtpKrTbbftdluEqjSElzaQThJolar/fY7tW0GAzmq0WqlaLRD75Jqc55qYayi/zeMi5UJy4XGluccFnu2wzdtCcyU79vDoeTGpsjT6hXfn1TgvIMZV9VhGrdbjvL6pb37NJrOH1uNvZZXjNUtbQraE1/I2HtK2ncXc84YLPJxeLHJvlCRJMOL898NDPoyzMuVH1/e7+w/dp4PuXy3/RqnLk+Gn+/2zy/vB1Z/izJu0Cu8k07Ovp/u72acv9c3XcXeRHqTBaZkn4yyNB3F9c3X0nM0O9Wg8FHtH4z09jGde+U1fhIvds8+fd3o0v/7OSx1GdZfXX6IeE4wziUOwPq/Popf6a1Sf8/ocQ4yLPmfTeVZNkjzLC/YWq4/dhRLyYCmv7LhRey4oPOgT0pDXkMmkSLL09thFTqNefcGZYe/aq41k03yRGpjJzZwn+fRuYgJ3cYVbU44nj4wrDJTzQf4wp7eK/iuvuz83A5i8zcBINwOjtszATOzfnUHYf33FzfkDc7iNemY6l0upl/I8ekF7Er2wIMClZlm7G8hC3TwXor0WaJsrAjqHi7Be17Y9tK207QVQvFa23betZ1vftsf2PQfIQHV8rkLBIol11FFcdkC0ug3tk/ahAbY6gO6Q7kAjZas1dEg65FJ7TmsPmvy1gJakJbQiDa4mrgZXO67ykJvnuOihHRc9tOOih3Zc9FwJx0UP7bjooR0XPbTjood2XPTQxBXgCuIKcAVxJXwk+Uj4SPKR8JHkI+EjyUfCR5KPhI988zEFi/KXpoBR/hL5K8pfIX9F+StwFXEVuIq4Prg+cX1wfeL64PrE9cH1ieuD6xPXB9cnrg9uQNwA3IC4AbgBcQNwA+IG4AbEDcANiGvWEq0T9NDExTpRtE7QQxMX60TROkEPTVwNH00+Gj6afDR8NPlo+Gjy0fAJySeEj13PWOBXdpnv2bZt28Au/475Hv7UN/Wff9P+Np2eatttbfPp/zfj/Z0eO58XwzhJGTY1VubZbenOb9PnOKlY5DbX1ZFGbDaf3qXYFVZCWZ4/YoPf5vA21AhORrO8SLcOmWA6GL1nZYa2WN3lxWAtp6c4y5pzsb9nGiG3KzVCVYEtZ+U8Lor8qRGZxtW4EVjZnhpO6Wztw6ziZorxQ7xGmy4/jtcd9szs0UOhNjfu/18gH/gXiLlR3kerbh8tHbvG8+IHBWc5uB7eUnYQ/UHlWRndFn+nyKyMrsc3KopJdrOoILqlriC6XloQ2qwuCG4UGMTeqTHGdb3MmKzWK41BbRQbg1qtNz329u+M9Xe+Aw==", } @pytest.fixture @@ -33,8 +47,21 @@ def puzzlink_test_urls(): # shimaguni "shimaguni_1": "https://puzz.link/p?shimaguni/10/10/884aha5h85jshipgi17vjqfmudt1buhlti1ui2h34g3m", + # aqre + "aqre_1": "https://puzz.link/p?aqre/12/6/4i2914gi8944i000001vvg000057711373", + "aqre_2": "https://puzz.link/p?aqre/12/12/1aglaa552i01h0oic944i2954ig007vs000fvvv007o01vgfg3s00033332361g2g1g17416414", + # nonogram "nonogram_1": "https://puzz.link/p?nonogram/15/15/22l33l1221j231k251k11l9m311k272k2212j21511i12321i2112j21l22l3m12l222k1211j152k211113h21122i2112j21122i211113h252k1321j222k132k3", "nonogram_2": "https://puzz.link/p?nonogram/12/12/1k3k5k1k1k11j71j1k1k1k1k1k1k2kck2k1k2k1k1k1k1k1k1k", - "nonogram_3": "https://puzz.link/p?nonogram/15/11/55j111i13p55j1k55j1k121i5111h12j5k121i21111g1212h33113i111111h11113i11111i3331r111111h211211h22111i111111h211113h" + "nonogram_3": "https://puzz.link/p?nonogram/15/11/55j111i13p55j1k55j1k121i5111h12j5k121i21111g1212h33113i111111h11113i11111i3331r111111h211211h22111i111111h211113h", + "nonogram_4": "https://puzz.link/p?nonogram/13/14/3l33k412j252j124j44k411j4111i45kbl231j611j33k2l211j133j224j162j421j44k411j214j124j82k62k7l3l", + + # ayeheya + "ayeheya_1": "https://puzz.link/p?ayeheya/16/13/930930920920920cgg4gg4gg4gg4gg489489489000c0vo7g0000fvvs00007o000001vvv0000000254l5h45g453g", + "ayeheya_2": "https://puzz.link/p?ayeheya/11/10/5i5ililkkkkhkh4h444400e003se0ov00e00vs00g2k2n", + + # stostone + "stostone_1": "https://puzz.link/p?stostone/6/6/g0iic2vn03ecg3255g", + "stostone_2": "https://puzz.link/p?stostone/12/10/0000000000000000002000007vu00fvvvv007vu0vvo0gccc6g" } \ No newline at end of file From 69e2cb3022c2a52adb0ed7a70618831fc90fef8e Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 19 Mar 2026 02:30:14 +0800 Subject: [PATCH 09/17] sync for convert --- src/puzzlekit/formats/base.py | 54 +++++-- src/puzzlekit/formats/debug.py | 4 +- src/puzzlekit/formats/penpa_converter.py | 149 +++++--------------- src/puzzlekit/formats/penpa_template.py | 12 +- src/puzzlekit/formats/puzzlink_converter.py | 139 +++++++++++++++++- src/puzzlekit/formats/utils.py | 95 +++++++++++++ tests/formats/conftest.py | 46 +++++- 7 files changed, 363 insertions(+), 136 deletions(-) diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py index 1ab72560..c9ccb03b 100644 --- a/src/puzzlekit/formats/base.py +++ b/src/puzzlekit/formats/base.py @@ -15,6 +15,8 @@ class NumberColor(Enum): """ BLACK: int = 1 GREEN: int = 2 + CIRCLE_BLACK: int = 6 + WHITE_ON_BLACK: int = 7 # ... etc class SurfaceColor(Enum): @@ -75,20 +77,6 @@ class SurfaceColor(Enum): PENPA_PU_X_DEFAULT = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, PENPA_PU_X_STR)) -@dataclass -class CellState: - """ - Cell status - """ - value: Optional[str] = None # Number clue - shaded: bool = False # black? - # num_color: int = 1 # number color - num_color: Optional[NumberColor] = None # number color - num_style: str = "1" # number style - - surf_color: Optional[SurfaceColor] = None - - @dataclass class EdgeState: """Edge Status @@ -102,6 +90,41 @@ class EdgeState: edge_type: int = 2 # default = 2 # blacked: bool = False # black border # deleted: bool = False # delete mark + +@dataclass +class SymbolState: + """_summary_ + """ + symbol_index: int = 0 + symbol_type: str = "circle_L" + symbol_style: int = 1 + + def to_dict(self) -> dict: + """Convert to dict. + + Returns: + dict: dict of symbol_index, symbol_type, symbol_style + """ + return { + "symbol_index": self.symbol_index, + "symbol_type": self.symbol_type, + "symbol_style": self.symbol_style + } + +@dataclass +class CellState: + """ + Cell status + """ + value: Optional[str] = None # Number clue + shaded: bool = False # black? + # num_color: int = 1 # number color + num_color: Optional[NumberColor] = None # number color + num_style: str = "1" # number style + + surf_color: Optional[SurfaceColor] = None + symbol: Optional[SymbolState] = None + @dataclass @@ -162,7 +185,8 @@ def normalize(self) -> dict: "shaded": state.shaded, "num_color": state.num_color.value if state.num_color is not None else None, "num_style": state.num_style, - "surf_color": state.surf_color.value if state.surf_color is not None else None + "surf_color": state.surf_color.value if state.surf_color is not None else None, + "symbol": state.symbol.to_dict() if state.symbol is not None else None } # 2. norm edges - after sort diff --git a/src/puzzlekit/formats/debug.py b/src/puzzlekit/formats/debug.py index dc6e0fa5..b32088a4 100644 --- a/src/puzzlekit/formats/debug.py +++ b/src/puzzlekit/formats/debug.py @@ -351,8 +351,8 @@ def _compare_edges_detail(edges1: Dict, edges2: Dict) -> List[str]: if __name__ == "__main__": # 测试 URL - PUZZLINK_URL = "https://puzz.link/p?stostone/10/14/0001ail18seopri14284g90i10006co37saag11g280000000000g44gch" - PENPA_URL = "m=edit&p=7VdrT+M4FP3Or1j561jbOM7DiTRalddICFhYYFhaVSi0oQ2kTSdJAQXx3+fYudk2bZnVaLUSH6YP9+Tcm2NfX/s6Lb4tojzmwuLC4VJx/OLtCMVd3+KSvs37MinTOPyNdxflJMsBJmU5L8JOZ76oelXv9zSZPXbmfxRlhs8s7girI5yOZVkiSlKhijib54lwbOWMAysRMHjDTPpFFI2FGNsKBL3GjjMeTjj/8/CQ30dpEfOjm4fd/cfu80H3747bk/Lq9P7Tw/751cPo+qs4t5JObp2manZytr+bfvpS9U4m3af4IPbOimw4SeNoFFW966OXdHaoxpN7sXc02VP30cwqvqnL4Gn3/PPnnT7FOdh5rYKw6vLqS9hngnFm4yvYgFfn4Wt1ElYXvLqAiXEx4Gy6SMtkmKVZzhquOq5vtAEPlvDa2DXaq0lhAZ8SBrwBHCb5MI1vj2vmLOxXl5zpvnfN3RqyafYU68702PT1MJveJZq4i0qkqJgkc8YlDMVilD0uyFUM3njV/bkIINJEoGEdgUZbItCB/b8RBIO3NyTnL8RwG/Z1OFdLqJbwInxFexq+Ms/CrQ7WtMkf85zWpbAkroVNBO4R5s4b0x6a1jbtJYR5JU27b1rLtK5pj43PAfqzA59LIVhoY9UEATB6AJaWAHYJS2C/xgK8TbwAbze8CxwQhqasNWHn0mkw9B3Sx+aVrk0YvEu8gy3sImqDscddRRj6Luk7Hpd6ojR2MQaPxuDC3yN/F/o+6bvQ90nfw3gUjceDjyIfDz6KfHzEqChG3wb2CKMvRX0p+ATko3zuWKQZuMD1OMGhNNU+4Lhj1/rggMnHltyRtSY47tD8gOOOW2uCAyYfF5oeabrw8Zvc+dwO6tjxC0xzpfNoUYwWYtEryGCda5pDlNNmDZjcCZpDlFUpSMfW+aV5sDH/Td51fm3yt+HfrAGda0n6EvrNetB5l00ewTdrAzEi38tcO9QX4v1nnXjw95rc6ZySvin7xCudL4pRQcfkDov92iz5PdM6pvXMVvD1DvypPfrfd92/DqePGdMHW/vt/uIGO312scjvo2HMcOyxIktvi/r6Nn6JhiUL6+N31dLiZovpXYxzY4VKs2yOR4FtCo2pRSbjWZbHW02ajEfj96S0aYvUXZaP1sb0HKVpOxbz5NOi6nOrRZU5DqWV6yjPs+cWM43KSYtYOcBaSvFsbTLLqD3E6DFa6226nI63HfbCzLcvOc6rX88oH/oZRSfK+mhV8KMNx6zxLP9BwVka1+ktZQfsDyrPinUb/06RWbGu8xsVRQ92s6iA3VJXwK6XFlCb1QXkRoEB906N0arrZUaPar3S6K42io3uarXe9FnzP44Ndr4D" + PUZZLINK_URL = "https://puzz.link/p?nurikabe/10/10/j2m3i2i2h.j6t4k..k3t6j.h4i4i2m2j" + PENPA_URL = "m=edit&p=7VZrb+o4EP3Or1j5ay2Ik5AbIlUVz0pVy5YtXbYghAKYJpDE3DzaKoj/3rEDJQ5ppd2VVl3pKniYOWPPw8YnRD8TO6SYKPyjmRi+4dGJKYZqGmIoh2foxh61fsPNJHZYCIoTx9vIqtW2STpOx1XPDTa17VWQhO7GntMaUfhnrfqaq7qqU10bsb6pVjdabKyrju7qruqra4x/7/XwyvYiim+e1q3Opvnabf5Vq4817bG/ulh3Bo/r5ehPMlDcWqj0PTO4u++0vIvrdHznNF9olxr3EVs4HrWXdjoe3bx5Qc98dlakfeO0zZUdKNFPc9h4aQ0uLyuTQzfTyi5tWGkTp9fWBBGEkQqDoClOB9YuvbPQgvlzF+H0AfwIkylGfuLF7oJ5LERHLL3NVqugdk/qSPi51s5AooDeP+igPoG6cMOFR2e3GXJvTdIhRryAlljNVeSzF8qT8QK5nRUFwNyO4TQix90irIEjSpZskxymkukep81/0AZEOrbB1awNrpW0wbv79214W1bSQGO638MB/QEtzKwJ7+bxpJon9cHagexbO6TpsFSFX684Q6QbYGofZl2RvHVTMg3C015BA0eArzY+TLMBpv5hNuqF6UTh4WVEzk9UHuIUkYhypRXaDykJ0XmWvM0jnGomouijDZtAxFY8CdkTUhVyCDuFU03IjpCKkHUhb8WcrpAjIdtC6kIaYs4Pvtd/6zT+g3ImWkZW8lP//2HTygQ9JOHKXlC4HG3mb1nkxhQBQaGIebMo883om72IkZURZd4jYUHizync6xzkMbYFai6LcHRJoPscsJCWujhIl8+fheKuklBzFi4LNb3anif3It5EEpTxigTFIZBGzrbDkL1KiG/HjgTkeFKKRIPCZsa2XKK9sQvZ/NN27CvoDYkBlxwu4a+3ybd/m/DDUr4bi323csTvnIVfkM7JWYRLqAfQL9gn5y3DPyGanLeIn7EKL/acWAAt4RZAi/QC0DnDAHhGMoB9wjM8apFqeFVFtuGpzgiHp8pzzgQd/1ujaeUd" plc = Plc() ppc = Ppc() ir1 = plc.decode(PUZZLINK_URL) diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index 45811016..7bba4648 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -1,6 +1,6 @@ from puzzlekit.formats.base import ( PuzzleInstance, CellState, EdgeState, - COMPRESS_SUB, NumberColor, SurfaceColor + COMPRESS_SUB, NumberColor, SurfaceColor, SymbolState ) from puzzlekit.formats.penpa_template import ( PENPA_FIXED_FIELDS as fixed, @@ -8,7 +8,9 @@ get_penpa_template, penpa_str_to_dict ) -from puzzlekit.formats.utils import generate_centerlist_diff +from puzzlekit.formats.utils import ( + calculate_center_n +) from typing import Any, Dict, List, Optional, Tuple, Union import json import ast @@ -35,99 +37,6 @@ def to_penpa_str(pu_x: Optional[Dict | List], apply_compression : bool = True): else: return json.dumps(pu_x, separators=(',', ':'), ensure_ascii=False) -def calculate_center_n(nx: int, ny: int, size: int = 38) -> int: - """ - Simulate search_center() logic of penpa+ - return center_n (point index) - """ - nx0, ny0 = nx + 4, ny + 4 # internal grid size - - # 1. centerlist (visible cell centers, type=0) - centerlist = [i + j * nx0 for j in range(2, ny0 - 2) for i in range(2, nx0 - 2)] - - # 2. Geometry center(based on cell center pixel coords) - coords = [((idx % nx0 + 0.5) * size, (idx // nx0 + 0.5) * size) for idx in centerlist] - xmin, xmax = min(c[0] for c in coords), max(c[0] for c in coords) - ymin, ymax = min(c[1] for c in coords), max(c[1] for c in coords) - geo_center = ((xmin + xmax) / 2, (ymin + ymax) / 2) - - # 3. search all point for nearest - min_dist = float('inf') - closest_idx = 0 - base = nx0 * ny0 # points per type - - # Type 0: Cell Centers - for j in range(ny0): - for i in range(nx0): - k = i + j * nx0 - x, y = (i + 0.5) * size, (j + 0.5) * size - dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 - if dist < min_dist: - min_dist, closest_idx = dist, k - - # Type 1: Vertices - for j in range(ny0): - for i in range(nx0): - k = base + i + j * nx0 - x = (i + 0.5) * size + 0.5 * size - y = (j + 0.5) * size + 0.5 * size - dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 - if dist < min_dist: - min_dist, closest_idx = dist, k - - # Type 2: H-Edge Mids (y direction offset) - for j in range(ny0): - for i in range(nx0): - k = 2*base + i + j * nx0 - x = (i + 0.5) * size - y = (j + 0.5) * size + 0.5 * size - dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 - if dist < min_dist: - min_dist, closest_idx = dist, k - - # Type 3: V-Edge Mids (x direction offset) - for j in range(ny0): - for i in range(nx0): - k = 3*base + i + j * nx0 - x = (i + 0.5) * size + 0.5 * size - y = (j + 0.5) * size - dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 - if dist < min_dist: - min_dist, closest_idx = dist, k - - # Type 4,5 omit - offsets_4 = [(-0.25, -0.25), (0.25, -0.25), (-0.25, 0.25), (0.25, 0.25)] - for j in range(ny0): - for i in range(nx0): - base_k = 4*base + 4*(i + j * nx0) - cx = (i + 0.5) * size - cy = (j + 0.5) * size - for subidx, (ox, oy) in enumerate(offsets_4): - k = base_k + subidx - x = cx + ox * size - y = cy + oy * size - dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 - if dist < min_dist: - min_dist, closest_idx = dist, k - - # ========== Type 5: Compass Points (r=0.3, 4 per cell) ========== - # Order: N, E, W, S (up, right, left, down) - offsets_5 = [(0, -0.3), (0.3, 0), (-0.3, 0), (0, 0.3)] - for j in range(ny0): - for i in range(nx0): - base_k = 8*base + 4*(i + j * nx0) - cx = (i + 0.5) * size - cy = (j + 0.5) * size - for subidx, (ox, oy) in enumerate(offsets_5): - k = base_k + subidx - x = cx + ox * size - y = cy + oy * size - dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 - if dist < min_dist: - min_dist, closest_idx = dist, k - - return closest_idx - class PenpaConverter: """Convert Penpa to PuzzleInstance @@ -153,7 +62,7 @@ def index_to_coord(self, index: int, type_: str = 'edge') -> Tuple[Tuple[int, in return (index // self.real_cols - 2, index % self.real_cols - 2), category def coord_to_index(self, coord: Tuple[int, int] ,type_: str) -> Tuple[int, int]: - assert type_ in ("edge", "cell"), f"Wrong index type for index_to_coord, expected 'cell', 'edge', get {type}" + assert type_ in ("edge", "cell"), f"Wrong index type for index_to_coord, expected 'cell', 'edge', get {type_}" r_, c_ = coord if type_ == "edge": return (r_ + 1) * self.real_cols + (c_ + 1) + self.real_cols * self.real_rows @@ -175,7 +84,7 @@ def decode(self, url: str) -> PuzzleInstance: self.parts = decompress(b64decode(self.url[len(PENPA_PREFIX) :]), -15).decode().split("\n") header = self.parts[0].split(",") - assert header[0] in ("square", "sudoku", "kakuro"), "Penpa puzzle must be in square, sudoku, kakuro" + assert header[0] in ("square", "sudoku", "kakuro"), f"Penpa puzzle must be in square, sudoku, kakuro, get {header[0]}" # info collect self.ir_puzzle.grid_type = "square" @@ -197,7 +106,6 @@ def decode(self, url: str) -> PuzzleInstance: self.ir_puzzle.rows, self.ir_puzzle.cols = self.new_rows, self.new_cols - # print(f"Puzzle shape (r, c) = {(self.new_rows, self.new_cols)}", ) self._display_parts() for p in range(len(self.parts)): if p == 1: @@ -214,6 +122,8 @@ def decode(self, url: str) -> PuzzleInstance: self._decode_number(number_dict = v) elif k == "surface": self._decode_surface(surface_dict = v) + elif k == "symbol": + self._decode_symbol(symbol_dict = v) else: pass elif p == 5: @@ -223,11 +133,25 @@ def decode(self, url: str) -> PuzzleInstance: elif p == 17: genre_tag = ast.literal_eval(self.parts[p]) self.ir_puzzle.puzzle_type = genre_tag[0] if len(genre_tag) > 0 else "" + print(self.ir_puzzle.puzzle_type) # else: # print(p, self.parts[p]) return self.ir_puzzle + def _decode_symbol(self, symbol_dict: Dict[str, List[Any]]): + for index, symbol_data in symbol_dict.items(): + (r, c), _ = self.index_to_coord(int(index), 'cell') + if not symbol_data: continue + if (r, c) not in self.ir_puzzle.cells: + self.ir_puzzle.cells[(r, c)] = CellState( + symbol = SymbolState(symbol_data[0],symbol_data[1],symbol_data[2]) + ) + else: + cell = self.ir_puzzle.cells[(r, c)] + cell.symbol = SymbolState(symbol_data[0],symbol_data[1],symbol_data[2]) + self.ir_puzzle.cells[(r, c)] = cell + def _decode_surface(self, surface_dict: Dict[str, int]): for index, num_data in surface_dict.items(): (r, c), _ = self.index_to_coord(int(index), 'cell') @@ -272,7 +196,15 @@ def _decode_edge(self, edge_dict: Dict[str, int]): new_edge_dict[(coord_1, coord_2)] = EdgeState(connected = True, edge_type = v_) return new_edge_dict - def _encode_surface(self, cell_dict: Dict[str, CellState]): + def _encode_symbol(self, symbol_dict: Dict[tuple[int, int], SymbolState]): + new_symbol_dict = dict() + for coords, v_ in symbol_dict.items(): + if v_.symbol: + index = f"{self.coord_to_index(coords, 'cell')}" + new_symbol_dict[str(index)] = [v_.symbol.symbol_index, v_.symbol.symbol_type, v_.symbol.symbol_style] + return new_symbol_dict + + def _encode_surface(self, cell_dict: Dict[tuple[int, int], CellState]): new_surface_dict = dict() for coords, v_ in cell_dict.items(): if v_.surf_color: @@ -302,28 +234,25 @@ def encode(self, inst: PuzzleInstance) -> str: hdr = fixed['header'] penpa_template = get_penpa_template(inst.puzzle_type) - # mtd = PenpaMetadata() center_n = calculate_center_n(inst.cols , inst.rows , hdr.size) - center_list = generate_centerlist_diff(inst.rows, inst.cols, inst.margins) self.real_rows = inst.rows + 4 # penpa size after padding self.real_cols = inst.cols + 4 # 1. form pu_q dict, then update original_pu_q = PENPA_PU_X_DEFAULT.copy() - # print(self.parts[3], "\n") # ==== augmented update ====== - # (number/edge/surface only: for now) + # (number/edge/surface/symbol only: for now) original_pu_q["surface"] = self._encode_surface(inst.cells) original_pu_q["number"] = self._encode_number(inst.cells) original_pu_q["lineE"] = self._encode_edge(inst.edges) + original_pu_q['symbol'] = self._encode_symbol(inst.cells) # 2. standard JSON serialization (compact mode) # 3. construct text_lines text_lines = [] - to_pack_elem = [ ",".join(map(str, [ inst.grid_type, inst.cols, inst.rows, hdr.size, hdr.theta, hdr.reflect[0], hdr.reflect[1], @@ -358,7 +287,7 @@ def encode(self, inst: PuzzleInstance) -> str: for i in range(19): text_lines.append(to_pack_elem[i]) - print(to_pack_elem[i]) + # print(to_pack_elem[i]) # else: text_lines.append(self.parts[i]) # 5. concatenate + compress + base64 @@ -371,7 +300,8 @@ def encode(self, inst: PuzzleInstance) -> str: if __name__ == "__main__": for test_url in [ - "m=edit&p=7VdrT+M4FP3Or1j561jbOM7DiTRalddICFhYYFhaVSi0oQ2kTSdJAQXx3+fYudk2bZnVaLUSH6YP9+Tcm2NfX/s6Lb4tojzmwuLC4VJx/OLtCMVd3+KSvs37MinTOPyNdxflJMsBJmU5L8JOZ76oelXv9zSZPXbmfxRlhs8s7girI5yOZVkiSlKhijib54lwbOWMAysRMHjDTPpFFI2FGNsKBL3GjjMeTjj/8/CQ30dpEfOjm4fd/cfu80H3747bk/Lq9P7Tw/751cPo+qs4t5JObp2manZytr+bfvpS9U4m3af4IPbOimw4SeNoFFW966OXdHaoxpN7sXc02VP30cwqvqnL4Gn3/PPnnT7FOdh5rYKw6vLqS9hngnFm4yvYgFfn4Wt1ElYXvLqAiXEx4Gy6SMtkmKVZzhquOq5vtAEPlvDa2DXaq0lhAZ8SBrwBHCb5MI1vj2vmLOxXl5zpvnfN3RqyafYU68702PT1MJveJZq4i0qkqJgkc8YlDMVilD0uyFUM3njV/bkIINJEoGEdgUZbItCB/b8RBIO3NyTnL8RwG/Z1OFdLqJbwInxFexq+Ms/CrQ7WtMkf85zWpbAkroVNBO4R5s4b0x6a1jbtJYR5JU27b1rLtK5pj43PAfqzA59LIVhoY9UEATB6AJaWAHYJS2C/xgK8TbwAbze8CxwQhqasNWHn0mkw9B3Sx+aVrk0YvEu8gy3sImqDscddRRj6Luk7Hpd6ojR2MQaPxuDC3yN/F/o+6bvQ90nfw3gUjceDjyIfDz6KfHzEqChG3wb2CKMvRX0p+ATko3zuWKQZuMD1OMGhNNU+4Lhj1/rggMnHltyRtSY47tD8gOOOW2uCAyYfF5oeabrw8Zvc+dwO6tjxC0xzpfNoUYwWYtEryGCda5pDlNNmDZjcCZpDlFUpSMfW+aV5sDH/Td51fm3yt+HfrAGda0n6EvrNetB5l00ewTdrAzEi38tcO9QX4v1nnXjw95rc6ZySvin7xCudL4pRQcfkDov92iz5PdM6pvXMVvD1DvypPfrfd92/DqePGdMHW/vt/uIGO312scjvo2HMcOyxIktvi/r6Nn6JhiUL6+N31dLiZovpXYxzY4VKs2yOR4FtCo2pRSbjWZbHW02ajEfj96S0aYvUXZaP1sb0HKVpOxbz5NOi6nOrRZU5DqWV6yjPs+cWM43KSYtYOcBaSvFsbTLLqD3E6DFa6226nI63HfbCzLcvOc6rX88oH/oZRSfK+mhV8KMNx6zxLP9BwVka1+ktZQfsDyrPinUb/06RWbGu8xsVRQ92s6iA3VJXwK6XFlCb1QXkRoEB906N0arrZUaPar3S6K42io3uarXe9FnzP44Ndr4D" + # "m=edit&p=7Zdbb+o4EMff+RQrvx5rEydckkhHK65Hqlq2bOmyJULIgGkCCebk0rJBfPeODRW5uOfhrFbqSkuUYfiNGc/E5m8Rf09pxDCpYwubFtYxgavR0rFZN7AJWNz65Rr7ScCcX3A7TTwegeMlyT52NG2fZtNs+mvg77ba/reQxl6qkbpmaaapi5fdtJvw1vS3YE2bgiX6RvdFzDB0jH8fDPCaBjHDN0+bTm/bfu23/9IaU9N8HK6/bHqjx81q8icZ6b4W6cPA2t3d9zrBl2/Z9M5rv7A+a97HfOkFjK5oNp3cHILdwHr21qR743WtNd3p8XdrbL90Rl+/1txLP7PaMbOdrI2zb46LDITlTdAMZyPnmN05aMnDhY9w9gBxhMkMozANEn/JAx6hd5bdgkcQNsDtX92JjAuve4ZEB3948cF9AnfpR8uAzW/P5N5xszFGooCO/LZwUchfmJgMviY/n4sCsKAJrEfs+XuETQjE6Ypv08tQMjvhrP0TbUCm9zaEe25DeIo2RHf/uA3YNeyg6MCenU6wQn9AD3PHFe08Xl3r6j44R7BDaYm0T84RmTakITBNvjRUN1S0aSppC6hRocq8LeVYW+St0oaSWipKdF2JichRqYIQUUYVGyKJAisfBjFFJVVcbyorqatHN0Tu6uiW8omQliI3rONArqYh7RgWG2emtD1pdWkb0t7KMX1pJ9J2pa1L25RjWmK7/PSG+pfKcUF0heSqr8Z/PzarueghjdZ0yeCH3+Xhnsd+whCIL4p5MI/PsTk70GWCnPMhkI8U2C4NFww0K4cCzvdCQhQZ3kMF6D/veMSUIQHZ6vmjVCKkSLXg0apU0ysNgmIv8pgtoPNeL6AkAkHMfaZRxF8LJKSJVwC5M6CQie1KDzOhxRLplpZmC6+P41RDByRv18SGWMT/T8rPflKK1dI/m7x9tnLkRufRD1TnGixjhfYA/YH85KIq/oHS5KJlXpEVUWxVWYAqxAVoWV8AVSUGYEVlgH0gNCJrWWtEVWW5EVNVFEdMlRcdF8Ffh79TNKu9AQ==" + "m=edit&p=7Zdbb+o4EMff+RQrvx5rEydckkhHK65Hqlq2bOmyJULIgGkCCebk0rJBfPeODRW5uOfhrFbqSkuUYfiNGc/E5m8Rf09pxDCpYwubFtYxgavR0rFZN7AJWNz65Rr7ScCcX3A7TTwegeMlyT52NG2fZtNs+mvg77ba/reQxl6qkbpmaaapi5fdtJvw1vS3YE2bgiX6RvdFzDB0jH8fDPCaBjHDN0+bTm/bfu23/9IaU9N8HK6/bHqjx81q8icZ6b4W6cPA2t3d9zrBl2/Z9M5rv7A+a97HfOkFjK5oNp3cHILdwHr21qR743WtNd3p8XdrbL90Rl+/1txLP7PaMbOdrI2zb46LDITlTdAMZyPnmN05aMnDhY9w9gBxhMkMozANEn/JAx6hd5bdgkcQNsDtX92JjAuve4ZEB3948cF9AnfpR8uAzW/P5N5xszFGooCO/LZwUchfmJgMviY/n4sCsKAJrEfs+XuETQjE6Ypv08tQMjvhrP0TbUCm9zaEe25DeIo2RHf/uA3YNeyg6MCenU6wQn9AD3PHFe08Xl3r6j44R7BDaYm0T84RmTakITBNvjRUN1S0aSppC6hRocq8LeVYW+St0oaSWipKdF2JichRqYIQUUYVGyKJAisfBjFFJVVcbyorqatHN0Tu6uiW8omQliI3rONArqYh7RgWG2emtD1pdWkb0t7KMX1pJ9J2pa1L25RjWmK7/PSG+pfKcUF0heSqr8Z/PzarueghjdZ0yeCH3+Xhnsd+whCIL4p5MI/PsTk70GWCnPMhkI8U2C4NFww0K4cCzvdCQhQZ3kMF6D/veMSUIQHZ6vmjVCKkSLXg0apU0ysNgmIv8pgtoPNeL6AkAkHMfaZRxF8LJKSJVwC5M6CQie1KDzOhxRLplpZmC6+P41RDByRv18SGWMT/T8rPflKK1dI/m7x9tnLkRufRD1TnGixjhfYA/YH85KIq/oHS5KJlXpEVUWxVWYAqxAVoWV8AVSUGYEVlgH0gNCJrWWtEVWW5EVNVFEdMlRcdF8Ffh79TNKu9AQ==" ]: hpc = PenpaConverter(dict()) tmp = hpc.decode(test_url) @@ -381,9 +311,4 @@ def encode(self, inst: PuzzleInstance) -> str: print(enc) # b = hpc.decode(enc) # print(enc) - - # print(a) - # print(b) - # print(enc) - # print(tmp.cells) - \ No newline at end of file + \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_template.py b/src/puzzlekit/formats/penpa_template.py index 2dc6fe97..a668c8fa 100644 --- a/src/puzzlekit/formats/penpa_template.py +++ b/src/puzzlekit/formats/penpa_template.py @@ -39,11 +39,18 @@ "genre_tags": ["ayeheya (ekawayeh)"] }, + "kurochute": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"], + "genre_tags": ["kurochute"] + }, + "default" : { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', "user_tab_setting": ["Surface"], "genre_tags": [] }, + } PUZZLE_TYPE_ALIASES = { @@ -51,7 +58,10 @@ "slither": "slitherlink", "slitherlink": "slitherlink", "vslither": "slitherlink", - + # kuroshute aliases + "kurochute": "kurochute", + "kuroshuto": "kurochute", + "kurochuto": "kurochute", # ==== "nonogram": "nonogram", # shimaguni diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index 02a5751b..d7bcf33a 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -9,8 +9,9 @@ import logging ALLOWED_PUZZLE_TYPE = { - "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", - "nonogram", "ayeheya" + "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", "ayeheya", + "nonogram", + "nurikabe", "kurochute", "kurodoko", "kurotto", "nurimisaki" } # allowed puzzle types @@ -97,6 +98,72 @@ def _decode_heyawake_variant(self): # pu.mode_set("surface"); //include redraw # UserSettings.tab_settings = ["Surface"]; + def _decode_nurikabe_variant(self): + """ + Decode nurikabe-type puzzles (number-only, no region borders in puzz.link format). + + Puzzle types and their differences: + ┌─────────────┬───────────┬────────────────────────────┐ + │ Type │ Style │ "?" handling │ + ├─────────────┼───────────┼────────────────────────────┤ + │ nurikabe │ BLACK (1) │ shown as "?" │ + │ kurochute │ BLACK (1) │ shown as "?" │ + │ kurodoko │ CIRCLE (6)│ hidden (treated as empty) │ + │ kurotto │ CIRCLE (6)│ hidden │ + │ nurimisaki │ CIRCLE (6)│ hidden │ + └─────────────┴───────────┴────────────────────────────┘ + """ + number_map = self._decode_number16() + + # JS: number_style = type !== "kurochute" && type !== "nurikabe" ? 6 : 1 + if self.puzzle_type in ["nurikabe", "kurochute"]: + num_color = NumberColor.BLACK # style = 1 + hide_question = False # "?" 显示为 "?" + else: + num_color = NumberColor.CIRCLE_BLACK # style = 6 + hide_question = True # "?" 隐藏(不放入 IR) + + cell_dict = {} + + for k, v in number_map.items(): + row_idx = k // self.num_cols + col_idx = k % self.num_cols + + # 越界保护(decode_number16 不限制上界) + if row_idx >= self.num_rows or col_idx >= self.num_cols: + continue + + # JS: number = hide_ques && value === "?" ? " " : value + # if v == '?' and hide_question: + # continue # 直接跳过,IR 中不存储 + if not hide_question: + cell_dict[(row_idx, col_idx)] = CellState( + value = str(v) , # "?" 原样保留(nurikabe/kurochute) + num_color= num_color, + num_style="1" + ) + else: + cell_dict[(row_idx, col_idx)] = CellState( + value = str(v) if str(v) != "?" else " " , # "?" 原样保留(nurikabe/kurochute) + num_color= num_color, + num_style="1" + ) + + # 填充 IR + self.ir_puzzle.puzzle_type = self.puzzle_type + self.ir_puzzle.title = self.puzzle_type + self.ir_puzzle.rows = self.num_rows + self.ir_puzzle.cols = self.num_cols + self.ir_puzzle.margins = [0, 0, 0, 0] + self.ir_puzzle.source = self.url + self.ir_puzzle.cells = cell_dict + self.ir_puzzle.edges = {} # 此类谜题无区域边线 + self.ir_puzzle.boxes = generate_centerlist_diff( + self.ir_puzzle.rows, + self.ir_puzzle.cols, + self.ir_puzzle.margins + ) + def _reindex_number(self, r: int, c: int, margins: List[int], grid: List[List[str]], skip: Set[str] = set(), color: int = 1, style: str = "1"): @@ -176,6 +243,8 @@ def decode(self, url: str) -> Dict[str, Any]: self._decode_heyawake_variant() elif self.puzzle_type in ['nonogram']: self._decode_nonogram_variant() + elif self.puzzle_type in ['kurochute', "kurodoko", "kurotto", "nurikabe", "nurimisaki"]: + self._decode_nurikabe_variant() elif self.puzzle_type in ["country", "detour", "juosan", "yajilin-regions", "yajirin-regions"]: # toichika2, nagenawa, maxi, factors are neglected. return self.ir_puzzle @@ -233,6 +302,9 @@ def encode(self, inst: PuzzleInstance) -> str: elif self.puzzle_type in ['nonogram']: body_str = self._encode_nonogram_variant(inst) return body_str + elif self.puzzle_type in ["nurikabe", "kurochute", "kurodoko", "kurotto", "nurimisaki"]: + body_str = self._encode_nurikabe_variant(inst) + return body_str else: raise NotImplementedError(f"Puzzle type {self.puzzle_type} not supported for encoding") @@ -312,6 +384,61 @@ def _encode_nonogram_variant(self, inst: PuzzleInstance): url = f"https://puzz.link/p?nonogram/{num_cols}/{num_rows}/{body_str}" print(url) return url + + def _encode_nurikabe_variant(self, inst: PuzzleInstance): + """ + Encode nurikabe-type PuzzleInstance to puzz.link URL. + + Encoding logic: + - No border data (unlike heyawake) + - Numbers encoded via _encode_number16 + - "?" kept for nurikabe/kurochute, hidden for others (not in IR, skipped) + + Args: + inst: PuzzleInstance with cells containing number clues. + + Returns: + str: puzz.link URL, e.g. "https://puzz.link/p?nurikabe/7/7/2o2o3n8j1k5h2k" + """ + top_m = inst.margins[0] + left_m = inst.margins[2] + + # Step 1: Build flat number_map {k: value} + # k = row_in_grid * num_cols + col_in_grid + number_map: Dict[int, Any] = {} + + for (r, c), cell_state in inst.cells.items(): + if not cell_state.value: + continue + + val = cell_state.value + # if not val: + # print + + r_grid = r - top_m + c_grid = c - left_m + + if not (0 <= r_grid < self.num_rows and 0 <= c_grid < self.num_cols): + continue + + k = r_grid * self.num_cols + c_grid + + # "?" is kept as '?'(_encode_value 会将其编码为 '.') + if val == '?' or val == " ": + number_map[k] = '?' + else: + try: + number_map[k] = int(val) + except ValueError: + number_map[k] = val + + # Step 2: Encode to base16 string + # max_k get the last cell with number to avoid redundent skip + max_k = max(number_map.keys()) if number_map else 0 + body_str = self._encode_number16(number_map, max_k) + + url = f"https://puzz.link/p?{inst.puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" + return url def _region_grid_to_borders(self, edges_dict: Dict[Any, List[EdgeState]]) -> Dict[int, int]: """ @@ -618,6 +745,7 @@ def _encode_value(self, val: Any) -> str: | 77776+ | $ | 5 字符 | '$00000' | | '?' | . | 1 字符 | '.' | """ + if val == '?': return '.' elif isinstance(val, int): @@ -938,11 +1066,16 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, from puzzlekit.formats.penpa_converter import PenpaConverter PzpCvtr = PuzzlinkConverter() url_list = [ - "https://puzz.link/p?stostone/10/14/0001ail18seopri14284g90i10006co37saag11g280000000000g44gch" + # "https://puzz.link/p?nurikabe/7/7/2o2o3n8j1k5h2k", + "https://puzz.link/p?nurikabe/10/10/j2m3i2i2h.j6t4k..k3t6j.h4i4i2m2j", + ] for url in url_list: p_ir = PzpCvtr.decode(url) + logger.info(p_ir) + logger.info(p_ir.cells) url_new = PzpCvtr.encode(p_ir) + logger.info(url_new) # penpa_cvter = PenpaConverter() diff --git a/src/puzzlekit/formats/utils.py b/src/puzzlekit/formats/utils.py index f0d025c7..c46d6f61 100644 --- a/src/puzzlekit/formats/utils.py +++ b/src/puzzlekit/formats/utils.py @@ -1,6 +1,9 @@ from typing import List, Tuple from puzzlekit.formats.base import EdgeState + +# ==================== PENPA UTILS ============================ + def auto_border_split(rr: int, rc: int, margins: List[int] = [0, 0, 0, 0], intervals = 5): """ Automatically split the grid into more readiable style via adding edges. @@ -80,6 +83,98 @@ def coord_to_index(rr: int, rc: int, coord: Tuple[int, int] ,type_: str) -> Tupl else: return r_ * rc + c_ + rc * 2 + 2 +def calculate_center_n(nx: int, ny: int, size: int = 38) -> int: + """ + Simulate search_center() logic of penpa+ + return center_n (point index) + """ + nx0, ny0 = nx + 4, ny + 4 # internal grid size + + # 1. centerlist (visible cell centers, type=0) + centerlist = [i + j * nx0 for j in range(2, ny0 - 2) for i in range(2, nx0 - 2)] + + # 2. Geometry center(based on cell center pixel coords) + coords = [((idx % nx0 + 0.5) * size, (idx // nx0 + 0.5) * size) for idx in centerlist] + xmin, xmax = min(c[0] for c in coords), max(c[0] for c in coords) + ymin, ymax = min(c[1] for c in coords), max(c[1] for c in coords) + geo_center = ((xmin + xmax) / 2, (ymin + ymax) / 2) + + # 3. search all point for nearest + min_dist = float('inf') + closest_idx = 0 + base = nx0 * ny0 # points per type + + # Type 0: Cell Centers + for j in range(ny0): + for i in range(nx0): + k = i + j * nx0 + x, y = (i + 0.5) * size, (j + 0.5) * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # Type 1: Vertices + for j in range(ny0): + for i in range(nx0): + k = base + i + j * nx0 + x = (i + 0.5) * size + 0.5 * size + y = (j + 0.5) * size + 0.5 * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # Type 2: H-Edge Mids (y direction offset) + for j in range(ny0): + for i in range(nx0): + k = 2*base + i + j * nx0 + x = (i + 0.5) * size + y = (j + 0.5) * size + 0.5 * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # Type 3: V-Edge Mids (x direction offset) + for j in range(ny0): + for i in range(nx0): + k = 3*base + i + j * nx0 + x = (i + 0.5) * size + 0.5 * size + y = (j + 0.5) * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # Type 4,5 omit + offsets_4 = [(-0.25, -0.25), (0.25, -0.25), (-0.25, 0.25), (0.25, 0.25)] + for j in range(ny0): + for i in range(nx0): + base_k = 4*base + 4*(i + j * nx0) + cx = (i + 0.5) * size + cy = (j + 0.5) * size + for subidx, (ox, oy) in enumerate(offsets_4): + k = base_k + subidx + x = cx + ox * size + y = cy + oy * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + # ========== Type 5: Compass Points (r=0.3, 4 per cell) ========== + # Order: N, E, W, S (up, right, left, down) + offsets_5 = [(0, -0.3), (0.3, 0), (-0.3, 0), (0, 0.3)] + for j in range(ny0): + for i in range(nx0): + base_k = 8*base + 4*(i + j * nx0) + cx = (i + 0.5) * size + cy = (j + 0.5) * size + for subidx, (ox, oy) in enumerate(offsets_5): + k = base_k + subidx + x = cx + ox * size + y = cy + oy * size + dist = (x - geo_center[0])**2 + (y - geo_center[1])**2 + if dist < min_dist: + min_dist, closest_idx = dist, k + + return closest_idx def generate_centerlist_diff(rows: int, cols: int, margins: List[int] = [0, 0, 0, 0]): """ diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py index 796b8225..ed1d305d 100644 --- a/tests/formats/conftest.py +++ b/tests/formats/conftest.py @@ -20,8 +20,7 @@ def penpa_test_urls(): # https://puzz.link/p?simpleloop/10/10/4h00008g0000g1000040 "simpleloop_2": "m=edit&p=7VZdb+I6EH3nV1R+rbXYBGiIVF3xWalq2bKly5YIoQCGpDiY5qNFRvz3jh0qkkCrq3ulVVdaJT4zOWPGM3F8RPgcOwHDlKjbMDFYuMrU1KNkVvUg+6vvRZxZZ7geR64IwHGjaB1axeI6lkM5/Ma91bK4/if0/DVnXIh1kRJ1E0IWJqE1sCUPgBJSxvh7p4PnDg8Zvn58arSW9dd2/VexMjSMh+78/KnVe3iaDX7SHvGKAelyc3V712rw8ys5vHXrL6zNqnehmLqcOTNHDgfXG77qmAt3TpvXbtOcOysSPpv92kujd3lZsPc9jApbWbNkHcsry0YlhPWgaIRlz9rKWwtNhT/xEJb3EEeYjjDyYx55U8FFgN45eQMeRbgEbvvgDnRcec2EpAT87t4H9xHcqRdMORvfJMydZcs+RqqAhv61cpEvXphaDH6mn5OigJg4EexB6HprhA0IhPFMLOP9VDraYVn/D21Apvc2lJu0obwTbaju/ncb8KWwzYkOaqPdDnboB/QwtmzVzsPBNQ/uvbVF5RqyyhhVEnNRTYypjUm1oSR5pLS8txeJLSezadUAC/m61haQanzU2NFY0tiHRbE0NLY0Eo0VjTd6TlvjQGNTY1ljVc+5UGX/y8Z+Wzm2kRz37FX587hRwUb3cTB3pgw+tKbw1yL0IobgsKNQ8HGYxMZs40wjZCWik45kuFXsTxickRSltEx9sicyvIcypLdYiUAr4FFIkWy2+CiVCp1INRHBLFfTq8N5thet5RkqOaMZKgrgAKaenSAQrxnGdyI3Q6Q0J5OJrXIvM3KyJTpLJ7eaf3gduwLaID1sA5fUJv5V5q+uzGq3yFeTsa9Wjv7QRfCJ6hyCefqE9gD7ifykoqf4D5QmFc3zR7Kiij1WFmBPiAuweX0B6lhigDxSGeA+EBqVNa81qqq83KiljhRHLZUWHRslf0/P1AtAo8Ib", # https://puzz.link/p?simpleloop/10/10/000g80190002i0001004 - "nurikabe_1": "m=edit&p=7Vdtb+I4EP7Orzj561pHnIQkRFqdKG1Xqtpue22vVxBCBgwJGJzmpa1c9b/v2KElDmHvTiet9qRT4mHmGWc8M46fiOyxoCnDxFK3E2D4hcslgR524Olhba/bOOcs/AX3ijwSKShRnidZ2G4nhRzIwa883qzayW+bIo1XdMLaxFJ31Fn5ucc7iR25ib2wH92Fv7SfnKW9wPjr6SmeU54xfPawPDpe9Z5Pen+2OwPHubucf1oeX98tZ/d/kGsrbqfWJQ82F1fHR/zTFzm4iHpP7IR5V5mYRpzRGZWD+7MXvjkNFtGc9M+ifjCnGyt7DG67T0fXnz+3httCRq1X2Q1lD8sv4RARhJENg6ARltfhq7wI0VSsJzHC8gb8CJMRRuuC5/FUcJGid0yel0/boJ7s1HvtV1q/BIkF+uVWB/UB1GmcTjkbn5fIVTiUtxipBI7000pFa/HE1GIqQWWXSQEwoTlsRBbFCcIOOLJiJlbFdioZvWHZ02XIm79ZAQR5r0CpZQVKa6hAFfbvK+CJaMi9O3p7g735HbIfh0NVyN1ODXbqTfgK8jJ8RY4Nj3bgndXbh5wATP/D9AiY3ofpm5MDH0z7w+xaYLofJrE6hptY5nRiO+Z8u2usTRzXnN9Rfmdne1U/lEN0UQ9anmppa3kLNWPpaHmspaVlR8tzPedEy3st+1q6Wnp6jq+69o/6+gPSGTol2ZiX2p3/GDZqDdFNkc7plMFr3hfrRGRxzhCwDMoEH2elb8xe6DRHYcl2VY+BbYr1hMEJrUBciASotSnCu8sA48VGpKzRpUA2WxwKpVwNoSYindVyeqacm7XoL4kBlQxhQHkKx79i0zQVzwaypnlkABWyMyKxTa2ZOTVTpCtaW229a8dbC70gPeBIwiH8/5PwM38S1D5ZPxuB/UU6Q2i128XyK0ZJMaZjaLPui8I9p4ZDBxXu+zUc2qHxoIa72zhuDe+U+N663oF1/QNxggPrdg/kCW/DD2+/PtIi/Q6/7px1uIFlAf0O0Va8TfgBTq146/gegapk9zkU0AYaBbTOpADtkymAe3wK2AFKVVHrrKqyqhOrWmqPW9VSVXodove/AWjU+gY=", - # https://puzz.link/p?nurikabe/10/10/h5k7t6l5p2h4p2g2q4g7j2v3j2g + "nonogram_1": "m=edit&p=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", "nonogram_2": "m=edit&p=7Vhdbxo5FH3Pr1j5tdYyHtvzgVStyFelqs0m22SzCULRhEwCYWDoAE01Uf57z7U9ZUxIV7t9yUMF2Odew5zje+9cDIvPq6zKuZBcKC4THnCBh5Yh13HAYx2ZV+Aep+NlkXd/473VclRWAKPlcr7odjrzVX1ZX/5ejGeTzvyPWTkr76ps2hGheU7kRE8EPcR9LO4Nco9wMsSL5raX8z8PD/ltVixy/v7ifnd/0ns46P3T0ZdSnh3dvrnfPzm7vzn/W5wE404VHBXJ7OPx/m7x5l19+XHU+5If5NHxohyOijy7yerL8/dfi9lhcje6FXvvR3vJbTYLFp+T0/TL7snbtzv90Ow7GOw81mm37vH6XbfPBOMsxEuwAa9Puo/1xy4bltPrMeP1J6wzLgacTVfFcjwsi7Jija/+YD8dAh6s4blZJ7RnnSIAPnIY8AJwOK6GRX71wXqOu/36lDMSsGs+TZBNyy85kZFAsq0oOK6zJfKzGI3njEssLFY35WTl3ioGT7zu/Y9t4ErNNgjabRDasg3a3c9vo5iXWzaQDp6ekKC/sIWrbp92c7aGyRp+6j5iPOo+MhXio1TPJodMSc/Uyjc1TNwGjRnB1Gsz9t+c+GbqmVEAM16bwl/1VUW+qshXFZGqtRnTlddmQqrC76YIzIdbjpCoW7b0dQuz6dZ67GsTib+xMKCotOzQFx8qX1/ohQ1ZESY3F2Y8NGNoxlOkjtfSjPtmDMyozfjBvOcAGZWh5pLSGnKGmUuKPGGdchlDHOE44jKFMMKp5EpAFDBmrkIIIhzGXClEi7BSXFGOCEeCqxhBIhwnXKUIEOFUcy0sL2auQ8uLmWtleTFzTdkkXhlAJ7jQWCE05pLyaoyI+izYjJEISAWdMVLwCfCRAcCVBKEx0I6VAqMxVAq5oDRGFHGVgNMYieQ6wGbJAIBipwAAPd0pAOBaOwUAEN0okIiudqQAUO1IAaDakQJwFTSkASIcOlIAqHakAFzRTWYMjSDHjhQAqh0pAFS7bQMgtk4BAFQ7BQBQ7RQA4PvJKZDgkcRj6gDRJRpTBwgusRAGiSISyiU4FHGYOkBkiYIwGBQxEAaBIgJTB9hh6mooRVQDW0OYodXyYkYdWF7MUGp5MUOo5ZXAXr1SmyAcoVZc/WEGdvUdUQJcPUXSlpDB2CP1BYORMSoGg1H3kdWGGdjtPQIvJdJguk8cL77gm1gphT3S/WArDLlSTSEqRIsq3BoIS1PWADCaLCoE2CtR3dSERk005QYAoylrVJjSTR1pKPCqhXqTNaDge+ZDBJRuPmugJmRT8LjltGzKWppDTGNQHTkegI1bQTa1h+rXsuGRqLCmkgFguJ0CbFQlNT1jKCigIjAGstd0A8zArlKQPe2yhxnYdQ9kT7vsYbY3pcGgc9nDTMcyi5E97bKHGdh1p5iObo4XlatjxxuD11QiWum5aah7ZlRmjEyjjekb9D99x/58T/9XOX3sbvsj+uWnx2Cnzz6tqttsmOMotVdO5+VivMwZjrNsURZXC7t2lX/NhkvWtcfq9ornm62m1zlOgS1XUZZzHO23XaFZ8pzju1lZ5VuXyJnf3L10KVracqnrsrrZ0PSQFYW/F/NLxnPZU6jnWlY4YrbsrKrKB88zzZYjz9E6VXtXymcbwVxmvsRskm2wTdfheNphX5l54dCJE9iv3x6v/rcHJSt4bd3xtckxdV5WP2g668VN95bWA+8Puk9rdZv/hUbTWt30P+sqJPZ5Y4F3S2+Bd7O9wPW8w8D5rMnA90KfoatuthpStdltiOpZwyGqds/ps+a/GTbY+QY=", "nonogram_3": "m=edit&p=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", @@ -33,6 +32,28 @@ def penpa_test_urls(): # stostone "stostone_1": "m=edit&p=7VZtb6pIFP7ur9jM106uMAPUkjQbX5s0rbeudt1qjEEchYpgebENxv/eMwMooO1Nd3OTftggJ888Z+a8zMCDwUtk+AxrcNEalrAMF9E0ccuKIm4pvQZ26DD9D1yPQsvzAVhhuAn0anUTxaN49MOx3VV182cQevBzWVWDaynZtkm2rkSZuaREVZcY/+x08MJwAoZvn54brVX9tV3/p6qOKH3sLi6eW73H5/nwb7kn2VVf6jo19/6h1XAubuLRvVXfsjbTHgLPtBxmzI14NLx9c9xObWkt5Oat1awtDFcKXmqDq22jd31dGafVTyq7+EqP6zi+0cdIRhgRuGU0wXFP38X3etzHcR9cCMsTjNaRE9qm53g+yrj4LllIALaPcCj8HDUTUpYAd1MM8Amgafumw6Z3CfOgj+MBRjx3Q6zmEK29LePJeG18bHrrmc2JmRHCxgeWvUGYgiOI5t4qSqfKkz2O61/rAIJkHXCYdMDRmQ54Y7+3g6vJfg+H8xf0MNXHvJ3HI6wdYV/fge3qO0QoLKXwpIrzQ5TAkByGCveqh6HGvdkQAsgizJOwHWGJsAPIgmMqbEtYSVhV2Dsxpw3JZRlSEQhKICIFrGQYkihqghXg1ZRXgFdTXoWqNahPYHi7NCXBGsy5TOcQeAez+ITnSucTWEvS+QTWkmw+rCVaiuGtJZfH2mi6lsJamq6lsJZm9UCurE6V15yr7VAzrz+Nr0J8NY2v8V6y+nkvfD5s0lBsVVNYRVhNbOElP8YvHfR/P61fljPmO3y4+GPyL/GkMkb9yF8YJkOgNSjwnGmQjKfszTBDpCeal/cUODdazxi8rDnK8bwNqOq5CJmrQNpL1/PZWRcn2Xz5USjuOhNq5vnzUk2vhuMUexGfkAKViEWBCn1QgtzY8H3vtcCsjdAqEDnVKERibmkzQ6NYorEyStnWx+3YV9AbEjdICBz+/x+Gb/1h4AclfTfV+G7liGfc8z8RnKOzTJ+RHWA/UZ6c9xz/gcjkvGX+RFF4saeiAuwZXQG2LC1AnaoLkCcCA9wHGsOjlmWGV1VWGp7qRGx4qrzejFH2lxhNKu8=", "stostone_2": "m=edit&p=7Zdtb+I4FIW/91es/HWsJbZJcCKNVvRtpKrTbbftdluEqjSElzaQThJolar/fY7tW0GAzmq0WqlaLRD75Jqc55qYayi/zeMi5UJy4XGluccFnu2wzdtCcyU79vDoeTGpsjT6hXfn1TgvIMZV9VhGrdbjvL6pb37NJrOH1uNvZZXjNUtbQraE1/I2HtK2ncXc84YLPJxeLHJvlCRJMOL898NDPoyzMuVH1/e7+w/dp4PuXy3/RqnLk+Gn+/2zy/vB1Z/izJu0Cu8k07Ovp/u72acv9c3XcXeRHqTBaZkn4yyNB3F9c3X0nM0O9Wg8FHtH4z09jGde+U1fhIvds8+fd3o0v/7OSx1GdZfXX6IeE4wziUOwPq/Popf6a1Sf8/ocQ4yLPmfTeVZNkjzLC/YWq4/dhRLyYCmv7LhRey4oPOgT0pDXkMmkSLL09thFTqNefcGZYe/aq41k03yRGpjJzZwn+fRuYgJ3cYVbU44nj4wrDJTzQf4wp7eK/iuvuz83A5i8zcBINwOjtszATOzfnUHYf33FzfkDc7iNemY6l0upl/I8ekF7Er2wIMClZlm7G8hC3TwXor0WaJsrAjqHi7Be17Y9tK207QVQvFa23betZ1vftsf2PQfIQHV8rkLBIol11FFcdkC0ug3tk/ahAbY6gO6Q7kAjZas1dEg65FJ7TmsPmvy1gJakJbQiDa4mrgZXO67ykJvnuOihHRc9tOOih3Zc9FwJx0UP7bjooR0XPbTjood2XPTQxBXgCuIKcAVxJXwk+Uj4SPKR8JHkI+EjyUfCR5KPhI988zEFi/KXpoBR/hL5K8pfIX9F+StwFXEVuIq4Prg+cX1wfeL64PrE9cH1ieuD6xPXB9cnrg9uQNwA3IC4AbgBcQNwA+IG4AbEDcANiGvWEq0T9NDExTpRtE7QQxMX60TROkEPTVwNH00+Gj6afDR8NPlo+Gjy0fAJySeEj13PWOBXdpnv2bZt28Au/475Hv7UN/Wff9P+Np2eatttbfPp/zfj/Z0eO58XwzhJGTY1VubZbenOb9PnOKlY5DbX1ZFGbDaf3qXYFVZCWZ4/YoPf5vA21AhORrO8SLcOmWA6GL1nZYa2WN3lxWAtp6c4y5pzsb9nGiG3KzVCVYEtZ+U8Lor8qRGZxtW4EVjZnhpO6Wztw6ziZorxQ7xGmy4/jtcd9szs0UOhNjfu/18gH/gXiLlR3kerbh8tHbvG8+IHBWc5uB7eUnYQ/UHlWRndFn+nyKyMrsc3KopJdrOoILqlriC6XloQ2qwuCG4UGMTeqTHGdb3MmKzWK41BbRQbg1qtNz329u+M9Xe+Aw==", + + # kurochuto + "kurochute_1": "m=edit&p=7Vdtb+JGEP6eX1Ht11sVr9dvWDpVhCQnRUkuaZKmwULIAYMNC+b8kkQb5b/fzBJirzHX9iqdrlKFGM08M563Nc+K/EsZZhFlFvUo96hBGXxs16DcMikHGL/G2+cmKUTk/0J7ZRGnGShxUaxzv9NZl3IgB7+KZLXorH9blFk6jssi6jCr43VY4gpuzszYmrG5EzOemHOWmJYwF9xe2MJyEmvOE9uNzbkDYc7M4gIeovTzyQmdhiKP6On9/PBo0Xs67v3ZsQec315MP8yPrm7nk7s/2JWRdDLjQnir88ujQ/Hhkxycx73H6DhyLnPoREThJJSDu9NnsTrxZvGU9U/jvjcNV0b+xbvpPh5effx4ELxNOTx4kV1f9qj85AeEEUpM+DIypPLKf5HnPhmny4eEUHkNfkLZkJJlKYpknIo0I1tMnm2eNkE9rtQ75UetvwGZAfrFmw7qPajjJBuLaHS2QS79QN5Qgg0cqqdRJcv0McJi2CDam6YAeAgLOKU8TtaEcnDk5SRdlG+hbPhKZe87xoBM2zFQ3YyBWssYON2/H0Os05YBusPXVzig32GEkR/gNLeV6lXqtf8C8sJ/IdyDR/GtVmdILBNMtzK7YPJ30zbANCvT0U0Mtt5Nh2mZHQx2KhODK69raYVc7KrK7HEt2HM1bxd7rup2MXPlZYatpWYMh7BrNjZWs00sVqVjJvZSNc64Xo5x7KaW38K5a/kszF+tlKmd1vqzcfRa/sZWmVprze/gPLX6arG1+q6+LOZif9v6cOpMnf29kidKmkrewKtBJVfySElDSVvJMxVzrOSdkn0lLSUdFePiy/WPXr8f0E4AvI3baP/gOf3HfcODgFyX2TQcR8AS/XS5TvOkiAgwNclTMco3vlH0HI4L4m9ujLpHw1bl8iECgqtBIk3XcHe1Zdi6NDCZrdIsanUhGE1m+1KhqyXVQ5pNGj09hULos6ibWoM2BKtBRQbsWbPDLEufNGQZFrEG1C4MLVO0aiyzCPUWw0XYqLas1vF6QJ6J+sJPF37s/1+rP/21iodl/Gzs9hftBPKadm0qP1OyLkfhCNas9qJwZw/u7sGbeWDjgbzfg7fk38a34i11t/F/F4dja82/xZt98uEPPy1FA2n2DU6unE24hZkB/QY517xt+B4ernmb+A7pYrO7vAtoC/UC2mRfgHYJGMAdDgZsDw1j1iYTY1dNMsZSO3yMpeqUHJD3/2ZkePAV", + + # nurikabe + "nurikabe_1": "m=edit&p=7Vdtb+I4EP7Orzj561pHnIQkRFqdKG1Xqtpue22vVxBCBgwJGJzmpa1c9b/v2KElDmHvTiet9qRT4mHmGWc8M46fiOyxoCnDxFK3E2D4hcslgR524Olhba/bOOcs/AX3ijwSKShRnidZ2G4nhRzIwa883qzayW+bIo1XdMLaxFJ31Fn5ucc7iR25ib2wH92Fv7SfnKW9wPjr6SmeU54xfPawPDpe9Z5Pen+2OwPHubucf1oeX98tZ/d/kGsrbqfWJQ82F1fHR/zTFzm4iHpP7IR5V5mYRpzRGZWD+7MXvjkNFtGc9M+ifjCnGyt7DG67T0fXnz+3httCRq1X2Q1lD8sv4RARhJENg6ARltfhq7wI0VSsJzHC8gb8CJMRRuuC5/FUcJGid0yel0/boJ7s1HvtV1q/BIkF+uVWB/UB1GmcTjkbn5fIVTiUtxipBI7000pFa/HE1GIqQWWXSQEwoTlsRBbFCcIOOLJiJlbFdioZvWHZ02XIm79ZAQR5r0CpZQVKa6hAFfbvK+CJaMi9O3p7g735HbIfh0NVyN1ODXbqTfgK8jJ8RY4Nj3bgndXbh5wATP/D9AiY3ofpm5MDH0z7w+xaYLofJrE6hptY5nRiO+Z8u2usTRzXnN9Rfmdne1U/lEN0UQ9anmppa3kLNWPpaHmspaVlR8tzPedEy3st+1q6Wnp6jq+69o/6+gPSGTol2ZiX2p3/GDZqDdFNkc7plMFr3hfrRGRxzhCwDMoEH2elb8xe6DRHYcl2VY+BbYr1hMEJrUBciASotSnCu8sA48VGpKzRpUA2WxwKpVwNoSYindVyeqacm7XoL4kBlQxhQHkKx79i0zQVzwaypnlkABWyMyKxTa2ZOTVTpCtaW229a8dbC70gPeBIwiH8/5PwM38S1D5ZPxuB/UU6Q2i128XyK0ZJMaZjaLPui8I9p4ZDBxXu+zUc2qHxoIa72zhuDe+U+N663oF1/QNxggPrdg/kCW/DD2+/PtIi/Q6/7px1uIFlAf0O0Va8TfgBTq146/gegapk9zkU0AYaBbTOpADtkymAe3wK2AFKVVHrrKqyqhOrWmqPW9VSVXodove/AWjU+gY=", + "nurikabe_2": "m=edit&p=7VZrb+o4EP3Or1j5ay2Ik5AbIlUVz0pVy5YtXbYghAKYJpDE3DzaKoj/3rEDJQ5ppd2VVl3pKniYOWPPw8YnRD8TO6SYKPyjmRi+4dGJKYZqGmIoh2foxh61fsPNJHZYCIoTx9vIqtW2STpOx1XPDTa17VWQhO7GntMaUfhnrfqaq7qqU10bsb6pVjdabKyrju7qruqra4x/7/XwyvYiim+e1q3Opvnabf5Vq4817bG/ulh3Bo/r5ehPMlDcWqj0PTO4u++0vIvrdHznNF9olxr3EVs4HrWXdjoe3bx5Qc98dlakfeO0zZUdKNFPc9h4aQ0uLyuTQzfTyi5tWGkTp9fWBBGEkQqDoClOB9YuvbPQgvlzF+H0AfwIkylGfuLF7oJ5LERHLL3NVqugdk/qSPi51s5AooDeP+igPoG6cMOFR2e3GXJvTdIhRryAlljNVeSzF8qT8QK5nRUFwNyO4TQix90irIEjSpZskxymkukep81/0AZEOrbB1awNrpW0wbv79214W1bSQGO638MB/QEtzKwJ7+bxpJon9cHagexbO6TpsFSFX684Q6QbYGofZl2RvHVTMg3C015BA0eArzY+TLMBpv5hNuqF6UTh4WVEzk9UHuIUkYhypRXaDykJ0XmWvM0jnGomouijDZtAxFY8CdkTUhVyCDuFU03IjpCKkHUhb8WcrpAjIdtC6kIaYs4Pvtd/6zT+g3ImWkZW8lP//2HTygQ9JOHKXlC4HG3mb1nkxhQBQaGIebMo883om72IkZURZd4jYUHizync6xzkMbYFai6LcHRJoPscsJCWujhIl8+fheKuklBzFi4LNb3anif3It5EEpTxigTFIZBGzrbDkL1KiG/HjgTkeFKKRIPCZsa2XKK9sQvZ/NN27CvoDYkBlxwu4a+3ybd/m/DDUr4bi323csTvnIVfkM7JWYRLqAfQL9gn5y3DPyGanLeIn7EKL/acWAAt4RZAi/QC0DnDAHhGMoB9wjM8apFqeFVFtuGpzgiHp8pzzgQd/1ujaeUd", + + # kurotto + "kurotto_1": "m=edit&p=7Vdtb+I4EP7Or1j561ohjpNsiLQ6AW1XqtpeudLjCkJVCiEJJJjNS1sF8d93xqHKC+lKdyedetIqZJh5xh4/Y4cnIvmeObFLmYofblH4hktnlrw1y5S3erzGQRq69ifaz1JfxOD4abpL7G53l+XTfKqEwXbT3f22yWKRpqLLVPz4urbWI8VjvqIomqd4ijACpsVMCXTBPOZxhXMfEpGxZtyn9PeLC7pywsSllw/rwdmm/3Le/6trTDm/v1l9Xp+N7tfLyZ9spAbdWL0Jre317dkg/Pwtn177/Wf33DVvE7HwQ9dZOvl0cvkabi8sz1+x4aU/tFbOVk2+W+Pe82D09Wtnduxt3tnnPTvv0/ybPSOMUKLBzcic5iN7n1/bZCGip4DQ/A7yhLI5JVEWpsFChCImb1h+VczWwD0v3YnMozcsQKaCf3P0wX0AdxHEi9B9vCqQW3uWjylBAgM5G10SiWcXF0OCGBekAHhyUjibxA92hHJIJNlSbLLjUDY/0Lz/D9qASm9toFu0gV5LG9jdv28j3ImWBnrzwwEO6A9o4dGeYTf3pWuV7p29B3tj7wnXYKpOzeIMCecQamVo1bKGiut8IiWAs1kZ9hp5sznBZE0AK5QLmnozbzYAC0saZYgMSwYWMijLMRWnl2mmfmmUY8yotcg0rFCZwZFxNcYd4pW4yZhxrFgdgRwqsV7fY6bj+FoFA1mWPTK5SRUOZpUDHCSTx/kg7YW0mrRjOG2ac2nPpFWlNaS9kmPOpZ1IO5RWl9aUY77g8/K3nqj/gM6MF/Jbv4z/HzbvzMhdFq+chQs/8KGIdiIJUpeAyJJEhI9JkXt0X51FSuxC7KuZGrbNoicXtKkChULs4GXTVuEtVQMDbytitzWFoLv03iuFqZZSTyJeNji9OGFY70W+W2tQoY01KI1B+CqxE8fipYZETurXgIrW1yq528Zmpk6dorNxGqtF5XYcOuSVyBt+ghoe4q834gd/I+JhqR9NxT4aHfmci/gnolMmm3CL9AD6E/WpZNvwd4Smkm3iJ6qCZE+FBdAWbQG0KS8AnSoMgCciA9g7OoNVm1KDrJpqg0udCA4uVdWcGTn+WyDzzg8=", + "kurotto_2": "m=edit&p=7VZtb+JIDP7Or1jN1x1BJikhRKpOvHWlqu2VKz2uIIQChCRlyLB5aasg/vvaE0peSFfaO+nUk05JjP3Y47HH8ITwe2wFNmUK3ppB4ROuC2bIRzV0+SjHa+RF3Da/0E4cuSIAxY2iXWg2Grs4mSSTOvf8TWP32yYORBSJBlPwrvMW1xzm1B3dUX3mqi4Tums4mmsFil539WTiam69QenvV1d0bfHQptdPz93+pvM66PzVaE407fFu/fW5P3x8Xo3/ZEPFawTKHTf82/t+l3/9lkxu3c6LPbD1+1AsXW5bKyuZjK/fuH9lOO6a9a7dnrG2fCX8bozaL93h5WVtemxqVtsnbTPp0OSbOSWMUKLCw8iMJkNzn9yaZCm2C4/Q5AH8hLIZJduYR95ScBGQdyy5SVeroA4ydSz9qPVSkCmg3x11UJ9AXXrBktvzmxS5N6fJiBIsoCtXo0q24sXGzbBAtNOiAFhYEQwldL0doRo4wnglNvExlM0ONOn8jTYg03sbqKZtoFbRBnb3z9vgO1HRQHt2OMCA/oAW5uYUu3nMVCNTH8w9yDtzTzQFl34hVE+nSLQWAK2TeWGAqZ3MJoazzFRLq5sYrp9MHcPVk9nSCqtbetHbLnjbGJylYgrmMnI27p1VxpQmLlcyQMWAvF1KqGKt+eKZxooRemkLHbfIVsARMnmQT1JeSalKOYJzpokmZV9KRcqmlDcyZiDlWMqelBdS6jKmhZP6pVn+C+VMtZTxilfzv4fNalPyEAdra2nDT6sntjsRepFNgN5IKPg8TH1z+81aRsRMaTbvKWB+vF3YwAo5iAuxA36vyvDuKoCe44vArnQhaK+cj1KhqyLVQgSrUk2vFufFXuTrrAClrFSAogAoJ2dbQSBeC8jWitwCkGPZQibbLx1mZBVLtDZWabdtdhyHGnkj8oEfpYpD/P9d9MnfRTgs5bOx2GcrR37PRfAT0smcZbiCegD9CfvkvFX4B0ST85bxM1bBYs+JBdAKbgG0TC8AnTMMgGckA9gHPINZy1SDVZXZBrc6IxzcKs85U3L8g05mtR8=", + + # Nurimisaki + "nurimisaki_1": "m=edit&p=7VZtb9owEP7Or6j8tRbEBGiIVE28VqpaVlY6VhBCBgIJeTHNC62M+O89O3QkIa20TZo6aUp8XJ6zz/fY8ROCp4j6BiaKuFUNwy9cFaLJVtZqsimHa2CFjqGf4UYUmswHxwzDTaCXSpuIj/io6FieXdp88SLfcq2A2laJKOKurIpW0VTDoqeui1uVj+yirZqA2Rh/7XbxkjqBga8f18223XjuNH6UqiNVfegtz9ft/sN6MfxO+opV8pWeo3m3d+2mc37FR7dmY2t0jNpdwOamY9AF5aPh9YvjdbWVuSSta7OlLamnBE/aoL5t9i8vC+MDk0lhx+s6b2B+pY8RQRiVoRE0wbyv7/itjubMnVkI83uII0wmGLmRE1pz5jAfvWH8Jh5dBrdzdIcyLrxWDBIF/N7BB/cR3Lnlzx1jehMjd/qYDzASBTTlaOEil20NMZkoUDzHRQEwoyHsRGBaG4RVCATRgtnRoSuZ7DFv/AYNyPRGQ7gxDeHl0BDs/pyGs2E5BOqT/R426BtQmOpjwebh6GpH917fge3pO6QqMLSCa/EeIrUsMp2hI1DLAnUA1J+PteyAi2oqron8yThR0h1IRc32qKSnINVsDaSWzgpciGT0KG1X2rK0AyCMuSptW1pF2qq0N7JPR9qhtC1pK9LWZJ8LsWS/tKh/oZyxGutN+qr+e9ikMEb3kb+kcwPe8RZzNyywQgOBzqCAOdMgjk2NFzoPkR7rXTKSwrzInRlwPBOQw9gG1DUvw1soBVorj/lGbkiAxmL1XioRykk1Y/4iU9MzdZw0F/kxSUGxPKSg0Iezn3imvs+eU4hLQzMFJOQulcnwMosZ0nSJ1KaZ2dzjcuwL6AXJBse0LDbx/0fhk38UxGYpn03FPls58j1n/geicwxm4RzpAfQD9UlE8/B3hCYRzeInqiKKPRUWQHO0BdCsvAB0qjAAnogMYO/ojMialRpRVVZtxFQngiOmSmrOGB3/HqNJ4RU=", + "nurimisaki_2": "m=edit&p=7VZpb/I4EP7Or6j8tRbEOSCNVK04K1UtW7b0ZQtCyEAggSSmOWgVxH/v2KHNQVppd6VVV1olHmaeseewkycELxH1TUwkfis6hl+4VKKLIet1MaTTNbRDxzQucDMKLeaDYoXhLjBqtV0Uj+Nx1bG9bW33mxf5tmsHdGvXiMRvzVY9dVP1qht5rWyUQFvL++paCVSvYWH8e6+HV9QJTHz7vGl1ts3XbvPPmjZWlKf+6nLTGTxtlqNfZCDZNV/qO7p3/9BpOZc38fjeau7Nrll/CNjCcky6pPF4dPvmeD19ba1I+9Zq6yvqScGLPrzatwbX15XJqZdp5RBfGXETxzfGBBGEkQyDoCmOB8YhvjfQgrlzG+H4EfwIkylGbuSE9oI5zEcfWHyXrJZB7abqSPi51k5AIoHeP+mgPoO6sP2FY87uEuTBmMRDjHgBLbGaq8hle5Mn4wVyOykKgDkN4SwCy94hrIAjiJZsG52mkukRx82/0QZE+miDq0kbXCtpg3f3z9twdqykgavp8QgH9Ae0MDMmvJunVNVT9dE4gOwbB6RIsFTD9eQMkaKCqX6aaiNnajLPc4E+gbpWABp8vZyadTCVT1MnOZNISi45kXi4dDURxWXDE4VXkImg8RlpgaTOIzZONrRIRKPPQvaElIUcwj7gWBGyI6QkpCbknZjTFXIkZFtIVci6mNPgO/mX9vpfKGeiJESUv7T/HjatTNBj5K/owoRHv83cHQvs0ERAPyhgzixIfDPzjS5CZCQ0mPXkMC9y5ya8tRnIYWwHtFsW4cOVA+21x3yz1MVBc7n+KhR3lYSaM39ZqOmVOk6+F/GVyUEJa+Sg0AdKyNjU99lrDnFpaOWADAvmIpleYTNDmi+Rbmkhm5tux7GC3pAY8IrK/BD//1b88G8FPyzpp7HYTytHPOfM/4Z0UmcRLqEeQL9hn4y3DP+CaDLeIn7GKrzYc2IBtIRbAC3SC0DnDAPgGckA9gXP8KhFquFVFdmGpzojHJ4qyzkTlP5vRtPKOw==", + + # kurodoko + "kurodoko_1": "m=edit&p=7VZbb+I6EH7nV1R+rVXiONAQqTriWqlq2bKlhy0IoQChCRic5tJWQfz3jh1oLqSVzq50xMPKZDTzfc54xk6+4L+EpmfhGgyqYwUTGFRX5KVr4qfsR98JmGWc4XoY2NwDxw4C1zfKZTeMhtHwgjmbVdn9ZxV6fM5XvFyDQZeaTdkF05a6qzmqqy41iKhNlyrGPzodvDCZb+Gbp2Wjtaq/teu/ypUhpY/dxfmy1Xtczgf/kp7ilD2ly/TN3X2rwc6vo+GdXX+12lb13uczm1nm3IyGg5t3tunoz/aCNG/spr4wN4r/ovdrr43e1VVptG9jXNpGNSOq4+jaGCGCMFLhImiMo56xje4MNOPrqYNw9AA8wmSM0TpkgTPjjHvogEW38d0quO3EHUheeM0YJAr43b0P7hO4M8ebMWtyGyP3xijqYyQKaMi7hYvW/NUSi4kCRRwXBcDUDOAYfNtxEaZA+CFsdrifSsY7HNV/ow3IdGhDuHEbwitoQ3T3520wlxc0UBvvdnBAP6GFiTES3Twmrp64D8YWbNfYIlWHWymuxmeIKIVQS8JqhtUuxTpn6BOoiLuT6VXB65+hrmZYXSRTP0OikGxMlMx0ogo+vRqhImFSDqGVbKyJDIeM0CCRbT5J25FWlbYPu4AjKm1LWkXairS3ck5b2oG0TWk1aatyzqXYx/+00/9DOSNVl/qTHpXTQsalEXoIvYU5s+CRbvK1y30nsBDICvI5m/gxN7HezVmAjFje0kwG24TrqQVvYwpinLugpEUZDlQGdJ433LMKKQFa8+evUgmqINWUe/NcTW8mY9le5EcjA8VqkIECD171VGx6Hn/LIGszsDNASt0ymaxNbjMDM1uiuTJzq62T7diV0DuSF7xsqjjEv9+AE/8GiMNSTk2fTq0c+Zxz7xvRScg8XCA9gH6jPim2CP9CaFJsHj9SFVHssbAAWqAtgOblBaBjhQHwSGQA+0JnRNa81Iiq8mojljoSHLFUWnNG6PBXGI1LHw==", + "kurodoko_2": "m=edit&p=7VdtT+M4EP7eX3Hy17WusfP2Iq1OpcBKCDg44DhaVSi0aWvq1t28AAriv++MQ0mclj3drbTiwyrKdJ5nnPEzsTVOs69FnCaUW5RZ1A4o/MLlhwF1GNwh07f1el2KXCbRb7RX5HOVgjPP83UWdbvrohyUg9+lWC266z8WRaomaqG63Ooyq3vvL23pzjzhrd2FI1zpiGDhSF/A78yWnghFuHIB4ShXcOFIe+EJLl3hLeI1F+4skcmS31P65+EhncYyS+jRzf3e/qL3eND7p+sObPvqdPrpfv/86n5y/Tc7t0Q3tU5lsDo529+Tn76Ug5N57yE5SLyzTI3nMokncTm4PnqSq8NgNp+y/tG8H0zjlZV9DS7Dh73zz587w9eyR53nMozKHi2/REPCCCUcbkZGtDyPnsuTiIzV8k4QWl5AnFA2omRZyFyMlVQp2XDlcfU0B/egdq91HL1+RTIL/NNXH9wbcMciHcvk9rhizqJheUkJCtjTT6NLluohwclQIOJKFBB3cQ7Lls3FmlAbAlkBi1O8DmWjF1r2/kcZkGlTBrpVGejtKAOr+/Ey5FrtKCAcvbzAAv0FJdxGQ6zmqnaD2r2InsGeRs/EdeBRn3rVGhKPA7RrGAJ036DvAvRqiNEahpYxmFmInQbGqRpxxsw4w+xBjTniRtxGbbVUZntm3MH5Gs87GK9rYa5tyGWuDzissYd6Gthv6fUDc77AfFcsMF8WC1F/E2Ocv2FuoZ46H2eov87HOeJaL+eop/G8jXrr/Nw2V4c7iKGTvREuvpBGAs9cMO5hnDUU+aiwSQQ45SYD7CCm99GNtofacm0vYZvR0tZ2X1tLW1fbYz3mQNtrbfvaOtp6eoyPG/U/beWfIGfoVkfCv1/ur3E/Y9yoMyQXRTqNxwl0yL5arlUm8oTAKUUyJW+zKnabPMXjnETVadmMGNyqWN4l0NwblFRqDQf5rgybkEGK2Uqlyc4Qkslk9l4qDO1IdafSSUvTYyylWYv+bjGo6nAxqDyFk6OB4zRVjwazjPO5QTQOSyNTsmq9zDw2JcaLuDXbsn4dLx3yRPQN3Y7jIv76pPjgnxS4WNZH68YfTY7e5yr9TtOpg216R+sB9jvdpxHdxb/TaBrRNr/VVVDsdmMBdkdvAbbdXoDa7jBAbjUZ4N7pM5i13WpQVbvb4FRbDQenavacIdn8EyOjzjc=", + + # masyu + "masyu_1": "m=edit&p=7Zdbb+o4EMff+RQrvx5rEydckkhHK65Hqlq2bOmyJULIgGkCCebk0rJBfPeODRW5uOfhrFbqSkuUYfiNGc/E5m8Rf09pxDCpYwubFtYxgavR0rFZN7AJWNz65Rr7ScCcX3A7TTwegeMlyT52NG2fZtNs+mvg77ba/reQxl6qkbpmaaapi5fdtJvw1vS3YE2bgiX6RvdFzDB0jH8fDPCaBjHDN0+bTm/bfu23/9IaU9N8HK6/bHqjx81q8icZ6b4W6cPA2t3d9zrBl2/Z9M5rv7A+a97HfOkFjK5oNp3cHILdwHr21qR743WtNd3p8XdrbL90Rl+/1txLP7PaMbOdrI2zb46LDITlTdAMZyPnmN05aMnDhY9w9gBxhMkMozANEn/JAx6hd5bdgkcQNsDtX92JjAuve4ZEB3948cF9AnfpR8uAzW/P5N5xszFGooCO/LZwUchfmJgMviY/n4sCsKAJrEfs+XuETQjE6Ypv08tQMjvhrP0TbUCm9zaEe25DeIo2RHf/uA3YNeyg6MCenU6wQn9AD3PHFe08Xl3r6j44R7BDaYm0T84RmTakITBNvjRUN1S0aSppC6hRocq8LeVYW+St0oaSWipKdF2JichRqYIQUUYVGyKJAisfBjFFJVVcbyorqatHN0Tu6uiW8omQliI3rONArqYh7RgWG2emtD1pdWkb0t7KMX1pJ9J2pa1L25RjWmK7/PSG+pfKcUF0heSqr8Z/PzarueghjdZ0yeCH3+Xhnsd+whCIL4p5MI/PsTk70GWCnPMhkI8U2C4NFww0K4cCzvdCQhQZ3kMF6D/veMSUIQHZ6vmjVCKkSLXg0apU0ysNgmIv8pgtoPNeL6AkAkHMfaZRxF8LJKSJVwC5M6CQie1KDzOhxRLplpZmC6+P41RDByRv18SGWMT/T8rPflKK1dI/m7x9tnLkRufRD1TnGixjhfYA/YH85KIq/oHS5KJlXpEVUWxVWYAqxAVoWV8AVSUGYEVlgH0gNCJrWWtEVWW5EVNVFEdMlRcdF8Ffh79TNKu9AQ==", } @pytest.fixture @@ -63,5 +84,24 @@ def puzzlink_test_urls(): # stostone "stostone_1": "https://puzz.link/p?stostone/6/6/g0iic2vn03ecg3255g", - "stostone_2": "https://puzz.link/p?stostone/12/10/0000000000000000002000007vu00fvvvv007vu0vvo0gccc6g" + "stostone_2": "https://puzz.link/p?stostone/12/10/0000000000000000002000007vu00fvvvv007vu0vvo0gccc6g", + + # kurotto + "kurotto_1": "https://puzz.link/p?kurotto/10/10/h42j4m.g1h...2g.g.o5i12r1.i4o1g1g3.33h2g.m5j13h", + "kurotto_2": "https://puzz.link/p?kurotto/10/10/.l7l3g1g.g6g2n1h2h1o6h8g3har06.h6zh3h./", + + # kurochute + "kurochute_1": "https://puzz.link/p?kurochute/14/8/1i7l32g2h4g1j6h13i2j1i24l2k35k5l46i4j3i57h2j6g2h6g43l1i7", + + # kurodoko + "kurodoko_1": "https://puzz.link/p?kurodoko/9/9/3j4h3l.l4j8p4i2p2j4l.l3h3j2", + "kurodoko_2": "https://puzz.link/p?kurodoko/20/10/j7m3l5g6i6p5k4i5l4i8k4l7i4i8g3l6i9i9n5i4i3l5g5i2i4l3k6i2l5i6kap2i5gelem2j", + + # nurimisaki + "nurimisaki_1": "https://puzz.link/p?nurimisaki/10/10/4g.i.h3t.n3j.v3zk.k3h.i.k", + "nurimisaki_2": "https://puzz.link/p?nurimisaki/10/10/5i4n4j.n.j2g3j3s5g2v.g3s4n7h", + + # nurikabe + "nurikabe_1": "https://puzz.link/p?nurikabe/10/10/h5k7t6l5p2h4p2g2q4g7j2v3j2g", + "nurikabe_2": "https://puzz.link/p?nurikabe/10/10/j2m3i2i2h.j6t4k..k3t6j.h4i4i2m2j", } \ No newline at end of file From 80bfdca5392bb6aa4666ed93b1b9467ed3038e76 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Thu, 19 Mar 2026 23:12:01 +0800 Subject: [PATCH 10/17] update masyu and moonsun converter with test --- src/puzzlekit/formats/base.py | 34 +- src/puzzlekit/formats/debug.py | 364 -------------------- src/puzzlekit/formats/penpa_converter.py | 20 +- src/puzzlekit/formats/puzzlink_converter.py | 357 +++++++++++++++---- tests/formats/conftest.py | 7 + 5 files changed, 340 insertions(+), 442 deletions(-) diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py index c9ccb03b..50c6b5b8 100644 --- a/src/puzzlekit/formats/base.py +++ b/src/puzzlekit/formats/base.py @@ -76,6 +76,28 @@ class SurfaceColor(Enum): PENPA_PU_X_STR = '{zR:{z_:[]},zU:{z_:[]},z8:{z_:[]},zS:{},zN:{},z1:{},zY:{},zF:{},z2:{},zT:[],z3:[],zD:[],z0:[],z5:[],zL:{},zE:{},zW:{},zC:{},z4:{},z6:[],z7:[]}' PENPA_PU_X_DEFAULT = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, PENPA_PU_X_STR)) +@dataclass +class NumberState: + """_summary_ + + Returns: + _type_: _description_ + """ + value: Optional[str] = None + number_color: Optional[NumberColor] = None + number_style: str = "1" + + def to_dict(self) -> dict: + """Convert to dict. + + Returns: + dict: dict of number, number_color, number_style + """ + return { + "value": self.value, + "number_color": self.number_color.value if self.number_color is not None else None, + "number_style": self.number_style + } @dataclass class EdgeState: @@ -116,12 +138,12 @@ class CellState: """ Cell status """ - value: Optional[str] = None # Number clue + # value: Optional[str] = None # Number clue shaded: bool = False # black? - # num_color: int = 1 # number color - num_color: Optional[NumberColor] = None # number color - num_style: str = "1" # number style + # num_color: Optional[NumberColor] = None # number color + # num_style: str = "1" # number style + number: Optional[NumberState] = None surf_color: Optional[SurfaceColor] = None symbol: Optional[SymbolState] = None @@ -181,10 +203,8 @@ def normalize(self) -> dict: cells_normalized = {} for (r, c), state in sorted(self.cells.items()): cells_normalized[f"{r},{c}"] = { - "value": state.value, "shaded": state.shaded, - "num_color": state.num_color.value if state.num_color is not None else None, - "num_style": state.num_style, + "number": state.number.to_dict() if state.number is not None else None, "surf_color": state.surf_color.value if state.surf_color is not None else None, "symbol": state.symbol.to_dict() if state.symbol is not None else None } diff --git a/src/puzzlekit/formats/debug.py b/src/puzzlekit/formats/debug.py index b32088a4..e69de29b 100644 --- a/src/puzzlekit/formats/debug.py +++ b/src/puzzlekit/formats/debug.py @@ -1,364 +0,0 @@ -# formats/debug.py -from puzzlekit.formats.penpa_converter import PenpaConverter as Ppc -from puzzlekit.formats.puzzlink_converter import PuzzlinkConverter as Plc -from puzzlekit.formats.base import PuzzleInstance -from typing import Dict, Any, List, Tuple - -import json - -def compare_edges(edges1: Dict, edges2: Dict) -> List[str]: - """比较两个 edges dict,返回差异列表""" - diffs = [] - all_keys = set(edges1.keys()) | set(edges2.keys()) - - for key in sorted(all_keys): - e1, e2 = edges1.get(key), edges2.get(key) - if e1 is None: - diffs.append(f"❌ edge [{key}] missing in first, expected: {e2}") - elif e2 is None: - diffs.append(f"❌ edge [{key}] missing in second, expected: {e1}") - else: - # 比较 EdgeState 的各个字段 - if e1.connected != e2.connected: - diffs.append(f"⚠️ edge [{key}].connected: {e1.connected} != {e2.connected}") - if e1.edge_type != e2.edge_type: - diffs.append(f"⚠️ edge [{key}].edge_type: {e1.edge_type} != {e2.edge_type}") - return diffs - - -def debug_edges_summary(edges: Dict, label: str = "Edges"): - """打印 edges 的统计摘要,方便快速定位问题""" - if not edges: - print(f"📋 {label}: empty") - return - - # 按 edge_type 分组统计 - type_count = {} - for edge_state in edges.values(): - t = edge_state.edge_type - type_count[t] = type_count.get(t, 0) + 1 - - print(f"📋 {label}: total={len(edges)}, by edge_type: {type_count}") - - # 可选:打印前 5 个 edge 详情 - # for i, (k, v) in enumerate(list(edges.items())[:5]): - # print(f" [{k}] -> connected={v.connected}, type={v.edge_type}") - -def compare_cells(cells1: Dict, cells2: Dict, tolerance: float = 0) -> List[str]: - """比较两个 cells dict,返回差异列表""" - diffs = [] - all_keys = set(cells1.keys()) | set(cells2.keys()) - - for key in sorted(all_keys): - c1, c2 = cells1.get(key), cells2.get(key) - if c1 is None: - diffs.append(f"❌ [{key}] missing in first: {c2}") - elif c2 is None: - diffs.append(f"❌ [{key}] missing in second: {c1}") - else: - # 比较各个字段 - for field in ['value', 'num_color', 'num_style', 'surf_color', 'shaded']: - v1 = getattr(c1, field, None) - v2 = getattr(c2, field, None) - # 处理 Enum 比较 - if hasattr(v1, 'value'): v1 = v1.value - if hasattr(v2, 'value'): v2 = v2.value - if v1 != v2: - diffs.append(f"⚠️ [{key}].{field}: {v1} != {v2}") - return diffs - -def compare_edges(edges1: Dict, edges2: Dict) -> List[str]: - """比较两个 edges dict""" - diffs = [] - all_keys = set(edges1.keys()) | set(edges2.keys()) - - for key in sorted(all_keys): - e1, e2 = edges1.get(key), edges2.get(key) - if e1 is None: - diffs.append(f"❌ edge [{key}] missing in first") - elif e2 is None: - diffs.append(f"❌ edge [{key}] missing in second") - elif e1.edge_type != e2.edge_type or e1.connected != e2.connected: - diffs.append(f"⚠️ edge [{key}]: {e1} != {e2}") - return diffs - -def debug_roundtrip(converter, url: str, name: str, skip_compare: List[str] = None): - """ - 执行双向转换并打印调试信息 - - Args: - converter: 转换器实例 (PuzzlinkConverter 或 PenpaConverter) - url: 原始 URL - name: 测试名称 - skip_compare: 跳过比较的字段列表,如 ['metadata', 'source'] - """ - skip_compare = skip_compare or [] - print(f"\n{'='*60}") - print(f"🔍 {name} Roundtrip Debug") - print(f"{'='*60}") - - # Step 1: Decode - print(f"\n📥 Decoding: {url[:80]}...") - ir1 = converter.decode(url) - print(f"✅ IR decoded: {ir1.rows}x{ir1.cols}, cells={len(ir1.cells)}, edges={len(ir1.edges)}") - - # Step 2: Encode - print(f"\n📤 Encoding back...") - try: - url2 = converter.encode(ir1) - print(f"✅ Encoded URL: {url2[:80]}...") - except Exception as e: - print(f"❌ Encode failed: {e}") - import traceback - traceback.print_exc() - return ir1, None, None - - # Step 3: Decode again - print(f"\n📥 Re-decoding encoded URL...") - ir2 = converter.decode(url2) - print(f"✅ Re-decoded IR: {ir2.rows}x{ir2.cols}, cells={len(ir2.cells)}, edges={len(ir2.edges)}") - - # Step 4: Compare - print(f"\n🔎 Comparing IRs...") - all_diffs = [] - - # 基础属性比较 - for attr in ['rows', 'cols', 'puzzle_type', 'grid_type']: - if attr in skip_compare: continue - v1, v2 = getattr(ir1, attr), getattr(ir2, attr) - if v1 != v2: - all_diffs.append(f"❌ {attr}: {v1} != {v2}") - else: - print(f"✅ {attr}: {v1}") - - # margins 比较 - if 'margins' not in skip_compare: - if ir1.margins != ir2.margins: - all_diffs.append(f"❌ margins: {ir1.margins} != {ir2.margins}") - else: - print(f"✅ margins: {ir1.margins}") - - # cells 比较 (只显示前 10 个差异) - if 'cells' not in skip_compare: - cell_diffs = compare_cells(ir1.cells, ir2.cells) - if cell_diffs: - print(f"⚠️ Cell differences ({len(cell_diffs)} total, showing first 10):") - for d in cell_diffs[:10]: - print(f" {d}") - if len(cell_diffs) > 10: - print(f" ... and {len(cell_diffs) - 10} more") - all_diffs.extend(cell_diffs) - else: - print(f"✅ cells: all {len(ir1.cells)} cells match") - - # edges 比较 - if 'edges' not in skip_compare: - edge_diffs = compare_edges(ir1.edges, ir2.edges) - if edge_diffs: - print(f"⚠️ Edge differences ({len(edge_diffs)} total):") - # 打印详细差异 - for d in edge_diffs[:20]: # 限制输出数量 - print(f" {d}") - if len(edge_diffs) > 20: - print(f" ... and {len(edge_diffs) - 20} more") - all_diffs.extend(edge_diffs) - else: - print(f"✅ edges: all {len(ir1.edges)} edges match") - - # 打印 edges 摘要(可选,方便调试) - debug_edges_summary(ir1.edges, "IR1 Edges") - debug_edges_summary(ir2.edges, "IR2 Edges") - # Summary - print(f"\n{'-'*60}") - if all_diffs: - print(f"🔴 FAILED: {len(all_diffs)} differences found") - # 可选:保存差异到文件 - # with open(f"debug_{name}_diffs.txt", "w") as f: - # f.write("\n".join(all_diffs)) - else: - print(f"🟢 SUCCESS: Roundtrip perfect! ✅") - print(f"{'-'*60}\n") - - return ir1, ir2, all_diffs - -def debug_nonogram_specific(ir: Any): - """针对 nonogram 的专项调试:打印 margin 区域的数字""" - if ir.puzzle_type != "nonogram": - return - - print(f"\n🧩 Nonogram Specific Debug ({ir.rows}x{ir.cols}, margins={ir.margins})") - rows_offset, cols_offset = ir.margins[0], ir.margins[2] - - # 打印顶部行提示 (row clues) - print(f"\n📋 Top row clues (rows 0~{rows_offset-1}):") - for r in range(rows_offset): - clues = [(c, ir.cells[(r, c)].value) for c in range(ir.cols) - if (r, c) in ir.cells and ir.cells[(r, c)].value] - if clues: - print(f" Row {r}: {clues}") - - # 打印左侧列提示 (col clues) - print(f"\n📋 Left col clues (cols 0~{cols_offset-1}):") - for c in range(cols_offset): - clues = [(r, ir.cells[(r, c)].value) for r in range(ir.rows) - if (r, c) in ir.cells and ir.cells[(r, c)].value] - if clues: - print(f" Col {c}: {clues}") - -def compare_ir(ir1: PuzzleInstance, ir2: PuzzleInstance, - ignore_fields: List[str] = None, - verbose: bool = True) -> Tuple[bool, List[str]]: - """ - Cross-compare two PuzzleInstance objects. - - Args: - ir1, ir2: Two PuzzleInstance to compare - ignore_fields: Fields to skip comparison (e.g., ['source', 'metadata', 'author']) - verbose: Whether to print detailed differences - - Returns: - (is_equal: bool, diffs: List[str]) - """ - ignore_fields = ignore_fields or ['source', 'metadata', 'author', 'title'] - diffs = [] - - # ===== 1. 基础属性对比 ===== - basic_fields = ['grid_type', 'puzzle_type', 'rows', 'cols', 'margins', 'boxes'] - for field in basic_fields: - if field in ignore_fields: - continue - v1, v2 = getattr(ir1, field, None), getattr(ir2, field, None) - if v1 != v2: - diffs.append(f"❌ {field}: {v1} != {v2}") - elif verbose: - print(f"✅ {field}: {v1}") - - # ===== 2. Cells 对比 ===== - if 'cells' not in ignore_fields: - cell_diffs = _compare_cells_detail(ir1.cells, ir2.cells) - if cell_diffs: - diffs.extend(cell_diffs) - if verbose: - print(f"⚠️ Cell differences ({len(cell_diffs)}):") - for d in cell_diffs[:10]: - print(f" {d}") - if len(cell_diffs) > 10: - print(f" ... and {len(cell_diffs) - 10} more") - elif verbose and ir1.cells: - print(f"✅ cells: all {len(ir1.cells)} cells match") - - # ===== 3. Edges 对比 ===== - if 'edges' not in ignore_fields: - edge_diffs = _compare_edges_detail(ir1.edges, ir2.edges) - if edge_diffs: - diffs.extend(edge_diffs) - if verbose: - print(f"⚠️ Edge differences ({len(edge_diffs)}):") - for d in edge_diffs[:10]: - print(f" {d}") - if len(edge_diffs) > 10: - print(f" ... and {len(edge_diffs) - 10} more") - elif verbose and ir1.edges: - print(f"✅ edges: all {len(ir1.edges)} edges match") - - # ===== 4. 可选:CellState/EdgeState 字段级对比配置 ===== - # 如需更细粒度控制,可扩展 _compare_cells_detail 的 compare_fields 参数 - - is_equal = len(diffs) == 0 - if verbose: - print(f"\n{'='*50}") - if is_equal: - print("🟢 IRs are SEMANTICALLY EQUAL ✅") - else: - print(f"🔴 IRs DIFFER: {len(diffs)} issues found") - print(f"{'='*50}\n") - - return is_equal, diffs - - -def _compare_cells_detail(cells1: Dict, cells2: Dict, - compare_fields: List[str] = None) -> List[str]: - """ - 详细对比两个 cells dict,返回差异列表。 - """ - if compare_fields is None: - compare_fields = ['value', 'num_color', 'num_style', 'surf_color', 'shaded'] - - diffs = [] - all_keys = set(cells1.keys()) | set(cells2.keys()) - - # 先检查数量 - if len(cells1) != len(cells2): - diffs.append(f"❌ cells count: {len(cells1)} != {len(cells2)}") - - for key in sorted(all_keys): - c1, c2 = cells1.get(key), cells2.get(key) - - if c1 is None: - diffs.append(f"❌ [{key}] missing in ir1, ir2 has: {c2}") - continue - if c2 is None: - diffs.append(f"❌ [{key}] missing in ir2, ir1 has: {c1}") - continue - - # 字段级对比 - for field in compare_fields: - v1 = getattr(c1, field, None) - v2 = getattr(c2, field, None) - # 处理 Enum 值比较 - if hasattr(v1, 'value'): v1 = v1.value - if hasattr(v2, 'value'): v2 = v2.value - if v1 != v2: - diffs.append(f"⚠️ [{key}].{field}: '{v1}' != '{v2}'") - - return diffs - - -def _compare_edges_detail(edges1: Dict, edges2: Dict) -> List[str]: - """ - 详细对比两个 edges dict,返回差异列表。 - """ - diffs = [] - all_keys = set(edges1.keys()) | set(edges2.keys()) - - if len(edges1) != len(edges2): - diffs.append(f"❌ edges count: {len(edges1)} != {len(edges2)}") - - for key in sorted(all_keys): - # key 是 ((r1,c1), (r2,c2)) 元组,需要标准化顺序 - k_std = tuple(sorted(key)) if isinstance(key, tuple) and len(key) == 2 else key - - e1 = edges1.get(key) or edges1.get(k_std) - e2 = edges2.get(key) or edges2.get(k_std) - - if e1 is None: - diffs.append(f"❌ edge {key} missing in ir1") - continue - if e2 is None: - diffs.append(f"❌ edge {key} missing in ir2") - continue - - if e1.connected != e2.connected: - diffs.append(f"⚠️ edge {key}.connected: {e1.connected} != {e2.connected}") - if e1.edge_type != e2.edge_type: - diffs.append(f"⚠️ edge {key}.edge_type: {e1.edge_type} != {e2.edge_type}") - - return diffs - - -# ============ 主测试入口 ============ - - -if __name__ == "__main__": - # 测试 URL - PUZZLINK_URL = "https://puzz.link/p?nurikabe/10/10/j2m3i2i2h.j6t4k..k3t6j.h4i4i2m2j" - PENPA_URL = "m=edit&p=7VZrb+o4EP3Or1j5ay2Ik5AbIlUVz0pVy5YtXbYghAKYJpDE3DzaKoj/3rEDJQ5ppd2VVl3pKniYOWPPw8YnRD8TO6SYKPyjmRi+4dGJKYZqGmIoh2foxh61fsPNJHZYCIoTx9vIqtW2STpOx1XPDTa17VWQhO7GntMaUfhnrfqaq7qqU10bsb6pVjdabKyrju7qruqra4x/7/XwyvYiim+e1q3Opvnabf5Vq4817bG/ulh3Bo/r5ehPMlDcWqj0PTO4u++0vIvrdHznNF9olxr3EVs4HrWXdjoe3bx5Qc98dlakfeO0zZUdKNFPc9h4aQ0uLyuTQzfTyi5tWGkTp9fWBBGEkQqDoClOB9YuvbPQgvlzF+H0AfwIkylGfuLF7oJ5LERHLL3NVqugdk/qSPi51s5AooDeP+igPoG6cMOFR2e3GXJvTdIhRryAlljNVeSzF8qT8QK5nRUFwNyO4TQix90irIEjSpZskxymkukep81/0AZEOrbB1awNrpW0wbv79214W1bSQGO638MB/QEtzKwJ7+bxpJon9cHagexbO6TpsFSFX684Q6QbYGofZl2RvHVTMg3C015BA0eArzY+TLMBpv5hNuqF6UTh4WVEzk9UHuIUkYhypRXaDykJ0XmWvM0jnGomouijDZtAxFY8CdkTUhVyCDuFU03IjpCKkHUhb8WcrpAjIdtC6kIaYs4Pvtd/6zT+g3ImWkZW8lP//2HTygQ9JOHKXlC4HG3mb1nkxhQBQaGIebMo883om72IkZURZd4jYUHizync6xzkMbYFai6LcHRJoPscsJCWujhIl8+fheKuklBzFi4LNb3anif3It5EEpTxigTFIZBGzrbDkL1KiG/HjgTkeFKKRIPCZsa2XKK9sQvZ/NN27CvoDYkBlxwu4a+3ybd/m/DDUr4bi323csTvnIVfkM7JWYRLqAfQL9gn5y3DPyGanLeIn7EKL/acWAAt4RZAi/QC0DnDAHhGMoB9wjM8apFqeFVFtuGpzgiHp8pzzgQd/1ujaeUd" - plc = Plc() - ppc = Ppc() - ir1 = plc.decode(PUZZLINK_URL) - ir2 = ppc.decode(PENPA_URL) - # debug_roundtrip(plc, PUZZLINK_URL, "Puzzlink Nonogram", skip_compare=['source', 'metadata']) - - - print("\n🔍 Cross-comparing IR1 vs IR2:") - is_equal, diffs = compare_ir(ir1, ir2, ignore_fields=['source', 'metadata']) \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index 7bba4648..828e89f2 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -1,6 +1,6 @@ from puzzlekit.formats.base import ( PuzzleInstance, CellState, EdgeState, - COMPRESS_SUB, NumberColor, SurfaceColor, SymbolState + COMPRESS_SUB, NumberColor, SurfaceColor, SymbolState, NumberState, ) from puzzlekit.formats.penpa_template import ( PENPA_FIXED_FIELDS as fixed, @@ -176,9 +176,14 @@ def _decode_number(self, number_dict: Dict[str, int]): # NORMAL if (r, c) not in self.ir_puzzle.cells: self.ir_puzzle.cells[(r, c)] = CellState( - value = f"{num_data[0]}", - num_color = NumberColor(num_data[1]), - num_style = num_data[2] + number = NumberState( + value = f"{num_data[0]}", + number_color = NumberColor(num_data[1]), + number_style = num_data[2] + ) + # value = f"{num_data[0]}", + # num_color = NumberColor(num_data[1]), + # num_style = num_data[2] ) else: cell = self.ir_puzzle.cells[(r, c)] @@ -215,9 +220,9 @@ def _encode_surface(self, cell_dict: Dict[tuple[int, int], CellState]): def _encode_number(self, number_dict: Dict[str, CellState]): new_number_dict = dict() for coords, v_ in number_dict.items(): - if v_.value: + if v_.number: index = f"{self.coord_to_index(coords, 'cell')}" - new_number_dict[str(index)] = [v_.value, v_.num_color.value, v_.num_style] + new_number_dict[str(index)] = [v_.number.value, v_.number.number_color.value, v_.number.number_style] return new_number_dict @@ -301,11 +306,12 @@ def encode(self, inst: PuzzleInstance) -> str: for test_url in [ # "m=edit&p=7Zdbb+o4EMff+RQrvx5rEydckkhHK65Hqlq2bOmyJULIgGkCCebk0rJBfPeODRW5uOfhrFbqSkuUYfiNGc/E5m8Rf09pxDCpYwubFtYxgavR0rFZN7AJWNz65Rr7ScCcX3A7TTwegeMlyT52NG2fZtNs+mvg77ba/reQxl6qkbpmaaapi5fdtJvw1vS3YE2bgiX6RvdFzDB0jH8fDPCaBjHDN0+bTm/bfu23/9IaU9N8HK6/bHqjx81q8icZ6b4W6cPA2t3d9zrBl2/Z9M5rv7A+a97HfOkFjK5oNp3cHILdwHr21qR743WtNd3p8XdrbL90Rl+/1txLP7PaMbOdrI2zb46LDITlTdAMZyPnmN05aMnDhY9w9gBxhMkMozANEn/JAx6hd5bdgkcQNsDtX92JjAuve4ZEB3948cF9AnfpR8uAzW/P5N5xszFGooCO/LZwUchfmJgMviY/n4sCsKAJrEfs+XuETQjE6Ypv08tQMjvhrP0TbUCm9zaEe25DeIo2RHf/uA3YNeyg6MCenU6wQn9AD3PHFe08Xl3r6j44R7BDaYm0T84RmTakITBNvjRUN1S0aSppC6hRocq8LeVYW+St0oaSWipKdF2JichRqYIQUUYVGyKJAisfBjFFJVVcbyorqatHN0Tu6uiW8omQliI3rONArqYh7RgWG2emtD1pdWkb0t7KMX1pJ9J2pa1L25RjWmK7/PSG+pfKcUF0heSqr8Z/PzarueghjdZ0yeCH3+Xhnsd+whCIL4p5MI/PsTk70GWCnPMhkI8U2C4NFww0K4cCzvdCQhQZ3kMF6D/veMSUIQHZ6vmjVCKkSLXg0apU0ysNgmIv8pgtoPNeL6AkAkHMfaZRxF8LJKSJVwC5M6CQie1KDzOhxRLplpZmC6+P41RDByRv18SGWMT/T8rPflKK1dI/m7x9tnLkRufRD1TnGixjhfYA/YH85KIq/oHS5KJlXpEVUWxVWYAqxAVoWV8AVSUGYEVlgH0gNCJrWWtEVWW5EVNVFEdMlRcdF8Ffh79TNKu9AQ==" + # "m=edit&p=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" + # masyu "m=edit&p=7Zdbb+o4EMff+RQrvx5rEydckkhHK65Hqlq2bOmyJULIgGkCCebk0rJBfPeODRW5uOfhrFbqSkuUYfiNGc/E5m8Rf09pxDCpYwubFtYxgavR0rFZN7AJWNz65Rr7ScCcX3A7TTwegeMlyT52NG2fZtNs+mvg77ba/reQxl6qkbpmaaapi5fdtJvw1vS3YE2bgiX6RvdFzDB0jH8fDPCaBjHDN0+bTm/bfu23/9IaU9N8HK6/bHqjx81q8icZ6b4W6cPA2t3d9zrBl2/Z9M5rv7A+a97HfOkFjK5oNp3cHILdwHr21qR743WtNd3p8XdrbL90Rl+/1txLP7PaMbOdrI2zb46LDITlTdAMZyPnmN05aMnDhY9w9gBxhMkMozANEn/JAx6hd5bdgkcQNsDtX92JjAuve4ZEB3948cF9AnfpR8uAzW/P5N5xszFGooCO/LZwUchfmJgMviY/n4sCsKAJrEfs+XuETQjE6Ypv08tQMjvhrP0TbUCm9zaEe25DeIo2RHf/uA3YNeyg6MCenU6wQn9AD3PHFe08Xl3r6j44R7BDaYm0T84RmTakITBNvjRUN1S0aSppC6hRocq8LeVYW+St0oaSWipKdF2JichRqYIQUUYVGyKJAisfBjFFJVVcbyorqatHN0Tu6uiW8omQliI3rONArqYh7RgWG2emtD1pdWkb0t7KMX1pJ9J2pa1L25RjWmK7/PSG+pfKcUF0heSqr8Z/PzarueghjdZ0yeCH3+Xhnsd+whCIL4p5MI/PsTk70GWCnPMhkI8U2C4NFww0K4cCzvdCQhQZ3kMF6D/veMSUIQHZ6vmjVCKkSLXg0apU0ysNgmIv8pgtoPNeL6AkAkHMfaZRxF8LJKSJVwC5M6CQie1KDzOhxRLplpZmC6+P41RDByRv18SGWMT/T8rPflKK1dI/m7x9tnLkRufRD1TnGixjhfYA/YH85KIq/oHS5KJlXpEVUWxVWYAqxAVoWV8AVSUGYEVlgH0gNCJrWWtEVWW5EVNVFEdMlRcdF8Ffh79TNKu9AQ==" ]: hpc = PenpaConverter(dict()) tmp = hpc.decode(test_url) - # print("\n\n", tmp.cells) enc = hpc.encode(tmp) print(tmp) print(enc) diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index d7bcf33a..428ca3d9 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -1,6 +1,6 @@ from typing import Dict, Any, List, Optional, Union, Set from puzzlekit.formats.base import ( - PuzzleInstance, CellState, EdgeState, NumberColor, SurfaceColor + PuzzleInstance, CellState, EdgeState, NumberColor, SurfaceColor, SymbolState, NumberState ) from puzzlekit.formats.utils import ( generate_centerlist_diff, index_to_coord, coord_to_index, auto_border_split @@ -11,7 +11,8 @@ ALLOWED_PUZZLE_TYPE = { "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", "ayeheya", "nonogram", - "nurikabe", "kurochute", "kurodoko", "kurotto", "nurimisaki" + "nurikabe", "kurochute", "kurodoko", "kurotto", "nurimisaki", + "moonsun", "masyu", "mashu", "pearl" } # allowed puzzle types @@ -48,17 +49,21 @@ def _decode_nonogram_variant(self): row_idx = rows_offset - k % max_rows_offset - 1 col_idx = cols_offset + int(k / max_rows_offset) cell_dict[(row_idx, col_idx)] = CellState( - value = f"{v}", - num_color = NumberColor.BLACK, - num_style = "1" + number = NumberState( + value = f"{v}", + number_color = NumberColor.BLACK, + number_style = "1" + ) ) else: row_idx = rows_offset + int((k - max_rows_offset * self.num_cols) / max_cols_offset) col_idx = cols_offset - (k - max_rows_offset * self.num_cols) % max_cols_offset - 1 cell_dict[(row_idx, col_idx)] = CellState( - value = f"{v}", - num_color = NumberColor.BLACK, - num_style = "1" + number = NumberState( + value = f"{v}", + number_color = NumberColor.BLACK, + number_style = "1" + ) ) self.ir_puzzle.puzzle_type = "nonogram" @@ -78,8 +83,9 @@ def _decode_heyawake_variant(self): number_map = self._decode_number16() grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] self._move_numbers_to_top_left_corner(grid, region_grid, number_map) - - self.ir_puzzle.puzzle_type = "heyawake" + + # puzzle_type + self.ir_puzzle.puzzle_type = self.puzzle_type self.ir_puzzle.title = self.puzzle_type self.ir_puzzle.rows = self.num_rows self.ir_puzzle.cols = self.num_cols @@ -91,12 +97,6 @@ def _decode_heyawake_variant(self): # puzzlink_pu.drawBorder(pu, info_edge, 2); // 2 is for Black Style # puzzlink_pu.drawNumbers(pu, info_number, 1, "1") // Black Style, Normal submode is 1 self.ir_puzzle.boxes = generate_centerlist_diff(self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins) - # return (self.num_rows, self.num_cols, grid, region_grid) - # NOTE: - # // Change to Solution Tab - # pu.mode_qa("pu_a"); - # pu.mode_set("surface"); //include redraw - # UserSettings.tab_settings = ["Surface"]; def _decode_nurikabe_variant(self): """ @@ -138,15 +138,25 @@ def _decode_nurikabe_variant(self): # continue # 直接跳过,IR 中不存储 if not hide_question: cell_dict[(row_idx, col_idx)] = CellState( - value = str(v) , # "?" 原样保留(nurikabe/kurochute) - num_color= num_color, - num_style="1" + number = NumberState( + value = str(v), # "?" 原样保留(nurikabe/kurochute) + number_color = num_color, + number_style = "1" + ) + # value = str(v) , + # num_color= num_color, + # num_style="1" ) else: cell_dict[(row_idx, col_idx)] = CellState( - value = str(v) if str(v) != "?" else " " , # "?" 原样保留(nurikabe/kurochute) - num_color= num_color, - num_style="1" + number = NumberState( + value = str(v) if str(v) != "?" else " " , # "?" 原样保留(nurikabe/kurochute) + number_color = num_color, + number_style = "1" + ) + # value = str(v) if str(v) != "?" else " " , # "?" 原样保留(nurikabe/kurochute) + # num_color= num_color, + # num_style="1" ) # 填充 IR @@ -163,20 +173,100 @@ def _decode_nurikabe_variant(self): self.ir_puzzle.cols, self.ir_puzzle.margins ) - - def _reindex_number(self, r: int, c: int, margins: List[int], - grid: List[List[str]], skip: Set[str] = set(), - color: int = 1, style: str = "1"): + + def _reindex_cells( + self, + r: int, + c: int, + margins: List[int], + grid: List[List[str]], + skip: Optional[Set[str]] = None, + symbol_dict: Optional[Dict[str, SymbolState]] = None, + color: int = 1, + style: str = "1", + parse_number: bool = True, + parse_symbol: bool = True, + ) -> Dict[tuple[int, int], CellState]: + """ + Reindex grid content into unified CellState dict. - new_number_dict = dict() + Rules: + - If value in `skip`, do not emit cell. + - If `parse_symbol` and value exists in `symbol_dict`, emit symbol cell. + - Else if `parse_number`, emit number cell using (value, color, style). + - Else ignore this grid value. + """ + skip = skip or set() + symbol_dict = symbol_dict or {} + new_cell_dict: Dict[tuple[int, int], CellState] = {} top_m, bottom_m, left_m, right_m = margins + for r_ in range(r): for c_ in range(c): - if grid[r_][c_] not in skip: - # idx = pad_index + r_ * (c + 4 + left_m + right_m) + (c_ + 2 + left_m) - # res_dict[f"{idx}"] = [grid[r_][c_], color, submode] - new_number_dict[(r_ + top_m, c_ + left_m)] = CellState(value = grid[r_][c_], num_color = NumberColor(color), num_style = style) - return new_number_dict + token = grid[r_][c_] + if token in skip: + continue + + coord = (r_ + top_m, c_ + left_m) + if parse_symbol and token in symbol_dict: + new_cell_dict[coord] = CellState(symbol=symbol_dict[token]) + elif parse_number: + new_cell_dict[coord] = CellState( + number = NumberState( + value = token, + number_color = NumberColor(color), + number_style = style, + ) + # value=token, + # num_color=NumberColor(color), + # num_style=style, + ) + + return new_cell_dict + + def _reindex_symbol( + self, + r: int, + c: int, + margins: List[int], + grid: List[List[str]], + skip: Optional[Set[str]] = None, + symbol_dict: Optional[Dict[str, SymbolState]] = None, + ): + # Backward-compatible wrapper: symbol only. + return self._reindex_cells( + r=r, + c=c, + margins=margins, + grid=grid, + skip=skip, + symbol_dict=symbol_dict, + parse_number=False, + parse_symbol=True, + ) + + def _reindex_number( + self, + r: int, + c: int, + margins: List[int], + grid: List[List[str]], + skip: Optional[Set[str]] = None, + color: int = 1, + style: str = "1", + ): + # Backward-compatible wrapper: number only. + return self._reindex_cells( + r=r, + c=c, + margins=margins, + grid=grid, + skip=skip, + color=color, + style=style, + parse_number=True, + parse_symbol=False, + ) def _reindex_edge(self, r: int, c: int, margins: List[int], region_grid: List[List[str]], skip: Set[str] = set()): @@ -221,15 +311,15 @@ def decode(self, url: str) -> Dict[str, Any]: if self.puzzle_type in ["yajilin", "yajirin", "snakes", "hebi", "castle"]: return self._decode_yajilin_variant() elif self.puzzle_type in ["moonsun","mashu", "masyu", "pearl"]: - return self._decode_masyu_variant() - elif self.puzzle_type in ["slither", "slitherlink", "vslither"]: - info_number = self._decode_number4() - grid_matrix = self._convert_number_map_to_grid(info_number) - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - "grid": grid_matrix - } + self._decode_masyu_variant() + # elif self.puzzle_type in ["slither", "slitherlink", "vslither"]: + # info_number = self._decode_number4() + # grid_matrix = self._convert_number_map_to_grid(info_number) + # return { + # "num_rows": self.num_rows, + # "num_cols": self.num_cols, + # "grid": grid_matrix + # } elif self.puzzle_type in [ "heyawake", @@ -305,6 +395,9 @@ def encode(self, inst: PuzzleInstance) -> str: elif self.puzzle_type in ["nurikabe", "kurochute", "kurodoko", "kurotto", "nurimisaki"]: body_str = self._encode_nurikabe_variant(inst) return body_str + elif self.puzzle_type in ["moonsun", "masyu", "pearl", "mashu"]: + body_str = self._encode_masyu_variant(inst) + return body_str else: raise NotImplementedError(f"Puzzle type {self.puzzle_type} not supported for encoding") @@ -316,7 +409,7 @@ def _encode_heyawake_variant(self, inst: PuzzleInstance): number_map: Dict[int, Any] = dict() for k, cell_state in inst.cells.items(): (r_, c_) = k - val = int(cell_state.value) if cell_state.value.isdigit() else cell_state.value + val = int(cell_state.number.value) if cell_state.number.value.isdigit() else cell_state.number.value number_map[int(region_grid[r_][c_])] = val border_str = self._encode_border(border_list) @@ -343,11 +436,11 @@ def _encode_nonogram_variant(self, inst: PuzzleInstance): number_map: Dict[int, Any] = dict() for (r, c), cell_state in inst.cells.items(): - if not cell_state.value or cell_state.value.strip() in ['-', '']: + if not cell_state.number.value or cell_state.number.value.strip() in ['-', '']: continue # 解析数字值 - val = cell_state.value.strip() + val = cell_state.number.value.strip() if val == '?': number_val = '?' else: @@ -382,7 +475,83 @@ def _encode_nonogram_variant(self, inst: PuzzleInstance): # 6. 构建完整的 puzz.link URL body_str = number_str url = f"https://puzz.link/p?nonogram/{num_cols}/{num_rows}/{body_str}" - print(url) + return url + + def _encode_masyu_variant(self, inst: PuzzleInstance): + """ + Encode masyu-like PuzzleInstance to puzz.link URL. + + Inverse of `_decode_masyu_variant`: + - moonsun: border (regions) + number3 + - others : number3 only + """ + top_m = inst.margins[0] + left_m = inst.margins[2] + + total_cells = self.num_rows * self.num_cols + number_list = [0] * total_cells + + def _token_from_cell(cell_state: CellState) -> Optional[str]: + # Prefer symbol information, then fallback to number value. + if cell_state.symbol is not None: + sym = cell_state.symbol + if sym.symbol_type == "sun_moon": + if sym.symbol_index == 1: + return "o" + if sym.symbol_index == 2: + return "x" + + if sym.symbol_type in ["circle_L", "circle"]: + # Compatible with both index conventions: + # old: w=0, b=1 + # new: w=1, b=2 + if sym.symbol_index == 0: + return "w" + if sym.symbol_index == 1: + return "w" + if sym.symbol_index == 2: + return "b" + if sym.symbol_type == "x": + return "x" + + if cell_state.number is not None and cell_state.number.value: + return str(cell_state.number.value).strip().lower() + return None + + for (r, c), cell_state in inst.cells.items(): + r_grid = r - top_m + c_grid = c - left_m + if not (0 <= r_grid < self.num_rows and 0 <= c_grid < self.num_cols): + continue + + token = _token_from_cell(cell_state) + if not token or token in ["-", "", " "]: + continue + + idx = r_grid * self.num_cols + c_grid + if self.puzzle_type == "moonsun": + if token in ["o", "sun", "moon_o", "1", "w"]: + number_list[idx] = 1 + elif token in ["x", "moon", "moon_x", "2", "b"]: + number_list[idx] = 2 + else: + if token in ["w", "o", "white", "1"]: + number_list[idx] = 1 + elif token in ["b", "x", "black", "2"]: + number_list[idx] = 2 + + number3_str = self._encode_number3(number_list) + + if self.puzzle_type == "moonsun": + border_list = self._region_grid_to_borders(inst.edges) + border_str = self._encode_border(border_list) + body_str = border_str + number3_str + else: + # logger.info(number3_str) + logger.info(number_list) + body_str = number3_str + + url = f"https://puzz.link/p?{inst.puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" return url def _encode_nurikabe_variant(self, inst: PuzzleInstance): @@ -408,12 +577,10 @@ def _encode_nurikabe_variant(self, inst: PuzzleInstance): number_map: Dict[int, Any] = {} for (r, c), cell_state in inst.cells.items(): - if not cell_state.value: + if not cell_state.number.value: continue - val = cell_state.value - # if not val: - # print + val = cell_state.number.value r_grid = r - top_m c_grid = c - left_m @@ -553,26 +720,60 @@ def _decode_yajilin_variant(self): } def _decode_masyu_variant(self): + """ + Decode masyu-like variants into PuzzleInstance (IR). + + Notes: + - For `moonsun`, the body contains a border section (regions) followed by number3. + - For other masyu-like types, the body is number3 only. + - This method follows the same "fill self.ir_puzzle" style as `_decode_heyawake_variant`. + """ + margins = [0, 0, 0, 0] + region_grid = None + if self.puzzle_type in ["moonsun"]: - border_list = self._decode_border() region_grid, _ = self._convert_border_to_region_grid(border_list) info_number = self._decode_number3() - grid = self._convert_one_two_2_white_black_grid(info_number, category = "moonsun") - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - "grid": grid, - "region_grid": region_grid - } + grid = self._convert_one_two_2_white_black_grid(info_number, category="moonsun") + logger.info(grid) else: info_number = self._decode_number3() grid = self._convert_one_two_2_white_black_grid(info_number) - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - "grid": grid - } + logger.info(grid) + + # Fill IR (cells/edges mapping can be refined later by user) + self.ir_puzzle.puzzle_type = self.puzzle_type + self.ir_puzzle.title = self.puzzle_type + self.ir_puzzle.rows = self.num_rows + self.ir_puzzle.cols = self.num_cols + self.ir_puzzle.margins = margins + self.ir_puzzle.source = self.url + symbol_dict = { + "x": SymbolState(symbol_index=2, symbol_type="sun_moon", symbol_style=1), + "o": SymbolState(symbol_index=1, symbol_type="sun_moon", symbol_style=1), + "w": SymbolState(symbol_index=1, symbol_type="circle_L", symbol_style=1), + "b": SymbolState(symbol_index=2, symbol_type="circle_L", symbol_style=1), + } + self.ir_puzzle.cells = self._reindex_cells( + self.num_rows, + self.num_cols, + margins, + grid, + skip={"-"}, + symbol_dict=symbol_dict, + parse_number=False, # by default, these puzzles will not have numbers. + parse_symbol=True, # by default, these puzzles can and will only have symbols. + ) + + if region_grid is not None: + self.ir_puzzle.edges = self._reindex_edge(self.num_rows, self.num_cols, margins, region_grid) + else: + self.ir_puzzle.edges = {} + + self.ir_puzzle.boxes = generate_centerlist_diff( + self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins + ) def _move_numbers_to_top_left_corner(self, grid_matrix: List[List[str]], @@ -844,6 +1045,33 @@ def _decode_number3(self, max_iter: int = -1) -> List[int]: self.body = self.body[len(number_list) // 3:] return number_list + + def _encode_number3(self, number_list: List[int]) -> str: + """ + Reverse operation of `_decode_number3`. + + Pack every 3 trits (0/1/2) into one base36 char: + encoded = a*9 + b*3 + c + """ + if not number_list: + return "" + + def _int_to_base36(val: int) -> str: + if 0 <= val <= 9: + return str(val) + return chr(ord("a") + val - 10) + + result: List[str] = [] + i = 0 + while i < len(number_list): + a = number_list[i] if i < len(number_list) else 0 + b = number_list[i + 1] if i + 1 < len(number_list) else 0 + c = number_list[i + 2] if i + 2 < len(number_list) else 0 + packed = a * 9 + b * 3 + c + result.append(_int_to_base36(packed)) + i += 3 + + return "".join(result) def _decode_yajilin_arrows(self, parsing_castle: bool = False) -> Dict[int, List[Any]]: """Decode Yajilin arrows (or Castle arrows)""" @@ -1067,7 +1295,8 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, PzpCvtr = PuzzlinkConverter() url_list = [ # "https://puzz.link/p?nurikabe/7/7/2o2o3n8j1k5h2k", - "https://puzz.link/p?nurikabe/10/10/j2m3i2i2h.j6t4k..k3t6j.h4i4i2m2j", + # "https://puzz.link/p?moonsun/17/13/ga43qcc6htvn19vfuuaeiqssmklmdkhlpp4e3vo0g0elrj9d8lbdah2l65a19d9j98qldatj8qum3bajv5aii3003390o62000403m36200030032030j000i900b120ik3023j000006401000291p100000", + "https://puzz.link/p?mashu/14/8/330000096960006ik00039a00010j0i0000220" ] for url in url_list: @@ -1075,7 +1304,7 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, logger.info(p_ir) logger.info(p_ir.cells) url_new = PzpCvtr.encode(p_ir) - logger.info(url_new) + logger.info(f" -> {url_new}") # penpa_cvter = PenpaConverter() diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py index ed1d305d..abae7051 100644 --- a/tests/formats/conftest.py +++ b/tests/formats/conftest.py @@ -54,6 +54,8 @@ def penpa_test_urls(): # masyu "masyu_1": "m=edit&p=7Zdbb+o4EMff+RQrvx5rEydckkhHK65Hqlq2bOmyJULIgGkCCebk0rJBfPeODRW5uOfhrFbqSkuUYfiNGc/E5m8Rf09pxDCpYwubFtYxgavR0rFZN7AJWNz65Rr7ScCcX3A7TTwegeMlyT52NG2fZtNs+mvg77ba/reQxl6qkbpmaaapi5fdtJvw1vS3YE2bgiX6RvdFzDB0jH8fDPCaBjHDN0+bTm/bfu23/9IaU9N8HK6/bHqjx81q8icZ6b4W6cPA2t3d9zrBl2/Z9M5rv7A+a97HfOkFjK5oNp3cHILdwHr21qR743WtNd3p8XdrbL90Rl+/1txLP7PaMbOdrI2zb46LDITlTdAMZyPnmN05aMnDhY9w9gBxhMkMozANEn/JAx6hd5bdgkcQNsDtX92JjAuve4ZEB3948cF9AnfpR8uAzW/P5N5xszFGooCO/LZwUchfmJgMviY/n4sCsKAJrEfs+XuETQjE6Ypv08tQMjvhrP0TbUCm9zaEe25DeIo2RHf/uA3YNeyg6MCenU6wQn9AD3PHFe08Xl3r6j44R7BDaYm0T84RmTakITBNvjRUN1S0aSppC6hRocq8LeVYW+St0oaSWipKdF2JichRqYIQUUYVGyKJAisfBjFFJVVcbyorqatHN0Tu6uiW8omQliI3rONArqYh7RgWG2emtD1pdWkb0t7KMX1pJ9J2pa1L25RjWmK7/PSG+pfKcUF0heSqr8Z/PzarueghjdZ0yeCH3+Xhnsd+whCIL4p5MI/PsTk70GWCnPMhkI8U2C4NFww0K4cCzvdCQhQZ3kMF6D/veMSUIQHZ6vmjVCKkSLXg0apU0ysNgmIv8pgtoPNeL6AkAkHMfaZRxF8LJKSJVwC5M6CQie1KDzOhxRLplpZmC6+P41RDByRv18SGWMT/T8rPflKK1dI/m7x9tnLkRufRD1TnGixjhfYA/YH85KIq/oHS5KJlXpEVUWxVWYAqxAVoWV8AVSUGYEVlgH0gNCJrWWtEVWW5EVNVFEdMlRcdF8Ffh79TNKu9AQ==", + "masyu_2": "m=edit&p=7ZfdT+M4EMDf+1ec/LrWxY6bNIm0OpUCKyHg4IDjaFShtHVJIK27+QAuiP99xy6o+ZjysKeTeFhFmU5/Mx3PxPY4zb+XUSYpZ5Q7VHgUPuHqc4+6zKO+YOZ+vy6TIpXBb3RYFrHKQImLYp0HlrUuq3E1/j1NVg/W+o9llMelxZnFHUv4jLEZ812XM8F8kUwXfTYXtmszJu4FZ8xjLriIlAkffGE0+ufhIV1EaS7p0c393v7D8Olg+I/ljIW4Ol18ud8/v7qfX//Nz1liZew09VYnZ/t76Zdv1fgkHj7KA+me5WoWpzKaR9X4+ug5XR16d/GCj47ikbeIViz/7l36j3vnX7/2wrfiJr2Xyg+qIa2+BSGxCTU3JxNanQcv1UlAZmo5TQitLsBOKJ9QsizTIpmpVGXknVXHoHFCbVAPtuq1sWtttIGcgX76poN6A+osyWapvD3ekLMgrC4p0QnsmV9rlSzVo9SDwc/M901SAKZRAZOTx8maUAGGvJyrh/LNlU9eaTX8iTIg0nsZWt2UoTWkDF3dfy4DlpB8RirwJ6+vMEN/QQ23QajLudqq3la9CF5AnhrJjbwJXojgEIbDMPXUiBAY7fsYdRhKUV9Xx7U71MXooI9FGKBxPQ+jvo1SHbczGmfog4CtiXujyXHm4N5oepyhtXA+wDEehONBbLwcW3t3EzSrAMF4OX08QbM8ut4OnomDB3Fx7wE+DWaNIBjPxEcflW1mvuNtM3T12HwHRheEjc+lje062JCHZlvaRl7CrqWVMHLfSGakY+Sx8Tkw8trIkZF9I13jM9D7/qc7w/+UTgjHmD5Im5fzi2Fs0gvJRZktopmE02CklmuVJ4UkcCKTXKW3+cZ2K5+jWUGCzZtB3dJgq3I5lXCQ1VCq1FqfK0iEd1MDJncrlUnUpKGc3+0KpU1IqKnK5q2cnqI0bdZi3sQaaLNvGqjI4JSsfY+yTD01yDIq4gaovRg0IslV62EWUTPF6CFqjbbcPo7XHnkm5g4FtfUk/np9+uyvT3q22GdrlZ8tHbPQVfZB19ka2xjpPUA/aD81K8Z3dJqatc07bUUn2+0sQJHmArTdXwB1WwzATpcBtqPR6KjtXqOzarcbPVSn4+ih6k0nJPDn8t+STHo/AA==", + "masyu_3": "m=edit&p=7Zdtb+I4EMff8ylOfrvWESeQJpFWJx5XqtpeudLjSoSQAdMEEszmoeWC+O47dqjIg9sXezqpJx0mw/AbM54h5m8Rf09pxDDRxNOwMLzCaBFLXrplyks7j7GfBMz5BXfSxOMROF6S7GOn2dyn2TSb/hr4u21z/1tI47/TJtHEU4OHrdkLw9R0cIlBYdjg+ZppbIgGy+Lfh0O8pkHM8PXTptvfdl4Hnb+a7alhPN6tv2z6o8fNavInGWl+M9LuAmt3e9/vBl++ZdNbr/PCBsy8j/nSCxhd0Ww6uT4Eu6H17K1J79rrWWu60+Lv1th+6Y6+fm2452ZmjWNmO1kHZ98cF+kIy4ugGc5GzjG7ddCShwsf4ewB4giTGUZhGiT+kgc8Qm8suwGPIKyDO7i4ExkXXi+HRAP/7uyD+wTu0o+WAZvf5OTecbMxRqKArvy0cFHIX5hYDD4m3+dFAVjQBG5G7Pl7hA0IxOmKb9PzVDI74azzE21Aprc2hJu3ITxFG6K7f9wGbBl2UHRgz04nuEN/QA9zxxXtPF5c6+I+OEewd9ISaZ+cI2qZkIbAMsXSUFtXUltFTQJUr1FDOVesVpt7daWitjKD3VZR+HWosTIH0d5JIupQYGXbhKiTEHUSQ1RS65G0WmqsTtISldRnt9XNty0lNsWSFQybYSi3hC7tGHYMzgxp+9Jq0ralvZFzBtJOpO1J25LWlHOuxJ776V35L5XjGrlol0f7v8dmDRc9pNGaLhmoRI+Hex77CUOg1CjmwTzOY3N2oMsEOfmJUYyU2C4NFwwEroACzvdCbxQZ3kIl6D/veMSUIQHZ6vm9VCKkSLXg0apS0ysNgnIv8kQuoXxPl1ASgXoW3tMo4q8lEtLEK4HCgVHKxHaVLzOh5RLpllZWCy9fx6mBDkheroF1cRP/P1Y/+7Eq7pb22WTss5UjNzqPPlCdS7CKFdoD9AP5KURV/B2lKUSrvCYroti6sgBViAvQqr4AqksMwJrKAHtHaETWqtaIqqpyI5aqKY5Yqig6LpJ/MtCs8QM=" } @pytest.fixture @@ -104,4 +106,9 @@ def puzzlink_test_urls(): # nurikabe "nurikabe_1": "https://puzz.link/p?nurikabe/10/10/h5k7t6l5p2h4p2g2q4g7j2v3j2g", "nurikabe_2": "https://puzz.link/p?nurikabe/10/10/j2m3i2i2h.j6t4k..k3t6j.h4i4i2m2j", + + # masyu + "masyu_1": "https://puzz.link/p?mashu/14/8/330000096960006ik00039a00010j0i0000220", + "masyu_2": "https://puzz.link/p?mashu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030", + "masyu_3": "https://puzz.link/p?masyu/10/10/0000909b360200013a3a39000i063j1010" } \ No newline at end of file From 683534077784e3f1b5361975fab96a599484e47f Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sat, 21 Mar 2026 16:00:03 +0800 Subject: [PATCH 11/17] update scripts --- src/puzzlekit/formats/base.py | 5 + src/puzzlekit/formats/debug.py | 280 ++++++++++++++++++++ src/puzzlekit/formats/penpa_converter.py | 35 ++- src/puzzlekit/formats/penpa_template.py | 22 +- src/puzzlekit/formats/puzzlink_converter.py | 280 +++++++++++++++----- tests/formats/conftest.py | 33 ++- 6 files changed, 558 insertions(+), 97 deletions(-) diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py index 50c6b5b8..855d743a 100644 --- a/src/puzzlekit/formats/base.py +++ b/src/puzzlekit/formats/base.py @@ -80,6 +80,11 @@ class SurfaceColor(Enum): class NumberState: """_summary_ + Yajilin: value: "{a}_{b}" where a is number (or str) and b is Direction. + b: Direction: + 0: n; 1: w; 2: e; 3: s; 4: nw; 5: ne; 6: sw; 7: se; + + Returns: _type_: _description_ """ diff --git a/src/puzzlekit/formats/debug.py b/src/puzzlekit/formats/debug.py index e69de29b..8b63096c 100644 --- a/src/puzzlekit/formats/debug.py +++ b/src/puzzlekit/formats/debug.py @@ -0,0 +1,280 @@ +import argparse +import json +from pathlib import Path +from typing import Any, Dict, List, Tuple + +from puzzlekit.formats.base import PuzzleInstance +from puzzlekit.formats.penpa_converter import PENPA_PREFIX, PENPA_URLPREFIX, PenpaConverter +from puzzlekit.formats.puzzlink_converter import PuzzlinkConverter + + +def _read_nonempty_lines(path: str) -> List[str]: + with open(path, "r", encoding="utf-8") as f: + return [line.strip() for line in f.readlines() if line.strip()] + + +def _resolve_input_urls(args: argparse.Namespace) -> Tuple[str, str]: + # Priority: --pair-file > (--puzzlink-file/--penpa-file) > (--puzzlink-url/--penpa-url) + if args.pair_file: + lines = _read_nonempty_lines(args.pair_file) + if len(lines) < 2: + raise ValueError("--pair-file must contain at least 2 non-empty lines.") + return lines[0], lines[1] + + puzzlink_url = args.puzzlink_url + penpa_url = args.penpa_url + + if args.puzzlink_file: + lines = _read_nonempty_lines(args.puzzlink_file) + if not lines: + raise ValueError("--puzzlink-file is empty.") + puzzlink_url = lines[0] + + if args.penpa_file: + lines = _read_nonempty_lines(args.penpa_file) + if not lines: + raise ValueError("--penpa-file is empty.") + penpa_url = lines[0] + + if not puzzlink_url or not penpa_url: + raise ValueError( + "Please provide URLs via --pair-file, or both sides via URL/file arguments." + ) + + return puzzlink_url, penpa_url + + +def _normalize_penpa_url(url: str) -> str: + """ + PenpaConverter.decode expects text starting with `m=edit&p=`. + Accept full Penpa URL and convert it into expected format. + """ + url = url.strip() + if url.startswith(PENPA_PREFIX): + return url + if url.startswith(PENPA_URLPREFIX): + frag = url.split("#", 1)[1] if "#" in url else "" + return frag if frag.startswith(PENPA_PREFIX) else f"{PENPA_PREFIX}{frag}" + return url + + +def _detect_format(url: str) -> str: + u = url.strip() + if "puzz.link/p?" in u: + return "puzzlink" + if u.startswith(PENPA_PREFIX) or u.startswith(PENPA_URLPREFIX): + return "penpa" + raise ValueError("Unknown URL format. Please provide puzz.link or penpa URL.") + + +def decode_url_to_ir(url: str) -> Tuple[str, PuzzleInstance]: + fmt = _detect_format(url) + if fmt == "puzzlink": + return fmt, PuzzlinkConverter().decode(url) + normalized = _normalize_penpa_url(url) + return fmt, PenpaConverter().decode(normalized) + + +def summarize_ir(ir: PuzzleInstance) -> Dict[str, Any]: + return { + "grid_type": ir.grid_type, + "puzzle_type": ir.puzzle_type, + "rows": ir.rows, + "cols": ir.cols, + "margins": ir.margins, + "cells_count": len(ir.cells), + "edges_count": len(ir.edges), + "boxes_count": len(ir.boxes), + "source": ir.source, + } + + +def _diff_dict( + left: Dict[str, Any], right: Dict[str, Any], max_examples: int +) -> Dict[str, Any]: + left_keys = set(left.keys()) + right_keys = set(right.keys()) + only_left = sorted(left_keys - right_keys) + only_right = sorted(right_keys - left_keys) + common = sorted(left_keys & right_keys) + + changed = [] + for k in common: + if left[k] != right[k]: + changed.append(k) + + examples: List[Dict[str, Any]] = [] + for k in changed[:max_examples]: + examples.append({"key": k, "left": left[k], "right": right[k]}) + + return { + "only_left_count": len(only_left), + "only_right_count": len(only_right), + "changed_count": len(changed), + "only_left_examples": only_left[:max_examples], + "only_right_examples": only_right[:max_examples], + "changed_examples": examples, + } + + +def _diff_list(left: List[Any], right: List[Any], max_examples: int) -> Dict[str, Any]: + min_len = min(len(left), len(right)) + changed_indices = [i for i in range(min_len) if left[i] != right[i]] + + examples = [] + for i in changed_indices[:max_examples]: + examples.append({"index": i, "left": left[i], "right": right[i]}) + + extra_left = left[min_len : min_len + max_examples] + extra_right = right[min_len : min_len + max_examples] + + return { + "left_len": len(left), + "right_len": len(right), + "changed_count": len(changed_indices), + "extra_left_count": max(0, len(left) - min_len), + "extra_right_count": max(0, len(right) - min_len), + "changed_examples": examples, + "extra_left_examples": extra_left, + "extra_right_examples": extra_right, + } + + +def compare_irs(left: PuzzleInstance, right: PuzzleInstance, max_examples: int = 20) -> Dict[str, Any]: + ln = left.normalize() + rn = right.normalize() + + result = { + "semantic_equal": left.semantic_equals(right), + "meta": { + "rows_equal": ln["rows"] == rn["rows"], + "cols_equal": ln["cols"] == rn["cols"], + "margins_equal": ln["margins"] == rn["margins"], + "grid_type_equal": ln["grid_type"] == rn["grid_type"], + "rows": (ln["rows"], rn["rows"]), + "cols": (ln["cols"], rn["cols"]), + "margins": (ln["margins"], rn["margins"]), + "grid_type": (ln["grid_type"], rn["grid_type"]), + }, + "cells": _diff_dict(ln["cells"], rn["cells"], max_examples), + "edges": _diff_dict(ln["edges"], rn["edges"], max_examples), + "boxes": _diff_list(ln["boxes"], rn["boxes"], max_examples), + } + return result + + +def _print_section(title: str) -> None: + print(f"\n=== {title} ===") + + +def print_summary(tag: str, ir: PuzzleInstance) -> None: + s = summarize_ir(ir) + _print_section(tag) + print(json.dumps(s, ensure_ascii=False, indent=2)) + + +def print_diff_report(report: Dict[str, Any]) -> None: + _print_section("IR Comparison") + print(f"semantic_equal: {report['semantic_equal']}") + print("meta:", json.dumps(report["meta"], ensure_ascii=False)) + print("cells:", json.dumps(report["cells"], ensure_ascii=False)) + print("edges:", json.dumps(report["edges"], ensure_ascii=False)) + print("boxes:", json.dumps(report["boxes"], ensure_ascii=False)) + + +def _safe_roundtrip(fmt: str, ir: PuzzleInstance) -> Dict[str, Any]: + try: + if fmt == "puzzlink": + encoded = PuzzlinkConverter().encode(ir) + decoded = PuzzlinkConverter().decode(encoded) + elif fmt == "penpa": + encoded = PenpaConverter().encode(ir) + decoded = PenpaConverter().decode(encoded) + else: + return {"ok": False, "error": f"Unknown format: {fmt}"} + + return { + "ok": True, + "encoded_preview": encoded[:120], + "semantic_equal_after_roundtrip": ir.semantic_equals(decoded), + "encoded_length": len(encoded), + } + except Exception as e: + return {"ok": False, "error": str(e)} + + +def dump_normalized(prefix: str, left: PuzzleInstance, right: PuzzleInstance, report: Dict[str, Any]) -> None: + p = Path(prefix) + p.parent.mkdir(parents=True, exist_ok=True) + + with open(f"{prefix}_left.normalized.json", "w", encoding="utf-8") as f: + json.dump(left.normalize(), f, ensure_ascii=False, indent=2) + with open(f"{prefix}_right.normalized.json", "w", encoding="utf-8") as f: + json.dump(right.normalize(), f, ensure_ascii=False, indent=2) + with open(f"{prefix}_diff.report.json", "w", encoding="utf-8") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Debug IR equivalence between puzzlink and penpa URLs." + ) + parser.add_argument("--puzzlink-url", type=str, default="", help="puzz.link URL") + parser.add_argument("--penpa-url", type=str, default="", help="penpa URL or m=edit&p=...") + parser.add_argument( + "--puzzlink-file", + type=str, + default="", + help="read puzz.link URL from file (first non-empty line)", + ) + parser.add_argument( + "--penpa-file", + type=str, + default="", + help="read penpa URL from file (first non-empty line)", + ) + parser.add_argument( + "--pair-file", + type=str, + default="", + help="read two URLs from one file: line1=puzzlink, line2=penpa", + ) + parser.add_argument("--max-diff", type=int, default=20, help="max diff examples per section") + parser.add_argument( + "--check-roundtrip", + action="store_true", + help="also run format self roundtrip checks", + ) + parser.add_argument( + "--dump-prefix", + type=str, + default="", + help="optional file prefix to dump normalized json and diff report", + ) + args = parser.parse_args() + + puzzlink_url, penpa_url = _resolve_input_urls(args) + + left_fmt, left_ir = decode_url_to_ir(puzzlink_url) + right_fmt, right_ir = decode_url_to_ir(penpa_url) + + print_summary(f"Left ({left_fmt})", left_ir) + print_summary(f"Right ({right_fmt})", right_ir) + + report = compare_irs(left_ir, right_ir, max_examples=args.max_diff) + print_diff_report(report) + + if args.check_roundtrip: + _print_section("Roundtrip Checks") + print("left:", json.dumps(_safe_roundtrip(left_fmt, left_ir), ensure_ascii=False)) + print("right:", json.dumps(_safe_roundtrip(right_fmt, right_ir), ensure_ascii=False)) + + if args.dump_prefix: + dump_normalized(args.dump_prefix, left_ir, right_ir, report) + print(f"\nSaved debug artifacts with prefix: {args.dump_prefix}") + + +if __name__ == "__main__": + main() + +# PYTHONPATH=src python src/puzzlekit/formats/debug.py --pair-file src/puzzlekit/formats/temp/pair.txt \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index 828e89f2..0e1fcda3 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -172,23 +172,19 @@ def _decode_number(self, number_dict: Dict[str, int]): for index, num_data in number_dict.items(): (r, c), _ = self.index_to_coord(int(index), 'cell') - if num_data[2] == "1": - # NORMAL - if (r, c) not in self.ir_puzzle.cells: - self.ir_puzzle.cells[(r, c)] = CellState( - number = NumberState( - value = f"{num_data[0]}", - number_color = NumberColor(num_data[1]), - number_style = num_data[2] - ) - # value = f"{num_data[0]}", - # num_color = NumberColor(num_data[1]), - # num_style = num_data[2] + + if (r, c) not in self.ir_puzzle.cells: + self.ir_puzzle.cells[(r, c)] = CellState( + number = NumberState( + value = f"{num_data[0]}", + number_color = NumberColor(num_data[1]), + number_style = num_data[2] ) - else: - cell = self.ir_puzzle.cells[(r, c)] - cell.value, cell.num_color, cell.num_style = f"{num_data[0]}", NumberColor(num_data[1]), num_data[2] - self.ir_puzzle.cells[(r, c)] = cell + ) + else: + cell = self.ir_puzzle.cells[(r, c)] + cell.value, cell.num_color, cell.num_style = f"{num_data[0]}", NumberColor(num_data[1]), num_data[2] + self.ir_puzzle.cells[(r, c)] = cell # ELSE? def _decode_edge(self, edge_dict: Dict[str, int]): @@ -305,13 +301,12 @@ def encode(self, inst: PuzzleInstance) -> str: if __name__ == "__main__": for test_url in [ - # "m=edit&p=7Zdbb+o4EMff+RQrvx5rEydckkhHK65Hqlq2bOmyJULIgGkCCebk0rJBfPeODRW5uOfhrFbqSkuUYfiNGc/E5m8Rf09pxDCpYwubFtYxgavR0rFZN7AJWNz65Rr7ScCcX3A7TTwegeMlyT52NG2fZtNs+mvg77ba/reQxl6qkbpmaaapi5fdtJvw1vS3YE2bgiX6RvdFzDB0jH8fDPCaBjHDN0+bTm/bfu23/9IaU9N8HK6/bHqjx81q8icZ6b4W6cPA2t3d9zrBl2/Z9M5rv7A+a97HfOkFjK5oNp3cHILdwHr21qR743WtNd3p8XdrbL90Rl+/1txLP7PaMbOdrI2zb46LDITlTdAMZyPnmN05aMnDhY9w9gBxhMkMozANEn/JAx6hd5bdgkcQNsDtX92JjAuve4ZEB3948cF9AnfpR8uAzW/P5N5xszFGooCO/LZwUchfmJgMviY/n4sCsKAJrEfs+XuETQjE6Ypv08tQMjvhrP0TbUCm9zaEe25DeIo2RHf/uA3YNeyg6MCenU6wQn9AD3PHFe08Xl3r6j44R7BDaYm0T84RmTakITBNvjRUN1S0aSppC6hRocq8LeVYW+St0oaSWipKdF2JichRqYIQUUYVGyKJAisfBjFFJVVcbyorqatHN0Tu6uiW8omQliI3rONArqYh7RgWG2emtD1pdWkb0t7KMX1pJ9J2pa1L25RjWmK7/PSG+pfKcUF0heSqr8Z/PzarueghjdZ0yeCH3+Xhnsd+whCIL4p5MI/PsTk70GWCnPMhkI8U2C4NFww0K4cCzvdCQhQZ3kMF6D/veMSUIQHZ6vmjVCKkSLXg0apU0ysNgmIv8pgtoPNeL6AkAkHMfaZRxF8LJKSJVwC5M6CQie1KDzOhxRLplpZmC6+P41RDByRv18SGWMT/T8rPflKK1dI/m7x9tnLkRufRD1TnGixjhfYA/YH85KIq/oHS5KJlXpEVUWxVWYAqxAVoWV8AVSUGYEVlgH0gNCJrWWtEVWW5EVNVFEdMlRcdF8Ffh79TNKu9AQ==" - # "m=edit&p=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" - # masyu - "m=edit&p=7Zdbb+o4EMff+RQrvx5rEydckkhHK65Hqlq2bOmyJULIgGkCCebk0rJBfPeODRW5uOfhrFbqSkuUYfiNGc/E5m8Rf09pxDCpYwubFtYxgavR0rFZN7AJWNz65Rr7ScCcX3A7TTwegeMlyT52NG2fZtNs+mvg77ba/reQxl6qkbpmaaapi5fdtJvw1vS3YE2bgiX6RvdFzDB0jH8fDPCaBjHDN0+bTm/bfu23/9IaU9N8HK6/bHqjx81q8icZ6b4W6cPA2t3d9zrBl2/Z9M5rv7A+a97HfOkFjK5oNp3cHILdwHr21qR743WtNd3p8XdrbL90Rl+/1txLP7PaMbOdrI2zb46LDITlTdAMZyPnmN05aMnDhY9w9gBxhMkMozANEn/JAx6hd5bdgkcQNsDtX92JjAuve4ZEB3948cF9AnfpR8uAzW/P5N5xszFGooCO/LZwUchfmJgMviY/n4sCsKAJrEfs+XuETQjE6Ypv08tQMjvhrP0TbUCm9zaEe25DeIo2RHf/uA3YNeyg6MCenU6wQn9AD3PHFe08Xl3r6j44R7BDaYm0T84RmTakITBNvjRUN1S0aSppC6hRocq8LeVYW+St0oaSWipKdF2JichRqYIQUUYVGyKJAisfBjFFJVVcbyorqatHN0Tu6uiW8omQliI3rONArqYh7RgWG2emtD1pdWkb0t7KMX1pJ9J2pa1L25RjWmK7/PSG+pfKcUF0heSqr8Z/PzarueghjdZ0yeCH3+Xhnsd+whCIL4p5MI/PsTk70GWCnPMhkI8U2C4NFww0K4cCzvdCQhQZ3kMF6D/veMSUIQHZ6vmjVCKkSLXg0apU0ysNgmIv8pgtoPNeL6AkAkHMfaZRxF8LJKSJVwC5M6CQie1KDzOhxRLplpZmC6+P41RDByRv18SGWMT/T8rPflKK1dI/m7x9tnLkRufRD1TnGixjhfYA/YH85KIq/oHS5KJlXpEVUWxVWYAqxAVoWV8AVSUGYEVlgH0gNCJrWWtEVWW5EVNVFEdMlRcdF8Ffh79TNKu9AQ==" + "#m=edit&p=7VZdb+I4FH3nV6z8OtYmtoFCpNGKz5Gqtlu2dNkSIWSCIYGAmXy0TBD/fa4duiQhjLS70qoPoyhXJ+d++F47nBB+jXkgMGGYEMwa2MQErhqjuFqrY0Jv9G2erqEX+cL6BbfiyJUBADeKdqFlGLs4GSfjX31vuzZ2v33jKw+gQZhBiPGNmZyRHaEzQgNGVtQUjHJqckqT8RKc1FxWyZIRB+Pf+3284H4o8O3Lqt1dt956rb+M2pix54fFp1V38Lyaj/4kA9MzAvPBb2zvH7tt/9OXZHzvtl5FT9QfQ+m4vuBznoxHt3t/228s3QXp3LqdxoJvzfBrY9h8bQ8+f67Yp7EmlUPStJIWTr5YNiIIIwo3QROcDKxDcm8hR25mHsLJE/gRJhOMNrEfeY70ZYDeueQuzaYAe2c40n6FOilJTMAPJwzwBaDjBY4vpncp82jZyRAj1UBbZyuINvJVqMVUg+o5bQqIGY/gWELX2yHMwBHGc7mOT6FkcsRJ61+MAZXex1AwHUOhkjHUdP95DHhjxL5kgubkeIQT+gNmmFq2Guf5DBtn+GQdwD5YB1SvqVRzqrpUhwkVb4iulqWaVFF0amYonZijCNVhuUzCWLoAy3BVzdFcXPXUSC7uJo3Lcs1TvWxu2ksul5o6l0zh/fybIzou0x9sAtFb8aJtX1uq7RB2CidM2662prY1be90TE/bkbYdbava1nXMjdrrf3Qa/0M7NgOpunLVfnomFRs9xcGCOwJ+cx252cnQiwQC3UOh9Kdh6puKPXciZKX6m/XkuG28mQmQiwzlS7lTv96SCu+uHOkttzIQpS5FivnyWinlKik1k8G80NMb9/38LPpLl6NSucpRUQBalHnmQSDfcsyGR26OyMhvrpLYFjYz4vkW+ZoXVtuct+NYQXukb5thqg7x50fqo3+k1GmZH00cP1o7+kWXwQ9U5+ws0iXaA+wP5CfjLeOvKE3GW+QvZEU1e6kswJaIC7BFfQHqUmKAvFAZ4K4Ijapa1BrVVVFu1FIXiqOWyoqOjU5/3tGk8h0=" + # "m=edit&p=7Vj7b6M4EP69f8XJv673YptHSKTVKX2tVLXZ9tper42iiBLyhJAlkHap+r/v2CYBwiNU6lW30grFMt98M56xHY+H1ffQ9G1MGaY6VgxMMIVHbalYV3WsNqEPPxI/N9PAsdt/4E4YTDwfOpMgWK7ajcYyjB6ihz+d6WLeWP5leeEi8H80KGtQvTFbNtdDqisKoQY1iEV0oo+Hc2tuzdaLWbgOVEpMbWaOmwaBLjOIRwihKnXH7mjmLixGxmPlMXQ/q6araGOqTzD+dnqKR6azsvHZ/ezweN55Oun829AeFOW2O/o0O766nQ3v/qFXZNrwSdcxFheXx4fOp6/Rw8Wks7ZPbP1y5VkTxzaHZvRwd/bsLE6N8WREj84mR8bIXJDVd+OmtT68+vLloBfH3z94iVrtqIOjr+0eoggjBj+K+ji6ar9EF21kee7jFOHoGuQI0z5GbugEU8tzPB9tsOhcajPoniTdOyHnvSMJUgL9btyH7j10ralvOfbgXCKX7V50gxF34FBo8y5yvbXNB+MO8nfpFACPZgDrt5pMlwgrIFiFQ28exlTaf8VRR4QRdWtGAEY2EfCujID3diNoIdx6jwhgg9nec4H3rf7rK6zO3+D/oN3jodwmXSPpXrdfoO2Klor2XrSnomWivQEqjhTRHouWiFYT7bngnIj2TrRHolVFqwtOkw/2JneQRiGSzUZLnj5uYdTkc6C1eKgqU1SIFiODY4bENCYxlWOUNjmoDPj88x0qMEGcDiRPYowI5UEzxWNMrnFCUjjAbW0hxeAQG2gpPV2TYwK25emxHzrCxhYTugrLOKIL5zJjGLrgbRC5WIg1m8I/jgMLXoV38CeMX1UR0fZVOCU3x3+5zj1FFadm/tF+4x+B9w966Dr0R6Zlw4Fx5LlLbzUNbATnNVp5zmAlZQP72bQC1JZ5Iy3JYIvQfbThrEtBjuct+dFTYGEjyoDT8cLz7UIRB+3huMwUFxWYevT84Y5PT6bjZGMRqTwDybM2AwU+HKSpd9P3vacM4prBJAOk0kbGkr3YmczAzLpozs2d0dxkOl4P0DMSv56C4frxO7n+v5MrXynyi6VYHv5niqNvGC3DgTmAiUY8p74d72LIq1hmKRZnqRJKkhYrbOyhZLJ3nsMIN5NK3CWUTHLPc+C+D2YgP++hsFqc1H0gz9FFVMlNoIzBrezlpG4UBRyNBwVmypdJUDKXlYJlaHEzqWtKCSVzlSnh/KjBMWtwZjU4mTteQegirNRVq4AidgW/VFVTlDocVo+03yEgba+DVSRSh1Q9RTEJ/jofaalyN8ak97JT7TZje7e+oFQSlL37zNDlkZBn0KakGHyzSoYobXI2JCFV/JQx5MxVU+qYeZ+h9jKqCYXpSSjutazK3VHJ2BtAtt7McbTEE1GklhDkMJUMOQ/VlMqBKO71kq9XuZK6TGFHpZ5CSqmuwlalvkKsUkdh89RS2Fh/g0KiUlvhTTFspvPNCtu1qK+wbx0o5p8TYMeJ23CxmJ948aeIRAwVRyzmJ2b84SIR61uxWiQ2UuLsvx54KcX4k0fqBCX9D7+KiwLP8yuq7US4CxfU3IBWlN0paRFeUmGnpLt4rpzmzuYrakALimpAd+tqgPKlNYC56hqwkgKbW92tsblXu2U2HypXafOh0sU23ELN2RTmC/UPfgI=" ]: hpc = PenpaConverter(dict()) tmp = hpc.decode(test_url) + print(tmp.cells) enc = hpc.encode(tmp) print(tmp) print(enc) diff --git a/src/puzzlekit/formats/penpa_template.py b/src/puzzlekit/formats/penpa_template.py index a668c8fa..f819b9fa 100644 --- a/src/puzzlekit/formats/penpa_template.py +++ b/src/puzzlekit/formats/penpa_template.py @@ -39,11 +39,22 @@ "genre_tags": ["ayeheya (ekawayeh)"] }, + "country road": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["lineox",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"], + "genre_tags": ["country road"] + }, + "kurochute": { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', "user_tab_setting": ["Surface","Composite"], "genre_tags": ["kurochute"] }, + "yajilin" : { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["linex",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"], + "genre_tags": ["yajilin"] + }, "default" : { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', @@ -54,10 +65,12 @@ } PUZZLE_TYPE_ALIASES = { - + # slither aliases and variants "slither": "slitherlink", - "slitherlink": "slitherlink", - "vslither": "slitherlink", + "slitherlink": "slitherlink", + "vslither": "slitherlink", # vertex slither + "tslither": "slitherlink", # touching slither + # kuroshute aliases "kurochute": "kurochute", "kuroshuto": "kurochute", @@ -69,7 +82,8 @@ "simpleloop": "simpleloop", "heyawacky": "heyawake", - "heyawake": "heyawake" + "heyawake": "heyawake", + } def get_penpa_template(puzzle_type: str) -> dict: diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index 428ca3d9..2bbd5c57 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -9,10 +9,11 @@ import logging ALLOWED_PUZZLE_TYPE = { - "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", "ayeheya", + "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", "ayeheya", "country", "nonogram", "nurikabe", "kurochute", "kurodoko", "kurotto", "nurimisaki", - "moonsun", "masyu", "mashu", "pearl" + "moonsun", "masyu", "mashu", "pearl", + "slither", "slitherlink", "vslither", "tslither" } # allowed puzzle types @@ -76,6 +77,20 @@ def _decode_nonogram_variant(self): self.ir_puzzle.edges = auto_border_split(self.num_rows + rows_offset + 4, self.num_cols + cols_offset + 4, [rows_offset, 0, cols_offset, 0]) self.ir_puzzle.boxes = generate_centerlist_diff(self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins) + def _decode_slither_variant(self): + info_number = self._decode_number4() + grid = self._convert_number_map_to_grid(info_number) + self.ir_puzzle.puzzle_type = self.puzzle_type + self.ir_puzzle.title = self.puzzle_type + self.ir_puzzle.rows = self.num_rows + self.ir_puzzle.cols = self.num_cols + self.ir_puzzle.margins = [0, 0, 0, 0] + self.ir_puzzle.source = self.url + self.ir_puzzle.cells = self._reindex_number(self.num_rows, self.num_cols, [0, 0, 0, 0], grid, skip = "-") + # THINK: DO WE NEED TO ADD EDGES for pre-filled grid? + # NO. Because puzz.link not support edges for slither. + self.ir_puzzle.boxes = generate_centerlist_diff(self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins) + def _decode_heyawake_variant(self): border_list = self._decode_border() @@ -83,7 +98,6 @@ def _decode_heyawake_variant(self): number_map = self._decode_number16() grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] self._move_numbers_to_top_left_corner(grid, region_grid, number_map) - # puzzle_type self.ir_puzzle.puzzle_type = self.puzzle_type self.ir_puzzle.title = self.puzzle_type @@ -92,7 +106,9 @@ def _decode_heyawake_variant(self): self.ir_puzzle.margins = [0, 0, 0, 0] self.ir_puzzle.source = self.url self.ir_puzzle.cells = self._reindex_number(self.num_rows, self.num_cols, [0, 0, 0, 0], grid, skip = "-") - self.ir_puzzle.edges = self._reindex_edge(self.num_rows, self.num_cols, [0, 0, 0, 0], region_grid) + # Preserve every encoded border segment directly. + # Reconstructing from region ids can drop non-boundary helper segments. + self.ir_puzzle.edges = self._reindex_border_list(self.num_rows, self.num_cols, [0, 0, 0, 0], border_list) # puzzlink_pu.drawBorder(pu, info_edge, 2); // 2 is for Black Style # puzzlink_pu.drawNumbers(pu, info_number, 1, "1") // Black Style, Normal submode is 1 @@ -268,24 +284,37 @@ def _reindex_number( parse_symbol=False, ) - def _reindex_edge(self, r: int, c: int, margins: List[int], - region_grid: List[List[str]], skip: Set[str] = set()): + def _reindex_border_list(self, r: int, c: int, margins: List[int], border_list: Dict[int, int]): + """ + Convert puzz.link border ids directly into IR edges. + This keeps all border segments exactly as encoded. + """ new_edge_dict = dict() top_m, bottom_m, left_m, right_m = margins - for r_ in range(r): - for c_ in range(c): - if r_ > 0: - if region_grid[r_][c_] != region_grid[r_ - 1][c_]: # top - new_edge_dict[((r_ + top_m, c_ + left_m) , (r_ + top_m, c_ + left_m + 1))] = EdgeState(connected = True, edge_type = 2) - if r_ < r - 1: - if region_grid[r_][c_] != region_grid[r_ + 1][c_]: # bottom - new_edge_dict[((r_ + top_m + 1, c_ + left_m) , (r_ + top_m + 1, c_ + left_m + 1))] = EdgeState(connected = True, edge_type = 2) - if c_ > 0: - if region_grid[r_][c_] != region_grid[r_][c_ - 1]: # left - new_edge_dict[((r_ + top_m, c_ + left_m) , (r_ + top_m + 1, c_ + left_m))] = EdgeState(connected = True, edge_type = 2) - if c_ < c - 1: - if region_grid[r_][c_] != region_grid[r_][c_ + 1]: # right - new_edge_dict[((r_ + top_m, c_ + left_m + 1) , (r_ + top_m + 1, c_ + left_m + 1))] = EdgeState(connected = True, edge_type = 2) + num_vert = (c - 1) * r + num_horiz = c * (r - 1) + total = num_vert + num_horiz + + for border_id in border_list.keys(): + if border_id < 0 or border_id >= total: + continue + + if border_id < num_vert: + # Vertical border between cells (row, col) and (row, col+1) + row = border_id // (c - 1) + col = border_id % (c - 1) + p1 = (row + top_m, col + 1 + left_m) + p2 = (row + 1 + top_m, col + 1 + left_m) + else: + # Horizontal border between cells (row, col) and (row+1, col) + local = border_id - num_vert + row = local // c + col = local % c + p1 = (row + 1 + top_m, col + left_m) + p2 = (row + 1 + top_m, col + 1 + left_m) + + new_edge_dict[(p1, p2)] = EdgeState(connected=True, edge_type=2) + return new_edge_dict @@ -312,30 +341,15 @@ def decode(self, url: str) -> Dict[str, Any]: return self._decode_yajilin_variant() elif self.puzzle_type in ["moonsun","mashu", "masyu", "pearl"]: self._decode_masyu_variant() - # elif self.puzzle_type in ["slither", "slitherlink", "vslither"]: - # info_number = self._decode_number4() - # grid_matrix = self._convert_number_map_to_grid(info_number) - # return { - # "num_rows": self.num_rows, - # "num_cols": self.num_cols, - # "grid": grid_matrix - # } - - elif self.puzzle_type in [ - "heyawake", - "shikaku", - "aqre", - "heyawacky", - "shimaguni", - "stostone", - "ayeheya" - ]: + elif self.puzzle_type in ["slither", "slitherlink", "vslither", "tslither"]: + self._decode_slither_variant() + elif self.puzzle_type in ["heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", "ayeheya", "country"]: self._decode_heyawake_variant() elif self.puzzle_type in ['nonogram']: self._decode_nonogram_variant() elif self.puzzle_type in ['kurochute', "kurodoko", "kurotto", "nurikabe", "nurimisaki"]: self._decode_nurikabe_variant() - elif self.puzzle_type in ["country", "detour", "juosan", "yajilin-regions", "yajirin-regions"]: + elif self.puzzle_type in ["detour", "juosan", "yajilin-regions", "yajirin-regions"]: # toichika2, nagenawa, maxi, factors are neglected. return self.ir_puzzle elif self.puzzle_type in ["hitori"]: @@ -378,15 +392,7 @@ def encode(self, inst: PuzzleInstance) -> str: self.puzzle_type = inst.puzzle_type self.num_rows, self.num_cols = inst.rows - inst.margins[0] - inst.margins[1], inst.cols - inst.margins[2] - inst.margins[3] - if self.puzzle_type in [ - "heyawake", - "shikaku", - "aqre", - "heyawacky", - "shimaguni", - "ayeheya", - "stostone" - ]: + if self.puzzle_type in ["heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "ayeheya", "stostone", "country"]: body_str = self._encode_heyawake_variant(inst) return body_str elif self.puzzle_type in ['nonogram']: @@ -398,6 +404,9 @@ def encode(self, inst: PuzzleInstance) -> str: elif self.puzzle_type in ["moonsun", "masyu", "pearl", "mashu"]: body_str = self._encode_masyu_variant(inst) return body_str + elif self.puzzle_type in ["slither", "slitherlink", "vslither", "tslither"]: + body_str = self._encode_slither_variant(inst) + return body_str else: raise NotImplementedError(f"Puzzle type {self.puzzle_type} not supported for encoding") @@ -407,10 +416,38 @@ def _encode_heyawake_variant(self, inst: PuzzleInstance): border_list = self._region_grid_to_borders(inst.edges) region_grid, max_region_id = self._convert_border_to_region_grid(border_list) number_map: Dict[int, Any] = dict() + + # Region anchor index: choose cell nearest to (0, 0). + # Tie-break: later in row-major order wins. + region_top_left: Dict[str, tuple[int, int]] = {} + region_best_dist2: Dict[str, int] = {} + for r_ in range(self.num_rows): + for c_ in range(self.num_cols): + rid = region_grid[r_][c_] + dist2 = r_ * r_ + c_ * c_ + if ( + rid not in region_top_left + or dist2 < region_best_dist2[rid] + or dist2 == region_best_dist2[rid] + ): + region_top_left[rid] = (r_, c_) + region_best_dist2[rid] = dist2 + for k, cell_state in inst.cells.items(): (r_, c_) = k - val = int(cell_state.number.value) if cell_state.number.value.isdigit() else cell_state.number.value - number_map[int(region_grid[r_][c_])] = val + if cell_state.number is None or not cell_state.number.value: + continue + if not (0 <= r_ < self.num_rows and 0 <= c_ < self.num_cols): + continue + + rid = region_grid[r_][c_] + # Encode number only from region's top-left cell. + if region_top_left.get(rid) != (r_, c_): + continue + + val_raw = cell_state.number.value + val = int(val_raw) if str(val_raw).isdigit() else val_raw + number_map[int(rid)] = val border_str = self._encode_border(border_list) # 5. number_map → number16 @@ -607,6 +644,51 @@ def _encode_nurikabe_variant(self, inst: PuzzleInstance): url = f"https://puzz.link/p?{inst.puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" return url + def _encode_slither_variant(self, inst: PuzzleInstance): + """ + Encode slither-like PuzzleInstance to puzz.link URL. + + Reverse operation of `_decode_slither_variant` using number4 format. + Supported puzzle types share the same strategy: + - slither + - slitherlink + - vslither + - tslither + """ + top_m = inst.margins[0] + left_m = inst.margins[2] + + number_map: Dict[int, Union[int, str]] = {} + for (r, c), cell_state in inst.cells.items(): + if cell_state.number is None or cell_state.number.value is None: + continue + + r_grid = r - top_m + c_grid = c - left_m + if not (0 <= r_grid < self.num_rows and 0 <= c_grid < self.num_cols): + continue + + raw = str(cell_state.number.value).strip() + if raw == "": + continue + + if raw == "?": + val: Union[int, str] = "?" + else: + try: + parsed = int(raw) + except ValueError: + continue + if parsed < 0 or parsed > 4: + continue + val = parsed + + k = r_grid * self.num_cols + c_grid + number_map[k] = val + + body_str = self._encode_number4(number_map) + return f"https://puzz.link/p?{inst.puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" + def _region_grid_to_borders(self, edges_dict: Dict[Any, List[EdgeState]]) -> Dict[int, int]: """ Reconstruct edge dict from region_grid. @@ -729,11 +811,10 @@ def _decode_masyu_variant(self): - This method follows the same "fill self.ir_puzzle" style as `_decode_heyawake_variant`. """ margins = [0, 0, 0, 0] - region_grid = None + border_list = None if self.puzzle_type in ["moonsun"]: border_list = self._decode_border() - region_grid, _ = self._convert_border_to_region_grid(border_list) info_number = self._decode_number3() grid = self._convert_one_two_2_white_black_grid(info_number, category="moonsun") logger.info(grid) @@ -766,8 +847,10 @@ def _decode_masyu_variant(self): parse_symbol=True, # by default, these puzzles can and will only have symbols. ) - if region_grid is not None: - self.ir_puzzle.edges = self._reindex_edge(self.num_rows, self.num_cols, margins, region_grid) + if border_list is not None: + self.ir_puzzle.edges = self._reindex_border_list( + self.num_rows, self.num_cols, margins, border_list + ) else: self.ir_puzzle.edges = {} @@ -787,16 +870,23 @@ def _move_numbers_to_top_left_corner(self, Parse the {RegionID: Number} and fill it into the grid_matrix at the top left corner of the region. """ - # 1. Find the top left corner of each region + # 1. Find the anchor cell of each region: + # nearest to (0, 0); tie-break by later row-major visit. # region_start_points: {region_id: (r, c)} region_start_points = {} + region_best_dist2 = {} for r in range(self.num_rows): for c in range(self.num_cols): r_id = region_grid[r][c] - if r_id not in region_start_points: - # because the iteration is from top to bottom, and left to right + dist2 = r * r + c * c + if ( + r_id not in region_start_points + or dist2 < region_best_dist2[r_id] + or dist2 == region_best_dist2[r_id] + ): region_start_points[r_id] = (r, c) + region_best_dist2[r_id] = dist2 for r_id_raw, val in number_map.items(): r_id = str(r_id_raw) @@ -867,13 +957,13 @@ def _encode_number16(self, number_map: Dict[int, Any], max_region_id: int) -> st """ reverse operation of _decode_number16. - 参数: + Parameters: number_map: Dict[int, Optional[int, str]] - key = region_id (int 0 开始的连续/非连续整数) - value = 整数 或 '?' + key = region_id (int 0-based continuous/non-continuous integers) + value = integer or '?' - 返回: - str: 16 进制压缩字符串,可直接拼接到 body 中 + Returns: + str: 16-based compressed string, can be directly concatenated to body. """ if not number_map: return "" @@ -1025,6 +1115,63 @@ def _decode_number4(self) -> Dict[int, Union[int, str]]: i += 1 return number_map + + def _encode_number4(self, number_map: Dict[int, Union[int, str]]) -> str: + """ + Reverse operation of `_decode_number4`. + + Encoding table: + - '.' -> '?' + - '0'..'4' -> value 0..4 + - '5'..'9' -> value 0..4 plus skip 1 cell + - 'a'..'e' -> value 0..4 plus skip 2 cells + - 'g'..'z' -> skip 1..20 cells + """ + if not number_map: + return "" + + max_pos = max(number_map.keys()) + result: List[str] = [] + pos = 0 + + while pos <= max_pos: + if pos not in number_map: + skip_count = 0 + while pos <= max_pos and pos not in number_map and skip_count < 20: + skip_count += 1 + pos += 1 + result.append(chr(ord("g") + skip_count - 1)) + continue + + val = number_map[pos] + if val == "?": + result.append(".") + pos += 1 + continue + + n = int(val) + if not (0 <= n <= 4): + pos += 1 + continue + + next_missing = (pos + 1 <= max_pos and (pos + 1) not in number_map) + next2_missing = ( + pos + 2 <= max_pos + and (pos + 1) not in number_map + and (pos + 2) not in number_map + ) + + if next2_missing: + result.append(chr(ord("a") + n)) # a-e + pos += 3 + elif next_missing: + result.append(str(n + 5)) # 5-9 + pos += 2 + else: + result.append(str(n)) # 0-4 + pos += 1 + + return "".join(result) def _decode_number3(self, max_iter: int = -1) -> List[int]: """Decode number3 format (3 numbers per character)""" @@ -1294,17 +1441,15 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, from puzzlekit.formats.penpa_converter import PenpaConverter PzpCvtr = PuzzlinkConverter() url_list = [ - # "https://puzz.link/p?nurikabe/7/7/2o2o3n8j1k5h2k", - # "https://puzz.link/p?moonsun/17/13/ga43qcc6htvn19vfuuaeiqssmklmdkhlpp4e3vo0g0elrj9d8lbdah2l65a19d9j98qldatj8qum3bajv5aii3003390o62000403m36200030032030j000i900b120ik3023j000006401000291p100000", - "https://puzz.link/p?mashu/14/8/330000096960006ik00039a00010j0i0000220" + "https://puzz.link/p?country/12/16/jp7vd1633018180c0606gdkckcjvnjuvt410a5jag780410280o000141mgmfjmnc20gg3bum-4am35g16h" ] for url in url_list: p_ir = PzpCvtr.decode(url) logger.info(p_ir) logger.info(p_ir.cells) - url_new = PzpCvtr.encode(p_ir) - logger.info(f" -> {url_new}") + # url_new = PzpCvtr.encode(p_ir) + # logger.info(f" -> {url_new}") # penpa_cvter = PenpaConverter() @@ -1317,4 +1462,3 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, # from puzzlekit.formats.penpa_converter import PenpaConverter # penpa_url = PenpaConverter("") # penpa_test = penpa_url.encode(res) - \ No newline at end of file diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py index abae7051..aef57f16 100644 --- a/tests/formats/conftest.py +++ b/tests/formats/conftest.py @@ -26,9 +26,6 @@ def penpa_test_urls(): "nonogram_3": "m=edit&p=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", "nonogram_4": "m=edit&p=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", - # slitherlink - "slitherlink_1": "m=edit&p=7Vfrb+I4EP/OX3Hy17WO2CYPIq1OlNKVqrbbXtvrFYRQXkBoIGwSSpWq//vOmCJsQ3uPSqv9sEIZzfxmMi/jsVN+WwVFQplHmUWFRy3K4OdyTluAtWxLPtvfTVplif8b7ayqaV4AM62qZek3m8tV3a/7v2fp4qG5/KPM0mqaFE3mNZnVnITRhMeTOI5dEXMvjtdcWK7DOPNsJniYRrNoEc6CVAjmuYwLzxVCxOvQ4TFz4jAOJyyaRAGlX09O6DjIyoSe3s+Ojh86617n76bdF+L2Yvxpdnx1O4vv/mJXVtosrIvMW5xfHh9ln77U/fNp5zHpJc5lmUfTLAnioO7fnT5lixNvMh2z7um0642DhVV+827aj0dXnz83Bq9FDxvPdduvO7T+4g+IIJQweDgZ0vrKf67PfRLl8zAltL4GPaFsSMl8lVVplGd5QbZYfQYcvMmB7e3YO6lHrrsBmQX8xSsP7D2wUVpEWTI62yCX/qC+oQQTOJJvI0vm+WOCwTA5lDdJARAGFSxaOU2XhApQlKs4f1i9mrLhC607/6MM8LQtA9lNGcgdKAOr+3AZSTxJng5U0B6+vMAK/Qk1jPwBlnO7Y70de+0/A73wn0nLhVfxXw6vgzfbApHvxJYu2iCKndjWRIfroq51Ubtz5aJnRat7dj3dWHflMV3UXTGmR2JMGHq0x629lTG2aq+3hDGMrshc7xFsXUOv94FxIx53DHuMp9rrxTNuxBcoK/oW+lNkx8jHQX9KfNlcJZ7RXeZhfcr7RruZh/1U4nnYP9XeyMcz6vOM+G2jn22jf21j/drGerf1/w7naL/Lhxv95kZ/ueyv8r7A+Iq9MPwJI55Af4q+Zehbht42/Nt6P7mN/VJkB/uzXT/Ytkxu3ntJTyTlkt7A3qa1kPRYUktSW9IzadOT9E7SrqQtSR1p4+J0+E/z4wekM2g58hh+/2f/svmozbAxINerYhxECZw33Xy+zMu0Sgic+aTMs1G50Y2SpyCqiL+5e6gaDVus5mECR6UCZXm+hEvRIQ9blQamk0VeJAdVCOIh+IYrVB1wFeZFbOS0DrJMr0XeATVoc1RrUFXAOazIQVHkaw2ZB9VUA5Srh+YpWRjNrAI9xeAhMKLNd+14aZAnIh8YOTA0fl3Qfv4LGq6W9bON2X9IZ1D3KIFvEirgeKT1V0qWq1EwgnYT+CqgH1H/G4Mf3g25z/LinaG3U5rwgdEH6DvTT9Eewt8YdIrWxPemGia7P9gAPTDbADXHG0D7Ew7AvSEH2BtzDr2aow6zMqcdhtobeBhKnXkD8vpVjd/YZNj4Dg==", - # stostone "stostone_1": "m=edit&p=7VZtb6pIFP7ur9jM106uMAPUkjQbX5s0rbeudt1qjEEchYpgebENxv/eMwMooO1Nd3OTftggJ888Z+a8zMCDwUtk+AxrcNEalrAMF9E0ccuKIm4pvQZ26DD9D1yPQsvzAVhhuAn0anUTxaN49MOx3VV182cQevBzWVWDaynZtkm2rkSZuaREVZcY/+x08MJwAoZvn54brVX9tV3/p6qOKH3sLi6eW73H5/nwb7kn2VVf6jo19/6h1XAubuLRvVXfsjbTHgLPtBxmzI14NLx9c9xObWkt5Oat1awtDFcKXmqDq22jd31dGafVTyq7+EqP6zi+0cdIRhgRuGU0wXFP38X3etzHcR9cCMsTjNaRE9qm53g+yrj4LllIALaPcCj8HDUTUpYAd1MM8Amgafumw6Z3CfOgj+MBRjx3Q6zmEK29LePJeG18bHrrmc2JmRHCxgeWvUGYgiOI5t4qSqfKkz2O61/rAIJkHXCYdMDRmQ54Y7+3g6vJfg+H8xf0MNXHvJ3HI6wdYV/fge3qO0QoLKXwpIrzQ5TAkByGCveqh6HGvdkQAsgizJOwHWGJsAPIgmMqbEtYSVhV2Dsxpw3JZRlSEQhKICIFrGQYkihqghXg1ZRXgFdTXoWqNahPYHi7NCXBGsy5TOcQeAez+ITnSucTWEvS+QTWkmw+rCVaiuGtJZfH2mi6lsJamq6lsJZm9UCurE6V15yr7VAzrz+Nr0J8NY2v8V6y+nkvfD5s0lBsVVNYRVhNbOElP8YvHfR/P61fljPmO3y4+GPyL/GkMkb9yF8YJkOgNSjwnGmQjKfszTBDpCeal/cUODdazxi8rDnK8bwNqOq5CJmrQNpL1/PZWRcn2Xz5USjuOhNq5vnzUk2vhuMUexGfkAKViEWBCn1QgtzY8H3vtcCsjdAqEDnVKERibmkzQ6NYorEyStnWx+3YV9AbEjdICBz+/x+Gb/1h4AclfTfV+G7liGfc8z8RnKOzTJ+RHWA/UZ6c9xz/gcjkvGX+RFF4saeiAuwZXQG2LC1AnaoLkCcCA9wHGsOjlmWGV1VWGp7qRGx4qrzejFH2lxhNKu8=", "stostone_2": "m=edit&p=7Zdtb+I4FIW/91es/HWsJbZJcCKNVvRtpKrTbbftdluEqjSElzaQThJolar/fY7tW0GAzmq0WqlaLRD75Jqc55qYayi/zeMi5UJy4XGluccFnu2wzdtCcyU79vDoeTGpsjT6hXfn1TgvIMZV9VhGrdbjvL6pb37NJrOH1uNvZZXjNUtbQraE1/I2HtK2ncXc84YLPJxeLHJvlCRJMOL898NDPoyzMuVH1/e7+w/dp4PuXy3/RqnLk+Gn+/2zy/vB1Z/izJu0Cu8k07Ovp/u72acv9c3XcXeRHqTBaZkn4yyNB3F9c3X0nM0O9Wg8FHtH4z09jGde+U1fhIvds8+fd3o0v/7OSx1GdZfXX6IeE4wziUOwPq/Popf6a1Sf8/ocQ4yLPmfTeVZNkjzLC/YWq4/dhRLyYCmv7LhRey4oPOgT0pDXkMmkSLL09thFTqNefcGZYe/aq41k03yRGpjJzZwn+fRuYgJ3cYVbU44nj4wrDJTzQf4wp7eK/iuvuz83A5i8zcBINwOjtszATOzfnUHYf33FzfkDc7iNemY6l0upl/I8ekF7Er2wIMClZlm7G8hC3TwXor0WaJsrAjqHi7Be17Y9tK207QVQvFa23betZ1vftsf2PQfIQHV8rkLBIol11FFcdkC0ug3tk/ahAbY6gO6Q7kAjZas1dEg65FJ7TmsPmvy1gJakJbQiDa4mrgZXO67ykJvnuOihHRc9tOOih3Zc9FwJx0UP7bjooR0XPbTjood2XPTQxBXgCuIKcAVxJXwk+Uj4SPKR8JHkI+EjyUfCR5KPhI988zEFi/KXpoBR/hL5K8pfIX9F+StwFXEVuIq4Prg+cX1wfeL64PrE9cH1ieuD6xPXB9cnrg9uQNwA3IC4AbgBcQNwA+IG4AbEDcANiGvWEq0T9NDExTpRtE7QQxMX60TROkEPTVwNH00+Gj6afDR8NPlo+Gjy0fAJySeEj13PWOBXdpnv2bZt28Au/475Hv7UN/Wff9P+Np2eatttbfPp/zfj/Z0eO58XwzhJGTY1VubZbenOb9PnOKlY5DbX1ZFGbDaf3qXYFVZCWZ4/YoPf5vA21AhORrO8SLcOmWA6GL1nZYa2WN3lxWAtp6c4y5pzsb9nGiG3KzVCVYEtZ+U8Lor8qRGZxtW4EVjZnhpO6Wztw6ziZorxQ7xGmy4/jtcd9szs0UOhNjfu/18gH/gXiLlR3kerbh8tHbvG8+IHBWc5uB7eUnYQ/UHlWRndFn+nyKyMrsc3KopJdrOoILqlriC6XloQ2qwuCG4UGMTeqTHGdb3MmKzWK41BbRQbg1qtNz329u+M9Xe+Aw==", @@ -55,7 +52,20 @@ def penpa_test_urls(): # masyu "masyu_1": "m=edit&p=7Zdbb+o4EMff+RQrvx5rEydckkhHK65Hqlq2bOmyJULIgGkCCebk0rJBfPeODRW5uOfhrFbqSkuUYfiNGc/E5m8Rf09pxDCpYwubFtYxgavR0rFZN7AJWNz65Rr7ScCcX3A7TTwegeMlyT52NG2fZtNs+mvg77ba/reQxl6qkbpmaaapi5fdtJvw1vS3YE2bgiX6RvdFzDB0jH8fDPCaBjHDN0+bTm/bfu23/9IaU9N8HK6/bHqjx81q8icZ6b4W6cPA2t3d9zrBl2/Z9M5rv7A+a97HfOkFjK5oNp3cHILdwHr21qR743WtNd3p8XdrbL90Rl+/1txLP7PaMbOdrI2zb46LDITlTdAMZyPnmN05aMnDhY9w9gBxhMkMozANEn/JAx6hd5bdgkcQNsDtX92JjAuve4ZEB3948cF9AnfpR8uAzW/P5N5xszFGooCO/LZwUchfmJgMviY/n4sCsKAJrEfs+XuETQjE6Ypv08tQMjvhrP0TbUCm9zaEe25DeIo2RHf/uA3YNeyg6MCenU6wQn9AD3PHFe08Xl3r6j44R7BDaYm0T84RmTakITBNvjRUN1S0aSppC6hRocq8LeVYW+St0oaSWipKdF2JichRqYIQUUYVGyKJAisfBjFFJVVcbyorqatHN0Tu6uiW8omQliI3rONArqYh7RgWG2emtD1pdWkb0t7KMX1pJ9J2pa1L25RjWmK7/PSG+pfKcUF0heSqr8Z/PzarueghjdZ0yeCH3+Xhnsd+whCIL4p5MI/PsTk70GWCnPMhkI8U2C4NFww0K4cCzvdCQhQZ3kMF6D/veMSUIQHZ6vmjVCKkSLXg0apU0ysNgmIv8pgtoPNeL6AkAkHMfaZRxF8LJKSJVwC5M6CQie1KDzOhxRLplpZmC6+P41RDByRv18SGWMT/T8rPflKK1dI/m7x9tnLkRufRD1TnGixjhfYA/YH85KIq/oHS5KJlXpEVUWxVWYAqxAVoWV8AVSUGYEVlgH0gNCJrWWtEVWW5EVNVFEdMlRcdF8Ffh79TNKu9AQ==", "masyu_2": "m=edit&p=7ZfdT+M4EMDf+1ec/LrWxY6bNIm0OpUCKyHg4IDjaFShtHVJIK27+QAuiP99xy6o+ZjysKeTeFhFmU5/Mx3PxPY4zb+XUSYpZ5Q7VHgUPuHqc4+6zKO+YOZ+vy6TIpXBb3RYFrHKQImLYp0HlrUuq3E1/j1NVg/W+o9llMelxZnFHUv4jLEZ812XM8F8kUwXfTYXtmszJu4FZ8xjLriIlAkffGE0+ufhIV1EaS7p0c393v7D8Olg+I/ljIW4Ol18ud8/v7qfX//Nz1liZew09VYnZ/t76Zdv1fgkHj7KA+me5WoWpzKaR9X4+ug5XR16d/GCj47ikbeIViz/7l36j3vnX7/2wrfiJr2Xyg+qIa2+BSGxCTU3JxNanQcv1UlAZmo5TQitLsBOKJ9QsizTIpmpVGXknVXHoHFCbVAPtuq1sWtttIGcgX76poN6A+osyWapvD3ekLMgrC4p0QnsmV9rlSzVo9SDwc/M901SAKZRAZOTx8maUAGGvJyrh/LNlU9eaTX8iTIg0nsZWt2UoTWkDF3dfy4DlpB8RirwJ6+vMEN/QQ23QajLudqq3la9CF5AnhrJjbwJXojgEIbDMPXUiBAY7fsYdRhKUV9Xx7U71MXooI9FGKBxPQ+jvo1SHbczGmfog4CtiXujyXHm4N5oepyhtXA+wDEehONBbLwcW3t3EzSrAMF4OX08QbM8ut4OnomDB3Fx7wE+DWaNIBjPxEcflW1mvuNtM3T12HwHRheEjc+lje062JCHZlvaRl7CrqWVMHLfSGakY+Sx8Tkw8trIkZF9I13jM9D7/qc7w/+UTgjHmD5Im5fzi2Fs0gvJRZktopmE02CklmuVJ4UkcCKTXKW3+cZ2K5+jWUGCzZtB3dJgq3I5lXCQ1VCq1FqfK0iEd1MDJncrlUnUpKGc3+0KpU1IqKnK5q2cnqI0bdZi3sQaaLNvGqjI4JSsfY+yTD01yDIq4gaovRg0IslV62EWUTPF6CFqjbbcPo7XHnkm5g4FtfUk/np9+uyvT3q22GdrlZ8tHbPQVfZB19ka2xjpPUA/aD81K8Z3dJqatc07bUUn2+0sQJHmArTdXwB1WwzATpcBtqPR6KjtXqOzarcbPVSn4+ih6k0nJPDn8t+STHo/AA==", - "masyu_3": "m=edit&p=7Zdtb+I4EMff8ylOfrvWESeQJpFWJx5XqtpeudLjSoSQAdMEEszmoeWC+O47dqjIg9sXezqpJx0mw/AbM54h5m8Rf09pxDDRxNOwMLzCaBFLXrplyks7j7GfBMz5BXfSxOMROF6S7GOn2dyn2TSb/hr4u21z/1tI47/TJtHEU4OHrdkLw9R0cIlBYdjg+ZppbIgGy+Lfh0O8pkHM8PXTptvfdl4Hnb+a7alhPN6tv2z6o8fNavInGWl+M9LuAmt3e9/vBl++ZdNbr/PCBsy8j/nSCxhd0Ww6uT4Eu6H17K1J79rrWWu60+Lv1th+6Y6+fm2452ZmjWNmO1kHZ98cF+kIy4ugGc5GzjG7ddCShwsf4ewB4giTGUZhGiT+kgc8Qm8suwGPIKyDO7i4ExkXXi+HRAP/7uyD+wTu0o+WAZvf5OTecbMxRqKArvy0cFHIX5hYDD4m3+dFAVjQBG5G7Pl7hA0IxOmKb9PzVDI74azzE21Aprc2hJu3ITxFG6K7f9wGbBl2UHRgz04nuEN/QA9zxxXtPF5c6+I+OEewd9ISaZ+cI2qZkIbAMsXSUFtXUltFTQJUr1FDOVesVpt7daWitjKD3VZR+HWosTIH0d5JIupQYGXbhKiTEHUSQ1RS65G0WmqsTtISldRnt9XNty0lNsWSFQybYSi3hC7tGHYMzgxp+9Jq0ralvZFzBtJOpO1J25LWlHOuxJ776V35L5XjGrlol0f7v8dmDRc9pNGaLhmoRI+Hex77CUOg1CjmwTzOY3N2oMsEOfmJUYyU2C4NFwwEroACzvdCbxQZ3kIl6D/veMSUIQHZ6vm9VCKkSLXg0apS0ysNgnIv8kQuoXxPl1ASgXoW3tMo4q8lEtLEK4HCgVHKxHaVLzOh5RLpllZWCy9fx6mBDkheroF1cRP/P1Y/+7Eq7pb22WTss5UjNzqPPlCdS7CKFdoD9AP5KURV/B2lKUSrvCYroti6sgBViAvQqr4AqksMwJrKAHtHaETWqtaIqqpyI5aqKY5Yqig6LpJ/MtCs8QM=" + "masyu_3": "m=edit&p=7Zdtb+I4EMff8ylOfrvWESeQJpFWJx5XqtpeudLjSoSQAdMEEszmoeWC+O47dqjIg9sXezqpJx0mw/AbM54h5m8Rf09pxDDRxNOwMLzCaBFLXrplyks7j7GfBMz5BXfSxOMROF6S7GOn2dyn2TSb/hr4u21z/1tI47/TJtHEU4OHrdkLw9R0cIlBYdjg+ZppbIgGy+Lfh0O8pkHM8PXTptvfdl4Hnb+a7alhPN6tv2z6o8fNavInGWl+M9LuAmt3e9/vBl++ZdNbr/PCBsy8j/nSCxhd0Ww6uT4Eu6H17K1J79rrWWu60+Lv1th+6Y6+fm2452ZmjWNmO1kHZ98cF+kIy4ugGc5GzjG7ddCShwsf4ewB4giTGUZhGiT+kgc8Qm8suwGPIKyDO7i4ExkXXi+HRAP/7uyD+wTu0o+WAZvf5OTecbMxRqKArvy0cFHIX5hYDD4m3+dFAVjQBG5G7Pl7hA0IxOmKb9PzVDI74azzE21Aprc2hJu3ITxFG6K7f9wGbBl2UHRgz04nuEN/QA9zxxXtPF5c6+I+OEewd9ISaZ+cI2qZkIbAMsXSUFtXUltFTQJUr1FDOVesVpt7daWitjKD3VZR+HWosTIH0d5JIupQYGXbhKiTEHUSQ1RS65G0WmqsTtISldRnt9XNty0lNsWSFQybYSi3hC7tGHYMzgxp+9Jq0ralvZFzBtJOpO1J25LWlHOuxJ776V35L5XjGrlol0f7v8dmDRc9pNGaLhmoRI+Hex77CUOg1CjmwTzOY3N2oMsEOfmJUYyU2C4NFwwEroACzvdCbxQZ3kIl6D/veMSUIQHZ6vm9VCKkSLXg0apS0ysNgnIv8kQuoXxPl1ASgXoW3tMo4q8lEtLEK4HCgVHKxHaVLzOh5RLpllZWCy9fx6mBDkheroF1cRP/P1Y/+7Eq7pb22WTss5UjNzqPPlCdS7CKFdoD9AP5KURV/B2lKUSrvCYroti6sgBViAvQqr4AqksMwJrKAHtHaETWqtaIqqpyI5aqKY5Yqig6LpJ/MtCs8QM=", + + # slitherlink + "slitherlink_1": "m=edit&p=7VdfT+M4EH/nU5z8utY1tpvEjbQ6lQIrIeDggONoVaH8a5uSNt0kpSiI774zLlVttyDdrXTiYRVlNPOb8fxzM3ar78uwTCmTlDlUSOpQBo/POW0D1nYd9W6em6zO0+A32l3Wk6IEZlLXiypotRbLpt/0f8+z+WNr8UeVZ/UkLVtMtpjTGkfxmCfjJEl8kXCZJCsuHN9jnEmXCR5l8TSeR9MwE4JJn3EhfSFEsoo8njAviZJozOJxHFL658kJHYV5ldLT++nh0WN3ddz9p+X2hbi9GH2ZHl3dTpO7v9mVk7VK5yKX8/PLo8P8y7emfz7pPqXHqXdZFfEkT8MkbPp3p8/5/ESOJyPWO5305CicO9V3edN5Orz6+vVg8Fb08OCl6QRNlzbfggERhBIGLydD2lwFL815QOJiFmWENtegJ5QNKZkt8zqLi7woyQZrzoCDlRzY4y17p/TI9dYgc4C/eOOBvQc2zso4Tx/O1shlMGhuKMEEDtVqZMmseEoxGCaH8jopAKKwhk2rJtmCUAGKapkUj8s3UzZ8pU33P5QBnjZlILsuA7k9ZWB1P11GmozT5z0VdIavr7BDf0END8EAy7ndsnLLXgcvQC+CF9L2YSn+ymE5eHMdEPlWbJuiC6LYih1D9LgpmloftVtXPnrWtKZnX5rGpivJTNF0xZgZiTFh6dEeP+2NjLF1e7MljGF0TeZmj+DTtfRmHxi34nHPssd4ur1ZPONWfIGypm+jP032rHw89KfFV83V4lndZRLr09Zb7WYS+6nFk9g/3d7KR1r1SSt+x+pnx+pfx9q/jrXfHfO3wznab/PhVr+51V+u+qutFxhfsxeWP2HFE+hP07ctfdvSu5Z/1+wnd7Ffmuxhfzb7B58tUx/vvaIninJFb+Dbpo1Q9EhRR1FX0TNlc6zonaI9RduKesrGx+nwr+bH/5DOoO2pY/jjx/1l87M2w4MBuV6WozBO4bzpFbNFUWV1SuDMJ1WRP1Rr3UP6HMY1CdZ3D11jYPPlLErhqNSgvCgWcCna52GjMsBsPC/KdK8KQTwE33GFqj2uoqJMrJxWYZ6btag7oAGtj2oDqks4hzU5LMtiZSCzsJ4YgHb1MDylc6uZdWimGD6GVrTZth2vB+SZqBdGDgyNXxe0z39Bw91yPtuY/WzpqB96UX4wdbZKG94zewD9YPxo2n34O5NG09r4zljBZHcnC6B7hgug9nwBaHfEALgzZQB7Z9CgV3vWYFb2uMFQOxMHQ+lDZ0De/tbin1wyPPgB", + "slitherlink_2": "m=edit&p=7VZbb+I8EH3nV3zya63NraQhUrXiWqlq2bKlyxaEkAOGpBhMc2lREP+9YwMiDmml7kqf+rCKMpw5Y49nHPuI6DkhIcVlbGPLwTo24DFNB5u2Db+6fA9PN4gZdf/D1ST2eQjAj+NV5GraKkn7af8bC5ZzbfU9YkHs01Ara7ZmejNP9zzfM8hkZngY/2i18JSwiOLrx6daY159bVZ/a+W+ZT20p2dPjc7D06T3y+jogRbqbeYsb+8aNXZ2lfZv/eoLbVL7LuJjn1EyIWm/d71my5Yz86dG/dqvO1Oy1KNnp1t5qXUuL0uDfeXD0iatuGkVp1fuAFkIIwNeEw1x2nE36a2LxnzhBQin9xBH2BhitEhYHIw54yE6cOkNIJhpAmweYU/GBarvSEMH3N5jgI8Ax0E4ZnR0s2Pu3EHaxUgUUJOzBUQL/kLFYqI44e+KAsIjMex85AcrhC0IRMmEz5P9UGO4xWn1D9qATIc2BNy1IVBBG6K7v26DTmZ0XdBBZbjdwhf6CT2M3IFo5+EInSO8dzdg2+4GmTpMNeGownTIZhrgipO7dyuKa5ngipO9dy0lei5SZVx1btlW3QsllS3mWkfXUQbb2VRQuCHLf5S2Ja0pbRe6w6klbUNaXdqytDdyTFPanrR1ac+lteWYC7E/n9rB/6GcASiH6F485c+iYWmA7pNwSsYUTlGdL1Y8CmKK4CajiLNRtIuN6JqMY+TuFCUbUbhlsvAoXIAMxThfgV4VZTiEFDKYLXlIC0OCFEf7nVQiVJDK4+EkV9MrYUztRYqzQu0uoELFIdyujE/CkL8qzILEvkJkBEXJRJe5zYyJWiKZk9xqi+N2bEtojeQLlwJu5z/Z/fqyK76W/tWk46uVIw86Dz9QnWMwTxdoD7AfyE8mWsS/ozSZaJ4/kRVR7KmyAFsgLsDm9QWoU4kB8kRlgHtHaETWvNaIqvJyI5Y6URyxVFZ0Bmj/j1P8/0TD0hs=", + + # vslither + "vslither_1": "m=edit&p=7VZbb+o4EH7nV6z8WuuQC+QmVSuulaqWLVt6OAUhFIJpUgKmubQoiP/eGQMnF9JKu2d11IeVyWjmm/F4xrE/Er7EdsCoDkM1qERlGKpUE48m4e80Bl7kM+sP2ogjlweguFG0Ca1qdRMno2T0zffWy+rmz9D3IpcFVR2HpjlLXdfYUjdMtjR1bUbpX90uXdh+yOj143OzvWy8dRo/qvWRqj70FhfP7f7D83z4Xe5LXjWQer6xvr1rN/2Lq2R06zZeWYdpdyF3XJ/ZczsZDa+3/rprPLkLuXXttoyFvZbCF2Ngvjb7l5eV8bH6SWWXmFbSoMmVNSYqoUSGRyETmvStXXJrEYevZh6hyT34CZUnlKxiP/Ic7vOAnLDkBjSYqYDaSdWh8KPWOoCyBHrvqIP6CKrjBY7PpjcH5M4aJwNKsICmmI0qWfFXhothcWgfigJgZkew+6HrbQhVwRHGc76Mj6HyZE+Txr9oAzKd2kD10AZqJW1gd7/cBps/sW1JB+Zkv4c39Df0MLXG2M5Dqhqpem/tQPasHVFqMFWBwwrTIZuigYln92gaOVOVcsE1DM6YGJyadQxO59YVMGs/TS0frGOwmpr5YB2LTE0TgzMmBqepTAxO1zWzHUHTsmj9UciukIqQA9gZmqhCtoWUhKwLeSNiOkIOhWwJWRNSEzE67u0/2v3fUM5YwY1KR/2/tyaVMbmPg4XtMDinLb7a8NCLGAGuICH3p+HBN2Vb24mIdeCsrCeHrePVjMEVy0A+5xtgxbIMJ1cO9J7WPGClLgTx8nyQCl0lqWY8mBdqerN9P9+L+APIQYcrnoOiAO5vxraDgL/lkJUduTkgQ1m5TGxd2MzIzpdoL+3Caqt0O/YVsiXigWsHl+d/Yv/6xI5vS/pqBPPVyhEHnQefsE7qLMIl3APoJ/ST8ZbhHzBNxlvEz2gFiz1nFkBLyAXQIr8AdE4xAJ6xDGAfEA1mLXINVlWkG1zqjHFwqSzpjMnxuxa/csmk8g4=", + + # tslither + "tslither_1": "m=edit&p=7VZtb6NGEP7uX1Ht11sVFgzGSFHlOMlJUZKLm6RpbFkRhjWQYK+Pl8Qiyn+/mcUWLCYntZXafKgQo5lndt521w/OvhdeyqlNGaOmQ3XK4DFsm/Ytm1pGX7767rmN84S7v9BRkUciBSXK803matqmKKfl9NckXj9rm9+yJM4jnmq2xphm+OEiNAO2CIPQ8M0AbB9sCRs+PqFP6bezM7r0kozT84en45Pn0evp6E/Nmprm3dXyy9PJ5O4puP+DTfRYS/WrxFlfXp8cJ1++ltPLaPTCT7l9nQk/SrgXeOX0/nybrM+cMFqy8Xk0dpbeWs++O7fDl+PJ0VFvtptn3nsrh245ouVXd0ZMQgmD1yBzWk7ct/LSJb5YLWJCyxvwE8rmlKyKJI99kYiU7LHyAjSINEA9rdV76UdtXIFMB/1qp4P6AKofp37CHy8q5NqdlbeUYAPHMhpVshIvHIthc2hXTQGw8HI4jyyKN4Sa4MiKQDwXu6Vs/k7L0d8YAzLtx0C1GgO1jjFwun88Bg9Cvu2YYDh/f4cT+h1meHRnOM5drTq1euO+gbxy34hhQKgBFxjCIZthquYATLzelWlaYJq1aStmH2Prxf2+YlpYqF5sYWxdyMJCtWljbL3YVusO1J4Has8DNZWjxjpqz0M1doh1a5PpWKmegelq10xXazHWimdYvGG3dpu1tpvJ/d7bcEJMntODlGdSGlLewjHS0pTyREpdSkvKC7nmVMp7KcdS9qW05ZoBXoS/dFX+hXZmBs6+f6z/Rp/3ZuSmSJeez+FnNxarjcjinBOgPpKJ5DGrfI986/k5cSsKbnoUbF2sFhwYowElQmyA9rsy7F0KGIdrkfJOF4LIBR+kQldHqoVIg1ZPr16SqLPIj5wCVYylQHkKdNSwvTQVrwqy8vJIARoMrGTi69Zm5p7aovfstaqt6u1475EtkS/8uOES/f+d+vzfKTwt/bNR0GdrR150kf6EdWpnG+7gHkB/Qj8Nbxf+AdM0vG38gFaw2UNmAbSDXABt8wtAhxQD4AHLAPYB0WDWNtdgV226wVIHjIOlmqQzI7s/7vg3nsx7PwA=", + + # country + "countryroad_1": "#m=edit&p=7Vhrb9s4Fv2eX1HoazljUnyIMlAs8ixQtJlmm26mCYxAsRU/IluubKcZBfnvPRQv/YrbxU4xQBcoDMtHl9e8h9Q5V7JnnxdZlTMRM2GYtIwzgZdKFTPKMJUA483pdT6cF3n7BdtfzAdlBTCYz6ezdqs1XdSX9eXvxXBy15r+q1suJvPqr5aIW8K0RtPkvieMlFxYYXmXG276vbvuXXd0Pxkt7udK8EyPsn5iOWBseck5F0qM++Pb0XjSjXm/L28W499UNpa6L8yAsT9OTthtVsxy9ubT6ODobv/L8f6fLX0p5cfT25ejo7OPo97Ff8QZH7YqflrYybv3RwfFy9f15bvB/n1+nJv3s7I7KPKsl9WXF28eismJ7Q9uxeGbwaG9zSZ89tmep/cHZ69e7V3R+jt7j3XarvdZ/bp9FYmIRTHeIuqw+qz9WL9rR91yfDOMWP0B4xETHRaNF8V82C2LsopCrH7rvx0DHq/gRTPu0KEPCg58ShjwE2B3WHWL/Pqtj7xvX9XnLHIEDppvOxiNy/vcFXME3bknhcBNNsf1mw2G04hJDMwWvfJuQami88Tq/b+xDMwUluGgX4ZDO5bhVvfDy4DK8vJhxxLSztMTLtG/sYjr9pVbz8cVtCv4of2I42n7MTIJvpooSL65jFEcGwTk2nmKc706lxLnziLhXOPc0DkmFc3Un5rjSXOMm+M5KrNaNsej5sibo26Ob5ucYxCS8KDUKmrHLJJKAINhg2NgS1gCg1iDFZOGE06AQdBhrZlMBGFYO4kJIycJOaiVUC2dAmMxDhsOjI1oMDgkxMGAQ0IcDOpaqpsg31J+ghxLOQlqpVTLIielHIvOwolPig7Daf40BqbvpsgRPge5wD6OGFNxwClT7gI4HCdMKV9LScWU9twU9kfR/iAG7Dkghs4WMGpZqpVodD+/bxgHpriVwH7PlU2Z5lQ35cA0D/hr4q9Sy7Twe6s5coTP0VwxHXs+mAPY18L3gClfIC4pjjVqWiO+B0zzxBLY80Eu08rvoZbgoDwHLZGjQg7q0p5gPmCaXxpgv0YtE2C/hxo61KRDrVBXU13cFbShfOhNk95QE5jyNfg4ZzUYfAzx0eBjiI8Gn4T4QKuatIo6wDS/QX5C+dCbJr1pg3xL+bh2mq4dagITHwM+lvgY7KGlPYRWNWkVdYCJG/SpSZ/aJszw4BHoXJMvnI+CH51HDPnL+SL4DpzhjZUvggdxF116MEFO8B30tvSa80vwl4U3LXkcepMpeQ16g2eWfgn+goeW3oFX4BfSqvNF8IvzRfALegi8sfKIorjTf/CFhddoP53mofWl5pcecZ4NHFLUSoP+nUeCznHtyC/wCjBpA5yDX+AVeIRynP6DX/BYogXlC+RTH8AnMGkjht5i0kCMaxqTBpxHgqdi56Ogf+ejoH/ku6YePBK85jwSvOY8IoO/UHfpO8ypaE4FHPzlNEw6abRKPUfj+mq6vvhcadtpL+gZe77UMHrOUsPW6Z+4Waf/oFWnf6oF3Xo942Zy0dxSDpujao6mudUk7hb4P90kf/yu9l/pXGHn3U31+cvden/F//F4Z+8q+rCobrNujqetw3I8LWfDeR7hiTealcX1zI9d5w9Zdx61/ZP3+shGbLIY3+R4UFwLFWU5dc9tO2YIQxvBYX9SVvnOIRfMe/1vTeWGdkx1U1a9LU5fsqLYXEvzY2gj5B9UN0LzCk+ha+dZVZVfNiLjbD7YCKw9eG/MlE+2NnOebVLM7rKtauPVdjztRQ9R88YTK37A/fp58n/w88RdLv6z9d+fjU6j9LL6TttZDW6HdzQfRL/Tf9ZGd8W/0WrWRrfjz/qKI/u8tSC6o7sgut1gEHreYxB81mYQ+0ancbNuNxvHarvfuFLPWo4rtd514A//B8+Lqsx6UWfvKw==" } @pytest.fixture @@ -110,5 +120,18 @@ def puzzlink_test_urls(): # masyu "masyu_1": "https://puzz.link/p?mashu/14/8/330000096960006ik00039a00010j0i0000220", "masyu_2": "https://puzz.link/p?mashu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030", - "masyu_3": "https://puzz.link/p?masyu/10/10/0000909b360200013a3a39000i063j1010" + "masyu_3": "https://puzz.link/p?masyu/10/10/0000909b360200013a3a39000i063j1010", + + # slitherlink + "slitherlink_1": "https://puzz.link/p?slither/18/10/gbcg2dgddd73d28ddw2307612185132bicjcnbjai3318712387333dwb62d16dbdbg1cgca", + "slitherlink_2": "https://puzz.link/p?slither/5/6/2bgb0bbhb1adg1b", + + # vslither + "vslither_1" : "https://puzz.link/p?vslither/7/7/766ck776ek789ek976b", + + # tslither + "tslither_1": "https://puzz.link/p?tslither/6/11/2cgbg3d1bgdg2c3d2cgcg3d2cgbg2c2c2cgc", + + # country road + "countryroad_1": "https://puzz.link/p?country/12/16/jp7vd1633018180c0606gdkckcjvnjuvt410a5jag780410280o000141mgmfjmnc20gg3bum-4am35g16h" } \ No newline at end of file From 794d1c912c2d660b98148442e91616e2cdd39804 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sat, 21 Mar 2026 16:00:50 +0800 Subject: [PATCH 12/17] update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1feaec7e..72b2b327 100644 --- a/.gitignore +++ b/.gitignore @@ -155,4 +155,5 @@ cython_debug/ assets/ benchmark_results/ docs/puzzles/*.md -penpa_edit/ \ No newline at end of file +penpa_edit/ +src/puzzlekit/formats/temp \ No newline at end of file From fbacfeb18773c84c226558cf9827319af154364d Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Sat, 21 Mar 2026 20:11:02 +0800 Subject: [PATCH 13/17] sync for next version --- scripts/quick_start.py | 17 +- src/puzzlekit/__init__.py | 142 +++++++++- src/puzzlekit/formats/penpa_converter.py | 16 +- src/puzzlekit/formats/puzzlink_converter.py | 293 ++++++++++++++++---- tests/formats/conftest.py | 30 +- tests/formats/test_cross_format.py | 2 - tests/formats/test_roundtrip.py | 31 +++ 7 files changed, 465 insertions(+), 66 deletions(-) diff --git a/scripts/quick_start.py b/scripts/quick_start.py index 1125352d..69af8b53 100644 --- a/scripts/quick_start.py +++ b/scripts/quick_start.py @@ -27,4 +27,19 @@ end_time = time.time() print(f"Time taken: {end_time - start_time} seconds") # Visualize (optional) -res.show() +# res.show() + + +# URL -> IR +ir = puzzlekit.decode("https://puzz.link/p?masyu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030") + +# IR -> penpa +penpa_url = puzzlekit.encode(ir, "penpa") + +penpa_url2 = puzzlekit.convert("https://puzz.link/p?masyu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030", "penpa") +# print(penpa_url2) +puzzlink_url = puzzlekit.convert(penpa_url2, "puzzlink") + +# # 获取 IR(自动识别) +# ir2 = puzzlekit.convert(penpa_url2, "ir") +# print(penpa_url, puzzlink_url) \ No newline at end of file diff --git a/src/puzzlekit/__init__.py b/src/puzzlekit/__init__.py index 397e1d58..68f0f699 100644 --- a/src/puzzlekit/__init__.py +++ b/src/puzzlekit/__init__.py @@ -1,6 +1,64 @@ -from typing import Dict, Any, Union +from typing import Dict, Any, Optional, Union from puzzlekit.solvers import get_solver_class from puzzlekit.parsers.registry import get_parser +from puzzlekit.formats.base import PuzzleInstance +from puzzlekit.formats.puzzlink_converter import PuzzlinkConverter +from puzzlekit.formats.penpa_converter import ( + PenpaConverter, + PENPA_PREFIX, + PENPA_URLPREFIX, +) + + +_FORMAT_ALIASES = { + "puzzlink": "puzzlink", + "pzl": "puzzlink", + "penpa": "penpa", + "ppa": "penpa", + "ir": "ir", + "instance": "ir", + "puzzleinstance": "ir", +} + + +def _normalize_format_name(name: str) -> str: + if not isinstance(name, str) or not name.strip(): + raise ValueError("Format name must be a non-empty string.") + key = name.strip().lower() + if key not in _FORMAT_ALIASES: + raise ValueError(f"Unknown format '{name}'. Supported: puzzlink, penpa, ir.") + return _FORMAT_ALIASES[key] + + +def _normalize_penpa_source(source: str) -> str: + s = source.strip() + if s.startswith("#"): + s = s[1:] + if s.startswith(PENPA_PREFIX): + return s + if s.startswith(PENPA_URLPREFIX): + frag = s.split("#", 1)[1] if "#" in s else "" + if frag.startswith("#"): + frag = frag[1:] + return frag if frag.startswith(PENPA_PREFIX) else f"{PENPA_PREFIX}{frag}" + return s + + +def _detect_source_format(source: str) -> str: + s = source.strip() + if "puzz.link/p?" in s: + return "puzzlink" + if s.startswith(PENPA_PREFIX) or s.startswith("#" + PENPA_PREFIX) or PENPA_URLPREFIX in s: + return "penpa" + raise ValueError("Cannot detect source format. Please pass source_format explicitly.") + + +def _build_converter(fmt: str, converter_config: Optional[Dict[str, Any]] = None): + if fmt == "puzzlink": + return PuzzlinkConverter(converter_config or {}) + if fmt == "penpa": + return PenpaConverter(converter_config or {}) + raise ValueError(f"Unsupported converter format '{fmt}'.") def solve( source: Union[str, Dict[str, Any]], @@ -71,5 +129,85 @@ def solver(puzzle_type: str, data: Dict[str, Any] = None, **kwargs) -> Any: raise ValueError(f"Unknown puzzle type '{puzzle_type}'.") from e return SolverClass(**init_params) -__all__ = ["solve", "solver"] + +def decode( + source: str, + source_format: Optional[str] = None, + converter_config: Optional[Dict[str, Any]] = None, +) -> PuzzleInstance: + """ + Decode puzzle URL/text into PuzzleInstance (IR). + + Args: + source: puzz.link URL, penpa URL, or penpa payload (`m=edit&p=...`). + source_format: Optional explicit source format ('puzzlink' or 'penpa'). + converter_config: Optional converter config dict. + """ + if not isinstance(source, str): + raise TypeError(f"source must be str, got {type(source)}") + + fmt = _normalize_format_name(source_format) if source_format else _detect_source_format(source) + if fmt == "ir": + raise ValueError("decode(source_format='ir') is not valid.") + + converter = _build_converter(fmt, converter_config) + normalized_source = _normalize_penpa_source(source) if fmt == "penpa" else source + return converter.decode(normalized_source) + + +def encode( + instance: PuzzleInstance, + target_format: str, + converter_config: Optional[Dict[str, Any]] = None, +) -> str: + """ + Encode PuzzleInstance (IR) into target format URL/text. + + Args: + instance: PuzzleInstance IR. + target_format: 'puzzlink' or 'penpa'. + converter_config: Optional converter config dict. + """ + if not isinstance(instance, PuzzleInstance): + raise TypeError(f"instance must be PuzzleInstance, got {type(instance)}") + + fmt = _normalize_format_name(target_format) + if fmt == "ir": + raise ValueError("encode(target_format='ir') is not valid.") + + converter = _build_converter(fmt, converter_config) + return converter.encode(instance) + + +def convert( + source: Union[str, PuzzleInstance], + target_format: str, + source_format: Optional[str] = None, + converter_config: Optional[Dict[str, Any]] = None, +) -> Union[str, PuzzleInstance]: + """ + Convert between puzzlink/penpa/IR. + + Examples: + - URL -> IR: convert(url, "ir") + - puzzlink -> penpa: convert(url, "penpa") + - penpa -> puzzlink: convert(url, "puzzlink") + - IR -> puzzlink: convert(ir, "puzzlink") + """ + dst = _normalize_format_name(target_format) + + if isinstance(source, PuzzleInstance): + if dst == "ir": + return source + return encode(source, dst, converter_config=converter_config) + + if not isinstance(source, str): + raise TypeError(f"source must be str or PuzzleInstance, got {type(source)}") + + ir = decode(source, source_format=source_format, converter_config=converter_config) + if dst == "ir": + return ir + return encode(ir, dst, converter_config=converter_config) + +__all__ = ["solve", "solver", "decode", "encode", "convert"] __version__ = '0.3.2' \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index 0e1fcda3..bde5d45a 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -183,7 +183,17 @@ def _decode_number(self, number_dict: Dict[str, int]): ) else: cell = self.ir_puzzle.cells[(r, c)] - cell.value, cell.num_color, cell.num_style = f"{num_data[0]}", NumberColor(num_data[1]), num_data[2] + # Existing cell may come from surface/symbol pass and have number=None. + if cell.number is None: + cell.number = NumberState( + value = f"{num_data[0]}", + number_color = NumberColor(num_data[1]), + number_style = num_data[2], + ) + else: + cell.number.value = f"{num_data[0]}" + cell.number.number_color = NumberColor(num_data[1]) + cell.number.number_style = num_data[2] self.ir_puzzle.cells[(r, c)] = cell # ELSE? @@ -301,8 +311,8 @@ def encode(self, inst: PuzzleInstance) -> str: if __name__ == "__main__": for test_url in [ - "#m=edit&p=7VZdb+I4FH3nV6z8OtYmtoFCpNGKz5Gqtlu2dNkSIWSCIYGAmXy0TBD/fa4duiQhjLS70qoPoyhXJ+d++F47nBB+jXkgMGGYEMwa2MQErhqjuFqrY0Jv9G2erqEX+cL6BbfiyJUBADeKdqFlGLs4GSfjX31vuzZ2v33jKw+gQZhBiPGNmZyRHaEzQgNGVtQUjHJqckqT8RKc1FxWyZIRB+Pf+3284H4o8O3Lqt1dt956rb+M2pix54fFp1V38Lyaj/4kA9MzAvPBb2zvH7tt/9OXZHzvtl5FT9QfQ+m4vuBznoxHt3t/228s3QXp3LqdxoJvzfBrY9h8bQ8+f67Yp7EmlUPStJIWTr5YNiIIIwo3QROcDKxDcm8hR25mHsLJE/gRJhOMNrEfeY70ZYDeueQuzaYAe2c40n6FOilJTMAPJwzwBaDjBY4vpncp82jZyRAj1UBbZyuINvJVqMVUg+o5bQqIGY/gWELX2yHMwBHGc7mOT6FkcsRJ61+MAZXex1AwHUOhkjHUdP95DHhjxL5kgubkeIQT+gNmmFq2Guf5DBtn+GQdwD5YB1SvqVRzqrpUhwkVb4iulqWaVFF0amYonZijCNVhuUzCWLoAy3BVzdFcXPXUSC7uJo3Lcs1TvWxu2ksul5o6l0zh/fybIzou0x9sAtFb8aJtX1uq7RB2CidM2662prY1be90TE/bkbYdbava1nXMjdrrf3Qa/0M7NgOpunLVfnomFRs9xcGCOwJ+cx252cnQiwQC3UOh9Kdh6puKPXciZKX6m/XkuG28mQmQiwzlS7lTv96SCu+uHOkttzIQpS5FivnyWinlKik1k8G80NMb9/38LPpLl6NSucpRUQBalHnmQSDfcsyGR26OyMhvrpLYFjYz4vkW+ZoXVtuct+NYQXukb5thqg7x50fqo3+k1GmZH00cP1o7+kWXwQ9U5+ws0iXaA+wP5CfjLeOvKE3GW+QvZEU1e6kswJaIC7BFfQHqUmKAvFAZ4K4Ijapa1BrVVVFu1FIXiqOWyoqOjU5/3tGk8h0=" - # "m=edit&p=7Vj7b6M4EP69f8XJv673YptHSKTVKX2tVLXZ9tper42iiBLyhJAlkHap+r/v2CYBwiNU6lW30grFMt98M56xHY+H1ffQ9G1MGaY6VgxMMIVHbalYV3WsNqEPPxI/N9PAsdt/4E4YTDwfOpMgWK7ajcYyjB6ihz+d6WLeWP5leeEi8H80KGtQvTFbNtdDqisKoQY1iEV0oo+Hc2tuzdaLWbgOVEpMbWaOmwaBLjOIRwihKnXH7mjmLixGxmPlMXQ/q6araGOqTzD+dnqKR6azsvHZ/ezweN55Oun829AeFOW2O/o0O766nQ3v/qFXZNrwSdcxFheXx4fOp6/Rw8Wks7ZPbP1y5VkTxzaHZvRwd/bsLE6N8WREj84mR8bIXJDVd+OmtT68+vLloBfH3z94iVrtqIOjr+0eoggjBj+K+ji6ar9EF21kee7jFOHoGuQI0z5GbugEU8tzPB9tsOhcajPoniTdOyHnvSMJUgL9btyH7j10ralvOfbgXCKX7V50gxF34FBo8y5yvbXNB+MO8nfpFACPZgDrt5pMlwgrIFiFQ28exlTaf8VRR4QRdWtGAEY2EfCujID3diNoIdx6jwhgg9nec4H3rf7rK6zO3+D/oN3jodwmXSPpXrdfoO2Klor2XrSnomWivQEqjhTRHouWiFYT7bngnIj2TrRHolVFqwtOkw/2JneQRiGSzUZLnj5uYdTkc6C1eKgqU1SIFiODY4bENCYxlWOUNjmoDPj88x0qMEGcDiRPYowI5UEzxWNMrnFCUjjAbW0hxeAQG2gpPV2TYwK25emxHzrCxhYTugrLOKIL5zJjGLrgbRC5WIg1m8I/jgMLXoV38CeMX1UR0fZVOCU3x3+5zj1FFadm/tF+4x+B9w966Dr0R6Zlw4Fx5LlLbzUNbATnNVp5zmAlZQP72bQC1JZ5Iy3JYIvQfbThrEtBjuct+dFTYGEjyoDT8cLz7UIRB+3huMwUFxWYevT84Y5PT6bjZGMRqTwDybM2AwU+HKSpd9P3vacM4prBJAOk0kbGkr3YmczAzLpozs2d0dxkOl4P0DMSv56C4frxO7n+v5MrXynyi6VYHv5niqNvGC3DgTmAiUY8p74d72LIq1hmKRZnqRJKkhYrbOyhZLJ3nsMIN5NK3CWUTHLPc+C+D2YgP++hsFqc1H0gz9FFVMlNoIzBrezlpG4UBRyNBwVmypdJUDKXlYJlaHEzqWtKCSVzlSnh/KjBMWtwZjU4mTteQegirNRVq4AidgW/VFVTlDocVo+03yEgba+DVSRSh1Q9RTEJ/jofaalyN8ak97JT7TZje7e+oFQSlL37zNDlkZBn0KakGHyzSoYobXI2JCFV/JQx5MxVU+qYeZ+h9jKqCYXpSSjutazK3VHJ2BtAtt7McbTEE1GklhDkMJUMOQ/VlMqBKO71kq9XuZK6TGFHpZ5CSqmuwlalvkKsUkdh89RS2Fh/g0KiUlvhTTFspvPNCtu1qK+wbx0o5p8TYMeJ23CxmJ948aeIRAwVRyzmJ2b84SIR61uxWiQ2UuLsvx54KcX4k0fqBCX9D7+KiwLP8yuq7US4CxfU3IBWlN0paRFeUmGnpLt4rpzmzuYrakALimpAd+tqgPKlNYC56hqwkgKbW92tsblXu2U2HypXafOh0sU23ELN2RTmC/UPfgI=" + "m=edit&p=7VZdb9owFH3nV0x+rbV8EQiRqonPSlXLykrHSoSQCaYJBEzz0bIg/nuvHRBJSDttkyYepihXJ+c61+fG9oHgOSI+xaqMdawZWMYKXNWagVXVwFVdF7e8v/pu6FHzE65HocN8AE4YrgNTktZRPIyHnz13tZDWX36SuQtQUmVJl5im2qoyUVVS1qKyNtXKvqrEQxvjr50OnhEvoPj6cd5oLeqv7foPSR9q2kN3djFv9R7m08F3pSe7ki93PWN1e9dqeBdX8fDWqb/QNq3cBcx2PEqmJB4OrjfeqmM8OTOlee00jRlZycGz0a+9NHqXlyVr38OotI1rZlzH8ZVpIQVhpMKtoBGOe+Y2vjWRzZYTF+H4HvIIKyOMlpEXujbzmI8OXHyTvK0CbB/hQOQ5aiakIgPu7jHAR4C269seHd8kzJ1pxX2MuICGeJtDtGQvlE/GBfLnRBQQExLCGgSOu0ZYg0QQTdki2g9VRjsc1/+gDah0aIPDpA2OCtrg3f11G7A96Kagg9pot4MV+gY9jE2Lt/NwhMYR3ptbiF1ziyo6f1Udc5V8MaFipSaqjbUjVa0ko9KUwSltDOt/oBS5XMCJauX0BIqm5mYAMYqQ9ChiR0RVxD4oxrEmYktEWURdxBsxpi3iQMSmiGURK2JMlff8W1/lH8ix9MQrfn3p5z1uVLLQfeTPiE1hlzbZcs0CN6QInAIFzBsHSW5MN8QOkZk4VjqT4VbRckLhgKUoj7E13+8FFQ6pDOk+rZhPC1OcpNOn90rxVEGpCfOnOU2vxPOyvYjfgQyVHPAMFfpwelPPxPfZa4ZZktDJECnDylSiq9zHDElWIlmQ3GzL4+fYldAGidvSsMoX8b+tn7ut89WSz83Gzk2O2OjM/8B1jsk8XeA9wH5gP6lsEf+O06Syef7EVrjYU2cBtsBcgM37C1CnFgPkicsA947R8Kp5r+Gq8nbDpzpxHD5V2nQstP9vi0alNw==", + "m=edit&p=7VddT+M4FH3nV6z8OtY0/kibRBqtytdIiGFhgWWhqqpQAi2kTSdJAQXx3+dc26FtWkarnRdWWrWxT46Pr++9ca/T4vs8zhMuPPqqgKPHR4vAXDJom8tzn7NxmSbRb7w7L0dZDjAqy1kRtVqzeXVVXX1Ox9OH1uz3YVxA1xIefaXWIylVIpQYSiUTIdWdVGIqhZTeZ6kFwFQIImUitRqCTcCNpFKc/7G/z2/jtEj4weX99u5D92mv+3fLv1Lq/Oj20/3uyfn9zcVf4sQbt3LvKA2m3453t9NPX6urb6PuY7KXtI+LbDhKk/gmrq4uDp7T6X5wN7oVOwejneA2nnrF9+AsfNw++fJlq+fi7G+9VGFUdXn1NeoxwTiTuATr8+okeqm+RWyYTa7HjFenGGdc9DmbzNNyPMzSLGc1Vx3a2RJwbwEvzDihHUsKD/jIYcBLwOE4H6bJ4NAyx1GvOuOMHNg2swmySfaY0GLkIN1bp0BcxyXyX4zGM8YVBor5TfYwd1LRf+VV91+EAUt1GARtGIQ2hEHR/XIY2E3J84YIwv7rK57Qn4hhEPUonPMFDBbwNHphymOR5kyFpvOV6Tq+6UJtO3snPCsVnrC9krbXdd+2ve/6Ns3DMkdumR7TA+yTDu0WuExL9pgaqAWlDSUGlCxHkUc9JomqVW1DmYm1ihx2E2sVOU8TvSXKqBb3JiKatuSVia4xT0ijU8TVK5roG46ZTJBu2R5lZWVRSk9zAUoVTXwzhrSJ6AXtpWn3TStNe4aHxytl2l3Teqb1TXtoNHtIuRSCSwnDEhaFBMbCBoMX8JSw9Be89LhUyJrB0Cg3l3iJrBBWwNppFDTaaYhXtUaDDxyGfXqqhDXq5RuGxncaDY3veB+aN4za2kHmDe4AO/vE+x2LO1i35juwE7i5hDsuriDkMnR2OrAT1nqy7+yEsFPzAeKirVLjwOUqhM2wtqmA3dwAuQ2wJ40GNh2vPMGVsHaUJ4HtXMN71ibGF7xQXEk3V2hgmx/DC5tzJYGV00holNMQL2tNhyttY4ENYLeWxro1VtD4ToNTTfmO96GpsW5z1bZ5Uxr6ttMTr50PbRyKhsemuzBbb8e0mjYgbQTaRPSboQdOm4UwJZ4eCGFKHiUV2DhOQRMmByk4AWNts7M7VLT+YVmzVefXf0TrUTXc6WHj0wvB6sf/73H9rR47nee38TDBMbOTTWZZMS4ThqOeFVk6KOzYIHmOhyWL7CvH8sgKN51PrhOckEtUmmUzOrA2WKiHVsjx3TTLk41DRCY3d++ZoqENpq6z/Kbh01OcpquxmLe9Fcqe0CtUmeP4XbqP8zx7WmEmcTlaIZbeOFYsJdNGMst41cX4IW6sNlmk43WLPTNz9VCP6CH+/1720d/L6Gl5H62MfTR3zEbP8p9UncVgk95Qe8D+pPwsjW7i36k0S6NNfq2skLPrlQXshuICtllfQK2XGJBrVQbcO4WGrDZrDXnVLDe01FrFoaWWiw5+HuYPrImzv/UD" ]: hpc = PenpaConverter(dict()) tmp = hpc.decode(test_url) diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index 2bbd5c57..c5e833c8 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -13,7 +13,8 @@ "nonogram", "nurikabe", "kurochute", "kurodoko", "kurotto", "nurimisaki", "moonsun", "masyu", "mashu", "pearl", - "slither", "slitherlink", "vslither", "tslither" + "slither", "slitherlink", "vslither", "tslither", + "yajilin", "yajirin", "castle", "hebi" } # allowed puzzle types @@ -159,9 +160,6 @@ def _decode_nurikabe_variant(self): number_color = num_color, number_style = "1" ) - # value = str(v) , - # num_color= num_color, - # num_style="1" ) else: cell_dict[(row_idx, col_idx)] = CellState( @@ -170,9 +168,6 @@ def _decode_nurikabe_variant(self): number_color = num_color, number_style = "1" ) - # value = str(v) if str(v) != "?" else " " , # "?" 原样保留(nurikabe/kurochute) - # num_color= num_color, - # num_style="1" ) # 填充 IR @@ -183,7 +178,7 @@ def _decode_nurikabe_variant(self): self.ir_puzzle.margins = [0, 0, 0, 0] self.ir_puzzle.source = self.url self.ir_puzzle.cells = cell_dict - self.ir_puzzle.edges = {} # 此类谜题无区域边线 + self.ir_puzzle.edges = {} self.ir_puzzle.boxes = generate_centerlist_diff( self.ir_puzzle.rows, self.ir_puzzle.cols, @@ -332,13 +327,12 @@ def decode(self, url: str) -> Dict[str, Any]: self.num_cols: int = 0 self.skip_shading: bool = True self.puzzle_type: str = "" - # 0. Parse the header url. - # logger.info(f"URL: {self.url}") + # .0 parse header self._parse_header() # If wanna add more puzzle types, just add the puzzle type to the list and implement the corresponding logic if self.puzzle_type in ["yajilin", "yajirin", "snakes", "hebi", "castle"]: - return self._decode_yajilin_variant() + self._decode_yajilin_variant() elif self.puzzle_type in ["moonsun","mashu", "masyu", "pearl"]: self._decode_masyu_variant() elif self.puzzle_type in ["slither", "slitherlink", "vslither", "tslither"]: @@ -407,6 +401,9 @@ def encode(self, inst: PuzzleInstance) -> str: elif self.puzzle_type in ["slither", "slitherlink", "vslither", "tslither"]: body_str = self._encode_slither_variant(inst) return body_str + elif self.puzzle_type in ["yajilin", "yajirin", "castle", "hebi"]: + body_str = self._encode_yajilin_variant(inst) + return body_str else: raise NotImplementedError(f"Puzzle type {self.puzzle_type} not supported for encoding") @@ -689,6 +686,128 @@ def _encode_slither_variant(self, inst: PuzzleInstance): body_str = self._encode_number4(number_map) return f"https://puzz.link/p?{inst.puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" + def _encode_yajilin_variant(self, inst: PuzzleInstance): + top_m = inst.margins[0] + left_m = inst.margins[2] + + # Config switch: + # - False (default): emit normal yajilin URL without "/b" section. + # - True: emit shade-mode yajilin URL with "/b" section. + with_shading = bool(self.config.get("yajilin_encode_with_shading", False)) + + # IR dir code (base.py): 0:n,1:w,2:e,3:s -> puzz.link: 1:up,2:down,3:left,4:right + ir_to_puzzlink_dir = {"0": 1, "1": 3, "2": 4, "3": 2} + is_castle_or_hebi = self.puzzle_type in ["castle", "hebi"] + + clues: Dict[int, str] = {} + + for (r, c), cell_state in inst.cells.items(): + if cell_state.number is None or cell_state.number.value is None: + continue + + r_grid = r - top_m + c_grid = c - left_m + if not (0 <= r_grid < self.num_rows and 0 <= c_grid < self.num_cols): + continue + + raw = str(cell_state.number.value).strip() + if raw in ["-", "_"]: + raw = "" + if raw == "": + # For castle/hebi, empty clue text is still meaningful when + # shading exists (encoded as "."). Do not skip these cells. + if not is_castle_or_hebi: + continue + + if "_" in raw: + a_part, b_part = raw.split("_", 1) + else: + # Backward compatibility: pure number or pure marker. + a_part, b_part = raw, "" + + a_part = a_part.strip() + b_part = b_part.strip() + + direction = ir_to_puzzlink_dir.get(b_part, 0) + + # a_part: + # - "?" means empty number in this converter's yajilin decode path. + # - empty means no number. + # - decimal string means clue number. + if a_part in ["", "?", "-", " "]: + number_hex = "." + else: + try: + number_int = int(a_part) + except ValueError: + # Non-numeric payload is ignored for puzz.link yajilin encoding. + continue + if number_int < 0: + continue + number_hex = format(number_int, "x") + + # decodeYajilinArrows length rule inverse: + # - no '-' => base length 1, direction digit plus 5 means +1 digit (len 2) + # - '-' => base length 3, direction digit plus 5 means +1 digit (len 4) + # We emit canonical shortest form that can be decoded losslessly. + nlen = len(number_hex) + if nlen <= 2: + prefix = "" + direc_code = direction + (5 if nlen == 2 else 0) + elif nlen <= 4: + prefix = "-" + if nlen == 3: + direc_code = direction + else: + direc_code = direction + 5 + else: + # yajilin arrow format supports up to 4 hex digits in this decoder. + continue + + token = f"{prefix}{direc_code}{number_hex}" + + if self.puzzle_type == "castle": + # Castle stores per-clue shading prefix before direction token. + # 0: light gray, 1: white/none, 2: black. + if cell_state.surf_color == SurfaceColor.BLACK: + shading_code = 2 + elif cell_state.surf_color == SurfaceColor.LIGHT_GREY: + shading_code = 0 + else: + shading_code = 1 + token = f"{shading_code}{token}" + + clues[r_grid * self.num_cols + c_grid] = token + + if not clues: + body_str = "" + else: + body_parts: List[str] = [] + pos = 0 + # Keep trailing skips to match puzz.link yajilin bodies more stably. + end_pos = self.num_rows * self.num_cols - 1 + while pos <= end_pos: + if pos in clues: + body_parts.append(clues[pos]) + pos += 1 + continue + + skip = 0 + while pos <= end_pos and pos not in clues and skip < 26: + skip += 1 + pos += 1 + # decode side: c += int(char, 36) - 9 + body_parts.append(chr(ord("a") + skip - 1)) + + body_str = "".join(body_parts) + + puzzle_type = "yajilin" if self.puzzle_type == "yajirin" else self.puzzle_type + if is_castle_or_hebi: + return f"https://puzz.link/p?{puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" + if with_shading: + return f"https://puzz.link/p?{puzzle_type}/b/{self.num_cols}/{self.num_rows}/{body_str}" + return f"https://puzz.link/p?{puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" + def _region_grid_to_borders(self, edges_dict: Dict[Any, List[EdgeState]]) -> Dict[int, int]: """ Reconstruct edge dict from region_grid. @@ -756,50 +875,102 @@ def _decode_yajilin_variant(self): if self.puzzle_type == "yajirin": self.puzzle_type = "yajilin" elif self.puzzle_type == "snakes": - self.puzzle_type = "hebi" - # TODO: this pzl is not paid enough attention to, because neither data nor solver is implemented. + self.puzzle_type = "hebi" parsing_castle = (self.puzzle_type == "castle") arrows = self._decode_yajilin_arrows(parsing_castle) - - number_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] - # shading_grid = [["-" for _ in range(self.num_cols)] for _ in range(self.num_rows)] - + margins = [0, 0, 0, 0] + + cell_dict: Dict[tuple[int, int], CellState] = {} + edge_dict: Dict[tuple[Any], EdgeState] = {} + + # puzz.link direction encoding (for yajilin arrows): 1=up,2=down,3=left,4=right + # IR NumberState.value direction part follows base.py: + # 0:n, 1:w, 2:e, 3:s, ... + direction_map = {1: "0", 2: "3", 3: "1", 4: "2"} + for cell_index, arrow_data in arrows.items(): - direction, number_str, shading_type = arrow_data + if cell_index < 0: + continue + row = cell_index // self.num_cols col = cell_index % self.num_cols - - number = number_str - if self.skip_shading and not number_str: - number = "?" - - if direction != 0 and number_str: - direction_map = {1: "n", 2: "s", 3: "w", 4: "e"} # 上、下、左、右 - number = f"{number_str}{direction_map[direction]}" - - number_grid[row][col] = number - - if self.puzzle_type == "yajilin": - if shading_type == 2 and number == "-": - number_grid[row][col] = "x" - elif self.puzzle_type == "castle": - if shading_type == 2: - number_grid[row][col] = "x" if number == "-" else f"{number}x" - elif shading_type == 1: - number_grid[row][col] = "o" if number == "-" else f"{number}o" - else: - # snake puzzle, temporary not implemented. - pass - - return { - "num_rows": self.num_rows, - "num_cols": self.num_cols, - # "puzzle_type": self.puzzle_type, - "grid": number_grid, # grid + arrow matrix - # "shading": shading_grid, # bg grid - # "arrows": arrows # raw arrow data (ignored for now, available for debug) - } + if row >= self.num_rows or col >= self.num_cols: + continue + + direction, number_str, shading_type = arrow_data + effective_shading = 2 if self.puzzle_type == "hebi" else shading_type + + # number token a in "{a}_{b}". + # Keep JS behavior: if shading is skipped and clue has no number, render as "?". + a_part = number_str if number_str else ("?" if self.skip_shading else "") + b_part = direction_map.get(direction, "") if direction != 0 else "" + value = f"{a_part}_{b_part}" if (a_part or b_part) else "" + + number_color = NumberColor.BLACK + if not self.skip_shading and effective_shading == 2: + # Black background uses white number style in IR, even for empty text. + number_color = NumberColor.WHITE_ON_BLACK + + cell_state = CellState( + number=NumberState( + value=value, + number_color=number_color, + number_style="2", + ) + ) + + # Yajilin shading/background comes only in /b format. + if not self.skip_shading: + if effective_shading == 0: + cell_state.surf_color = SurfaceColor.LIGHT_GREY + elif effective_shading == 2: + cell_state.surf_color = SurfaceColor.BLACK + + cell_dict[(row, col)] = cell_state + + # JS line toggling for shading mode: + # each candidate edge is XOR-toggled (add if absent, remove if present). + if not self.skip_shading: + cell_edges = [ + (((row, col), (row + 1, col)), (row, col - 1)), # left + (((row, col + 1), (row + 1, col + 1)), (row, col + 1)), # right + (((row, col), (row, col + 1)), (row - 1, col)), # top + (((row + 1, col), (row + 1, col + 1)), (row + 1, col)), # bottom + ] + for (p1, p2), adjacent_cell in cell_edges: + key = (p1, p2) + if key in edge_dict: + if self.puzzle_type == "castle": + # Castle only removes a shared border if both sides have same shading. + adjacent_state = cell_dict.get(adjacent_cell) + adjacent_shading = None + if adjacent_state is not None: + if adjacent_state.surf_color == SurfaceColor.BLACK: + adjacent_shading = 2 + elif adjacent_state.surf_color == SurfaceColor.LIGHT_GREY: + adjacent_shading = 0 + else: + adjacent_shading = 1 + if adjacent_shading == effective_shading: + del edge_dict[key] + else: + # Yajilin / Hebi: shared border is always removed. + del edge_dict[key] + else: + edge_dict[key] = EdgeState(connected=True, edge_type=2) + + self.ir_puzzle.puzzle_type = self.puzzle_type + self.ir_puzzle.title = self.puzzle_type + self.ir_puzzle.rows = self.num_rows + self.ir_puzzle.cols = self.num_cols + self.ir_puzzle.margins = margins + self.ir_puzzle.source = self.url + self.ir_puzzle.cells = cell_dict + self.ir_puzzle.edges = edge_dict + self.ir_puzzle.boxes = generate_centerlist_diff( + self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins + ) def _decode_masyu_variant(self): """ @@ -817,11 +988,9 @@ def _decode_masyu_variant(self): border_list = self._decode_border() info_number = self._decode_number3() grid = self._convert_one_two_2_white_black_grid(info_number, category="moonsun") - logger.info(grid) else: info_number = self._decode_number3() grid = self._convert_one_two_2_white_black_grid(info_number) - logger.info(grid) # Fill IR (cells/edges mapping can be refined later by user) self.ir_puzzle.puzzle_type = self.puzzle_type @@ -1441,16 +1610,19 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, from puzzlekit.formats.penpa_converter import PenpaConverter PzpCvtr = PuzzlinkConverter() url_list = [ - "https://puzz.link/p?country/12/16/jp7vd1633018180c0606gdkckcjvnjuvt410a5jag780410280o000141mgmfjmnc20gg3bum-4am35g16h" + "https://puzz.link/p?hebi/10/10/d0.b35c150.a44k0.a25c0.41a0.d0.e41a0.b25a0.e0.d0.a0.0.c30a23k44a0.43c0.b35d" ] for url in url_list: p_ir = PzpCvtr.decode(url) logger.info(p_ir) logger.info(p_ir.cells) - # url_new = PzpCvtr.encode(p_ir) - # logger.info(f" -> {url_new}") - + url_new = PzpCvtr.encode(p_ir) + logger.info(f" -> {url_new}") + penpa = PenpaConverter() + penpa_url = penpa.encode(p_ir) + + logger.info(penpa_url) # penpa_cvter = PenpaConverter() # penpa_str = penpa_cvter.encode(p_ir) @@ -1462,3 +1634,14 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, # from puzzlekit.formats.penpa_converter import PenpaConverter # penpa_url = PenpaConverter("") # penpa_test = penpa_url.encode(res) + +# "key": "1,1", +# "left": {"shaded": true, "number": {"value": "1_2", "number_color": 7, "number_style": "2"}, "surf_color": 4, "symbol": null}, +# "right": {"shaded": false, "number": {"value": "1_2", "number_color": 7, "number_style": "2"}, "surf_color": 4, "symbol": null}}, + +# {"key": "2,4", +# "left": {"shaded": true, "number": {"value": "_", "number_color": 1, "number_style": "2"}, "surf_color": 4, "symbol": null}, +# "right": {"shaded": false, "number": {"value": "", "number_color": 7, "number_style": "2"}, "surf_color": 4, "symbol": null}}, +# {"key": "3,1", +# "left": {"shaded": false, "number": {"value": "_", "number_color": 1, "number_style": "2"}, "surf_color": 3, "symbol": null}, +# "right": {"shaded": false, "number": {"value": "", "number_color": 1, "number_style": "2"}, "surf_color": 3, "symbol": null}}] \ No newline at end of file diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py index aef57f16..38e44b4f 100644 --- a/tests/formats/conftest.py +++ b/tests/formats/conftest.py @@ -20,7 +20,7 @@ def penpa_test_urls(): # https://puzz.link/p?simpleloop/10/10/4h00008g0000g1000040 "simpleloop_2": "m=edit&p=7VZdb+I6EH3nV1R+rbXYBGiIVF3xWalq2bKly5YIoQCGpDiY5qNFRvz3jh0qkkCrq3ulVVdaJT4zOWPGM3F8RPgcOwHDlKjbMDFYuMrU1KNkVvUg+6vvRZxZZ7geR64IwHGjaB1axeI6lkM5/Ma91bK4/if0/DVnXIh1kRJ1E0IWJqE1sCUPgBJSxvh7p4PnDg8Zvn58arSW9dd2/VexMjSMh+78/KnVe3iaDX7SHvGKAelyc3V712rw8ys5vHXrL6zNqnehmLqcOTNHDgfXG77qmAt3TpvXbtOcOysSPpv92kujd3lZsPc9jApbWbNkHcsry0YlhPWgaIRlz9rKWwtNhT/xEJb3EEeYjjDyYx55U8FFgN45eQMeRbgEbvvgDnRcec2EpAT87t4H9xHcqRdMORvfJMydZcs+RqqAhv61cpEvXphaDH6mn5OigJg4EexB6HprhA0IhPFMLOP9VDraYVn/D21Apvc2lJu0obwTbaju/ncb8KWwzYkOaqPdDnboB/QwtmzVzsPBNQ/uvbVF5RqyyhhVEnNRTYypjUm1oSR5pLS8txeJLSezadUAC/m61haQanzU2NFY0tiHRbE0NLY0Eo0VjTd6TlvjQGNTY1ljVc+5UGX/y8Z+Wzm2kRz37FX587hRwUb3cTB3pgw+tKbw1yL0IobgsKNQ8HGYxMZs40wjZCWik45kuFXsTxickRSltEx9sicyvIcypLdYiUAr4FFIkWy2+CiVCp1INRHBLFfTq8N5thet5RkqOaMZKgrgAKaenSAQrxnGdyI3Q6Q0J5OJrXIvM3KyJTpLJ7eaf3gduwLaID1sA5fUJv5V5q+uzGq3yFeTsa9Wjv7QRfCJ6hyCefqE9gD7ifykoqf4D5QmFc3zR7Kiij1WFmBPiAuweX0B6lhigDxSGeA+EBqVNa81qqq83KiljhRHLZUWHRslf0/P1AtAo8Ib", # https://puzz.link/p?simpleloop/10/10/000g80190002i0001004 - + # nonogram "nonogram_1": "m=edit&p=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", "nonogram_2": "m=edit&p=7Vhdbxo5FH3Pr1j5tdYyHtvzgVStyFelqs0m22SzCULRhEwCYWDoAE01Uf57z7U9ZUxIV7t9yUMF2Odew5zje+9cDIvPq6zKuZBcKC4THnCBh5Yh13HAYx2ZV+Aep+NlkXd/473VclRWAKPlcr7odjrzVX1ZX/5ejGeTzvyPWTkr76ps2hGheU7kRE8EPcR9LO4Nco9wMsSL5raX8z8PD/ltVixy/v7ifnd/0ns46P3T0ZdSnh3dvrnfPzm7vzn/W5wE404VHBXJ7OPx/m7x5l19+XHU+5If5NHxohyOijy7yerL8/dfi9lhcje6FXvvR3vJbTYLFp+T0/TL7snbtzv90Ow7GOw81mm37vH6XbfPBOMsxEuwAa9Puo/1xy4bltPrMeP1J6wzLgacTVfFcjwsi7Jija/+YD8dAh6s4blZJ7RnnSIAPnIY8AJwOK6GRX71wXqOu/36lDMSsGs+TZBNyy85kZFAsq0oOK6zJfKzGI3njEssLFY35WTl3ioGT7zu/Y9t4ErNNgjabRDasg3a3c9vo5iXWzaQDp6ekKC/sIWrbp92c7aGyRp+6j5iPOo+MhXio1TPJodMSc/Uyjc1TNwGjRnB1Gsz9t+c+GbqmVEAM16bwl/1VUW+qshXFZGqtRnTlddmQqrC76YIzIdbjpCoW7b0dQuz6dZ67GsTib+xMKCotOzQFx8qX1/ohQ1ZESY3F2Y8NGNoxlOkjtfSjPtmDMyozfjBvOcAGZWh5pLSGnKGmUuKPGGdchlDHOE44jKFMMKp5EpAFDBmrkIIIhzGXClEi7BSXFGOCEeCqxhBIhwnXKUIEOFUcy0sL2auQ8uLmWtleTFzTdkkXhlAJ7jQWCE05pLyaoyI+izYjJEISAWdMVLwCfCRAcCVBKEx0I6VAqMxVAq5oDRGFHGVgNMYieQ6wGbJAIBipwAAPd0pAOBaOwUAEN0okIiudqQAUO1IAaDakQJwFTSkASIcOlIAqHakAFzRTWYMjSDHjhQAqh0pAFS7bQMgtk4BAFQ7BQBQ7RQA4PvJKZDgkcRj6gDRJRpTBwgusRAGiSISyiU4FHGYOkBkiYIwGBQxEAaBIgJTB9hh6mooRVQDW0OYodXyYkYdWF7MUGp5MUOo5ZXAXr1SmyAcoVZc/WEGdvUdUQJcPUXSlpDB2CP1BYORMSoGg1H3kdWGGdjtPQIvJdJguk8cL77gm1gphT3S/WArDLlSTSEqRIsq3BoIS1PWADCaLCoE2CtR3dSERk005QYAoylrVJjSTR1pKPCqhXqTNaDge+ZDBJRuPmugJmRT8LjltGzKWppDTGNQHTkegI1bQTa1h+rXsuGRqLCmkgFguJ0CbFQlNT1jKCigIjAGstd0A8zArlKQPe2yhxnYdQ9kT7vsYbY3pcGgc9nDTMcyi5E97bKHGdh1p5iObo4XlatjxxuD11QiWum5aah7ZlRmjEyjjekb9D99x/58T/9XOX3sbvsj+uWnx2Cnzz6tqttsmOMotVdO5+VivMwZjrNsURZXC7t2lX/NhkvWtcfq9ornm62m1zlOgS1XUZZzHO23XaFZ8pzju1lZ5VuXyJnf3L10KVracqnrsrrZ0PSQFYW/F/NLxnPZU6jnWlY4YrbsrKrKB88zzZYjz9E6VXtXymcbwVxmvsRskm2wTdfheNphX5l54dCJE9iv3x6v/rcHJSt4bd3xtckxdV5WP2g668VN95bWA+8Puk9rdZv/hUbTWt30P+sqJPZ5Y4F3S2+Bd7O9wPW8w8D5rMnA90KfoatuthpStdltiOpZwyGqds/ps+a/GTbY+QY=", "nonogram_3": "m=edit&p=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", @@ -65,7 +65,19 @@ def penpa_test_urls(): "tslither_1": "m=edit&p=7VZtb6NGEP7uX1Ht11sVFgzGSFHlOMlJUZKLm6RpbFkRhjWQYK+Pl8Qiyn+/mcUWLCYntZXafKgQo5lndt521w/OvhdeyqlNGaOmQ3XK4DFsm/Ytm1pGX7767rmN84S7v9BRkUciBSXK803matqmKKfl9NckXj9rm9+yJM4jnmq2xphm+OEiNAO2CIPQ8M0AbB9sCRs+PqFP6bezM7r0kozT84en45Pn0evp6E/Nmprm3dXyy9PJ5O4puP+DTfRYS/WrxFlfXp8cJ1++ltPLaPTCT7l9nQk/SrgXeOX0/nybrM+cMFqy8Xk0dpbeWs++O7fDl+PJ0VFvtptn3nsrh245ouVXd0ZMQgmD1yBzWk7ct/LSJb5YLWJCyxvwE8rmlKyKJI99kYiU7LHyAjSINEA9rdV76UdtXIFMB/1qp4P6AKofp37CHy8q5NqdlbeUYAPHMhpVshIvHIthc2hXTQGw8HI4jyyKN4Sa4MiKQDwXu6Vs/k7L0d8YAzLtx0C1GgO1jjFwun88Bg9Cvu2YYDh/f4cT+h1meHRnOM5drTq1euO+gbxy34hhQKgBFxjCIZthquYATLzelWlaYJq1aStmH2Prxf2+YlpYqF5sYWxdyMJCtWljbL3YVusO1J4Has8DNZWjxjpqz0M1doh1a5PpWKmegelq10xXazHWimdYvGG3dpu1tpvJ/d7bcEJMntODlGdSGlLewjHS0pTyREpdSkvKC7nmVMp7KcdS9qW05ZoBXoS/dFX+hXZmBs6+f6z/Rp/3ZuSmSJeez+FnNxarjcjinBOgPpKJ5DGrfI986/k5cSsKbnoUbF2sFhwYowElQmyA9rsy7F0KGIdrkfJOF4LIBR+kQldHqoVIg1ZPr16SqLPIj5wCVYylQHkKdNSwvTQVrwqy8vJIARoMrGTi69Zm5p7aovfstaqt6u1475EtkS/8uOES/f+d+vzfKTwt/bNR0GdrR150kf6EdWpnG+7gHkB/Qj8Nbxf+AdM0vG38gFaw2UNmAbSDXABt8wtAhxQD4AHLAPYB0WDWNtdgV226wVIHjIOlmqQzI7s/7vg3nsx7PwA=", # country - "countryroad_1": "#m=edit&p=7Vhrb9s4Fv2eX1HoazljUnyIMlAs8ixQtJlmm26mCYxAsRU/IluubKcZBfnvPRQv/YrbxU4xQBcoDMtHl9e8h9Q5V7JnnxdZlTMRM2GYtIwzgZdKFTPKMJUA483pdT6cF3n7BdtfzAdlBTCYz6ezdqs1XdSX9eXvxXBy15r+q1suJvPqr5aIW8K0RtPkvieMlFxYYXmXG276vbvuXXd0Pxkt7udK8EyPsn5iOWBseck5F0qM++Pb0XjSjXm/L28W499UNpa6L8yAsT9OTthtVsxy9ubT6ODobv/L8f6fLX0p5cfT25ejo7OPo97Ff8QZH7YqflrYybv3RwfFy9f15bvB/n1+nJv3s7I7KPKsl9WXF28eismJ7Q9uxeGbwaG9zSZ89tmep/cHZ69e7V3R+jt7j3XarvdZ/bp9FYmIRTHeIuqw+qz9WL9rR91yfDOMWP0B4xETHRaNF8V82C2LsopCrH7rvx0DHq/gRTPu0KEPCg58ShjwE2B3WHWL/Pqtj7xvX9XnLHIEDppvOxiNy/vcFXME3bknhcBNNsf1mw2G04hJDMwWvfJuQami88Tq/b+xDMwUluGgX4ZDO5bhVvfDy4DK8vJhxxLSztMTLtG/sYjr9pVbz8cVtCv4of2I42n7MTIJvpooSL65jFEcGwTk2nmKc706lxLnziLhXOPc0DkmFc3Un5rjSXOMm+M5KrNaNsej5sibo26Ob5ucYxCS8KDUKmrHLJJKAINhg2NgS1gCg1iDFZOGE06AQdBhrZlMBGFYO4kJIycJOaiVUC2dAmMxDhsOjI1oMDgkxMGAQ0IcDOpaqpsg31J+ghxLOQlqpVTLIielHIvOwolPig7Daf40BqbvpsgRPge5wD6OGFNxwClT7gI4HCdMKV9LScWU9twU9kfR/iAG7Dkghs4WMGpZqpVodD+/bxgHpriVwH7PlU2Z5lQ35cA0D/hr4q9Sy7Twe6s5coTP0VwxHXs+mAPY18L3gClfIC4pjjVqWiO+B0zzxBLY80Eu08rvoZbgoDwHLZGjQg7q0p5gPmCaXxpgv0YtE2C/hxo61KRDrVBXU13cFbShfOhNk95QE5jyNfg4ZzUYfAzx0eBjiI8Gn4T4QKuatIo6wDS/QX5C+dCbJr1pg3xL+bh2mq4dagITHwM+lvgY7KGlPYRWNWkVdYCJG/SpSZ/aJszw4BHoXJMvnI+CH51HDPnL+SL4DpzhjZUvggdxF116MEFO8B30tvSa80vwl4U3LXkcepMpeQ16g2eWfgn+goeW3oFX4BfSqvNF8IvzRfALegi8sfKIorjTf/CFhddoP53mofWl5pcecZ4NHFLUSoP+nUeCznHtyC/wCjBpA5yDX+AVeIRynP6DX/BYogXlC+RTH8AnMGkjht5i0kCMaxqTBpxHgqdi56Ogf+ejoH/ku6YePBK85jwSvOY8IoO/UHfpO8ypaE4FHPzlNEw6abRKPUfj+mq6vvhcadtpL+gZe77UMHrOUsPW6Z+4Waf/oFWnf6oF3Xo942Zy0dxSDpujao6mudUk7hb4P90kf/yu9l/pXGHn3U31+cvden/F//F4Z+8q+rCobrNujqetw3I8LWfDeR7hiTealcX1zI9d5w9Zdx61/ZP3+shGbLIY3+R4UFwLFWU5dc9tO2YIQxvBYX9SVvnOIRfMe/1vTeWGdkx1U1a9LU5fsqLYXEvzY2gj5B9UN0LzCk+ha+dZVZVfNiLjbD7YCKw9eG/MlE+2NnOebVLM7rKtauPVdjztRQ9R88YTK37A/fp58n/w88RdLv6z9d+fjU6j9LL6TttZDW6HdzQfRL/Tf9ZGd8W/0WrWRrfjz/qKI/u8tSC6o7sgut1gEHreYxB81mYQ+0ancbNuNxvHarvfuFLPWo4rtd514A//B8+Lqsx6UWfvKw==" + "countryroad_1": "#m=edit&p=7Vhrb9s4Fv2eX1HoazljUnyIMlAs8ixQtJlmm26mCYxAsRU/IluubKcZBfnvPRQv/YrbxU4xQBcoDMtHl9e8h9Q5V7JnnxdZlTMRM2GYtIwzgZdKFTPKMJUA483pdT6cF3n7BdtfzAdlBTCYz6ezdqs1XdSX9eXvxXBy15r+q1suJvPqr5aIW8K0RtPkvieMlFxYYXmXG276vbvuXXd0Pxkt7udK8EyPsn5iOWBseck5F0qM++Pb0XjSjXm/L28W499UNpa6L8yAsT9OTthtVsxy9ubT6ODobv/L8f6fLX0p5cfT25ejo7OPo97Ff8QZH7YqflrYybv3RwfFy9f15bvB/n1+nJv3s7I7KPKsl9WXF28eismJ7Q9uxeGbwaG9zSZ89tmep/cHZ69e7V3R+jt7j3XarvdZ/bp9FYmIRTHeIuqw+qz9WL9rR91yfDOMWP0B4xETHRaNF8V82C2LsopCrH7rvx0DHq/gRTPu0KEPCg58ShjwE2B3WHWL/Pqtj7xvX9XnLHIEDppvOxiNy/vcFXME3bknhcBNNsf1mw2G04hJDMwWvfJuQami88Tq/b+xDMwUluGgX4ZDO5bhVvfDy4DK8vJhxxLSztMTLtG/sYjr9pVbz8cVtCv4of2I42n7MTIJvpooSL65jFEcGwTk2nmKc706lxLnziLhXOPc0DkmFc3Un5rjSXOMm+M5KrNaNsej5sibo26Ob5ucYxCS8KDUKmrHLJJKAINhg2NgS1gCg1iDFZOGE06AQdBhrZlMBGFYO4kJIycJOaiVUC2dAmMxDhsOjI1oMDgkxMGAQ0IcDOpaqpsg31J+ghxLOQlqpVTLIielHIvOwolPig7Daf40BqbvpsgRPge5wD6OGFNxwClT7gI4HCdMKV9LScWU9twU9kfR/iAG7Dkghs4WMGpZqpVodD+/bxgHpriVwH7PlU2Z5lQ35cA0D/hr4q9Sy7Twe6s5coTP0VwxHXs+mAPY18L3gClfIC4pjjVqWiO+B0zzxBLY80Eu08rvoZbgoDwHLZGjQg7q0p5gPmCaXxpgv0YtE2C/hxo61KRDrVBXU13cFbShfOhNk95QE5jyNfg4ZzUYfAzx0eBjiI8Gn4T4QKuatIo6wDS/QX5C+dCbJr1pg3xL+bh2mq4dagITHwM+lvgY7KGlPYRWNWkVdYCJG/SpSZ/aJszw4BHoXJMvnI+CH51HDPnL+SL4DpzhjZUvggdxF116MEFO8B30tvSa80vwl4U3LXkcepMpeQ16g2eWfgn+goeW3oFX4BfSqvNF8IvzRfALegi8sfKIorjTf/CFhddoP53mofWl5pcecZ4NHFLUSoP+nUeCznHtyC/wCjBpA5yDX+AVeIRynP6DX/BYogXlC+RTH8AnMGkjht5i0kCMaxqTBpxHgqdi56Ogf+ejoH/ku6YePBK85jwSvOY8IoO/UHfpO8ypaE4FHPzlNEw6abRKPUfj+mq6vvhcadtpL+gZe77UMHrOUsPW6Z+4Waf/oFWnf6oF3Xo942Zy0dxSDpujao6mudUk7hb4P90kf/yu9l/pXGHn3U31+cvden/F//F4Z+8q+rCobrNujqetw3I8LWfDeR7hiTealcX1zI9d5w9Zdx61/ZP3+shGbLIY3+R4UFwLFWU5dc9tO2YIQxvBYX9SVvnOIRfMe/1vTeWGdkx1U1a9LU5fsqLYXEvzY2gj5B9UN0LzCk+ha+dZVZVfNiLjbD7YCKw9eG/MlE+2NnOebVLM7rKtauPVdjztRQ9R88YTK37A/fp58n/w88RdLv6z9d+fjU6j9LL6TttZDW6HdzQfRL/Tf9ZGd8W/0WrWRrfjz/qKI/u8tSC6o7sgut1gEHreYxB81mYQ+0ancbNuNxvHarvfuFLPWo4rtd514A//B8+Lqsx6UWfvKw==", + + # yajilin + "yajilin_1": "m=edit&p=7VZdb9owFH3nV0x+rbV8EQiRqonPSlXLykrHSoSQCaYJBEzz0bIg/nuvHRBJSDttkyYepihXJ+c61+fG9oHgOSI+xaqMdawZWMYKXNWagVXVwFVdF7e8v/pu6FHzE65HocN8AE4YrgNTktZRPIyHnz13tZDWX36SuQtQUmVJl5im2qoyUVVS1qKyNtXKvqrEQxvjr50OnhEvoPj6cd5oLeqv7foPSR9q2kN3djFv9R7m08F3pSe7ki93PWN1e9dqeBdX8fDWqb/QNq3cBcx2PEqmJB4OrjfeqmM8OTOlee00jRlZycGz0a+9NHqXlyVr38OotI1rZlzH8ZVpIQVhpMKtoBGOe+Y2vjWRzZYTF+H4HvIIKyOMlpEXujbzmI8OXHyTvK0CbB/hQOQ5aiakIgPu7jHAR4C269seHd8kzJ1pxX2MuICGeJtDtGQvlE/GBfLnRBQQExLCGgSOu0ZYg0QQTdki2g9VRjsc1/+gDah0aIPDpA2OCtrg3f11G7A96Kagg9pot4MV+gY9jE2Lt/NwhMYR3ptbiF1ziyo6f1Udc5V8MaFipSaqjbUjVa0ko9KUwSltDOt/oBS5XMCJauX0BIqm5mYAMYqQ9ChiR0RVxD4oxrEmYktEWURdxBsxpi3iQMSmiGURK2JMlff8W1/lH8ix9MQrfn3p5z1uVLLQfeTPiE1hlzbZcs0CN6QInAIFzBsHSW5MN8QOkZk4VjqT4VbRckLhgKUoj7E13+8FFQ6pDOk+rZhPC1OcpNOn90rxVEGpCfOnOU2vxPOyvYjfgQyVHPAMFfpwelPPxPfZa4ZZktDJECnDylSiq9zHDElWIlmQ3GzL4+fYldAGidvSsMoX8b+tn7ut89WSz83Gzk2O2OjM/8B1jsk8XeA9wH5gP6lsEf+O06Syef7EVrjYU2cBtsBcgM37C1CnFgPkicsA947R8Kp5r+Gq8nbDpzpxHD5V2nQstP9vi0alNw==", + "yajilin_2": "m=edit&p=7ZZtb+I4EMff8ylOfltrYxNok0jVicdKVcuVLV2uRAgZME0gYJqHlgbx3Tt2QCQhrbS70qkrnQKj4Tf2eMZO/iF4jpjPsYkpxbqBCaZw6QbBleo5Ni/kh+yvnht63PoL16LQET44ThiuA0vT1lE8iAffPHe10NZ/v7G5C65mapRq8zJllI4r5A0cXV+ArdA3IDpwfY7xP+02njEv4Pj6cV5vLmqvrdq/WnWg6w+d2dm82X2YT/s/aJe4mk86nrG6vWvWvbOreHDr1F54i5/fBWLieJxNWTzoX2+8Vdt4cma0ce00jBlbkeDZ6Jkv9e7lZcnetzIsbWPTims4vrJsRBFGZfhSNMRx19rGtxaaiOXYRTi+hzjCdIjRMvJCdyI84aMDi2+S2WVwW0e3r+LSaySQEvA7ex/cR3Anrj/x+OgmIXeWHfcwkgXU1WzpoqV44XIxWaD8nRQFYMxCOIrAcdcI6xAIoqlYRPuhdLjDce0X2oBMhzakm7QhvYI2ZHe/3QbcJXxT0IE53O3ghL5DDyPLlu08HF3j6N5bW7Ada4sqZTV1pEMd8jAhY6WSIJJCFxKRERz2ARnVk4mGGqWPZMd7RIlimWGUmAlLZaPJCplFaZUkLJ2vqurVj+OgD6q6eVS2rWxZ2R40i2Nd2aayRNmqsjdqTEvZvrINZSvKnqsxF3K7fmpD/4Ny7LKhtCZ9Vf88MizZ6D7yZ2zC4fZuiOVaBG7IEUgMCoQ3CpLYiG/YJERWInXpSIatouWYw5OZQp4Qa/mgFGQ4hDLQfVoJnxeGJOTTp49SyVBBqrHwp7maXpnnZXtRb5EMSpQhg0IfHvvUb+b74jVDlix0MiCldJlMfJXbzJBlS2QLllttedyOXQltkPraOi7LQ/z/ffDV3wfytMhXE7GvVo660YX/ieocg3lcoD1AP5GfVLSIf6A0qWien8iKLPZUWYAWiAvQvL4AOpUYgCcqA+wDoZFZ81ojq8rLjVzqRHHkUmnRsdH+vzEalt4B", + + # castle + "castle_1": "m=edit&p=7VZtT+M4EP7eX3Hy17Vo7Ly0RFqdSoGVEHBwwHG0qiq3dUkgrbt5ARTEf98Zx7kmadg7LV/4cLLiPnlmPJ7xy5Mm3zMRS+pBs/vUogwa9zz9MMfRj2XadZhG0v+NDrI0UDGAIE03id/tbrJ8lI/2onD92N38PhcJ+HU9aNKyueAOC7i1N7OsvYDZTFgOl5T+cXxMlyJKJD25ezg4fBw8Hw3+7roj2745X355OLy8eVjc/sUurbAbW+dRf312cXgQffmWj86CwZM8kt5FouZBJMVC5KPbk5dofdy/D5ZseBIM+0uxtpLv/ev9p4PLr187Y1PCpPOa7/v5gObf/DFhhBIODyMTml/6r/mZT+ZqNQsJza/ATiibULLKojScq0jFpOTy02I0B3i0hbfajmhYkMwCfG4wwDuA8zCeR3J6WjAX/ji/pgQTONCjEZKVepI4GSaI70VSQMxECkubBOGGUBsMSbZQj5lxZZM3mg9+oQyIVJaBsCgDUUsZWN2Hy4CDIl9aKtifvL3BDv0JNUz9MZZzs4X9LbzyXwnvEd+mxLaJ71DiePrHhTcgexx+wO/c+I0Jn2IduN0wJ46B+aaw9z1D4XjMu3zHQHqVzLun7awaBSfBwBCloGBC5r9Cf6f7Y91z3V9D3jS3dX+oe0v3ru5Ptc8RJMsY3DoOc3E4lawHGLLXGHhmMEefEnPKbEhEYxsw5F3y3GAbfUrswo12DcYbbuZC3jbYQZ8Sw1jXxHcgPq5LyTsGu+hTYhjrmfguxMd1K3nXYA99Sgx602MGQxxc1JL3DO6hD2JYpFu9VEPdO7r39BL28GD8x6NTHIyP79a/pjPGXfinQdW/iiedMbnK4qWYS7hKQ7XaqCRMJQE5I4mKpklhm8oXMU+JX8hq1VLj1tlqJkEFKlSk1AYvZUuE0lQjw/u1imWrCUm5uH8vFJpaQs1UvGjk9CyiqF6L/lTVqEKFalQag8RU3kUcq+casxJpUCMqqlqLJNeNxUxFPUXxKBqzrbbL8dYhL0Q/Y5vCQfj/2/P5vz24W9Znk5HPlo4+6Cr+iepsjU26RXuA/Yn8VKxt/DtKU7E2+R1ZwWR3lQXYFnEBtqkvQO1KDJA7KgPcO0KDUZtag1k15Qan2lEcnKoqOnA99P9vXeek8wM=", + "castle_2": "m=edit&p=7VffT+M4EH7nrzj5dS0a22maRFqdyq+VEHBwwHFQVSi0KS2kTTdNAQXxv+83tkOTtKxOty+cdGpjf/48Hs+MJ1N38X0ZZTEXDn2Vz9Hj4wpfP9L39OPYz8UkT+LwN95d5uM0Axjn+XwRtlrzZXFT3Gwnk9lja/77IFpAriUc+jpS4jtyFHVDx9nGN6eRsz1wXOoeiBRyQJ2Sc0CgIc0JOSVJISPO/zg44KMoWcT88PphZ++x+7zf/bvVvlHq8mT05WHv7PJhePWXOHMmrcw5SfzZ8eneTvLlW3FzPO4+xfuxd7pIB+MkjoZRcXN1+JLMDvz78UjsHo53/VE0cxbf/Yvgaefs69etnvW4v/VaBGHR5cW3sMcE40ziEazPi7PwtTgO2SCd3k0YL84xz7joczZdJvlkkCZpxkquODKrJeD+Cl7peUK7hhQO8InFgNeAg0k2SOLbI8Ochr3igjMyYEevJsim6VNMm5GBNDZGgbiLcpzEYjyZM64wsVgO08elFRX9N150/4Ub0FS6QdC4QWiDG+TdL7uBvIpfNngQ9N/ecEJ/wofbsEfuXK6gv4Ln4StTDgsVZ0qYzjddoDvXjFwz6phRx4z8jukMKRxle9f2JW+khbK8apvetWPXjj273iMepp1Y03pM3ioEhzIMbpKZTQo7EUUBLils2pAiV/RxlWMtshqTcw0t5GhVhDwmEaT6O9XQqsNQJ+APLXKq3Noqa29lcx2w5kIKXm0hRbFhko5ocyFFt7kBRbomh6iL8BXttW4PdCt1e4F84YXS7Z5uHd22dXukZfZxYlIILiUMkNBIWEiDZRu8Z3kFjFhqLCED47QMiuo7H3CpLE9YIhUIK6ylyGh56KeIaOxABrmhZdwKj7VuqZ90Wj0u9FDwtDz0UNA0hh7KMS0DPSXfxtqO1UPYs3o60NOxejzo6Vh5D3o8q6cDPe88/XjYmBD2cPKEfcTBt/o7WOuXOmlfu5ePvUreh84AaVRi3+oPoDModXpcOeVaxN+38Q+CCk/Y7gssA8ODA2/3CgRwqR+2BcYvzL/zSrS5kmZfjYXZCxxXNuZKdIDtWgHbhDlrzK945Imit7fENmfAceVaPRJ63FIeemzOYH7F4+xU28RHY3uO4MBb/S7W2nPEPGTsXjhfwyOpr3Rq7+rW1QmOgyKj6JWhpKNkJEyJQwlFmA6fkkJjBJUOljAdAh0OYQRSBxhYB4OCRJgcIkcJk1FkrMDmnn7TOlS3/2FlN0X011/q9Sg0zOnBd7od1T/t/x7X3+qx82U2igYxfml30+k8XUzymOG2wxZpcrswc7fxSzTIWWhuXdWZGjdbTu9iXBIqVJKmc/rN3qChnKqRk/tZmsUbp4iMh/cfqaKpDaru0mzYsOk5SpK6L/rqW6PMJaVG5RluIJVxlGXpc42ZRvm4RlQuXTVN8awRzDyqmxg9Ro3dpqtwvG2xF6afHuojHeL/V9PPfjWl03I+Wxn7bOboRE+zn1Sd1WST3lB7wP6k/FRmN/EfVJrKbJNfKytk7HplAbuhuIBt1hdQ6yUG5FqVAfdBoSGtzVpDVjXLDW21VnFoq2rRweuh/81rP/tbPwA=", + + # hebi (snakes) + "hebi_1": "m=edit&p=7VhtT+M4F/3Orxj56+RpY8d5lUar8jYSGrqwwLIQVVXaBtohbZi0HVAQ/33OtR2ahDI70uqRWGnVxj4+vrn3XNtx3C6/rZMitbhNXyewUOMjeaAuEXjqss3nfLbK0uiD1VuvpnkBMF2t7pdRt3u/Lq/L6042W9x173+bpqNZl9v0XXDX7nA3kSKRfOQIuzO2O4ndsTsTh1M1EtLuLIQDRE0pJlTBAnaCg0sEV/awsqzfDw+tmyRbptbR1dfd/bvew0Hvr6577TgX/ZuPX/dPL75OLv/kp/asW9j9LFgcn+zvZh8/l9fH09739CD1Tpb5eJqlySQpry+PHrPFYXA7veF7R9O94CZZ2MtvwXn4fff006ed2KQ92Hkqw6jsWeXnKGacWUzg4mxglafRU3kcsXE+H82YVZ6hn1l8YLH5OlvNxnmWF6ziyi/6bgF4sIGXqp/Qnia5Ddw3GPAKcDwrxlk6/KKZkyguzy1GAnbV3QTZPP+eUjASSG0tCsQoWWHaltPZPbMcdCzXk/xubUz54NkqeyqNsv+LGcBJlQFBnQGhLRlQYv/fDMLB8zNm6A/kMIxiSudiA4MNPIuemAxYJC0mQ1W5tq6ErnSfx3WlSc9Tla9bvqMrbelrL4H2Evi60n3c1k1umzZ3Ta0dcm76hfbMhXbNhXbKHdN2pKmNvTS8NP6k8SepH1n2TZYxc4c2s3xarBgxypjGuWpT6i0TGoaYiSEW9wulHPE6RcNDVhj4F0rdWGtDUr1Ng9doI4VG20SpuaShrZvQGDfayDZmcujUKOVl01YzEDOnbqNmo2FE09IkWuLVRLWGRU1aw4hmr0m0ElDz2SQwsZR1XR5NcsOIZrttRDPfNGpLprWwIbAoePSE8kqVh6oUqjzHk2GVjir3VWmr0lXlF2VzgAUlRGgJ0iLgkbCD+SDs2MBYEYSlhA20KV4AQ5bCHDYYMGXj1ngHGEoVpnsxNxVPg6MwYrkmrkQst8ZLo0Eilmt8Eq50unh3ucY/aaPFrHh/gyUwrWiF8b6jpaxsoMEz93pkU90L/R6mVmHyafJ1oYeWdYU9o82HH99o9qC/iuXjXt/c60Gzb/T78F9hj96/Rk8Am8D49+EnqHxSXBMrQKyKDwLLsat7yY/WDA688RPihW9X9vATGj+hZ+HV/IJFaMYhxDiEehwczmFj/IchcOUTsUITiyOW4RETWPtBDWz82C5wpU1Cjx4TxLccobWpWKLiYSMqDcD0+CoMzWZNggOvx9DBWt3wGBOzDhET2GjDWn3BAj4d41/Aj2O04WT0ggVimbWnsDB5YX06Us8vuI1OCf3SxCU9Zn2CMxgP2aV61PZUKVXpqUfQp1fXL77c9Ob/z5/2v5UT46mnU2Lz4/77uMFOzM7WxU0yTnHi6K/no7T40M+LeZIxHPrYMs+GS90/TB+T8YpF+vBZ72lwC+WjQWV5fo9z8TYPVVeDnN0u8iLd2kVkOrl9yxV1bXE1yotJS9NDkmXNXNTPgAalD2wNalXgNFZrJ0WRPzSYebKaNojaya3hKV20BnOVNCUmd0kr2nwzHM877JGpK8amRxP53wn9vZ/Qabbs97aVvTc5aqHnxU92nU1nm96y94D9yfZT693Gv7HT1Hrb/KtthcS+3lnAbtlcwLb3F1CvtxiQr3YZcG9sNOS1vdeQqvZ2Q6Fe7TgUqr7pxIz+2fjfbDyd3eZssPMD", + "hebi_2": "m=edit&p=7Zdbb+JGFMff+RSred1pmbHHV2m1IreVooQmTdI0WAgZMLGDwawvSeQo333PGY+LbUi6UlU1DxXy+M9vLuccz/C3yL4XfhpQi3JGdZsyyuGjM0EFt6luOfJi6nMd5XHgfqKDIg+TFESY55vM7fc3RTkqR7/G0XrZ33wNg2nUt/qc9SOuLzVDiDk3cm7MuZgLMRV6SOlvJyd04cdZQE/vHg6OloOn48GffWOk6zfDxeeHo8ubh/ntH/ySRf2UDWN7fX5xdBB//laOzsPBY3AcmBdZMgvjwJ/75ej29Dlen9j34YIfnoaH9sJfs+y7fe08Hlx++dLzVAHj3kvpuOWAlt9cj3BCiQYXJ2NaXrov5blLZslqGhFaXkE/oXxMyaqI82iWxElKalaeVbM1kMdbeSv7UR1WkDPQQ6VB3oGcReksDiZnFblwvfKaEkzgQM5GSVbJY4DBMEH8XiUFYOrnsAFZGG0I1aEjK+bJslBD+fiVlgNZRjn8yQpgkboClFUFqPZUgIX9uxU449dX2KHfoYaJ62E5N1tpb+WV+0J0i7iCEkPdbHkzdXlzTHnjzKjuXKg7Dob5QzXfI/qEETj7cAwgF1zLI8ZEbyBY1yNiAsekRhgDRzUmYrwOkrFxZpNhHp3VZE6Yx18M8uPuC7R3sj2RrSbbayiflrpsj2TLZGvI9kyOOYbaOPxkucOIq0EAG37SDq80cltxp8Edh2q85qBZpYEBr8ZojIPWlAbOFOdNboCGhyG1CRqKqzlTnDc4GI4mHKUhrqHiItcVFw0uLNCwJVLDGKMeA1wobjS4AbFMFcuAMWY9BjjuNmqzySGWpWKZUKOlakRuKm7VHB72rXzkh7IVsjXlVlh4Tn/yJFfn8Z/v+t+m42lCmnv9Mf7Lb+OeR66KdOHPArCIYbGaBumnYZKu/JiAS5MsiSdZ1T8Jnv1ZTtzqbdHsabG1XKOF4iTZwCtp3wp1VwtG9+skDfZ2IQzm928thV17lpom6byT05Mfx+1a5Au4hSqHbaE8BftsfPfTNHlqkZWfhy3QsNrWSsG68zBzv52iv/Q70Vbbx/HaI89EXp5ONdzI/1+pH/2VirvFPpodfbR05EFP0ndcZ9vZxXu8B+g79tPo3cffcJpGb5fv2Aomu+ssQPeYC9CuvwDatRiAOy4D7A2jwVW7XoNZde0GQ+04DoZqmo5H8E/FL9EsjO4TMu79AA==", } @pytest.fixture @@ -133,5 +145,17 @@ def puzzlink_test_urls(): "tslither_1": "https://puzz.link/p?tslither/6/11/2cgbg3d1bgdg2c3d2cgcg3d2cgbg2c2c2cgc", # country road - "countryroad_1": "https://puzz.link/p?country/12/16/jp7vd1633018180c0606gdkckcjvnjuvt410a5jag780410280o000141mgmfjmnc20gg3bum-4am35g16h" + "countryroad_1": "https://puzz.link/p?country/12/16/jp7vd1633018180c0606gdkckcjvnjuvt410a5jag780410280o000141mgmfjmnc20gg3bum-4am35g16h", + + # yajilin + "yajilin_1": "https://puzz.link/p?yajilin/20/5/o32c21b22a43u43d34r21zc", + "yajilin_2": "https://puzz.link/p?yajilin/9/11/j21a11b40y21a33k21a41y11b31a13j", + + # castle + "castle_1": "https://puzz.link/p?castle/6/6/e032a241h20.b00.h131a042e", + "castle_2": "https://puzz.link/p?castle/10/10/022022f032022d00.00.t03200.c04200.j00.012c00.032p01200.d042012m032012a", + + # hebi (snakes) + "hebi_1": "https://puzz.link/p?hebi/10/10/n150.15a42a41b320.c0.a0.0.d310.0.b240.n230.b0.0.42d0.0.a0.c0.21b0.a21a0.0.0.n", + "hebi_2": "https://puzz.link/p?hebi/7/10/i13k2544d15t15d14d44b43h", } \ No newline at end of file diff --git a/tests/formats/test_cross_format.py b/tests/formats/test_cross_format.py index 16c51e1d..fedfa108 100644 --- a/tests/formats/test_cross_format.py +++ b/tests/formats/test_cross_format.py @@ -15,11 +15,9 @@ def test_cross_penpa_puzzlink(self, puzzlink_test_urls, penpa_test_urls): # First decode, from url -> IR1 converter1 = PuzzlinkConverter() ir1 = converter1.decode(url_pzl) - print(ir1.cells) # Second decode, from url2 -> IR2 converter2 = PenpaConverter() ir2 = converter2.decode(url_ppa) - print(ir2.cells) # If IR1 ?== IR2 assert ir1 == ir2, f"[Puzzlink] Puzzle {pid} wrong. URL: {url_pzl}" diff --git a/tests/formats/test_roundtrip.py b/tests/formats/test_roundtrip.py index 4183d4cf..125f7ef5 100644 --- a/tests/formats/test_roundtrip.py +++ b/tests/formats/test_roundtrip.py @@ -24,6 +24,37 @@ def test_self_roundtrip(self, puzzlink_test_urls): # If IR1 ?== IR2 assert ir1 == ir2, f"[Puzzlink] Puzzle {puzzle} wrong. URL: {url}" + def test_yajilin_roundtrip(self, puzzlink_test_urls): + """Keep yajilin coverage explicit even when cross-format is disabled.""" + yajilin_cases = { + k: v for k, v in puzzlink_test_urls.items() if k.startswith("yajilin") + } + for puzzle, url in yajilin_cases.items(): + converter1 = PuzzlinkConverter() + ir1 = converter1.decode(url) + + url2 = converter1.encode(ir1) + + converter2 = PuzzlinkConverter() + ir2 = converter2.decode(url2) + + assert ir1 == ir2, f"[Puzzlink][Yajilin] Puzzle {puzzle} wrong. URL: {url}" + + def test_yajilin_roundtrip_with_shading_config(self): + """Yajilin /b mode: encode with explicit shading config.""" + shaded_url = "https://puzz.link/p?yajilin/b/9/11/j21a11b40y21a33k21a41y11b31a13j" + + decoder = PuzzlinkConverter() + ir1 = decoder.decode(shaded_url) + + encoder = PuzzlinkConverter(config={"yajilin_encode_with_shading": True}) + url2 = encoder.encode(ir1) + assert "?yajilin/b/" in url2, f"Expected shaded yajilin url, got: {url2}" + + decoder2 = PuzzlinkConverter() + ir2 = decoder2.decode(url2) + assert ir1 == ir2, f"[Puzzlink][Yajilin][Shading] wrong. URL: {shaded_url}" + class TestPenpaTrip: """ From 20606b9dacaef4e123f46c623b48598e1fc73ba7 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Mon, 30 Mar 2026 16:48:57 +0800 Subject: [PATCH 14/17] update for unified converter --- scripts/quick_start.py | 17 +- src/puzzlekit/__init__.py | 16 +- src/puzzlekit/formats/base.py | 2 +- src/puzzlekit/formats/penpa_converter.py | 18 +- src/puzzlekit/formats/penpa_template.py | 98 ++++++----- src/puzzlekit/formats/puzzle_types.py | 175 ++++++++++++++++++++ src/puzzlekit/formats/puzzlink_converter.py | 92 ++++------ tests/formats/conftest.py | 2 +- 8 files changed, 301 insertions(+), 119 deletions(-) create mode 100644 src/puzzlekit/formats/puzzle_types.py diff --git a/scripts/quick_start.py b/scripts/quick_start.py index 69af8b53..0051b5f3 100644 --- a/scripts/quick_start.py +++ b/scripts/quick_start.py @@ -31,14 +31,27 @@ # URL -> IR -ir = puzzlekit.decode("https://puzz.link/p?masyu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030") +ir = puzzlekit.decode("ttps://puzz.link/p?hebi/10/10/n150.15a42a41b320.c0.a0.0.d310.0.b240.n230.b0.0.42d0.0.a0.c0.21b0.a21a0.0.0.n") # IR -> penpa penpa_url = puzzlekit.encode(ir, "penpa") +print(penpa_url) +# print(penpa_url) penpa_url2 = puzzlekit.convert("https://puzz.link/p?masyu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030", "penpa") -# print(penpa_url2) + + +print(penpa_url2) puzzlink_url = puzzlekit.convert(penpa_url2, "puzzlink") +print(puzzlink_url) +# # Per-stage converter config (optional, for fine-grained conversion control) +# # e.g. when target is puzzlink and you need encode-side flags only. +# _ = puzzlekit.convert( +# penpa_url2, +# "puzzlink", +# decode_converter_config={}, +# encode_converter_config={}, +# ) # # 获取 IR(自动识别) # ir2 = puzzlekit.convert(penpa_url2, "ir") diff --git a/src/puzzlekit/__init__.py b/src/puzzlekit/__init__.py index 68f0f699..f04eaa03 100644 --- a/src/puzzlekit/__init__.py +++ b/src/puzzlekit/__init__.py @@ -184,6 +184,8 @@ def convert( target_format: str, source_format: Optional[str] = None, converter_config: Optional[Dict[str, Any]] = None, + decode_converter_config: Optional[Dict[str, Any]] = None, + encode_converter_config: Optional[Dict[str, Any]] = None, ) -> Union[str, PuzzleInstance]: """ Convert between puzzlink/penpa/IR. @@ -193,21 +195,29 @@ def convert( - puzzlink -> penpa: convert(url, "penpa") - penpa -> puzzlink: convert(url, "puzzlink") - IR -> puzzlink: convert(ir, "puzzlink") + + Notes: + - `converter_config` is kept for backward compatibility and applies to both + decode/encode when side-specific configs are not provided. + - `decode_converter_config` and `encode_converter_config` let callers pass + different settings per stage in a two-step conversion. """ dst = _normalize_format_name(target_format) + decode_cfg = decode_converter_config if decode_converter_config is not None else converter_config + encode_cfg = encode_converter_config if encode_converter_config is not None else converter_config if isinstance(source, PuzzleInstance): if dst == "ir": return source - return encode(source, dst, converter_config=converter_config) + return encode(source, dst, converter_config=encode_cfg) if not isinstance(source, str): raise TypeError(f"source must be str or PuzzleInstance, got {type(source)}") - ir = decode(source, source_format=source_format, converter_config=converter_config) + ir = decode(source, source_format=source_format, converter_config=decode_cfg) if dst == "ir": return ir - return encode(ir, dst, converter_config=converter_config) + return encode(ir, dst, converter_config=encode_cfg) __all__ = ["solve", "solver", "decode", "encode", "convert"] __version__ = '0.3.2' \ No newline at end of file diff --git a/src/puzzlekit/formats/base.py b/src/puzzlekit/formats/base.py index 855d743a..82e4e283 100644 --- a/src/puzzlekit/formats/base.py +++ b/src/puzzlekit/formats/base.py @@ -227,7 +227,7 @@ def normalize(self) -> dict: # 3. core attributes: return { "grid_type": self.grid_type, - # "puzzle_type": self.puzzle_type, + "puzzle_type": self.puzzle_type, "rows": self.rows, "cols": self.cols, "margins": self.margins, diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index bde5d45a..365bec87 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -11,6 +11,7 @@ from puzzlekit.formats.utils import ( calculate_center_n ) +from puzzlekit.formats.puzzle_types import normalize_puzzle_type, get_penpa_genre_tags from typing import Any, Dict, List, Optional, Tuple, Union import json import ast @@ -132,7 +133,8 @@ def decode(self, url: str) -> PuzzleInstance: self.ir_puzzle.boxes = boxes elif p == 17: genre_tag = ast.literal_eval(self.parts[p]) - self.ir_puzzle.puzzle_type = genre_tag[0] if len(genre_tag) > 0 else "" + raw_type = genre_tag[0] if len(genre_tag) > 0 else "" + self.ir_puzzle.puzzle_type = normalize_puzzle_type(raw_type) print(self.ir_puzzle.puzzle_type) # else: # print(p, self.parts[p]) @@ -245,7 +247,6 @@ def encode(self, inst: PuzzleInstance) -> str: hdr = fixed['header'] penpa_template = get_penpa_template(inst.puzzle_type) - center_n = calculate_center_n(inst.cols , inst.rows , hdr.size) self.real_rows = inst.rows + 4 # penpa size after padding @@ -292,7 +293,7 @@ def encode(self, inst: PuzzleInstance) -> str: to_penpa_str(fixed['pu_q_col']), to_penpa_str(fixed['pu_a_col']), to_penpa_str(fixed['sol_check_or'], apply_compression = False), - to_penpa_str(penpa_template['genre_tags']), + to_penpa_str(get_penpa_genre_tags(inst.puzzle_type)), fixed["custom_message"] ] @@ -311,15 +312,14 @@ def encode(self, inst: PuzzleInstance) -> str: if __name__ == "__main__": for test_url in [ - "m=edit&p=7VZdb9owFH3nV0x+rbV8EQiRqonPSlXLykrHSoSQCaYJBEzz0bIg/nuvHRBJSDttkyYepihXJ+c61+fG9oHgOSI+xaqMdawZWMYKXNWagVXVwFVdF7e8v/pu6FHzE65HocN8AE4YrgNTktZRPIyHnz13tZDWX36SuQtQUmVJl5im2qoyUVVS1qKyNtXKvqrEQxvjr50OnhEvoPj6cd5oLeqv7foPSR9q2kN3djFv9R7m08F3pSe7ki93PWN1e9dqeBdX8fDWqb/QNq3cBcx2PEqmJB4OrjfeqmM8OTOlee00jRlZycGz0a+9NHqXlyVr38OotI1rZlzH8ZVpIQVhpMKtoBGOe+Y2vjWRzZYTF+H4HvIIKyOMlpEXujbzmI8OXHyTvK0CbB/hQOQ5aiakIgPu7jHAR4C269seHd8kzJ1pxX2MuICGeJtDtGQvlE/GBfLnRBQQExLCGgSOu0ZYg0QQTdki2g9VRjsc1/+gDah0aIPDpA2OCtrg3f11G7A96Kagg9pot4MV+gY9jE2Lt/NwhMYR3ptbiF1ziyo6f1Udc5V8MaFipSaqjbUjVa0ko9KUwSltDOt/oBS5XMCJauX0BIqm5mYAMYqQ9ChiR0RVxD4oxrEmYktEWURdxBsxpi3iQMSmiGURK2JMlff8W1/lH8ix9MQrfn3p5z1uVLLQfeTPiE1hlzbZcs0CN6QInAIFzBsHSW5MN8QOkZk4VjqT4VbRckLhgKUoj7E13+8FFQ6pDOk+rZhPC1OcpNOn90rxVEGpCfOnOU2vxPOyvYjfgQyVHPAMFfpwelPPxPfZa4ZZktDJECnDylSiq9zHDElWIlmQ3GzL4+fYldAGidvSsMoX8b+tn7ut89WSz83Gzk2O2OjM/8B1jsk8XeA9wH5gP6lsEf+O06Syef7EVrjYU2cBtsBcgM37C1CnFgPkicsA947R8Kp5r+Gq8nbDpzpxHD5V2nQstP9vi0alNw==", - "m=edit&p=7VddT+M4FH3nV6z8OtY0/kibRBqtytdIiGFhgWWhqqpQAi2kTSdJAQXx3+dc26FtWkarnRdWWrWxT46Pr++9ca/T4vs8zhMuPPqqgKPHR4vAXDJom8tzn7NxmSbRb7w7L0dZDjAqy1kRtVqzeXVVXX1Ox9OH1uz3YVxA1xIefaXWIylVIpQYSiUTIdWdVGIqhZTeZ6kFwFQIImUitRqCTcCNpFKc/7G/z2/jtEj4weX99u5D92mv+3fLv1Lq/Oj20/3uyfn9zcVf4sQbt3LvKA2m3453t9NPX6urb6PuY7KXtI+LbDhKk/gmrq4uDp7T6X5wN7oVOwejneA2nnrF9+AsfNw++fJlq+fi7G+9VGFUdXn1NeoxwTiTuATr8+okeqm+RWyYTa7HjFenGGdc9DmbzNNyPMzSLGc1Vx3a2RJwbwEvzDihHUsKD/jIYcBLwOE4H6bJ4NAyx1GvOuOMHNg2swmySfaY0GLkIN1bp0BcxyXyX4zGM8YVBor5TfYwd1LRf+VV91+EAUt1GARtGIQ2hEHR/XIY2E3J84YIwv7rK57Qn4hhEPUonPMFDBbwNHphymOR5kyFpvOV6Tq+6UJtO3snPCsVnrC9krbXdd+2ve/6Ns3DMkdumR7TA+yTDu0WuExL9pgaqAWlDSUGlCxHkUc9JomqVW1DmYm1ihx2E2sVOU8TvSXKqBb3JiKatuSVia4xT0ijU8TVK5roG46ZTJBu2R5lZWVRSk9zAUoVTXwzhrSJ6AXtpWn3TStNe4aHxytl2l3Teqb1TXtoNHtIuRSCSwnDEhaFBMbCBoMX8JSw9Be89LhUyJrB0Cg3l3iJrBBWwNppFDTaaYhXtUaDDxyGfXqqhDXq5RuGxncaDY3veB+aN4za2kHmDe4AO/vE+x2LO1i35juwE7i5hDsuriDkMnR2OrAT1nqy7+yEsFPzAeKirVLjwOUqhM2wtqmA3dwAuQ2wJ40GNh2vPMGVsHaUJ4HtXMN71ibGF7xQXEk3V2hgmx/DC5tzJYGV00holNMQL2tNhyttY4ENYLeWxro1VtD4ToNTTfmO96GpsW5z1bZ5Uxr6ttMTr50PbRyKhsemuzBbb8e0mjYgbQTaRPSboQdOm4UwJZ4eCGFKHiUV2DhOQRMmByk4AWNts7M7VLT+YVmzVefXf0TrUTXc6WHj0wvB6sf/73H9rR47nee38TDBMbOTTWZZMS4ThqOeFVk6KOzYIHmOhyWL7CvH8sgKN51PrhOckEtUmmUzOrA2WKiHVsjx3TTLk41DRCY3d++ZoqENpq6z/Kbh01OcpquxmLe9Fcqe0CtUmeP4XbqP8zx7WmEmcTlaIZbeOFYsJdNGMst41cX4IW6sNlmk43WLPTNz9VCP6CH+/1720d/L6Gl5H62MfTR3zEbP8p9UncVgk95Qe8D+pPwsjW7i36k0S6NNfq2skLPrlQXshuICtllfQK2XGJBrVQbcO4WGrDZrDXnVLDe01FrFoaWWiw5+HuYPrImzv/UD" + "m=edit&p=7VhrT+M4FP3Orxj560StX4mTSKNVeY2EoAMLLDtUVZW2gRbShklbQEH973tt3zSPltmRViux0qqtfXx8c+/xI9eGxY9VlMUOo/orfAdq+Ejmmx/3PfOj+LmaLpM4/OR0VstJmgFYLp8WYbv9tMpv89tWMp0/tp9+m8TDaZtR/Z0zl7aYG0keSTYUnLZGtBXRFm2NBdPVkEvamnMBSDclH+sqMnacARdxZuzBynG+HR87x1GyiJ2T7w/7h4+dl6POn233Vojr7t3nh8OL64fxzR/sgk7bGe0m/vzs/HA/+fw1vz2bdJ7jo9g7X6SjSRJH4yi/vTl5TebH/v3kjh2cTA78u2hOFz/8q+B5/+LLl70ejrq/95YHYd5x8q9hjzDiEA4/RvpOfhG+5WchGaWz4ZQ4+SX0E4f1HTJbJcvpKE3SjBRcfmqf5gCPSnhj+jU6sCSjgLuIAX4HOJpmoyQenFrmPOzlVw7RAvbN0xqSWfocExSo21YUEMNoCau2mEyfiCOgY7Eap48rUkRYO3nHDCPv/uIIRDkCsRmB2D0C/q+PIOiv17BCv8MYBmFPD+e6hH4JL8M3In0SSofIwFQutRW3le3zmK0s6XmmUralhK2spbJefOvFV7ayfYwqrLHNXKw9rLGfc6wF1tYpE9gWEmu0l8hL9CfRn9T9az3tdpQ94g4ocZTerH07Yj3PRVsPvWGip6FH+IBXKOOIVSk9PdqKVShe963nrNpWjX49i7U2Rqm4VA25Pm20lW7LgahQft3ErECPiKqNWY2akV6WOuE1CbU1LWbRakZcNInGAMx61glpR12VJ1TDSIptI9mULJuSZc0NbAoWvq31a6jLY1NyU17Bm+HkwpSHpqSmdE15amyOYENxHjhca+GOxYJaLChgZrGUYOMizwF7iBnYcLRxK7wArBDrZ/2S15NjMMRyMa6EWG6Fl6hBQizXK3Gh04Wjy1WlNhf9u6rEErBX6IfjzkOdLmjw8FlP2xTPgn4vQKx94nhd0KNEiT3UpsCPQs0eLWMpeFbhsx5oVqhfuSX29PGLenyw8dG/Aj9+4VPHxVi+KHnfdwTlG8x9qxk44NFPAOc9LezBT4B+As+Bo3mDeYDzEMA8BHYeBGNgg/6DAHDhE2IFGIvxDQ8xASvkFWD0Q13AhTYJely0AW1clrF4wYMN90vMCh40454EDng7hwL2asnDnOA+hJiAURvs1Q3m4FOgfw5+BGoTfok5xMK9ZzDHccH+FJIhr0qdEvRLXurB/Qkc4rU+QvWrdmBKaUrPvIJKH12/eLjZ5P/P3/a/ldMT9pJY/7j/Pa6/1yOXq+wuGsVw4+iuZsM4+9RNs1mUELj0kUWaDBa2fxC/RqMlCe/M5bPaU+PmxkeNStL0Ce7FuzwUXTVyej9Ps3hnlybj8f17rnTXDlfDNBs3NL1ESVIfi/kroEbZC1uNWmbTWjvKsvSlxsyi5aRGVG5uNU/xvDGZy6guMXqMGtFm5XSs98grMb8eJD29kP/f0D/6DV2vFv1oqeyjyTEbPc1+knXKzia9I/cA+5P0U+ndxb+TaSq9TX4rrWix25kF2B3JBdhmfgFqO8UAuZVlgHsn0WivzVyjVTXTjQ61lXF0qGrS6RH9nw04Gv4C" ]: hpc = PenpaConverter(dict()) tmp = hpc.decode(test_url) - print(tmp.cells) + # print(tmp.cells) enc = hpc.encode(tmp) - print(tmp) - print(enc) - # b = hpc.decode(enc) + # print(tmp) # print(enc) + b = hpc.decode(enc) + print(enc) \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_template.py b/src/puzzlekit/formats/penpa_template.py index f819b9fa..dd80db8d 100644 --- a/src/puzzlekit/formats/penpa_template.py +++ b/src/puzzlekit/formats/penpa_template.py @@ -1,5 +1,6 @@ from typing import List, TypedDict, Type, Any, Dict from puzzlekit.formats.base import COMPRESS_SUB +from puzzlekit.formats.puzzle_types import normalize_puzzle_type from dataclasses import dataclass, field import json from functools import reduce @@ -14,84 +15,89 @@ "heyawake": { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', "user_tab_setting": ["Surface"], - "genre_tags": ["heyawake"] }, - "shimaguni": { + "shikaku": { + "mode": '{z9:zA,zG:["2","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["edgesub",3],"sudoku":["1",9]}}', + 'user_tab_setting': ["Surface","Composite"] + }, + "aqre": { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",9]}}', "user_tab_setting": ['Surface'], - "genre_tags": ["shimaguni (islands)"] }, - "aqre": { + "shimaguni": { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",9]}}', "user_tab_setting": ['Surface'], - "genre_tags": ['aqre'] }, - - "slitherlink" : { - "mode": '{z9:zA,zG:["3","1","2"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["edgex",3],"sudoku":["1",9]}}', - "user_tab_setting": ['Surface', "Composite"], - "genre_tags": ["slitherlink"] + "stostone": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",9]}}', + "user_tab_setting": ['Surface'], }, - "ayeheya": { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:zS,zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",9]}}', "user_tab_setting": ['Surface'], - "genre_tags": ["ayeheya (ekawayeh)"] }, - - "country road": { + "country": { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["lineox",3],"sudoku":["1",9]}}', "user_tab_setting": ["Surface","Composite"], - "genre_tags": ["country road"] }, - + "nonogram": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"] + }, + "nurikabe": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"] + }, "kurochute": { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', "user_tab_setting": ["Surface","Composite"], - "genre_tags": ["kurochute"] + }, + "kurodoko": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"] + }, + "kurotto": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"] + }, + "nurimisaki": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"] + }, + "moonsun": { + "mode": '{z9:zA,zG:["2","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["linex",3],"sudoku":["1",9]}}', + 'user_tab_setting': ["Surface","Composite"] + }, + "masyu": { + "mode": '{z9:zA,zG:["2","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["linex",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"] + }, + "slitherlink": { + "mode": '{z9:zA,zG:["3","1","2"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["edgex",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"] }, "yajilin" : { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["linex",3],"sudoku":["1",9]}}', "user_tab_setting": ["Surface","Composite"], - "genre_tags": ["yajilin"] }, - + "castle": { + 'mode': '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["linex",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Composite"], + }, + "hebi": { + "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:zN,zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",9]}}', + "user_tab_setting": ["Surface","Number Normal"] + }, "default" : { "mode": '{z9:zA,zG:["1","2","1"],zQ:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",2],zE:["1",2],zW:["",2],zC:["1",10],zN:["1",1],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["battleship",3],"sudoku":["1",1]},zA:{zM:"combi",zS:["",1],"multicolor":["",1],zL:["1",3],zE:["1",3],zW:["",3],zC:["1",10],zN:["1",2],zY:["circle_L",1],zP:[zT,""],zB:["",""],"move":["1",""],"combi":["blpo",3],"sudoku":["1",9]}}', "user_tab_setting": ["Surface"], - "genre_tags": [] }, } -PUZZLE_TYPE_ALIASES = { - # slither aliases and variants - "slither": "slitherlink", - "slitherlink": "slitherlink", - "vslither": "slitherlink", # vertex slither - "tslither": "slitherlink", # touching slither - - # kuroshute aliases - "kurochute": "kurochute", - "kuroshuto": "kurochute", - "kurochuto": "kurochute", - # ==== - "nonogram": "nonogram", - # shimaguni - "shimaguni (islands)": "shimaguni", - - "simpleloop": "simpleloop", - "heyawacky": "heyawake", - "heyawake": "heyawake", - -} - def get_penpa_template(puzzle_type: str) -> dict: - - normalized = PUZZLE_TYPE_ALIASES.get(puzzle_type, puzzle_type) - + normalized = normalize_puzzle_type(puzzle_type) template = PENPA_MODE_TEMPLATE.get(normalized, PENPA_MODE_TEMPLATE["default"]) - return template def penpa_str_to_dict(mode_str: str): diff --git a/src/puzzlekit/formats/puzzle_types.py b/src/puzzlekit/formats/puzzle_types.py new file mode 100644 index 00000000..2547b6f8 --- /dev/null +++ b/src/puzzlekit/formats/puzzle_types.py @@ -0,0 +1,175 @@ +from typing import Dict, List, Optional, Set + +# Single source of truth: canonical IR type -> per-format metadata. +# key: Intermediate Representation (IR) puzzle type. +# value: +# - puzzlink.aliases: accepted source names / aliases +# - puzzlink.primary: preferred type token when encoding puzz.link URL +# - puzzlink.family: decode/encode pipeline family in PuzzlinkConverter +# - penpa.aliases: accepted genre-tag aliases when decoding penpa +# - penpa.genre_tag: preferred genre tag when encoding penpa +# If penpa.genre_tag is omitted, a placeholder (canonical type) is used. + +PUZZLE_TYPES_DICT: Dict[str, Dict[str, Dict[str, object]]] = { + "heyawake": { + "puzzlink": {"aliases": ["heyawake", "heyawacky", "heyawack"], "primary": "heyawake", "family": "heyawake_family"}, + "penpa": {"aliases": ["heyawake"], "genre_tag": "heyawake"}, + }, + "shikaku": { + "puzzlink": { "aliases": ["shikaku"], "primary": "shikaku", "family": "heyawake_family"}, + "penpa": { "aliases": ["shikaku"], 'genre_tag': ['shikaku']}, + }, + "aqre": { + "puzzlink": {"aliases": ["aqre"], "primary": "aqre", "family": "heyawake_family"}, + "penpa": {"aliases": ["aqre"], "genre_tag": "aqre"}, + }, + "shimaguni": { + "puzzlink": {"aliases": ["shimaguni"], "primary": "shimaguni", "family": "heyawake_family"}, + "penpa": {"aliases": ["shimaguni (islands)"], "genre_tag": "shimaguni (islands)"}, + }, + "stostone": { + "puzzlink": {"aliases": ["stostone"], "primary": "stostone", "family": "heyawake_family"}, + "penpa": {"aliases": ["stostone"]}, + }, + "ayeheya": { + "puzzlink": {"aliases": ["ayeheya"], "primary": "ayeheya", "family": "heyawake_family"}, + "penpa": {"aliases": ["ayeheya (ekawayeh)"], "genre_tag": "ayeheya (ekawayeh)"}, + }, + "country": { + "puzzlink": {"aliases": ["country"], "primary": "country", "family": "heyawake_family"}, + "penpa": {"aliases": ["country road"], "genre_tag": "country road"}, + }, + "nonogram": { + "puzzlink": {"aliases": ["nonogram"], "primary": "nonogram", "family": "nonogram_family"}, + "penpa": {"aliases": ["nonogram"]}, + }, + "nurikabe": { + "puzzlink": {"aliases": ["nurikabe"], "primary": "nurikabe", "family": "nurikabe_family"}, + "penpa": {"aliases": ["nurikabe"]}, + }, + "kurochute": { + "puzzlink": {"aliases": ["kurochute", "kuroshuto", "kurochuto"], "primary": "kurochute", "family": "nurikabe_family"}, + "penpa": {"aliases": ["kurochute"], "genre_tag": "kurochute"}, + }, + "kurodoko": { + "puzzlink": {"aliases": ["kurodoko"], "primary": "kurodoko", "family": "nurikabe_family"}, + "penpa": {"aliases": ["kurodoko"]}, + }, + "kurotto": { + "puzzlink": {"aliases": ["kurotto"], "primary": "kurotto", "family": "nurikabe_family"}, + "penpa": {"aliases": ["kurotto"]}, + }, + "nurimisaki": { + "puzzlink": {"aliases": ["nurimisaki"], "primary": "nurimisaki", "family": "nurikabe_family"}, + "penpa": {"aliases": ["nurimisaki"]}, + }, + "moonsun": { + "puzzlink": {"aliases": ["moonsun"], "primary": "moonsun", "family": "masyu_family"}, + "penpa": {"aliases": ["moonsun"], 'genre_tag': 'moon or sun'}, + }, + "masyu": { + "puzzlink": {"aliases": ["masyu", "mashu", "pearl"], "primary": "masyu", "family": "masyu_family"}, + "penpa": {"aliases": ["masyu"]}, + }, + "slitherlink": { + "puzzlink": {"aliases": ["slitherlink", "slither", "vslither", "tslither"], "primary": "slither", "family": "slither_family"}, + "penpa": {"aliases": ["slitherlink"], "genre_tag": "slitherlink"}, + }, + "yajilin": { + "puzzlink": {"aliases": ["yajilin", "yajirin"], "primary": "yajilin", "family": "yajilin_family"}, + "penpa": {"aliases": ["yajilin"], "genre_tag": "yajilin"}, + }, + "castle": { + "puzzlink": {"aliases": ["castle"], "primary": "castle", "family": "yajilin_family"}, + "penpa": {"aliases": ["castlewall"], "genre_tag": "castlewall"}, + }, + "hebi": { + "puzzlink": {"aliases": ["hebi", "snakes"], "primary": "hebi", "family": "yajilin_family"}, + "penpa": {"aliases": ["hebi-ichigo"], "genre_tag": "hebi-ichigo"}, + } +} + + +def _invert_family_groups(groups: Dict[str, Set[str]]) -> Dict[str, str]: + """Build canonical_type -> family map with collision guard.""" + inverted: Dict[str, str] = {} + for family, type_set in groups.items(): + for puzzle_type in type_set: + if puzzle_type in inverted and inverted[puzzle_type] != family: + raise ValueError(f"Puzzle type '{puzzle_type}' mapped to multiple families.") + inverted[puzzle_type] = family + return inverted + + +# Canonical puzzle types used in IR. +IR_PUZZLE_TYPES: Set[str] = set(PUZZLE_TYPES_DICT.keys()) + +# External/source aliases -> canonical IR puzzle type. +PUZZLE_TYPE_ALIASES: Dict[str, str] = {} +for canonical, meta in PUZZLE_TYPES_DICT.items(): + PUZZLE_TYPE_ALIASES[canonical] = canonical + for alias in meta.get("puzzlink", {}).get("aliases", []): + PUZZLE_TYPE_ALIASES[str(alias).strip().lower()] = canonical + for alias in meta.get("penpa", {}).get("aliases", []): + PUZZLE_TYPE_ALIASES[str(alias).strip().lower()] = canonical + +# Canonical -> primary puzz.link type token for encoding URLs. +IR_TO_PUZZLINK_TYPE: Dict[str, str] = { + canonical: str(meta.get("puzzlink", {}).get("primary", canonical)) + for canonical, meta in PUZZLE_TYPES_DICT.items() +} + +# Puzzlink pipeline groups (family -> canonical type set), auto-generated. +PUZZLINK_FAMILY_GROUPS: Dict[str, Set[str]] = {} +for canonical, meta in PUZZLE_TYPES_DICT.items(): + family = meta.get("puzzlink", {}).get("family") + if family: + fam = str(family) + PUZZLINK_FAMILY_GROUPS.setdefault(fam, set()).add(canonical) + +# Keep decode/encode group names for compatibility and future divergence. +PUZZLINK_DECODE_FAMILY_GROUPS: Dict[str, Set[str]] = { + family: set(types) for family, types in PUZZLINK_FAMILY_GROUPS.items() +} +PUZZLINK_ENCODE_FAMILY_GROUPS: Dict[str, Set[str]] = { + family: set(types) for family, types in PUZZLINK_FAMILY_GROUPS.items() +} + +# Canonical IR type -> family (auto-generated from groups). +PUZZLINK_DECODE_FAMILY: Dict[str, str] = _invert_family_groups(PUZZLINK_DECODE_FAMILY_GROUPS) +PUZZLINK_ENCODE_FAMILY: Dict[str, str] = _invert_family_groups(PUZZLINK_ENCODE_FAMILY_GROUPS) + +# Canonical types currently supported by PuzzlinkConverter.encode(). +PUZZLINK_ENCODABLE_TYPES: Set[str] = set(PUZZLINK_ENCODE_FAMILY.keys()) + + +def normalize_puzzle_type(puzzle_type: str) -> str: + """Normalize an external puzzle type into canonical IR type.""" + key = (puzzle_type or "").strip().lower() + return PUZZLE_TYPE_ALIASES.get(key, key) + + +def to_puzzlink_type(canonical_type: str) -> str: + """Map canonical IR type to a puzz.link type token.""" + return IR_TO_PUZZLINK_TYPE.get(canonical_type, canonical_type) + + +def get_penpa_genre_tags(canonical_type: str) -> List[str]: + """ + Return penpa genre tags for encoding. + + If genre_tag is not explicitly provided, use canonical type as placeholder. + """ + norm = normalize_puzzle_type(canonical_type) + meta = PUZZLE_TYPES_DICT.get(norm, {}) + penpa_meta = meta.get("penpa", {}) + genre_tag = str(penpa_meta.get("genre_tag", norm)) + return [genre_tag] + + +def get_puzzlink_decode_family(canonical_type: str) -> Optional[str]: + return PUZZLINK_DECODE_FAMILY.get(canonical_type) + + +def get_puzzlink_encode_family(canonical_type: str) -> Optional[str]: + return PUZZLINK_ENCODE_FAMILY.get(canonical_type) \ No newline at end of file diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index c5e833c8..7b1a304d 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -2,22 +2,19 @@ from puzzlekit.formats.base import ( PuzzleInstance, CellState, EdgeState, NumberColor, SurfaceColor, SymbolState, NumberState ) +from puzzlekit.formats.puzzle_types import ( + normalize_puzzle_type, + to_puzzlink_type, + PUZZLINK_ENCODABLE_TYPES, + get_puzzlink_decode_family, + get_puzzlink_encode_family, +) from puzzlekit.formats.utils import ( generate_centerlist_diff, index_to_coord, coord_to_index, auto_border_split ) import math import logging -ALLOWED_PUZZLE_TYPE = { - "heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", "ayeheya", "country", - "nonogram", - "nurikabe", "kurochute", "kurodoko", "kurotto", "nurimisaki", - "moonsun", "masyu", "mashu", "pearl", - "slither", "slitherlink", "vslither", "tslither", - "yajilin", "yajirin", "castle", "hebi" -} -# allowed puzzle types - logger = logging.getLogger(__name__) logging.basicConfig( level=logging.DEBUG, @@ -330,41 +327,21 @@ def decode(self, url: str) -> Dict[str, Any]: # .0 parse header self._parse_header() - # If wanna add more puzzle types, just add the puzzle type to the list and implement the corresponding logic - if self.puzzle_type in ["yajilin", "yajirin", "snakes", "hebi", "castle"]: + decode_family = get_puzzlink_decode_family(self.puzzle_type) + if decode_family == "yajilin_family": self._decode_yajilin_variant() - elif self.puzzle_type in ["moonsun","mashu", "masyu", "pearl"]: + elif decode_family == "masyu_family": self._decode_masyu_variant() - elif self.puzzle_type in ["slither", "slitherlink", "vslither", "tslither"]: + elif decode_family == "slither_family": self._decode_slither_variant() - elif self.puzzle_type in ["heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "stostone", "ayeheya", "country"]: + elif decode_family == "heyawake_family": self._decode_heyawake_variant() - elif self.puzzle_type in ['nonogram']: + elif decode_family == "nonogram_family": self._decode_nonogram_variant() - elif self.puzzle_type in ['kurochute', "kurodoko", "kurotto", "nurikabe", "nurimisaki"]: + elif decode_family == "nurikabe_family": self._decode_nurikabe_variant() - elif self.puzzle_type in ["detour", "juosan", "yajilin-regions", "yajirin-regions"]: - # toichika2, nagenawa, maxi, factors are neglected. + elif decode_family == "noop": return self.ir_puzzle - elif self.puzzle_type in ["hitori"]: - return self.ir_puzzle - # info_number = self._decode_number36(self.num_cols * self.num_rows) - # grid_matrix = [["-" 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): - # grid_matrix[i][j] = str(info_number[i * self.num_cols + j]) - # return { - # "num_rows": self.num_rows, - # "num_cols": self.num_cols, - # "grid": grid_matrix - # } - - # pu = new Puzzle_square(cols, rows, size); - # setupProblem(pu, "surface"); - - # info_number = puzzlink_pu.decodeNumber36(cols * rows); - # puzzlink_pu.drawNumbers(pu, info_number, 1, "1", false); - else: raise NotImplementedError @@ -382,26 +359,28 @@ def encode(self, inst: PuzzleInstance) -> str: """ assert inst.grid_type in ["square"], f"Puzzle grid type must be 'square', get {inst.grid_type}." - assert inst.puzzle_type in ALLOWED_PUZZLE_TYPE, f"Puzzle {inst.puzzle_type} has not been implemented yet... " + normalized_type = normalize_puzzle_type(inst.puzzle_type) + assert normalized_type in PUZZLINK_ENCODABLE_TYPES, f"Puzzle {inst.puzzle_type} has not been implemented yet... " - self.puzzle_type = inst.puzzle_type + self.puzzle_type = normalized_type self.num_rows, self.num_cols = inst.rows - inst.margins[0] - inst.margins[1], inst.cols - inst.margins[2] - inst.margins[3] - if self.puzzle_type in ["heyawake", "shikaku", "aqre", "heyawacky", "shimaguni", "ayeheya", "stostone", "country"]: + encode_family = get_puzzlink_encode_family(self.puzzle_type) + if encode_family == "heyawake_family": body_str = self._encode_heyawake_variant(inst) return body_str - elif self.puzzle_type in ['nonogram']: + elif encode_family == 'nonogram_family': body_str = self._encode_nonogram_variant(inst) return body_str - elif self.puzzle_type in ["nurikabe", "kurochute", "kurodoko", "kurotto", "nurimisaki"]: + elif encode_family == "nurikabe_family": body_str = self._encode_nurikabe_variant(inst) return body_str - elif self.puzzle_type in ["moonsun", "masyu", "pearl", "mashu"]: + elif encode_family == "masyu_family": body_str = self._encode_masyu_variant(inst) return body_str - elif self.puzzle_type in ["slither", "slitherlink", "vslither", "tslither"]: + elif encode_family == "slither_family": body_str = self._encode_slither_variant(inst) return body_str - elif self.puzzle_type in ["yajilin", "yajirin", "castle", "hebi"]: + elif encode_family == "yajilin_family": body_str = self._encode_yajilin_variant(inst) return body_str else: @@ -451,7 +430,8 @@ def _encode_heyawake_variant(self, inst: PuzzleInstance): number_str = self._encode_number16(number_map, max_region_id) # 6. concat body - body = f"https://puzz.link/p?{inst.puzzle_type}/{inst.cols}/{inst.rows}/{border_str + number_str}" + pzl_type = to_puzzlink_type(self.puzzle_type) + body = f"https://puzz.link/p?{pzl_type}/{self.num_cols}/{self.num_rows}/{border_str + number_str}" return body def _encode_nonogram_variant(self, inst: PuzzleInstance): @@ -585,7 +565,8 @@ def _token_from_cell(cell_state: CellState) -> Optional[str]: logger.info(number_list) body_str = number3_str - url = f"https://puzz.link/p?{inst.puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" + pzl_type = to_puzzlink_type(self.puzzle_type) + url = f"https://puzz.link/p?{pzl_type}/{self.num_cols}/{self.num_rows}/{body_str}" return url def _encode_nurikabe_variant(self, inst: PuzzleInstance): @@ -638,7 +619,8 @@ def _encode_nurikabe_variant(self, inst: PuzzleInstance): max_k = max(number_map.keys()) if number_map else 0 body_str = self._encode_number16(number_map, max_k) - url = f"https://puzz.link/p?{inst.puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" + pzl_type = to_puzzlink_type(self.puzzle_type) + url = f"https://puzz.link/p?{pzl_type}/{self.num_cols}/{self.num_rows}/{body_str}" return url def _encode_slither_variant(self, inst: PuzzleInstance): @@ -684,7 +666,8 @@ def _encode_slither_variant(self, inst: PuzzleInstance): number_map[k] = val body_str = self._encode_number4(number_map) - return f"https://puzz.link/p?{inst.puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" + pzl_type = to_puzzlink_type(self.puzzle_type) + return f"https://puzz.link/p?{pzl_type}/{self.num_cols}/{self.num_rows}/{body_str}" def _encode_yajilin_variant(self, inst: PuzzleInstance): top_m = inst.margins[0] @@ -801,7 +784,7 @@ def _encode_yajilin_variant(self, inst: PuzzleInstance): body_str = "".join(body_parts) - puzzle_type = "yajilin" if self.puzzle_type == "yajirin" else self.puzzle_type + puzzle_type = to_puzzlink_type(self.puzzle_type) if is_castle_or_hebi: return f"https://puzz.link/p?{puzzle_type}/{self.num_cols}/{self.num_rows}/{body_str}" if with_shading: @@ -849,7 +832,7 @@ def _parse_header(self): if len(urldata) > 1 and urldata[1] == 'v:': urldata.pop(1) - self.puzzle_type = urldata[0] + self.puzzle_type = normalize_puzzle_type(urldata[0]) self.skip_shading = (self.puzzle_type != "castle") and (self.puzzle_type != "hebi") if urldata[1] == "b": self.skip_shading = False @@ -872,11 +855,6 @@ def _parse_header(self): def _decode_yajilin_variant(self): - if self.puzzle_type == "yajirin": - self.puzzle_type = "yajilin" - elif self.puzzle_type == "snakes": - self.puzzle_type = "hebi" - parsing_castle = (self.puzzle_type == "castle") arrows = self._decode_yajilin_arrows(parsing_castle) margins = [0, 0, 0, 0] diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py index 38e44b4f..1d1bce9f 100644 --- a/tests/formats/conftest.py +++ b/tests/formats/conftest.py @@ -5,7 +5,7 @@ def penpa_test_urls(): """Penpa+ cases""" return { "heyawake_1": "m=edit&p=7VdrT+NGFP3Or6j8dUeNx+O3tKrCayUEFAqUQhQhE0xeTpx1HlAj/vueO76TxCFstapWolKVZHzmzMydO/dcXzvTr/OkSIW06atCgSs+rgz1zwl9/bP5c9mfZWn8i2jOZ728AOjNZpNp3GhM5uVteftr1h8PG5PfeunfyVMyTBvSpm9nOLnvF56fdKZDGY0GHV91i0GuVNfuLBZykasg7zpdJQey6ziyr9TQ6Qrx++GheEyyaSqObga7+8Pm00Hzr4Z3q9TV6eOnwf751eDh+k95bvcbhX2aheOTs/3d7NOX8vak11ykB6l/Ns07vSxNHpLy9vroORsfht3eo9w76u2Fj8nYnn4NL6PF7vnnzzstPmJ756WM4rIpyi9xy5KWsBz8pNUW5Xn8Up7E5YUoLzBkCdkW1miezfqdPMsLy3DlcbXQATxYwWs9TmivIqUNfMoY8Aaw0y86WXp3XDFncau8FBbtvatXE7RG+SKlzcg36nfy0X2fiPtkBnWmvf7EEgoD0/lDPpzzVNl+FWXzx04AI+YEBKsTENpyAjrYzz1B1H59hTh/4Ax3cYuOc7WC4QpexC9oT+MXSzlY6iCdtX6W8tFVq26ILiV71fVpdNUN6mtDu96NapOlrJuWkmyv9b315fBOah9vdHuoW0e3lziCKJVu93Vr69bT7bGec4CTOVIJxwms2EF+ShcYG2ocCEdJxhGwqrAD3mXewX3t4niElQT2GMOmyzZdWzieyxhzPJ7jOsA4rMYeMAKhMez7bN+FfZ/te5jj8xwPtSRAIDSGbwH75sN+wPZ98CHzAXwI2YcAvoXsWwCbJADhEHzEfIg4RByHMBDKZn/CEJj9CSNgth9hjuQ5EXhZ8eCEcip/wAFX9pX0hVKV/wpxVhxn5bjA1b5YB1z5hnVCccyxDrg6C9YBs31UXOVVvmEdMPvgwqbHNhFnZeJM2jkGk74cT9hf6g470G+lo9GdtHP57C5pbfRF3EwOkI4ux5B0dI12mG/ywcN8kw+kndHah31/TbuA9w1IX54TYI7RHRpBm6VGS00j6BtxjkXIGaMv9FrqG2F+ZOaTpqyLjTiz7rgudcd1qTuuwKyFjTjba1pL5nFPKWl4xJ9ucJMD0uQD5Q9rhGelkkZ3yhm2Q7lhcol0p5KkMfzhe1PrznrhusoZ3HeK71NcgU1uwL7WDsXgWpeEPd26uvV1qQioFv5Qtfz3Vekf3WkhS6lo1j/ef49r77Ssi3nxmHRSCy8M1jTP7qZV/y59TjozK65eXNZHatx4PrpP8cRdo7I8n+D9aZsFM1Qj+91xXqRbh4hMH7rvmaKhLabu8+Jhw6enJMvqZ9GvizWqeuLXqFmBx/laPymK/KnGjJJZr0asPfprltLxRjBnSd3FZJhs7DZaheN1x3q29A9PYzx//3+7+9BvdySU/dGq1kdzR+d4Xnyn4KwGN+ktZQfsdyrP2ug2/p0isza6yb+pKOTs26ICdktdAbtZWkC9rS4g3xQYcO/UGLK6WWbIq81KQ1u9KTa01Xq9aVnmz6/V3vkG", - "heyawake_2_mark?": "m=edit&p=7VZrT+M4FP3Orxjl60TT2M5bGo3KoyMhYGAHloWqQiGENm3adNIHbFD/+x47N82DMqt9fGClVRv79Pj6+NzEvs3ixyrIIp0Z8itcHT0+JnPVxV1bXQZ9LuNlEvkfRtHvwVMwifTuajlKM/+DPlou5wu/05mv8vxTEs8mnfmXMqrDDPkNJ/P7OLPsIFxMmDcdh7YYZuNUiKERrtdsnQonHfKhYGM25PxTLMSED3X9W6+n94JkEenHN+P9w0n36aj7W8e6FeLq7PHj+PDiavxw/Su7MOJOZpwl7uz0/HA/+fg1vz0dddfRUWSfL9JwlETBQ5DfXh8/J7OeOxw9soPj0YH7GMyMxQ/30lvvX3z+vNenRAd7L7nn5109/+r3NabpGsfFtIGeX/gv+amvhen0Ptb0/DvGNZ0NdG26SpZxmCZpppVcflLM5oBHFbxW4xIdFCQzgM8IA94AhnEWJtHdScGc+/38UtekgX01W0Jtmq4jjQzK34UpEPfBEg9qMYrnmi4wsFg9pJOVVq6w0fPu30hDVGmIbRpidxr8X0kjmac7EvAGmw0e0C9I4c7vy2yuKuhW8Lv/spGGXjTB5VQ8Q1Y8RU3YkhA1wi3vDhG23SKctoZrtAlPEl8qgrH2Moy5bcZqysAxU75vVNtTLVftJdLSc6HaQ9UaqrVUe6JijpAtZ0Ln3NF8jn3LTGC3wBwnmnuEPZ0Lo8DCAGYU79SwjBEUA2xSvIl4k2K4U8PQNznFcGCaa8KPaRKGH9MiTVbDMoY8m1KTPJtSkzxbiLHMykOJLWhapGOyGoYHyyZsAXuVvk2ebcTbvFqrxDbm2uTfwly79ICK6NB9cODBIR0HOg7NdTDXKed6FZZrOeTNBu+KSselXBzk6NJ9cOHTpfvgympMHhyrwh6wRzl68OaVc0WFPeh4pY5ZYQ+aXqnj6cIo8kIPzLYeSowemG/9lBg9MOXieluMHtgkbAJbWz+CsWpdRvHYt4JRPPatYBZhC9gmbAM7hKWOSxh+WJGL4NDhpMOhw0sdVsMyxqk06SwoTdr/6IEpR4Ec6SwoTUHrcngTtC7OiKAzgh6YdHBGthj7U9AZQQ9Mmjgjgs4IvNSwjCfP+H8WFq/Wssq58GORH+xzofb5RlZoWRIOVGuq1lalwpH18S9V0H9elf7UTl8U7x7Nj/Xf4wZ7fbw/aIs0uVussscgjO6i5yBcav6jeo+pjzS42Wp6H2UNKknTOV6ndimUQw0yHs7SLNo5JMnoYfiWlBzaIXWfZg8tT09BkjRzUe+QDar4229Qyyxu/A6yLH1qMNNgOWoQtdeYhlI0a93MZdC0GEyC1mrT6nZs9rRnTV191Ej5vP5/2XvnL3vyYRnvrWC9Nztqn6fZT4pONdimd5QesD+pPrXRXfwbhaY22uZfVRVp9nVhAbujtoBtlxdQrysMyFdFBtwbdUaqtkuNdNWuNnKpVwVHLlWvOfhP+AM=", + "heyawake_2_mark?": "m=edit&p=7VddT9tIFH3nV1R+7ah4ZvwtrapAodsuUGhBbBNFyKQmceLgrOOE1oj/3jPjO8Q2oavdvnSlVZKZkzN3zpw7Ht84y79WcZEwbqu3DBh6vBwe6I8IPP2x6XWellkSvZgk3+K7eJaw3qqc5EX0gk3KcrGMdncXq6pf9V9l6e1sd/HaxO1yW71Hs8V1WrhePFrOeDifjjw5Lqa5lGN7tF7zdS79fCzGkk/5WIhXqZQzMWbsw+Ehu4mzZcLef54c7ee9uze9P9dB2e/zt/bqnX05PZy+/Dj/410qC354Epwenx6nYtz7fX/vzDt46Z2ulhdlsj6b873pRf/85vRyHIpvByd9p+p/sN33/Zvdde/it50BZTncua/CqOqx6m00sLjFLIEPt4asOovuq+PIGuXz69Ri1SeMW4wPmTVfZWU6yrO8sAxXHdWzBeDBBl7qcYX2a5LbwCeEAT8DjtJilCVXRzVzGg2qc2YpA3t6toLWPF8najFlUH2vTYG4jktcpeUkXVhMYmC5+pLPVhTKhw+s6v2LNKBk0lCwTkOhLWmo7H4+jWyRb0kgHD484AJ9RApX0UBlc7GBwQZ+iu7RnkT3lhRqKq4hr6+iJT1FyAYRmN0hwtMRDcLvagR2lwgV8XpDcN5dhnO9TpNx2zJwzLXvz7o91K3Q7TnSYpXU7Rvd2rp1dXukYw6QreCSCeFbkcC55Q4wFlVY4HYWMKlxyITE0gpLG5hTvN/AKkZSDLBD8Q7iHYoRiH/E0HewTTpGANNcB34chzD8OC5p8gZWMeTZUZrk2VGa5NlFjGt04MFgF5ou6TjQfMTw4OISaOwCk47S98izh3jPeMZaBnuY65F/F3M94wHl0Kd98OHBJx0fOuqIaIy5vpmLfTNYreWTNw98QLzSCSgXHzkGtA8BfAa0D4EqxeTBhx+DQ+CQcgzhLTRzoWNwCJ3Q6GCvDA6hGRqdkEm7zgs9MOUFDwajB6Yc4cdg9MCUS6B0aoweuM4LPTDlDj+Sk75al1M8zq3kFI9zK3kdjx64zhE9cJ0XeuA6F/TAdS5SQEeQjoCOMDrw/4hVjNGBJt0LWpPOP3pgylEiR7oXtKYqGBrDm6R1cY9IukfQA5MO7pFHjPMp6R5BD0yauEck3SPw0sAqnjzjx1m6xg/Wcs1c+HHJD8651OccxeBSl4R93Tq69XSp8FV9/EcV9Oer0t/aGSBr9eDRfrn/PW64M8Dzg7XMs6vlqriJR8lV8jUelVZUP8I0R1rc7Wp+neBnt0Fleb7Ak9Q2BTPUItPxbV4kW4cUmXwZPyelhrZIXefFl46nuzjL2rnoB8gWVf/st6iywG9643tcFPldi5nH5aRFNB5jWkrJbWczy7htMZ7FndXmm+142LG+WvozQI1U1+v/h71f/GFPXSz7VytYv5odfc7z4gdFZzPYpbeUHrA/qD6N0W38M4WmMdrln1QVZfZpYQG7pbaA7ZYXUE8rDMgnRQbcM3VGqXZLjXLVrTZqqScFRy3VrDkDy/wVtoY73wE=", "heyawake_3": "m=edit&p=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", "ayeheya_1": "m=edit&p=7Vhrb9s2FP2eX1Ho04YSMymRlGSgGJxXgSLJkiVd1hhGoDjyo5GtVH4kVZD/3kPyqrIcJ0MxDMuHwjZ5eO8RH/ceXRCefVkkRcqEZiJgQcQ4E/hoqZkKfCZEFLuG0+dsPM/S9hvWWcxHeQEwms9vZ+1W63ZRXpQXv2Xj6U3r9vfkazpKvyYtoVsiaMUBN1+fvv3hUK5+o9h9Oed9vszDIQAfLJcz04e5ablYLpcWcO4rmamRVEOpgiFjf+zvs0GSzVL24dPn7d2bzt1e5++WugiCj0eDt593Tz5+vj7/S5zwcavgR1k0PTze3c7evi8vDkedZbqX6uNZ3h9laXKdlBfnH+6z6X40HA3EzofRTjRIpnz2JTqLl9sn795tdSkOva2HMm6XHVa+b3c94THPx094PVaetB/Kw3Z5yspTuDwmesybLLL5uJ9neeFVtvLAPegD7tXw3PoN2nFGwYGPCAN+AuyPi36WXh44y3G7W54xz6y9bZ820Jvky9QsZvZmxv18cjU2hqtkjhTORuNbjwVwzBbX+c2CqKL3yMrOj50Ak1QnMNCdwKANJzAH+29PEPceH5GcP3GGy3bXHOdjDaManrYf0B61Hzzp41Fo3eXPkxJDVQ9DDOX3oZCq4RbaPL3ij4OG37f82u/LuOlXZrmAxtiRsPv6ZNt92/q2PcO2WRnYdte23LbKtgeWs4fTBNq8x9iEz4A1sHY45CyIOWEBLByOAiY58SMNTPyYMymIHwtgxweXSd/xwQV2fHCZDBwffiYlcXxwJHECcBRxJDiaOCg3UhNHgRMSB2eRdBapJTCCZXEIjMQYHPpMxkiCwZFkihMnCoGJE/tMCceBnynfceAHdhz4Tclz2AfH6MBicIwIDEZJVIo4EhxNHAmOJo4CJyQO9qxoz0pHwJHDYcCUEYrFKLWxOzu4TNP+wQUmfhwwLYgfa2DHB5dpOgu4wI4PLtOB44MLTLHlyCOnWHHEjfJuc1flV5hcEwcxkbQuemCKrUAuBIRtsQKuNIA8Coq5yTvFFj2w2xt6YLwEFse1ZkyuKZ4215rm15i/0gb0jNzXGtA0J2IrNc2pMWelH2hehnQuaB5aqTUT0rmQCxnSuiHWDWndEOuGtC5yJENaN8I8UaU3zFPp07xHVa6Nlqp8IW6K4oYe2M2PHpjyjrgpipvSAlqq9IN56B1BX+vN6Irigx6Y5kF8vusQ8YHmau1RfNBDny4+6IFdfNDXukV8oNFaqxQf9MC0LuID7dYatvFBETq3pWjHttK22pao0NTdH6rM/74a/uN2utIU/pc+plT/9P9v/t5W1ztdFIOkn3q4dHmzPLucufFlep/0517bXf5WPQ3bdDG5SnFrWTFleX6Li+qmGSpXwzgeTvMi3egyxvR6+NxUxrVhqqu8uF7b012SZc2z2Jt5w+RuTQ3TvMCVaGWcFEV+17BMkvmoYVi5PjVmSqdrwZwnzS0mN8naapM6HI9b3r1nf7jN4KX6eUN+1Tdkkyj+2qrxa9uO1XhevFBwaue6eUPZgfWFyrPi3WR/psiseNftTyqK2ezTogLrhroC63ppgelpdYHxSYGB7ZkaY2ZdLzNmV+uVxiz1pNiYpVbrTdejfxne/JLeJHdm8KvX2/oG", From 99be5449e649b517560e8dfe9cfd5195420a18fb Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Mon, 30 Mar 2026 19:22:31 +0800 Subject: [PATCH 15/17] update for converter --- scripts/debug_penpa_converter.py | 83 +++++ scripts/quick_start.py | 26 +- src/puzzlekit/formats/debug.py | 363 +++++++++++++++++--- src/puzzlekit/formats/penpa_converter.py | 30 +- src/puzzlekit/formats/penpa_template.py | 4 - src/puzzlekit/formats/puzzlink_converter.py | 31 +- 6 files changed, 471 insertions(+), 66 deletions(-) create mode 100644 scripts/debug_penpa_converter.py diff --git a/scripts/debug_penpa_converter.py b/scripts/debug_penpa_converter.py new file mode 100644 index 00000000..dfdea628 --- /dev/null +++ b/scripts/debug_penpa_converter.py @@ -0,0 +1,83 @@ +import argparse +import logging +import runpy +import os +from pathlib import Path + +from puzzlekit.formats.penpa_converter import PenpaConverter + + +def _read_first_nonempty_line(path: str) -> str: + p = Path(path) + with p.open("r", encoding="utf-8") as f: + for line in f: + s = line.strip() + if s: + return s + raise ValueError(f"No non-empty lines found in: {path}") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="One-click debugger for penpa_converter." + ) + parser.add_argument( + "--level", + default="DEBUG", + help="Logging level (DEBUG/INFO/WARNING...). Default: DEBUG", + ) + parser.add_argument( + "--run-main", + action="store_true", + help="Run penpa_converter.py __main__ block directly.", + ) + parser.add_argument( + "--url", + default="", + help="Penpa payload/url to decode directly (m=edit&p=... or full URL).", + ) + parser.add_argument( + "--url-file", + default="", + help="Read URL from first non-empty line in file.", + ) + parser.add_argument( + "--show-parts", + action="store_true", + help="Enable debug_penpa_parts when decoding URL.", + ) + args = parser.parse_args() + + level = getattr(logging, str(args.level).upper(), logging.DEBUG) + logging.basicConfig( + level=level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%H:%M:%S", + ) + + if args.run_main: + if args.show_parts: + # Let penpa_converter decode() enable parts dump even in __main__ flow. + os.environ["PUZZLEKIT_DEBUG_PENPA_PARTS"] = "1" + runpy.run_path("src/puzzlekit/formats/penpa_converter.py", run_name="__main__") + return + + url = args.url.strip() + if args.url_file: + url = _read_first_nonempty_line(args.url_file) + + if not url: + parser.error("Provide --run-main, or --url/--url-file.") + + cfg = {"debug_penpa_parts": True} if args.show_parts else {} + converter = PenpaConverter(cfg) + ir = converter.decode(url) + print( + f"decoded puzzle_type={ir.puzzle_type} rows={ir.rows} cols={ir.cols} " + f"cells={len(ir.cells)} edges={len(ir.edges)} boxes={len(ir.boxes)}" + ) + + +if __name__ == "__main__": + main() + diff --git a/scripts/quick_start.py b/scripts/quick_start.py index 0051b5f3..f932865d 100644 --- a/scripts/quick_start.py +++ b/scripts/quick_start.py @@ -1,6 +1,22 @@ import puzzlekit import time +import os +import logging + +# Script-only logging configuration: +# - Library code never calls `basicConfig`. +# - Enable logs by setting `PUZZLEKIT_LOG_LEVEL` (e.g. DEBUG/INFO/WARNING). +_level_name = os.getenv("PUZZLEKIT_LOG_LEVEL", "").strip().upper() +if _level_name: + _level = getattr(logging, _level_name, None) + if isinstance(_level, int): + logging.basicConfig( + level=_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + # Raw input data start_time = time.time() problem_str = """ @@ -31,19 +47,19 @@ # URL -> IR -ir = puzzlekit.decode("ttps://puzz.link/p?hebi/10/10/n150.15a42a41b320.c0.a0.0.d310.0.b240.n230.b0.0.42d0.0.a0.c0.21b0.a21a0.0.0.n") +ir = puzzlekit.decode("https://puzz.link/p?hitori/8/8/7113844227266831246771852247731643381555633245178631347515752264") # IR -> penpa penpa_url = puzzlekit.encode(ir, "penpa") print(penpa_url) # print(penpa_url) -penpa_url2 = puzzlekit.convert("https://puzz.link/p?masyu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030", "penpa") +# penpa_url2 = puzzlekit.convert("https://puzz.link/p?masyu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030", "penpa") -print(penpa_url2) -puzzlink_url = puzzlekit.convert(penpa_url2, "puzzlink") -print(puzzlink_url) +# print(penpa_url2) +# puzzlink_url = puzzlekit.convert(penpa_url2, "puzzlink") +# print(puzzlink_url) # # Per-stage converter config (optional, for fine-grained conversion control) # # e.g. when target is puzzlink and you need encode-side flags only. # _ = puzzlekit.convert( diff --git a/src/puzzlekit/formats/debug.py b/src/puzzlekit/formats/debug.py index 8b63096c..0e78a5d4 100644 --- a/src/puzzlekit/formats/debug.py +++ b/src/puzzlekit/formats/debug.py @@ -1,7 +1,10 @@ import argparse import json +import sys +import logging from pathlib import Path -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Tuple, Optional, Iterable +import copy from puzzlekit.formats.base import PuzzleInstance from puzzlekit.formats.penpa_converter import PENPA_PREFIX, PENPA_URLPREFIX, PenpaConverter @@ -13,35 +16,58 @@ def _read_nonempty_lines(path: str) -> List[str]: return [line.strip() for line in f.readlines() if line.strip()] -def _resolve_input_urls(args: argparse.Namespace) -> Tuple[str, str]: - # Priority: --pair-file > (--puzzlink-file/--penpa-file) > (--puzzlink-url/--penpa-url) +def _first_line_from_file(path: str, flag_name: str) -> str: + lines = _read_nonempty_lines(path) + if not lines: + raise ValueError(f"{flag_name} is empty.") + return lines[0] + + +def _resolve_two_inputs(args: argparse.Namespace) -> Tuple[str, str]: + """ + Resolve two inputs (left/right) with backward compatibility. + + Priority: + - --pair-file (two non-empty lines) + - --left/--right + - --left-file/--right-file + - legacy: --puzzlink-url/--penpa-url (+ corresponding *-file) + """ + # 1) pair-file if args.pair_file: lines = _read_nonempty_lines(args.pair_file) if len(lines) < 2: raise ValueError("--pair-file must contain at least 2 non-empty lines.") return lines[0], lines[1] + # 2) explicit left/right + left = args.left + right = args.right + + if args.left_file: + left = _first_line_from_file(args.left_file, "--left-file") + if args.right_file: + right = _first_line_from_file(args.right_file, "--right-file") + + if left and right: + return left, right + + # 3) legacy flags (kept for compatibility) puzzlink_url = args.puzzlink_url penpa_url = args.penpa_url if args.puzzlink_file: - lines = _read_nonempty_lines(args.puzzlink_file) - if not lines: - raise ValueError("--puzzlink-file is empty.") - puzzlink_url = lines[0] - + puzzlink_url = _first_line_from_file(args.puzzlink_file, "--puzzlink-file") if args.penpa_file: - lines = _read_nonempty_lines(args.penpa_file) - if not lines: - raise ValueError("--penpa-file is empty.") - penpa_url = lines[0] + penpa_url = _first_line_from_file(args.penpa_file, "--penpa-file") - if not puzzlink_url or not penpa_url: - raise ValueError( - "Please provide URLs via --pair-file, or both sides via URL/file arguments." - ) + if puzzlink_url and penpa_url: + return puzzlink_url, penpa_url - return puzzlink_url, penpa_url + raise ValueError( + "Please provide two inputs via --pair-file, or --left/--right, " + "or --left-file/--right-file (legacy: --puzzlink-url/--penpa-url)." + ) def _normalize_penpa_url(url: str) -> str: @@ -140,25 +166,188 @@ def _diff_list(left: List[Any], right: List[Any], max_examples: int) -> Dict[str } -def compare_irs(left: PuzzleInstance, right: PuzzleInstance, max_examples: int = 20) -> Dict[str, Any]: - ln = left.normalize() - rn = right.normalize() +def _parse_ignore_paths(raw: str) -> List[str]: + if not raw: + return [] + parts = [p.strip() for p in raw.split(",")] + return [p for p in parts if p] + + +def _iter_dict_items(obj: Any) -> Iterable[Tuple[Any, Any]]: + if isinstance(obj, dict): + return obj.items() + return [] + + +def _apply_ignore_paths(data: Any, ignore_paths: List[str]) -> Any: + """ + Remove fields from a nested JSON-like structure. + + Path syntax: + - dot-separated keys, e.g. "cells", "meta.rows", "cells.*.number.number_style" + - "*" matches all dict keys / list items at that level + """ + if not ignore_paths: + return data + root = copy.deepcopy(data) + + def _delete_at(node: Any, parts: List[str]) -> None: + if not parts: + return + head, *tail = parts + + # delete the node itself (parent handles actual deletion) + if isinstance(node, dict): + if head == "*": + for k in list(node.keys()): + if tail: + _delete_at(node.get(k), tail) + else: + node.pop(k, None) + return + + if head not in node: + return + + if not tail: + node.pop(head, None) + return + _delete_at(node.get(head), tail) + return + + if isinstance(node, list): + if head == "*": + for item in node: + _delete_at(item, tail) + return + # numeric index for lists + if head.isdigit(): + idx = int(head) + if 0 <= idx < len(node): + if not tail: + node[idx] = None + else: + _delete_at(node[idx], tail) + return + + for path in ignore_paths: + _delete_at(root, path.split(".")) + return root + + +def _jaccard(a: set, b: set) -> float: + if not a and not b: + return 1.0 + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def _value_equal_ratio(left: Dict[str, Any], right: Dict[str, Any]) -> float: + lk = set(left.keys()) + rk = set(right.keys()) + common = lk & rk + if not common: + return 1.0 if not lk and not rk else 0.0 + eq = sum(1 for k in common if left.get(k) == right.get(k)) + return eq / len(common) + + +def _similarity_report(ln: Dict[str, Any], rn: Dict[str, Any]) -> Dict[str, Any]: + cells_l = ln.get("cells", {}) if isinstance(ln.get("cells"), dict) else {} + cells_r = rn.get("cells", {}) if isinstance(rn.get("cells"), dict) else {} + edges_l = ln.get("edges", {}) if isinstance(ln.get("edges"), dict) else {} + edges_r = rn.get("edges", {}) if isinstance(rn.get("edges"), dict) else {} + boxes_l = ln.get("boxes", []) if isinstance(ln.get("boxes"), list) else [] + boxes_r = rn.get("boxes", []) if isinstance(rn.get("boxes"), list) else [] + + # boxes: simple positional equality ratio on the overlapping prefix + min_len = min(len(boxes_l), len(boxes_r)) + if min_len == 0: + boxes_eq_ratio = 1.0 if not boxes_l and not boxes_r else 0.0 + else: + boxes_eq_ratio = sum(1 for i in range(min_len) if boxes_l[i] == boxes_r[i]) / min_len + + return { + "cells": { + "key_jaccard": _jaccard(set(cells_l.keys()), set(cells_r.keys())), + "value_equal_ratio_on_common_keys": _value_equal_ratio(cells_l, cells_r), + "left_count": len(cells_l), + "right_count": len(cells_r), + }, + "edges": { + "key_jaccard": _jaccard(set(edges_l.keys()), set(edges_r.keys())), + "value_equal_ratio_on_common_keys": _value_equal_ratio(edges_l, edges_r), + "left_count": len(edges_l), + "right_count": len(edges_r), + }, + "boxes": { + "left_len": len(boxes_l), + "right_len": len(boxes_r), + "equal_ratio_on_common_prefix": boxes_eq_ratio, + }, + "meta": { + "grid_type_equal": ln.get("grid_type") == rn.get("grid_type"), + "puzzle_type_equal": ln.get("puzzle_type") == rn.get("puzzle_type"), + "rows_equal": ln.get("rows") == rn.get("rows"), + "cols_equal": ln.get("cols") == rn.get("cols"), + "margins_equal": ln.get("margins") == rn.get("margins"), + }, + } + + +def compare_irs( + left: PuzzleInstance, + right: PuzzleInstance, + max_examples: int = 20, + ignore_paths: Optional[List[str]] = None, +) -> Dict[str, Any]: + ignore_paths = ignore_paths or [] + + # normalize -> apply ignores + ln_raw = left.normalize() + rn_raw = right.normalize() + ln = _apply_ignore_paths(ln_raw, ignore_paths) + rn = _apply_ignore_paths(rn_raw, ignore_paths) + + # tolerate missing keys after ignore filtering + ln_cells = ln.get("cells", {}) if isinstance(ln.get("cells", {}), dict) else {} + rn_cells = rn.get("cells", {}) if isinstance(rn.get("cells", {}), dict) else {} + ln_edges = ln.get("edges", {}) if isinstance(ln.get("edges", {}), dict) else {} + rn_edges = rn.get("edges", {}) if isinstance(rn.get("edges", {}), dict) else {} + ln_boxes = ln.get("boxes", []) if isinstance(ln.get("boxes", []), list) else [] + rn_boxes = rn.get("boxes", []) if isinstance(rn.get("boxes", []), list) else [] + + ln_rows = ln.get("rows") + rn_rows = rn.get("rows") + ln_cols = ln.get("cols") + rn_cols = rn.get("cols") + ln_margins = ln.get("margins") + rn_margins = rn.get("margins") + ln_grid = ln.get("grid_type") + rn_grid = rn.get("grid_type") + ln_type = ln.get("puzzle_type") + rn_type = rn.get("puzzle_type") result = { - "semantic_equal": left.semantic_equals(right), + "semantic_equal": ln == rn, + "ignore_paths": ignore_paths, + "similarity": _similarity_report(ln, rn), "meta": { - "rows_equal": ln["rows"] == rn["rows"], - "cols_equal": ln["cols"] == rn["cols"], - "margins_equal": ln["margins"] == rn["margins"], - "grid_type_equal": ln["grid_type"] == rn["grid_type"], - "rows": (ln["rows"], rn["rows"]), - "cols": (ln["cols"], rn["cols"]), - "margins": (ln["margins"], rn["margins"]), - "grid_type": (ln["grid_type"], rn["grid_type"]), + "rows_equal": ln_rows == rn_rows, + "cols_equal": ln_cols == rn_cols, + "margins_equal": ln_margins == rn_margins, + "grid_type_equal": ln_grid == rn_grid, + "puzzle_type_equal": ln_type == rn_type, + "rows": (ln_rows, rn_rows), + "cols": (ln_cols, rn_cols), + "margins": (ln_margins, rn_margins), + "grid_type": (ln_grid, rn_grid), + "puzzle_type": (ln_type, rn_type), }, - "cells": _diff_dict(ln["cells"], rn["cells"], max_examples), - "edges": _diff_dict(ln["edges"], rn["edges"], max_examples), - "boxes": _diff_list(ln["boxes"], rn["boxes"], max_examples), + "cells": _diff_dict(ln_cells, rn_cells, max_examples), + "edges": _diff_dict(ln_edges, rn_edges, max_examples), + "boxes": _diff_list(ln_boxes, rn_boxes, max_examples), } return result @@ -176,6 +365,10 @@ def print_summary(tag: str, ir: PuzzleInstance) -> None: def print_diff_report(report: Dict[str, Any]) -> None: _print_section("IR Comparison") print(f"semantic_equal: {report['semantic_equal']}") + if report.get("ignore_paths"): + print("ignore_paths:", json.dumps(report["ignore_paths"], ensure_ascii=False)) + if report.get("similarity") is not None: + print("similarity:", json.dumps(report["similarity"], ensure_ascii=False)) print("meta:", json.dumps(report["meta"], ensure_ascii=False)) print("cells:", json.dumps(report["cells"], ensure_ascii=False)) print("edges:", json.dumps(report["edges"], ensure_ascii=False)) @@ -203,7 +396,13 @@ def _safe_roundtrip(fmt: str, ir: PuzzleInstance) -> Dict[str, Any]: return {"ok": False, "error": str(e)} -def dump_normalized(prefix: str, left: PuzzleInstance, right: PuzzleInstance, report: Dict[str, Any]) -> None: +def dump_normalized( + prefix: str, + left: PuzzleInstance, + right: PuzzleInstance, + report: Dict[str, Any], + ignore_paths: Optional[List[str]] = None, +) -> None: p = Path(prefix) p.parent.mkdir(parents=True, exist_ok=True) @@ -211,14 +410,65 @@ def dump_normalized(prefix: str, left: PuzzleInstance, right: PuzzleInstance, re json.dump(left.normalize(), f, ensure_ascii=False, indent=2) with open(f"{prefix}_right.normalized.json", "w", encoding="utf-8") as f: json.dump(right.normalize(), f, ensure_ascii=False, indent=2) + if ignore_paths: + with open(f"{prefix}_left.filtered.json", "w", encoding="utf-8") as f: + json.dump(_apply_ignore_paths(left.normalize(), ignore_paths), f, ensure_ascii=False, indent=2) + with open(f"{prefix}_right.filtered.json", "w", encoding="utf-8") as f: + json.dump(_apply_ignore_paths(right.normalize(), ignore_paths), f, ensure_ascii=False, indent=2) with open(f"{prefix}_diff.report.json", "w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=2) +# 🔧 新增辅助函数(放在 main() 之前或之后都可以) +def _setup_logging(args: argparse.Namespace) -> None: + """Configure logging based on CLI arguments.""" + + # 决定日志级别 + if args.debug or args.verbose >= 2: + level = logging.DEBUG + elif args.verbose >= 1: + level = logging.INFO + else: + level = logging.WARNING + + # 决定输出目标 + handlers = [] + if args.log_file: + handlers.append(logging.FileHandler(args.log_file, mode="w", encoding="utf-8")) + else: + handlers.append(logging.StreamHandler(sys.stdout)) + + # 配置 root logger(force=True:脚本入口强制接管输出,库代码不应 basicConfig) + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)-8s] %(name)s:%(lineno)d - %(message)s", + datefmt="%H:%M:%S", + handlers=handlers, + force=True, + ) + + # 精准控制 puzzlekit 的日志级别 + logging.getLogger("puzzlekit").setLevel(level) + + # 屏蔽第三方库噪音(按需调整) + for noisy in ["urllib3", "httpx", "PIL", "matplotlib"]: + logging.getLogger(noisy).setLevel(logging.WARNING) + + # 如果是 debug 模式,打印配置信息(验证是否生效) + logger = logging.getLogger(__name__) + logger.debug("Logging configured: level=%s, handlers=%s", + logging.getLevelName(level), [type(h).__name__ for h in handlers]) def main() -> None: parser = argparse.ArgumentParser( - description="Debug IR equivalence between puzzlink and penpa URLs." + description="Compare two puzzle URLs (puzz.link / penpa) by IR diff + similarity." ) + # New generic interface + parser.add_argument("--left", type=str, default="", help="Left input: puzz.link URL or penpa URL/payload") + parser.add_argument("--right", type=str, default="", help="Right input: puzz.link URL or penpa URL/payload") + parser.add_argument("--left-file", type=str, default="", help="Read left input from file (first non-empty line)") + parser.add_argument("--right-file", type=str, default="", help="Read right input from file (first non-empty line)") + + # Legacy interface (kept for compatibility) parser.add_argument("--puzzlink-url", type=str, default="", help="puzz.link URL") parser.add_argument("--penpa-url", type=str, default="", help="penpa URL or m=edit&p=...") parser.add_argument( @@ -240,6 +490,16 @@ def main() -> None: help="read two URLs from one file: line1=puzzlink, line2=penpa", ) parser.add_argument("--max-diff", type=int, default=20, help="max diff examples per section") + parser.add_argument( + "--ignore", + type=str, + default="", + help=( + "Comma-separated ignore paths applied to normalized IR before diff. " + "Examples: 'meta.source,cells.*.number.number_style,edges'. " + "Supported: dot paths with '*' wildcard." + ), + ) parser.add_argument( "--check-roundtrip", action="store_true", @@ -251,17 +511,40 @@ def main() -> None: default="", help="optional file prefix to dump normalized json and diff report", ) + + # 🔧 新增调试参数 + parser.add_argument( + "--debug", + action="store_true", + help="Enable DEBUG logging for puzzlekit" + ) + parser.add_argument( + "--verbose", "-v", + action="count", + default=0, + help="Increase verbosity: -v=INFO, -vv=DEBUG" + ) + parser.add_argument( + "--log-file", + type=str, + default="", + help="Optional: write logs to file instead of stdout" + ) + args = parser.parse_args() + + _setup_logging(args) - puzzlink_url, penpa_url = _resolve_input_urls(args) + left_url, right_url = _resolve_two_inputs(args) + ignore_paths = _parse_ignore_paths(args.ignore) - left_fmt, left_ir = decode_url_to_ir(puzzlink_url) - right_fmt, right_ir = decode_url_to_ir(penpa_url) + left_fmt, left_ir = decode_url_to_ir(left_url) + right_fmt, right_ir = decode_url_to_ir(right_url) print_summary(f"Left ({left_fmt})", left_ir) print_summary(f"Right ({right_fmt})", right_ir) - report = compare_irs(left_ir, right_ir, max_examples=args.max_diff) + report = compare_irs(left_ir, right_ir, max_examples=args.max_diff, ignore_paths=ignore_paths) print_diff_report(report) if args.check_roundtrip: @@ -270,11 +553,11 @@ def main() -> None: print("right:", json.dumps(_safe_roundtrip(right_fmt, right_ir), ensure_ascii=False)) if args.dump_prefix: - dump_normalized(args.dump_prefix, left_ir, right_ir, report) + dump_normalized(args.dump_prefix, left_ir, right_ir, report, ignore_paths=ignore_paths) print(f"\nSaved debug artifacts with prefix: {args.dump_prefix}") if __name__ == "__main__": main() -# PYTHONPATH=src python src/puzzlekit/formats/debug.py --pair-file src/puzzlekit/formats/temp/pair.txt \ No newline at end of file +# python src/puzzlekit/formats/debug.py --pair-file src/puzzlekit/formats/temp/pair.txt \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index 365bec87..2e7864c5 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -15,9 +15,13 @@ from typing import Any, Dict, List, Optional, Tuple, Union import json import ast +import os from base64 import b64decode, b64encode from functools import reduce from zlib import compress, decompress +import logging + +logger = logging.getLogger(__name__) PENPA_URLPREFIX = "https://swaroopg92.github.io/penpa-edit/#" @@ -71,8 +75,9 @@ def coord_to_index(self, coord: Tuple[int, int] ,type_: str) -> Tuple[int, int]: return r_ * self.real_cols + c_ + self.real_cols * 2 + 2 def _display_parts(self): + # Verbose Penpa payload introspection; guarded by config + DEBUG level. for p in range(len(self.parts)): - print(p, self.parts[p]) + logger.debug("penpa.parts[%d]=%s", p, self.parts[p]) def decode(self, url: str) -> PuzzleInstance: self.url = url @@ -106,8 +111,17 @@ def decode(self, url: str) -> PuzzleInstance: self.new_cols = self.cols + self.left_margin + self.right_margin # PuzzleInstance new size, no padding, with margin self.ir_puzzle.rows, self.ir_puzzle.cols = self.new_rows, self.new_cols - - self._display_parts() + + env_debug_parts = os.getenv("PUZZLEKIT_DEBUG_PENPA_PARTS", "").strip().lower() in { + "1", "true", "yes", "on", + } + debug_penpa_parts = bool( + self.config.get("debug_penpa_parts", False) + or self.config.get("debug_dump", False) + or env_debug_parts + ) + if debug_penpa_parts and logger.isEnabledFor(logging.DEBUG): + self._display_parts() for p in range(len(self.parts)): if p == 1: # decode margins @@ -135,7 +149,7 @@ def decode(self, url: str) -> PuzzleInstance: genre_tag = ast.literal_eval(self.parts[p]) raw_type = genre_tag[0] if len(genre_tag) > 0 else "" self.ir_puzzle.puzzle_type = normalize_puzzle_type(raw_type) - print(self.ir_puzzle.puzzle_type) + logger.debug("penpa.puzzle_type=%s", self.ir_puzzle.puzzle_type) # else: # print(p, self.parts[p]) @@ -312,14 +326,16 @@ def encode(self, inst: PuzzleInstance) -> str: if __name__ == "__main__": for test_url in [ - "m=edit&p=7VhrT+M4FP3Orxj560StX4mTSKNVeY2EoAMLLDtUVZW2gRbShklbQEH973tt3zSPltmRViux0qqtfXx8c+/xI9eGxY9VlMUOo/orfAdq+Ejmmx/3PfOj+LmaLpM4/OR0VstJmgFYLp8WYbv9tMpv89tWMp0/tp9+m8TDaZtR/Z0zl7aYG0keSTYUnLZGtBXRFm2NBdPVkEvamnMBSDclH+sqMnacARdxZuzBynG+HR87x1GyiJ2T7w/7h4+dl6POn233Vojr7t3nh8OL64fxzR/sgk7bGe0m/vzs/HA/+fw1vz2bdJ7jo9g7X6SjSRJH4yi/vTl5TebH/v3kjh2cTA78u2hOFz/8q+B5/+LLl70ejrq/95YHYd5x8q9hjzDiEA4/RvpOfhG+5WchGaWz4ZQ4+SX0E4f1HTJbJcvpKE3SjBRcfmqf5gCPSnhj+jU6sCSjgLuIAX4HOJpmoyQenFrmPOzlVw7RAvbN0xqSWfocExSo21YUEMNoCau2mEyfiCOgY7Eap48rUkRYO3nHDCPv/uIIRDkCsRmB2D0C/q+PIOiv17BCv8MYBmFPD+e6hH4JL8M3In0SSofIwFQutRW3le3zmK0s6XmmUralhK2spbJefOvFV7ayfYwqrLHNXKw9rLGfc6wF1tYpE9gWEmu0l8hL9CfRn9T9az3tdpQ94g4ocZTerH07Yj3PRVsPvWGip6FH+IBXKOOIVSk9PdqKVShe963nrNpWjX49i7U2Rqm4VA25Pm20lW7LgahQft3ErECPiKqNWY2akV6WOuE1CbU1LWbRakZcNInGAMx61glpR12VJ1TDSIptI9mULJuSZc0NbAoWvq31a6jLY1NyU17Bm+HkwpSHpqSmdE15amyOYENxHjhca+GOxYJaLChgZrGUYOMizwF7iBnYcLRxK7wArBDrZ/2S15NjMMRyMa6EWG6Fl6hBQizXK3Gh04Wjy1WlNhf9u6rEErBX6IfjzkOdLmjw8FlP2xTPgn4vQKx94nhd0KNEiT3UpsCPQs0eLWMpeFbhsx5oVqhfuSX29PGLenyw8dG/Aj9+4VPHxVi+KHnfdwTlG8x9qxk44NFPAOc9LezBT4B+As+Bo3mDeYDzEMA8BHYeBGNgg/6DAHDhE2IFGIvxDQ8xASvkFWD0Q13AhTYJely0AW1clrF4wYMN90vMCh40454EDng7hwL2asnDnOA+hJiAURvs1Q3m4FOgfw5+BGoTfok5xMK9ZzDHccH+FJIhr0qdEvRLXurB/Qkc4rU+QvWrdmBKaUrPvIJKH12/eLjZ5P/P3/a/ldMT9pJY/7j/Pa6/1yOXq+wuGsVw4+iuZsM4+9RNs1mUELj0kUWaDBa2fxC/RqMlCe/M5bPaU+PmxkeNStL0Ce7FuzwUXTVyej9Ps3hnlybj8f17rnTXDlfDNBs3NL1ESVIfi/kroEbZC1uNWmbTWjvKsvSlxsyi5aRGVG5uNU/xvDGZy6guMXqMGtFm5XSs98grMb8eJD29kP/f0D/6DV2vFv1oqeyjyTEbPc1+knXKzia9I/cA+5P0U+ndxb+TaSq9TX4rrWix25kF2B3JBdhmfgFqO8UAuZVlgHsn0WivzVyjVTXTjQ61lXF0qGrS6RH9nw04Gv4C" + # "#m=edit&p=1Vddb9pIFH3Pr6j8Wi94xuMvpGpFkqZS1bLNNt1sYyFkwAluDE4NNJGj9rf33JlrsB3arbTahxUwc+bM9f2auZ5h/XmblKktHPq6oY0eHyVC/ZOhr38Ofy6yTZ4OntnD7WZRlACbzd160O/fbaur6qqXZ6vb/t3vi3Sa9YVD35XwnJ7wEiUTJaaudHozp5c4Pac3dwV1U6mc3kq6QDRUck4dJCAnBbhECi0PKdv+4+zMvk7ydWq//vjp+PR2eP9y+Hffu3LdD6Pr559Ozz98ml/+Jc6drF86ozxcvX13epw/f1VdvV0Mv6QvU//dupgt8jSZJ9XV5euHfHUW3iyuxcnrxUl4nayc9efwIvpyfP7ixVHMUY+PYktYtiXxE9b4WzX6pgk5Pnqs/hw8VpNBPP5qVx/2MNzD94NHS4XWQNmWinTnOaaTpjNzvjCdIX1fd4EZBa7pjGRgtIRGSxiYzswJxwyFw2PhcW8UCsHz0mgW0qgW0igVLo9dxT3LK+YV61OsT9E8ohxxlLHlTRzLDihbYxNxbO3HFHpHhNIQW3KC7O4orUg0KUoPSSHtO0o/2BjDpeaYktcaI4TWmK00VFJqmyKU49YY0caWmrgNSmvZj/UKxJbblNGr0RKiZWkTHef1QnXSohetJUSr1yY6Aej1bBNYWIq66R4tckuIVrsrRCvfFuq6THthT2BTiMEj2o+6PdOt1O0FKsOuXN2e6tbRrafbN1rmJTaUlJEtyRcJjYRdrAdh1wHGjiCsFGTgm+YlMNzSWEAGCdMyXoN3geGpxvQs1qbmKTkaw5bHdhVseQ1esQ8KtjzWSbj208Mb02P95BttZs0He6yAaUdrjLcsbWUtAx98ftYnmfpZ+O9jaTUmnRyvB39oW9fYZ98C6AnYZx/+17YCPBvwsz58Dtj/APpr7NNbn/0JIROy/gB6wlon2WVbIWzVfBjarlM/S3qMz+DAs54Ix4xTy0NPxHoi38aJsMMy4jxEyENk8uAKARnWH0XAtU7YitiWgC3mYRPY6EEPzHocD7j2TcEfkxPYt11pfNO2ZM1DRtY+AFP5agyfeU+CA29y6GKv7nnkhPchbAKzb9irOyyh02X9Enpc9g3n8Q5L2OK9p7HkuLA/XWXWF9zeTwX/Fdslf3h/gmOMIrvUpXaiW6VbX5dgQEfX0VGMOqPbQPvj/f84OsHfb8vrZJbiDB9tl9O0fDYqymWSWzjErXWRT9ZmfpI+JLONNTC3jOZMi1tpHS0qL4o7XIAOaainWmR2syrK9OAUken85keqaOqAqmlRzjs+3Sd53o5FX/da1CwrZ3mb2pRZa5yUZXHfYpbJZtEipskGV8P1Irtra0pXnWRukraLyW3Ssbbcp+PrkfVg6V+M1wwt5GMVDaqhXb2iA2p/KbOrc1y53g6sWbGcZhZdu+goEjiXltt8k82KvIBd5nDG8A2ODpodvNTzhE4MKRzgEWPAj4AmXZM3hnk3iKsL2yIHjvXTBK1l8QURGAdpbJwC0ciS7WJivZ0Xt1sWFXRmDnUY1egXI4CSOgKCJgJCByKgwP7bCKLxV7Nizi/ejM3N8d9fFf7xXfbAVV6UPyn0/WSXPlDuYH9S8Y3ZQ/wPirsx2+WfVDI5+7SYwR6oZ7Ddkgb1tKpBPilscD+obdLaLW/yqlvhZOpJkZOpZp3HFv1r/C2bLbKbAi9lTX8H" + # "#m=edit&p=1Vddb9pIFH3Pr6j8Wi94xuMvpGpFkqZS1bLNNt1sYyFkwAluDE4NNJGj9rf33JlrsB3arbTahxUwc+bM9f2auZ5h/XmblKktHPq6oY0eHyVC/ZOhr38Ofy6yTZ4OntnD7WZRlACbzd160O/fbaur6qqXZ6vb/t3vi3Sa9YVD35XwnJ7wEiUTJaaudHozp5c4Pac3dwV1U6mc3kq6QDRUck4dJCAnBbhECi0PKdv+4+zMvk7ydWq//vjp+PR2eP9y+Hffu3LdD6Pr559Ozz98ml/+Jc6drF86ozxcvX13epw/f1VdvV0Mv6QvU//dupgt8jSZJ9XV5euHfHUW3iyuxcnrxUl4nayc9efwIvpyfP7ixVHMUY+PYktYtiXxE9b4WzX6pgk5Pnqs/hw8VpNBPP5qVx/2MNzD94NHS4XWQNmWinTnOaaTpjNzvjCdIX1fd4EZBa7pjGRgtIRGSxiYzswJxwyFw2PhcW8UCsHz0mgW0qgW0igVLo9dxT3LK+YV61OsT9E8ohxxlLHlTRzLDihbYxNxbO3HFHpHhNIQW3KC7O4orUg0KUoPSSHtO0o/2BjDpeaYktcaI4TWmK00VFJqmyKU49YY0caWmrgNSmvZj/UKxJbblNGr0RKiZWkTHef1QnXSohetJUSr1yY6Aej1bBNYWIq66R4tckuIVrsrRCvfFuq6THthT2BTiMEj2o+6PdOt1O0FKsOuXN2e6tbRrafbN1rmJTaUlJEtyRcJjYRdrAdh1wHGjiCsFGTgm+YlMNzSWEAGCdMyXoN3geGpxvQs1qbmKTkaw5bHdhVseQ1esQ8KtjzWSbj208Mb02P95BttZs0He6yAaUdrjLcsbWUtAx98ftYnmfpZ+O9jaTUmnRyvB39oW9fYZ98C6AnYZx/+17YCPBvwsz58Dtj/APpr7NNbn/0JIROy/gB6wlon2WVbIWzVfBjarlM/S3qMz+DAs54Ix4xTy0NPxHoi38aJsMMy4jxEyENk8uAKARnWH0XAtU7YitiWgC3mYRPY6EEPzHocD7j2TcEfkxPYt11pfNO2ZM1DRtY+AFP5agyfeU+CA29y6GKv7nnkhPchbAKzb9irOyyh02X9Enpc9g3n8Q5L2OK9p7HkuLA/XWXWF9zeTwX/Fdslf3h/gmOMIrvUpXaiW6VbX5dgQEfX0VGMOqPbQPvj/f84OsHfb8vrZJbiDB9tl9O0fDYqymWSWzjErXWRT9ZmfpI+JLONNTC3jOZMi1tpHS0qL4o7XIAOaainWmR2syrK9OAUken85keqaOqAqmlRzjs+3Sd53o5FX/da1CwrZ3mb2pRZa5yUZXHfYpbJZtEipskGV8P1Irtra0pXnWRukraLyW3Ssbbcp+PrkfVg6V+M1wwt5GMVDaqhXb2iA2p/KbOrc1y53g6sWbGcZhZdu+goEjiXltt8k82KvIBd5nDG8A2ODpodvNTzhE4MKRzgEWPAj4AmXZM3hnk3iKsL2yIHjvXTBK1l8QURGAdpbJwC0ciS7WJivZ0Xt1sWFXRmDnUY1egXI4CSOgKCJgJCByKgwP7bCKLxV7Nizi/ejM3N8d9fFf7xXfbAVV6UPyn0/WSXPlDuYH9S8Y3ZQ/wPirsx2+WfVDI5+7SYwR6oZ7Ddkgb1tKpBPilscD+obdLaLW/yqlvhZOpJkZOpZp3HFv1r/C2bLbKbAi9lTX8H" + "#m=edit&p=tVZtb6JMFP3ur9jM105W3rUkzcbXJk3r1tWuW4kxiFioCJaX1mDa3957h0EFbZ90N0+QyeXM4c49M5wbo6fEDG2qwSXXqUBFuCRNY7eoKOwW+DV0Y8/Wv9FGEjtBCIETx+tIr1bXSTpOx989119W1z+iOICfb1c1uBTHSuarhRraiqssZUp/drt0YXqRTa/uH5vtZeOl0/hTVceyfNdbnD22+3eP89FvsS+41VDoeXX/5rbd9M4u0/GN03i2O7Z2GwWW49nm3EzHo6uN53frD85CbF05rfrC9IXoqT48f272Ly4qBi99UjGISCiR4BbJ5C0dvBmEUHFS2aa/9G061Y3JK03v9mF9Hw70LYw9fUskiegG7AlLQomqwaPMH4EiMuI9G7tslNg4hDw0ldnYZqPARpWN14zTgfSiCLklhegSZJQkKsqwHovhLGRYDGMZFlRkHrPzyWIF+CrnK8BROQfPUM05KsRqFqvA0ThHBY7GOSqspfG1NMhZ4zk14NQ4R4M8NZ4H65R4Hgly7upHLTkH+FJeP+o6qF/mHBk4O42ot7bXletFXTu9wFc4X8FvlfNV+ILzfVBR74GWXC/WzzTCxo/Y9rfYqLBRY8dSw8OvVAzUt7vgvb+N8RMcJOHCtGwCnx2JAm8aZc9Te2NaMdEzWxzOFDA/Wc3ssAB5QbAG153KkE8VQPfBD0L75BSC9vzho1Q4dSLVLAjnpZpeTM8ramEtpgBZbmh5RSgO3cKzGYbBSwFZmbFTAGZmDA0pctx1MZPtlzYzNoslmkuztNpqvx2vFbIh7AZvw+FjjzjX0wZNL/VCF6FpH5rEjZ4OsEdk/YSSVeLFrhV4ASzJMbA4e1GCsLMPR2weo1YGigLEPR5DeA9htlPT6wy51Y10SAmu3WRvY0hWwTMUn9WGz1awmoE8gxxsEJVhIkrmwTLhVBFbVuNrCiBJrgDDTAFGJxSgsP9XwfnkNTss4Utt/N879X+2jQ03eBB+4vH9ZBk+4XRAPzH7wewp/ANfH8yW8SMTY7HHPgb0hJUBLbsZoGNDA3jkacA+sDVmLTsbqyqbG5c68jcudWhxg+T/UqAZM+wd" ]: - hpc = PenpaConverter(dict()) + hpc = PenpaConverter() tmp = hpc.decode(test_url) # print(tmp.cells) enc = hpc.encode(tmp) # print(tmp) # print(enc) b = hpc.decode(enc) - print(enc) + logger.info(enc) \ No newline at end of file diff --git a/src/puzzlekit/formats/penpa_template.py b/src/puzzlekit/formats/penpa_template.py index dd80db8d..6d880f67 100644 --- a/src/puzzlekit/formats/penpa_template.py +++ b/src/puzzlekit/formats/penpa_template.py @@ -186,7 +186,3 @@ class PenpaFixedFields(TypedDict): "custom_message" : "" } - -# if __name__ == "__main__": -# template = get_penpa_template("heyawake") -# print(template) \ No newline at end of file diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index 7b1a304d..b589d80c 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -16,17 +16,27 @@ import logging logger = logging.getLogger(__name__) -logging.basicConfig( - level=logging.DEBUG, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) # Yajilin, Masyu, Slitherlink, heyawake, shikaku, norinori, hitori class PuzzlinkConverter: def __init__(self, config: Dict[Any, Any] = dict()): self.config = config or {} + def _debug_dump_enabled(self, key: str) -> bool: + """ + Control large debug dumps. + + Strategy: + - Library code never configures logging globally. + - Dumps only happen when DEBUG logging is enabled AND config flag is set. + """ + if not logger.isEnabledFor(logging.DEBUG): + return False + # Allow both a global switch and per-dump keys. + if self.config.get("debug_dump", False): + return True + return bool(self.config.get(key, False)) + def _decode_nonogram_variant(self): self.body = self.url.split("/")[-1] number_map = self._decode_number16() @@ -343,7 +353,7 @@ def decode(self, url: str) -> Dict[str, Any]: elif decode_family == "noop": return self.ir_puzzle else: - raise NotImplementedError + raise NotImplementedError(f"Puzzle type {self.puzzle_type} is not supported currently.") return self.ir_puzzle @@ -384,7 +394,7 @@ def encode(self, inst: PuzzleInstance) -> str: body_str = self._encode_yajilin_variant(inst) return body_str else: - raise NotImplementedError(f"Puzzle type {self.puzzle_type} not supported for encoding") + raise NotImplementedError(f"Puzzle type {self.puzzle_type} is not supported currently.") # _decode_heyawake_variant @@ -561,8 +571,8 @@ def _token_from_cell(cell_state: CellState) -> Optional[str]: border_str = self._encode_border(border_list) body_str = border_str + number3_str else: - # logger.info(number3_str) - logger.info(number_list) + if self._debug_dump_enabled("debug_dump_puzzlink_number_list"): + logger.debug("puzzlink.number_list=%s", number_list) body_str = number3_str pzl_type = to_puzzlink_type(self.puzzle_type) @@ -1118,7 +1128,8 @@ def _encode_number16(self, number_map: Dict[int, Any], max_region_id: int) -> st result = [] current_id = 0 skip_count = 0 - logger.info(f"{number_map}") + if self._debug_dump_enabled("debug_dump_puzzlink_number_map"): + logger.debug("puzzlink.number_map=%s", number_map) while current_id <= max_region_id: if current_id in number_map: # 🔹 先输出累积的跳过 From 665ee579bbcf9cffe1e7e8980ddda8db659b6333 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Tue, 31 Mar 2026 01:17:07 +0800 Subject: [PATCH 16/17] unfiy API --- scripts/quick_start.py | 29 +- src/puzzlekit/__init__.py | 3 +- src/puzzlekit/formats/debug.py | 13 +- src/puzzlekit/formats/penpa_converter.py | 281 +++++++++++++++----- src/puzzlekit/formats/puzzlink_converter.py | 104 ++++++-- tests/formats/conftest.py | 12 + tests/formats/test_roundtrip.py | 17 ++ 7 files changed, 337 insertions(+), 122 deletions(-) diff --git a/scripts/quick_start.py b/scripts/quick_start.py index f932865d..eb648189 100644 --- a/scripts/quick_start.py +++ b/scripts/quick_start.py @@ -1,5 +1,4 @@ import puzzlekit - import time import os import logging @@ -47,28 +46,16 @@ # URL -> IR -ir = puzzlekit.decode("https://puzz.link/p?hitori/8/8/7113844227266831246771852247731643381555633245178631347515752264") - +ir = puzzlekit.decode("https://puzz.link/p?slither/10/10/g188227cl1dg367bdcg3ddgbhdgd1agbd760dg2cl633661d") # IR -> penpa penpa_url = puzzlekit.encode(ir, "penpa") print(penpa_url) +# get: +# m=edit&p=7VdtT9swEP7Or0... -# print(penpa_url) -# penpa_url2 = puzzlekit.convert("https://puzz.link/p?masyu/10/15/39000c0966103093ibf40d3262003j31008060003l03990030", "penpa") - - -# print(penpa_url2) -# puzzlink_url = puzzlekit.convert(penpa_url2, "puzzlink") -# print(puzzlink_url) -# # Per-stage converter config (optional, for fine-grained conversion control) -# # e.g. when target is puzzlink and you need encode-side flags only. -# _ = puzzlekit.convert( -# penpa_url2, -# "puzzlink", -# decode_converter_config={}, -# encode_converter_config={}, -# ) -# # 获取 IR(自动识别) -# ir2 = puzzlekit.convert(penpa_url2, "ir") -# print(penpa_url, puzzlink_url) \ No newline at end of file +penpa_url = "https://swaroopg92.github.io/penpa-edit/#m=edit&p=7Vfvb+I4EP3OX3Hy17WOOIFgIq1OlP6QqrZXru31CkKVISZJawibhLZK1f99Zxyq2IGudHfSqiedQoaZ9+zxTGKeRf5tIzJJmYMfj1P4hqvDuL5d7uvb2V7XSaFk8AsdbIo4zcCJi2KdB+32elOOy/GvKlk9tte/5SopYpm1mYOfGfdF5PMwYm5vFvmuUGHEQxHyWRQq1w+jnuvO9QDOZyGlvx8f04VQuaSndw8Hh4+D56PBX+3u2PNuLhZfHg5HNw/h7Z9s5CTtzLlQfHV+eXigvpyU4/N48CSPpH+Zp/NYSRGKcnx7+qJWxzyKF2x4Gg/5Qqyc/Bu/7j8djL5+bU22rU1br2U/KAe0PAkmxCOUMLhdMqXlKHgtzwMyT5ezhNDyCnhC2ZSS5UYVyTxVaUbesfIMPJjpgntUu7eaR29YgcwB/2Lrg3sH7jzJ5kren1XIZTAprynBAg70bHTJMn2SuBgWh3FVFAAzUcCryeNkTagHRL4J08fNdiibvtFy8A/agEzvbaBbtYHenjawu3/dhgwj+bKng/707Q3e0B/Qw30wwXZuapfX7lXwCvYieCWeA1NxL8N0yOZ5EHp12LXZHoS49auwY7MdZOu5nb4VdjFzPbjLIXTrEAfXoc+swb69kI8L1YN7rlUVx7n1utwuo2+z/Y41lzk2zRzkzdi3KmGswbvYZV0ac7FNY7x+3MZ4D+cb4z3Mb8Z2q6yDvZpxo54Ormfk7zbW04/diP0G72N+I59+8u88bBmmN86dtsfautpew76ipaftobaOtl1tz/SYI21vtR1q29HW12N6uDP/1t79CeVMvEro7av738OmrQm52mQLMZegJcN0uU7zpJAE9JzkqbrPK+5evoh5QYLqXDEZC1ttljMJMmhAKk3XcKzty/BOWWASrdJM7qUQRIH7IBVSe1LN0ixs1PQslLJ70ae4BVUybEFFBhprxCLL0mcLWYoitgDjWLEyyVXjYRbCLlE8isZqy/pxvLXIC9E3/AThR///4fv5D198W85nk7HPVo7e6Gn2A9WpySa8R3sA/YH8GOw+/AOlMdgmviMrWOyusgC6R1wAbeoLQLsSA+COygD2gdBg1qbWYFVNucGldhQHlzJFZ0K2f0zwbwqZtr4D" +puzzlink_url = puzzlekit.convert(penpa_url, "puzzlink") +print(puzzlink_url) +# get: +# https://puzz.link/p?slither/10/10/b86ag68dg127bg62aldg8dad8bgdl26dg722cg68dg88b3 \ No newline at end of file diff --git a/src/puzzlekit/__init__.py b/src/puzzlekit/__init__.py index f04eaa03..a205daeb 100644 --- a/src/puzzlekit/__init__.py +++ b/src/puzzlekit/__init__.py @@ -7,6 +7,7 @@ PenpaConverter, PENPA_PREFIX, PENPA_URLPREFIX, + PenpaDecodeError, ) @@ -219,5 +220,5 @@ def convert( return ir return encode(ir, dst, converter_config=encode_cfg) -__all__ = ["solve", "solver", "decode", "encode", "convert"] +__all__ = ["solve", "solver", "decode", "encode", "convert", "PenpaDecodeError"] __version__ = '0.3.2' \ No newline at end of file diff --git a/src/puzzlekit/formats/debug.py b/src/puzzlekit/formats/debug.py index 0e78a5d4..641da5ae 100644 --- a/src/puzzlekit/formats/debug.py +++ b/src/puzzlekit/formats/debug.py @@ -8,7 +8,7 @@ from puzzlekit.formats.base import PuzzleInstance from puzzlekit.formats.penpa_converter import PENPA_PREFIX, PENPA_URLPREFIX, PenpaConverter -from puzzlekit.formats.puzzlink_converter import PuzzlinkConverter +from puzzlekit.formats.puzzlink_converter import PuzzlinkConverter, parse_puzzlink_input def _read_nonempty_lines(path: str) -> List[str]: @@ -86,10 +86,17 @@ def _normalize_penpa_url(url: str) -> str: def _detect_format(url: str) -> str: u = url.strip() - if "puzz.link/p?" in u: - return "puzzlink" if u.startswith(PENPA_PREFIX) or u.startswith(PENPA_URLPREFIX): return "penpa" + if "#" in u: + frag = u.split("#", 1)[1] + if "m=" in frag and "p=" in frag: + return "penpa" + try: + parse_puzzlink_input(u) + return "puzzlink" + except ValueError: + pass raise ValueError("Unknown URL format. Please provide puzz.link or penpa URL.") diff --git a/src/puzzlekit/formats/penpa_converter.py b/src/puzzlekit/formats/penpa_converter.py index 2e7864c5..7fecbe51 100644 --- a/src/puzzlekit/formats/penpa_converter.py +++ b/src/puzzlekit/formats/penpa_converter.py @@ -13,9 +13,12 @@ ) from puzzlekit.formats.puzzle_types import normalize_puzzle_type, get_penpa_genre_tags from typing import Any, Dict, List, Optional, Tuple, Union +import binascii import json import ast import os +import zlib +from urllib.parse import unquote, urlsplit from base64 import b64decode, b64encode from functools import reduce from zlib import compress, decompress @@ -27,6 +30,107 @@ PENPA_URLPREFIX = "https://swaroopg92.github.io/penpa-edit/#" PENPA_PREFIX = "m=edit&p=" + +class PenpaDecodeError(ValueError): + """Raised when a Penpa URL or payload cannot be decoded into a puzzle. + + Subclass of :class:`ValueError` so callers that already handle invalid + input can catch one base type. The original failure is chained via + ``raise ... from e`` when applicable (see ``__cause__``). + """ + + +def _parse_penpa_query(fragment: str) -> List[Tuple[str, str]]: + """Parse a Penpa fragment into key/value pairs while preserving '+'.""" + if not fragment: + return [] + + params: List[Tuple[str, str]] = [] + for token in fragment.split("&"): + if not token: + continue + key, sep, value = token.partition("=") + if not sep: + # tolerate legacy raw payload in query parser + params.append(("p", unquote(key))) + continue + params.append((unquote(key), unquote(value))) + return params + + +def parse_penpa_input(url: str) -> Dict[str, Any]: + """Normalize Penpa input and extract mode/p payload/additional params. + + Supported forms: + - full URL, e.g. https://.../#m=solve&p=... + - fragment, e.g. #m=solve&p=... + - query-like, e.g. m=edit&p=...&foo=bar + - payload only, e.g. + """ + raw = (url or "").strip() + if not raw: + raise ValueError("Penpa input must be a non-empty string") + + parsed = urlsplit(raw) + # Priority: Penpa payload is usually in fragment (#...), fallback to query (?...) + fragment = (parsed.fragment or "").strip() + query = (parsed.query or "").strip() + + if fragment.startswith("?"): + fragment = fragment[1:] + if query.startswith("?"): + query = query[1:] + + candidate = fragment or query + if not candidate: + # non-URL / simplified fragment / payload-only fallback + candidate = raw.split("#", 1)[1] if "#" in raw else raw + candidate = candidate.lstrip("#").strip() + if candidate.startswith("?"): + candidate = candidate[1:] + + params_pairs: List[Tuple[str, str]] = [] + mode = "edit" + payload = "" + + if "=" in candidate or "&" in candidate: + params_pairs = _parse_penpa_query(candidate) + for k, v in params_pairs: + if k == "m": + mode = v + elif k == "p" and not payload: + payload = v + # payload-only strings may contain '=' padding and be misread as key=value + if not payload and all(k not in {"m", "p"} for k, _ in params_pairs): + payload = candidate + params_pairs = [] + else: + payload = candidate + + if not payload: + raise ValueError(f"Cannot parse Penpa payload from input: {url[:120]}") + + raw_params: Dict[str, List[str]] = {} + for k, v in params_pairs: + raw_params.setdefault(k, []).append(v) + + extra_params = { + k: values for k, values in raw_params.items() + if k not in {"m", "p"} + } + + return { + "mode": mode or "edit", + "p_payload": payload, + "raw_params": raw_params, + "extra_params": extra_params, + "fragment": candidate, + "normalized_fragment": "&".join( + [f"m={mode or 'edit'}", f"p={payload}"] + + [f"{k}={v}" for k, vals in extra_params.items() for v in vals] + ), + } + def to_penpa_str(pu_x: Optional[Dict | List], apply_compression : bool = True): assert isinstance(pu_x, list) or isinstance(pu_x, dict), f"pu_x must be dict or list, get {type(pu_x)}" @@ -81,79 +185,114 @@ def _display_parts(self): def decode(self, url: str) -> PuzzleInstance: self.url = url + try: + penpa_input = parse_penpa_input(url) + except ValueError as e: + raise PenpaDecodeError( + "Invalid Penpa input: missing or unreadable payload (expected m=…&p=… or a raw p segment)." + ) from e + self.ir_puzzle = PuzzleInstance( metadata={ "source": "penpa", "original_url": self.url, + "penpa_mode": penpa_input["mode"], + "penpa_params": penpa_input["raw_params"], + "penpa_extra_params": penpa_input["extra_params"], } ) - self.parts = decompress(b64decode(self.url[len(PENPA_PREFIX) :]), -15).decode().split("\n") - header = self.parts[0].split(",") - assert header[0] in ("square", "sudoku", "kakuro"), f"Penpa puzzle must be in square, sudoku, kakuro, get {header[0]}" + try: + self.parts = decompress( + b64decode(penpa_input["p_payload"]), + -15 + ).decode().split("\n") + except binascii.Error as e: + raise PenpaDecodeError( + "Invalid Penpa URL: the 'p' segment is not valid Base64 (truncated, wrong padding, or corrupted)." + ) from e + except zlib.error as e: + raise PenpaDecodeError( + "Invalid Penpa URL: the payload is not zlib-compressed Penpa data (wrong or corrupted bytes)." + ) from e + except UnicodeDecodeError as e: + raise PenpaDecodeError( + "Invalid Penpa payload: decompressed bytes are not valid UTF-8 text." + ) from e - # info collect - self.ir_puzzle.grid_type = "square" - self.ir_puzzle.size = header[3] - self.ir_puzzle.title = header[15][len("Title: "):] - self.ir_puzzle.author = header[16][len("Author: "):] - self.ir_puzzle.source = header[17] - - self.margin = json.loads(self.parts[1]) - self.top_margin, self.bottom_margin, self.left_margin, self.right_margin = self.margin - self.rows = int(header[2]) - self.top_margin - self.bottom_margin # net penpa size, no margin - self.cols = int(header[1]) - self.left_margin - self.right_margin - - - self.real_rows = self.rows + self.top_margin + self.bottom_margin + 4 # penpa size after padding - self.real_cols = self.cols + self.left_margin + self.right_margin + 4 - self.new_rows = self.rows + self.top_margin + self.bottom_margin - self.new_cols = self.cols + self.left_margin + self.right_margin # PuzzleInstance new size, no padding, with margin - - self.ir_puzzle.rows, self.ir_puzzle.cols = self.new_rows, self.new_cols - - env_debug_parts = os.getenv("PUZZLEKIT_DEBUG_PENPA_PARTS", "").strip().lower() in { - "1", "true", "yes", "on", - } - debug_penpa_parts = bool( - self.config.get("debug_penpa_parts", False) - or self.config.get("debug_dump", False) - or env_debug_parts - ) - if debug_penpa_parts and logger.isEnabledFor(logging.DEBUG): - self._display_parts() - for p in range(len(self.parts)): - if p == 1: - # decode margins - margins = json.loads(self.parts[p]) - self.ir_puzzle.margins = margins - elif p == 3: - # decode from pu_q - self.board = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, self.parts[p])) - for k, v in self.board.items(): - if k == "lineE": - self.ir_puzzle.edges = self._decode_edge(edge_dict = v) - elif k == "number": - self._decode_number(number_dict = v) - elif k == "surface": - self._decode_surface(surface_dict = v) - elif k == "symbol": - self._decode_symbol(symbol_dict = v) - else: - pass - elif p == 5: - # decode box - boxes = json.loads(self.parts[p]) - self.ir_puzzle.boxes = boxes - elif p == 17: - genre_tag = ast.literal_eval(self.parts[p]) - raw_type = genre_tag[0] if len(genre_tag) > 0 else "" - self.ir_puzzle.puzzle_type = normalize_puzzle_type(raw_type) - logger.debug("penpa.puzzle_type=%s", self.ir_puzzle.puzzle_type) - # else: - # print(p, self.parts[p]) - - return self.ir_puzzle + try: + header = self.parts[0].split(",") + + assert header[0] in ("square"), f"Penpa puzzle must be in square, get {header[0]}" + + # info collect + self.ir_puzzle.grid_type = "square" + self.ir_puzzle.size = header[3] + self.ir_puzzle.title = header[15][len("Title: "):] + self.ir_puzzle.author = header[16][len("Author: "):] + self.ir_puzzle.source = header[17] + + self.margin = json.loads(self.parts[1]) + self.top_margin, self.bottom_margin, self.left_margin, self.right_margin = self.margin + self.rows = int(header[2]) - self.top_margin - self.bottom_margin # net penpa size, no margin + self.cols = int(header[1]) - self.left_margin - self.right_margin + + + self.real_rows = self.rows + self.top_margin + self.bottom_margin + 4 # penpa size after padding + self.real_cols = self.cols + self.left_margin + self.right_margin + 4 + self.new_rows = self.rows + self.top_margin + self.bottom_margin + self.new_cols = self.cols + self.left_margin + self.right_margin # PuzzleInstance new size, no padding, with margin + + self.ir_puzzle.rows, self.ir_puzzle.cols = self.new_rows, self.new_cols + + env_debug_parts = os.getenv("PUZZLEKIT_DEBUG_PENPA_PARTS", "").strip().lower() in { + "1", "true", "yes", "on", + } + debug_penpa_parts = bool( + self.config.get("debug_penpa_parts", False) + or self.config.get("debug_dump", False) + or env_debug_parts + ) + if debug_penpa_parts and logger.isEnabledFor(logging.DEBUG): + self._display_parts() + for p in range(len(self.parts)): + if p == 1: + # decode margins + margins = json.loads(self.parts[p]) + self.ir_puzzle.margins = margins + elif p == 3: + # decode from pu_q + self.board = json.loads(reduce(lambda s, abbr: s.replace(abbr[1], abbr[0]), COMPRESS_SUB, self.parts[p])) + for k, v in self.board.items(): + if k == "lineE": + self.ir_puzzle.edges = self._decode_edge(edge_dict = v) + elif k == "number": + self._decode_number(number_dict = v) + elif k == "surface": + self._decode_surface(surface_dict = v) + elif k == "symbol": + self._decode_symbol(symbol_dict = v) + else: + pass + elif p == 5: + # decode box + boxes = json.loads(self.parts[p]) + self.ir_puzzle.boxes = boxes + elif p == 17: + genre_tag = ast.literal_eval(self.parts[p]) + raw_type = genre_tag[0] if len(genre_tag) > 0 else "" + self.ir_puzzle.puzzle_type = normalize_puzzle_type(raw_type) + logger.debug("penpa.puzzle_type=%s", self.ir_puzzle.puzzle_type) + # else: + # print(p, self.parts[p]) + + return self.ir_puzzle + except PenpaDecodeError: + raise + except (json.JSONDecodeError, AssertionError, IndexError, KeyError, ValueError, SyntaxError, TypeError) as e: + raise PenpaDecodeError( + "Invalid or corrupt Penpa puzzle file: the decompressed text does not match the expected Penpa+ format." + ) from e def _decode_symbol(self, symbol_dict: Dict[str, List[Any]]): for index, symbol_data in symbol_dict.items(): @@ -320,22 +459,20 @@ def encode(self, inst: PuzzleInstance) -> str: plain_text = "\n".join(text_lines) compressed = compress(plain_text.encode())[2:-4] - return PENPA_PREFIX + b64encode(compressed).decode('ascii') + return PENPA_URLPREFIX + PENPA_PREFIX + b64encode(compressed).decode('ascii') # return PENPA_URLPREFIX + PENPA_PREFIX + b64encode(compressed).decode('ascii') if __name__ == "__main__": for test_url in [ - # "#m=edit&p=1Vddb9pIFH3Pr6j8Wi94xuMvpGpFkqZS1bLNNt1sYyFkwAluDE4NNJGj9rf33JlrsB3arbTahxUwc+bM9f2auZ5h/XmblKktHPq6oY0eHyVC/ZOhr38Ofy6yTZ4OntnD7WZRlACbzd160O/fbaur6qqXZ6vb/t3vi3Sa9YVD35XwnJ7wEiUTJaaudHozp5c4Pac3dwV1U6mc3kq6QDRUck4dJCAnBbhECi0PKdv+4+zMvk7ydWq//vjp+PR2eP9y+Hffu3LdD6Pr559Ozz98ml/+Jc6drF86ozxcvX13epw/f1VdvV0Mv6QvU//dupgt8jSZJ9XV5euHfHUW3iyuxcnrxUl4nayc9efwIvpyfP7ixVHMUY+PYktYtiXxE9b4WzX6pgk5Pnqs/hw8VpNBPP5qVx/2MNzD94NHS4XWQNmWinTnOaaTpjNzvjCdIX1fd4EZBa7pjGRgtIRGSxiYzswJxwyFw2PhcW8UCsHz0mgW0qgW0igVLo9dxT3LK+YV61OsT9E8ohxxlLHlTRzLDihbYxNxbO3HFHpHhNIQW3KC7O4orUg0KUoPSSHtO0o/2BjDpeaYktcaI4TWmK00VFJqmyKU49YY0caWmrgNSmvZj/UKxJbblNGr0RKiZWkTHef1QnXSohetJUSr1yY6Aej1bBNYWIq66R4tckuIVrsrRCvfFuq6THthT2BTiMEj2o+6PdOt1O0FKsOuXN2e6tbRrafbN1rmJTaUlJEtyRcJjYRdrAdh1wHGjiCsFGTgm+YlMNzSWEAGCdMyXoN3geGpxvQs1qbmKTkaw5bHdhVseQ1esQ8KtjzWSbj208Mb02P95BttZs0He6yAaUdrjLcsbWUtAx98ftYnmfpZ+O9jaTUmnRyvB39oW9fYZ98C6AnYZx/+17YCPBvwsz58Dtj/APpr7NNbn/0JIROy/gB6wlon2WVbIWzVfBjarlM/S3qMz+DAs54Ix4xTy0NPxHoi38aJsMMy4jxEyENk8uAKARnWH0XAtU7YitiWgC3mYRPY6EEPzHocD7j2TcEfkxPYt11pfNO2ZM1DRtY+AFP5agyfeU+CA29y6GKv7nnkhPchbAKzb9irOyyh02X9Enpc9g3n8Q5L2OK9p7HkuLA/XWXWF9zeTwX/Fdslf3h/gmOMIrvUpXaiW6VbX5dgQEfX0VGMOqPbQPvj/f84OsHfb8vrZJbiDB9tl9O0fDYqymWSWzjErXWRT9ZmfpI+JLONNTC3jOZMi1tpHS0qL4o7XIAOaainWmR2syrK9OAUken85keqaOqAqmlRzjs+3Sd53o5FX/da1CwrZ3mb2pRZa5yUZXHfYpbJZtEipskGV8P1Irtra0pXnWRukraLyW3Ssbbcp+PrkfVg6V+M1wwt5GMVDaqhXb2iA2p/KbOrc1y53g6sWbGcZhZdu+goEjiXltt8k82KvIBd5nDG8A2ODpodvNTzhE4MKRzgEWPAj4AmXZM3hnk3iKsL2yIHjvXTBK1l8QURGAdpbJwC0ciS7WJivZ0Xt1sWFXRmDnUY1egXI4CSOgKCJgJCByKgwP7bCKLxV7Nizi/ejM3N8d9fFf7xXfbAVV6UPyn0/WSXPlDuYH9S8Y3ZQ/wPirsx2+WfVDI5+7SYwR6oZ7Ddkgb1tKpBPilscD+obdLaLW/yqlvhZOpJkZOpZp3HFv1r/C2bLbKbAi9lTX8H" - # "#m=edit&p=1Vddb9pIFH3Pr6j8Wi94xuMvpGpFkqZS1bLNNt1sYyFkwAluDE4NNJGj9rf33JlrsB3arbTahxUwc+bM9f2auZ5h/XmblKktHPq6oY0eHyVC/ZOhr38Ofy6yTZ4OntnD7WZRlACbzd160O/fbaur6qqXZ6vb/t3vi3Sa9YVD35XwnJ7wEiUTJaaudHozp5c4Pac3dwV1U6mc3kq6QDRUck4dJCAnBbhECi0PKdv+4+zMvk7ydWq//vjp+PR2eP9y+Hffu3LdD6Pr559Ozz98ml/+Jc6drF86ozxcvX13epw/f1VdvV0Mv6QvU//dupgt8jSZJ9XV5euHfHUW3iyuxcnrxUl4nayc9efwIvpyfP7ixVHMUY+PYktYtiXxE9b4WzX6pgk5Pnqs/hw8VpNBPP5qVx/2MNzD94NHS4XWQNmWinTnOaaTpjNzvjCdIX1fd4EZBa7pjGRgtIRGSxiYzswJxwyFw2PhcW8UCsHz0mgW0qgW0igVLo9dxT3LK+YV61OsT9E8ohxxlLHlTRzLDihbYxNxbO3HFHpHhNIQW3KC7O4orUg0KUoPSSHtO0o/2BjDpeaYktcaI4TWmK00VFJqmyKU49YY0caWmrgNSmvZj/UKxJbblNGr0RKiZWkTHef1QnXSohetJUSr1yY6Aej1bBNYWIq66R4tckuIVrsrRCvfFuq6THthT2BTiMEj2o+6PdOt1O0FKsOuXN2e6tbRrafbN1rmJTaUlJEtyRcJjYRdrAdh1wHGjiCsFGTgm+YlMNzSWEAGCdMyXoN3geGpxvQs1qbmKTkaw5bHdhVseQ1esQ8KtjzWSbj208Mb02P95BttZs0He6yAaUdrjLcsbWUtAx98ftYnmfpZ+O9jaTUmnRyvB39oW9fYZ98C6AnYZx/+17YCPBvwsz58Dtj/APpr7NNbn/0JIROy/gB6wlon2WVbIWzVfBjarlM/S3qMz+DAs54Ix4xTy0NPxHoi38aJsMMy4jxEyENk8uAKARnWH0XAtU7YitiWgC3mYRPY6EEPzHocD7j2TcEfkxPYt11pfNO2ZM1DRtY+AFP5agyfeU+CA29y6GKv7nnkhPchbAKzb9irOyyh02X9Enpc9g3n8Q5L2OK9p7HkuLA/XWXWF9zeTwX/Fdslf3h/gmOMIrvUpXaiW6VbX5dgQEfX0VGMOqPbQPvj/f84OsHfb8vrZJbiDB9tl9O0fDYqymWSWzjErXWRT9ZmfpI+JLONNTC3jOZMi1tpHS0qL4o7XIAOaainWmR2syrK9OAUken85keqaOqAqmlRzjs+3Sd53o5FX/da1CwrZ3mb2pRZa5yUZXHfYpbJZtEipskGV8P1Irtra0pXnWRukraLyW3Ssbbcp+PrkfVg6V+M1wwt5GMVDaqhXb2iA2p/KbOrc1y53g6sWbGcZhZdu+goEjiXltt8k82KvIBd5nDG8A2ODpodvNTzhE4MKRzgEWPAj4AmXZM3hnk3iKsL2yIHjvXTBK1l8QURGAdpbJwC0ciS7WJivZ0Xt1sWFXRmDnUY1egXI4CSOgKCJgJCByKgwP7bCKLxV7Nizi/ejM3N8d9fFf7xXfbAVV6UPyn0/WSXPlDuYH9S8Y3ZQ/wPirsx2+WfVDI5+7SYwR6oZ7Ddkgb1tKpBPilscD+obdLaLW/yqlvhZOpJkZOpZp3HFv1r/C2bLbKbAi9lTX8H" - "#m=edit&p=tVZtb6JMFP3ur9jM105W3rUkzcbXJk3r1tWuW4kxiFioCJaX1mDa3957h0EFbZ90N0+QyeXM4c49M5wbo6fEDG2qwSXXqUBFuCRNY7eoKOwW+DV0Y8/Wv9FGEjtBCIETx+tIr1bXSTpOx989119W1z+iOICfb1c1uBTHSuarhRraiqssZUp/drt0YXqRTa/uH5vtZeOl0/hTVceyfNdbnD22+3eP89FvsS+41VDoeXX/5rbd9M4u0/GN03i2O7Z2GwWW49nm3EzHo6uN53frD85CbF05rfrC9IXoqT48f272Ly4qBi99UjGISCiR4BbJ5C0dvBmEUHFS2aa/9G061Y3JK03v9mF9Hw70LYw9fUskiegG7AlLQomqwaPMH4EiMuI9G7tslNg4hDw0ldnYZqPARpWN14zTgfSiCLklhegSZJQkKsqwHovhLGRYDGMZFlRkHrPzyWIF+CrnK8BROQfPUM05KsRqFqvA0ThHBY7GOSqspfG1NMhZ4zk14NQ4R4M8NZ4H65R4Hgly7upHLTkH+FJeP+o6qF/mHBk4O42ot7bXletFXTu9wFc4X8FvlfNV+ILzfVBR74GWXC/WzzTCxo/Y9rfYqLBRY8dSw8OvVAzUt7vgvb+N8RMcJOHCtGwCnx2JAm8aZc9Te2NaMdEzWxzOFDA/Wc3ssAB5QbAG153KkE8VQPfBD0L75BSC9vzho1Q4dSLVLAjnpZpeTM8ramEtpgBZbmh5RSgO3cKzGYbBSwFZmbFTAGZmDA0pctx1MZPtlzYzNoslmkuztNpqvx2vFbIh7AZvw+FjjzjX0wZNL/VCF6FpH5rEjZ4OsEdk/YSSVeLFrhV4ASzJMbA4e1GCsLMPR2weo1YGigLEPR5DeA9htlPT6wy51Y10SAmu3WRvY0hWwTMUn9WGz1awmoE8gxxsEJVhIkrmwTLhVBFbVuNrCiBJrgDDTAFGJxSgsP9XwfnkNTss4Utt/N879X+2jQ03eBB+4vH9ZBk+4XRAPzH7wewp/ANfH8yW8SMTY7HHPgb0hJUBLbsZoGNDA3jkacA+sDVmLTsbqyqbG5c68jcudWhxg+T/UqAZM+wd" + "https://swaroopg92.github.io/penpa-edit/#m=solve&p=tVZtb6JMcbXJk3r1tWuTyXGIGKhIlheWoNpf3vvHQYVtN302WyQyeHO4c49M5wbo6fEDG2qwSXXqUBFuCRNY7eoKOwW+DV0Y8/Wv9FGEjtBCMCJ43WkV6vrJB2n4++e6y+r6x9RHMDPt6saXIpjJfPVQg1txVWWMqU/u126ML3Iplf3j832svHSafxXVceyfNdbnD22+3eP89FvsS+41VDoeXX/5rbd9M4u0/GN03i2O7Z2GwWW49nm3EzHo6uN53frD85CbF05rfrC9IXoqT48f272Ly4qBi99UjGISCiR4BbJ5C0dvBmEUHFS2aa/9G061Y3JK03v9rC+hwN9C2NP3xJJIroBe8KSUKJq8CjzR6CIjHjPxi4bJTYOIQ9NZTa22SiwUWXjNeN0IL0oQm5JIboEGSWJijKsxzCchQyLIZZhQUXmmJ1PhhXgq5yvAEflHDxDNeeogNUMq8DROEcFjsY5Kqyl8bU0yFnjOTXg1DhHgzw1ngfrlHgeCXLu6kctOQf4Ul4/6jqoX+YcGTg7jai3tteV60VdO73AVzhfwW+V81X4gvN9UFHvgZZcL9bPNMLGj9j2t9iosFFjx1LDw69UDNS3u+C9/4vxExwk4cK0bAKfHYkCbxplz1N7Y1ox0TNbHM4QPQ4THvKT1cwOCywvCNZgulMJ8qlC0H3wg9A+OYVBe/7wUSqcOpFqFoTzUk0vpucVpbAOUwhZbmh5xVAcuoVnMwyDl0JkZcZOITAzY+hHkeOui5lsv7SXsVks0VyapdVW++14rZANYTdYG84eW8S5njZoeqkXmghN+9AjbvR0gC0iayeUrBIvdq3AC2BJHgOHsxclgJ09HLF5RK0sKAqAexwDvAeY7dT0Oovc6kY6pATXbrK3EZJV8AzFZ7XhsxWsZiDPIAcbRGWYiJJ5sEw4VcSO1fiaAkiSK0CYKUB0QgEK+7cKziev2WEJX+rif9+o/9g1NtzfQfiJxfeT5fAJp0P0E7MfzJ6Kf+Drg9ly/MjEWOyxjyF6wsoQLbsZQseGhuCRpyH2ga0xa9nZWFXZ3LjUkb9xqUOLGyT/k0ImlXc=&a=VcJBCQAAAIPAQmsk9q/he3DgngE=" ]: hpc = PenpaConverter() tmp = hpc.decode(test_url) # print(tmp.cells) enc = hpc.encode(tmp) - # print(tmp) - # print(enc) + print(tmp) + print(enc) b = hpc.decode(enc) - logger.info(enc) + # logger.info(enc) \ No newline at end of file diff --git a/src/puzzlekit/formats/puzzlink_converter.py b/src/puzzlekit/formats/puzzlink_converter.py index b589d80c..9a2707f7 100644 --- a/src/puzzlekit/formats/puzzlink_converter.py +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -1,4 +1,7 @@ +import re from typing import Dict, Any, List, Optional, Union, Set +from urllib.parse import unquote, urlsplit + from puzzlekit.formats.base import ( PuzzleInstance, CellState, EdgeState, NumberColor, SurfaceColor, SymbolState, NumberState ) @@ -17,6 +20,76 @@ logger = logging.getLogger(__name__) +# puzz.link path: /// or /b///, etc. +_PUZZLINK_FIRST_SEG_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]*$") + + +def looks_like_puzzlink_path(puzzle_path: str) -> bool: + """Heuristic: first segment is a genre token; avoid penpa/m=edit&p= style strings.""" + s = (puzzle_path or "").strip().lstrip("/") + if "/" not in s: + return False + parts = [p for p in s.split("/") if p != ""] + if len(parts) < 4: + return False + first = parts[0] + if "=" in first or "&" in first: + return False + return bool(_PUZZLINK_FIRST_SEG_RE.match(first)) + + +def parse_puzzlink_input(url: str) -> Dict[str, Any]: + """Extract the puzz.link payload path from many URL shapes or a bare path. + + Accepts: + - ``https://puzz.link/p?hebi/10/10/...`` + - ``https://pzplus.tck.mn/p.html?hebi/10/10/...`` + - ``http://pzv.jp/p?hebi/10/10/...`` + - ``?hebi/10/10/...`` (paste without scheme) + - ``hebi/10/10/...`` (bare path) + + Extra query pairs (e.g. ``&a=...``) are ignored; only the first ``&``-separated + chunk of the query is used as the puzzle path when it looks like puzz.link. + """ + raw = (url or "").strip() + if not raw: + raise ValueError("puzz.link input must be a non-empty string") + + # Paste-only: "?genre/..." without a scheme + if raw.startswith("?") and looks_like_puzzlink_path(raw[1:]): + puzzle_path = raw[1:].split("#", 1)[0].split("&", 1)[0].strip() + return {"puzzle_path": puzzle_path} + + sp = urlsplit(raw) + puzzle_path = "" + + if sp.query: + candidate = unquote(sp.query.split("&", 1)[0]).lstrip("/") + if looks_like_puzzlink_path(candidate): + puzzle_path = candidate + + if not puzzle_path and sp.fragment: + candidate = unquote(sp.fragment).lstrip("/").split("?", 1)[0].split("&", 1)[0] + if looks_like_puzzlink_path(candidate): + puzzle_path = candidate + + if not puzzle_path and sp.path: + path = unquote(sp.path).lstrip("/") + if path.startswith("?"): + candidate = path[1:].split("#", 1)[0].split("&", 1)[0] + else: + candidate = path.split("#", 1)[0].split("&", 1)[0] + if looks_like_puzzlink_path(candidate): + puzzle_path = candidate + + if not puzzle_path: + raise ValueError( + f"Cannot extract puzz.link puzzle path from input: {raw[:160]!r}" + ) + + return {"puzzle_path": puzzle_path} + + # Yajilin, Masyu, Slitherlink, heyawake, shikaku, norinori, hitori class PuzzlinkConverter: def __init__(self, config: Dict[Any, Any] = dict()): @@ -38,7 +111,7 @@ def _debug_dump_enabled(self, key: str) -> bool: return bool(self.config.get(key, False)) def _decode_nonogram_variant(self): - self.body = self.url.split("/")[-1] + self.body = self._puzzle_path.split("/")[-1] number_map = self._decode_number16() # print(number_map) max_cols_offset = math.ceil(self.num_cols / 2) @@ -322,10 +395,13 @@ def _reindex_border_list(self, r: int, c: int, margins: List[int], border_list: def decode(self, url: str) -> Dict[str, Any]: self.url: str = url + parsed = parse_puzzlink_input(url) + self._puzzle_path: str = parsed["puzzle_path"] self.ir_puzzle = PuzzleInstance( metadata={ "source": "puzz.link", "original_url": self.url, + "puzzlink_puzzle_path": self._puzzle_path, } ) @@ -837,8 +913,7 @@ def _region_grid_to_borders(self, edges_dict: Dict[Any, List[EdgeState]]) -> Dic def _parse_header(self): """Parse the header of the puzzle, such as: slither/10/10/body_str""" - parts = self.url.split("?") - urldata = parts[1].split("/") + urldata = self._puzzle_path.split("/") if len(urldata) > 1 and urldata[1] == 'v:': urldata.pop(1) @@ -1612,25 +1687,4 @@ def _bfs_flood_fill(self, start_r, start_c, region_id, region_grid, border_list, penpa_url = penpa.encode(p_ir) logger.info(penpa_url) - - # penpa_cvter = PenpaConverter() - # penpa_str = penpa_cvter.encode(p_ir) - # print(penpa_str) - - # penpa_ir = PzpCvtr.decode(url_new) - # print(penpa_ir.cells) - # assert url_new == url - # from puzzlekit.formats.penpa_converter import PenpaConverter - # penpa_url = PenpaConverter("") - # penpa_test = penpa_url.encode(res) - -# "key": "1,1", -# "left": {"shaded": true, "number": {"value": "1_2", "number_color": 7, "number_style": "2"}, "surf_color": 4, "symbol": null}, -# "right": {"shaded": false, "number": {"value": "1_2", "number_color": 7, "number_style": "2"}, "surf_color": 4, "symbol": null}}, - -# {"key": "2,4", -# "left": {"shaded": true, "number": {"value": "_", "number_color": 1, "number_style": "2"}, "surf_color": 4, "symbol": null}, -# "right": {"shaded": false, "number": {"value": "", "number_color": 7, "number_style": "2"}, "surf_color": 4, "symbol": null}}, -# {"key": "3,1", -# "left": {"shaded": false, "number": {"value": "_", "number_color": 1, "number_style": "2"}, "surf_color": 3, "symbol": null}, -# "right": {"shaded": false, "number": {"value": "", "number_color": 1, "number_style": "2"}, "surf_color": 3, "symbol": null}}] \ No newline at end of file + \ No newline at end of file diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py index 1d1bce9f..2518380e 100644 --- a/tests/formats/conftest.py +++ b/tests/formats/conftest.py @@ -78,6 +78,12 @@ def penpa_test_urls(): # hebi (snakes) "hebi_1": "m=edit&p=7VhtT+M4F/3Orxj56+RpY8d5lUar8jYSGrqwwLIQVVXaBtohbZi0HVAQ/33OtR2ahDI70uqRWGnVxj4+vrn3XNtx3C6/rZMitbhNXyewUOMjeaAuEXjqss3nfLbK0uiD1VuvpnkBMF2t7pdRt3u/Lq/L6042W9x173+bpqNZl9v0XXDX7nA3kSKRfOQIuzO2O4ndsTsTh1M1EtLuLIQDRE0pJlTBAnaCg0sEV/awsqzfDw+tmyRbptbR1dfd/bvew0Hvr6577TgX/ZuPX/dPL75OLv/kp/asW9j9LFgcn+zvZh8/l9fH09739CD1Tpb5eJqlySQpry+PHrPFYXA7veF7R9O94CZZ2MtvwXn4fff006ed2KQ92Hkqw6jsWeXnKGacWUzg4mxglafRU3kcsXE+H82YVZ6hn1l8YLH5OlvNxnmWF6ziyi/6bgF4sIGXqp/Qnia5Ddw3GPAKcDwrxlk6/KKZkyguzy1GAnbV3QTZPP+eUjASSG0tCsQoWWHaltPZPbMcdCzXk/xubUz54NkqeyqNsv+LGcBJlQFBnQGhLRlQYv/fDMLB8zNm6A/kMIxiSudiA4MNPIuemAxYJC0mQ1W5tq6ErnSfx3WlSc9Tla9bvqMrbelrL4H2Evi60n3c1k1umzZ3Ta0dcm76hfbMhXbNhXbKHdN2pKmNvTS8NP6k8SepH1n2TZYxc4c2s3xarBgxypjGuWpT6i0TGoaYiSEW9wulHPE6RcNDVhj4F0rdWGtDUr1Ng9doI4VG20SpuaShrZvQGDfayDZmcujUKOVl01YzEDOnbqNmo2FE09IkWuLVRLWGRU1aw4hmr0m0ElDz2SQwsZR1XR5NcsOIZrttRDPfNGpLprWwIbAoePSE8kqVh6oUqjzHk2GVjir3VWmr0lXlF2VzgAUlRGgJ0iLgkbCD+SDs2MBYEYSlhA20KV4AQ5bCHDYYMGXj1ngHGEoVpnsxNxVPg6MwYrkmrkQst8ZLo0Eilmt8Eq50unh3ucY/aaPFrHh/gyUwrWiF8b6jpaxsoMEz93pkU90L/R6mVmHyafJ1oYeWdYU9o82HH99o9qC/iuXjXt/c60Gzb/T78F9hj96/Rk8Am8D49+EnqHxSXBMrQKyKDwLLsat7yY/WDA688RPihW9X9vATGj+hZ+HV/IJFaMYhxDiEehwczmFj/IchcOUTsUITiyOW4RETWPtBDWz82C5wpU1Cjx4TxLccobWpWKLiYSMqDcD0+CoMzWZNggOvx9DBWt3wGBOzDhET2GjDWn3BAj4d41/Aj2O04WT0ggVimbWnsDB5YX06Us8vuI1OCf3SxCU9Zn2CMxgP2aV61PZUKVXpqUfQp1fXL77c9Ob/z5/2v5UT46mnU2Lz4/77uMFOzM7WxU0yTnHi6K/no7T40M+LeZIxHPrYMs+GS90/TB+T8YpF+vBZ72lwC+WjQWV5fo9z8TYPVVeDnN0u8iLd2kVkOrl9yxV1bXE1yotJS9NDkmXNXNTPgAalD2wNalXgNFZrJ0WRPzSYebKaNojaya3hKV20BnOVNCUmd0kr2nwzHM877JGpK8amRxP53wn9vZ/Qabbs97aVvTc5aqHnxU92nU1nm96y94D9yfZT693Gv7HT1Hrb/KtthcS+3lnAbtlcwLb3F1CvtxiQr3YZcG9sNOS1vdeQqvZ2Q6Fe7TgUqr7pxIz+2fjfbDyd3eZssPMD", "hebi_2": "m=edit&p=7Zdbb+JGFMff+RSred1pmbHHV2m1IreVooQmTdI0WAgZMLGDwawvSeQo333PGY+LbUi6UlU1DxXy+M9vLuccz/C3yL4XfhpQi3JGdZsyyuGjM0EFt6luOfJi6nMd5XHgfqKDIg+TFESY55vM7fc3RTkqR7/G0XrZ33wNg2nUt/qc9SOuLzVDiDk3cm7MuZgLMRV6SOlvJyd04cdZQE/vHg6OloOn48GffWOk6zfDxeeHo8ubh/ntH/ySRf2UDWN7fX5xdBB//laOzsPBY3AcmBdZMgvjwJ/75ej29Dlen9j34YIfnoaH9sJfs+y7fe08Hlx++dLzVAHj3kvpuOWAlt9cj3BCiQYXJ2NaXrov5blLZslqGhFaXkE/oXxMyaqI82iWxElKalaeVbM1kMdbeSv7UR1WkDPQQ6VB3oGcReksDiZnFblwvfKaEkzgQM5GSVbJY4DBMEH8XiUFYOrnsAFZGG0I1aEjK+bJslBD+fiVlgNZRjn8yQpgkboClFUFqPZUgIX9uxU449dX2KHfoYaJ62E5N1tpb+WV+0J0i7iCEkPdbHkzdXlzTHnjzKjuXKg7Dob5QzXfI/qEETj7cAwgF1zLI8ZEbyBY1yNiAsekRhgDRzUmYrwOkrFxZpNhHp3VZE6Yx18M8uPuC7R3sj2RrSbbayiflrpsj2TLZGvI9kyOOYbaOPxkucOIq0EAG37SDq80cltxp8Edh2q85qBZpYEBr8ZojIPWlAbOFOdNboCGhyG1CRqKqzlTnDc4GI4mHKUhrqHiItcVFw0uLNCwJVLDGKMeA1wobjS4AbFMFcuAMWY9BjjuNmqzySGWpWKZUKOlakRuKm7VHB72rXzkh7IVsjXlVlh4Tn/yJFfn8Z/v+t+m42lCmnv9Mf7Lb+OeR66KdOHPArCIYbGaBumnYZKu/JiAS5MsiSdZ1T8Jnv1ZTtzqbdHsabG1XKOF4iTZwCtp3wp1VwtG9+skDfZ2IQzm928thV17lpom6byT05Mfx+1a5Au4hSqHbaE8BftsfPfTNHlqkZWfhy3QsNrWSsG68zBzv52iv/Q70Vbbx/HaI89EXp5ONdzI/1+pH/2VirvFPpodfbR05EFP0ndcZ9vZxXu8B+g79tPo3cffcJpGb5fv2Aomu+ssQPeYC9CuvwDatRiAOy4D7A2jwVW7XoNZde0GQ+04DoZqmo5H8E/FL9EsjO4TMu79AA==", + + # test url parse, with `&a` param and different url prefix + "url_parse_1": "https://swaroopg92.github.io/penpa-edit/#m=solve&p=tVbrT/pIFP3OX7GZr05+9A02MRueJkZZWXBZaQgpZbCV0mIfSkr0b/fe6RQooBt3symdnJ453LlnpvdC/JLaEaMGXGqdSlSGSzEMfsuaxm9JXEMv8Zn5G22kiRtGANwkWcdmtbpOs3E2/uV7wbK6/j1OQvgErGrApblOOl8t9IhpnrZUKf2j26VJlDJ68/jcbC8bb53G31V9rKoPvcXFc7v/8Dwf/SX3Ja8aST2/Htzdt5v+xXU2vnMbr6zDjPs4dFyf2XM7G49uNn7QrT+5C7l147bqCzuQ4pf68PK12b+6qlgi80nFIjKhRIFbJpOPbPBhEULlSWWb/Wlus6lpTd5p9rCH9T0cmFsYe+aWKAoxLdgSHoQS3YBHVTyCRObCRz52+ajwcQhxaKbysc1HiY86H2+5pgPhZRliKxoxFYioKFRWYT2O4ShUWAyxCgtqqsD8eHKsgV4Xeg00utDgEeqFRges51gHjSE0OmgModFhLUOsZUDMmohpgKYmNAbEqYk4mKci4igQc5c/eik0oFeK/NHXQf6q0Kig2XlEv7W9r8Iv+tr5Bb0m9Bq+qkKvwwtc7IOOfg+8FH4xf+4RNn7Et7/FR42PBj+WGh5+pWKhv90F3/u3GF/BQRotbIcReO1IHPrTOH+eso3tJMRc2H7M6OFMiQvS1YxFJcoPwzUU3bkIxVSJ9J6CMGJnp5Bk86evQuHUmVCzMJof5fRm+37ZC+8wJcrxIscvU0nklZ7tKArfSszKTtwSMbMT6Eex663LkVhwtJmJXU7RXtpHq6322/FeIRvCb6htOHzsEZdm1qDZtVnqIjTrQ5O4M7MB9oi8n1CySv3Ec0I/hCUFByXOv6gA7OzhiM8jauWkLAHuCQzwEWC+U9PbnLk3rWxICa7d5N9GSFbhKySf54bPTriagT2LHGwQVWEiTufhMhVSGVtW42cOIEjhAGHuANEZB2js/3VwOXnPD0v6URv/7536H9vGRhR4GH1T4/tJYvIfxII9U+jAflPrB7Pn+C/K+mD2mD+pYcz1tIyBPVPJwB4XM1Cn9QzkSUkD90VVY9Tjwsasjmsblzopb1zqsMItUvxHIZPKJw==&a=FYqxEQAxCMN2cf0VmHiYHPuvkVejw0j3qkqfqsGAA/KjEc1lLmONHeJhHgOS8AszJIl2Hw==", + "url_parse_2": "https://t0nyx1ang.github.io/noqx/penpa-edit/?m=edit&p=7VVvb/o2EH7Pp5j8tpYWJyENkfaCUuh+HaW0BbGCEAo0QNoEd/lDuyC+e+8cUOyQVtomTZ00hRx3z2Of74x5HP+RupFHmYYfw6bwDY/JbPHqtiVe7fAM/CTwnJ9oM03WPAKH0ttOhy7dIPbo9eO62+LNt8vm71s7GY/ZlZb+0EbPneez+/C3H74RsU7P7t/0b3x91fy1dXFntc+sfhoPE297F7KL5+F4sOyPVg39z3ZvbGbjW61+PV7+vG0Of6lNDjVMa7us4WRNml05E2IQShi8OpnS7M7ZZTcOWfBw7hOaPQBPKJtSEqZB4i94wCNyxLIueDBTB7dduCPBo9fKQaaB3zv44D6Cu/CjReDNujnSdybZgBIs4ELMRpeEfOvhYlgcxnlRAMzdBPYwXvuvhBpAxOkTf0kPQ9l0T7Pm32gDMh3bQDdvA72KNrC7f9yG97Ty3is6aEz3e/iF7qGHmTPBdoaFaxfug7MD23N2xNBgKh46mA7ZDANCowjrKnsOIZ7RPDRV1kS2mGs2lLCOmYvBdRtCvQhxcBFaTBlsqQtZuFAx+FxXqrJxbrGurZbRUNmGqcxlmkozDXk5tpRKGCvxOnZZlMZ0bFMaL7ZbGm/gfGm8gfnlWG2VmdirHJfqMXE9KX+9tJ7Ydim2SryF+aV8YuePPBwZJg7Oo7AdYXVhB3CuaGYIeymsJmxd2K4Y0xZ2JGxLWFNYS4w5x5P5l87uv1DOxMgVWX3q/z1sWpuQhzRaugsPtKTFw1ce+4lHQM9JzINZnHMz791dJMTJrxSZUbBNGs49kEEJCjh/DfxNVYYjpYD+asMjr5JCEAXuk1RIVaSa8+ipVNObGwRqL+K6VaBchhUoiUBjpdiNIv6mIKGbrBVAulaUTN6mtJmJq5bovril1cJiO/Y18k7EC39B+NP/f/l+/8sXfy3tu8nYdytHHHQefaE6BVmGK7QH0C/kR2Kr8E+URmLL+ImsYLGnygJohbgAWtYXgE4lBsATlQHsE6HBrGWtwarKcoNLnSgOLiWLzoTEgZ+svQi27IVMax8=", + "url_parse_3": "https://swaroopg92.github.io/penpa-edit/#m=edit&p=7ZdRb+I4EIDf+RUnv651xOMAIdLqRGm7UtX22mt73YIQMhCa0EBoEmg3Vf/72pOw2Ela6e6kUx+KyWjyjT2escM4JI8bEXuUddSXO9SiTLa2Y+MFYOG1a9dBGnrub7S3Sf0oloqfpmu32Vxng+3vi3Vz/ccPsQjCYNVkHfV9tFkATABXLQT2g1sMP1ubTWyWDQRYNovxZmOzXVsA8zkIDhMO2YCBDQmwCbAHDvc2qPuiIywo/fP4mM5FmHj05G5xcPjQezrqfW+2BpzfnM+/LA4vbxaz27/ZpRU0Y+s8dFZnF4cH4Zdv2eDM7229I699kURTP/TETGSD25PncHXs3Ptz1j/x+85crKzk0bnubg8uv35tDIuVGDVesq6b9Wj2zR0SRigBeTEyotml+5KduWQaLScBodmVtBPKRpQsN2EaTKMwismOZaf5aJDq0V69RbvS+jlkltTPC12qd1KdBvE09ManOblwh9k1JSqAAxytVLKMtp6aTAWo7vOgJJiIVO5k4gdrQrk0JJtZ9LApurLRK816/yIN6WmXhlLzNJRWk4bK7j+nIZ8077kmg+7o9VXu0F8yh7E7VOnc7FVnr165L1Keuy+k3cKhY7mNTG2m9NgpENdQRyFuIKeKuhXUbVd8MW4pZo3VwvxiLO9n6QxqGK+yNvozUmDtop/OuhiepccCVnUsQHUsQJGHzlp1DBeqxHClSgxjMVlNHlCzF+DYioG+fuBgLCWGcxiMQzFWWz8OOAfo83K7iE+bl7eK/dBZG3Mz53AwD9Ofg/trzuugv1I/XHvb7IcxlxjGXGLFGvxi8hFn+KDfoTxGCSiv5e+AZhzlIUoLZQvlKfY5QnmLso/SRtnGPh31S/pHv7X/IZyhbeM59l5rffb47PF2GzWG5GoTz8XUk6dOP1quoyRIPSJPfpJE4TjJbWPvWUxT4uZvILrFYKvNcuLJA1NDYRSt1flV42FnMmBwv4pir9akoDe7f8uVMtW4mkTxrBTTkwhDMxd8PTRQfmAbKI3laazdiziOngyyFKlvAO0FxPDkrUqLmQozRPEgSrMt98vx2iDPBK8hp6A28fM17aO/pqndsj7aAfLRwsEHPYrfqTp7YxnX1B5J3yk/mrWOv1FpNGuZV8qKCrZaWSStKS6SluuLRNUSI2Glykj2RqFRXsu1RkVVLjdqqkrFUVPpRWdIir+9ZNT4CQ==", + "url_parse_4": "https://swaroopg92.github.io/penpa-edit/#m=solve&p=7Vdbb9s4E33Pr1jotcRaJEXdgGLh3AoUbbbZpJuvMYJAcZRYsWK5viWroP3tPUOOIstxuyiKfftsWDwccg7J4RmKnn9eZrNcyFhIX+hY+ELiGyklAtgC49tf8z0tFmWe/ib6y8WomgGMFotp2utN6/PV73fT3vSPUf5P9pCN856Me9LvqcLE41FmgsQkKsmy26wYq3EZlAaGLA4/Fz4+srpZVSj1ajn0/TDXKz8a3fpyuoI50CoojC6UUiOtVCHVrSyF+PPwUNxk5TwXbz/d7e6P+w8H/f/1zLnWH49uXt3tH3+8uz77Wx77RW/mH5Xx5P2H/d3y1Zv6/P2ov8oP8vDDvBqOyjy7zurzs7eP5eQwvh3dyL23o734Jpv488/xabLaPX79emfAAbjYGXjSE57CT3oXX+uTrwPPE/Ji56n+K32qL9PBxRdRf2xh3MKT9AnPo/TJC0IvHQSIM5EIz/io6raqUFVt1XQ6J+RrnqvS1x1nKam99ZYBta/XiW6tHnYHlzHV19rjoFNXfoI6aYTrUnbbNfE37VivtKv+ZJ+H9qns8xRBEbW2z3379O3T2Oc72+cAsdLaCG0iL1UCOALGBAgH0GuIUFkcAGPhFofAMeNY6AgLImzgG7GvSaB1TJxwqICxSIvhG7NvCN+EfSOMlfBYUSICn31jCYwAWxwA8xxipI5k3wR9JPdJ4KucLziAnR0cwAicxSGwmwM4BOTPGH0ouIQVfGljLYadNtViA+xiBQ5KXYc1pTHzaKS2cesFhwhC9g3gG7JvEAG7WNkjIGJfA9+IfQ3WG7n1gkMEMfuG8I3ZN8R6E15vBN+EfSP4kpAtxnoTXi9iazi24ANmnhh2EhphxNNwPMEHzDxJCMw8SSyMcmsHH7DrDz5htOMBHzDbEVvDsQUfsOMBnzAB8ygf2MUBfMDcX2MOxs0BHMAuDgZaNaxV8AlDiUYYujWsW/ABu5iAD5h1Dl3pmPUWkz4bjelWq9CY5jijbLWakFZZn4jVs24T8CSNDpELHHOUwMxD+vRZMz72vdkL0qrPGvCxj6x5qzHFe6Tgq9f0xnFG2WqYtMcxRwnMekAMn7WNGAaa91RDG7rRJGmeeRC3Z/2TbulAtRj9G/2TJvlMQNlqmzTJZwJK4Eaf4G90TpqMuH8EPUeNPrFePkNIqwEdllaf4OHzJIgpF3jcGDxNXkCreAsyRp9G/9BtkDR9MBbvi9UtxxwlMGuVdMhnCErgRpPo0+gce2F4L1C2msdeGN4LlK3+sRfQcavnJhdIz7wXKIHXtN3kCGlbNzrHuE2+kM4DHhd753IHh/qZPdr37DOwz9Ae+RG9JXd2BthDen38+EsvwP/3+aU+dJc5Wc5usmHu4f7izavycu7ql/ljNlx4qbtfrbd0bJPl/VU+65jKqpqWxWQbQ9PUMRa3k2qWb20iY359+z0qatpCdVXNrjfm9JCVZXct9s7bMQ2L2bDsmhazolPPZrPqoWO5zxajjuEqW+B+PB8V0y5TPtkI5iLrTjEbZxuj3bfh+LLjPXr2h3sablp02UzSui/qN2nnOirqY9w236f1CV023cVUePfLclEMq7LCkGzD9co6KsCDFp7ZdkJ7zih94CPGgJ8AXaQu3znLh3RQnwqPxt613gS9+2qFybu5UX1Y3V9heQNvLUBCo2G+vK7GS+4q6brY/7kVgKRZAUG3AkJbVkAL+29XkFx8cZvl/9T/gV+/Jf/rsfrICV7NfpDjbeOmeUumw/qDZF9r3Wb/Tl6vtW7aXyQxTfZlHsO6JZVh3cxmmF4mNIwvchq276Q1sW5mNs1qM7lpqBf5TUOtp/jAa/454zC2tm8=" } @pytest.fixture @@ -158,4 +164,10 @@ def puzzlink_test_urls(): # hebi (snakes) "hebi_1": "https://puzz.link/p?hebi/10/10/n150.15a42a41b320.c0.a0.0.d310.0.b240.n230.b0.0.42d0.0.a0.c0.21b0.a21a0.0.0.n", "hebi_2": "https://puzz.link/p?hebi/7/10/i13k2544d15t15d14d44b43h", + + # test url parse + "url_parse_1": "https://puzz.link/p?stostone/6/6/4hcudmf5re4i4k3", + "url_parse_2": "https://puzz.link/p?slither/10/10/b86ag68dg127bg62aldg8dad8bgdl26dg722cg68dg88bd", + "url_parse_3": "http://pzv.jp/p?yajilin/17/17/q41i21a232323l21y30111111v41b41za2041r41b41u4141414141j21h32a32b32z1242s21b21k32g42124214141412j", + "url_parse_4": "https://pzplus.tck.mn/p?heyawake/18/10/2i58kha5495929aagaik2kl4l5959a86qi00001ofvo0003vuc006e3v07hg01pvvo04324i53i222h322i12g1l" } \ No newline at end of file diff --git a/tests/formats/test_roundtrip.py b/tests/formats/test_roundtrip.py index 125f7ef5..8543a14f 100644 --- a/tests/formats/test_roundtrip.py +++ b/tests/formats/test_roundtrip.py @@ -55,6 +55,23 @@ def test_yajilin_roundtrip_with_shading_config(self): ir2 = decoder2.decode(url2) assert ir1 == ir2, f"[Puzzlink][Yajilin][Shading] wrong. URL: {shaded_url}" + def test_puzzlink_alternate_hosts_and_bare_path(self): + """Same puzzle path must decode the same IR from puzz.link, pzplus, pzv, or bare path.""" + path = ( + "hebi/10/10/d0.b35c150.a44k0.a25c0.41a0.d0.e41a0.b25a0.e0.d0.a0.0." + "c30a23k44a0.43c0.b35d" + ) + ref = PuzzlinkConverter().decode(f"https://puzz.link/p?{path}") + alts = [ + f"https://pzplus.tck.mn/p.html?{path}", + f"http://pzv.jp/p?{path}", + path, + f"?{path}", + ] + for u in alts: + ir = PuzzlinkConverter().decode(u) + assert ir == ref, f"Mismatch for URL: {u}" + class TestPenpaTrip: """ From 61a05fc1e91d85e8fc8c5de815d0d0ef0f5b6b74 Mon Sep 17 00:00:00 2001 From: xiaoxiao Date: Tue, 31 Mar 2026 01:38:04 +0800 Subject: [PATCH 17/17] Update for v0.3.3, with url-converter --- README.md | 60 ++++++++++++++++++- pyproject.toml | 6 +- scripts/generate_format_interchange_table.py | 63 ++++++++++++++++++++ src/puzzlekit/__init__.py | 2 +- 4 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 scripts/generate_format_interchange_table.py diff --git a/README.md b/README.md index 8efe9eee..36b3a60c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This repository provides **100+ useful, efficient and problem‑specific solvers 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. - +This repo also provides **bidirectional conversion between [puzz.link](https://puzz.link) and [Penpa+](https://swaroopg92.github.io/penpa-edit/) puzzle URLs** for supported genres (19 for now) via a shared intermediate representation. puzz.link-like links from common mirrors (e.g., `pzplus.tck.mn`, `pzv.jp`) are also accepted.
Table of puzzles, datasets and solvers. @@ -164,6 +164,37 @@ For simplicity, the dataset is removed to [puzzlekit-dataset](https://github.com
+
+Supported puzzle types for URL interchange + + +> (canonical IR name, puzz.link token, aliases, Penpa genre tag) + +| Canonical (IR) | puzz.link token | puzz.link aliases | Penpa genre tag | +| -------------- | --------------- | ------------------------------- | --------------------- | +| `aqre` | `aqre` | aqre | `aqre` | +| `ayeheya` | `ayeheya` | ayeheya | `ayeheya (ekawayeh)` | +| `castle` | `castle` | castle | `castlewall` | +| `country` | `country` | country | `country road` | +| `hebi` | `hebi` | hebi, snakes | `hebi-ichigo` | +| `heyawake` | `heyawake` | heyawake, heyawacky, heyawack | `heyawake` | +| `kurochute` | `kurochute` | kurochute, kuroshuto, kurochuto | `kurochute` | +| `kurodoko` | `kurodoko` | kurodoko | `kurodoko` | +| `kurotto` | `kurotto` | kurotto | `kurotto` | +| `masyu` | `masyu` | masyu, mashu, pearl | `masyu` | +| `moonsun` | `moonsun` | moonsun | `moon or sun` | +| `nonogram` | `nonogram` | nonogram | `nonogram` | +| `nurikabe` | `nurikabe` | nurikabe | `nurikabe` | +| `nurimisaki` | `nurimisaki` | nurimisaki | `nurimisaki` | +| `shikaku` | `shikaku` | shikaku | `shikaku` | +| `shimaguni` | `shimaguni` | shimaguni | `shimaguni (islands)` | +| `slitherlink` | `slither` | slitherlink | `slitherlink` | +| `stostone` | `stostone` | stostone | `stostone` | +| `yajilin` | `yajilin` | yajilin, yajirin | `yajilin` | + +
+ +
Gallery of some puzzles (not complete!) @@ -240,6 +271,33 @@ python scripts/benchmark.py -a Currently it will take ~30 min to solve all 30k+ instances available. +--- + +If you want to try the bi-directional conversion between penpa+ and puzz.link, follow the quick tour: + + +```python +import puzzlekit + +# 1st method +ir = puzzlekit.decode("https://puzz.link/p?slither/10/10/g188227cl1dg367bdcg3ddgbhdgd1agbd760dg2cl633661d") +penpa_url = puzzlekit.encode(ir, "penpa") +print(penpa_url) +# get: +# https://swaroopg92.github.io/penpa-edit/#m=edit&p=7VdtT9swEP7Or0... + + +# 2nd method (recommend) +penpa_url = "YOUR_PENPA_URL" # both full URL or `m=edit&p=...` are okay +puzzlink_url = puzzlekit.convert(penpa_url, "puzzlink") +print(puzzlink_url) +# get: +# https://puzz.link/p?slither/10/10/b86ag68dg127bg62aldg8dad8bgdl26dg722cg68dg88b3 +``` + +See also `scripts/quick_start.py` for a runnable sample. + + ## Roadmap - [x] 130+ Puzzle Solvers & 40k+ Dataset. diff --git a/pyproject.toml b/pyproject.toml index 4f4a548e..13fcf4cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "puzzlekit" -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." +version = "0.3.3" +description = "100+ logic puzzle solvers on Google OR-Tools (e.g., yajilin, slitherlink, nonogram), with bidirectional conversion between puzz.link and Penpa+ URLs for supported genres (shared IR)." readme = "README.md" requires-python = ">=3.10" authors = [{name = "SmilingWayne", email = "xiaoxiaowayne@gmail.com"}] license = {file = "LICENSE"} -keywords = ["puzzle", "solver", "logic", "cp-sat", "efficient", "python", "nonogram", "slitherlink", "yajilin", "hitori", "sudoku", "akari"] +keywords = ["puzzle", "solver", "logic", "cp-sat", "python", "nonogram", "slitherlink", "yajilin", "sudoku", "puzzlink", "penpa", "url-conversion"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", diff --git a/scripts/generate_format_interchange_table.py b/scripts/generate_format_interchange_table.py new file mode 100644 index 00000000..2ca66ef0 --- /dev/null +++ b/scripts/generate_format_interchange_table.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Emit a Markdown table of puzzle types supported for puzz.link <-> Penpa+ URL conversion. + +Single source of truth: ``puzzlekit.formats.puzzle_types.PUZZLE_TYPES_DICT``. + +Usage (from repo root):: + + PYTHONPATH=src python scripts/generate_format_interchange_table.py + +Paste the output into README.md inside the ``
`` block for URL interchange, +or use it in release notes. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[1] +if str(_REPO_ROOT / "src") not in sys.path: + sys.path.insert(0, str(_REPO_ROOT / "src")) + +from puzzlekit.formats.puzzle_types import PUZZLE_TYPES_DICT # noqa: E402 + + +def _fmt_list(x: object) -> str: + if x is None: + return "" + if isinstance(x, list): + return ", ".join(str(i) for i in x) + return str(x) + + +def main() -> None: + print( + "| Canonical (IR) | puzz.link token | puzz.link aliases | Penpa genre tag |\n" + "| --- | --- | --- | --- |" + ) + for canonical in sorted(PUZZLE_TYPES_DICT.keys()): + meta = PUZZLE_TYPES_DICT[canonical] + pl = meta.get("puzzlink", {}) + pp = meta.get("penpa", {}) + primary = pl.get("primary", canonical) + pl_aliases = _fmt_list(pl.get("aliases", [])) + genre = pp.get("genre_tag", canonical) + genre_s = _fmt_list(genre) + pa = pp.get("aliases", []) + if isinstance(pa, str): + pa = [pa] + extra = [ + str(a) for a in pa + if str(a).strip().lower() != genre_s.strip().lower() + ] + penpa_cell = f"`{genre_s}`" + if extra: + penpa_cell += f" · *also:* {', '.join(extra)}" + print( + f"| `{canonical}` | `{primary}` | {pl_aliases} | {penpa_cell} |" + ) + + +if __name__ == "__main__": + main() diff --git a/src/puzzlekit/__init__.py b/src/puzzlekit/__init__.py index a205daeb..fdacdf46 100644 --- a/src/puzzlekit/__init__.py +++ b/src/puzzlekit/__init__.py @@ -221,4 +221,4 @@ def convert( return encode(ir, dst, converter_config=encode_cfg) __all__ = ["solve", "solver", "decode", "encode", "convert", "PenpaDecodeError"] -__version__ = '0.3.2' \ No newline at end of file +__version__ = '0.3.3' \ No newline at end of file