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}"
+