diff --git a/.gitignore b/.gitignore index bae167aa..72b2b327 100644 --- a/.gitignore +++ b/.gitignore @@ -154,4 +154,6 @@ cython_debug/ # Ignore dataset directory assets/ benchmark_results/ -docs/puzzles/*.md \ No newline at end of file +docs/puzzles/*.md +penpa_edit/ +src/puzzlekit/formats/temp \ No newline at end of file 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/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/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/scripts/quick_start.py b/scripts/quick_start.py index 1125352d..eb648189 100644 --- a/scripts/quick_start.py +++ b/scripts/quick_start.py @@ -1,6 +1,21 @@ 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 = """ @@ -27,4 +42,20 @@ 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?slither/10/10/g188227cl1dg367bdcg3ddgbhdgd1agbd760dg2cl633661d") +# IR -> penpa +penpa_url = puzzlekit.encode(ir, "penpa") +print(penpa_url) +# get: +# m=edit&p=7VdtT9swEP7Or0... + + +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 397e1d58..fdacdf46 100644 --- a/src/puzzlekit/__init__.py +++ b/src/puzzlekit/__init__.py @@ -1,6 +1,65 @@ -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, + PenpaDecodeError, +) + + +_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 +130,95 @@ 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"] -__version__ = '0.3.2' \ No newline at end of file + +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, + decode_converter_config: Optional[Dict[str, Any]] = None, + encode_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") + + 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=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=decode_cfg) + if dst == "ir": + return ir + return encode(ir, dst, converter_config=encode_cfg) + +__all__ = ["solve", "solver", "decode", "encode", "convert", "PenpaDecodeError"] +__version__ = '0.3.3' \ No newline at end of file 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/__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..82e4e283 --- /dev/null +++ b/src/puzzlekit/formats/base.py @@ -0,0 +1,250 @@ +from dataclasses import dataclass, field +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 + CIRCLE_BLACK: int = 6 + WHITE_ON_BLACK: int = 7 + # ... 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 + + +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'), +] +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)) + +@dataclass +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_ + """ + 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: + """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 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: Optional[NumberColor] = None # number color + # num_style: str = "1" # number style + + number: Optional[NumberState] = None + surf_color: Optional[SurfaceColor] = None + symbol: Optional[SymbolState] = None + + + +@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) + 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. + """ + 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())} + """ + + 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}"] = { + "shaded": state.shaded, + "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 + } + + # 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/debug.py b/src/puzzlekit/formats/debug.py new file mode 100644 index 00000000..641da5ae --- /dev/null +++ b/src/puzzlekit/formats/debug.py @@ -0,0 +1,570 @@ +import argparse +import json +import sys +import logging +from pathlib import Path +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 +from puzzlekit.formats.puzzlink_converter import PuzzlinkConverter, parse_puzzlink_input + + +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 _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: + puzzlink_url = _first_line_from_file(args.puzzlink_file, "--puzzlink-file") + if args.penpa_file: + penpa_url = _first_line_from_file(args.penpa_file, "--penpa-file") + + if puzzlink_url and 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: + """ + 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 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.") + + +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 _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": 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 == 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), + } + 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']}") + 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)) + 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], + ignore_paths: Optional[List[str]] = None, +) -> 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) + 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="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( + "--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( + "--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", + 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", + ) + + # 🔧 新增调试参数 + 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) + + left_url, right_url = _resolve_two_inputs(args) + ignore_paths = _parse_ignore_paths(args.ignore) + + 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, ignore_paths=ignore_paths) + 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, ignore_paths=ignore_paths) + print(f"\nSaved debug artifacts with prefix: {args.dump_prefix}") + + +if __name__ == "__main__": + main() + +# 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 new file mode 100644 index 00000000..7fecbe51 --- /dev/null +++ b/src/puzzlekit/formats/penpa_converter.py @@ -0,0 +1,478 @@ +from puzzlekit.formats.base import ( + PuzzleInstance, CellState, EdgeState, + COMPRESS_SUB, NumberColor, SurfaceColor, SymbolState, NumberState, +) +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 ( + 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 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 +import logging + +logger = logging.getLogger(__name__) + + +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)}" + + 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) + + +class PenpaConverter: + """Convert Penpa to PuzzleInstance + """ + 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. + + * 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: + """ + 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_ + 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 _display_parts(self): + # Verbose Penpa payload introspection; guarded by config + DEBUG level. + for p in range(len(self.parts)): + logger.debug("penpa.parts[%d]=%s", p, self.parts[p]) + + 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"], + } + ) + + 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 + + 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(): + (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') + 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 + + for index, num_data in number_dict.items(): + (r, c), _ = self.index_to_coord(int(index), 'cell') + + 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)] + # 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? + + 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 = v_) + return new_edge_dict + + 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: + 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(): + if v_.number: + index = f"{self.coord_to_index(coords, 'cell')}" + new_number_dict[str(index)] = [v_.number.value, v_.number.number_color.value, v_.number.number_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.""" + + 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 + self.real_cols = inst.cols + 4 + # 1. form pu_q dict, then update + original_pu_q = PENPA_PU_X_DEFAULT.copy() + + # ==== augmented update ====== + # (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], + (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'), + hdr.rules.replace(',', '%2C'), + hdr.border_status, hdr.multisolution, + hdr.bg_image_encrypted + ])), # Line 0: header + to_penpa_str(inst.margins), + to_penpa_str(penpa_str_to_dict(penpa_template["mode"])), + to_penpa_str(original_pu_q), + to_penpa_str(PENPA_PU_X_DEFAULT.copy()), + to_penpa_str(inst.boxes), + 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(get_penpa_genre_tags(inst.puzzle_type)), + fixed["custom_message"] + ] + + 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 + plain_text = "\n".join(text_lines) + compressed = compress(plain_text.encode())[2:-4] + + 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 [ + "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) + b = hpc.decode(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 new file mode 100644 index 00000000..6d880f67 --- /dev/null +++ b/src/puzzlekit/formats/penpa_template.py @@ -0,0 +1,188 @@ +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 + +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"], + }, + "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'], + }, + "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'], + }, + "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'], + }, + "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"], + }, + "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"], + }, + "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"], + }, + "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"], + }, + +} + +def get_penpa_template(puzzle_type: str) -> dict: + 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): + 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" : "" +} + 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 new file mode 100644 index 00000000..9a2707f7 --- /dev/null +++ b/src/puzzlekit/formats/puzzlink_converter.py @@ -0,0 +1,1690 @@ +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 +) +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 + +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()): + 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._puzzle_path.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( + 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( + number = NumberState( + value = f"{v}", + number_color = NumberColor.BLACK, + number_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_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() + 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) + # 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 + 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 = "-") + # 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 + self.ir_puzzle.boxes = generate_centerlist_diff(self.ir_puzzle.rows, self.ir_puzzle.cols, self.ir_puzzle.margins) + + 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( + number = NumberState( + value = str(v), # "?" 原样保留(nurikabe/kurochute) + number_color = num_color, + number_style = "1" + ) + ) + else: + cell_dict[(row_idx, col_idx)] = CellState( + number = NumberState( + value = str(v) if str(v) != "?" else " " , # "?" 原样保留(nurikabe/kurochute) + number_color = num_color, + number_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_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. + + 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): + 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_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 + 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 + + + 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, + } + ) + + self.body: str = "" + self.num_rows: int = 0 + self.num_cols: int = 0 + self.skip_shading: bool = True + self.puzzle_type: str = "" + # .0 parse header + self._parse_header() + + decode_family = get_puzzlink_decode_family(self.puzzle_type) + if decode_family == "yajilin_family": + self._decode_yajilin_variant() + elif decode_family == "masyu_family": + self._decode_masyu_variant() + elif decode_family == "slither_family": + self._decode_slither_variant() + elif decode_family == "heyawake_family": + self._decode_heyawake_variant() + elif decode_family == "nonogram_family": + self._decode_nonogram_variant() + elif decode_family == "nurikabe_family": + self._decode_nurikabe_variant() + elif decode_family == "noop": + return self.ir_puzzle + else: + raise NotImplementedError(f"Puzzle type {self.puzzle_type} is not supported currently.") + + 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. + """ + + assert inst.grid_type in ["square"], f"Puzzle grid type must be 'square', get {inst.grid_type}." + 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 = normalized_type + self.num_rows, self.num_cols = inst.rows - inst.margins[0] - inst.margins[1], inst.cols - inst.margins[2] - inst.margins[3] + 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 encode_family == 'nonogram_family': + body_str = self._encode_nonogram_variant(inst) + return body_str + elif encode_family == "nurikabe_family": + body_str = self._encode_nurikabe_variant(inst) + return body_str + elif encode_family == "masyu_family": + body_str = self._encode_masyu_variant(inst) + return body_str + elif encode_family == "slither_family": + body_str = self._encode_slither_variant(inst) + return body_str + elif encode_family == "yajilin_family": + body_str = self._encode_yajilin_variant(inst) + return body_str + else: + raise NotImplementedError(f"Puzzle type {self.puzzle_type} is not supported currently.") + + # _decode_heyawake_variant + + 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 + 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 + number_str = self._encode_number16(number_map, max_region_id) + + # 6. concat body + 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): + 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.number.value or cell_state.number.value.strip() in ['-', '']: + continue + + # 解析数字值 + val = cell_state.number.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}" + 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: + 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) + 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): + """ + 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.number.value: + continue + + val = cell_state.number.value + + 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) + + 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): + """ + 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) + 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] + 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 = 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: + 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. + + 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""" + urldata = self._puzzle_path.split("/") + if len(urldata) > 1 and urldata[1] == 'v:': + urldata.pop(1) + + 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 + 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): + parsing_castle = (self.puzzle_type == "castle") + arrows = self._decode_yajilin_arrows(parsing_castle) + 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(): + if cell_index < 0: + continue + + row = cell_index // self.num_cols + col = cell_index % self.num_cols + 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): + """ + 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] + border_list = None + + if self.puzzle_type in ["moonsun"]: + border_list = self._decode_border() + info_number = self._decode_number3() + grid = self._convert_one_two_2_white_black_grid(info_number, category="moonsun") + else: + info_number = self._decode_number3() + grid = self._convert_one_two_2_white_black_grid(info_number) + + # 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 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 = {} + + 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]], + 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 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] + 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) + if r_id in region_start_points: + r, c = region_start_points[r_id] + grid_matrix[r][c] = str(val) + else: + 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): + 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 # + 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 _encode_number16(self, number_map: Dict[int, Any], max_region_id: int) -> str: + """ + reverse operation of _decode_number16. + + Parameters: + number_map: Dict[int, Optional[int, str]] + key = region_id (int 0-based continuous/non-continuous integers) + value = integer or '?' + + Returns: + str: 16-based compressed string, can be directly concatenated to body. + """ + if not number_map: + return "" + + result = [] + current_id = 0 + skip_count = 0 + 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: + # 🔹 先输出累积的跳过 + 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 + + # 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) + + + 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]: + + 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 _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)""" + 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 _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)""" + 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 _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. + + 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, 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""" + 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__": + from puzzlekit.formats.penpa_converter import PenpaConverter + PzpCvtr = PuzzlinkConverter() + url_list = [ + "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}") + penpa = PenpaConverter() + penpa_url = penpa.encode(p_ir) + + logger.info(penpa_url) + \ No newline at end of file diff --git a/src/puzzlekit/formats/utils.py b/src/puzzlekit/formats/utils.py new file mode 100644 index 00000000..c46d6f61 --- /dev/null +++ b/src/puzzlekit/formats/utils.py @@ -0,0 +1,194 @@ +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. + + 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 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]): + """ + 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 diff --git a/tests/formats/conftest.py b/tests/formats/conftest.py new file mode 100644 index 00000000..2518380e --- /dev/null +++ b/tests/formats/conftest.py @@ -0,0 +1,173 @@ +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_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", + "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", + # 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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", + "nonogram_4": "m=edit&p=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", + + # 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==", + "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=", + + # 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==", + + # 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==", + + # 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 +def puzzlink_test_urls(): + """Puzzlink cases""" + return { + # Heyawake + "heyawake_1": "https://puzz.link/p?heyawake/10/10/ckpbir56acsk19mjc63grjo33g0cvv1vo37og2g31j1g221i33k2g", + "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", + + # 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_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", + + # 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", + + # 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", + + # 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", + + # 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", + + # 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_cross_format.py b/tests/formats/test_cross_format.py new file mode 100644 index 00000000..fedfa108 --- /dev/null +++ b/tests/formats/test_cross_format.py @@ -0,0 +1,23 @@ +# 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) + # Second decode, from url2 -> IR2 + converter2 = PenpaConverter() + ir2 = converter2.decode(url_ppa) + + # 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..8543a14f --- /dev/null +++ b/tests/formats/test_roundtrip.py @@ -0,0 +1,96 @@ +# 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}" + + 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}" + + 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: + """ + 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}" +