linpuzs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. linpuzs-0.1.0/.github/workflows/release.yml +74 -0
  2. linpuzs-0.1.0/.gitignore +8 -0
  3. linpuzs-0.1.0/LICENSE +21 -0
  4. linpuzs-0.1.0/PKG-INFO +81 -0
  5. linpuzs-0.1.0/README.md +48 -0
  6. linpuzs-0.1.0/media/example/mini_sudoku.png +0 -0
  7. linpuzs-0.1.0/media/example/patches.png +0 -0
  8. linpuzs-0.1.0/media/example/queens.png +0 -0
  9. linpuzs-0.1.0/media/example/queens_black_navbar.png +0 -0
  10. linpuzs-0.1.0/media/example/tango.png +0 -0
  11. linpuzs-0.1.0/media/example/zip.png +0 -0
  12. linpuzs-0.1.0/pyproject.toml +29 -0
  13. linpuzs-0.1.0/scripts/make_digits.py +50 -0
  14. linpuzs-0.1.0/src/linpuzs/__init__.py +8 -0
  15. linpuzs-0.1.0/src/linpuzs/__main__.py +3 -0
  16. linpuzs-0.1.0/src/linpuzs/actions.py +39 -0
  17. linpuzs-0.1.0/src/linpuzs/cli.py +165 -0
  18. linpuzs-0.1.0/src/linpuzs/runner.py +60 -0
  19. linpuzs-0.1.0/src/linpuzs/solvers/__init__.py +66 -0
  20. linpuzs-0.1.0/src/linpuzs/solvers/base.py +60 -0
  21. linpuzs-0.1.0/src/linpuzs/solvers/mini_sudoku.py +213 -0
  22. linpuzs-0.1.0/src/linpuzs/solvers/patches.py +270 -0
  23. linpuzs-0.1.0/src/linpuzs/solvers/queens.py +171 -0
  24. linpuzs-0.1.0/src/linpuzs/solvers/tango.py +193 -0
  25. linpuzs-0.1.0/src/linpuzs/solvers/zip.py +327 -0
  26. linpuzs-0.1.0/src/linpuzs/ui.py +128 -0
  27. linpuzs-0.1.0/src/linpuzs/util/__init__.py +1 -0
  28. linpuzs-0.1.0/src/linpuzs/util/adb.py +117 -0
  29. linpuzs-0.1.0/src/linpuzs/util/digits.npz +0 -0
  30. linpuzs-0.1.0/src/linpuzs/util/digits.py +78 -0
  31. linpuzs-0.1.0/src/linpuzs/util/vision.py +92 -0
  32. linpuzs-0.1.0/tests/test_mini_sudoku.py +64 -0
  33. linpuzs-0.1.0/tests/test_mini_sudoku_fuzz.py +157 -0
  34. linpuzs-0.1.0/tests/test_patches.py +76 -0
  35. linpuzs-0.1.0/tests/test_patches_fuzz.py +218 -0
  36. linpuzs-0.1.0/tests/test_queens.py +71 -0
  37. linpuzs-0.1.0/tests/test_queens_fuzz.py +159 -0
  38. linpuzs-0.1.0/tests/test_tango.py +65 -0
  39. linpuzs-0.1.0/tests/test_tango_fuzz.py +156 -0
  40. linpuzs-0.1.0/tests/test_zip.py +70 -0
  41. linpuzs-0.1.0/tests/test_zip_fuzz.py +172 -0
  42. linpuzs-0.1.0/uv.lock +701 -0
@@ -0,0 +1,74 @@
1
+ # Every push to main publishes a new minor version to PyPI: v0.1.0, v0.2.0, v0.3.0, ...
2
+ #
3
+ # The version lives only in git tags (hatch-vcs reads it at build time), so
4
+ # nothing is committed back to main. Uploads use PyPI trusted publishing, which
5
+ # must be configured on pypi.org for this repo, this file (release.yml) and the
6
+ # "pypi" environment.
7
+
8
+ name: Release
9
+
10
+ on:
11
+ push:
12
+ branches: [main]
13
+
14
+ concurrency:
15
+ group: release
16
+ cancel-in-progress: false
17
+
18
+ jobs:
19
+ release:
20
+ runs-on: ubuntu-latest
21
+ environment: pypi
22
+ permissions:
23
+ contents: write # push the tag and create the GitHub release
24
+ id-token: write # trusted publishing to PyPI
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ with:
28
+ fetch-depth: 0 # all tags, to find the latest version
29
+
30
+ - uses: astral-sh/setup-uv@v6
31
+
32
+ - name: Test
33
+ run: |
34
+ uv sync
35
+ uv run pytest -q
36
+
37
+ - name: Pick the next version
38
+ id: version
39
+ run: |
40
+ # Re-running on an already released commit reuses its tag.
41
+ existing=$(git tag --points-at HEAD --list 'v*' | head -n1)
42
+ if [ -n "$existing" ]; then
43
+ echo "tag=$existing" >> "$GITHUB_OUTPUT"
44
+ exit 0
45
+ fi
46
+ latest=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)
47
+ if [ -z "$latest" ]; then
48
+ next="v0.1.0"
49
+ else
50
+ IFS=. read -r major minor _ <<< "${latest#v}"
51
+ next="v${major}.$((minor + 1)).0"
52
+ fi
53
+ echo "tag=$next" >> "$GITHUB_OUTPUT"
54
+ echo "Releasing $next (previous: ${latest:-none})"
55
+
56
+ - name: Build
57
+ run: |
58
+ git tag "${{ steps.version.outputs.tag }}" 2>/dev/null || true
59
+ rm -rf dist
60
+ uv build
61
+ ls dist
62
+
63
+ - name: Publish to PyPI
64
+ uses: pypa/gh-action-pypi-publish@release/v1
65
+ with:
66
+ skip-existing: true
67
+
68
+ - name: Push tag and create GitHub release
69
+ env:
70
+ GH_TOKEN: ${{ github.token }}
71
+ TAG: ${{ steps.version.outputs.tag }}
72
+ run: |
73
+ git push origin "$TAG"
74
+ gh release view "$TAG" >/dev/null 2>&1 || gh release create "$TAG" dist/* --generate-notes
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .DS_Store
linpuzs-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pratyush
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
linpuzs-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.5
2
+ Name: linpuzs
3
+ Version: 0.1.0
4
+ Summary: LinkedIn puzzle solver: screenshots your phone over ADB, solves the daily game, and taps in the answer.
5
+ License: MIT License
6
+
7
+ Copyright (c) 2026 Pratyush
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ of this software and associated documentation files (the "Software"), to deal
11
+ in the Software without restriction, including without limitation the rights
12
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ copies of the Software, and to permit persons to whom the Software is
14
+ furnished to do so, subject to the following conditions:
15
+
16
+ The above copyright notice and this permission notice shall be included in all
17
+ copies or substantial portions of the Software.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
26
+ License-File: LICENSE
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: numpy>=1.26
29
+ Requires-Dist: pillow>=10.0
30
+ Requires-Dist: questionary>=2.0
31
+ Requires-Dist: rich>=13.7
32
+ Description-Content-Type: text/markdown
33
+
34
+ # linpuzs: Linkedin Puzzle Solver
35
+
36
+ For when you want the streak but not the 4 minutes of staring at coloured squares.
37
+
38
+ Plug in your Android phone, open today's LinkedIn puzzle and run `linpuzs`. It grabs a screenshot over ADB, figures out the board, solves it, and taps the answer in for you, usually in a few seconds.
39
+
40
+ ### Supported games
41
+
42
+ | Game | Goal | How the answer is entered |
43
+ | --- | --- | --- |
44
+ | ๐Ÿ‘‘ Queens | One crown in every row, column and colour region, none touching | Double-tap each crown cell |
45
+ | ๐ŸŒ— Tango | Fill with suns and moons: balanced rows and columns, no three in a row, respect `=` and `ร—` | Tap each empty cell (once for sun, twice for moon) |
46
+ | ใ€ฐ๏ธ Zip | One path through every cell, passing the numbered dots in order | One continuous drag |
47
+ | ๐Ÿ”ข Mini Sudoku | 1-6 once in every row, column and 2ร—3 box | Tap the cell, then the number pad |
48
+ | ๐Ÿงต Patches | Split the grid into rectangles matching each clue's shape and size | Drag across each patch |
49
+
50
+ ## Setup
51
+
52
+ Requires [uv](https://docs.astral.sh/uv/) and `adb` (Android platform-tools) on your PATH, with USB debugging enabled on the phone.
53
+
54
+ ```bash
55
+ uv sync
56
+ uv run linpuzs
57
+ ```
58
+
59
+ ## Usage
60
+
61
+ ```
62
+ linpuzs interactive: pick device (if several) โ†’ pick game โ†’ solve
63
+ linpuzs -g queens solve one game without the menu
64
+ linpuzs -s SERIAL use a specific device
65
+ linpuzs -n dry run: solve, but don't touch the screen
66
+ linpuzs --list show games and solver status
67
+ ```
68
+
69
+ **Dry run:** `linpuzs -n` (or `--dry-run`) reads the board from your phone, solves it and prints the solved board in the terminal, without tapping anything.
70
+
71
+ ## Tests
72
+
73
+ No phone needed. Each game has two test files:
74
+
75
+ - `tests/test_<game>.py` checks the real screenshot in `media/example/`.
76
+ - `tests/test_<game>_fuzz.py` draws random boards the way the app does, solves them, and replays the touches.
77
+
78
+ ```bash
79
+ uv run pytest -q # everything (~1-2 min)
80
+ uv run pytest -q tests/test_zip.py # one game's screenshot tests
81
+ ```
@@ -0,0 +1,48 @@
1
+ # linpuzs: Linkedin Puzzle Solver
2
+
3
+ For when you want the streak but not the 4 minutes of staring at coloured squares.
4
+
5
+ Plug in your Android phone, open today's LinkedIn puzzle and run `linpuzs`. It grabs a screenshot over ADB, figures out the board, solves it, and taps the answer in for you, usually in a few seconds.
6
+
7
+ ### Supported games
8
+
9
+ | Game | Goal | How the answer is entered |
10
+ | --- | --- | --- |
11
+ | ๐Ÿ‘‘ Queens | One crown in every row, column and colour region, none touching | Double-tap each crown cell |
12
+ | ๐ŸŒ— Tango | Fill with suns and moons: balanced rows and columns, no three in a row, respect `=` and `ร—` | Tap each empty cell (once for sun, twice for moon) |
13
+ | ใ€ฐ๏ธ Zip | One path through every cell, passing the numbered dots in order | One continuous drag |
14
+ | ๐Ÿ”ข Mini Sudoku | 1-6 once in every row, column and 2ร—3 box | Tap the cell, then the number pad |
15
+ | ๐Ÿงต Patches | Split the grid into rectangles matching each clue's shape and size | Drag across each patch |
16
+
17
+ ## Setup
18
+
19
+ Requires [uv](https://docs.astral.sh/uv/) and `adb` (Android platform-tools) on your PATH, with USB debugging enabled on the phone.
20
+
21
+ ```bash
22
+ uv sync
23
+ uv run linpuzs
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ```
29
+ linpuzs interactive: pick device (if several) โ†’ pick game โ†’ solve
30
+ linpuzs -g queens solve one game without the menu
31
+ linpuzs -s SERIAL use a specific device
32
+ linpuzs -n dry run: solve, but don't touch the screen
33
+ linpuzs --list show games and solver status
34
+ ```
35
+
36
+ **Dry run:** `linpuzs -n` (or `--dry-run`) reads the board from your phone, solves it and prints the solved board in the terminal, without tapping anything.
37
+
38
+ ## Tests
39
+
40
+ No phone needed. Each game has two test files:
41
+
42
+ - `tests/test_<game>.py` checks the real screenshot in `media/example/`.
43
+ - `tests/test_<game>_fuzz.py` draws random boards the way the app does, solves them, and replays the touches.
44
+
45
+ ```bash
46
+ uv run pytest -q # everything (~1-2 min)
47
+ uv run pytest -q tests/test_zip.py # one game's screenshot tests
48
+ ```
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "linpuzs"
3
+ dynamic = ["version"] # from the latest vX.Y.Z git tag, see .github/workflows/release.yml
4
+ description = "LinkedIn puzzle solver: screenshots your phone over ADB, solves the daily game, and taps in the answer."
5
+ readme = "README.md"
6
+ license = { file = "LICENSE" }
7
+ requires-python = ">=3.10"
8
+ dependencies = [
9
+ "rich>=13.7",
10
+ "questionary>=2.0",
11
+ "pillow>=10.0",
12
+ "numpy>=1.26",
13
+ ]
14
+
15
+ [project.scripts]
16
+ linpuzs = "linpuzs.cli:main"
17
+
18
+ [dependency-groups]
19
+ dev = ["pytest>=8.0"]
20
+
21
+ [build-system]
22
+ requires = ["hatchling", "hatch-vcs"]
23
+ build-backend = "hatchling.build"
24
+
25
+ [tool.hatch.version]
26
+ source = "vcs"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/linpuzs"]
@@ -0,0 +1,50 @@
1
+ """Cut 0-9 digit templates (LinkedIn's game font) out of the Zip example screenshot.
2
+
3
+ uv run python scripts/make_digits.py
4
+
5
+ The numbers below are what the example board shows; each dot's glyphs are
6
+ paired with the digits of its number. Writes src/linpuzs/util/digits.npz.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ from PIL import Image
13
+
14
+ from linpuzs.solvers.zip import LINE_RGB, LINE_TOLERANCE, dot_ink
15
+ from linpuzs.util.digits import split_glyphs
16
+ from linpuzs.util.vision import find_grid
17
+
18
+ ROOT = Path(__file__).parents[1]
19
+ EXAMPLE = ROOT / "media" / "example" / "zip.png"
20
+ OUT = ROOT / "src" / "linpuzs" / "util" / "digits.npz"
21
+
22
+ # (row, col) -> number, as shown in media/example/zip.png
23
+ KNOWN = {
24
+ (1, 0): 11, (1, 1): 9, (1, 4): 4, (1, 5): 12,
25
+ (2, 1): 10, (2, 5): 5,
26
+ (3, 0): 8, (3, 4): 3,
27
+ (4, 0): 7, (4, 1): 1, (4, 4): 2, (4, 5): 6,
28
+ } # fmt: skip
29
+
30
+
31
+ def main() -> None:
32
+ a = np.asarray(Image.open(EXAMPLE).convert("RGB"))
33
+ grid = find_grid(np.abs(a.astype(int) - LINE_RGB).max(axis=2) <= LINE_TOLERANCE)
34
+ assert grid is not None
35
+ templates: dict[str, np.ndarray] = {}
36
+ for (r, c), number in KNOWN.items():
37
+ (y0, y1), (x0, x1) = grid.row_spans[r], grid.col_spans[c]
38
+ ink = dot_ink(a[y0:y1, x0:x1])
39
+ glyphs = split_glyphs(ink) if ink is not None else None
40
+ digits = str(number)
41
+ assert glyphs is not None and len(glyphs) == len(digits), (r, c, number)
42
+ for d, g in zip(digits, glyphs):
43
+ templates.setdefault(d, g)
44
+ assert sorted(templates) == list("0123456789"), sorted(templates)
45
+ np.savez_compressed(OUT, **templates)
46
+ print(f"wrote {OUT.relative_to(ROOT)}: " + ", ".join(f"{d} {t.shape[1]}ร—{t.shape[0]}" for d, t in sorted(templates.items())))
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
@@ -0,0 +1,8 @@
1
+ """linpuzs: LinkedIn puzzle solver."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("linpuzs")
7
+ except PackageNotFoundError: # running from a source tree that isn't installed
8
+ __version__ = "0.0.0"
@@ -0,0 +1,3 @@
1
+ from linpuzs.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,39 @@
1
+ """Touch actions a solver asks the device to perform, in screen pixel coordinates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class Tap:
10
+ x: int
11
+ y: int
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Swipe:
16
+ x1: int
17
+ y1: int
18
+ x2: int
19
+ y2: int
20
+ duration_ms: int = 100
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Drag:
25
+ """A single continuous stroke through a list of points (e.g. drawing a path)."""
26
+
27
+ points: tuple[tuple[int, int], ...]
28
+
29
+ def __post_init__(self):
30
+ if len(self.points) < 2:
31
+ raise ValueError("Drag needs at least two points")
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Wait:
36
+ ms: int
37
+
38
+
39
+ Action = Tap | Swipe | Drag | Wait
@@ -0,0 +1,165 @@
1
+ """`linpuzs` entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ import questionary
8
+ from questionary import Choice, Style
9
+
10
+ from linpuzs import __version__, runner
11
+ from linpuzs.solvers import GAMES, SolverError, get_solver, solver_for
12
+ from linpuzs.ui import ACCENT, banner, console, device_table, games_table, session_summary, stats_panel
13
+ from linpuzs.util.adb import AdbDevice, AdbError, Device, adb_path, list_devices
14
+
15
+ PROMPT_STYLE = Style(
16
+ [
17
+ ("qmark", f"fg:{ACCENT} bold"),
18
+ ("pointer", f"fg:{ACCENT} bold"),
19
+ ("highlighted", f"fg:{ACCENT} bold"),
20
+ ("selected", "fg:green"),
21
+ ("disabled", "fg:#666666 italic"),
22
+ ]
23
+ )
24
+
25
+ QUIT = "__quit__"
26
+
27
+
28
+ def parse_args(argv: list[str] | None) -> argparse.Namespace:
29
+ p = argparse.ArgumentParser(prog="linpuzs", description="Solve LinkedIn daily puzzles on your phone over ADB.")
30
+ p.add_argument("-s", "--serial", help="ADB device serial to use (skips the device prompt)")
31
+ p.add_argument("-g", "--game", choices=list(GAMES), help="Game to solve (skips the game menu)")
32
+ p.add_argument("-n", "--dry-run", action="store_true", help="Solve but don't send any touches")
33
+ p.add_argument("--list", action="store_true", help="List games and exit")
34
+ p.add_argument("--no-banner", action="store_true", help="Skip the ASCII banner")
35
+ p.add_argument("-V", "--version", action="version", version=f"linpuzs {__version__}")
36
+ return p.parse_args(argv)
37
+
38
+
39
+ def pick_device(serial: str | None) -> AdbDevice | None:
40
+ if adb_path() is None:
41
+ console.print("[red]โœ—[/] [bold]adb[/] not found on PATH. Install Android platform-tools and try again.")
42
+ return None
43
+
44
+ with console.status("Looking for ADB devicesโ€ฆ"):
45
+ devices = list_devices()
46
+
47
+ ready = [d for d in devices if d.is_ready]
48
+ not_ready = [d for d in devices if not d.is_ready]
49
+ for d in not_ready:
50
+ hint = "accept the USB debugging prompt on the phone" if d.state == "unauthorized" else "reconnect it"
51
+ console.print(f"[yellow]![/] {d.serial} is [yellow]{d.state}[/]: {hint}.")
52
+
53
+ if serial:
54
+ match = next((d for d in ready if d.serial == serial), None)
55
+ if match is None:
56
+ console.print(f"[red]โœ—[/] Device [bold]{serial}[/] is not connected and ready.")
57
+ return None
58
+ return _connected(match)
59
+
60
+ if not ready:
61
+ console.print("[red]โœ—[/] No ADB device found. Plug in your phone with USB debugging enabled.")
62
+ return None
63
+ if len(ready) == 1:
64
+ return _connected(ready[0])
65
+
66
+ console.print(device_table(ready))
67
+ chosen = questionary.select(
68
+ "Multiple devices connected. Which one?",
69
+ choices=[Choice(d.label(), value=d) for d in ready],
70
+ style=PROMPT_STYLE,
71
+ ).ask()
72
+ return _connected(chosen) if chosen else None
73
+
74
+
75
+ def _connected(d: Device) -> AdbDevice:
76
+ console.print(f"[green]โœ“[/] Connected to [bold]{d.model}[/] [dim]({d.serial})[/]")
77
+ return AdbDevice(d)
78
+
79
+
80
+ def pick_game() -> str | None:
81
+ choices = []
82
+ for g in GAMES.values():
83
+ ready = solver_for(g.slug) is not None
84
+ choices.append(
85
+ Choice(
86
+ f"{g.icon} {g.name:<12} {g.tagline}",
87
+ value=g.slug,
88
+ disabled=None if ready else "coming soon",
89
+ )
90
+ )
91
+ choices.append(Choice("๐Ÿ‘‹ Quit", value=QUIT))
92
+ return questionary.select("Which puzzle?", choices=choices, style=PROMPT_STYLE).ask()
93
+
94
+
95
+ def solve_once(slug: str, device: AdbDevice, args: argparse.Namespace) -> runner.RunResult | None:
96
+ game = GAMES[slug]
97
+ if not args.game:
98
+ ok = questionary.confirm(
99
+ f"Open {game.name} on your phone so the board is visible. Ready?",
100
+ default=True,
101
+ style=PROMPT_STYLE,
102
+ ).ask()
103
+ if not ok:
104
+ return None
105
+
106
+ solver = get_solver(slug)
107
+ with console.status(f"{game.icon} Solving {game.name}โ€ฆ", spinner="dots") as status:
108
+ try:
109
+ result = runner.run(
110
+ solver,
111
+ device,
112
+ dry_run=args.dry_run,
113
+ on_stage=lambda s: status.update(f"{game.icon} {game.name}: {s}โ€ฆ"),
114
+ )
115
+ except (SolverError, AdbError) as e:
116
+ console.print(f"[red]โœ—[/] {game.name} failed: {e}")
117
+ return None
118
+
119
+ console.print(stats_panel(result))
120
+ return result
121
+
122
+
123
+ def main(argv: list[str] | None = None) -> int:
124
+ args = parse_args(argv)
125
+
126
+ if not args.no_banner:
127
+ banner()
128
+
129
+ if args.list:
130
+ console.print(games_table())
131
+ return 0
132
+
133
+ try:
134
+ device = pick_device(args.serial)
135
+ except AdbError as e:
136
+ console.print(f"[red]โœ—[/] {e}")
137
+ return 1
138
+ if device is None:
139
+ return 1
140
+
141
+ if args.game and solver_for(args.game) is None:
142
+ console.print(f"[yellow]![/] No solver for {GAMES[args.game].name} yet.")
143
+ return 1
144
+ if not args.game and not any(solver_for(s) for s in GAMES):
145
+ console.print(games_table())
146
+ console.print("[yellow]![/] No solvers are implemented yet. Check back soon.")
147
+ return 0
148
+
149
+ results: list[runner.RunResult] = []
150
+ try:
151
+ if args.game:
152
+ if r := solve_once(args.game, device, args):
153
+ results.append(r)
154
+ else:
155
+ console.print()
156
+ while (slug := pick_game()) not in (None, QUIT):
157
+ if r := solve_once(slug, device, args):
158
+ results.append(r)
159
+ console.print()
160
+ except KeyboardInterrupt:
161
+ console.print("\n[dim]Interrupted.[/]")
162
+
163
+ session_summary(results)
164
+ console.print(f"[{ACCENT}]Bye![/]")
165
+ return 0 if results or not args.game else 1
@@ -0,0 +1,60 @@
1
+ """Runs a solver end to end against a device, timing each stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from contextlib import contextmanager
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Callable
9
+
10
+ from linpuzs.actions import Action
11
+ from linpuzs.solvers import Solver
12
+ from linpuzs.util.adb import AdbDevice
13
+
14
+ STAGES = ("screenshot", "parse", "solve", "plan", "perform")
15
+
16
+
17
+ @dataclass
18
+ class RunResult:
19
+ solver: Solver
20
+ timings: dict[str, float] = field(default_factory=dict)
21
+ puzzle: Any = None
22
+ solution: Any = None
23
+ actions: list[Action] = field(default_factory=list)
24
+ dry_run: bool = False
25
+
26
+ @property
27
+ def total(self) -> float:
28
+ return sum(self.timings.values())
29
+
30
+
31
+ def run(
32
+ solver: Solver,
33
+ device: AdbDevice,
34
+ *,
35
+ dry_run: bool = False,
36
+ on_stage: Callable[[str], None] = lambda _: None,
37
+ ) -> RunResult:
38
+ result = RunResult(solver=solver, dry_run=dry_run)
39
+
40
+ @contextmanager
41
+ def stage(name: str):
42
+ on_stage(name)
43
+ t0 = time.perf_counter()
44
+ yield
45
+ result.timings[name] = time.perf_counter() - t0
46
+
47
+ with stage("screenshot"):
48
+ img = device.screenshot()
49
+
50
+ with stage("parse"):
51
+ result.puzzle = solver.parse(img)
52
+ with stage("solve"):
53
+ result.solution = solver.solve(result.puzzle)
54
+ with stage("plan"):
55
+ result.actions = solver.plan(result.puzzle, result.solution)
56
+ if not dry_run:
57
+ with stage("perform"):
58
+ device.perform(result.actions)
59
+
60
+ return result
@@ -0,0 +1,66 @@
1
+ """Game catalog and solver registry.
2
+
3
+ To add a solver, create a module in this package and decorate the class:
4
+
5
+ @register("queens")
6
+ class QueensSolver(Solver[Board, list[Cell]]):
7
+ ...
8
+
9
+ Modules here are imported automatically, so nothing else needs wiring up.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import pkgutil
16
+
17
+ from linpuzs.solvers.base import GameInfo, Solver, SolverError
18
+
19
+ __all__ = ["GAMES", "GameInfo", "Solver", "SolverError", "get_solver", "register", "solver_for"]
20
+
21
+ # Every LinkedIn game we know about, in menu order. Ones without a registered
22
+ # solver show up as "coming soon".
23
+ GAMES: dict[str, GameInfo] = {
24
+ g.slug: g
25
+ for g in [
26
+ GameInfo("queens", "Queens", "One crown per row, column and colour region", "๐Ÿ‘‘"),
27
+ GameInfo("tango", "Tango", "Balance suns and moons across the grid", "๐ŸŒ—"),
28
+ GameInfo("zip", "Zip", "Draw one path through every cell, in order", "ใ€ฐ๏ธ"),
29
+ GameInfo("mini-sudoku", "Mini Sudoku", "6ร—6 sudoku", "๐Ÿ”ข"),
30
+ GameInfo("patches", "Patches", "Split the grid into shaped, sized patches", "๐Ÿงต"),
31
+ ]
32
+ }
33
+
34
+ _REGISTRY: dict[str, type[Solver]] = {}
35
+
36
+
37
+ def register(slug: str):
38
+ if slug not in GAMES:
39
+ raise KeyError(f"Unknown game {slug!r}; add it to GAMES first")
40
+
41
+ def deco(cls: type[Solver]) -> type[Solver]:
42
+ cls.game = GAMES[slug]
43
+ _REGISTRY[slug] = cls
44
+ return cls
45
+
46
+ return deco
47
+
48
+
49
+ def _discover() -> None:
50
+ for mod in pkgutil.iter_modules(__path__):
51
+ if mod.name != "base":
52
+ importlib.import_module(f"{__name__}.{mod.name}")
53
+
54
+
55
+ def solver_for(slug: str) -> type[Solver] | None:
56
+ return _REGISTRY.get(slug)
57
+
58
+
59
+ def get_solver(slug: str) -> Solver:
60
+ cls = solver_for(slug)
61
+ if cls is None:
62
+ raise KeyError(f"No solver for {slug!r} yet")
63
+ return cls()
64
+
65
+
66
+ _discover()