mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 13:23:15 +02:00
feat: puzzle solver algorithm
This commit is contained in:
parent
8998883a6c
commit
e51c12dd8e
55
puzzle_solver/README.md
Normal file
55
puzzle_solver/README.md
Normal file
@ -0,0 +1,55 @@
|
||||
## Sliding-Square Puzzle Solver
|
||||
|
||||
Parses a screenshot of a sliding-square puzzle and solves it via BFS.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd puzzle_solver
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install opencv-python-headless numpy
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# From workspace root, with venv active:
|
||||
|
||||
# Step 1 – Parse screenshot to editable JSON
|
||||
python -m puzzle_solver parse screenshot.png -o puzzle.json
|
||||
|
||||
# Step 2 – Review & fix any "unknown" squares in puzzle.json
|
||||
# (set "type" to: normal / portal / teleporter / key / lock)
|
||||
|
||||
# Step 3 – Solve
|
||||
python -m puzzle_solver solve puzzle.json
|
||||
|
||||
# One-shot (no manual review)
|
||||
python -m puzzle_solver run screenshot.png
|
||||
|
||||
# Debug overlay (visualise detected squares on image)
|
||||
python -m puzzle_solver debug screenshot.png -o debug.png
|
||||
```
|
||||
|
||||
### Game mechanics
|
||||
|
||||
| Square | JSON type | Description |
|
||||
| ------------------- | ------------ | ------------------------------------------------- |
|
||||
| Empty outline | `normal` | Regular landing square |
|
||||
| Solid fill | `player` | Starting position |
|
||||
| Ring inside | `goal` | Target destination |
|
||||
| Inner square offset | `portal` | Pass through from the side marked by `"side"` |
|
||||
| Antenna line(s) | `teleporter` | Warp to paired teleporter (`"group"` id) |
|
||||
| Key symbol | `key` | Removes matching lock (`"lock_id"`) |
|
||||
| Lock symbol | `lock` | Solid until matching key collected, then vanishes |
|
||||
|
||||
### Movement
|
||||
|
||||
You slide in a cardinal direction (up/down/left/right) until you hit
|
||||
another square. If you slide off the grid without hitting anything, you
|
||||
die.
|
||||
|
||||
### Algorithm
|
||||
|
||||
BFS over state = `(position, set_of_active_locks)`. Explores all
|
||||
reachable states and returns the shortest move sequence to the goal.
|
||||
1
puzzle_solver/__init__.py
Normal file
1
puzzle_solver/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Sliding-square puzzle solver package."""
|
||||
5
puzzle_solver/__main__.py
Normal file
5
puzzle_solver/__main__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Allow ``python -m puzzle_solver …`` invocation."""
|
||||
|
||||
from puzzle_solver.main import main
|
||||
|
||||
main()
|
||||
109
puzzle_solver/main.py
Normal file
109
puzzle_solver/main.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""CLI for the sliding-square puzzle solver.
|
||||
|
||||
Usage
|
||||
-----
|
||||
# 1) Parse a screenshot → JSON (review & hand-edit if needed)
|
||||
python puzzle_solver/main.py parse screenshot.png -o puzzle.json
|
||||
|
||||
# 2) Solve from JSON
|
||||
python puzzle_solver/main.py solve puzzle.json
|
||||
|
||||
# 3) One-shot: parse + solve (skip manual review)
|
||||
python puzzle_solver/main.py run screenshot.png
|
||||
|
||||
# 4) Draw debug overlay showing detected squares
|
||||
python puzzle_solver/main.py debug screenshot.png -o debug.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from puzzle_solver.parse_image import draw_debug, parse_image, save_puzzle
|
||||
from puzzle_solver.solver import Puzzle, print_puzzle, print_solution, solve
|
||||
|
||||
|
||||
def cmd_parse(args: argparse.Namespace) -> None:
|
||||
"""Parse a screenshot into editable puzzle JSON."""
|
||||
puzzle = parse_image(args.image, threshold=args.threshold)
|
||||
out = args.output or args.image.rsplit(".", 1)[0] + "_puzzle.json"
|
||||
save_puzzle(puzzle, out)
|
||||
if puzzle.get("notes"):
|
||||
for _n in puzzle["notes"]:
|
||||
pass
|
||||
|
||||
|
||||
def cmd_solve(args: argparse.Namespace) -> None:
|
||||
"""Solve a puzzle from a JSON file."""
|
||||
with Path(args.puzzle).open() as f:
|
||||
data = json.load(f)
|
||||
puzzle = Puzzle.from_json(data)
|
||||
print_puzzle(puzzle)
|
||||
moves = solve(puzzle)
|
||||
if moves is None:
|
||||
sys.exit(1)
|
||||
print_solution(puzzle, moves)
|
||||
|
||||
|
||||
def cmd_run(args: argparse.Namespace) -> None:
|
||||
"""Parse a screenshot and solve in one shot."""
|
||||
data = parse_image(args.image, threshold=args.threshold)
|
||||
if data.get("notes"):
|
||||
for _n in data["notes"]:
|
||||
pass
|
||||
|
||||
puzzle = Puzzle.from_json(data)
|
||||
print_puzzle(puzzle)
|
||||
moves = solve(puzzle)
|
||||
if moves is None:
|
||||
out = args.image.rsplit(".", 1)[0] + "_puzzle.json"
|
||||
save_puzzle(data, out)
|
||||
sys.exit(1)
|
||||
print_solution(puzzle, moves)
|
||||
|
||||
|
||||
def cmd_debug(args: argparse.Namespace) -> None:
|
||||
"""Draw a debug overlay showing detected square types."""
|
||||
data = parse_image(args.image, threshold=args.threshold)
|
||||
out = args.output or args.image.rsplit(".", 1)[0] + "_debug.png"
|
||||
draw_debug(args.image, data, out)
|
||||
from collections import Counter
|
||||
|
||||
counts = Counter(sq["type"] for sq in data["squares"])
|
||||
for _t, _n in counts.most_common():
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point for the puzzle solver CLI."""
|
||||
ap = argparse.ArgumentParser(description="Sliding-square puzzle solver")
|
||||
sub = ap.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_parse = sub.add_parser("parse", help="Parse screenshot → puzzle JSON")
|
||||
p_parse.add_argument("image")
|
||||
p_parse.add_argument("-o", "--output", help="Output JSON path")
|
||||
p_parse.add_argument("-t", "--threshold", type=int, default=55)
|
||||
|
||||
p_solve = sub.add_parser("solve", help="Solve puzzle from JSON")
|
||||
p_solve.add_argument("puzzle", help="Puzzle JSON file")
|
||||
|
||||
p_run = sub.add_parser("run", help="Parse + solve in one shot")
|
||||
p_run.add_argument("image")
|
||||
p_run.add_argument("-t", "--threshold", type=int, default=55)
|
||||
|
||||
p_debug = sub.add_parser("debug", help="Draw debug overlay on image")
|
||||
p_debug.add_argument("image")
|
||||
p_debug.add_argument("-o", "--output", help="Output image path")
|
||||
p_debug.add_argument("-t", "--threshold", type=int, default=55)
|
||||
|
||||
args = ap.parse_args()
|
||||
{"parse": cmd_parse, "solve": cmd_solve, "run": cmd_run, "debug": cmd_debug}[
|
||||
args.command
|
||||
](args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
438
puzzle_solver/parse_image.py
Normal file
438
puzzle_solver/parse_image.py
Normal file
@ -0,0 +1,438 @@
|
||||
"""Parse a puzzle screenshot into a solvable JSON representation.
|
||||
|
||||
Pipeline
|
||||
--------
|
||||
1. Threshold + contour detection → find square bounding boxes
|
||||
2. Cluster centres into a regular grid → (row, col) for each square
|
||||
3. Analyse each square's interior → classify type
|
||||
4. Pair teleporters and key/lock → assign group IDs
|
||||
5. Export JSON (editable by hand before solving)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
_MIN_SQUARE_AREA = 80
|
||||
_MAX_SQUARE_AREA = 12000
|
||||
_MIN_ASPECT_RATIO = 0.45
|
||||
_PLAYER_FILL_THRESHOLD = 0.40
|
||||
_NORMAL_FILL_CEILING = 0.12
|
||||
_MIN_INTERIOR_SIZE = 6
|
||||
_RING_CIRCULARITY = 0.65
|
||||
_RING_AREA_RATIO = 0.08
|
||||
|
||||
# ── Public API ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_image(image_path: str, *, threshold: int = 55) -> dict:
|
||||
"""Parse a screenshot and return a puzzle dict (ready for solver or JSON)."""
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
msg = f"Cannot load image: {image_path}"
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
raw = _detect_square_candidates(gray, threshold)
|
||||
squares = _merge_overlapping(raw)
|
||||
grid_map = _snap_to_grid(squares)
|
||||
classified = _classify_all(gray, grid_map)
|
||||
_assign_teleporter_and_kl_groups(classified)
|
||||
return _build_output(classified)
|
||||
|
||||
|
||||
def save_puzzle(puzzle: dict, path: str) -> None:
|
||||
"""Write puzzle dict to a JSON file."""
|
||||
with Path(path).open("w") as f:
|
||||
json.dump(puzzle, f, indent=2)
|
||||
|
||||
|
||||
# ── Square detection ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _detect_square_candidates(
|
||||
gray: np.ndarray, thresh: int
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
_, binary = cv2.threshold(gray, thresh, 255, cv2.THRESH_BINARY)
|
||||
kernel = np.ones((3, 3), np.uint8)
|
||||
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
|
||||
|
||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
candidates: list[tuple[int, int, int, int]] = []
|
||||
for cnt in contours:
|
||||
x, y, w, h = cv2.boundingRect(cnt)
|
||||
area = w * h
|
||||
if area < _MIN_SQUARE_AREA or area > _MAX_SQUARE_AREA:
|
||||
continue
|
||||
aspect = min(w, h) / max(w, h)
|
||||
if aspect < _MIN_ASPECT_RATIO:
|
||||
continue
|
||||
candidates.append((x, y, w, h))
|
||||
return candidates
|
||||
|
||||
|
||||
def _merge_overlapping(
|
||||
candidates: list[tuple[int, int, int, int]],
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
"""Merge bounding boxes whose centres are very close."""
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
cands = sorted(candidates, key=lambda c: c[2] * c[3], reverse=True)
|
||||
used = [False] * len(cands)
|
||||
merged: list[tuple[int, int, int, int]] = []
|
||||
|
||||
for i, (x1, y1, w1, h1) in enumerate(cands):
|
||||
if used[i]:
|
||||
continue
|
||||
cx1, cy1 = x1 + w1 // 2, y1 + h1 // 2
|
||||
group = [(x1, y1, w1, h1)]
|
||||
used[i] = True
|
||||
|
||||
for j in range(i + 1, len(cands)):
|
||||
if used[j]:
|
||||
continue
|
||||
x2, y2, w2, h2 = cands[j]
|
||||
cx2, cy2 = x2 + w2 // 2, y2 + h2 // 2
|
||||
if (
|
||||
abs(cx1 - cx2) < max(w1, w2) * 0.55
|
||||
and abs(cy1 - cy2) < max(h1, h2) * 0.55
|
||||
):
|
||||
group.append(cands[j])
|
||||
used[j] = True
|
||||
|
||||
gx = min(g[0] for g in group)
|
||||
gy = min(g[1] for g in group)
|
||||
gx2 = max(g[0] + g[2] for g in group)
|
||||
gy2 = max(g[1] + g[3] for g in group)
|
||||
merged.append((gx, gy, gx2 - gx, gy2 - gy))
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
# ── Grid snapping ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cluster_values(vals: list[int], min_gap: int) -> list[int]:
|
||||
if not vals:
|
||||
return []
|
||||
vals = sorted(vals)
|
||||
clusters: list[list[int]] = [[vals[0]]]
|
||||
for v in vals[1:]:
|
||||
if v - clusters[-1][-1] < min_gap:
|
||||
clusters[-1].append(v)
|
||||
else:
|
||||
clusters.append([v])
|
||||
return [int(np.mean(c)) for c in clusters]
|
||||
|
||||
|
||||
def _snap_to_grid(
|
||||
squares: list[tuple[int, int, int, int]],
|
||||
) -> dict[tuple[int, int], tuple[int, int, int, int]]:
|
||||
centres = [(x + w // 2, y + h // 2) for x, y, w, h in squares]
|
||||
xs = [c[0] for c in centres]
|
||||
ys = [c[1] for c in centres]
|
||||
|
||||
def _median_gap(vals: list[int]) -> int:
|
||||
s = sorted(set(vals))
|
||||
gaps = [s[i + 1] - s[i] for i in range(len(s) - 1)]
|
||||
return int(np.median(gaps)) if gaps else 30
|
||||
|
||||
x_gap = max(8, int(_median_gap(xs) * 0.4))
|
||||
y_gap = max(8, int(_median_gap(ys) * 0.4))
|
||||
|
||||
x_clusters = _cluster_values(xs, x_gap)
|
||||
y_clusters = _cluster_values(ys, y_gap)
|
||||
|
||||
grid: dict[tuple[int, int], tuple[int, int, int, int]] = {}
|
||||
for sq, (cx, cy) in zip(squares, centres, strict=False):
|
||||
col = min(range(len(x_clusters)), key=lambda i: abs(x_clusters[i] - cx))
|
||||
row = min(range(len(y_clusters)), key=lambda i: abs(y_clusters[i] - cy))
|
||||
grid[(row, col)] = sq
|
||||
return grid
|
||||
|
||||
|
||||
# ── Classification ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _classify_all(
|
||||
gray: np.ndarray,
|
||||
grid_map: dict[tuple[int, int], tuple[int, int, int, int]],
|
||||
) -> dict[tuple[int, int], dict]:
|
||||
classified: dict[tuple[int, int], dict] = {}
|
||||
for (row, col), (x, y, w, h) in grid_map.items():
|
||||
sq_type, extra = _classify_one(gray, (x, y, w, h))
|
||||
classified[(row, col)] = {
|
||||
"pos": [row, col],
|
||||
"type": sq_type,
|
||||
"pixel_center": [x + w // 2, y + h // 2],
|
||||
"pixel_bbox": [x, y, w, h],
|
||||
**extra,
|
||||
}
|
||||
return classified
|
||||
|
||||
|
||||
Bbox = tuple[int, int, int, int]
|
||||
|
||||
|
||||
def _classify_by_fill(
|
||||
fill: float,
|
||||
gray: np.ndarray,
|
||||
bbox: Bbox,
|
||||
interior: np.ndarray,
|
||||
) -> tuple[str, dict] | None:
|
||||
"""Try to classify based on fill ratio and feature detectors."""
|
||||
if fill > _PLAYER_FILL_THRESHOLD:
|
||||
return "player", {}
|
||||
if fill < _NORMAL_FILL_CEILING:
|
||||
return "normal", {}
|
||||
|
||||
antenna = _detect_antenna(gray, bbox)
|
||||
if antenna:
|
||||
return "teleporter", {"antenna_sides": antenna}
|
||||
if _is_ring_pattern(interior):
|
||||
return "goal", {}
|
||||
|
||||
return _classify_interior_feature(fill, interior)
|
||||
|
||||
|
||||
def _classify_interior_feature(
|
||||
fill: float,
|
||||
interior: np.ndarray,
|
||||
) -> tuple[str, dict] | None:
|
||||
"""Classify portal, key/lock, or return None for unknown."""
|
||||
side = _detect_portal_side(interior)
|
||||
if side:
|
||||
return "portal", {"side": side}
|
||||
if _has_interior_feature(interior):
|
||||
return "key_or_lock", {"fill_ratio": round(fill, 3)}
|
||||
return None
|
||||
|
||||
|
||||
def _classify_one(
|
||||
gray: np.ndarray,
|
||||
bbox: Bbox,
|
||||
) -> tuple[str, dict]:
|
||||
x, y, w, h = bbox
|
||||
border = max(3, min(w, h) // 5)
|
||||
ix1, iy1 = x + border, y + border
|
||||
ix2, iy2 = x + w - border, y + h - border
|
||||
if ix2 <= ix1 or iy2 <= iy1:
|
||||
return "normal", {}
|
||||
|
||||
interior = gray[iy1:iy2, ix1:ix2]
|
||||
fill = float(np.mean(interior) / 255.0)
|
||||
|
||||
result = _classify_by_fill(fill, gray, bbox, interior)
|
||||
if result is not None:
|
||||
return result
|
||||
return "unknown", {"fill_ratio": round(fill, 3)}
|
||||
|
||||
|
||||
# ── Feature detectors ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _detect_antenna(
|
||||
gray: np.ndarray,
|
||||
bbox: Bbox,
|
||||
margin: int = 8,
|
||||
thr: float = 0.08,
|
||||
) -> list[str] | None:
|
||||
"""Check for bright pixels in a narrow strip outside each edge."""
|
||||
x, y, w, h = bbox
|
||||
ih, iw = gray.shape
|
||||
sides: list[str] = []
|
||||
qw, qh = w // 4, h // 4 # quarter-width / height
|
||||
|
||||
# up
|
||||
if y > margin:
|
||||
s = gray[y - margin : y - 1, x + qw : x + w - qw]
|
||||
if s.size and float(np.mean(s) / 255) > thr:
|
||||
sides.append("up")
|
||||
# down
|
||||
if y + h + margin < ih:
|
||||
s = gray[y + h + 1 : y + h + margin, x + qw : x + w - qw]
|
||||
if s.size and float(np.mean(s) / 255) > thr:
|
||||
sides.append("down")
|
||||
# left
|
||||
if x > margin:
|
||||
s = gray[y + qh : y + h - qh, x - margin : x - 1]
|
||||
if s.size and float(np.mean(s) / 255) > thr:
|
||||
sides.append("left")
|
||||
# right
|
||||
if x + w + margin < iw:
|
||||
s = gray[y + qh : y + h - qh, x + w + 1 : x + w + margin]
|
||||
if s.size and float(np.mean(s) / 255) > thr:
|
||||
sides.append("right")
|
||||
|
||||
return sides or None
|
||||
|
||||
|
||||
def _is_ring_pattern(interior: np.ndarray) -> bool:
|
||||
h, w = interior.shape
|
||||
if h < _MIN_INTERIOR_SIZE or w < _MIN_INTERIOR_SIZE:
|
||||
return False
|
||||
|
||||
_, bw = cv2.threshold(interior, 40, 255, cv2.THRESH_BINARY)
|
||||
contours, _ = cv2.findContours(bw, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
for cnt in contours:
|
||||
area = cv2.contourArea(cnt)
|
||||
peri = cv2.arcLength(cnt, closed=True)
|
||||
if peri == 0:
|
||||
continue
|
||||
circ = 4 * np.pi * area / (peri * peri)
|
||||
if circ > _RING_CIRCULARITY and area > (h * w) * _RING_AREA_RATIO:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _detect_portal_side(interior: np.ndarray) -> str | None:
|
||||
h, w = interior.shape
|
||||
if h < _MIN_INTERIOR_SIZE or w < _MIN_INTERIOR_SIZE:
|
||||
return None
|
||||
|
||||
thirds_w, thirds_h = w // 3, h // 3
|
||||
regions = {
|
||||
"left": float(np.mean(interior[:, :thirds_w])),
|
||||
"right": float(np.mean(interior[:, w - thirds_w :])),
|
||||
"up": float(np.mean(interior[:thirds_h, :])),
|
||||
"down": float(np.mean(interior[h - thirds_h :, :])),
|
||||
}
|
||||
|
||||
best = max(regions, key=lambda k: regions[k])
|
||||
opposites = {"left": "right", "right": "left", "up": "down", "down": "up"}
|
||||
opp = regions[opposites[best]]
|
||||
|
||||
if regions[best] > max(opp * 2.5, 8):
|
||||
return best
|
||||
return None
|
||||
|
||||
|
||||
def _has_interior_feature(interior: np.ndarray) -> bool:
|
||||
_, bw = cv2.threshold(interior, 40, 255, cv2.THRESH_BINARY)
|
||||
total_white = int(np.sum(bw > 0))
|
||||
return total_white > interior.size * 0.06
|
||||
|
||||
|
||||
# ── Teleporter / key-lock grouping ───────────────────────────────────
|
||||
|
||||
|
||||
def _assign_teleporter_and_kl_groups(classified: dict[tuple[int, int], dict]) -> None:
|
||||
# ── Teleporters ──
|
||||
tele = [(p, d) for p, d in classified.items() if d["type"] == "teleporter"]
|
||||
gid = 1
|
||||
used: set[tuple[int, int]] = set()
|
||||
for i, (p1, d1) in enumerate(tele):
|
||||
if p1 in used:
|
||||
continue
|
||||
s1 = set(d1.get("antenna_sides", []))
|
||||
for p2, d2 in tele[i + 1 :]:
|
||||
if p2 in used:
|
||||
continue
|
||||
s2 = set(d2.get("antenna_sides", []))
|
||||
if s1 == s2:
|
||||
d1["group"] = gid
|
||||
d2["group"] = gid
|
||||
used |= {p1, p2}
|
||||
gid += 1
|
||||
break
|
||||
|
||||
# pair any remaining teleporters sequentially
|
||||
unpaired = [
|
||||
p
|
||||
for p, d in classified.items()
|
||||
if d["type"] == "teleporter" and "group" not in d
|
||||
]
|
||||
for i in range(0, len(unpaired) - 1, 2):
|
||||
classified[unpaired[i]]["group"] = gid
|
||||
classified[unpaired[i + 1]]["group"] = gid
|
||||
gid += 1
|
||||
|
||||
# ── Key / lock ──
|
||||
kl = [p for p, d in classified.items() if d["type"] == "key_or_lock"]
|
||||
lid = 1
|
||||
for i in range(0, len(kl) - 1, 2):
|
||||
classified[kl[i]]["type"] = "key"
|
||||
classified[kl[i]]["lock_id"] = lid
|
||||
classified[kl[i + 1]]["type"] = "lock"
|
||||
classified[kl[i + 1]]["lock_id"] = lid
|
||||
lid += 1
|
||||
# odd one out → mark unknown
|
||||
if len(kl) % 2:
|
||||
classified[kl[-1]]["type"] = "unknown"
|
||||
|
||||
|
||||
# ── Output builder ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_output(classified: dict[tuple[int, int], dict]) -> dict:
|
||||
squares: list[dict] = []
|
||||
notes: list[str] = []
|
||||
|
||||
for pos in sorted(classified):
|
||||
d = classified[pos]
|
||||
sq: dict = {"pos": d["pos"], "type": d["type"]}
|
||||
|
||||
if "side" in d:
|
||||
sq["side"] = d["side"]
|
||||
if "group" in d:
|
||||
sq["group"] = d["group"]
|
||||
if "lock_id" in d:
|
||||
sq["lock_id"] = d["lock_id"]
|
||||
|
||||
# keep pixel info for debugging (prefixed with _)
|
||||
sq["_pixel_center"] = d["pixel_center"]
|
||||
sq["_pixel_bbox"] = d["pixel_bbox"]
|
||||
|
||||
if d["type"] == "unknown":
|
||||
notes.append(
|
||||
f"grid {d['pos']} pixel {d['pixel_center']}: "
|
||||
f"classified 'unknown' (fill={d.get('fill_ratio', '?')}) "
|
||||
"- edit type manually"
|
||||
)
|
||||
squares.append(sq)
|
||||
|
||||
return {"squares": squares, "notes": notes}
|
||||
|
||||
|
||||
# ── Debug visualisation ──────────────────────────────────────────────
|
||||
|
||||
TYPE_COLOURS = {
|
||||
"normal": (200, 200, 200),
|
||||
"player": (0, 255, 0),
|
||||
"goal": (0, 200, 255),
|
||||
"portal": (255, 100, 0),
|
||||
"teleporter": (255, 0, 255),
|
||||
"key": (0, 255, 255),
|
||||
"lock": (0, 0, 255),
|
||||
"key_or_lock": (100, 100, 255),
|
||||
"unknown": (0, 0, 200),
|
||||
}
|
||||
|
||||
|
||||
def draw_debug(image_path: str, puzzle: dict, output_path: str) -> None:
|
||||
"""Draw classified squares on the image and save for visual verification."""
|
||||
img = cv2.imread(image_path)
|
||||
if img is None:
|
||||
return
|
||||
|
||||
for sq in puzzle["squares"]:
|
||||
x, y, w, h = sq["_pixel_bbox"]
|
||||
colour = TYPE_COLOURS.get(sq["type"], (128, 128, 128))
|
||||
cv2.rectangle(img, (x, y), (x + w, y + h), colour, 2)
|
||||
label = sq["type"][0].upper()
|
||||
if sq["type"] == "portal":
|
||||
arrows = {"left": "<", "right": ">", "up": "^", "down": "v"}
|
||||
label = arrows.get(sq.get("side", ""), "O")
|
||||
cv2.putText(
|
||||
img, label, (x + 2, y + h - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.4, colour, 1
|
||||
)
|
||||
|
||||
cv2.imwrite(output_path, img)
|
||||
330
puzzle_solver/solver.py
Normal file
330
puzzle_solver/solver.py
Normal file
@ -0,0 +1,330 @@
|
||||
"""BFS puzzle solver for sliding-square puzzles.
|
||||
|
||||
The player slides in one of 4 directions until hitting a square (or dies
|
||||
if no square is reached). Special square types modify traversal:
|
||||
- PORTAL: pass-through when approached from the marked side
|
||||
- TELEPORTER: warp to paired teleporter on landing
|
||||
- KEY: removes the matching LOCK square from the board
|
||||
- LOCK: solid until its KEY is collected, then disappears
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# ── Direction helpers ────────────────────────────────────────────────
|
||||
UP = (-1, 0)
|
||||
DOWN = (1, 0)
|
||||
LEFT = (0, -1)
|
||||
RIGHT = (0, 1)
|
||||
|
||||
DIRECTIONS: dict[str, tuple[int, int]] = {
|
||||
"up": UP,
|
||||
"down": DOWN,
|
||||
"left": LEFT,
|
||||
"right": RIGHT,
|
||||
}
|
||||
|
||||
# When moving in a direction, which side of the target square do we approach?
|
||||
DIR_TO_APPROACH_SIDE: dict[tuple[int, int], str] = {
|
||||
RIGHT: "left",
|
||||
LEFT: "right",
|
||||
DOWN: "up",
|
||||
UP: "down",
|
||||
}
|
||||
|
||||
|
||||
# ── Data model ───────────────────────────────────────────────────────
|
||||
class SquareType(Enum):
|
||||
"""Types of squares in the puzzle grid."""
|
||||
|
||||
NORMAL = "normal"
|
||||
PLAYER = "player"
|
||||
GOAL = "goal"
|
||||
PORTAL = "portal"
|
||||
TELEPORTER = "teleporter"
|
||||
KEY = "key"
|
||||
LOCK = "lock"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Square:
|
||||
"""A single square on the puzzle board."""
|
||||
|
||||
pos: tuple[int, int]
|
||||
square_type: SquareType
|
||||
portal_side: str | None = None # PORTAL: side with inner square
|
||||
teleporter_group: int | None = None # TELEPORTER: pair id
|
||||
lock_id: int | None = None # KEY / LOCK: matching id
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class State:
|
||||
"""Immutable snapshot of player position and remaining locks."""
|
||||
|
||||
pos: tuple[int, int]
|
||||
active_locks: frozenset[tuple[int, int]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ParseMetadata:
|
||||
"""Intermediate bookkeeping collected while parsing squares."""
|
||||
|
||||
player_start: tuple[int, int]
|
||||
goal_pos: tuple[int, int]
|
||||
teleporter_groups: dict[int, list[tuple[int, int]]]
|
||||
key_map: dict[int, tuple[int, int]]
|
||||
lock_map: dict[int, tuple[int, int]]
|
||||
|
||||
|
||||
def _parse_square_list(
|
||||
square_dicts: list[dict[str, Any]],
|
||||
) -> tuple[dict[tuple[int, int], Square], _ParseMetadata]:
|
||||
"""Parse the JSON squares list into Square objects and metadata."""
|
||||
squares: dict[tuple[int, int], Square] = {}
|
||||
player_start: tuple[int, int] | None = None
|
||||
goal_pos: tuple[int, int] | None = None
|
||||
teleporter_groups: dict[int, list[tuple[int, int]]] = {}
|
||||
key_map: dict[int, tuple[int, int]] = {}
|
||||
lock_map: dict[int, tuple[int, int]] = {}
|
||||
|
||||
for sd in square_dicts:
|
||||
pos = (int(sd["pos"][0]), int(sd["pos"][1]))
|
||||
sq_type = SquareType(sd["type"])
|
||||
sq = Square(
|
||||
pos=pos,
|
||||
square_type=sq_type,
|
||||
portal_side=sd.get("side"),
|
||||
teleporter_group=sd.get("group"),
|
||||
lock_id=sd.get("lock_id"),
|
||||
)
|
||||
squares[pos] = sq
|
||||
|
||||
if sq_type == SquareType.PLAYER:
|
||||
player_start = pos
|
||||
elif sq_type == SquareType.GOAL:
|
||||
goal_pos = pos
|
||||
elif sq_type == SquareType.TELEPORTER and sq.teleporter_group is not None:
|
||||
teleporter_groups.setdefault(sq.teleporter_group, []).append(pos)
|
||||
elif sq_type == SquareType.KEY and sq.lock_id is not None:
|
||||
key_map[sq.lock_id] = pos
|
||||
elif sq_type == SquareType.LOCK and sq.lock_id is not None:
|
||||
lock_map[sq.lock_id] = pos
|
||||
|
||||
if player_start is None:
|
||||
msg = "No player start position found in puzzle data"
|
||||
raise ValueError(msg)
|
||||
if goal_pos is None:
|
||||
msg = "No goal position found in puzzle data"
|
||||
raise ValueError(msg)
|
||||
|
||||
metadata = _ParseMetadata(
|
||||
player_start, goal_pos, teleporter_groups, key_map, lock_map
|
||||
)
|
||||
return squares, metadata
|
||||
|
||||
|
||||
def _pair_teleporters(
|
||||
groups: dict[int, list[tuple[int, int]]],
|
||||
) -> dict[tuple[int, int], tuple[int, int]]:
|
||||
"""Pair up teleporter squares by group id."""
|
||||
pairs: dict[tuple[int, int], tuple[int, int]] = {}
|
||||
expected_pair_size = 2
|
||||
for gid, positions in groups.items():
|
||||
if len(positions) != expected_pair_size:
|
||||
msg = f"Teleporter group {gid} has {len(positions)} members (need 2)"
|
||||
raise ValueError(msg)
|
||||
pairs[positions[0]] = positions[1]
|
||||
pairs[positions[1]] = positions[0]
|
||||
return pairs
|
||||
|
||||
|
||||
def _map_keys_to_locks(
|
||||
key_map: dict[int, tuple[int, int]],
|
||||
lock_map: dict[int, tuple[int, int]],
|
||||
) -> dict[tuple[int, int], tuple[int, int]]:
|
||||
"""Map each key position to its corresponding lock position."""
|
||||
key_to_lock: dict[tuple[int, int], tuple[int, int]] = {}
|
||||
for lid, kpos in key_map.items():
|
||||
if lid not in lock_map:
|
||||
msg = f"Key with lock_id={lid} has no matching lock"
|
||||
raise ValueError(msg)
|
||||
key_to_lock[kpos] = lock_map[lid]
|
||||
return key_to_lock
|
||||
|
||||
|
||||
@dataclass
|
||||
class Puzzle:
|
||||
"""Full puzzle definition with squares, teleporters, and key-lock pairs."""
|
||||
|
||||
squares: dict[tuple[int, int], Square]
|
||||
player_start: tuple[int, int]
|
||||
goal_pos: tuple[int, int]
|
||||
teleporter_pairs: dict[tuple[int, int], tuple[int, int]]
|
||||
key_to_lock: dict[tuple[int, int], tuple[int, int]]
|
||||
grid_bounds: tuple[int, int, int, int] # min_r, max_r, min_c, max_c
|
||||
|
||||
# ── JSON round-trip ──────────────────────────────────────────────
|
||||
@classmethod
|
||||
def from_json(cls, data: dict[str, Any]) -> Puzzle:
|
||||
"""Build a Puzzle from a parsed JSON dict."""
|
||||
squares, metadata = _parse_square_list(data["squares"])
|
||||
teleporter_pairs = _pair_teleporters(metadata.teleporter_groups)
|
||||
key_to_lock = _map_keys_to_locks(metadata.key_map, metadata.lock_map)
|
||||
|
||||
all_pos = list(squares)
|
||||
rows = [p[0] for p in all_pos]
|
||||
cols = [p[1] for p in all_pos]
|
||||
bounds = (min(rows) - 1, max(rows) + 1, min(cols) - 1, max(cols) + 1)
|
||||
|
||||
return cls(
|
||||
squares,
|
||||
metadata.player_start,
|
||||
metadata.goal_pos,
|
||||
teleporter_pairs,
|
||||
key_to_lock,
|
||||
bounds,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str) -> Puzzle:
|
||||
"""Load a Puzzle from a JSON file path."""
|
||||
with Path(path).open() as f:
|
||||
return cls.from_json(json.load(f))
|
||||
|
||||
|
||||
# ── Solver ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def solve(puzzle: Puzzle) -> list[str] | None:
|
||||
"""BFS over (position, active_locks) states. Returns move list or None."""
|
||||
initial_locks = frozenset(
|
||||
sq.pos for sq in puzzle.squares.values() if sq.square_type == SquareType.LOCK
|
||||
)
|
||||
start = State(puzzle.player_start, initial_locks)
|
||||
|
||||
queue: deque[tuple[State, list[str]]] = deque([(start, [])])
|
||||
visited: set[State] = {start}
|
||||
|
||||
while queue:
|
||||
state, path = queue.popleft()
|
||||
|
||||
for dir_name, (dr, dc) in DIRECTIONS.items():
|
||||
result = _simulate_move(puzzle, state, dr, dc)
|
||||
if result is None:
|
||||
continue
|
||||
|
||||
new_state, reached_goal = result
|
||||
if reached_goal:
|
||||
return [*path, dir_name]
|
||||
if new_state not in visited:
|
||||
visited.add(new_state)
|
||||
queue.append((new_state, [*path, dir_name]))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _simulate_move(
|
||||
puzzle: Puzzle,
|
||||
state: State,
|
||||
dr: int,
|
||||
dc: int,
|
||||
) -> tuple[State, bool] | None:
|
||||
"""Slide in (dr, dc). Returns (new_state, is_goal) or None on death."""
|
||||
r, c = state.pos
|
||||
min_r, max_r, min_c, max_c = puzzle.grid_bounds
|
||||
approach_side = DIR_TO_APPROACH_SIDE[(dr, dc)]
|
||||
|
||||
cr, cc = r + dr, c + dc
|
||||
while min_r <= cr <= max_r and min_c <= cc <= max_c:
|
||||
pos = (cr, cc)
|
||||
|
||||
if pos in puzzle.squares:
|
||||
sq = puzzle.squares[pos]
|
||||
|
||||
# Vanished lock - slide through
|
||||
if sq.square_type == SquareType.LOCK and pos not in state.active_locks:
|
||||
cr += dr
|
||||
cc += dc
|
||||
continue
|
||||
|
||||
# Portal pass-through when approached from marked side
|
||||
if sq.square_type == SquareType.PORTAL and sq.portal_side == approach_side:
|
||||
cr += dr
|
||||
cc += dc
|
||||
continue
|
||||
|
||||
# ── Landing ──
|
||||
if sq.square_type == SquareType.GOAL:
|
||||
return State(pos, state.active_locks), True
|
||||
|
||||
if (
|
||||
sq.square_type == SquareType.TELEPORTER
|
||||
and pos in puzzle.teleporter_pairs
|
||||
):
|
||||
return State(puzzle.teleporter_pairs[pos], state.active_locks), False
|
||||
|
||||
if sq.square_type == SquareType.KEY and pos in puzzle.key_to_lock:
|
||||
lock_pos = puzzle.key_to_lock[pos]
|
||||
return State(pos, state.active_locks - {lock_pos}), False
|
||||
|
||||
# Default: land on square
|
||||
return State(pos, state.active_locks), False
|
||||
|
||||
cr += dr
|
||||
cc += dc
|
||||
|
||||
return None # off-grid → death
|
||||
|
||||
|
||||
# ── Pretty-print ─────────────────────────────────────────────────────
|
||||
|
||||
_TYPE_CHAR = {
|
||||
SquareType.NORMAL: ".",
|
||||
SquareType.PLAYER: "P",
|
||||
SquareType.GOAL: "G",
|
||||
SquareType.PORTAL: "O",
|
||||
SquareType.TELEPORTER: "T",
|
||||
SquareType.KEY: "K",
|
||||
SquareType.LOCK: "L",
|
||||
}
|
||||
|
||||
|
||||
def print_puzzle(puzzle: Puzzle) -> None:
|
||||
"""Print an ASCII representation of the puzzle grid."""
|
||||
min_r, max_r, min_c, max_c = puzzle.grid_bounds
|
||||
for r in range(min_r + 1, max_r):
|
||||
row_chars: list[str] = []
|
||||
for c in range(min_c + 1, max_c):
|
||||
if (r, c) in puzzle.squares:
|
||||
sq = puzzle.squares[(r, c)]
|
||||
ch = _TYPE_CHAR.get(sq.square_type, "?")
|
||||
if sq.square_type == SquareType.PORTAL and sq.portal_side:
|
||||
arrow = {"left": "<", "right": ">", "up": "^", "down": "v"}
|
||||
ch = arrow.get(sq.portal_side, "O")
|
||||
row_chars.append(ch)
|
||||
else:
|
||||
row_chars.append(" ")
|
||||
|
||||
|
||||
def print_solution(puzzle: Puzzle, moves: list[str]) -> None:
|
||||
"""Print the solution path step by step."""
|
||||
state = State(
|
||||
puzzle.player_start,
|
||||
frozenset(
|
||||
sq.pos
|
||||
for sq in puzzle.squares.values()
|
||||
if sq.square_type == SquareType.LOCK
|
||||
),
|
||||
)
|
||||
for _i, move in enumerate(moves, 1):
|
||||
dr, dc = DIRECTIONS[move]
|
||||
result = _simulate_move(puzzle, state, dr, dc)
|
||||
if result is None:
|
||||
return
|
||||
state, goal = result
|
||||
@ -91,6 +91,8 @@ PROTECTED_APP_IDS = {
|
||||
1493710, # Proton Experimental
|
||||
1161040, # Proton BattlEye Runtime
|
||||
1007020, # Proton EasyAntiCheat Runtime
|
||||
# Games allowed to be installed anytime
|
||||
3949040, # RV There Yet?
|
||||
}
|
||||
|
||||
STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser()
|
||||
@ -1088,10 +1090,15 @@ def _try_reassign_shorter_game(
|
||||
candidates.sort(key=lambda g: g.completionist_hours)
|
||||
if not candidates or candidates[0].app_id == app_id:
|
||||
return False
|
||||
shortest = candidates[0]
|
||||
# Filter out Linux-incompatible games before deciding to reassign.
|
||||
playable = _pick_playable_candidate(
|
||||
[c for c in candidates if c.app_id != app_id],
|
||||
)
|
||||
if playable is None or playable.completionist_hours >= hours:
|
||||
return False
|
||||
_echo(
|
||||
f"\n Reassigning: {shortest.name} is shorter"
|
||||
f" (~{shortest.completionist_hours:.1f}h vs ~{hours:.1f}h)"
|
||||
f"\n Reassigning: {playable.name} is shorter"
|
||||
f" (~{playable.completionist_hours:.1f}h vs ~{hours:.1f}h)"
|
||||
)
|
||||
pick_next_game(all_games, state, config)
|
||||
return True
|
||||
|
||||
@ -62,8 +62,8 @@ class ProtonDBRating:
|
||||
- Its tier is gold but trending to silver or worse.
|
||||
- No data exists (unknown compatibility).
|
||||
"""
|
||||
if not self.tier:
|
||||
return True # No data → don't block; user can skip manually.
|
||||
if not self.tier or self.tier == "pending":
|
||||
return True # No data / pending → don't block; user can skip manually.
|
||||
tier_rank = TIER_ORDER.get(self.tier, 99)
|
||||
min_rank = TIER_ORDER[MIN_PLAYABLE_TIER]
|
||||
|
||||
@ -83,7 +83,10 @@ class ProtonDBRating:
|
||||
def _load_cache() -> dict[str, Any]:
|
||||
"""Load the on-disk ProtonDB cache."""
|
||||
if PROTONDB_CACHE_FILE.exists():
|
||||
return json.loads(PROTONDB_CACHE_FILE.read_text(encoding="utf-8")) # type: ignore[no-any-return]
|
||||
data: dict[str, Any] = json.loads(
|
||||
PROTONDB_CACHE_FILE.read_text(encoding="utf-8"),
|
||||
)
|
||||
return data
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user