mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 17:23:05 +02:00
- Move puzzle_solver/, poker_modifier_app/, articles/, tests/ into python_pkg/ - Move moviepy_showcase.py and _moviepy_*.py into python_pkg/moviepy_showcase/ - Update all imports to use python_pkg. prefix - Update pyproject.toml per-file-ignores and pytest testpaths - Add pre-commit hook to enforce Python files under python_pkg/
331 lines
11 KiB
Python
331 lines
11 KiB
Python
"""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
|