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.
- linpuzs-0.1.0/.github/workflows/release.yml +74 -0
- linpuzs-0.1.0/.gitignore +8 -0
- linpuzs-0.1.0/LICENSE +21 -0
- linpuzs-0.1.0/PKG-INFO +81 -0
- linpuzs-0.1.0/README.md +48 -0
- linpuzs-0.1.0/media/example/mini_sudoku.png +0 -0
- linpuzs-0.1.0/media/example/patches.png +0 -0
- linpuzs-0.1.0/media/example/queens.png +0 -0
- linpuzs-0.1.0/media/example/queens_black_navbar.png +0 -0
- linpuzs-0.1.0/media/example/tango.png +0 -0
- linpuzs-0.1.0/media/example/zip.png +0 -0
- linpuzs-0.1.0/pyproject.toml +29 -0
- linpuzs-0.1.0/scripts/make_digits.py +50 -0
- linpuzs-0.1.0/src/linpuzs/__init__.py +8 -0
- linpuzs-0.1.0/src/linpuzs/__main__.py +3 -0
- linpuzs-0.1.0/src/linpuzs/actions.py +39 -0
- linpuzs-0.1.0/src/linpuzs/cli.py +165 -0
- linpuzs-0.1.0/src/linpuzs/runner.py +60 -0
- linpuzs-0.1.0/src/linpuzs/solvers/__init__.py +66 -0
- linpuzs-0.1.0/src/linpuzs/solvers/base.py +60 -0
- linpuzs-0.1.0/src/linpuzs/solvers/mini_sudoku.py +213 -0
- linpuzs-0.1.0/src/linpuzs/solvers/patches.py +270 -0
- linpuzs-0.1.0/src/linpuzs/solvers/queens.py +171 -0
- linpuzs-0.1.0/src/linpuzs/solvers/tango.py +193 -0
- linpuzs-0.1.0/src/linpuzs/solvers/zip.py +327 -0
- linpuzs-0.1.0/src/linpuzs/ui.py +128 -0
- linpuzs-0.1.0/src/linpuzs/util/__init__.py +1 -0
- linpuzs-0.1.0/src/linpuzs/util/adb.py +117 -0
- linpuzs-0.1.0/src/linpuzs/util/digits.npz +0 -0
- linpuzs-0.1.0/src/linpuzs/util/digits.py +78 -0
- linpuzs-0.1.0/src/linpuzs/util/vision.py +92 -0
- linpuzs-0.1.0/tests/test_mini_sudoku.py +64 -0
- linpuzs-0.1.0/tests/test_mini_sudoku_fuzz.py +157 -0
- linpuzs-0.1.0/tests/test_patches.py +76 -0
- linpuzs-0.1.0/tests/test_patches_fuzz.py +218 -0
- linpuzs-0.1.0/tests/test_queens.py +71 -0
- linpuzs-0.1.0/tests/test_queens_fuzz.py +159 -0
- linpuzs-0.1.0/tests/test_tango.py +65 -0
- linpuzs-0.1.0/tests/test_tango_fuzz.py +156 -0
- linpuzs-0.1.0/tests/test_zip.py +70 -0
- linpuzs-0.1.0/tests/test_zip_fuzz.py +172 -0
- 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
|
linpuzs-0.1.0/.gitignore
ADDED
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
|
+
```
|
linpuzs-0.1.0/README.md
ADDED
|
@@ -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
|
|
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,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()
|