testsAndMisc/python_pkg/puzzle_solver/solver.py

331 lines
11 KiB
Python
Raw Normal View History

2026-03-16 19:49:52 +01:00
"""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