diff --git a/puzzle_solver/README.md b/puzzle_solver/README.md new file mode 100644 index 0000000..cf7ebe4 --- /dev/null +++ b/puzzle_solver/README.md @@ -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. diff --git a/puzzle_solver/__init__.py b/puzzle_solver/__init__.py new file mode 100644 index 0000000..24c9833 --- /dev/null +++ b/puzzle_solver/__init__.py @@ -0,0 +1 @@ +"""Sliding-square puzzle solver package.""" diff --git a/puzzle_solver/__main__.py b/puzzle_solver/__main__.py new file mode 100644 index 0000000..41e0787 --- /dev/null +++ b/puzzle_solver/__main__.py @@ -0,0 +1,5 @@ +"""Allow ``python -m puzzle_solver …`` invocation.""" + +from puzzle_solver.main import main + +main() diff --git a/puzzle_solver/main.py b/puzzle_solver/main.py new file mode 100644 index 0000000..1ed011b --- /dev/null +++ b/puzzle_solver/main.py @@ -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() diff --git a/puzzle_solver/parse_image.py b/puzzle_solver/parse_image.py new file mode 100644 index 0000000..f66bc69 --- /dev/null +++ b/puzzle_solver/parse_image.py @@ -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) diff --git a/puzzle_solver/solver.py b/puzzle_solver/solver.py new file mode 100644 index 0000000..aa2c57c --- /dev/null +++ b/puzzle_solver/solver.py @@ -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 diff --git a/python_pkg/steam_backlog_enforcer/main.py b/python_pkg/steam_backlog_enforcer/main.py index 33a7f7d..5093a4b 100644 --- a/python_pkg/steam_backlog_enforcer/main.py +++ b/python_pkg/steam_backlog_enforcer/main.py @@ -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 diff --git a/python_pkg/steam_backlog_enforcer/protondb.py b/python_pkg/steam_backlog_enforcer/protondb.py index d9ebbe6..52f0c19 100644 --- a/python_pkg/steam_backlog_enforcer/protondb.py +++ b/python_pkg/steam_backlog_enforcer/protondb.py @@ -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 {}