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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,6 @@ cython_debug/
# Ignore dataset directory
assets/
benchmark_results/
docs/puzzles/*.md
docs/puzzles/*.md
penpa_edit/
src/puzzlekit/formats/temp
60 changes: 59 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<details>
<summary><strong>Table of puzzles, datasets and solvers.</strong></summary>
Expand Down Expand Up @@ -164,6 +164,37 @@ For simplicity, the dataset is removed to [puzzlekit-dataset](https://github.com

</details>

<details>
<summary><strong>Supported puzzle types for URL interchange</strong>
</summary>

> (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` |

</details>



<details>
<summary><strong>Gallery of some puzzles (not complete!)</strong></summary>
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
83 changes: 83 additions & 0 deletions scripts/debug_penpa_converter.py
Original file line number Diff line number Diff line change
@@ -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()

63 changes: 63 additions & 0 deletions scripts/generate_format_interchange_table.py
Original file line number Diff line number Diff line change
@@ -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 ``<details>`` 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()
35 changes: 33 additions & 2 deletions scripts/quick_start.py
Original file line number Diff line number Diff line change
@@ -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 = """
Expand All @@ -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
Loading
Loading